Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 6a298b8f70 fix: always use V2 lazy diff RPCs in new clients
Back-compat is old clients talking to new daemons, not the other way
around. New clients should always use the V2 lazy diff path — there's
no need to fall back to V1 based on a feature flag.

- Remove useLazyDiff feature gate from git-diff-pane
- Always use useCheckoutDiffMetadataQuery (V2)
- Remove useCheckoutDiffQuery (V1) usage from the component
- Remove supportsLazyDiffRpcs utility (no longer needed client-side)
- Server still advertises the flag and serves V1 for old clients
2026-04-14 09:36:38 +07:00
Mohamed Boudra bf1d5d2ab2 feat: lazy-load diff RPCs for mobile performance
Opening a workspace with a large diff blocked the JS thread because the
full structured diff (hunks, lines, syntax tokens) was sent over the
wire and parsed via JSON.parse + Zod safeParse on the main thread.

Add namespaced checkout RPCs that split diff delivery into two phases:

1. **Metadata-only snapshots** (`checkout/subscribe_diff` →
   `checkout/diff_snapshot`) — lightweight file list with status, path,
   insertions/deletions, and a fingerprint. Sent on every file-system
   change. No hunks, no tokens.

2. **On-demand hunk fetching** (`checkout/get_file_hunks` →
   `checkout/file_hunks_response`) — full structured diff for a single
   file, fetched when the user expands it. Served from an in-memory
   cache with a single-file git fallback.

Feature detection via `server_info.features.lazyDiffRpcs`. New clients
fall back to the V1 full-diff path when talking to older daemons. Old
clients are completely unaffected — all new schema fields are optional
and new message types are additive.

Per-file fingerprints (hash of hunks excluding tokens) drive React Query
cache invalidation so expanded files auto-refresh only when their actual
content changes.
2026-04-14 08:59:36 +07:00
76 changed files with 2874 additions and 3171 deletions
-22
View File
@@ -1,27 +1,5 @@
# Changelog
## 0.1.55 - 2026-04-14
### Added
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
- Choose provider and model when creating scheduled agents.
- Max reasoning effort option for Opus 4.6 models.
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
### Improved
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
- iPad and tablet layouts work correctly across all screen sizes.
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
### Fixed
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
- Workspace navigation updates correctly when switching between workspaces in the same project.
- Desktop workspace header alignment and model selector no longer overflow on narrow windows.
- Loading indicators are visible in light mode.
## 0.1.54 - 2026-04-12
### Added
+19
View File
@@ -0,0 +1,19 @@
# CI Test Status
Tracking progress toward all-green CI.
## CI Jobs
| Job | Status | Notes |
|-----|--------|-------|
| format | unknown | `npx biome format .` |
| typecheck | unknown | `npm run typecheck` |
| server-tests | unknown | unit + integration (vitest) |
| app-tests | unknown | unit tests (vitest) |
| playwright | unknown | E2E tests (playwright) |
| relay-tests | unknown | unit tests (vitest) |
| cli-tests | unknown | local tests |
## Log
- 2026-04-10: Branch created, automated agents begin iterating
-1
View File
@@ -24,7 +24,6 @@ This is an npm workspace monorepo:
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
-522
View File
@@ -1,522 +0,0 @@
# Custom Provider Configuration
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
All provider configuration lives under `agents.providers` in config.json:
```json
{
"version": 1,
"agents": {
"providers": {
"provider-id": { ... }
}
}
}
```
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
---
## Table of Contents
- [Extending a built-in provider](#extending-a-built-in-provider)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
- [Custom binary for a provider](#custom-binary-for-a-provider)
- [Disabling a provider](#disabling-a-provider)
- [ACP providers](#acp-providers)
- [Provider override reference](#provider-override-reference)
---
## Extending a built-in provider
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
```json
{
"agents": {
"providers": {
"my-claude": {
"extends": "claude",
"label": "My Claude",
"description": "Claude with custom API endpoint",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
}
}
}
}
}
```
Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
---
## Z.AI (Zhipu) coding plan
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
### Setup
1. Register at [z.ai](https://z.ai) and subscribe to a coding plan
2. Create an API key from the Z.AI dashboard
3. Add a provider entry in config.json:
```json
{
"agents": {
"providers": {
"zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
}
}
}
}
```
### Available models
| Model | Tier |
|---|---|
| `glm-5.1` | Advanced (flagship) |
| `glm-5-turbo` | Advanced |
| `glm-4.7` | Standard |
| `glm-4.5-air` | Lightweight |
### Notes
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
- Automated setup is also available: `npx @z_ai/coding-helper`
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
---
## Alibaba Cloud (Qwen) coding plan
[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models.
### Setup
1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region)
2. Subscribe to the Pro plan ($50/month)
3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key
4. Add a provider entry in config.json:
```json
{
"agents": {
"providers": {
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
{ "id": "kimi-k2.5", "label": "Kimi K2.5" }
]
}
}
}
}
```
### API endpoints
| Mode | Base URL |
|---|---|
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
### Available models
**Recommended for coding plan:**
| Model | Notes |
|---|---|
| `qwen3.5-plus` | Vision capable, recommended |
| `qwen3-coder-next` | Optimized for coding |
| `kimi-k2.5` | Vision capable |
| `glm-5` | Zhipu GLM |
| `MiniMax-M2.5` | MiniMax |
**Additional models (pay-as-you-go):**
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
### Notes
- API keys must be created in the **Singapore region**
- The coding plan is for personal use only in interactive coding tools
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
---
## Multiple profiles for the same provider
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
Example: two different Anthropic accounts as separate profiles:
```json
{
"agents": {
"providers": {
"claude-work": {
"extends": "claude",
"label": "Claude (Work)",
"description": "Work Anthropic account",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-work-..."
}
},
"claude-personal": {
"extends": "claude",
"label": "Claude (Personal)",
"description": "Personal Anthropic account",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-personal-..."
}
}
}
}
}
```
Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent.
You can also combine profiles with model overrides to pin specific models per profile:
```json
{
"agents": {
"providers": {
"claude-fast": {
"extends": "claude",
"label": "Claude (Fast)",
"models": [
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }
]
},
"claude-smart": {
"extends": "claude",
"label": "Claude (Smart)",
"models": [
{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }
]
}
}
}
}
```
---
## Custom binary for a provider
Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments.
### Override a built-in provider's binary
```json
{
"agents": {
"providers": {
"claude": {
"command": ["/opt/claude-nightly/claude"]
}
}
}
}
```
### Use a custom wrapper script
```json
{
"agents": {
"providers": {
"claude": {
"command": ["/usr/local/bin/my-claude-wrapper", "--verbose"]
}
}
}
}
```
### Custom binary on a derived provider
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "Codex (Custom Build)",
"command": ["/home/user/codex-dev/target/release/codex"]
}
}
}
}
```
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
---
## Disabling a provider
Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI.
```json
{
"agents": {
"providers": {
"copilot": { "enabled": false },
"codex": { "enabled": false }
}
}
}
```
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
---
## ACP providers
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider.
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
### Adding a generic ACP provider
Set `extends: "acp"` and provide a `command`:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent-binary", "--acp"],
"env": {
"MY_API_KEY": "..."
}
}
}
}
}
```
Required fields for ACP providers:
- `extends: "acp"`
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
### Example: Google Gemini CLI
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
2. Authenticate with Google (Gemini CLI handles its own auth)
3. Add to config.json:
```json
{
"agents": {
"providers": {
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
}
}
}
}
```
Ref: [Gemini CLI ACP mode docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md)
### Example: Hermes (Nous Research)
[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand.
1. Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`
2. Install ACP support: `pip install -e '.[acp]'`
3. Configure Hermes credentials in `~/.hermes/`
4. Add to config.json:
```json
{
"agents": {
"providers": {
"hermes": {
"extends": "acp",
"label": "Hermes",
"description": "Nous Research self-improving AI agent",
"command": ["hermes", "acp"]
}
}
}
}
```
Ref: [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp)
### How ACP providers work in Paseo
When you launch an agent with an ACP provider:
1. Paseo spawns the process using the configured `command`
2. Sends an `initialize` JSON-RPC request over stdin
3. The agent responds with its capabilities, available modes, and models
4. Paseo creates a session and sends prompts through the ACP protocol
5. The agent streams responses, tool calls, and permission requests back over stdout
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"models": [
{ "id": "fast-model", "label": "Fast", "isDefault": true },
{ "id": "smart-model", "label": "Smart" }
]
}
}
}
}
```
Profile models (defined in config.json) completely replace runtime-discovered models when present.
---
## Provider override reference
Every entry under `agents.providers` accepts these fields:
| Field | Type | Required | Description |
|---|---|---|---|
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
### Model definition
Each entry in the `models` array:
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Model identifier sent to the provider |
| `label` | `string` | Yes | Display name in the UI |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default model selection |
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
### Thinking option
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Thinking option identifier |
| `label` | `string` | Yes | Display name |
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Valid `extends` values
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
Special value: `acp` — creates a generic ACP provider (requires `command`)
### Full example
A config.json with multiple custom providers:
```json
{
"version": 1,
"agents": {
"providers": {
"copilot": { "enabled": false },
"zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<zai-api-key>",
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"models": [
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
},
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
]
},
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
},
"hermes": {
"extends": "acp",
"label": "Hermes",
"command": ["hermes", "acp"]
}
}
}
}
```
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-1mnfVYAAGP6x9ywFHEQ55q4i/C9uuZ2foU1ytacRMrE=";
npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34906,16 +34906,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.56",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
"@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/server": "0.1.55-rc.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35056,11 +35056,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/relay": "0.1.55-rc.1",
"@getpaseo/server": "0.1.55-rc.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35101,11 +35101,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/cli": "0.1.55-rc.1",
"@getpaseo/server": "0.1.55-rc.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35139,7 +35139,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35340,7 +35340,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35366,7 +35366,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35382,14 +35382,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/relay": "0.1.56",
"@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/relay": "0.1.55-rc.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35817,7 +35817,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -14,7 +14,6 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
@@ -37,7 +36,6 @@
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
+38 -50
View File
@@ -1,4 +1,4 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -224,51 +224,6 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
return offer as OfferPayload;
}
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
const stdout = execFileSync(
process.execPath,
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
{
cwd: repoRoot,
env: {
...process.env,
PASEO_HOME: paseoHomePath,
},
encoding: "utf8",
},
);
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
throw new Error(`Unexpected daemon pair response: ${stdout}`);
}
return decodeOfferFromFragmentUrl(payload.url);
}
async function waitForPairingOfferFromCli(args: {
repoRoot: string;
paseoHome: string;
timeoutMs?: number;
}): Promise<OfferPayload> {
const timeoutMs = args.timeoutMs ?? 15000;
const start = Date.now();
let lastError: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
} catch (error) {
lastError = error;
await sleep(100);
}
}
throw new Error(
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
);
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
@@ -478,6 +433,12 @@ export default async function globalSetup() {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null;
const offerPromise = new Promise<void>((resolve) => {
offerResolve = resolve;
});
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
@@ -512,6 +473,26 @@ export default async function globalSetup() {
const trimmed = line.trim();
if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
if (!offerPayload) {
const clean = stripAnsi(trimmed);
try {
const obj = JSON.parse(clean) as { msg?: string; url?: string };
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
offerPayload = decodeOfferFromFragmentUrl(obj.url);
offerResolve?.();
}
} catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes("pairing_offer")) {
try {
offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.();
} catch {
// ignore parsing failures
}
}
}
}
console.log(`[daemon] ${trimmed}`);
}
});
@@ -542,10 +523,17 @@ export default async function globalSetup() {
}),
]);
const offer = await waitForPairingOfferFromCli({
repoRoot,
paseoHome,
});
// Wait for daemon to emit a pairing offer (includes relay session ID).
await Promise.race([
offerPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
),
]);
if (!offerPayload) {
throw new Error("pairing_offer was not parsed from daemon logs");
}
const offer = offerPayload as OfferPayload;
process.env.E2E_DAEMON_PORT = String(port);
process.env.E2E_RELAY_PORT = String(relayPort);
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.56",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
"@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/server": "0.1.55-rc.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -1225,8 +1225,6 @@ const styles = StyleSheet.create((theme) => ({
},
prefsButton: {
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
@@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
@@ -25,7 +24,6 @@ export function BranchSwitcher({
isGitCheckout,
}: BranchSwitcherProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -67,7 +65,7 @@ export function BranchSwitcher({
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={branchOptions}
@@ -113,10 +111,6 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginLeft: {
xs: -theme.spacing[2],
md: 0,
},
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
@@ -634,9 +634,7 @@ export function CombinedModelSelector({
) : (
<>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel}
</Text>
<Text style={styles.triggerText}>{triggerLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</>
)}
@@ -706,8 +704,6 @@ export function CombinedModelSelector({
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
@@ -725,8 +721,6 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.5,
},
triggerText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
+132 -7
View File
@@ -32,11 +32,15 @@ import {
} from "lucide-react-native";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import {
useCheckoutDiffQuery,
type ParsedDiffFile,
type DiffLine,
type HighlightToken,
} from "@/hooks/use-checkout-diff-query";
import {
useCheckoutDiffMetadataQuery,
type DiffFileMetadata,
} from "@/hooks/use-checkout-diff-metadata-query";
import { useFileHunks } from "@/hooks/use-file-hunks";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -130,8 +134,16 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
);
}
interface DiffFileHeaderFields {
path: string;
isNew: boolean;
isDeleted: boolean;
additions: number;
deletions: number;
}
interface DiffFileSectionProps {
file: ParsedDiffFile;
file: DiffFileHeaderFields;
isExpanded: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
@@ -617,6 +629,109 @@ function DiffFileBody({
);
}
interface LazyDiffFileBodyProps {
file: DiffFileMetadata;
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
layout: "unified" | "split";
wrapLines: boolean;
onBodyHeightChange?: (path: string, height: number) => void;
testID?: string;
}
function LazyDiffFileBody({
file,
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
layout,
wrapLines,
onBodyHeightChange,
testID,
}: LazyDiffFileBodyProps) {
const {
file: resolvedFile,
isLoading,
isError,
error,
} = useFileHunks({
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
file,
enabled: true,
});
if (file.status === "binary" || file.status === "too_large") {
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
</View>
);
}
if (isLoading) {
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<View style={styles.statusMessageContainer}>
<ActivityIndicator size="small" />
</View>
</View>
);
}
if (isError || !resolvedFile) {
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{error instanceof Error ? error.message : "Failed to load diff"}
</Text>
</View>
</View>
);
}
return (
<DiffFileBody
file={resolvedFile}
layout={layout}
wrapLines={wrapLines}
onBodyHeightChange={onBodyHeightChange}
testID={testID}
/>
);
}
interface GitDiffPaneProps {
serverId: string;
workspaceId?: string | null;
@@ -625,8 +740,8 @@ interface GitDiffPaneProps {
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
| { type: "header"; file: DiffFileHeaderFields; fileIndex: number; isExpanded: boolean }
| { type: "lazy-body"; file: DiffFileMetadata; fileIndex: number };
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
@@ -687,7 +802,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
isError: isDiffError,
error: diffError,
refresh: refreshDiff,
} = useCheckoutDiffQuery({
} = useCheckoutDiffMetadataQuery({
serverId,
cwd,
mode: diffMode,
@@ -788,7 +903,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
stickyIndices.push(items.length - 1);
}
if (isExpanded) {
items.push({ type: "body", file, fileIndex: i });
items.push({ type: "lazy-body", file: file as DiffFileMetadata, fileIndex: i });
}
}
return { flatItems: items, stickyHeaderIndices: stickyIndices };
@@ -1068,8 +1183,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
);
}
return (
<DiffFileBody
<LazyDiffFileBody
file={item.file}
serverId={serverId}
cwd={cwd}
mode={diffMode}
baseRef={baseRef}
ignoreWhitespace={changesPreferences.hideWhitespace}
layout={effectiveLayout}
wrapLines={wrapLines}
onBodyHeightChange={handleBodyHeightChange}
@@ -1078,10 +1198,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
);
},
[
baseRef,
changesPreferences.hideWhitespace,
cwd,
diffMode,
effectiveLayout,
handleBodyHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
serverId,
wrapLines,
],
);
@@ -1290,15 +1290,11 @@ const styles = StyleSheet.create(((theme: any) => ({
justifyContent: "space-between",
},
leftButtonGroup: {
minWidth: 0,
flexShrink: 1,
flexGrow: 1,
flexDirection: "row",
alignItems: "flex-end",
gap: theme.spacing[1],
},
rightButtonGroup: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
@@ -236,14 +236,7 @@ function WorkspaceStatusIndicator({
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
</View>
);
}
@@ -347,14 +340,7 @@ function ProjectLeadingVisual({
if (shouldShowSyncedLoader) {
return (
<View style={styles.projectLeadingVisualSlot}>
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
</View>
);
}
@@ -808,12 +794,7 @@ function ProjectHeaderRow({
<NewWorktreeButton
displayName={displayName}
onPress={() => createWorktreeMutation.mutate()}
visible={
isHovered ||
platformIsNative ||
isMobileBreakpoint ||
createWorktreeMutation.isPending
}
visible={isHovered || platformIsNative || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
@@ -16,7 +16,7 @@ const DOT_COUNT = DOT_SEQUENCE.length;
const GRID_ROWS = 3;
const GRID_COLUMNS = 2;
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
const SNAKE_OPACITIES = [1, 0.72, 0.46, 0.22, 0] as const;
const sharedStepProgress = makeMutable(0);
let sharedLoopStarted = false;
@@ -1156,11 +1156,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
removeWorkspace(serverId, message.payload.id);
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
const existingWorkspace = useSessionStore
.getState()
.sessions[serverId]?.workspaces.get(workspace.id);
mergeWorkspaces(serverId, [workspace]);
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
});
const unsubStatus = client.on("status", (message) => {
@@ -1,6 +1,6 @@
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number];
interface DiffFileWithPath {
path: string;
}
export function compareCheckoutDiffPaths(left: string, right: string): number {
if (left === right) {
@@ -9,7 +9,7 @@ export function compareCheckoutDiffPaths(left: string, right: string): number {
return left < right ? -1 : 1;
}
export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] {
export function orderCheckoutDiffFiles<T extends DiffFileWithPath>(files: T[]): T[] {
if (files.length < 2) {
return files;
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
function createMetadata(path: string, fingerprint = "fp1"): DiffFileMetadata {
return {
path,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
fingerprint,
};
}
describe("useCheckoutDiffMetadataQuery — cache replacement behavior", () => {
it("orderCheckoutDiffFiles works with DiffFileMetadata arrays", () => {
const files: DiffFileMetadata[] = [
createMetadata("zeta.ts"),
createMetadata("alpha.ts"),
createMetadata("beta.ts"),
];
const ordered = orderCheckoutDiffFiles(files);
expect(ordered.map((f) => f.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
});
it("replaces full list on snapshot — does not merge with previous", () => {
const initial: DiffFileMetadata[] = [
createMetadata("a.ts", "fp1"),
createMetadata("b.ts", "fp2"),
createMetadata("c.ts", "fp3"),
];
// Simulate a new snapshot that removes b.ts and changes c.ts fingerprint
const updated: DiffFileMetadata[] = [
createMetadata("a.ts", "fp1"),
createMetadata("c.ts", "fp4"),
];
// Full replacement: the result is exactly the updated list, not a merge
const result = orderCheckoutDiffFiles(updated);
expect(result).toHaveLength(2);
expect(result.map((f) => f.path)).toEqual(["a.ts", "c.ts"]);
expect(result.find((f) => f.path === "c.ts")?.fingerprint).toBe("fp4");
// b.ts is gone — no remnant from initial
expect(result.find((f) => f.path === "b.ts")).toBeUndefined();
// Initial is unchanged (no mutation)
expect(initial).toHaveLength(3);
});
it("preserves fingerprint and status on metadata items", () => {
const file: DiffFileMetadata = {
path: "large.bin",
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
status: "binary",
fingerprint: "abc123",
};
const ordered = orderCheckoutDiffFiles([file]);
expect(ordered[0].fingerprint).toBe("abc123");
expect(ordered[0].status).toBe("binary");
});
});
@@ -0,0 +1,214 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useId, useMemo } from "react";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutDiffSnapshot } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
const CHECKOUT_DIFF_METADATA_STALE_TIME = 30_000;
export type DiffFileMetadata = CheckoutDiffSnapshot["payload"]["files"][number];
type CheckoutDiffMetadataPayload = Omit<CheckoutDiffSnapshot["payload"], "subscriptionId">;
function checkoutDiffMetadataQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
return [
"checkoutDiffMetadata",
serverId,
cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
] as const;
}
interface UseCheckoutDiffMetadataQueryOptions {
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
enabled?: boolean;
}
function normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
}
export function useCheckoutDiffMetadataQuery({
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
enabled = true,
}: UseCheckoutDiffMetadataQueryOptions) {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab);
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const hookInstanceId = useId();
const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
[mode, baseRef, ignoreWhitespace],
);
const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef;
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
const queryKey = useMemo(
() => checkoutDiffMetadataQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
);
const query = useQuery({
queryKey,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
const payload = await client.getCheckoutDiffMetadata(cwd, {
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
});
return {
cwd: payload.cwd,
files: orderCheckoutDiffFiles(payload.files),
error: payload.error,
requestId: payload.requestId,
};
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: CHECKOUT_DIFF_METADATA_STALE_TIME,
});
useEffect(() => {
if (!client || !isConnected || !cwd || !enabled) {
return;
}
if (!isOpen || explorerTab !== "changes") {
return;
}
const subscriptionId = [
"checkoutDiffMetadata",
hookInstanceId,
serverId,
cwd,
compareMode,
compareBaseRef ?? "",
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
].join(":");
let cancelled = false;
const unsubscribeSnapshot = client.on("checkout/diff_snapshot", (message) => {
if (message.type !== "checkout/diff_snapshot") {
return;
}
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
cwd: message.payload.cwd,
files: orderCheckoutDiffFiles(message.payload.files),
error: message.payload.error,
requestId: message.payload.requestId,
});
});
void client
.subscribeCheckoutDiffLazy(
cwd,
{
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
},
{ subscriptionId },
)
.then((payload) => {
if (cancelled) {
return;
}
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
cwd: payload.cwd,
files: orderCheckoutDiffFiles(payload.files),
error: payload.error,
requestId: payload.requestId,
});
})
.catch((error) => {
if (cancelled) {
return;
}
console.error("[useCheckoutDiffMetadataQuery] subscribeCheckoutDiffLazy failed", {
serverId,
cwd,
error,
});
});
return () => {
cancelled = true;
unsubscribeSnapshot();
try {
client.unsubscribeCheckoutDiffLazy(subscriptionId);
} catch {
// Ignore disconnect race during effect cleanup.
}
};
}, [
client,
isConnected,
cwd,
enabled,
isOpen,
explorerTab,
hookInstanceId,
serverId,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
queryKey,
queryClient,
]);
const refresh = useCallback(() => {
return query.refetch();
}, [query]);
const payload = query.data ?? null;
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
payloadError,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError || Boolean(payloadError),
error: query.error,
refresh,
};
}
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
/**
* Tests for use-file-hunks hook logic.
*
* The hook itself is a thin React Query wrapper, so we test the key properties:
* 1. Query key includes fingerprint — fingerprint change = automatic refetch
* 2. Metadata fields are passed through to the merged result
*/
function fileHunksQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef: string | undefined,
ignoreWhitespace: boolean | undefined,
path: string,
fingerprint: string,
) {
return [
"checkoutFileHunks",
serverId,
cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
path,
fingerprint,
] as const;
}
describe("use-file-hunks query key", () => {
it("includes fingerprint in query key", () => {
const key = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-abc");
expect(key).toContain("fp-abc");
expect(key).toContain("foo.ts");
});
it("different fingerprints produce different query keys", () => {
const key1 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-1");
const key2 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-2");
expect(key1).not.toEqual(key2);
// Only the fingerprint element differs
expect(key1.slice(0, -1)).toEqual(key2.slice(0, -1));
});
it("same fingerprint + same params produces identical key", () => {
const key1 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
const key2 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
expect(key1).toEqual(key2);
});
it("normalizes missing baseRef to empty string", () => {
const key = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, false, "a.ts", "fp");
expect(key[4]).toBe("");
});
it("normalizes ignoreWhitespace to boolean", () => {
const key = fileHunksQueryKey("s1", "/repo", "base", "main", undefined, "a.ts", "fp");
expect(key[5]).toBe(false);
});
});
describe("use-file-hunks metadata merge", () => {
it("metadata fields carry through to merged ParsedDiffFile shape", () => {
const meta: DiffFileMetadata = {
path: "src/index.ts",
isNew: true,
isDeleted: false,
additions: 10,
deletions: 2,
status: "ok",
fingerprint: "abc",
};
// Simulates merging metadata with fetched hunks
const merged = {
path: meta.path,
isNew: meta.isNew,
isDeleted: meta.isDeleted,
additions: meta.additions,
deletions: meta.deletions,
status: meta.status,
hunks: [],
};
expect(merged.path).toBe("src/index.ts");
expect(merged.isNew).toBe(true);
expect(merged.additions).toBe(10);
expect(merged.hunks).toEqual([]);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { DiffFileMetadata } from "@/hooks/use-checkout-diff-metadata-query";
function fileHunksQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef: string | undefined,
ignoreWhitespace: boolean | undefined,
path: string,
fingerprint: string,
) {
return [
"checkoutFileHunks",
serverId,
cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
path,
fingerprint,
] as const;
}
interface UseFileHunksOptions {
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
file: DiffFileMetadata;
enabled?: boolean;
}
export function useFileHunks({
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
file,
enabled = true,
}: UseFileHunksOptions) {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const queryKey = useMemo(
() =>
fileHunksQueryKey(
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
file.path,
file.fingerprint,
),
[serverId, cwd, mode, baseRef, ignoreWhitespace, file.path, file.fingerprint],
);
const query = useQuery({
queryKey,
queryFn: async (): Promise<ParsedDiffFile> => {
if (!client) {
throw new Error("Daemon client not available");
}
const payload = await client.getCheckoutFileHunks(
cwd,
{ mode, baseRef, ignoreWhitespace },
file.path,
);
if (payload.error) {
throw new Error(payload.error.message);
}
return {
path: file.path,
isNew: file.isNew,
isDeleted: file.isDeleted,
additions: file.additions,
deletions: file.deletions,
status: file.status,
hunks: payload.hunks,
};
},
enabled: !!client && isConnected && enabled,
staleTime: 60_000,
});
return {
file: query.data ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
};
}
@@ -284,12 +284,6 @@ export function useSidebarWorkspacesList(options?: {
});
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
useEffect(() => {
if (!serverId) {
return;
}
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
useEffect(() => {
if (!serverId || projects.length === 0) {
return;
@@ -355,12 +349,7 @@ export function useSidebarWorkspacesList(options?: {
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
cursor,
error,
});
} catch {
// ignore explicit refresh failures; hook keeps existing data
}
})();
@@ -141,18 +141,6 @@ function decodeSegment(value: string): string {
}
}
function areHeaderLabelsEquivalent(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
if (!normalizedA || !normalizedB) {
return false;
}
return normalizedA === normalizedB;
}
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return "New Agent";
@@ -759,12 +747,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
: null;
const isWorkspaceHeaderLoading = workspaceHeader === null;
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
workspaceHeaderTitle,
workspaceHeaderSubtitle,
);
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
const currentBranchName =
@@ -1967,29 +1949,27 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
{isWorkspaceHeaderLoading ? (
<View style={styles.headerTitleTextGroup}>
<>
<View style={styles.headerTitleSkeleton} />
<View style={styles.headerProjectTitleSkeleton} />
</View>
</>
) : (
<View style={styles.headerTitleTextGroup}>
<>
<BranchSwitcher
currentBranchName={currentBranchName}
title={workspaceHeaderTitle}
title={workspaceHeader.title}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isGitCheckout={isGitCheckout}
/>
{shouldShowWorkspaceHeaderSubtitle ? (
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{workspaceHeaderSubtitle}
</Text>
) : null}
</View>
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{workspaceHeader.subtitle}
</Text>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger
@@ -2311,40 +2291,15 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
},
headerTitleContainer: {
flex: 1,
flexShrink: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerTitleTextGroup: {
minWidth: 0,
flexShrink: 1,
flexGrow: {
xs: 1,
md: 0,
},
flexDirection: {
xs: "column",
md: "row",
},
alignItems: {
xs: "flex-start",
md: "center",
},
justifyContent: "flex-start",
gap: {
xs: 0,
md: theme.spacing[2],
},
},
headerProjectTitle: {
color: theme.colors.foregroundMuted,
fontSize: {
xs: theme.fontSize.sm,
md: theme.fontSize.base,
},
fontSize: theme.fontSize.base,
flexShrink: 1,
},
headerTitleSkeleton: {
@@ -134,14 +134,7 @@ export function WorkspaceTabIcon({
if (shouldShowLoader) {
return (
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
<SyncedLoader
size={size - 1}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
<SyncedLoader size={size - 1} color={theme.colors.palette.amber[500]} />
</View>
);
}
-1
View File
@@ -83,7 +83,6 @@ export const baseColors = {
// Amber scale
amber: {
500: "#f59e0b",
700: "#b45309",
},
// Yellow scale
@@ -1,104 +0,0 @@
type WorkspaceLike = {
id: string;
projectId: string;
projectDisplayName?: string;
name: string;
status: string;
workspaceKind: string;
};
type SidebarWorkspaceLike = {
workspaceId: string;
name: string;
statusBucket: string;
workspaceKind: string;
};
type SidebarProjectLike = {
projectKey: string;
projectName: string;
statusBucket: string;
activeCount: number;
totalWorkspaces: number;
workspaces: SidebarWorkspaceLike[];
};
function countByValue(values: Iterable<string>): Record<string, number> {
const counts = new Map<string, number>();
for (const value of values) {
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return Object.fromEntries(counts);
}
export function summarizeWorkspaceCollection(
workspaces: Iterable<WorkspaceLike> | null | undefined,
): {
count: number;
projectIds: string[];
statusCounts: Record<string, number>;
workspaces: Array<{
id: string;
projectId: string;
projectDisplayName: string | null;
name: string;
status: string;
workspaceKind: string;
}>;
} {
const entries = Array.from(workspaces ?? [], (workspace) => ({
id: workspace.id,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName?.trim() || null,
name: workspace.name,
status: workspace.status,
workspaceKind: workspace.workspaceKind,
}));
return {
count: entries.length,
projectIds: [...new Set(entries.map((workspace) => workspace.projectId))],
statusCounts: countByValue(entries.map((workspace) => workspace.status)),
workspaces: entries,
};
}
export function summarizeSidebarProjects(
projects: Iterable<SidebarProjectLike> | null | undefined,
): {
count: number;
projectKeys: string[];
projects: Array<{
projectKey: string;
projectName: string;
statusBucket: string;
activeCount: number;
totalWorkspaces: number;
workspaces: Array<{
workspaceId: string;
name: string;
statusBucket: string;
workspaceKind: string;
}>;
}>;
} {
const entries = Array.from(projects ?? [], (project) => ({
projectKey: project.projectKey,
projectName: project.projectName,
statusBucket: project.statusBucket,
activeCount: project.activeCount,
totalWorkspaces: project.totalWorkspaces,
workspaces: project.workspaces.map((workspace) => ({
workspaceId: workspace.workspaceId,
name: workspace.name,
statusBucket: workspace.statusBucket,
workspaceKind: workspace.workspaceKind,
})),
}));
return {
count: entries.length,
projectKeys: entries.map((project) => project.projectKey),
projects: entries,
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/relay": "0.1.55-rc.1",
"@getpaseo/server": "0.1.55-rc.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+53 -1
View File
@@ -16,7 +16,6 @@ import { resolve } from "node:path";
import { lookup } from "mime-types";
import { parseDuration } from "../../utils/duration.js";
import { collectMultiple } from "../../utils/command-options.js";
import { resolveProviderAndModel } from "../../utils/provider-model.js";
export function addRunOptions(cmd: Command): Command {
return cmd
@@ -99,6 +98,11 @@ export interface AgentRunOptions extends CommandOptions {
outputSchema?: string;
}
interface ResolvedProviderModel {
provider: string;
model: string | undefined;
}
function toRunResult(
agent: AgentSnapshotPayload,
statusOverride?: AgentRunResult["status"],
@@ -218,6 +222,54 @@ function structuredRunSchema(output: Record<string, unknown>): OutputSchema<Agen
};
}
export function resolveProviderAndModel(
options: Pick<AgentRunOptions, "provider" | "model">,
): ResolvedProviderModel {
const providerInput = options.provider?.trim() || "claude";
const modelInput = options.model?.trim();
if (options.model !== undefined && !modelInput) {
const error: CommandError = {
code: "INVALID_MODEL",
message: "--model cannot be empty",
};
throw error;
}
const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) {
return {
provider: providerInput,
model: modelInput,
};
}
const provider = providerInput.slice(0, slashIndex).trim();
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
if (!provider || !modelFromProvider) {
const error: CommandError = {
code: "INVALID_PROVIDER",
message: "Invalid --provider value",
details: "Use --provider <provider> or --provider <provider>/<model>",
};
throw error;
}
if (modelInput && modelInput !== modelFromProvider) {
const error: CommandError = {
code: "CONFLICTING_MODEL_OPTIONS",
message: "Conflicting model values provided",
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
};
throw error;
}
return {
provider,
model: modelInput ?? modelFromProvider,
};
}
export async function runRunCommand(
prompt: string,
options: AgentRunOptions,
@@ -15,7 +15,6 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
cron?: string;
name?: string;
target?: string;
provider?: string;
maxRuns?: string;
expiresIn?: string;
}
@@ -31,7 +30,6 @@ export async function runCreateCommand(
cron: options.cron,
name: options.name,
target: options.target,
provider: options.provider,
maxRuns: options.maxRuns,
expiresIn: options.expiresIn,
});
@@ -21,10 +21,6 @@ export function createScheduleCommand(): Command {
.option("--cron <expr>", "Cron cadence expression")
.option("--name <name>", "Optional schedule name")
.option("--target <self|new-agent|agent-id>", "Run target")
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
)
.option("--max-runs <n>", "Maximum number of runs")
.option("--expires-in <duration>", "Time to live for the schedule"),
).action(withOutput(runCreateCommand));
+17 -44
View File
@@ -9,7 +9,6 @@ import type {
ScheduleTarget,
} from "./types.js";
import { parseDuration } from "../../utils/duration.js";
import { resolveProviderAndModel } from "../../utils/provider-model.js";
export interface ScheduleCommandOptions extends CommandOptions {
host?: string;
@@ -59,8 +58,7 @@ export function formatTarget(target: ScheduleTarget | ScheduleListItem["target"]
if (target.type === "agent") {
return `agent:${target.agentId.slice(0, 7)}`;
}
const modelSuffix = target.config.model ? `/${target.config.model}` : "";
return `new-agent:${target.config.provider}${modelSuffix}`;
return `new-agent:${target.config.provider}`;
}
export function formatDurationMs(durationMs: number): string {
@@ -89,7 +87,6 @@ export function parseScheduleCreateInput(options: {
cron?: string;
name?: string;
target?: string;
provider?: string;
maxRuns?: string;
expiresIn?: string;
}): CreateScheduleInput {
@@ -114,53 +111,29 @@ export function parseScheduleCreateInput(options: {
: { type: "cron", expression: options.cron!.trim() };
const targetValue = options.target?.trim();
const hasExplicitProviderSelection = options.provider !== undefined;
const resolvedProviderModel = resolveProviderAndModel({
provider: options.provider,
defaultProvider: "claude",
});
const newAgentTarget: ScheduleTarget = {
type: "new-agent",
config: {
provider: resolvedProviderModel.provider,
cwd: process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
},
};
let target: ScheduleTarget;
if (!targetValue) {
if (!targetValue || targetValue === "self") {
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
if (currentAgentId && !hasExplicitProviderSelection) {
if (currentAgentId) {
target = { type: "self", agentId: currentAgentId };
} else {
target = newAgentTarget;
target = {
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
};
}
} else if (targetValue === "self") {
if (hasExplicitProviderSelection) {
throw {
code: "INVALID_TARGET",
message: "--provider can only be used with a new-agent target",
details: "Use --target new-agent or omit --target to create a new agent schedule",
} satisfies CommandError;
}
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
if (!currentAgentId) {
throw {
code: "INVALID_TARGET",
message: "--target self requires running inside a Paseo agent",
} satisfies CommandError;
}
target = { type: "self", agentId: currentAgentId };
} else if (targetValue === "new-agent") {
target = newAgentTarget;
target = {
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
};
} else {
if (hasExplicitProviderSelection) {
throw {
code: "INVALID_TARGET",
message: "--provider can only be used with a new-agent target",
details: "Use --target new-agent or omit --target to create a new agent schedule",
} satisfies CommandError;
}
target = {
type: "agent",
agentId: targetValue,
-60
View File
@@ -1,60 +0,0 @@
import type { CommandError } from "../output/index.js";
export interface ResolveProviderAndModelOptions {
provider?: string;
model?: string;
defaultProvider?: string;
}
export interface ResolvedProviderModel {
provider: string;
model: string | undefined;
}
export function resolveProviderAndModel(
options: ResolveProviderAndModelOptions,
): ResolvedProviderModel {
const providerInput = options.provider?.trim() || options.defaultProvider || "claude";
const modelInput = options.model?.trim();
if (options.model !== undefined && !modelInput) {
const error: CommandError = {
code: "INVALID_MODEL",
message: "--model cannot be empty",
};
throw error;
}
const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) {
return {
provider: providerInput,
model: modelInput,
};
}
const provider = providerInput.slice(0, slashIndex).trim();
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
if (!provider || !modelFromProvider) {
const error: CommandError = {
code: "INVALID_PROVIDER",
message: "Invalid --provider value",
details: "Use --provider <provider> or --provider <provider>/<model>",
};
throw error;
}
if (modelInput && modelInput !== modelFromProvider) {
const error: CommandError = {
code: "CONFLICTING_MODEL_OPTIONS",
message: "Conflicting model values provided",
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
};
throw error;
}
return {
provider,
model: modelInput ?? modelFromProvider,
};
}
@@ -2,10 +2,10 @@
import assert from "node:assert";
import {
resolveProviderAndModel,
resolveStructuredResponseMessage,
type StructuredResponseTimelineClient,
} from "../src/commands/agent/run.ts";
import { resolveProviderAndModel } from "../src/utils/provider-model.ts";
type TimelineEntry = {
item: {
@@ -23,11 +23,6 @@ try {
const createdJson = JSON.parse(created.stdout);
assert.strictEqual(createdJson.name, "review-prs");
assert.strictEqual(createdJson.cadence, "every:5m");
assert(
typeof createdJson.target === "string" &&
(createdJson.target.startsWith("agent:") || createdJson.target === "new-agent:claude"),
created.stdout,
);
const listed = await ctx.paseo(["schedule", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
@@ -58,61 +53,6 @@ try {
console.log("schedule commands work\n");
}
{
console.log("Test 1b: schedule create accepts provider/model syntax for new-agent runs");
const created = await ctx.paseo(
[
"schedule",
"create",
"Refactor the API layer",
"--every",
"10m",
"--provider",
"codex/gpt-5.4",
"--json",
],
{ timeout: 30000 },
);
assert.strictEqual(created.exitCode, 0, created.stderr);
const createdJson = JSON.parse(created.stdout);
assert.strictEqual(createdJson.target, "new-agent:codex/gpt-5.4");
const inspected = await ctx.paseo(["schedule", "inspect", createdJson.id, "--json"]);
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
const inspectedJson = JSON.parse(inspected.stdout);
assert.strictEqual(inspectedJson.target.config.provider, "codex");
assert.strictEqual(inspectedJson.target.config.model, "gpt-5.4");
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
console.log("schedule provider/model syntax works\n");
}
{
console.log("Test 1c: schedule create rejects provider with self target");
const result = await ctx.paseo(
[
"schedule",
"create",
"Conflicting schedule",
"--every",
"5m",
"--target",
"self",
"--provider",
"codex/gpt-5.4",
],
{ timeout: 30000 },
);
assert.notStrictEqual(result.exitCode, 0, "should fail for self target with provider");
const output = result.stdout + result.stderr;
assert(
output.includes("--provider can only be used with a new-agent target"),
"should explain provider target mismatch",
);
console.log("schedule rejects provider with self target\n");
}
{
console.log("Test 2: loop run/ls/inspect/logs/stop work");
const run = await ctx.paseo(
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -8,13 +8,12 @@
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/cli": "0.1.55-rc.1",
"@getpaseo/server": "0.1.55-rc.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
-36
View File
@@ -1,36 +0,0 @@
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$DesktopDir = (Resolve-Path "$ScriptDir\..").Path
$AppDir = (Resolve-Path "$DesktopDir\..\app").Path
$RootDir = (Resolve-Path "$DesktopDir\..\..").Path
# Build the Electron main process
npm run build:main
# Get a random available port for Metro
$env:EXPO_PORT = (npx get-port-cli).Trim()
# Set EXPO_DEV_URL in the environment so Electron inherits it
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
# Allow any origin in dev so Electron on random ports works.
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
# the daemon binds to localhost and this script is never used for production.
$env:PASEO_CORS_ORIGINS = "*"
Write-Host @"
======================================================
Paseo Desktop Dev (Windows)
======================================================
Metro: http://localhost:$($env:EXPO_PORT)
======================================================
"@
# Launch Metro + Electron together, kill both on exit
& "$RootDir\node_modules\.bin\concurrently" `
--kill-others `
--names "metro,electron" `
--prefix-colors "magenta,cyan" `
"cd `"$AppDir`" && npx expo start --port $($env:EXPO_PORT)" `
"npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/relay": "0.1.56",
"@getpaseo/highlight": "0.1.55-rc.1",
"@getpaseo/relay": "0.1.55-rc.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -516,6 +516,70 @@ describe("DaemonClient", () => {
});
});
test("subscribes to lazy checkout diff updates via RPC handshake", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.subscribeCheckoutDiffLazy(
"/tmp/project",
{ mode: "uncommitted" },
{ subscriptionId: "checkout-lazy-sub-1" },
);
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout/subscribe_diff";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string };
requestId: string;
};
};
expect(request.message.type).toBe("checkout/subscribe_diff");
expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({ mode: "uncommitted", ignoreWhitespace: false });
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout/diff_snapshot",
payload: {
subscriptionId: "checkout-lazy-sub-1",
cwd: "/tmp/project",
files: [],
error: null,
requestId: request.message.requestId,
},
},
}),
);
await expect(promise).resolves.toEqual({
subscriptionId: "checkout-lazy-sub-1",
cwd: "/tmp/project",
files: [],
error: null,
requestId: request.message.requestId,
});
});
test("getCheckoutDiff uses one-shot subscription protocol", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -589,6 +653,105 @@ describe("DaemonClient", () => {
expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId);
});
test("getCheckoutFileHunks requests hunks via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getCheckoutFileHunks(
"/tmp/project",
{ mode: "base", baseRef: "main" },
"src/app.ts",
);
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout/get_file_hunks";
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
path: string;
requestId: string;
};
};
expect(request.message.type).toBe("checkout/get_file_hunks");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({
mode: "base",
baseRef: "main",
ignoreWhitespace: false,
});
expect(request.message.path).toBe("src/app.ts");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout/file_hunks_response",
payload: {
requestId: request.message.requestId,
path: "src/app.ts",
hunks: [],
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: request.message.requestId,
path: "src/app.ts",
hunks: [],
error: null,
});
});
test("unsubscribeCheckoutDiffLazy sends unsubscribe request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
client.unsubscribeCheckoutDiffLazy("checkout-lazy-sub-1");
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout/unsubscribe_diff";
subscriptionId: string;
};
};
expect(request.message).toEqual({
type: "checkout/unsubscribe_diff",
subscriptionId: "checkout-lazy-sub-1",
});
});
test("requests branch suggestions via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -886,6 +1049,60 @@ describe("DaemonClient", () => {
expect(request.message.requestId.length).toBeGreaterThan(0);
});
test("resubscribes lazy checkout diff streams after reconnect", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const internal = client as unknown as {
checkoutDiffLazySubscriptions: Map<
string,
{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>;
};
internal.checkoutDiffLazySubscriptions.set("checkout-lazy-sub-1", {
cwd: "/tmp/project",
compare: { mode: "base", baseRef: "main", ignoreWhitespace: false },
});
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout/subscribe_diff";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
requestId: string;
};
};
expect(request.message.type).toBe("checkout/subscribe_diff");
expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({
mode: "base",
baseRef: "main",
ignoreWhitespace: false,
});
expect(typeof request.message.requestId).toBe("string");
expect(request.message.requestId.length).toBeGreaterThan(0);
});
test("fetches agents via RPC with filters, sort, and pagination", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+136
View File
@@ -216,6 +216,14 @@ type SubscribeCheckoutDiffPayload = Extract<
{ type: "subscribe_checkout_diff_response" }
>["payload"];
type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
type CheckoutDiffMetadataSnapshotPayload = Extract<
SessionOutboundMessage,
{ type: "checkout/diff_snapshot" }
>["payload"];
type CheckoutFileHunksPayload = Extract<
SessionOutboundMessage,
{ type: "checkout/file_hunks_response" }
>["payload"];
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
@@ -500,8 +508,13 @@ type SetDaemonConfigResponse = Extract<
SessionOutboundMessage,
{ type: "set_daemon_config_response" }
>;
type CheckoutDiffSnapshotResponse = Extract<
SessionOutboundMessage,
{ type: "checkout/diff_snapshot" }
>;
type CorrelatedResponseMessage =
| Extract<SessionOutboundMessage, { payload: { requestId: string } }>
| CheckoutDiffSnapshotResponse
| GetDaemonConfigResponse
| SetDaemonConfigResponse;
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
@@ -616,6 +629,13 @@ export class DaemonClient {
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>();
private checkoutDiffLazySubscriptions = new Map<
string,
{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>();
private terminalDirectorySubscriptions = new Set<string>();
private terminalSlots = new Map<string, number>();
private slotTerminals = new Map<number, string>();
@@ -1408,6 +1428,22 @@ export class DaemonClient {
}
}
private resubscribeCheckoutDiffLazySubscriptions(): void {
if (this.checkoutDiffLazySubscriptions.size === 0) {
return;
}
for (const [subscriptionId, subscription] of this.checkoutDiffLazySubscriptions) {
const message = SessionInboundMessageSchema.parse({
type: "checkout/subscribe_diff",
subscriptionId,
cwd: subscription.cwd,
compare: subscription.compare,
requestId: this.createRequestId(),
});
this.sendSessionMessage(message);
}
}
private resubscribeTerminalDirectorySubscriptions(): void {
if (this.terminalDirectorySubscriptions.size === 0) {
return;
@@ -2270,6 +2306,105 @@ export class DaemonClient {
});
}
async getCheckoutDiffMetadata(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
requestId?: string,
): Promise<Omit<CheckoutDiffMetadataSnapshotPayload, "subscriptionId">> {
const oneShotSubscriptionId = `oneshot-checkout-diff-lazy:${crypto.randomUUID()}`;
try {
const payload = await this.subscribeCheckoutDiffLazy(cwd, compare, {
subscriptionId: oneShotSubscriptionId,
requestId,
});
return {
cwd: payload.cwd,
files: payload.files,
error: payload.error,
requestId: payload.requestId,
};
} finally {
try {
this.unsubscribeCheckoutDiffLazy(oneShotSubscriptionId);
} catch {
// Ignore disconnect races during one-shot cleanup.
}
}
}
async subscribeCheckoutDiffLazy(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
options?: { subscriptionId?: string; requestId?: string },
): Promise<CheckoutDiffMetadataSnapshotPayload> {
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
const normalizedCompare = this.normalizeCheckoutDiffCompare(compare);
const previousSubscription = this.checkoutDiffLazySubscriptions.get(subscriptionId) ?? null;
this.checkoutDiffLazySubscriptions.set(subscriptionId, {
cwd,
compare: normalizedCompare,
});
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "checkout/subscribe_diff",
subscriptionId,
cwd,
compare: normalizedCompare,
requestId: resolvedRequestId,
});
try {
return await this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "checkout/diff_snapshot",
timeout: 60000,
options: { skipQueue: true },
selectPayload: (payload) => {
if (payload.subscriptionId !== subscriptionId) {
return null;
}
return payload;
},
});
} catch (error) {
if (previousSubscription) {
this.checkoutDiffLazySubscriptions.set(subscriptionId, previousSubscription);
} else {
this.checkoutDiffLazySubscriptions.delete(subscriptionId);
}
throw error;
}
}
unsubscribeCheckoutDiffLazy(subscriptionId: string): void {
this.checkoutDiffLazySubscriptions.delete(subscriptionId);
this.sendSessionMessage({
type: "checkout/unsubscribe_diff",
subscriptionId,
});
}
async getCheckoutFileHunks(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
path: string,
requestId?: string,
): Promise<CheckoutFileHunksPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout/get_file_hunks",
cwd,
compare: this.normalizeCheckoutDiffCompare(compare),
path,
},
responseType: "checkout/file_hunks_response",
timeout: 60000,
});
}
async checkoutCommit(
cwd: string,
input: { message?: string; addAll?: boolean },
@@ -3723,6 +3858,7 @@ export class DaemonClient {
this.reconnectAttempt = 0;
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
this.resubscribeCheckoutDiffSubscriptions();
this.resubscribeCheckoutDiffLazySubscriptions();
this.resubscribeTerminalDirectorySubscriptions();
this.flushPendingSendQueue();
this.resolveConnect();
@@ -55,7 +55,6 @@ import {
AgentStatusEnum,
ProviderSummarySchema,
parseDurationString,
resolveProviderAndModel,
sanitizePermissionRequest,
serializeSnapshotWithMetadata,
startAgentRun,
@@ -82,20 +81,13 @@ export async function createAgentManagementMcpServer(
component: "agent-management-mcp",
});
const waitTracker = new WaitForAgentTracker(logger);
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
const resolvedProviderModel = resolveProviderAndModel({
provider: params?.provider,
defaultProvider: "claude",
});
return {
type: "new-agent" as const,
config: {
provider: resolvedProviderModel.provider,
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
},
};
};
const resolveNewAgentScheduleTarget = (params?: { provider?: AgentProvider; cwd?: string }) => ({
type: "new-agent" as const,
config: {
provider: params?.provider ?? ("claude" as AgentProvider),
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
},
});
const server = new McpServer({
name: "paseo-agent-management",
@@ -664,9 +656,7 @@ export async function createAgentManagementMcpServer(
cron: z.string().optional(),
name: z.string().optional(),
target: z.enum(["self", "new-agent"]).optional(),
provider: AgentProviderEnum.optional().describe(
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
),
provider: AgentProviderEnum.optional(),
cwd: z.string().optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: z.string().optional(),
@@ -478,28 +478,6 @@ describe("MCP parity end-to-end", () => {
}
});
test("create_schedule accepts provider/model syntax", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
name: "Parity provider schedule",
provider: "codex/gpt-5.4",
});
scheduleId = created.id as string;
expect(created.target).toMatchObject({
type: "new-agent",
config: {
provider: "codex",
model: "gpt-5.4",
},
});
} finally {
await deleteScheduleIfPresent(scheduleId);
}
});
test("inspect_schedule returns details", async () => {
let scheduleId: string | null = null;
try {
@@ -590,27 +568,6 @@ describe("MCP parity end-to-end", () => {
}
});
test("create_schedule on agent MCP accepts provider/model override for new-agent", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(agentScopedClient, "create_schedule", {
prompt: "say hello",
every: "5m",
provider: "codex/gpt-5.4",
});
scheduleId = created.id as string;
expect(created.target).toMatchObject({
type: "new-agent",
config: {
provider: "codex",
model: "gpt-5.4",
},
});
} finally {
await deleteScheduleIfPresent(scheduleId);
}
});
test("create_schedule target self without callerAgentId throws", async () => {
await expectToolError(
topLevelClient,
+11 -46
View File
@@ -37,7 +37,6 @@ import {
AgentStatusEnum,
ProviderSummarySchema,
parseDurationString,
resolveProviderAndModel,
sanitizePermissionRequest,
setupFinishNotification,
serializeSnapshotWithMetadata,
@@ -231,36 +230,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return expandUserPath(trimmedCwd);
};
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
const resolveNewAgentScheduleTarget = () => {
const callerAgent = resolveCallerAgent();
if (callerAgent) {
const hasProviderOverride = params?.provider !== undefined;
const resolvedProviderModel = hasProviderOverride
? resolveProviderAndModel({
provider: params?.provider,
defaultProvider: callerAgent.provider,
})
: null;
const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider;
return {
type: "new-agent" as const,
config: {
provider: resolvedProvider,
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd,
...(callerAgent.currentModeId
? {
modeId: mapModeAcrossProviders(
callerAgent.currentModeId,
callerAgent.provider,
resolvedProvider,
),
}
: {}),
...(resolvedProviderModel?.model
? { model: resolvedProviderModel.model }
: !hasProviderOverride && callerAgent.config.model
? { model: callerAgent.config.model }
: {}),
provider: callerAgent.provider,
cwd: callerAgent.cwd,
...(callerAgent.currentModeId ? { modeId: callerAgent.currentModeId } : {}),
...(callerAgent.config.model ? { model: callerAgent.config.model } : {}),
...(callerAgent.config.thinkingOptionId
? { thinkingOptionId: callerAgent.config.thinkingOptionId }
: {}),
@@ -288,17 +267,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return {
type: "new-agent" as const,
config: (() => {
const resolvedProviderModel = resolveProviderAndModel({
provider: params?.provider,
defaultProvider: "claude",
});
return {
provider: resolvedProviderModel.provider,
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
};
})(),
config: {
provider: "claude" as AgentProvider,
cwd: process.cwd(),
},
};
};
const agentToAgentInputSchema = {
@@ -1209,16 +1181,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
cron: z.string().optional(),
name: z.string().optional(),
target: z.enum(["self", "new-agent"]).optional(),
provider: AgentProviderEnum.optional().describe(
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
),
cwd: z.string().optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: z.string().optional(),
},
outputSchema: ScheduleSummarySchema.shape,
},
async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
async ({ prompt, every, cron, name, target, maxRuns, expiresIn }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
@@ -1234,12 +1202,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!callerAgentId) {
throw new Error("target=self requires a caller agent");
}
if (provider !== undefined || cwd !== undefined) {
throw new Error("provider and cwd can only be used with target=new-agent");
}
return { type: "agent" as const, agentId: callerAgentId };
})()
: resolveNewAgentScheduleTarget({ provider, cwd });
: resolveNewAgentScheduleTarget();
const schedule = await scheduleService.create({
prompt: prompt.trim(),
@@ -7,7 +7,6 @@ import { curateAgentActivity } from "./activity-curator.js";
import type { AgentStorage } from "./agent-storage.js";
import { serializeAgentSnapshot } from "../messages.js";
import { StoredScheduleSchema } from "../schedule/types.js";
import type { AgentProvider } from "./agent-sdk-types.js";
export const AgentProviderEnum = z.string();
@@ -49,49 +48,6 @@ export const AgentModelSchema = z.object({
// 30 seconds - surface friendly message before SDK tool timeout (~60s)
export const AGENT_WAIT_TIMEOUT_MS = 30000;
export interface ResolvedProviderModel {
provider: AgentProvider;
model: string | undefined;
}
export function resolveProviderAndModel(params: {
provider?: string;
model?: string;
defaultProvider: AgentProvider;
}): ResolvedProviderModel {
const providerInput = params.provider?.trim() || params.defaultProvider;
const modelInput = params.model?.trim();
if (params.model !== undefined && !modelInput) {
throw new Error("model cannot be empty");
}
const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) {
return {
provider: providerInput as AgentProvider,
model: modelInput,
};
}
const provider = providerInput.slice(0, slashIndex).trim();
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
if (!provider || !modelFromProvider) {
throw new Error("provider must be <provider> or <provider>/<model>");
}
if (modelInput && modelInput !== modelFromProvider) {
throw new Error(
`Conflicting model values provided: provider specifies ${modelFromProvider}, but model specifies ${modelInput}`,
);
}
return {
provider: provider as AgentProvider,
model: modelInput ?? modelFromProvider,
};
}
export type StartAgentRunOptions = {
replaceRunning?: boolean;
};
@@ -1,11 +1,11 @@
import os from "node:os";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
@@ -150,14 +150,24 @@ describe("paseo daemon bootstrap", () => {
});
});
test("generates a relay pairing offer for unix socket listeners", async () => {
test("emits a relay pairing offer for unix socket listeners", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
await mkdir(path.dirname(socketPath), { recursive: true });
await mkdir(paseoHome, { recursive: true });
const logger = pino({ level: "silent" });
const lines: string[] = [];
const logger = pino(
{ level: "info" },
new Writable({
write(chunk, _encoding, callback) {
lines.push(chunk.toString("utf8"));
callback();
},
}),
);
const config: PaseoDaemonConfig = {
listen: socketPath,
@@ -181,16 +191,7 @@ describe("paseo daemon bootstrap", () => {
try {
await daemon.start();
const pairing = await generateLocalPairingOffer({
paseoHome,
relayEnabled: true,
relayEndpoint: "127.0.0.1:9",
relayPublicEndpoint: "127.0.0.1:9",
appBaseUrl: "https://app.paseo.sh",
includeQr: false,
});
expect(pairing.relayEnabled).toBe(true);
expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
expect(lines.some((line) => line.includes('"msg":"pairing_offer"'))).toBe(true);
} finally {
await daemon.stop().catch(() => undefined);
await daemon.agentManager.flush().catch(() => undefined);
+2 -9
View File
@@ -107,7 +107,6 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -369,10 +368,6 @@ export async function createPaseoDaemon(
});
const terminalManager = createTerminalManager();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger,
paseoHome: config.paseoHome,
});
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
@@ -386,7 +381,6 @@ export async function createPaseoDaemon(
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
@@ -395,7 +389,6 @@ export async function createPaseoDaemon(
const checkoutDiffManager = new CheckoutDiffManager({
logger,
paseoHome: config.paseoHome,
workspaceGitService,
});
const loopService = new LoopService({
paseoHome: config.paseoHome,
@@ -626,7 +619,6 @@ export async function createPaseoDaemon(
loopService,
scheduleService,
checkoutDiffManager,
workspaceGitService,
);
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
@@ -646,7 +638,8 @@ export async function createPaseoDaemon(
relay: { endpoint: relayPublicEndpoint },
});
encodeOfferToFragmentUrl({ offer, appBaseUrl });
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
logger.info({ url }, "pairing_offer");
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
@@ -1,184 +1,282 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
toCheckoutErrorMock: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted(
() => {
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
return {
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
readdirMock: vi.fn(async (directory: string) => {
if (directory === "/tmp/repo") {
return [
{ name: "packages", isDirectory: () => true },
{ name: ".git", isDirectory: () => true },
{ name: "README.md", isDirectory: () => false },
];
}
if (directory === path.join("/tmp/repo", "packages")) {
return [
{ name: "server", isDirectory: () => true },
{ name: "app", isDirectory: () => true },
];
}
if (directory === path.join("/tmp/repo", "packages", "server")) {
return [{ name: "src", isDirectory: () => true }];
}
if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
return [{ name: "server", isDirectory: () => true }];
}
return [];
}),
watchCalls: hoistedWatchCalls,
};
},
);
vi.mock("../utils/run-git-command.js", () => ({
runGitCommand: vi.fn(async () => ({
stdout: "/tmp/repo\n",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
})),
}));
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
return {
...actual,
readdir: readdirMock,
};
});
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
watch: vi.fn((watchPath: string) => {
const close = vi.fn();
watchCalls.push({ path: watchPath, close });
return {
close,
on: vi.fn().mockReturnThis(),
} as any;
}),
};
});
vi.mock("../utils/checkout-git.js", () => ({
getCheckoutDiff: getCheckoutDiffMock,
}));
vi.mock("./checkout-git-utils.js", () => ({
toCheckoutError: toCheckoutErrorMock,
READ_ONLY_GIT_ENV: {},
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
toCheckoutError: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
})),
}));
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
describe("CheckoutDiffManager", () => {
function createStructuredFile(options?: {
path?: string;
addContent?: string;
tokenStyle?: string;
}): {
path: string;
isNew: boolean;
isDeleted: boolean;
additions: number;
deletions: number;
hunks: Array<{
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: Array<{ type: "header" | "context" | "add"; content: string; tokens?: unknown[] }>;
}>;
status: "ok";
} {
const addContent = options?.addContent ?? "const value = 2;";
return {
path: options?.path ?? "src/example.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 2,
lines: [
{ type: "header", content: "@@ -1,1 +1,2 @@" },
{ type: "context", content: "const value = 1;" },
{
type: "add",
content: addContent,
tokens: [{ text: "const", style: options?.tokenStyle ?? "keyword" }],
},
],
},
],
status: "ok",
};
}
describe("CheckoutDiffManager Linux watchers", () => {
const originalPlatform = process.platform;
beforeEach(() => {
vi.useFakeTimers();
getCheckoutDiffMock.mockReset();
getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] });
toCheckoutErrorMock.mockClear();
watchCalls.length = 0;
getCheckoutDiffMock.mockClear();
resolveCheckoutGitDirMock.mockClear();
readdirMock.mockClear();
Object.defineProperty(process, "platform", {
configurable: true,
value: "linux",
});
});
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(process, "platform", {
configurable: true,
value: originalPlatform,
});
});
function createManager(options?: {
repoRoot?: string | null;
getCheckoutDiffImplementation?: typeof getCheckoutDiffMock;
}) {
const unsubscribe = vi.fn();
let onChange: (() => void) | null = null;
const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => {
onChange = listener;
return {
repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot,
unsubscribe,
};
test("watches nested repository directories on Linux", async () => {
const logger = {
child: () => logger,
warn: vi.fn(),
};
const manager = new CheckoutDiffManager({
logger: logger as any,
paseoHome: "/tmp/paseo-test",
});
const workspaceGitService = {
subscribe: vi.fn(),
peekSnapshot: vi.fn(),
getSnapshot: vi.fn(),
refresh: vi.fn(),
scheduleRefreshForCwd: vi.fn(),
requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
dispose: vi.fn(),
};
const subscription = await manager.subscribe(
{
cwd: path.join("/tmp/repo", "packages", "server"),
compare: { mode: "uncommitted" },
},
() => {},
);
if (options?.getCheckoutDiffImplementation) {
getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation);
}
expect(subscription.initial.error).toBeNull();
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
"/tmp/repo",
"/tmp/repo/.git",
"/tmp/repo/packages",
"/tmp/repo/packages/app",
"/tmp/repo/packages/server",
"/tmp/repo/packages/server/src",
"/tmp/repo/packages/server/src/server",
]);
subscription.unsubscribe();
manager.dispose();
});
test("subscribeLazy returns metadata snapshots and caches file hunks", async () => {
getCheckoutDiffMock.mockResolvedValueOnce({
diff: "",
structured: [createStructuredFile()],
});
const logger = {
child: () => logger,
warn: vi.fn(),
};
const manager = new CheckoutDiffManager({
logger: logger as any,
paseoHome: "/tmp/paseo-test",
workspaceGitService: workspaceGitService as any,
});
return {
manager,
workspaceGitService,
mockRequestWorkingTreeWatch,
unsubscribe,
getOnChange: () => onChange,
};
}
test("subscribe requests a working tree watch with the correct cwd", async () => {
const { manager, mockRequestWorkingTreeWatch } = createManager();
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
() => {},
);
expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith(
"/tmp/repo/packages/server",
expect.any(Function),
);
});
test("unsubscribe calls the working tree watch unsubscribe", async () => {
const { manager, unsubscribe } = createManager();
const subscription = await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
() => {},
);
subscription.unsubscribe();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
test("diffCwd uses repoRoot from the working tree watch result", async () => {
const { manager } = createManager({ repoRoot: "/tmp/repo" });
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
() => {},
);
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
"/tmp/repo",
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
{ paseoHome: "/tmp/paseo-test" },
);
});
test("diff refresh is triggered when the working tree watch callback fires", async () => {
getCheckoutDiffMock
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
})
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
});
const { manager, getOnChange } = createManager();
const listener = vi.fn();
await manager.subscribe(
const subscription = await manager.subscribeLazy(
{
cwd: "/tmp/repo/packages/server",
cwd: path.join("/tmp/repo", "packages", "server"),
compare: { mode: "uncommitted" },
},
listener,
);
const onChange = getOnChange();
expect(onChange).toBeTypeOf("function");
expect(subscription.initial.error).toBeNull();
expect(subscription.initial.files).toEqual([
expect.objectContaining({
path: "src/example.ts",
additions: 1,
deletions: 0,
fingerprint: expect.any(String),
}),
]);
expect((subscription.initial.files[0] as any).hunks).toBeUndefined();
expect(
manager.getFileHunks(
path.join("/tmp/repo", "packages", "server"),
{ mode: "uncommitted" },
"src/example.ts",
),
).toEqual(createStructuredFile().hunks);
onChange?.();
await vi.advanceTimersByTimeAsync(150);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
cwd: "/tmp/repo/packages/server",
files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
error: null,
});
subscription.unsubscribe();
manager.dispose();
});
test("falls back to cwd when the working tree watch returns no repo root", async () => {
const { manager } = createManager({ repoRoot: null });
test("lazy metadata fingerprint ignores token-only changes but emits on hunk changes", async () => {
getCheckoutDiffMock.mockResolvedValueOnce({
diff: "",
structured: [createStructuredFile({ tokenStyle: "keyword" })],
});
getCheckoutDiffMock.mockResolvedValueOnce({
diff: "",
structured: [createStructuredFile({ tokenStyle: "variableName" })],
});
getCheckoutDiffMock.mockResolvedValueOnce({
diff: "",
structured: [createStructuredFile({ addContent: "const value = 3;" })],
});
await manager.subscribe(
const logger = {
child: () => logger,
warn: vi.fn(),
};
const manager = new CheckoutDiffManager({
logger: logger as any,
paseoHome: "/tmp/paseo-test",
});
const listener = vi.fn();
await manager.subscribeLazy(
{
cwd: "/tmp/plain",
cwd: path.join("/tmp/repo", "packages", "server"),
compare: { mode: "uncommitted" },
},
() => {},
listener,
);
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
"/tmp/plain",
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
{ paseoHome: "/tmp/paseo-test" },
const target = Array.from((manager as any).targets.values())[0];
await (manager as any).refreshTarget(target);
expect(listener).not.toHaveBeenCalled();
await (manager as any).refreshTarget(target);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
files: [
expect.objectContaining({
path: "src/example.ts",
fingerprint: expect.any(String),
}),
],
}),
);
manager.dispose();
});
});
@@ -1,11 +1,17 @@
import { createHash } from "node:crypto";
import { watch, type FSWatcher } from "node:fs";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type pino from "pino";
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { DiffFileMetadata } from "./checkout/rpc-schemas.js";
import { getCheckoutDiff } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import { toCheckoutError } from "./checkout-git-utils.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js";
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
@@ -14,6 +20,16 @@ export type CheckoutDiffSnapshotPayload = Omit<
"subscriptionId"
>;
type ParsedDiffFile = CheckoutDiffSnapshotPayload["files"][number];
export type { DiffFileMetadata };
export type CheckoutLazyDiffSnapshotPayload = {
cwd: string;
files: DiffFileMetadata[];
error: CheckoutDiffSnapshotPayload["error"];
};
export type CheckoutDiffMetrics = {
checkoutDiffTargetCount: number;
checkoutDiffSubscriptionCount: number;
@@ -27,26 +43,31 @@ type CheckoutDiffWatchTarget = {
diffCwd: string;
compare: CheckoutDiffCompareInput;
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
workingTreeWatchUnsubscribe: (() => void) | null;
lazyListeners: Set<(snapshot: CheckoutLazyDiffSnapshotPayload) => void>;
watchers: FSWatcher[];
fallbackRefreshInterval: NodeJS.Timeout | null;
debounceTimer: NodeJS.Timeout | null;
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
latestPayload: CheckoutDiffSnapshotPayload | null;
latestLazyPayload: CheckoutLazyDiffSnapshotPayload | null;
latestFingerprint: string | null;
latestFilesByPath: Map<string, ParsedDiffFile>;
latestMetadataFingerprints: Map<string, string>;
watchedPaths: Set<string>;
repoWatchPath: string | null;
linuxTreeRefreshPromise: Promise<void> | null;
linuxTreeRefreshQueued: boolean;
};
export class CheckoutDiffManager {
private readonly logger: pino.Logger;
private readonly paseoHome: string;
private readonly workspaceGitService: WorkspaceGitService;
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
constructor(options: {
logger: pino.Logger;
paseoHome: string;
workspaceGitService: WorkspaceGitService;
}) {
constructor(options: { logger: pino.Logger; paseoHome: string }) {
this.logger = options.logger.child({ module: "checkout-diff-manager" });
this.paseoHome = options.paseoHome;
this.workspaceGitService = options.workspaceGitService;
}
async subscribe(
@@ -67,6 +88,7 @@ export class CheckoutDiffManager {
diffCwd: target.diffCwd,
}));
target.latestPayload = initial;
target.latestFilesByPath = new Map(initial.files.map((file) => [file.path, file]));
target.latestFingerprint = JSON.stringify(initial);
return {
initial,
@@ -76,6 +98,36 @@ export class CheckoutDiffManager {
};
}
async subscribeLazy(
params: {
cwd: string;
compare: CheckoutDiffCompareInput;
},
listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
): Promise<{ initial: CheckoutLazyDiffSnapshotPayload; unsubscribe: () => void }> {
const cwd = params.cwd;
const compare = this.normalizeCompare(params.compare);
const target = await this.ensureTarget(cwd, compare);
target.lazyListeners.add(listener);
const baseSnapshot =
target.latestPayload ??
(await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
diffCwd: target.diffCwd,
}));
target.latestPayload = baseSnapshot;
target.latestFilesByPath = new Map(baseSnapshot.files.map((file) => [file.path, file]));
const initial = target.latestLazyPayload ?? this.buildLazySnapshot(baseSnapshot);
target.latestLazyPayload = initial;
target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(initial.files);
return {
initial,
unsubscribe: () => {
this.removeLazyListener(target.key, listener);
},
};
}
scheduleRefreshForCwd(cwd: string): void {
const resolvedCwd = expandTilde(cwd);
for (const target of this.targets.values()) {
@@ -88,16 +140,23 @@ export class CheckoutDiffManager {
getMetrics(): CheckoutDiffMetrics {
let checkoutDiffSubscriptionCount = 0;
let checkoutDiffWatcherCount = 0;
let checkoutDiffFallbackRefreshTargetCount = 0;
for (const target of this.targets.values()) {
checkoutDiffSubscriptionCount += target.listeners.size;
checkoutDiffSubscriptionCount += target.lazyListeners.size;
checkoutDiffWatcherCount += target.watchers.length;
if (target.fallbackRefreshInterval) {
checkoutDiffFallbackRefreshTargetCount += 1;
}
}
return {
checkoutDiffTargetCount: this.targets.size,
checkoutDiffSubscriptionCount,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
checkoutDiffWatcherCount,
checkoutDiffFallbackRefreshTargetCount,
};
}
@@ -133,9 +192,19 @@ export class CheckoutDiffManager {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
target.workingTreeWatchUnsubscribe?.();
target.workingTreeWatchUnsubscribe = null;
if (target.fallbackRefreshInterval) {
clearInterval(target.fallbackRefreshInterval);
target.fallbackRefreshInterval = null;
}
for (const watcher of target.watchers) {
watcher.close();
}
target.watchers = [];
target.watchedPaths.clear();
target.listeners.clear();
target.lazyListeners.clear();
target.latestFilesByPath.clear();
target.latestMetadataFingerprints.clear();
}
private removeListener(
@@ -147,13 +216,54 @@ export class CheckoutDiffManager {
return;
}
target.listeners.delete(listener);
if (target.listeners.size > 0) {
if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
return;
}
this.closeTarget(target);
this.targets.delete(targetKey);
}
private removeLazyListener(
targetKey: string,
listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
): void {
const target = this.targets.get(targetKey);
if (!target) {
return;
}
target.lazyListeners.delete(listener);
if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
return;
}
this.closeTarget(target);
this.targets.delete(targetKey);
}
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(
["rev-parse", "--path-format=absolute", "--show-toplevel"],
{
cwd,
env: READ_ONLY_GIT_ENV,
},
);
const root = stdout.trim();
return root.length > 0 ? root : null;
} catch {
return null;
}
}
getFileHunks(
cwd: string,
compare: CheckoutDiffCompareInput,
path: string,
): ParsedDiffFile["hunks"] | null {
const target = this.targets.get(this.buildTargetKey(cwd, this.normalizeCompare(compare)));
return target?.latestFilesByPath.get(path)?.hunks ?? null;
}
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
@@ -164,6 +274,81 @@ export class CheckoutDiffManager {
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS);
}
private buildLazySnapshot(
snapshot: CheckoutDiffSnapshotPayload,
): CheckoutLazyDiffSnapshotPayload {
return {
cwd: snapshot.cwd,
files: this.deriveMetadata(snapshot.files),
error: snapshot.error,
};
}
private deriveMetadata(files: ParsedDiffFile[]): DiffFileMetadata[] {
return files.map((file) => ({
path: file.path,
isNew: file.isNew,
isDeleted: file.isDeleted,
additions: file.additions,
deletions: file.deletions,
...(file.status ? { status: file.status } : {}),
fingerprint: this.buildFileFingerprint(file),
}));
}
private buildFileFingerprint(file: ParsedDiffFile): string {
const serialized = JSON.stringify({
hunks: file.hunks.map((hunk) => ({
oldStart: hunk.oldStart,
oldCount: hunk.oldCount,
newStart: hunk.newStart,
newCount: hunk.newCount,
lines: hunk.lines.map((line) => ({
type: line.type,
content: line.content,
})),
})),
});
return createHash("sha256").update(serialized).digest("hex");
}
private buildMetadataFingerprintMap(files: DiffFileMetadata[]): Map<string, string> {
return new Map(
files.map((file) => [
file.path,
createHash("sha256").update(JSON.stringify(file)).digest("hex"),
]),
);
}
private metadataSnapshotChanged(
target: CheckoutDiffWatchTarget,
snapshot: CheckoutLazyDiffSnapshotPayload,
): boolean {
if (!target.latestLazyPayload) {
return true;
}
if (Boolean(target.latestLazyPayload.error) !== Boolean(snapshot.error)) {
return true;
}
if (
target.latestLazyPayload.error?.code !== snapshot.error?.code ||
target.latestLazyPayload.error?.message !== snapshot.error?.message
) {
return true;
}
const nextFingerprints = this.buildMetadataFingerprintMap(snapshot.files);
if (nextFingerprints.size !== target.latestMetadataFingerprints.size) {
return true;
}
for (const [path, fingerprint] of nextFingerprints) {
if (target.latestMetadataFingerprints.get(path) !== fingerprint) {
return true;
}
}
return false;
}
private async computeCheckoutDiffSnapshot(
cwd: string,
compare: CheckoutDiffCompareInput,
@@ -213,6 +398,7 @@ export class CheckoutDiffManager {
diffCwd: target.diffCwd,
});
target.latestPayload = snapshot;
target.latestFilesByPath = new Map(snapshot.files.map((file) => [file.path, file]));
const fingerprint = JSON.stringify(snapshot);
if (fingerprint !== target.latestFingerprint) {
target.latestFingerprint = fingerprint;
@@ -220,6 +406,17 @@ export class CheckoutDiffManager {
listener(snapshot);
}
}
const lazySnapshot = this.buildLazySnapshot(snapshot);
if (this.metadataSnapshotChanged(target, lazySnapshot)) {
target.latestLazyPayload = lazySnapshot;
target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(lazySnapshot.files);
for (const listener of target.lazyListeners) {
listener(lazySnapshot);
}
} else {
target.latestLazyPayload = lazySnapshot;
}
} while (target.refreshQueued);
})();
@@ -240,27 +437,216 @@ export class CheckoutDiffManager {
return existing;
}
const watchRoot = await this.resolveCheckoutWatchRoot(cwd);
const target: CheckoutDiffWatchTarget = {
key: targetKey,
cwd,
diffCwd: cwd,
diffCwd: watchRoot ?? cwd,
compare,
listeners: new Set(),
workingTreeWatchUnsubscribe: null,
lazyListeners: new Set(),
watchers: [],
fallbackRefreshInterval: null,
debounceTimer: null,
refreshPromise: null,
refreshQueued: false,
latestPayload: null,
latestLazyPayload: null,
latestFingerprint: null,
latestFilesByPath: new Map(),
latestMetadataFingerprints: new Map(),
watchedPaths: new Set<string>(),
repoWatchPath: null,
linuxTreeRefreshPromise: null,
linuxTreeRefreshQueued: false,
};
const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch(
cwd,
() => this.scheduleTargetRefresh(target),
);
target.diffCwd = repoRoot ?? cwd;
target.workingTreeWatchUnsubscribe = unsubscribe;
const repoWatchPath = watchRoot ?? cwd;
target.repoWatchPath = repoWatchPath;
const watchPaths = new Set<string>([repoWatchPath]);
const gitDir = await resolveCheckoutGitDir(cwd);
if (gitDir) {
watchPaths.add(gitDir);
}
let hasRecursiveRepoCoverage = false;
const allowRecursiveRepoWatch = process.platform !== "linux";
if (process.platform === "linux") {
hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
}
for (const watchPath of watchPaths) {
if (process.platform === "linux" && watchPath === repoWatchPath) {
continue;
}
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
const watcherIsRecursive = this.addWatcher(target, watchPath, shouldTryRecursive);
if (watchPath === repoWatchPath && watcherIsRecursive) {
hasRecursiveRepoCoverage = true;
}
}
const missingRepoCoverage = !hasRecursiveRepoCoverage;
if (target.watchers.length === 0 || missingRepoCoverage) {
target.fallbackRefreshInterval = setInterval(() => {
this.scheduleTargetRefresh(target);
}, CHECKOUT_DIFF_FALLBACK_REFRESH_MS);
this.logger.warn(
{
cwd,
compare,
intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS,
reason:
target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
},
"Checkout diff watchers unavailable; using timed refresh fallback",
);
}
this.targets.set(targetKey, target);
return target;
}
private addWatcher(
target: CheckoutDiffWatchTarget,
watchPath: string,
shouldTryRecursive: boolean,
): boolean {
if (target.watchedPaths.has(watchPath)) {
return false;
}
const { cwd, compare } = target;
const onChange = () => {
if (process.platform === "linux" && target.repoWatchPath) {
void this.refreshLinuxRepoTreeWatchers(target);
}
this.scheduleTargetRefresh(target);
};
const createWatcher = (recursive: boolean): FSWatcher =>
watch(watchPath, { recursive }, () => {
onChange();
});
let watcher: FSWatcher | null = null;
let watcherIsRecursive = false;
try {
if (shouldTryRecursive) {
watcher = createWatcher(true);
watcherIsRecursive = true;
} else {
watcher = createWatcher(false);
}
} catch (error) {
if (shouldTryRecursive) {
try {
watcher = createWatcher(false);
this.logger.warn(
{ err: error, watchPath, cwd, compare },
"Checkout diff recursive watch unavailable; using non-recursive fallback",
);
} catch (fallbackError) {
this.logger.warn(
{ err: fallbackError, watchPath, cwd, compare },
"Failed to start checkout diff watcher",
);
}
} else {
this.logger.warn(
{ err: error, watchPath, cwd, compare },
"Failed to start checkout diff watcher",
);
}
}
if (!watcher) {
return false;
}
watcher.on("error", (error) => {
this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error");
});
target.watchers.push(watcher);
target.watchedPaths.add(watchPath);
return watcherIsRecursive;
}
private async ensureLinuxRepoTreeWatchers(
target: CheckoutDiffWatchTarget,
rootPath: string,
): Promise<boolean> {
const directories = await this.listLinuxWatchDirectories(rootPath);
let complete = true;
for (const directory of directories) {
const watcherWasRecursive = this.addWatcher(target, directory, false);
if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
complete = false;
}
}
return complete && target.watchedPaths.has(rootPath);
}
private async refreshLinuxRepoTreeWatchers(target: CheckoutDiffWatchTarget): Promise<void> {
if (process.platform !== "linux" || !target.repoWatchPath) {
return;
}
const rootPath = target.repoWatchPath;
if (target.linuxTreeRefreshPromise) {
target.linuxTreeRefreshQueued = true;
return;
}
target.linuxTreeRefreshPromise = (async () => {
do {
target.linuxTreeRefreshQueued = false;
try {
await this.ensureLinuxRepoTreeWatchers(target, rootPath);
} catch (error) {
this.logger.warn(
{
err: error,
cwd: target.cwd,
compare: target.compare,
rootPath,
},
"Failed to refresh Linux checkout diff tree watchers",
);
}
} while (target.linuxTreeRefreshQueued);
})();
try {
await target.linuxTreeRefreshPromise;
} finally {
target.linuxTreeRefreshPromise = null;
}
}
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
const directories: string[] = [];
const pending = [rootPath];
while (pending.length > 0) {
const directory = pending.pop();
if (!directory) {
continue;
}
directories.push(directory);
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === ".git") {
continue;
}
pending.push(join(directory, entry.name));
}
}
return directories;
}
}
@@ -0,0 +1,122 @@
import { describe, expect, test } from "vitest";
import {
ServerInfoStatusPayloadSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
} from "../../shared/messages.js";
import { DiffFileMetadataSchema } from "./rpc-schemas.js";
describe("checkout lazy RPC schemas", () => {
test("parses checkout/subscribe_diff inbound messages", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "checkout/subscribe_diff",
requestId: "req-1",
subscriptionId: "sub-1",
cwd: "/tmp/repo",
compare: { mode: "uncommitted" },
});
expect(parsed.type).toBe("checkout/subscribe_diff");
});
test("parses checkout/unsubscribe_diff inbound messages", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "checkout/unsubscribe_diff",
subscriptionId: "sub-1",
});
expect(parsed.type).toBe("checkout/unsubscribe_diff");
});
test("parses checkout/get_file_hunks inbound messages", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "checkout/get_file_hunks",
requestId: "req-2",
cwd: "/tmp/repo",
path: "src/index.ts",
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
});
expect(parsed.type).toBe("checkout/get_file_hunks");
});
test("parses checkout/diff_snapshot outbound messages", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "checkout/diff_snapshot",
payload: {
subscriptionId: "sub-1",
requestId: "req-1",
cwd: "/tmp/repo",
files: [
{
path: "src/index.ts",
isNew: false,
isDeleted: false,
additions: 3,
deletions: 1,
status: "ok",
fingerprint: "fp-1",
},
],
error: null,
},
});
expect(parsed.type).toBe("checkout/diff_snapshot");
});
test("parses checkout/file_hunks_response outbound messages", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "checkout/file_hunks_response",
payload: {
requestId: "req-3",
path: "src/index.ts",
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 2,
lines: [
{ type: "header", content: "@@ -1,1 +1,2 @@" },
{ type: "context", content: "const a = 1;" },
{ type: "add", content: "const b = 2;" },
],
},
],
error: null,
},
});
expect(parsed.type).toBe("checkout/file_hunks_response");
});
test("requires fingerprint on diff metadata", () => {
const result = DiffFileMetadataSchema.safeParse({
path: "src/index.ts",
isNew: false,
isDeleted: false,
additions: 3,
deletions: 1,
status: "ok",
});
expect(result.success).toBe(false);
});
test("accepts lazyDiffRpcs in server info features", () => {
const parsed = ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "server-1",
hostname: null,
version: null,
capabilities: {},
features: {
providersSnapshot: true,
lazyDiffRpcs: true,
},
});
expect(parsed.features?.lazyDiffRpcs).toBe(true);
});
});
@@ -0,0 +1,60 @@
import { z } from "zod";
import {
CheckoutDiffCompareSchema,
CheckoutErrorSchema,
DiffHunkSchema,
} from "../../shared/messages.js";
export const DiffFileMetadataSchema = z.object({
path: z.string(),
isNew: z.boolean(),
isDeleted: z.boolean(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["ok", "too_large", "binary"]).optional(),
fingerprint: z.string(),
});
export type DiffFileMetadata = z.infer<typeof DiffFileMetadataSchema>;
export const CheckoutSubscribeDiffRequestSchema = z.object({
type: z.literal("checkout/subscribe_diff"),
requestId: z.string(),
subscriptionId: z.string(),
cwd: z.string(),
compare: z.lazy(() => CheckoutDiffCompareSchema),
});
export const CheckoutUnsubscribeDiffRequestSchema = z.object({
type: z.literal("checkout/unsubscribe_diff"),
subscriptionId: z.string(),
});
export const CheckoutGetFileHunksRequestSchema = z.object({
type: z.literal("checkout/get_file_hunks"),
requestId: z.string(),
cwd: z.string(),
path: z.string(),
compare: z.lazy(() => CheckoutDiffCompareSchema),
});
export const CheckoutDiffSnapshotSchema = z.object({
type: z.literal("checkout/diff_snapshot"),
payload: z.object({
subscriptionId: z.string(),
requestId: z.string().optional(),
cwd: z.string(),
files: z.array(DiffFileMetadataSchema),
error: z.lazy(() => CheckoutErrorSchema).nullable(),
}),
});
export const CheckoutFileHunksResponseSchema = z.object({
type: z.literal("checkout/file_hunks_response"),
payload: z.object({
requestId: z.string(),
path: z.string(),
hunks: z.array(z.lazy(() => DiffHunkSchema)),
error: z.lazy(() => CheckoutErrorSchema).nullable(),
}),
});
@@ -8,7 +8,6 @@ import { Writable } from "node:stream";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
function createCapturingLogger() {
@@ -23,25 +22,18 @@ function createCapturingLogger() {
return { logger, lines };
}
async function getPairingOfferUrl(args: {
paseoHome: string;
relayEnabled?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
appBaseUrl?: string;
}): Promise<string> {
const pairing = await generateLocalPairingOffer({
paseoHome: args.paseoHome,
relayEnabled: args.relayEnabled,
relayEndpoint: args.relayEndpoint,
relayPublicEndpoint: args.relayPublicEndpoint,
appBaseUrl: args.appBaseUrl,
includeQr: false,
});
if (!pairing.url) {
throw new Error("Expected relay pairing URL to be available");
function parseOfferUrlFromLogs(lines: string[]): string {
for (const line of lines) {
try {
const obj = JSON.parse(line) as { msg?: string; url?: string };
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
return obj.url;
}
} catch {
// ignore non-JSON lines
}
}
return pairing.url;
throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
}
function decodeOfferFromFragmentUrl(url: string): unknown {
@@ -80,7 +72,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
test("emits relay-only offer URL with stable serverId", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger } = createCapturingLogger();
const { logger, lines } = createCapturingLogger();
const daemon = await createTestPaseoDaemon({
listen: "0.0.0.0",
@@ -89,13 +81,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
});
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const offerUrl = parseOfferUrlFromLogs(lines);
expect(offerUrl.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
const offer = decodeOfferFromFragmentUrl(offerUrl) as {
@@ -125,7 +111,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
const tempHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-home-"));
const { logger: logger1 } = createCapturingLogger();
const { logger: logger1, lines: lines1 } = createCapturingLogger();
const daemon1 = await createTestPaseoDaemon({
listen: "0.0.0.0",
logger: logger1,
@@ -138,13 +124,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
let staticDir2: string | null = null;
try {
const offerUrl1 = await getPairingOfferUrl({
paseoHome: daemon1.paseoHome,
relayEnabled: daemon1.config.relayEnabled,
relayEndpoint: daemon1.config.relayEndpoint,
relayPublicEndpoint: daemon1.config.relayPublicEndpoint,
appBaseUrl: daemon1.config.appBaseUrl,
});
const offerUrl1 = parseOfferUrlFromLogs(lines1);
const offer1 = decodeOfferFromFragmentUrl(offerUrl1) as {
serverId: string;
daemonPublicKeyB64: string;
@@ -153,7 +133,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
await daemon1.close();
const { logger: logger2 } = createCapturingLogger();
const { logger: logger2, lines: lines2 } = createCapturingLogger();
const daemon2 = await createTestPaseoDaemon({
listen: "0.0.0.0",
logger: logger2,
@@ -164,13 +144,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
staticDir2 = daemon2.staticDir;
try {
const offerUrl2 = await getPairingOfferUrl({
paseoHome: daemon2.paseoHome,
relayEnabled: daemon2.config.relayEnabled,
relayEndpoint: daemon2.config.relayEndpoint,
relayPublicEndpoint: daemon2.config.relayPublicEndpoint,
appBaseUrl: daemon2.config.appBaseUrl,
});
const offerUrl2 = parseOfferUrlFromLogs(lines2);
const offer2 = decodeOfferFromFragmentUrl(offerUrl2) as {
serverId: string;
daemonPublicKeyB64: string;
@@ -233,6 +207,12 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
stdoutLines.push(text);
for (const line of text.split("\n")) {
if (!line.trim()) continue;
if (line.includes("pairing_offer")) {
clearTimeout(timeout);
reject(new Error("unexpected pairing_offer log when --no-relay is set"));
return;
}
try {
const parsed = JSON.parse(line) as { msg?: string };
if (parsed.msg !== `Server listening on http://0.0.0.0:${port}`) continue;
@@ -7,7 +7,6 @@ import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { Buffer } from "node:buffer";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js";
@@ -27,25 +26,19 @@ function createCapturingLogger() {
return { logger, lines };
}
async function getPairingOfferUrl(args: {
paseoHome: string;
relayEnabled?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
appBaseUrl?: string;
}): Promise<string> {
const pairing = await generateLocalPairingOffer({
paseoHome: args.paseoHome,
relayEnabled: args.relayEnabled,
relayEndpoint: args.relayEndpoint,
relayPublicEndpoint: args.relayPublicEndpoint,
appBaseUrl: args.appBaseUrl,
includeQr: false,
});
if (!pairing.url) {
throw new Error("Expected relay pairing URL to be available");
function parseOfferUrlFromLogs(lines: string[]): string {
for (const line of lines) {
if (!line.includes("pairing_offer")) continue;
try {
const obj = JSON.parse(line) as { msg?: string; url?: string };
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
return obj.url;
}
} catch {
// ignore
}
}
return pairing.url;
throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
}
function decodeOfferFromFragmentUrl(url: string): {
@@ -206,13 +199,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
});
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const offerUrl = parseOfferUrlFromLogs(lines);
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
@@ -327,13 +314,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
});
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const offerUrl = parseOfferUrlFromLogs(lines);
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
// Previously, the daemon would time out waiting for `hello` and reconnect every ~10s.
@@ -0,0 +1,353 @@
import { homedir } from "node:os";
import { beforeEach, describe, expect, test, vi } from "vitest";
const { getCheckoutDiffFileMock } = vi.hoisted(() => ({
getCheckoutDiffFileMock: vi.fn(),
}));
vi.mock("../utils/checkout-git.js", async () => {
const actual = await vi.importActual<typeof import("../utils/checkout-git.js")>(
"../utils/checkout-git.js",
);
return {
...actual,
getCheckoutDiffFile: getCheckoutDiffFileMock,
};
});
import { Session } from "./session.js";
function createSessionForCheckoutDiffTests(options?: {
checkoutDiffManager?: {
subscribe: ReturnType<typeof vi.fn>;
subscribeLazy: ReturnType<typeof vi.fn>;
getFileHunks: ReturnType<typeof vi.fn>;
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
getMetrics: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
};
}) {
const emitted: any[] = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const checkoutDiffManager =
options?.checkoutDiffManager ??
({
subscribe: vi.fn(async () => ({
initial: { cwd: "/tmp/repo", files: [], error: null },
unsubscribe: vi.fn(),
})),
subscribeLazy: vi.fn(async () => ({
initial: { cwd: "/tmp/repo", files: [], error: null },
unsubscribe: vi.fn(),
})),
getFileHunks: vi.fn(() => null),
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
} as const);
const session = new Session({
clientId: "test-client",
appVersion: null,
onMessage: vi.fn(),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
clearAgentAttention: async () => {},
notifyAgentState: () => {},
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
checkoutDiffManager: checkoutDiffManager as any,
workspaceGitService: {
subscribe: async () => ({
initial: {
cwd: "/tmp/repo",
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
refreshedAt: null,
},
},
unsubscribe: vi.fn(),
}),
peekSnapshot: () => null,
getSnapshot: async () => null,
refresh: async () => {},
dispose: () => {},
} as any,
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: null,
}) as any;
session.emit = (message: any) => emitted.push(message);
return { session, emitted, checkoutDiffManager };
}
describe("Session checkout lazy diff handlers", () => {
beforeEach(() => {
getCheckoutDiffFileMock.mockReset();
});
test("checkout/subscribe_diff emits initial metadata snapshot and replaces existing subscription", async () => {
const firstUnsubscribe = vi.fn();
const secondUnsubscribe = vi.fn();
const subscribeLazy = vi
.fn()
.mockResolvedValueOnce({
initial: {
cwd: "/tmp/repo",
files: [
{
path: "src/example.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
fingerprint: "fp-1",
},
],
error: null,
},
unsubscribe: firstUnsubscribe,
})
.mockResolvedValueOnce({
initial: { cwd: "/tmp/repo", files: [], error: null },
unsubscribe: secondUnsubscribe,
});
const { session, emitted } = createSessionForCheckoutDiffTests({
checkoutDiffManager: {
subscribe: vi.fn(),
subscribeLazy,
getFileHunks: vi.fn(),
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
},
});
await session.handleMessage({
type: "checkout/subscribe_diff",
requestId: "req-1",
subscriptionId: "sub-1",
cwd: "~/repo",
compare: { mode: "uncommitted" },
});
await session.handleMessage({
type: "checkout/subscribe_diff",
requestId: "req-2",
subscriptionId: "sub-1",
cwd: "~/repo",
compare: { mode: "uncommitted" },
});
expect(subscribeLazy).toHaveBeenNthCalledWith(
1,
{ cwd: `${homedir()}/repo`, compare: { mode: "uncommitted" } },
expect.any(Function),
);
expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
expect(emitted[0]).toMatchObject({
type: "checkout/diff_snapshot",
payload: {
subscriptionId: "sub-1",
requestId: "req-1",
cwd: "/tmp/repo",
},
});
});
test("checkout/get_file_hunks serves cached hunks before falling back to git", async () => {
const cachedHunks = [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 2,
lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
},
];
const getFileHunks = vi.fn(() => cachedHunks);
const { session, emitted } = createSessionForCheckoutDiffTests({
checkoutDiffManager: {
subscribe: vi.fn(),
subscribeLazy: vi.fn(),
getFileHunks,
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
},
});
await session.handleMessage({
type: "checkout/get_file_hunks",
requestId: "req-hunks",
cwd: "/tmp/repo",
path: "src/example.ts",
compare: { mode: "uncommitted" },
});
expect(getFileHunks).toHaveBeenCalledWith(
"/tmp/repo",
{ mode: "uncommitted" },
"src/example.ts",
);
expect(getCheckoutDiffFileMock).not.toHaveBeenCalled();
expect(emitted[0]).toMatchObject({
type: "checkout/file_hunks_response",
payload: {
requestId: "req-hunks",
path: "src/example.ts",
hunks: cachedHunks,
error: null,
},
});
});
test("checkout/get_file_hunks falls back to single-file diff and unsubscribe cleans up", async () => {
const unsubscribe = vi.fn();
getCheckoutDiffFileMock.mockResolvedValueOnce({
path: "src/example.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 2,
lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
},
],
status: "ok",
});
const { session, emitted } = createSessionForCheckoutDiffTests({
checkoutDiffManager: {
subscribe: vi.fn(),
subscribeLazy: vi.fn(async () => ({
initial: { cwd: "/tmp/repo", files: [], error: null },
unsubscribe,
})),
getFileHunks: vi.fn(() => null),
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
},
});
await session.handleMessage({
type: "checkout/subscribe_diff",
requestId: "req-sub",
subscriptionId: "sub-1",
cwd: "/tmp/repo",
compare: { mode: "base", baseRef: "main" },
});
await session.handleMessage({
type: "checkout/get_file_hunks",
requestId: "req-hunks",
cwd: "/tmp/repo",
path: "src/example.ts",
compare: { mode: "base", baseRef: "main" },
});
await session.handleMessage({
type: "checkout/unsubscribe_diff",
subscriptionId: "sub-1",
});
expect(getCheckoutDiffFileMock).toHaveBeenCalledWith(
"/tmp/repo",
{
mode: "base",
baseRef: "main",
ignoreWhitespace: undefined,
includeStructured: true,
},
"src/example.ts",
{ paseoHome: "/tmp/paseo-test" },
);
expect(emitted[1]).toMatchObject({
type: "checkout/file_hunks_response",
payload: {
requestId: "req-hunks",
path: "src/example.ts",
error: null,
},
});
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
+122 -92
View File
@@ -28,6 +28,9 @@ import {
type CaptureTerminalRequest,
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type CheckoutSubscribeDiffRequest,
type CheckoutUnsubscribeDiffRequest,
type CheckoutGetFileHunksRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
@@ -114,7 +117,6 @@ import type {
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import {
buildProjectPlacementForCwd,
checkoutLiteFromGitSnapshot,
detectStaleWorkspaces,
deriveProjectKind,
deriveProjectRootPath,
@@ -150,6 +152,7 @@ import { type WorktreeConfig } from "../utils/worktree.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import {
getCheckoutDiff,
getCheckoutDiffFile,
getCheckoutStatus,
listBranchSuggestions,
commitChanges,
@@ -335,42 +338,6 @@ type FetchWorkspacesCursor = {
id: string;
};
function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspacesResponseEntry>): {
count: number;
projectIds: string[];
statusCounts: Record<string, number>;
workspaces: Array<{
id: string;
projectId: string;
projectDisplayName: string;
name: string;
status: FetchWorkspacesResponseEntry["status"];
workspaceKind: FetchWorkspacesResponseEntry["workspaceKind"];
activityAt: string | null;
}>;
} {
const workspaces = Array.from(entries, (entry) => ({
id: entry.id,
projectId: entry.projectId,
projectDisplayName: entry.projectDisplayName,
name: entry.name,
status: entry.status,
workspaceKind: entry.workspaceKind,
activityAt: entry.activityAt,
}));
const statusCounts = new Map<string, number>();
for (const workspace of workspaces) {
statusCounts.set(workspace.status, (statusCounts.get(workspace.status) ?? 0) + 1);
}
return {
count: workspaces.length,
projectIds: [...new Set(workspaces.map((workspace) => workspace.projectId))],
statusCounts: Object.fromEntries(statusCounts),
workspaces,
};
}
class SessionRequestError extends Error {
constructor(
readonly code: string,
@@ -659,6 +626,7 @@ export class Session {
private inflightRequests = 0;
private peakInflightRequests = 0;
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly checkoutDiffLazySubscriptions = new Map<string, () => void>();
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
private readonly registerVoiceSpeakHandler?: (
agentId: string,
@@ -1404,7 +1372,7 @@ export class Session {
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
return buildProjectPlacementForCwd({
cwd,
workspaceGitService: this.workspaceGitService,
paseoHome: this.paseoHome,
});
}
@@ -1820,6 +1788,18 @@ export class Session {
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
case "checkout/subscribe_diff":
await this.handleCheckoutSubscribeDiffRequest(msg);
break;
case "checkout/get_file_hunks":
await this.handleCheckoutGetFileHunksRequest(msg);
break;
case "checkout/unsubscribe_diff":
this.handleCheckoutUnsubscribeDiffRequest(msg);
break;
case "checkout_switch_branch_request":
await this.handleCheckoutSwitchBranchRequest(msg);
break;
@@ -3209,7 +3189,6 @@ export class Session {
{
paseoHome: this.paseoHome,
sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.createBranchFromBase(params),
},
@@ -4315,6 +4294,89 @@ export class Session {
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
}
private async handleCheckoutSubscribeDiffRequest(
msg: CheckoutSubscribeDiffRequest,
): Promise<void> {
const cwd = expandTilde(msg.cwd);
this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
const subscription = await this.checkoutDiffManager.subscribeLazy(
{ cwd, compare: msg.compare },
(snapshot) => {
this.emit({
type: "checkout/diff_snapshot",
payload: {
subscriptionId: msg.subscriptionId,
...snapshot,
},
});
},
);
this.checkoutDiffLazySubscriptions.set(msg.subscriptionId, subscription.unsubscribe);
this.emit({
type: "checkout/diff_snapshot",
payload: {
subscriptionId: msg.subscriptionId,
...subscription.initial,
requestId: msg.requestId,
},
});
}
private async handleCheckoutGetFileHunksRequest(msg: CheckoutGetFileHunksRequest): Promise<void> {
const cwd = expandTilde(msg.cwd);
let hunks = this.checkoutDiffManager.getFileHunks(cwd, msg.compare, msg.path);
let error: {
code: "UNKNOWN" | "NOT_ALLOWED" | "NOT_GIT_REPO" | "MERGE_CONFLICT";
message: string;
} | null = null;
if (hunks === null) {
try {
const file = await getCheckoutDiffFile(
cwd,
{
mode: msg.compare.mode,
baseRef: msg.compare.baseRef,
ignoreWhitespace: msg.compare.ignoreWhitespace,
includeStructured: true,
},
msg.path,
{ paseoHome: this.paseoHome },
);
if (file) {
hunks = file.hunks;
} else {
hunks = [];
error = {
code: "UNKNOWN",
message: "File no longer present in diff",
};
}
} catch (caughtError) {
hunks = [];
error = toCheckoutError(caughtError);
}
}
this.emit({
type: "checkout/file_hunks_response",
payload: {
requestId: msg.requestId,
path: msg.path,
hunks,
error,
},
});
}
private handleCheckoutUnsubscribeDiffRequest(msg: CheckoutUnsubscribeDiffRequest): void {
this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
}
private async handleCheckoutSwitchBranchRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
): Promise<void> {
@@ -4719,6 +4781,7 @@ export class Session {
const { cwd, requestId } = msg;
try {
await this.workspaceGitService.refresh(cwd, { priority: "high" });
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
this.emit({
type: "checkout_pr_status_response",
@@ -5443,27 +5506,26 @@ export class Session {
projectRecord?: PersistedProjectRecord | null,
): Promise<WorkspaceDescriptorPayload> {
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
let snapshot: WorkspaceGitRuntimeSnapshot;
let displayName = workspace.displayName;
try {
snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd);
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd: workspace.cwd },
"Failed to load git snapshot for workspace",
);
return base;
const placement = await this.buildProjectPlacement(workspace.cwd);
displayName = deriveWorkspaceDisplayName({
cwd: workspace.cwd,
checkout: placement.checkout,
});
} catch {
// Fall back to the persisted label if checkout metadata is unavailable.
}
const checkout = checkoutLiteFromGitSnapshot(workspace.cwd, snapshot.git);
const displayName = deriveWorkspaceDisplayName({ cwd: workspace.cwd, checkout });
let snapshot: WorkspaceGitRuntimeSnapshot | null = null;
snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
return {
...base,
name: displayName,
diffStat: snapshot.git.diffStat ?? null,
gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot),
githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot),
diffStat: snapshot?.git.diffStat ?? null,
gitRuntime: snapshot ? this.buildWorkspaceGitRuntimePayload(snapshot) : undefined,
githubRuntime: snapshot ? this.buildWorkspaceGitHubRuntimePayload(snapshot) : undefined,
};
}
@@ -5776,9 +5838,7 @@ export class Session {
const filter = request.filter;
const sort = this.normalizeFetchWorkspacesSort(request.sort);
let entries = await this.listWorkspaceDescriptors();
const listedCount = entries.length;
entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter }));
const filteredCount = entries.length;
entries.sort((left, right) => this.compareFetchWorkspacesEntries(left, right, sort));
const cursorToken = request.page?.cursor;
@@ -5797,21 +5857,6 @@ export class Session {
? this.encodeFetchWorkspacesCursor(pagedEntries[pagedEntries.length - 1], sort)
: null;
this.sessionLogger.debug(
{
requestId: request.requestId,
filter: request.filter ?? null,
sort,
page: request.page ?? null,
listedCount,
filteredCount,
returnedCount: pagedEntries.length,
hasMore,
nextCursor,
},
"fetch_workspaces_entries_listed",
);
return {
entries: pagedEntries,
pageInfo: {
@@ -5890,6 +5935,8 @@ export class Session {
buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input),
buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd),
projectRegistry: this.projectRegistry,
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
workspaceRegistry: this.workspaceRegistry,
archiveProjectRecordIfEmpty: (projectId, archivedAt) =>
this.archiveProjectRecordIfEmpty(projectId, archivedAt),
@@ -6092,16 +6139,6 @@ export class Session {
: null;
try {
this.sessionLogger.debug(
{
requestId: request.requestId,
subscribeRequested: Boolean(request.subscribe),
filter: request.filter ?? null,
sort: request.sort ?? null,
page: request.page ?? null,
},
"fetch_workspaces_request_received",
);
if (subscriptionId) {
this.workspaceUpdatesSubscription = {
subscriptionId,
@@ -6113,15 +6150,6 @@ export class Session {
}
const payload = await this.listFetchWorkspacesEntries(request);
this.sessionLogger.debug(
{
requestId: request.requestId,
subscriptionId,
pageInfo: payload.pageInfo,
payload: summarizeFetchWorkspacesEntries(payload.entries),
},
"fetch_workspaces_response_ready",
);
const snapshotLatestActivityByWorkspaceId = new Map<string, number>();
for (const entry of payload.entries) {
const parsedLatestActivity = entry.activityAt
@@ -6272,13 +6300,10 @@ export class Session {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
workspaceGitService: this.workspaceGitService,
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
emit: (message) => this.emit(message),
registerPendingWorktreeWorkspace: (options) =>
this.registerPendingWorktreeWorkspace(options),
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
sessionLogger: this.sessionLogger,
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
},
@@ -7462,6 +7487,11 @@ export class Session {
}
this.checkoutDiffSubscriptions.clear();
for (const unsubscribe of this.checkoutDiffLazySubscriptions.values()) {
unsubscribe();
}
this.checkoutDiffLazySubscriptions.clear();
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
unsubscribe();
}
@@ -65,8 +65,6 @@ function createSessionForWorkspaceGitWatchTests(): {
peekSnapshot: ReturnType<typeof vi.fn>;
getSnapshot: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>;
requestWorkingTreeWatch: ReturnType<typeof vi.fn>;
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
};
subscriptions: Array<{
@@ -107,11 +105,6 @@ function createSessionForWorkspaceGitWatchTests(): {
peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
refresh: vi.fn(async () => {}),
requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({
repoRoot: cwd,
unsubscribe: vi.fn(),
})),
scheduleRefreshForCwd: vi.fn(),
dispose: vi.fn(),
};
@@ -335,17 +328,62 @@ describe("workspace git watch targets", () => {
});
});
test("checkout_pr_status_request reads cached snapshot without forcing a refresh", async () => {
test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => {
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
let refreshed = false;
workspaceGitService.refresh.mockImplementation(async () => {
refreshed = true;
});
workspaceGitService.getSnapshot.mockImplementation(async (cwd: string) =>
createWorkspaceRuntimeSnapshot(cwd, {
github: {
pullRequest: refreshed
? {
url: "https://github.com/acme/repo/pull/457",
title: "After explicit refresh",
state: "merged",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: true,
}
: {
url: "https://github.com/acme/repo/pull/456",
title: "Before explicit refresh",
state: "open",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: false,
},
refreshedAt: refreshed ? "2026-04-12T00:10:00.000Z" : "2026-04-12T00:05:00.000Z",
},
}),
);
await session.handleMessage({
type: "checkout_pr_status_request",
cwd: "/tmp/repo",
requestId: "req-pr-cached",
requestId: "req-pr-refresh",
});
expect(workspaceGitService.refresh).not.toHaveBeenCalled();
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", {
priority: "high",
});
expect(
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
).toEqual({
cwd: "/tmp/repo",
status: {
url: "https://github.com/acme/repo/pull/457",
title: "After explicit refresh",
state: "merged",
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: true,
},
githubFeaturesEnabled: true,
error: null,
requestId: "req-pr-refresh",
});
});
});
@@ -113,11 +113,6 @@ function createNoopWorkspaceGitService() {
},
}),
refresh: async () => {},
requestWorkingTreeWatch: async (cwd: string) => ({
repoRoot: cwd,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
};
}
@@ -1137,6 +1132,7 @@ describe("workspace aggregation", () => {
test("create paseo worktree request returns a registered workspace descriptor", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
const repoDir = path.join(tempDir, "repo");
const paseoHome = path.join(tempDir, "paseo-home");
@@ -1147,45 +1143,6 @@ describe("workspace aggregation", () => {
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => {
if (cwd === repoDir) {
return createWorkspaceRuntimeSnapshot(cwd, {
git: {
repoRoot: repoDir,
currentBranch: "main",
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
}
if (cwd.includes("worktree-123")) {
return createWorkspaceRuntimeSnapshot(cwd, {
git: {
repoRoot: cwd,
currentBranch: "worktree-123",
remoteUrl: null,
isPaseoOwnedWorktree: true,
mainRepoRoot: repoDir,
},
});
}
return createWorkspaceRuntimeSnapshot(cwd, {
git: {
repoRoot: cwd,
currentBranch: "main",
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
});
const session = createSessionForWorkspaceTests({
workspaceGitService,
}) as any;
const workspaces = new Map();
const projects = new Map();
@@ -1888,7 +1845,9 @@ describe("workspace aggregation", () => {
});
const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
workspaceGitService.getSnapshot = vi.fn(async () => runtimeSnapshot);
workspaceGitService.getSnapshot = vi.fn(async () => {
throw new Error("fetch_workspaces should not trigger per-workspace refreshes");
});
workspaceGitService.subscribe = vi.fn(async () => ({
initial: runtimeSnapshot,
unsubscribe: () => {},
@@ -1942,7 +1901,8 @@ describe("workspace aggregation", () => {
| { type: "fetch_workspaces_response"; payload: any }
| undefined;
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(response?.payload.entries).toEqual([
expect.objectContaining({
id: "/tmp/repo",
@@ -1993,7 +1953,9 @@ describe("workspace aggregation", () => {
});
const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
workspaceGitService.getSnapshot = vi.fn(async () => runtimeSnapshot);
workspaceGitService.getSnapshot = vi.fn(async () => {
throw new Error("workspace updates should use passive workspace git snapshots");
});
const session = createSessionForWorkspaceTests({
workspaceGitService,
@@ -2046,7 +2008,8 @@ describe("workspace aggregation", () => {
skipReconcile: true,
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(emitted).toContainEqual({
type: "workspace_update",
payload: {
+6 -63
View File
@@ -66,66 +66,6 @@ type WebSocketServerConfig = {
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl {
return {
subscribe: async ({ cwd }: { cwd: string }) => ({
initial: {
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
refreshedAt: null,
},
},
unsubscribe: () => {},
}),
peekSnapshot: () => null,
getSnapshot: async (cwd: string) => ({
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
refreshedAt: null,
},
}),
refresh: async () => {},
requestWorkingTreeWatch: async (cwd: string) => ({
repoRoot: cwd,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
} as unknown as WorkspaceGitServiceImpl;
}
function createNoopProjectRegistry(): ProjectRegistry {
return {
initialize: async () => {},
@@ -367,7 +307,6 @@ export class VoiceAssistantWebSocketServer {
loopService?: LoopService,
scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager,
workspaceGitService?: WorkspaceGitServiceImpl,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -395,7 +334,10 @@ export class VoiceAssistantWebSocketServer {
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
}
this.checkoutDiffManager = checkoutDiffManager;
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
this.workspaceGitService = new WorkspaceGitServiceImpl({
logger: this.logger,
paseoHome,
});
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.daemonConfigStore = daemonConfigStore;
@@ -573,8 +515,8 @@ export class VoiceAssistantWebSocketServer {
await Promise.all(cleanupPromises);
this.providerSnapshotManager.destroy();
this.checkoutDiffManager.dispose();
this.workspaceGitService.dispose();
this.checkoutDiffManager.dispose();
this.pendingConnections.clear();
this.sessions.clear();
this.externalSessionsByKey.clear();
@@ -858,6 +800,7 @@ export class VoiceAssistantWebSocketServer {
features: {
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true,
lazyDiffRpcs: true,
},
};
}
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import path from "node:path";
import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js";
import {
WorkspaceGitServiceImpl,
@@ -117,28 +116,11 @@ function createWatcher() {
};
}
function createDirent(name: string, isDirectory: boolean) {
return {
name,
isDirectory: () => isDirectory,
};
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createService(options?: {
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
@@ -147,8 +129,6 @@ function createService(options?: {
resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>;
hasOriginRemote?: ReturnType<typeof vi.fn>;
runGitFetch?: ReturnType<typeof vi.fn>;
runGitCommand?: ReturnType<typeof vi.fn>;
readdir?: ReturnType<typeof vi.fn>;
watch?: ReturnType<typeof vi.fn>;
now?: () => Date;
}) {
@@ -157,7 +137,6 @@ function createService(options?: {
paseoHome: "/tmp/paseo-test",
deps: {
watch: options?.watch ?? ((() => createWatcher()) as unknown as any),
readdir: options?.readdir ?? vi.fn(async () => []),
getCheckoutStatus:
options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
getCheckoutShortstat:
@@ -172,15 +151,6 @@ function createService(options?: {
resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"),
hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false),
runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}),
runGitCommand:
options?.runGitCommand ??
vi.fn(async () => ({
stdout: "/tmp/repo\n",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
})),
now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")),
},
});
@@ -247,48 +217,6 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => {
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
const service = createService({
getCheckoutStatus,
getPullRequestStatus,
resolveAbsoluteGitDir,
});
const firstSnapshotPromise = service.getSnapshot("/tmp/repo");
const secondSnapshotPromise = service.getSnapshot("/tmp/repo/.");
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
expect((service as any).workspaceTargets.size).toBe(0);
expect((service as any).workspaceTargetSetups.size).toBe(1);
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([
createSnapshot("/tmp/repo"),
createSnapshot("/tmp/repo"),
]);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
expect((service as any).workspaceTargets.size).toBe(1);
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
service.dispose();
});
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
@@ -504,172 +432,4 @@ describe("WorkspaceGitServiceImpl", () => {
subscription.unsubscribe();
service.dispose();
});
test("watches nested repository directories on Linux", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
value: "linux",
});
const watchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
const watch = vi.fn((watchPath: string) => {
const watcher = createWatcher();
watchCalls.push({ path: watchPath, close: watcher.close });
return watcher as any;
});
const readdir = vi.fn(async (directory: string) => {
if (directory === "/tmp/repo") {
return [
createDirent("packages", true),
createDirent(".git", true),
createDirent("README.md", false),
];
}
if (directory === path.join("/tmp/repo", "packages")) {
return [createDirent("server", true), createDirent("app", true)];
}
if (directory === path.join("/tmp/repo", "packages", "server")) {
return [createDirent("src", true)];
}
if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
return [createDirent("server", true)];
}
return [];
});
const service = createService({ watch, readdir });
const subscription = await service.requestWorkingTreeWatch(
path.join("/tmp/repo", "packages", "server"),
vi.fn(),
);
expect(subscription.repoRoot).toBe("/tmp/repo");
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
"/tmp/repo",
"/tmp/repo/.git",
"/tmp/repo/packages",
"/tmp/repo/packages/app",
"/tmp/repo/packages/server",
"/tmp/repo/packages/server/src",
"/tmp/repo/packages/server/src/server",
]);
subscription.unsubscribe();
service.dispose();
Object.defineProperty(process, "platform", {
configurable: true,
value: originalPlatform,
});
});
test("requestWorkingTreeWatch reference-counts watchers by cwd", async () => {
const watchers = [createWatcher(), createWatcher()];
const watch = vi
.fn()
.mockReturnValueOnce(watchers[0] as any)
.mockReturnValueOnce(watchers[1] as any);
const service = createService({ watch });
const firstListener = vi.fn();
const secondListener = vi.fn();
const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener);
const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener);
expect(first.repoRoot).toBe("/tmp/repo");
expect(second.repoRoot).toBe("/tmp/repo");
expect(watch).toHaveBeenCalledTimes(2);
first.unsubscribe();
expect(watchers[0].close).not.toHaveBeenCalled();
expect(watchers[1].close).not.toHaveBeenCalled();
second.unsubscribe();
expect(watchers[0].close).toHaveBeenCalledTimes(1);
expect(watchers[1].close).toHaveBeenCalledTimes(1);
service.dispose();
});
test("sets a 5-second fallback polling interval when recursive watch is unavailable", async () => {
if (process.platform === "linux") {
// On Linux, recursive watch is never attempted — the service uses per-directory
// watchers from the start. This scenario only applies to macOS/Windows where
// recursive watch is tried first and may fail.
return;
}
const recursiveUnsupported = new Error("recursive unsupported");
const watch = vi
.fn()
.mockImplementationOnce((_watchPath: string, options: { recursive: boolean }) => {
if (options.recursive) {
throw recursiveUnsupported;
}
return createWatcher() as any;
})
.mockImplementationOnce(() => createWatcher() as any);
const service = createService({ watch });
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
const target = (service as any).workingTreeWatchTargets.get("/tmp/repo");
expect(target?.fallbackRefreshInterval).not.toBeNull();
subscription.unsubscribe();
service.dispose();
});
test("non-git directories fall back to watching cwd with polling", async () => {
const watch = vi.fn(() => createWatcher() as any);
const runGitCommand = vi.fn(async () => {
throw new Error("not a git repository");
});
const resolveAbsoluteGitDir = vi.fn(async () => null);
const service = createService({
watch,
runGitCommand,
resolveAbsoluteGitDir,
});
const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn());
const target = (service as any).workingTreeWatchTargets.get("/tmp/plain");
expect(subscription.repoRoot).toBeNull();
const expectedRecursive = process.platform !== "linux";
expect(watch).toHaveBeenCalledWith(
"/tmp/plain",
{ recursive: expectedRecursive },
expect.any(Function),
);
expect(target?.repoWatchPath).toBe("/tmp/plain");
expect(target?.fallbackRefreshInterval).not.toBeNull();
subscription.unsubscribe();
service.dispose();
});
test("working tree changes notify listeners and schedule workspace refresh", async () => {
const watchCallbacks: Array<() => void> = [];
const watch = vi.fn(
(_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
watchCallbacks.push(callback);
return createWatcher() as any;
},
);
const service = createService({ watch });
const refreshSpy = vi.spyOn(service as any, "scheduleWorkspaceRefresh");
const listener = vi.fn();
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener);
expect(watchCallbacks).toHaveLength(2);
watchCallbacks[0]?.();
expect(listener).toHaveBeenCalledTimes(1);
expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo");
subscription.unsubscribe();
service.dispose();
});
});
@@ -1,5 +1,5 @@
import { watch, type FSWatcher } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import type pino from "pino";
import type { CheckoutContext } from "../utils/checkout-git.js";
@@ -12,12 +12,10 @@ import {
resolveAbsoluteGitDir,
} from "../utils/checkout-git.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
export type WorkspaceGitRuntimeSnapshot = {
cwd: string;
@@ -61,11 +59,6 @@ export interface WorkspaceGitService {
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
getSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot>;
refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise<void>;
requestWorkingTreeWatch(
cwd: string,
onChange: () => void,
): Promise<{ repoRoot: string | null; unsubscribe: () => void }>;
scheduleRefreshForCwd(cwd: string): void;
dispose(): void;
}
@@ -73,7 +66,6 @@ export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => vo
interface WorkspaceGitServiceDependencies {
watch: typeof watch;
readdir: typeof readdir;
getCheckoutStatus: typeof getCheckoutStatus;
getCheckoutShortstat: typeof getCheckoutShortstat;
getPullRequestStatus: typeof getPullRequestStatus;
@@ -81,7 +73,6 @@ interface WorkspaceGitServiceDependencies {
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
hasOriginRemote: (cwd: string) => Promise<boolean>;
runGitFetch: (cwd: string) => Promise<void>;
runGitCommand: typeof runGitCommand;
now: () => Date;
}
@@ -111,18 +102,6 @@ interface RepoGitTarget {
fetchInFlight: boolean;
}
interface WorkingTreeWatchTarget {
cwd: string;
repoRoot: string | null;
repoWatchPath: string | null;
watchers: FSWatcher[];
watchedPaths: Set<string>;
fallbackRefreshInterval: NodeJS.Timeout | null;
linuxTreeRefreshPromise: Promise<void> | null;
linuxTreeRefreshQueued: boolean;
listeners: Set<() => void>;
}
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly logger: pino.Logger;
private readonly paseoHome: string;
@@ -130,15 +109,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>();
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
constructor(options: WorkspaceGitServiceOptions) {
this.logger = options.logger.child({ module: "workspace-git-service" });
this.paseoHome = options.paseoHome;
this.deps = {
watch: options.deps?.watch ?? watch,
readdir: options.deps?.readdir ?? readdir,
watch,
getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus,
getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat,
getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus,
@@ -146,7 +122,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir,
hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote,
runGitFetch: options.deps?.runGitFetch ?? runGitFetch,
runGitCommand: options.deps?.runGitCommand ?? runGitCommand,
now: options.deps?.now ?? (() => new Date()),
};
}
@@ -176,9 +151,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (target?.latestSnapshot) {
return target.latestSnapshot;
}
const ensuredTarget = await this.ensureWorkspaceTarget(cwd);
return ensuredTarget.latestSnapshot ?? (await this.refreshSnapshot(cwd));
return this.refreshSnapshot(cwd);
}
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
@@ -197,47 +170,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
await this.ensureWorkspaceTarget(cwd);
}
async requestWorkingTreeWatch(
cwd: string,
onChange: () => void,
): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
cwd = normalizeWorkspaceId(cwd);
const target = await this.ensureWorkingTreeWatchTarget(cwd);
target.listeners.add(onChange);
return {
repoRoot: target.repoRoot,
unsubscribe: () => {
this.removeWorkingTreeWatchListener(cwd, onChange);
},
};
}
scheduleRefreshForCwd(cwd: string): void {
cwd = normalizeWorkspaceId(cwd);
const target = this.workspaceTargets.get(cwd);
if (target) {
this.scheduleWorkspaceRefresh(target);
}
}
dispose(): void {
for (const target of this.workspaceTargets.values()) {
this.closeWorkspaceTarget(target);
}
this.workspaceTargets.clear();
this.workspaceTargetSetups.clear();
for (const target of this.repoTargets.values()) {
this.closeRepoTarget(target);
}
this.repoTargets.clear();
for (const target of this.workingTreeWatchTargets.values()) {
this.closeWorkingTreeWatchTarget(target);
}
this.workingTreeWatchTargets.clear();
this.workingTreeWatchSetups.clear();
this.workspaceTargetSetups.clear();
}
private async ensureWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
@@ -258,24 +201,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return setup;
}
private async ensureWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
const existingTarget = this.workingTreeWatchTargets.get(cwd);
if (existingTarget) {
return existingTarget;
}
const existingSetup = this.workingTreeWatchSetups.get(cwd);
if (existingSetup) {
return existingSetup;
}
const setup = this.createWorkingTreeWatchTarget(cwd).finally(() => {
this.workingTreeWatchSetups.delete(cwd);
});
this.workingTreeWatchSetups.set(cwd, setup);
return setup;
}
private async createWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
const target: WorkspaceGitTarget = {
cwd,
@@ -305,83 +230,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return target;
}
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
const target: WorkingTreeWatchTarget = {
cwd,
repoRoot,
repoWatchPath: null,
watchers: [],
watchedPaths: new Set<string>(),
fallbackRefreshInterval: null,
linuxTreeRefreshPromise: null,
linuxTreeRefreshQueued: false,
listeners: new Set(),
};
const repoWatchPath = repoRoot ?? cwd;
target.repoWatchPath = repoWatchPath;
const watchPaths = new Set<string>([repoWatchPath]);
const gitDir = await this.deps.resolveAbsoluteGitDir(cwd);
if (gitDir) {
watchPaths.add(gitDir);
}
let hasRecursiveRepoCoverage = false;
const allowRecursiveRepoWatch = process.platform !== "linux";
if (process.platform === "linux") {
hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
}
for (const watchPath of watchPaths) {
if (process.platform === "linux" && watchPath === repoWatchPath) {
continue;
}
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
const watcherIsRecursive = this.addWorkingTreeWatcher(target, watchPath, shouldTryRecursive);
if (watchPath === repoWatchPath && watcherIsRecursive) {
hasRecursiveRepoCoverage = true;
}
}
const missingRepoCoverage = repoRoot === null || !hasRecursiveRepoCoverage;
if (target.watchers.length === 0 || missingRepoCoverage) {
target.fallbackRefreshInterval = setInterval(() => {
this.scheduleWorkspaceRefresh(cwd);
for (const listener of target.listeners) {
listener();
}
}, WORKING_TREE_WATCH_FALLBACK_REFRESH_MS);
this.logger.warn(
{
cwd,
intervalMs: WORKING_TREE_WATCH_FALLBACK_REFRESH_MS,
reason:
target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
},
"Working tree watchers unavailable; using timed refresh fallback",
);
}
this.workingTreeWatchTargets.set(cwd, target);
return target;
}
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await this.deps.runGitCommand(
["rev-parse", "--path-format=absolute", "--show-toplevel"],
{
cwd,
env: READ_ONLY_GIT_ENV,
},
);
const root = stdout.trim();
return root.length > 0 ? root : null;
} catch {
return null;
}
}
private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise<string> {
try {
const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim();
@@ -460,15 +308,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
void this.runRepoFetch(repoTarget);
}
private scheduleWorkspaceRefresh(targetOrCwd: WorkspaceGitTarget | string): void {
const target =
typeof targetOrCwd === "string"
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
: targetOrCwd;
if (!target) {
return;
}
private scheduleWorkspaceRefresh(target: WorkspaceGitTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
}
@@ -479,149 +319,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
}
private addWorkingTreeWatcher(
target: WorkingTreeWatchTarget,
watchPath: string,
shouldTryRecursive: boolean,
): boolean {
if (target.watchedPaths.has(watchPath)) {
return false;
}
const { cwd } = target;
const onChange = () => {
if (process.platform === "linux" && target.repoWatchPath) {
void this.refreshLinuxRepoTreeWatchers(target);
}
this.scheduleWorkspaceRefresh(cwd);
for (const listener of target.listeners) {
listener();
}
};
const createWatcher = (recursive: boolean): FSWatcher =>
this.deps.watch(watchPath, { recursive }, () => {
onChange();
});
let watcher: FSWatcher | null = null;
let watcherIsRecursive = false;
try {
if (shouldTryRecursive) {
watcher = createWatcher(true);
watcherIsRecursive = true;
} else {
watcher = createWatcher(false);
}
} catch (error) {
if (shouldTryRecursive) {
try {
watcher = createWatcher(false);
this.logger.warn(
{ err: error, watchPath, cwd },
"Working tree recursive watch unavailable; using non-recursive fallback",
);
} catch (fallbackError) {
this.logger.warn(
{ err: fallbackError, watchPath, cwd },
"Failed to start working tree watcher",
);
}
} else {
this.logger.warn({ err: error, watchPath, cwd }, "Failed to start working tree watcher");
}
}
if (!watcher) {
return false;
}
watcher.on("error", (error) => {
this.logger.warn({ err: error, watchPath, cwd }, "Working tree watcher error");
});
target.watchers.push(watcher);
target.watchedPaths.add(watchPath);
return watcherIsRecursive;
}
private async ensureLinuxRepoTreeWatchers(
target: WorkingTreeWatchTarget,
rootPath: string,
): Promise<boolean> {
const directories = await this.listLinuxWatchDirectories(rootPath);
let complete = true;
for (const directory of directories) {
const watcherWasRecursive = this.addWorkingTreeWatcher(target, directory, false);
if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
complete = false;
}
}
return complete && target.watchedPaths.has(rootPath);
}
private async refreshLinuxRepoTreeWatchers(target: WorkingTreeWatchTarget): Promise<void> {
if (process.platform !== "linux" || !target.repoWatchPath) {
return;
}
const rootPath = target.repoWatchPath;
if (target.linuxTreeRefreshPromise) {
target.linuxTreeRefreshQueued = true;
return;
}
target.linuxTreeRefreshPromise = (async () => {
do {
target.linuxTreeRefreshQueued = false;
try {
await this.ensureLinuxRepoTreeWatchers(target, rootPath);
} catch (error) {
this.logger.warn(
{
err: error,
cwd: target.cwd,
rootPath,
},
"Failed to refresh Linux working tree watchers",
);
}
} while (target.linuxTreeRefreshQueued);
})();
try {
await target.linuxTreeRefreshPromise;
} finally {
target.linuxTreeRefreshPromise = null;
}
}
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
const directories: string[] = [];
const pending = [rootPath];
while (pending.length > 0) {
const directory = pending.pop();
if (!directory) {
continue;
}
directories.push(directory);
let entries;
try {
entries = await this.deps.readdir(directory, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === ".git") {
continue;
}
pending.push(join(directory, entry.name));
}
}
return directories;
}
private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise<void> {
if (target.refreshPromise) {
target.refreshQueued = true;
@@ -738,21 +435,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.workspaceTargets.delete(target.cwd);
}
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
const target = this.workingTreeWatchTargets.get(cwd);
if (!target) {
return;
}
target.listeners.delete(listener);
if (target.listeners.size > 0) {
return;
}
this.closeWorkingTreeWatchTarget(target);
this.workingTreeWatchTargets.delete(cwd);
}
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
@@ -766,20 +448,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.listeners.clear();
}
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
if (target.fallbackRefreshInterval) {
clearInterval(target.fallbackRefreshInterval);
target.fallbackRefreshInterval = null;
}
for (const watcher of target.watchers) {
watcher.close();
}
target.watchers = [];
target.watchedPaths.clear();
target.listeners.clear();
}
private closeRepoTarget(target: RepoGitTarget): void {
if (target.intervalId) {
clearInterval(target.intervalId);
@@ -6,77 +6,15 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import { AgentStorage } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
function createNoopWorkspaceGitService(): WorkspaceGitService {
return {
subscribe: async (params) => ({
initial: {
cwd: params.cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
refreshedAt: null,
},
},
unsubscribe: () => {},
}),
peekSnapshot: () => null,
getSnapshot: async (cwd) => ({
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
refreshedAt: null,
},
}),
refresh: async () => {},
requestWorkingTreeWatch: async () => ({
repoRoot: null,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
};
}
describe("bootstrapWorkspaceRegistries", () => {
let tmpDir: string;
let paseoHome: string;
let agentStorage: AgentStorage;
let projectRegistry: FileBackedProjectRegistry;
let workspaceRegistry: FileBackedWorkspaceRegistry;
let workspaceGitService: WorkspaceGitService;
const logger = createTestLogger();
beforeEach(() => {
@@ -91,7 +29,6 @@ describe("bootstrapWorkspaceRegistries", () => {
path.join(paseoHome, "projects", "workspaces.json"),
logger,
);
workspaceGitService = createNoopWorkspaceGitService();
});
afterEach(() => {
@@ -157,7 +94,6 @@ describe("bootstrapWorkspaceRegistries", () => {
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
@@ -221,7 +157,6 @@ describe("bootstrapWorkspaceRegistries", () => {
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
@@ -13,7 +13,6 @@ import {
deriveWorkspaceKind,
normalizeWorkspaceId,
} from "./workspace-registry-model.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -54,7 +53,6 @@ export async function bootstrapWorkspaceRegistries(options: {
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
workspaceGitService: WorkspaceGitService;
logger: Logger;
}): Promise<void> {
const [projectsExists, workspacesExists] = await Promise.all([
@@ -81,7 +79,7 @@ export async function bootstrapWorkspaceRegistries(options: {
const normalizedCwd = normalizeWorkspaceId(record.cwd);
const placement = await buildProjectPlacementForCwd({
cwd: normalizedCwd,
workspaceGitService: options.workspaceGitService,
paseoHome: options.paseoHome,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] };
@@ -1,7 +1,7 @@
import { resolve } from "node:path";
import { getCheckoutStatusLite } from "../utils/checkout-git.js";
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
export type PersistedProjectKind = "git" | "non_git";
@@ -137,50 +137,6 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi
return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout";
}
export function checkoutLiteFromGitSnapshot(
cwd: string,
git: {
isGit: boolean;
currentBranch: string | null;
remoteUrl: string | null;
repoRoot: string | null;
isPaseoOwnedWorktree: boolean;
mainRepoRoot: string | null;
},
): ProjectCheckoutLitePayload {
if (!git.isGit) {
return {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
if (git.isPaseoOwnedWorktree && git.mainRepoRoot) {
return {
cwd,
isGit: true,
currentBranch: git.currentBranch,
remoteUrl: git.remoteUrl,
worktreeRoot: git.repoRoot ?? cwd,
isPaseoOwnedWorktree: true,
mainRepoRoot: git.mainRepoRoot,
};
}
return {
cwd,
isGit: true,
currentBranch: git.currentBranch,
remoteUrl: git.remoteUrl,
worktreeRoot: git.repoRoot ?? cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
export async function detectStaleWorkspaces(
input: DetectStaleWorkspacesInput,
): Promise<Set<string>> {
@@ -198,15 +154,45 @@ export async function detectStaleWorkspaces(
export async function buildProjectPlacementForCwd(input: {
cwd: string;
workspaceGitService: WorkspaceGitService;
paseoHome: string;
}): Promise<ProjectPlacementPayload> {
const normalizedCwd = normalizeWorkspaceId(input.cwd);
const checkout = await input.workspaceGitService
.getSnapshot(normalizedCwd)
.then(
(snapshot): ProjectCheckoutLitePayload =>
checkoutLiteFromGitSnapshot(normalizedCwd, snapshot.git),
)
const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome })
.then((status): ProjectCheckoutLitePayload => {
if (!status.isGit) {
return {
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
if (status.isPaseoOwnedWorktree && status.mainRepoRoot) {
return {
cwd: normalizedCwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
worktreeRoot: status.worktreeRoot,
isPaseoOwnedWorktree: true,
mainRepoRoot: status.mainRepoRoot,
};
}
return {
cwd: normalizedCwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
worktreeRoot: status.worktreeRoot,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
})
.catch(
(): ProjectCheckoutLitePayload => ({
cwd: normalizedCwd,
+23 -27
View File
@@ -17,11 +17,10 @@ import type {
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import { createAgentWorktree } from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { getCurrentBranch, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
computeWorktreePath,
@@ -35,7 +34,8 @@ import {
validateBranchSlug,
type WorktreeConfig,
} from "../utils/worktree.js";
import { toCheckoutError } from "./checkout-git-utils.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
export type NormalizedGitOptions = {
@@ -51,7 +51,6 @@ type EmitSessionMessage = (message: SessionOutboundMessage) => void;
type BuildAgentSessionConfigDependencies = {
paseoHome?: string;
sessionLogger: Logger;
workspaceGitService: WorkspaceGitService;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
createBranchFromBase: (params: {
cwd: string;
@@ -86,6 +85,7 @@ type RegisterPendingWorktreeWorkspaceDependencies = {
}) => PersistedWorkspaceRecord;
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
};
@@ -99,7 +99,6 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
type HandleCreatePaseoWorktreeRequestDependencies = {
paseoHome?: string;
workspaceGitService: WorkspaceGitService;
describeWorkspaceRecord: (
workspace: PersistedWorkspaceRecord,
) => Promise<WorkspaceDescriptorPayload>;
@@ -109,7 +108,6 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
worktreePath: string;
branchName: string;
}) => Promise<PersistedWorkspaceRecord>;
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
sessionLogger: Logger;
runWorktreeSetupInBackground: (options: {
requestCwd: string;
@@ -152,7 +150,11 @@ export async function buildAgentSessionConfig(
if (normalized.createNewBranch) {
targetBranch = normalized.newBranchName!;
} else {
targetBranch = (await getCurrentBranch(cwd)) ?? "";
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
targetBranch = stdout.trim();
}
if (!targetBranch) {
@@ -165,8 +167,7 @@ export async function buildAgentSessionConfig(
);
const baseBranch =
normalized.baseBranch ??
(await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService));
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
@@ -178,8 +179,7 @@ export async function buildAgentSessionConfig(
worktreeConfig = createdWorktree;
} else if (normalized.createNewBranch) {
const baseBranch =
normalized.baseBranch ??
(await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService));
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
await dependencies.createBranchFromBase({
cwd,
baseBranch,
@@ -264,18 +264,13 @@ export function assertSafeGitRef(ref: string, label: string): void {
}
}
export async function resolveGitCreateBaseBranch(
cwd: string,
workspaceGitService: WorkspaceGitService,
): Promise<string> {
const snapshot = await workspaceGitService.getSnapshot(cwd);
if (!snapshot.git.isGit) {
export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise<string> {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
if (!checkout.isGit) {
throw new Error("Cannot create a worktree outside a git repository");
}
const repoRoot = snapshot.git.isPaseoOwnedWorktree
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd)
: (snapshot.git.repoRoot ?? cwd);
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd;
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
@@ -530,6 +525,9 @@ export async function registerPendingWorktreeWorkspace(
await dependencies.projectRegistry.upsert(nextProjectRecord);
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
await dependencies.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
@@ -547,14 +545,14 @@ export async function handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
try {
const snapshot = await dependencies.workspaceGitService.getSnapshot(request.cwd);
if (!snapshot.git.isGit) {
const checkout = await getCheckoutStatusLite(request.cwd, {
paseoHome: dependencies.paseoHome,
});
if (!checkout.isGit) {
throw new Error("Create worktree requires a git repository");
}
const repoRoot = snapshot.git.isPaseoOwnedWorktree
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? request.cwd)
: (snapshot.git.repoRoot ?? request.cwd);
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd;
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
@@ -585,8 +583,6 @@ export async function handleCreatePaseoWorktreeRequest(
paseoHome: dependencies.paseoHome,
});
await dependencies.syncWorkspaceGitWatchTarget(worktreePath, { isGit: true });
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
dependencies.emit({
type: "create_paseo_worktree_response",
+24 -6
View File
@@ -47,6 +47,13 @@ import {
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "../server/loop/rpc-schemas.js";
import {
CheckoutDiffSnapshotSchema,
CheckoutFileHunksResponseSchema,
CheckoutGetFileHunksRequestSchema,
CheckoutSubscribeDiffRequestSchema,
CheckoutUnsubscribeDiffRequestSchema,
} from "../server/checkout/rpc-schemas.js";
// ---------------------------------------------------------------------------
// Mutable daemon config schemas (shared between server store and client)
// ---------------------------------------------------------------------------
@@ -1013,12 +1020,12 @@ const CheckoutErrorCodeSchema = z.enum([
"UNKNOWN",
]);
const CheckoutErrorSchema = z.object({
export const CheckoutErrorSchema = z.object({
code: CheckoutErrorCodeSchema,
message: z.string(),
});
const CheckoutDiffCompareSchema = z.object({
export const CheckoutDiffCompareSchema = z.object({
mode: z.enum(["uncommitted", "base"]),
baseRef: z.string().optional(),
ignoreWhitespace: z.boolean().optional(),
@@ -1231,18 +1238,18 @@ export const ArchiveWorkspaceRequestSchema = z.object({
// Highlighted diff token schema
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
const HighlightTokenSchema = z.object({
export const HighlightTokenSchema = z.object({
text: z.string(),
style: z.string().nullable(),
});
const DiffLineSchema = z.object({
export const DiffLineSchema = z.object({
type: z.enum(["add", "remove", "context", "header"]),
content: z.string(),
tokens: z.array(HighlightTokenSchema).optional(),
});
const DiffHunkSchema = z.object({
export const DiffHunkSchema = z.object({
oldStart: z.number(),
oldCount: z.number(),
newStart: z.number(),
@@ -1250,7 +1257,7 @@ const DiffHunkSchema = z.object({
lines: z.array(DiffLineSchema),
});
const ParsedDiffFileSchema = z.object({
export const ParsedDiffFileSchema = z.object({
path: z.string(),
isNew: z.boolean(),
isDeleted: z.boolean(),
@@ -1464,6 +1471,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema,
CheckoutSubscribeDiffRequestSchema,
CheckoutUnsubscribeDiffRequestSchema,
CheckoutGetFileHunksRequestSchema,
CheckoutCommitRequestSchema,
CheckoutMergeRequestSchema,
CheckoutMergeFromBaseRequestSchema,
@@ -1686,6 +1696,7 @@ export const ServerInfoStatusPayloadSchema = z
features: z
.object({
providersSnapshot: z.boolean().optional(),
lazyDiffRpcs: z.boolean().optional(),
})
.optional(),
})
@@ -2789,6 +2800,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutStatusResponseSchema,
SubscribeCheckoutDiffResponseSchema,
CheckoutDiffUpdateSchema,
CheckoutDiffSnapshotSchema,
CheckoutFileHunksResponseSchema,
CheckoutCommitResponseSchema,
CheckoutMergeResponseSchema,
CheckoutMergeFromBaseResponseSchema,
@@ -2999,8 +3012,13 @@ export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
export type UnsubscribeCheckoutDiffRequest = z.infer<typeof UnsubscribeCheckoutDiffRequestSchema>;
export type CheckoutSubscribeDiffRequest = z.infer<typeof CheckoutSubscribeDiffRequestSchema>;
export type CheckoutUnsubscribeDiffRequest = z.infer<typeof CheckoutUnsubscribeDiffRequestSchema>;
export type CheckoutGetFileHunksRequest = z.infer<typeof CheckoutGetFileHunksRequestSchema>;
export type SubscribeCheckoutDiffResponse = z.infer<typeof SubscribeCheckoutDiffResponseSchema>;
export type CheckoutDiffUpdate = z.infer<typeof CheckoutDiffUpdateSchema>;
export type CheckoutDiffSnapshot = z.infer<typeof CheckoutDiffSnapshotSchema>;
export type CheckoutFileHunksResponse = z.infer<typeof CheckoutFileHunksResponseSchema>;
export type CheckoutCommitRequest = z.infer<typeof CheckoutCommitRequestSchema>;
export type CheckoutCommitResponse = z.infer<typeof CheckoutCommitResponseSchema>;
export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
+26 -10
View File
@@ -18,11 +18,11 @@ import {
__setGhPathForTests,
__setPullRequestStatusCacheTtlForTests,
commitAll,
getCurrentBranch,
getCheckoutDiff,
getCheckoutShortstat,
getPullRequestStatus,
getCheckoutStatus,
getCheckoutStatusLite,
listBranchSuggestions,
mergeToBase,
mergeFromBase,
@@ -85,15 +85,6 @@ describe("checkout git utilities", () => {
);
});
it("returns null for getCurrentBranch in a repo with no commits", async () => {
const emptyRepo = join(tempDir, "empty-repo");
execSync(`mkdir -p ${emptyRepo}`);
execSync("git init -b main", { cwd: emptyRepo });
const branch = await getCurrentBranch(emptyRepo);
expect(branch).toBeNull();
});
it("handles status/diff/commit in a normal repo", async () => {
writeFileSync(join(repoDir, "file.txt"), "updated\n");
@@ -173,6 +164,15 @@ const x = 1;
expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]);
});
it("returns lightweight checkout status for normal repos", async () => {
const status = await getCheckoutStatusLite(repoDir);
expect(status.isGit).toBe(true);
expect(status.currentBranch).toBe("main");
expect(status.worktreeRoot).toBe(repoDir);
expect(status.isPaseoOwnedWorktree).toBe(false);
expect(status.mainRepoRoot).toBeNull();
});
it("exposes hasRemote when origin is configured", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
@@ -380,6 +380,22 @@ const x = 1;
expect(message).toBe("worktree update");
});
it("returns lightweight checkout status for .paseo worktrees", async () => {
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "lite-alpha",
paseoHome,
});
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
expect(status.isGit).toBe(true);
expect(status.worktreeRoot).toBe(result.worktreePath);
expect(status.isPaseoOwnedWorktree).toBe(true);
expect(status.mainRepoRoot).toBe(repoDir);
});
it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => {
const bareRepoDir = join(tempDir, "bare-repo");
execSync(`git clone --bare ${repoDir} ${bareRepoDir}`);
+103 -14
View File
@@ -482,6 +482,38 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
export type CheckoutStatusLiteNotGit = {
isGit: false;
currentBranch: null;
remoteUrl: null;
worktreeRoot: null;
isPaseoOwnedWorktree: false;
mainRepoRoot: null;
};
export type CheckoutStatusLiteGitNonPaseo = {
isGit: true;
currentBranch: string | null;
remoteUrl: string | null;
worktreeRoot: string;
isPaseoOwnedWorktree: false;
mainRepoRoot: null;
};
export type CheckoutStatusLiteGitPaseo = {
isGit: true;
currentBranch: string | null;
remoteUrl: string | null;
worktreeRoot: string;
isPaseoOwnedWorktree: true;
mainRepoRoot: string;
};
export type CheckoutStatusLiteResult =
| CheckoutStatusLiteNotGit
| CheckoutStatusLiteGitNonPaseo
| CheckoutStatusLiteGitPaseo;
export interface CheckoutDiffResult {
diff: string;
structured?: ParsedDiffFile[];
@@ -525,16 +557,12 @@ async function requireGitRepo(cwd: string): Promise<void> {
}
export async function getCurrentBranch(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const branch = stdout.trim();
return branch.length > 0 ? branch : null;
} catch {
return null;
}
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const branch = stdout.trim();
return branch.length > 0 ? branch : null;
}
async function getWorktreeRoot(cwd: string): Promise<string | null> {
@@ -1128,6 +1156,43 @@ export async function getCheckoutStatus(
};
}
export async function getCheckoutStatusLite(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutStatusLiteResult> {
const inspected = await inspectCheckoutContext(cwd, context);
if (!inspected) {
return {
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
if (inspected.configured.isPaseoOwnedWorktree) {
return {
isGit: true,
currentBranch: inspected.currentBranch,
remoteUrl: inspected.remoteUrl,
worktreeRoot: inspected.worktreeRoot,
isPaseoOwnedWorktree: true,
mainRepoRoot: await getMainRepoRoot(cwd),
};
}
return {
isGit: true,
currentBranch: inspected.currentBranch,
remoteUrl: inspected.remoteUrl,
worktreeRoot: inspected.worktreeRoot,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
export interface CheckoutShortstat {
additions: number;
deletions: number;
@@ -1212,9 +1277,10 @@ export async function getCheckoutShortstat(
}
}
export async function getCheckoutDiff(
async function getCheckoutDiffInternal(
cwd: string,
compare: CheckoutDiffCompare,
options?: { path?: string },
context?: CheckoutContext,
): Promise<CheckoutDiffResult> {
await requireGitRepo(cwd);
@@ -1240,7 +1306,12 @@ export async function getCheckoutDiff(
const ignoreWhitespace = compare.ignoreWhitespace === true;
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
changes.sort((a, b) => {
const targetPath = options?.path;
const filteredChanges =
typeof targetPath === "string"
? changes.filter((change) => change.path === targetPath)
: changes;
filteredChanges.sort((a, b) => {
if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1;
});
@@ -1264,8 +1335,8 @@ export async function getCheckoutDiff(
}
};
const trackedChanges = changes.filter((change) => !change.isUntracked);
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
const trackedChanges = filteredChanges.filter((change) => !change.isUntracked);
const untrackedChanges = filteredChanges.filter((change) => change.isUntracked === true);
const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change]));
const trackedNumstatByPath =
@@ -1449,6 +1520,24 @@ export async function getCheckoutDiff(
return { diff: diffText };
}
export async function getCheckoutDiff(
cwd: string,
compare: CheckoutDiffCompare,
context?: CheckoutContext,
): Promise<CheckoutDiffResult> {
return getCheckoutDiffInternal(cwd, compare, undefined, context);
}
export async function getCheckoutDiffFile(
cwd: string,
compare: CheckoutDiffCompare,
path: string,
context?: CheckoutContext,
): Promise<ParsedDiffFile | null> {
const result = await getCheckoutDiffInternal(cwd, compare, { path }, context);
return result.structured?.[0] ?? null;
}
export async function commitChanges(
cwd: string,
options: { message: string; addAll?: boolean },
+1 -1
View File
@@ -21,6 +21,6 @@ export default defineConfig({
maxForks: 1,
},
},
exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"],
exclude: ["**/node_modules/**", "**/dist/**"],
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.56",
"version": "0.1.55-rc.1",
"private": true,
"type": "module",
"scripts": {
-65
View File
@@ -1,65 +0,0 @@
$ErrorActionPreference = "Stop"
# Ensure node_modules/.bin is in PATH
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$env:PATH = "$ScriptDir\..\node_modules\.bin;$env:PATH"
# Derive PASEO_HOME: stable name for worktrees, temporary dir otherwise
if (-not $env:PASEO_HOME) {
$GitDir = git rev-parse --git-dir 2>$null
$GitCommonDir = git rev-parse --git-common-dir 2>$null
if ($GitDir -and $GitCommonDir -and ($GitDir -ne $GitCommonDir)) {
# Inside a worktree — derive a stable home from the worktree name
$WorktreeRoot = git rev-parse --show-toplevel
$WorktreeName = (Split-Path -Leaf $WorktreeRoot).ToLower() -replace '[^a-z0-9-]', '-' -replace '-+', '-' -replace '^-|-$', ''
$env:PASEO_HOME = "$env:USERPROFILE\.paseo-$WorktreeName"
New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null
} else {
$env:PASEO_HOME = Join-Path ([System.IO.Path]::GetTempPath()) "paseo-dev-$([System.Guid]::NewGuid().ToString('N').Substring(0,6))"
New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null
# Register cleanup on exit
$TempPaseoHome = $env:PASEO_HOME
Register-EngineEvent PowerShell.Exiting -Action {
Remove-Item -Recurse -Force $TempPaseoHome -ErrorAction SilentlyContinue
} | Out-Null
}
}
# Share speech models with the main install to avoid duplicate downloads
if (-not $env:PASEO_LOCAL_MODELS_DIR) {
$env:PASEO_LOCAL_MODELS_DIR = "$env:USERPROFILE\.paseo\models\local-speech"
New-Item -ItemType Directory -Force -Path $env:PASEO_LOCAL_MODELS_DIR | Out-Null
}
Write-Host @"
======================================================
Paseo Dev (Windows)
======================================================
Home: $($env:PASEO_HOME)
Models: $($env:PASEO_LOCAL_MODELS_DIR)
Daemon: localhost:6767
======================================================
"@
# Allow any origin in dev so Electron on random ports all work.
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
# the daemon binds to localhost and this script is never used for production.
# Build dependencies required by the daemon (they only ship dist/)
Write-Host "Building @getpaseo/highlight..."
npm run build --workspace=@getpaseo/highlight
Write-Host "Building @getpaseo/relay..."
npm run build --workspace=@getpaseo/relay
$env:PASEO_CORS_ORIGINS = "*"
# Configure the app to auto-connect to this daemon on localhost
$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6767"
$env:BROWSER = "none"
# Run both with concurrently
concurrently `
--names "daemon,metro" `
--prefix-colors "cyan,magenta" `
"npm run dev:server" `
"cd packages/app && npx expo start"
+65 -614
View File
@@ -41,20 +41,14 @@ Read user preferences:
cat ~/.paseo/orchestrate.json 2>/dev/null || echo '{}'
```
Merge with defaults for any missing fields. The file maps role categories to `<agent-type>/<model>` strings:
See [preferences.md](references/preferences.md) for schema, defaults, and mode resolution. Merge with defaults for any missing fields.
- The part before `/` is the `agentType` (e.g., `codex`, `claude`, `opencode`)
- The part after `/` is the `model` (e.g., `gpt-5.4`, `opus`)
If the user asks to store a preference at any point, update the file per the preferences reference.
| Category | Roles covered | Default |
|----------|--------------|---------|
| `impl` | impl, tester, refactorer | `codex/gpt-5.4` |
| `ui` | impl agents doing UI/styling work | `claude/opus` |
| `research` | researcher | `codex/gpt-5.4` |
| `planning` | planner, plan-reviewer | `codex/gpt-5.4` |
| `audit` | auditor, qa | `codex/gpt-5.4` |
Example models:
The file also has a `preferences` array of freeform natural language strings. Read these at startup and weave them into your behavior contextually. When the user says "store my preference: X", update the file.
- claude/opus
- codex/gpt-5.4
## Hard Rules
@@ -68,8 +62,6 @@ The file also has a `preferences` array of freeform natural language strings. Re
- **Never trust implementation agents at face value.** Always verify with separate auditor agents.
- **Never classify failures as "pre-existing."** If a test is failing, fix it or delete it.
- **The plan file on disk is the source of truth.** Re-read `~/.paseo/plans/<task-slug>.md` before every verification and QA phase. It survives compaction.
- **Never micromanage agents.** Describe the **problem** (what's broken, how it fails, the error output), not the **solution** (which line to change, what to change it to). Agents are smart — give them context and let them figure out the fix. If you find yourself writing specific line numbers or code snippets in an agent prompt, you're doing it wrong. Say "this test fails with this error" not "change line 47 to use X instead of Y."
- **Any task that touches tests MUST run those tests.** This is non-negotiable. If an agent modifies, fixes, or writes a test file, the prompt MUST explicitly say "run the test(s) and confirm they pass." Typecheck alone is never sufficient for test changes. An agent that changes a test without running it has not completed its task.
## Launching Agents
@@ -87,24 +79,6 @@ All agents are launched via the Paseo **create agent** tool. The standard patter
To send follow-up instructions: Paseo **send agent prompt**.
To archive: Paseo **archive agent**.
### How to Write Agent Prompts
**Describe the problem, not the solution.** Your prompt should tell the agent:
- What's wrong or what needs to be built (the goal)
- How it currently fails (error output, test output, user-visible behavior)
- The acceptance criteria (what "done" looks like)
**Do NOT tell the agent:**
- Which specific lines to change
- What code to write
- Which functions to call or which patterns to use
The agent reads the plan and the code. It will figure out the implementation. If you're writing specific line numbers or code snippets in the prompt, you're micromanaging and it will backfire — the agent takes you literally and skips its own judgment.
Bad: "In `new-workspace.spec.ts` at line 164, change the tab assertion from `getByText('New Agent')` to `getByTestId(/workspace-tab-agent_/)`"
Good: "The new-workspace E2E test is failing. The test creates a workspace via empty submit, but then the tab assertion fails because it looks for text 'New Agent' which doesn't match the actual tab label. Here's the error output: [paste error]. Fix the test and run it to confirm it passes."
---
## Worktree Mode
@@ -123,223 +97,51 @@ If `--worktree` is NOT set, skip this — work in the current directory as norma
default mode only default mode only
```
---
### Phase 1: Triage
## Phase 1: Triage
See [triage.md](references/triage.md).
Triage is fast and cheap. You do it yourself — no agents. The goal is to assess complexity order, which determines how many agents to deploy at each phase.
Assess complexity order (1-4) yourself. This is fast — grep relevant files, read the task, determine how many packages/modules are involved.
1. Read the task description
2. Grep the codebase for relevant files, types, and functions
3. Identify how many packages/modules are touched
4. Identify whether it's a new feature, refactor, bug fix, or architectural change
5. Assign a complexity order
State the order and why: "Order 3 — touches server session management and the app's git status display."
State the order and briefly why: "Order 3 — touches server session management and the app's git status display across two packages."
The order determines how many agents to deploy at each subsequent phase.
### Complexity Orders
### Phase 2: Grill (default mode only)
**Order 1 — Single file, single concern.** A contained change: fix a bug in one function, add a field to one type, update one component.
| Phase | Agents |
|-------|--------|
| Research | 1 researcher |
| Planning | 0 — orchestrator plans inline |
| Implement | 1 impl |
| Verify | 1-2 auditors |
| Cleanup | 0-1 refactorer |
**Order 2 — Single module, few files.** A feature or fix within one package that touches 3-8 files.
| Phase | Agents |
|-------|--------|
| Research | 2 researchers |
| Planning | 1 planner |
| Implement | 1 impl per phase |
| Verify | 2-3 auditors |
| Cleanup | 1 refactorer |
**Order 3 — Cross-module, multiple packages.** A feature that spans packages.
| Phase | Agents |
|-------|--------|
| Research | 3-4 researchers |
| Planning | 2 planners + 1 plan-reviewer |
| Implement | 1-2 impl agents per phase |
| Verify | 3-4 auditors |
| Cleanup | 1-2 refactorers |
**Order 4 — Architectural, system-wide.** A new subsystem, major refactor, or system-wide change.
| Phase | Agents |
|-------|--------|
| Research | 5+ researchers |
| Planning | 2+ planners + 2 plan-reviewers |
| Implement | 2+ impl agents per phase |
| Verify | Full auditor suite per phase |
| Cleanup | 2+ refactorers |
---
## Phase 2: Grill (default mode only)
See [grill.md](references/grill.md).
Skipped in `--auto` mode.
### Protocol: Research First, Grill Second
Research the codebase first to avoid asking questions the code can answer. Then question the user depth-first through the decision tree until all branches are resolved.
Before asking the user anything:
Conclude with a summary of resolved decisions. This feeds the research and planning phases.
1. Read the task description
2. Grep relevant files, types, functions
3. Read key files to understand the current state
4. Form your own understanding of the problem space
### Phase 3: Research
Then ask the user ONLY about things the code cannot answer: intent, scope boundaries, UX preferences, tradeoffs, priorities, acceptance criteria. Never ask a question the codebase could answer.
See [research-phase.md](references/research-phase.md).
### Questioning Approach
Deploy researchers in parallel based on complexity order. Each gets a narrow mandate — one area of the codebase, one external doc source, one reference project.
Treat the task as a decision tree. Each design choice branches into sub-decisions, constraints, and consequences.
- Ask one question at a time
- Wait for the answer before moving on
- Drill depth-first into each branch until it's resolved or explicitly deferred
- For each question, state your recommended answer based on what you've learned from the code — the user can confirm or override
- Cycle through question types: feasibility, dependency, edge case, alternative, scope, ordering, failure mode
Every 3-4 questions, summarize: resolved decisions, open branches, current focus.
Stop grilling when all branches are resolved, the user signals they're done, or no meaningful questions remain. Conclude with a final summary of all resolved decisions.
---
## Phase 3: Research
Deploy researchers to gather information before planning. Each researcher gets a narrow mandate — one area of the codebase, one external doc source, one reference project.
### Launching Researchers
```
title: "researcher-<scope>"
agentType: <resolved from providers.research>
model: <resolved from providers.research>
background: true
notifyOnFinish: true
initialPrompt: "You are a researcher.
Read the plan at ~/.paseo/plans/<task-slug>.md for the objective.
<specific research mandate>
Include in your findings: relevant files, types, interfaces, patterns, gotchas, and anything surprising. Do NOT suggest solutions or edit files."
```
Wait for all researchers to complete (you'll be notified). Use Paseo **get agent activity** to read their findings. Synthesize into a research summary that feeds the planning phase.
If findings raise new questions (default mode), go back and ask the user.
Wait for all researchers to complete (you'll be notified). Check their activity with Paseo **get agent activity** to read findings. If findings raise new questions (default mode), go back and ask the user.
Archive all researchers when done.
---
### Phase 4: Plan
## Phase 4: Plan
See [planning-phase.md](references/planning-phase.md).
Deploy planners to create an implementation plan informed by research findings.
Deploy planners informed by research findings. For Order 3+, deploy multiple planners and plan-reviewers. Iterate until the plan is solid.
### Refactor-First Thinking
Persist the final plan to `~/.paseo/plans/<task-slug>.md`.
Every planner prompt must emphasize this: the default agent instinct is to bolt new code on top of existing code. Resist this.
The right approach:
1. Study the existing code — understand why it's shaped the way it is
2. Design the target shape — what would the code look like if this feature had always existed?
3. Identify the refactoring gap — what needs to change so the new feature slots in cleanly?
4. Plan refactor phases before feature phases
If the plan has a phase called "wire up" or "connect" or "integrate," a refactor phase could probably eliminate the need for it.
### Launching Planners
```
title: "planner-<scope>"
agentType: <resolved from providers.planning>
model: <resolved from providers.planning>
background: true
notifyOnFinish: true
initialPrompt: "You are a planner.
Read the research findings provided below and the objective.
<paste synthesized research findings and objective>
Draft a phased implementation plan. Think refactor-first: before planning the feature, identify what existing code needs to be reshaped so the feature slots in naturally.
For each phase, specify:
- What changes and why
- Files involved
- Types and interfaces affected
- Tests to write (failing test first — TDD)
- Acceptance criteria for the phase
Write the plan to ~/.paseo/plans/<task-slug>.md"
```
### Launching Plan-Reviewers
```
title: "plan-reviewer-<scope>"
agentType: <resolved from providers.planning>
model: <resolved from providers.planning>
background: true
notifyOnFinish: true
initialPrompt: "You are a plan-reviewer.
Read the plan at ~/.paseo/plans/<task-slug>.md.
Challenge the plan:
- Is it bolting new code on top, or reshaping existing code first?
- Are there coordination/glue/bridge layers that a better refactor would eliminate?
- What edge cases are missing? What will break?
- What's over-engineered? What's under-specified?
- Is the phase ordering correct? Are there hidden dependencies?"
```
For Order 3+, deploy multiple planners (one per area) + plan-reviewers. Iterate until the plan-reviewer's only feedback is minor.
### Plan Structure
The final plan must follow:
```
# <Task Title>
## Objective
<one-paragraph summary>
## Acceptance Criteria
- [ ] <criterion 1>
- [ ] <criterion 2>
## Plan
### Phase 1: <name>
<description, files, types, tests, acceptance criteria>
### Phase 2: <name>
...
```
Persist to `~/.paseo/plans/<task-slug>.md`. Archive all planners and plan-reviewers.
---
## Phase 5: Approve (default mode only)
### Phase 5: Approve (default mode only)
Skipped in `--auto` mode.
Present the plan to the user. Wait for explicit confirmation before proceeding.
---
## Phase 6: Set Up
### Phase 6: Set Up
Persist the plan to disk and set up the heartbeat:
@@ -350,7 +152,7 @@ Use the Paseo **create schedule** tool with:
- `expiresIn`: `"4h"`
- `prompt`: (see heartbeat prompt below)
### Heartbeat prompt
#### Heartbeat prompt
```
HEARTBEAT — periodic self-check.
@@ -389,9 +191,9 @@ Do the following steps in order:
- Do NOT delete this schedule yet — if the user requests a PR, the heartbeat transitions to CI monitoring mode. Only delete it once CI is fully green (or if the user declines a PR).
```
---
### Phase 7: Implement
## Phase 7: Implement
See [impl-phase.md](references/impl-phase.md).
Execute phases from the plan sequentially. For each phase:
1. Launch impl agent(s) with `background: true, notifyOnFinish: true`
@@ -403,401 +205,52 @@ Execute phases from the plan sequentially. For each phase:
UI passes use `providers.ui` from preferences. All other impl work uses `providers.impl`.
### TDD — Not Optional
### Phase 8: Verify
Every impl agent works TDD:
1. Write a failing test that defines the expected behavior
2. Make it pass
3. Refactor if needed
4. All tests green — not just new ones, the full relevant suite
See [verification.md](references/verification.md).
If an impl agent finds a broken test, it fixes it. No "pre-existing failures." No exceptions.
After each implementation phase, deploy auditors in parallel. Match auditors to the type of work (refactor, feature, UI). Each auditor checks exactly one thing.
### Impl Agent Prompt Template
If auditors find issues, direct the impl agent to fix or launch a new one. Re-verify after fixes.
```
title: "impl-<scope>-<phase>"
agentType: <resolved from providers.impl>
model: <resolved from providers.impl>
cwd: <worktree-path if worktree mode, omit otherwise>
background: true
notifyOnFinish: true
initialPrompt: "You are an implementation engineer. [Load the e2e-playwright skill if frontend/E2E work.]
Archive all auditors when done.
Read the plan at ~/.paseo/plans/<task-slug>.md to understand the objective and your specific phase.
### Phase 9: Cleanup
Do not bolt new code on top of existing code. If the existing code isn't shaped to accommodate your work, reshape it first. The goal is code that looks like this feature always existed.
See [cleanup.md](references/cleanup.md).
Work TDD: write a failing test first, then make it pass. All tests must be green when done — not just your new ones, the full relevant suite. If you find a broken test, fix it.
After all phases are implemented and verified, deploy refactorers for a final sweep: DRY, dead code, naming. Run a regression auditor after cleanup to confirm nothing broke.
<describe the problem and acceptance criteria — NOT the solution>
Archive all refactorers when done.
When done: run typecheck AND run any tests you modified or that cover your changes. Both must pass. Do NOT commit."
```
### Phase 10: Final QA
### UI Agent Prompt Template
See [final-qa.md](references/final-qa.md).
```
title: "impl-<scope>-ui"
agentType: <resolved from providers.ui>
model: <resolved from providers.ui>
cwd: <worktree-path if worktree mode, omit otherwise>
background: true
notifyOnFinish: true
initialPrompt: "You are a UI engineer. [Load the e2e-playwright skill.]
Re-read the plan from disk. Run typecheck and tests yourself. Deploy final review and quality auditors. Fix any issues found. Do not deliver until everything passes.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Archive all QA agents when done.
The functionality is implemented. Your job is the styling pass:
- Study existing components and styles in nearby screens
- Follow existing conventions exactly — no new patterns
- Keep design minimal and consistent with the rest of the app
- Think carefully about spacing, alignment, and visual hierarchy
<describe the specific UI work>
Run typecheck when done. Do NOT commit."
```
### Handling Blockers
If an impl agent reports a blocker:
- Do NOT ask the user (in either mode)
- Spin up a researcher to investigate
- Spin up an impl agent to fix it
- The scope of work is unlimited — touching other files, packages, or systems is fine
Archive every impl agent as soon as its phase is verified.
---
## Phase 8: Verify
After every implementation phase, deploy auditors to verify the work. Auditors are read-only — they check, they don't fix. Each auditor has a single specialization.
### Which Auditors to Deploy
| Phase type | Auditors |
|-----------|----------|
| Refactor | `parity`, `regression`, `types` |
| Feature (backend) | `overeng`, `tests`, `regression`, `types` |
| Feature (frontend) | `overeng`, `tests`, `types`, `browser` (if applicable) |
| UI pass | `overeng`, `browser` (if applicable) |
| Test-only | `regression` |
Deploy all relevant auditors in parallel — they're read-only so they don't conflict.
### Auditor Prompts
All auditors are launched via the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`.
#### overeng (anti-over-engineering)
```
title: "auditor-<scope>-overeng"
initialPrompt: "You are an anti-over-engineering auditor.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Check the recent changes (use git diff) for:
- Unnecessary abstractions, helpers, or utility functions
- Defensive code for scenarios that can't happen
- Event emitters, observers, or pub/sub where a direct call would do
- Coordination/glue/bridge layers between old and new code
- Flag parameters or special-case branches
- Weird or overly literal naming
For each issue: file, line, what's wrong, what it should be instead.
Do NOT edit files."
```
#### dry (DRY violations)
```
title: "auditor-<scope>-dry"
initialPrompt: "You are a DRY auditor.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Check the recent changes (use git diff) for:
- Duplicated logic across files
- Copy-pasted code with minor variations
- Types that repeat fields from other types instead of deriving
- Constants or strings repeated instead of extracted
For each issue: the duplicated code locations and a brief note on how to consolidate.
Do NOT edit files."
```
#### tests (test coverage)
```
title: "auditor-<scope>-tests"
initialPrompt: "You are a test coverage auditor. [Load the e2e-playwright skill if E2E tests are in scope.]
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Check:
- Does every new behavior have a test?
- Do tests verify behavior, not implementation details?
- Are tests asserting real outcomes or just mocks?
- Are there edge cases without test coverage?
- Do E2E tests follow DSL-style helpers and ARIA role selectors (if applicable)?
Run the full relevant test suite and report output.
Do NOT edit files."
```
#### regression
```
title: "auditor-<scope>-regression"
initialPrompt: "You are a regression auditor.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Run the full test suite. Report:
- Total tests, passed, failed, skipped
- Any failures with full error output
- Whether failures are in new tests or existing tests
If ANY test fails, this phase is not done.
Do NOT edit files."
```
#### types
```
title: "auditor-<scope>-types"
initialPrompt: "You are a type auditor.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Run typecheck (npm run typecheck). Report:
- Pass/fail
- All type errors with file, line, and error message
- Any use of 'any', type assertions, or @ts-ignore in the changes
Do NOT edit files."
```
#### browser
```
title: "auditor-<scope>-browser"
initialPrompt: "You are a browser QA auditor. Load the e2e-playwright skill.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Test the affected user flows in a browser:
- Navigate to the relevant screens
- Exercise the new/changed functionality
- Check for visual regressions, broken layouts, missing states
- Take screenshots of results
Report what works and what doesn't with evidence. Do NOT edit files."
```
#### parity (for refactors)
```
title: "auditor-<scope>-parity"
initialPrompt: "You are a parity auditor.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
This was a refactoring phase — behavior must be identical before and after. Check:
- All existing tests still pass (run them)
- No behavioral changes were introduced
- Public APIs and interfaces are unchanged
- No removed functionality unless explicitly planned
Do NOT edit files."
```
### Interpreting Findings
If any auditor reports issues:
1. Check the auditor's activity with Paseo **get agent activity** for details
2. Direct the impl agent to fix them via Paseo **send agent prompt**, or launch a new impl agent if the old one is stale
3. Re-deploy the same auditor after fixes
4. Do not proceed to the next phase until all auditors pass
Archive every auditor as soon as its report is reviewed.
---
## Phase 9: Cleanup
After all implementation phases are verified, deploy refactorer agents for targeted cleanup. Each refactorer has a single specialization.
### Refactorer Prompts
All refactorers launched via the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`.
#### dry (consolidate duplication)
```
title: "refactorer-<scope>-dry"
initialPrompt: "You are a cleanup engineer specializing in DRY.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Look at the full diff of changes in this task (use git diff). Consolidate:
- Duplicated logic — extract shared functions or reuse existing ones
- Repeated types — derive with Pick, Omit, or extend instead of redefining
- Repeated constants or strings — extract to a single source
Only fix genuine duplication. Three similar lines is fine — don't create premature abstractions. Run typecheck and any tests you touch when done.
Do NOT commit."
```
#### dead-code (remove unused code)
```
title: "refactorer-<scope>-dead-code"
initialPrompt: "You are a cleanup engineer specializing in dead code.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Look at the full diff of changes (use git diff). Remove:
- Unused imports
- Unused variables, functions, or types introduced by this task
- Commented-out code
- Backwards-compatibility shims or renamed _vars that serve no purpose
Do NOT remove code that predates this task unless it was made dead by this task's changes. Run typecheck and any tests you touch when done.
Do NOT commit."
```
#### naming (fix unclear names)
```
title: "refactorer-<scope>-naming"
initialPrompt: "You are a cleanup engineer specializing in naming.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Look at all new names introduced by this task (functions, variables, types, files). Fix:
- Overly literal or verbose names
- Inconsistent naming relative to surrounding code conventions
- Unclear abbreviations
- Names that describe implementation instead of intent
Only rename things introduced or modified by this task. Run typecheck and any tests you touch when done.
Do NOT commit."
```
Deploy refactorers in parallel. After cleanup, run a regression auditor to confirm nothing broke.
Archive every refactorer as soon as verified.
---
## Phase 10: Final QA
After all phases are implemented, verified, and cleaned up, run one final pass.
### 1. Re-read the plan
```bash
cat ~/.paseo/plans/<task-slug>.md
```
### 2. Run typecheck yourself
```bash
npm run typecheck
```
Must pass. No exceptions.
### 3. Run the full test suite yourself
Run all relevant tests. Must be 100% green. No skipped tests, no "known failures."
### 4. Final review agent
```
title: "qa-<scope>-review"
initialPrompt: "You are a final reviewer.
Read the plan at ~/.paseo/plans/<task-slug>.md for the objective and acceptance criteria.
Review the entire git diff for this task. For each acceptance criterion, report:
- YES — met, with evidence (file, line, test that proves it)
- NO — not met, with explanation of what's missing
Do NOT edit files."
```
### 5. Final anti-over-engineering agent
```
title: "qa-<scope>-overeng"
initialPrompt: "You are a final quality auditor.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Audit the entire git diff for this task:
- Unnecessary abstractions or helpers
- Code that's clever instead of clear
- Missing error handling at system boundaries
- Excessive error handling for internal code
- Any code that doesn't serve the acceptance criteria
Do NOT edit files."
```
### 6. Browser QA (if applicable)
If the task involves UI changes:
```
title: "qa-<scope>-browser"
initialPrompt: "You are a QA engineer. Load the e2e-playwright skill.
Read the plan at ~/.paseo/plans/<task-slug>.md for context.
Test all affected user flows end-to-end in the browser. For each flow:
- What you tested
- What you expected
- What actually happened
- Screenshot evidence
Do NOT edit files."
```
If any final QA agent reports issues, launch an impl or refactorer to fix, then re-run the specific check. Do not deliver with any failing checks.
Archive all QA agents once reports are reviewed.
---
## Phase 11: Deliver
### Phase 11: Deliver
1. Archive any remaining implementation/QA agents
2. **If in worktree mode:**
- Report the worktree path and branch name
- Ask: "The work is in worktree `<worktree-path>` on branch `orchestrate/<task-slug>`. Should I merge it into your current branch, create a PR, or leave the worktree for you to review?"
- Do NOT remove the worktree automatically
- Do NOT remove the worktree automatically — the user decides what to do with it
3. **If NOT in worktree mode:**
- Report: what was done (high-level), what files changed, verification results
- Ask: "Should I commit this? Create a PR? Or leave it uncommitted for you to review?"
- Report to the user:
- What was done (high-level)
- What files changed
- Verification results (typecheck, tests, auditor verdicts)
- Ask: "Should I commit this? Create a PR? Or leave it uncommitted for you to review?"
Wait for the user's instruction.
**When the user asks for a PR, the job is NOT done when the PR is created.** The objective is: PR created AND all CI checks passing. After creating the PR:
1. Keep the heartbeat schedule running — do NOT delete it yet.
2. Update the heartbeat prompt to CI monitoring mode (below).
2. Update the heartbeat prompt to include CI monitoring (see below).
3. Monitor CI status via `gh pr checks <pr-number> --watch` or `gh pr checks <pr-number>`.
4. If any check fails:
- Read the failure logs (`gh run view <run-id> --log-failed`).
@@ -806,9 +259,11 @@ Wait for the user's instruction.
- Continue monitoring.
5. Only when ALL checks are green:
- Delete the heartbeat schedule.
- Report to the user with the full PR URL.
- Report to the user with the full PR URL (e.g. `https://github.com/owner/repo/pull/N`), not just a number.
### Post-PR heartbeat prompt
#### Post-PR heartbeat prompt
After creating the PR, update the heartbeat schedule prompt to:
```
HEARTBEAT — CI monitoring for PR #<pr-number>.
@@ -835,25 +290,22 @@ Do the following steps in order:
---
## Roles Reference
## Role Reference
Every agent has exactly one role. The role determines what the agent does, whether it can edit files, and how it's named.
See [roles.md](references/roles.md) for the complete role definitions, naming convention, and what each role can and cannot do.
**Naming:** `<role>-<scope>[-<specialization>]` in kebab-case.
| Role | Job | Edits? |
|------|-----|--------|
| `researcher` | Gathers info: codebase, docs, web, scripts | No |
| `planner` | Creates implementation plan from research | No |
| `plan-reviewer` | Adversarially challenges a plan | No |
| `impl` | Writes code, TDD | Yes |
| `tester` | Writes/runs tests | Yes |
| `auditor` | Read-only verification (sub-specializations) | No |
| `refactorer` | Targeted cleanup (sub-specializations) | Yes |
| `qa` | End-to-end QA, browser testing | No |
| Role | Job | Edits? | Prompt emphasis |
|------|-----|--------|----------------|
| `researcher` | Gathers info: codebase, docs, web, scripts | No | "Report what you find. Do not suggest solutions. Do not edit files." |
| `planner` | Creates implementation plan from research | No | "Think refactor-first. Design the target shape, not the steps." |
| `plan-reviewer` | Adversarially challenges a plan | No | "Challenge the plan. Find what's wrong, missing, or over-engineered." |
| `impl` | Writes code, works TDD | Yes | "Work TDD. Reshape existing code. Run typecheck AND run any tests you modified. Both must pass. Do NOT commit." |
| `tester` | Writes/fixes tests | Yes | "Verify behavior, not implementation. Run every test you modified and confirm it passes. A test change without running the test is not done." |
| `auditor` | Read-only verification | No | "Check [specialization]. Report YES/NO with evidence. Do NOT edit files." |
| `refactorer` | Targeted cleanup | Yes | "Fix [specialization] only. Run typecheck and any tests you touch. Do NOT commit." |
| `qa` | End-to-end QA, browser testing | No | "Test the actual user experience. Report with evidence." |
Auditor specializations: `overeng`, `dry`, `tests`, `regression`, `types`, `browser`, `parity`
Refactorer specializations: `dry`, `dead-code`, `naming`
Naming: `<role>-<scope>[-<specialization>]`
---
@@ -867,4 +319,3 @@ Refactorer specializations: `dry`, `dead-code`, `naming`
- **The plan file is the shared context.** Every agent reads the plan from disk.
- **Archive aggressively.** Done agents clutter the UI.
- **Trust but verify.** Always verify with separate agents. Never take an impl agent's word for it.
- **Describe problems, not solutions.** Tell agents what's wrong, not what to type.
@@ -26,26 +26,6 @@ After each phase:
Use the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`. **If in worktree mode, always set `cwd` to the worktree path.**
### How to describe the work
**Describe the problem, not the solution.** Your prompt should tell the agent:
- What's wrong or what needs to be built (the goal)
- How it currently fails (error output, test output, user-visible behavior)
- The acceptance criteria (what "done" looks like)
**Do NOT tell the agent:**
- Which specific lines to change
- What code to write
- Which functions to call or which patterns to use
The agent reads the plan and the code. It will figure out the implementation. If you're writing specific line numbers or code snippets in the prompt, you're micromanaging and it will backfire — the agent takes you literally and skips its own judgment.
Bad: "In `new-workspace.spec.ts` at line 164, change the tab assertion from `getByText('New Agent')` to `getByTestId(/workspace-tab-agent_/)`"
Good: "The new-workspace E2E test is failing. The test creates a workspace via empty submit, but then the tab assertion fails because it looks for text 'New Agent' which doesn't match the actual tab label. Here's the error output: [paste error]. Fix the test and run it to confirm it passes."
### Prompt template
```
title: "impl-<scope>-<phase>"
agentType: <resolved from providers.impl>
@@ -61,9 +41,9 @@ Do not bolt new code on top of existing code. If the existing code isn't shaped
Work TDD: write a failing test first, then make it pass. All tests must be green when done — not just your new ones, the full relevant suite. If you find a broken test, fix it.
<describe the problem and acceptance criteria — NOT the solution>
<describe the specific phase work and acceptance criteria>
When done: run typecheck AND run any tests you modified or that cover your changes. Both must pass. Do NOT commit."
Run typecheck and tests when done. Do NOT commit."
```
## UI Passes
+3 -3
View File
@@ -37,17 +37,17 @@ Adversarially challenges a plan. Looks for: bolted-on code vs natural fit, missi
### impl
Writes code. Works TDD: failing test first, then make it pass. Runs typecheck and all modified/related tests when done.
Writes code. Works TDD: failing test first, then make it pass. Runs typecheck and tests when done.
- **Edits files:** Yes
- **Prompt emphasis:** "Work TDD. Do not bolt new code on top — reshape existing code so the feature slots in naturally. Run typecheck AND run any tests you modified or that cover your changes when done. Both must pass. Do NOT commit."
- **Prompt emphasis:** "Work TDD. Do not bolt new code on top — reshape existing code so the feature slots in naturally. Run typecheck and tests when done. Do NOT commit."
### tester
Writes or fixes tests specifically. Used when test work is substantial enough to warrant a dedicated agent separate from impl.
- **Edits files:** Yes
- **Prompt emphasis:** "Write tests that verify behavior, not implementation details. Run every test you modified and confirm it passes. A test change without running the test is not done."
- **Prompt emphasis:** "Write tests that verify behavior, not implementation details. Run the full relevant suite when done."
### auditor
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: ["**/.claude/**"],
},
});