Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e529c3601f fix(server): drop Claude permission skip allowance
Fixes #1582.
2026-06-20 13:45:42 +07:00
96 changed files with 1258 additions and 4999 deletions
-29
View File
@@ -1,34 +1,5 @@
# Changelog
## 0.1.98 - 2026-06-21
### Added
- See plan usage in-app for Claude, Codex, Copilot, Cursor, Z.AI, Grok, and Kimi ([#1278](https://github.com/getpaseo/paseo/pull/1278) by [@ABorakati](https://github.com/ABorakati))
- Added Ultracode for Claude ([#1625](https://github.com/getpaseo/paseo/pull/1625))
- Detach a subagent to run it on its own ([#1612](https://github.com/getpaseo/paseo/pull/1612))
- Add a project without creating a workspace
- Add a setting to show branch names instead of titles in the sidebar
### Improved
- Mid-turn thinking and mode changes now say they apply next turn
- PR merge options name their method: squash, merge, or rebase ([#1608](https://github.com/getpaseo/paseo/pull/1608) by [@mcowger](https://github.com/mcowger))
- A running agent's mode change is remembered for new agents
- Copy a provider's launch diagnostic in one tap ([#1611](https://github.com/getpaseo/paseo/pull/1611))
### Fixed
- OpenCode no longer scans your whole disk on macOS desktop ([#1626](https://github.com/getpaseo/paseo/pull/1626))
- Daemon no longer crashes when OpenAI speech has no API key ([#1368](https://github.com/getpaseo/paseo/pull/1368) by [@mcowger](https://github.com/mcowger))
- Reopening an archived Codex agent no longer hangs
- Claude's context meter no longer jumps to subagent usage
- Claude's context meter fills from the first message in a new session
- OpenCode's mode picker now respects your disabled modes ([#1366](https://github.com/getpaseo/paseo/pull/1366) by [@mcowger](https://github.com/mcowger))
- File links and @-mentions find files in dot-folders and deep paths ([#1609](https://github.com/getpaseo/paseo/pull/1609))
- Archiving a project's last workspace no longer makes it vanish ([#1631](https://github.com/getpaseo/paseo/pull/1631))
- Collapsed sidebar projects stay collapsed
## 0.1.97 - 2026-06-18
### Added
+11 -18
View File
@@ -14,23 +14,16 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
- `relationship` decides whether the new agent belongs under the caller.
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
## Archive
@@ -113,11 +106,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.
+1 -4
View File
@@ -27,9 +27,6 @@ $PASEO_HOME/
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
└── push-tokens.json # Expo push notification tokens
```
@@ -54,7 +51,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
-8
View File
@@ -38,12 +38,6 @@ Draft metadata lookups should avoid creating provider sessions when the upstream
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
## Provider Helper Processes
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
---
## Provider Snapshot Refresh Contract
@@ -73,8 +67,6 @@ To add plan usage for a provider, add `packages/server/src/services/quota-fetche
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
---
## ACP Provider Checklist
-1
View File
@@ -130,7 +130,6 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
## Agent authentication in tests
+46 -72
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.98",
"version": "0.1.97",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.98",
"version": "0.1.97",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -11207,8 +11207,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
@@ -11221,8 +11220,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
@@ -11235,8 +11233,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
@@ -11249,8 +11246,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
@@ -11263,8 +11259,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
@@ -11277,8 +11272,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
@@ -11291,8 +11285,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
@@ -11305,8 +11298,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
@@ -11319,8 +11311,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
@@ -11333,8 +11324,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
@@ -11347,8 +11337,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
@@ -11361,8 +11350,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
@@ -11375,8 +11363,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
@@ -11389,8 +11376,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
@@ -11403,8 +11389,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
@@ -11417,8 +11402,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
@@ -11431,8 +11415,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
@@ -11445,8 +11428,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
@@ -11459,8 +11441,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
@@ -11473,8 +11454,7 @@
"optional": true,
"os": [
"openbsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
@@ -11487,8 +11467,7 @@
"optional": true,
"os": [
"openharmony"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
@@ -11501,8 +11480,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
@@ -11515,8 +11493,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
@@ -11529,8 +11506,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
@@ -11543,8 +11519,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
@@ -18272,7 +18247,6 @@
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -35243,7 +35217,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.98",
"version": "0.1.97",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -35566,12 +35540,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.98",
"version": "0.1.97",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.98",
"@getpaseo/protocol": "0.1.98",
"@getpaseo/server": "0.1.98",
"@getpaseo/client": "0.1.97",
"@getpaseo/protocol": "0.1.97",
"@getpaseo/server": "0.1.97",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35817,10 +35791,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.98",
"version": "0.1.97",
"dependencies": {
"@getpaseo/protocol": "0.1.98",
"@getpaseo/relay": "0.1.98",
"@getpaseo/protocol": "0.1.97",
"@getpaseo/relay": "0.1.97",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -35831,7 +35805,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.98",
"version": "0.1.97",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36074,7 +36048,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.98",
"version": "0.1.97",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -36970,7 +36944,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.98",
"version": "0.1.97",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37201,7 +37175,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.98",
"version": "0.1.97",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37213,7 +37187,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.98",
"version": "0.1.97",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37431,15 +37405,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.98",
"version": "0.1.97",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.98",
"@getpaseo/highlight": "0.1.98",
"@getpaseo/protocol": "0.1.98",
"@getpaseo/relay": "0.1.98",
"@getpaseo/client": "0.1.97",
"@getpaseo/highlight": "0.1.97",
"@getpaseo/protocol": "0.1.97",
"@getpaseo/relay": "0.1.97",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -37848,7 +37822,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.98",
"version": "0.1.97",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.98",
"version": "0.1.97",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+6 -4
View File
@@ -12,7 +12,7 @@ import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-scroll-",
model: "one-minute-stream",
prompt: "Stream for auto-scroll test.",
@@ -21,13 +21,14 @@ test.describe("Agent stream UI", () => {
await awaitAssistantMessage(page);
await expectScrollFollowsNewContent(page);
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-indicator-",
model: "ten-second-stream",
prompt: "Stream briefly for indicator transition test.",
@@ -38,7 +39,8 @@ test.describe("Agent stream UI", () => {
await expectAgentIdle(page, 30_000);
await expectTurnCopyButton(page);
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
-17
View File
@@ -26,26 +26,15 @@ import {
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let projectId: string;
let workspaceId: string;
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectSeedClient();
const created = await client.createWorkspace({
source: { kind: "directory", path: tempRepo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
}
projectId = created.workspace.projectId;
workspaceId = created.workspace.id;
});
test.afterAll(async () => {
await client?.removeProject(projectId).catch(() => undefined);
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
@@ -53,12 +42,10 @@ test.describe("Archive tab reconciliation", () => {
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -94,12 +81,10 @@ test.describe("Archive tab reconciliation", () => {
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -126,12 +111,10 @@ test.describe("Archive tab reconciliation", () => {
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
});
@@ -193,7 +193,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-queue-",
model: "one-minute-stream",
prompt: "Stay running for queue test.",
@@ -205,7 +205,8 @@ test.describe("Composer attachments", () => {
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
@@ -213,7 +214,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-interrupt-",
model: "ten-second-stream",
prompt: "Stay running for interrupt test.",
@@ -225,7 +226,8 @@ test.describe("Composer attachments", () => {
await expectAgentIdle(page, 15_000);
await expectComposerDraft(page, "preserve me");
} finally {
await agent.cleanup();
await client.close();
await repo.cleanup();
}
});
@@ -4,7 +4,7 @@ import { gotoAppShell } from "./helpers/app";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
@@ -66,13 +66,6 @@ async function addProjectFromPicker(page: Page, projectPath: string): Promise<st
return testId!.replace("sidebar-project-row-", "");
}
async function waitForSidebarProjectListReady(page: Page): Promise<void> {
await page
.locator('[data-testid="sidebar-project-empty-state"], [data-testid^="sidebar-project-row-"]')
.first()
.waitFor({ state: "visible", timeout: 60_000 });
}
// Projects are parents in the sidebar. Archiving the last workspace leaves the
// project row in place with a ghost "+ New workspace" child row.
test.describe("Project with no workspaces persists", () => {
@@ -83,7 +76,7 @@ test.describe("Project with no workspaces persists", () => {
try {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await waitForNoProjectsInSidebar(page);
projectId = await addProjectFromPicker(page, repo.path);
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
@@ -99,7 +92,7 @@ test.describe("Project with no workspaces persists", () => {
expect(workspaces.entries).toEqual([]);
} finally {
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
await removeProjectFromSidebar(page, projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
@@ -170,7 +163,7 @@ test.describe("Project remove", () => {
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
await page.reload();
await waitForSidebarProjectListReady(page);
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readded = await workspace.client.addProject(workspace.repoPath);
@@ -186,6 +179,7 @@ test.describe("Project remove", () => {
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await removeProjectFromSidebar(page, workspace.projectId).catch(() => undefined);
await workspace.cleanup();
}
});
+13 -3
View File
@@ -35,6 +35,10 @@ function buildSeededStoragePayload() {
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
createWorkspace(input: { source: { kind: "directory"; path: string } }): Promise<{
workspace: { id: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
model: string;
@@ -52,14 +56,20 @@ export interface IdleAgentSeedClient {
export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: input.cwd },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
}
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: input.workspaceId,
workspaceId: createdWorkspace.workspace.id,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -74,7 +84,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: input.workspaceId,
workspaceId: createdWorkspace.workspace.id,
};
}
+5 -21
View File
@@ -149,7 +149,6 @@ export async function selectGithubOption(
export interface MockAgentSetup {
client: SeedDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
cleanup: () => Promise<void>;
}
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
@@ -167,29 +166,20 @@ export async function startRunningMockAgent(
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
}
const workspace = createdWorkspace.workspace;
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
workspaceId: workspace.id,
workspaceId: createdWorkspace.workspace.id,
model: opts.model,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, createdWorkspace.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
return {
client,
repo,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
};
return { client, repo };
}
export interface GithubWorkspaceHandle {
@@ -208,14 +198,8 @@ export async function openGithubWorkspace(
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
}
const workspace = createdWorkspace.workspace;
await gotoAppShell(page);
await selectWorkspaceInSidebar(page, workspace.id);
await selectWorkspaceInSidebar(page, createdWorkspace.workspace.id);
await waitForTabBar(page);
return {
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
return { cleanup: () => client.close().catch(() => undefined) };
}
@@ -18,7 +18,6 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "patchDaemonConfig"
| "removeProject"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
-3
View File
@@ -14,7 +14,6 @@ export interface AgentHandle {
page: Page;
client: SeedDaemonClient;
agentId: string;
projectId: string;
workspaceId: string;
cwd: string;
provider: RewindFlowProvider;
@@ -188,7 +187,6 @@ export async function launchAgent(input: {
page: input.page,
client,
agentId: agent.id,
projectId: createdWorkspace.workspace.projectId,
workspaceId: createdWorkspace.workspace.id,
cwd: input.cwd,
provider: input.provider,
@@ -198,7 +196,6 @@ export async function launchAgent(input: {
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.removeProject(handle.projectId).catch(() => undefined);
await handle.client.close().catch(() => undefined);
}
+5 -8
View File
@@ -30,7 +30,6 @@ export interface SeedDaemonClient {
} | null;
error: string | null;
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
@@ -200,17 +199,15 @@ export async function seedWorkspace(options: {
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
return {
client,
repoPath: project.path,
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceId: created.workspace.id,
workspaceName: created.workspace.name,
workspaceDirectory: created.workspace.workspaceDirectory,
projectId: created.workspace.projectId,
projectDisplayName: created.workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
},
-5
View File
@@ -28,18 +28,15 @@ function sleep(ms: number): Promise<void> {
export class TerminalE2EHarness {
readonly client: SeedDaemonClient;
readonly tempRepo: TempRepo;
readonly projectId: string;
readonly workspaceId: string;
private constructor(input: {
client: SeedDaemonClient;
tempRepo: TempRepo;
projectId: string;
workspaceId: string;
}) {
this.client = input.client;
this.tempRepo = input.tempRepo;
this.projectId = input.projectId;
this.workspaceId = input.workspaceId;
}
@@ -57,13 +54,11 @@ export class TerminalE2EHarness {
return new TerminalE2EHarness({
client,
tempRepo,
projectId: seedResult.workspace.projectId,
workspaceId: seedResult.workspace.id,
});
}
async cleanup(): Promise<void> {
await this.client.removeProject(this.projectId).catch(() => {});
await this.client.close().catch(() => {});
await this.tempRepo.cleanup().catch(() => {});
}
@@ -37,7 +37,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
let client: WorkspaceSetupDaemonClient | null = null;
const repos: Array<{ cleanup: () => Promise<void> }> = [];
const worktrees: WorktreeRecord[] = [];
const projectIds = new Set<string>();
const withWorkspace: WithWorkspace = async (options) => {
if (!client) {
@@ -65,7 +64,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
if (!added.project) {
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
}
projectIds.add(added.project.projectId);
}
const created = await client.createWorkspace({
@@ -75,7 +73,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
}
const workspaceId = created.workspace.id;
projectIds.add(created.workspace.projectId);
return {
workspaceId,
@@ -91,11 +88,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
return {
withWorkspace,
cleanup: async () => {
if (client) {
for (const projectId of projectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
}
for (const { repoPath, worktreePath } of worktrees) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
@@ -19,7 +19,6 @@ type WorkspaceSetupDaemonClient = Pick<
| "fetchAgents"
| "fetchWorkspaces"
| "listTerminals"
| "removeProject"
| "subscribeRawMessages"
>;
@@ -1,220 +0,0 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { openAgentRoute } from "./helpers/mock-agent";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { escapeRegex } from "./helpers/regex";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
};
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
await page.addInitScript(
({ preferencesKey, serverId: seededServerId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId: seededServerId,
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4-mini",
mode: "auto",
thinkingByModel: {
"gpt-5.4-mini": "low",
},
},
mock: {
model: "ten-second-stream",
},
},
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
);
}
async function readCodexModePreference(page: Page): Promise<unknown> {
return page.evaluate((preferencesKey) => {
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as {
providerPreferences?: Record<string, { mode?: unknown }>;
};
return parsed.providerPreferences?.codex?.mode ?? null;
}, CREATE_AGENT_PREFERENCES_KEY);
}
async function selectMode(page: Page, label: string): Promise<void> {
const modeControl = page.getByTestId("mode-control").first();
await expect(modeControl).toBeVisible({ timeout: 30_000 });
await modeControl.click();
const searchInput = page.getByRole("textbox", { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(label);
const option = page
.getByRole("dialog")
.last()
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
.first();
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click({ force: true });
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
}
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const createAgentSeen = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => {
ws.send(message);
});
});
return {
waitForCreateAgentRequest: () => createAgentSeen,
};
}
test.describe("New workspace Codex mode preferences", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
page,
}) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
await seedCodexDefaultPermissionPreferences(page, serverId);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
expect(createAgentRequest.config).toMatchObject({
provider: "codex",
modeId: "full-access",
});
await expect
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
.toBe("full-access");
} finally {
await seeded.cleanup();
}
});
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
await seedCodexDefaultPermissionPreferences(page, serverId);
try {
const agent = await seeded.client.createAgent({
provider: "codex",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Codex live mode preference e2e",
modeId: "auto",
model: "gpt-5.4-mini",
});
await openAgentRoute(page, {
workspaceId: seeded.workspaceId,
agentId: agent.id,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
timeout: 30_000,
});
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
timeout: 30_000,
});
} finally {
await seeded.cleanup();
}
});
});
+1 -1
View File
@@ -52,7 +52,7 @@ interface WorkspaceStatusGroupEvent {
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
}
@@ -54,12 +54,10 @@ test.describe("Settings toggle tab regression", () => {
try {
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-b-${Date.now()}`,
});
@@ -97,12 +95,10 @@ test.describe("Settings toggle tab regression", () => {
try {
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-b-${Date.now()}`,
});
+1 -1
View File
@@ -127,7 +127,7 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
// Switch to status grouping.
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
@@ -34,7 +34,6 @@ test.describe("Workspace agent tab rename", () => {
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: initialTitle,
});
@@ -72,7 +72,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
timeout: 30_000,
@@ -390,7 +390,7 @@ async function expectWorkspaceRowInStatusBucket(
page: Page,
input: { serverId: string; workspaceId: string; bucket: string },
) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(
page
@@ -204,7 +204,6 @@ test.describe("Workspace navigation regression", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-reconnect-${Date.now()}`,
});
@@ -307,7 +306,6 @@ test.describe("Workspace navigation regression", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-refresh-route-${Date.now()}`,
});
await injectDesktopBridge(page, {
@@ -346,12 +344,10 @@ test.describe("Workspace navigation regression", () => {
try {
const firstAgent = await createIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: `workspace-nav-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: `workspace-nav-b-${Date.now()}`,
});
@@ -18,7 +18,6 @@ test.describe("Workspace pane mounting", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `pane-remount-${Date.now()}`,
});
@@ -20,7 +20,6 @@ test.describe("Worktree restore after daemon restart", () => {
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
const createdProjectIds = new Set<string>();
test.describe.configure({ retries: 0, timeout: 180_000 });
@@ -35,10 +34,6 @@ test.describe("Worktree restore after daemon restart", () => {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
for (const projectId of createdProjectIds) {
await worktreeClient.removeProject(projectId).catch(() => undefined);
}
createdProjectIds.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
@@ -54,18 +49,15 @@ test.describe("Worktree restore after daemon restart", () => {
// the History table cells must show after restore — never "main".
const worktreeSlug = `restart-restore-${randomUUID().slice(0, 8)}`;
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: worktreeSlug,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restart-restore-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
+2 -13
View File
@@ -25,7 +25,6 @@ test.describe("Worktree restore", () => {
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
const createdProjectIds = new Set<string>();
test.describe.configure({ timeout: 120_000 });
@@ -40,10 +39,6 @@ test.describe("Worktree restore", () => {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
for (const projectId of createdProjectIds) {
await worktreeClient.removeProject(projectId).catch(() => undefined);
}
createdProjectIds.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
@@ -53,18 +48,15 @@ test.describe("Worktree restore", () => {
page,
}) => {
const serverId = getServerId();
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
@@ -98,18 +90,15 @@ test.describe("Worktree restore", () => {
page,
}) => {
const serverId = getServerId();
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.98",
"version": "0.1.97",
"private": true,
"main": "index.ts",
"scripts": {
@@ -9,25 +9,23 @@ import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
maxTokens: number | null;
usedTokens: number | null;
maxTokens: number;
usedTokens: number;
totalCostUsd?: number | null;
showPercentage?: boolean;
serverId?: string;
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
}
const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 12;
const SVG_SIZE = 16;
const COMPACT_SVG_SIZE = 14;
const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 6;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const RADIUS = 7;
const COMPACT_RADIUS = 6;
const STROKE_WIDTH = 2.25;
const COMPACT_STROKE_WIDTH = 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
@@ -102,7 +100,6 @@ export function ContextWindowMeter({
showPercentage = false,
serverId,
provider,
pending = false,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -111,8 +108,7 @@ export function ContextWindowMeter({
serverId ?? null,
{ enabled: isTooltipOpen },
);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const percentage = getUsagePercentage(maxTokens, usedTokens);
const handleTooltipOpenChange = useCallback(
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
@@ -123,42 +119,14 @@ export function ContextWindowMeter({
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
// no usage is expected.
if (percentage === null || maxTokens === null || usedTokens === null) {
if (!pending) {
return null;
}
return (
<View style={geometry.containerStyle}>
<Svg
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={geometry.center}
cy={geometry.center}
r={geometry.radius}
fill="none"
stroke={theme.colors.surface3}
strokeWidth={geometry.strokeWidth}
/>
</Svg>
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
</View>
);
if (percentage === null) {
return null;
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } =
getMeterGeometry(showPercentage);
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost =
@@ -262,14 +230,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
skeletonLabel: {
width: 22,
height: theme.fontSize.sm,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
tooltipContent: {
gap: theme.spacing[1.5],
gap: theme.spacing[1],
minWidth: 200,
},
tooltipTitle: {
+3 -3
View File
@@ -35,7 +35,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -213,7 +213,7 @@ export const LeftSidebar = memo(function LeftSidebar({
enabled: isCompactLayout || isOpen,
});
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel({ projects });
useSidebarShortcutModel({ projects, isInitialLoad });
const groupMode = useSidebarViewStore((state) =>
activeServerId ? state.getGroupMode(activeServerId) : "project",
@@ -1096,7 +1096,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu serverId={serverId} />
<SidebarGroupingSelector serverId={serverId} />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
@@ -8,11 +8,9 @@ import {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -22,25 +20,11 @@ const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [
{ value: "status", label: "Status" },
];
const WORKSPACE_TITLE_SOURCE_ITEMS: Array<{ value: WorkspaceTitleSource; label: string }> = [
{ value: "title", label: "Title" },
{ value: "branch", label: "Branch name" },
];
interface DisplayPreferenceOption<Value extends string> {
value: Value;
label: string;
}
export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string | null }) {
export function SidebarGroupingSelector({ serverId }: { serverId: string | null }) {
const groupMode = useSidebarViewStore((state) =>
serverId ? state.getGroupMode(serverId) : "project",
);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const {
settings: { workspaceTitleSource },
updateSettings,
} = useAppSettings();
const handleSelect = useCallback(
(mode: SidebarGroupMode) => {
@@ -50,13 +34,6 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
[serverId, setGroupMode],
);
const handleWorkspaceTitleSourceSelect = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
},
[updateSettings],
);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
@@ -70,61 +47,45 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
<DropdownMenuTrigger
style={triggerStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Display preferences"
testID="sidebar-display-preferences-menu"
accessibilityLabel="Sidebar grouping"
testID="sidebar-grouping-selector"
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={180} testID="sidebar-display-preferences-content">
<DropdownMenuContent align="start" width={180} testID="sidebar-grouping-menu">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
{GROUP_MODE_ITEMS.map((item) => (
<DisplayPreferenceMenuItem
<GroupModeMenuItem
key={item.value}
item={item}
isSelected={groupMode === item.value}
testIDPrefix="sidebar-grouping"
onSelect={handleSelect}
/>
))}
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
</View>
{WORKSPACE_TITLE_SOURCE_ITEMS.map((item) => (
<DisplayPreferenceMenuItem
key={item.value}
item={item}
isSelected={workspaceTitleSource === item.value}
testIDPrefix="sidebar-workspace-title-source"
onSelect={handleWorkspaceTitleSourceSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function DisplayPreferenceMenuItem<Value extends string>({
function GroupModeMenuItem({
item,
isSelected,
testIDPrefix,
onSelect,
}: {
item: DisplayPreferenceOption<Value>;
item: { value: SidebarGroupMode; label: string };
isSelected: boolean;
testIDPrefix: string;
onSelect: (value: Value) => void;
onSelect: (mode: SidebarGroupMode) => void;
}) {
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
return (
<DropdownMenuItem
testID={`${testIDPrefix}-${item.value}`}
testID={`sidebar-grouping-${item.value}`}
selected={isSelected}
onSelect={handleSelect}
>
<Text style={styles.optionLabel}>{item.label}</Text>
{item.label}
</DropdownMenuItem>
);
}
@@ -149,7 +110,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
optionLabel: {
fontSize: theme.fontSize.sm,
},
}));
@@ -22,14 +22,12 @@ import { GitHubIcon } from "@/components/icons/github-icon";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { SyncedLoader } from "@/components/synced-loader";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { PrHint } from "@/git/use-pr-status-query";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { openExternalUrl } from "@/utils/open-external-url";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
@@ -112,10 +110,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
showShortcutBadge?: boolean;
children?: ReactNode;
}) {
const {
settings: { workspaceTitleSource },
} = useAppSettings();
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
const workspaceBranchTextStyle = useMemo(
() => [
styles.workspaceBranchText,
@@ -138,7 +132,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<View style={styles.workspaceTitleRow}>
<View style={styles.workspaceTitleLeft}>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspaceLabel}
{workspace.name}
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
describe("resolveSidebarWorkspacePrimaryLabel", () => {
it("uses the workspace name in title mode", () => {
const label = resolveSidebarWorkspacePrimaryLabel({
workspace: { name: "Investigate search", currentBranch: "fix/search" },
workspaceTitleSource: "title",
});
expect(label).toBe("Investigate search");
});
it("uses the branch name in branch mode", () => {
const label = resolveSidebarWorkspacePrimaryLabel({
workspace: { name: "Investigate search", currentBranch: "fix/search" },
workspaceTitleSource: "branch",
});
expect(label).toBe("fix/search");
});
it("falls back to the workspace name in branch mode without a branch", () => {
const label = resolveSidebarWorkspacePrimaryLabel({
workspace: { name: "Local folder", currentBranch: null },
workspaceTitleSource: "branch",
});
expect(label).toBe("Local folder");
});
});
@@ -1,12 +0,0 @@
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
export function resolveSidebarWorkspacePrimaryLabel(input: {
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
workspaceTitleSource: WorkspaceTitleSource;
}): string {
if (input.workspaceTitleSource === "branch") {
return input.workspace.currentBranch ?? input.workspace.name;
}
return input.workspace.name;
}
@@ -18,7 +18,6 @@ import { type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { useToast } from "@/contexts/toast-context";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -262,7 +261,6 @@ export const AgentModeControl = memo(function AgentModeControl({
compareAvailableModes,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const { updatePreferences } = useFormPreferences();
const toast = useToast();
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
@@ -274,18 +272,7 @@ export const AgentModeControl = memo(function AgentModeControl({
const handleSelectMode = useCallback(
(modeId: string) => {
if (!client || !slice?.provider) return;
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: slice.provider,
updates: {
mode: modeId || undefined,
},
}),
).catch((error) => {
console.warn("[AgentModeControl] persist mode preference failed", error);
});
if (!client) return;
void client
.setAgentMode(agentId, modeId)
.then((notice) => showProviderNoticeToast(toast, notice))
@@ -294,7 +281,7 @@ export const AgentModeControl = memo(function AgentModeControl({
toast.error(toErrorMessage(error));
});
},
[agentId, client, slice?.provider, toast, updatePreferences],
[agentId, client, toast],
);
if (!slice || availableModes.length === 0) return null;
+1 -9
View File
@@ -209,10 +209,8 @@ function renderContextWindowMeter(
showPercentage: boolean,
serverId: string,
provider: string | null,
pending: boolean,
): ReactElement | null {
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
if (!hasData && !pending) {
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
return null;
}
return (
@@ -223,7 +221,6 @@ function renderContextWindowMeter(
showPercentage={showPercentage}
serverId={serverId}
provider={provider}
pending={pending}
/>
);
}
@@ -1645,9 +1642,6 @@ export function Composer({
agentState.contextWindowUsedTokens,
);
const contextWindowPending =
agentState.status === "initializing" || agentState.status === "running";
const contextWindowMeter = useMemo(
() =>
renderContextWindowMeter(
@@ -1657,7 +1651,6 @@ export function Composer({
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
),
[
contextWindowMaxTokens,
@@ -1666,7 +1659,6 @@ export function Composer({
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
],
);
const { beforeVoiceContent, footerInlineContent } = useMemo(
+12 -12
View File
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.30.0",
version: "0.29.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.30.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.29.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.29",
version: "3.0.27",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.29", "--acp"],
command: ["npx", "-y", "cline@3.0.27", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.109.0",
version: "2.108.2",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.109.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.108.2", "--acp"],
},
{
id: "codewhale",
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.153.1",
version: "0.151.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.153.1", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.151.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.7.21",
version: "0.7.20",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.7.21", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.7.20", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -311,10 +311,10 @@ const CATALOG_DATA = [
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.18.4",
version: "0.18.3",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.18.4", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.18.3", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -38,7 +38,6 @@ import {
type ServiceUrlBehavior,
type Settings,
type SettingsDeps,
type WorkspaceTitleSource,
} from "./storage";
export {
@@ -68,7 +67,6 @@ export type {
ServiceUrlBehavior,
Settings,
SettingsDeps,
WorkspaceTitleSource,
};
const productionDeps: SettingsDeps = {
@@ -174,9 +172,6 @@ export function useSettings(): UseSettingsReturn {
if (updates.syntaxTheme !== undefined) {
appUpdates.syntaxTheme = updates.syntaxTheme;
}
if (updates.workspaceTitleSource !== undefined) {
appUpdates.workspaceTitleSource = updates.workspaceTitleSource;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -61,14 +61,6 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.language).toBe("system");
});
it("defaults workspace title source to title when storage is empty", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.workspaceTitleSource).toBe("title");
});
it("loads configured terminal scrollback lines from app settings", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -81,30 +73,6 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.terminalScrollbackLines).toBe(42_000);
});
it("loads configured workspace title source from app settings", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "branch" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.workspaceTitleSource).toBe("branch");
});
it("drops an unknown workspace title source back to title", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "directory" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.workspaceTitleSource).toBe("title");
});
it("normalizes terminal scrollback lines from storage", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -11,11 +11,9 @@ const LEGACY_SETTINGS_KEY = "@paseo:settings";
export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
export type WorkspaceTitleSource = "title" | "branch";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
@@ -38,7 +36,6 @@ export interface AppSettings {
uiFontSize: number; // clamped px, default 16
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
}
export interface Settings extends AppSettings {
@@ -57,7 +54,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
uiFontSize: DEFAULT_UI_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "one",
workspaceTitleSource: "title",
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -198,12 +194,6 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
if (
typeof stored.workspaceTitleSource === "string" &&
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
) {
result.workspaceTitleSource = stored.workspaceTitleSource;
}
return result;
}
@@ -1,102 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarShortcutModel } from "./use-sidebar-shortcut-model";
function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEntry {
return {
workspaceKey: `srv:${workspaceId}`,
serverId: "srv",
workspaceId,
projectKey,
projectRootPath: `/repo/${projectKey}`,
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
projectKind: "git",
workspaceKind: "worktree",
name: workspaceId,
title: null,
currentBranch: null,
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
};
}
function project(projectKey: string): SidebarProjectEntry {
return {
projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/repo/${projectKey}`,
canCreateWorktree: true,
workspaces: [workspace(projectKey, `${projectKey}-main`)],
};
}
const PROJECTS_BOTH = [project("p1"), project("p2")];
const PROJECTS_ONLY_SECOND = [project("p2")];
function Probe({ projectSet }: { projectSet: "both" | "onlySecond" }) {
const projects = projectSet === "both" ? PROJECTS_BOTH : PROJECTS_ONLY_SECOND;
useSidebarShortcutModel({ projects });
return null;
}
describe("useSidebarShortcutModel", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(),
collapsedStatusGroupKeys: new Set(),
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("keeps a collapsed project collapsed when the project list temporarily omits it", async () => {
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(["p1"]),
});
await act(async () => {
root?.render(<Probe projectSet="both" />);
});
await act(async () => {
root?.render(<Probe projectSet="onlySecond" />);
});
await act(async () => {
root?.render(<Probe projectSet="both" />);
});
expect(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys.has("p1")).toBe(true);
});
});
@@ -1,10 +1,13 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
export function useSidebarShortcutModel(input: { projects: SidebarProjectEntry[] }) {
const { projects } = input;
export function useSidebarShortcutModel(input: {
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
}) {
const { projects, isInitialLoad } = input;
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
@@ -24,6 +27,19 @@ export function useSidebarShortcutModel(input: { projects: SidebarProjectEntry[]
[collapsedProjectKeys, projects],
);
useEffect(() => {
if (isInitialLoad || projects.length === 0) {
return;
}
const projectKeys = new Set(projects.map((project) => project.projectKey));
for (const key of collapsedProjectKeys) {
if (!projectKeys.has(key)) {
setProjectCollapsed(key, false);
}
}
}, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
return {
collapsedProjectKeys,
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentScreenMissingState } from "@/hooks/use-agent-screen-state-machine";
import {
clearHistorySyncErrorAfterSuccessfulSync,
reconcileMissingAgentStateWithPresentAgent,
} from "./agent-panel-load-state";
describe("reconcileMissingAgentStateWithPresentAgent", () => {
it("clears lookup-only states once the agent record is present", () => {
expect(reconcileMissingAgentStateWithPresentAgent({ kind: "resolving" })).toEqual({
kind: "idle",
});
expect(
reconcileMissingAgentStateWithPresentAgent({
kind: "not_found",
message: "Agent not found: agent-1",
}),
).toEqual({ kind: "idle" });
});
it("preserves history sync errors while the agent record is present", () => {
const state: AgentScreenMissingState = {
kind: "error",
message: "Failed to get logs: session is archived",
};
expect(reconcileMissingAgentStateWithPresentAgent(state)).toBe(state);
});
});
describe("clearHistorySyncErrorAfterSuccessfulSync", () => {
it("clears a sync error after a later successful refresh", () => {
expect(
clearHistorySyncErrorAfterSuccessfulSync({
kind: "error",
message: "Failed to get logs: session is archived",
}),
).toEqual({ kind: "idle" });
});
it("leaves non-error states alone", () => {
const state: AgentScreenMissingState = { kind: "resolving" };
expect(clearHistorySyncErrorAfterSuccessfulSync(state)).toBe(state);
});
});
@@ -1,19 +0,0 @@
import type { AgentScreenMissingState } from "@/hooks/use-agent-screen-state-machine";
export function reconcileMissingAgentStateWithPresentAgent(
state: AgentScreenMissingState,
): AgentScreenMissingState {
if (state.kind === "resolving" || state.kind === "not_found") {
return { kind: "idle" };
}
return state;
}
export function clearHistorySyncErrorAfterSuccessfulSync(
state: AgentScreenMissingState,
): AgentScreenMissingState {
if (state.kind === "error") {
return { kind: "idle" };
}
return state;
}
+5 -15
View File
@@ -53,10 +53,6 @@ import {
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import {
clearHistorySyncErrorAfterSuccessfulSync,
reconcileMissingAgentStateWithPresentAgent,
} from "@/panels/agent-panel-load-state";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
@@ -851,15 +847,9 @@ function ChatAgentContent({
if (!agentId) {
return;
}
ensureAgentIsInitialized(agentId)
.then(() => {
setMissingAgentState(clearHistorySyncErrorAfterSuccessfulSync);
return undefined;
})
.catch((error) => {
handleHistorySyncFailure({ origin, error });
return undefined;
});
ensureAgentIsInitialized(agentId).catch((error) => {
handleHistorySyncFailure({ origin, error });
});
},
[agentId, ensureAgentIsInitialized, handleHistorySyncFailure],
);
@@ -1021,8 +1011,8 @@ function ChatAgentContent({
return;
}
if (agentState.id) {
if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") {
setMissingAgentState(reconcileMissingAgentStateWithPresentAgent);
if (missingAgentState.kind !== "idle") {
setMissingAgentState({ kind: "idle" });
}
return;
}
+9 -6
View File
@@ -22,7 +22,7 @@ function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const foregroundIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
function statusText(usage: ProviderUsage): string | null {
if (usage.status === "available") return null;
@@ -65,7 +65,11 @@ export function ProviderUsageCard({
return (
<View style={containerStyle}>
<View style={styles.header}>
<ThemedProviderUsageIcon iconKey={usage.providerId} size={14} uniProps={mutedIconColor} />
<ThemedProviderUsageIcon
iconKey={usage.providerId}
size={16}
uniProps={foregroundIconColor}
/>
<Text style={styles.name} numberOfLines={1}>
{usage.displayName}
</Text>
@@ -125,12 +129,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
},
containerPadded: {
gap: theme.spacing[4],
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
containerCompact: {
gap: theme.spacing[3],
gap: theme.spacing[2],
},
header: {
flexDirection: "row",
@@ -140,7 +143,7 @@ const styles = StyleSheet.create((theme) => ({
name: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
},
headerSpacer: {
flex: 1,
@@ -167,7 +170,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
bars: {
gap: theme.spacing[3],
gap: theme.spacing[2],
},
details: {
gap: theme.spacing[1],
@@ -54,13 +54,9 @@ export function ProviderUsageTooltipSection({
const styles = StyleSheet.create((theme) => ({
divider: {
height: 1,
// Same token the popover draws its own outline with, so the rule reads as the
// popover's edge. `border` is invisible here (equals the popover background).
backgroundColor: theme.colors.borderAccent,
marginVertical: theme.spacing[2],
// Cancel the tooltip content's horizontal padding so the rule spans edge to edge.
marginHorizontal: -theme.spacing[2],
height: StyleSheet.hairlineWidth,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1] + 1,
},
detail: {
color: theme.colors.foregroundMuted,
@@ -16,11 +16,7 @@ import {
workspaceEqualityFns,
type SidebarOrderSnapshot,
} from "./selectors";
import {
useSessionStore,
type EmptyProjectDescriptor,
type WorkspaceDescriptor,
} from "../session-store";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
const SERVER_ID = "test-server";
@@ -91,12 +87,6 @@ function emptySidebarOrder(): SidebarOrderSnapshot {
};
}
function selectWorkspaceStructureProjectKeys(
state: Parameters<typeof selectWorkspaceStructureProjects>[0],
): string[] {
return selectWorkspaceStructureProjects(state, SERVER_ID).map((project) => project.projectKey);
}
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
});
@@ -218,38 +208,6 @@ describe("workspace structure composition", () => {
});
}
it("keeps a project parent visible throughout the last workspace archive transition", () => {
const workspace = createWorkspace({
id: "workspace-a",
projectId: "project-a",
projectDisplayName: "Project A",
projectRootPath: "/repo/a",
workspaceDirectory: "/repo/a",
});
const emptyProject: EmptyProjectDescriptor = {
projectId: "project-a",
projectDisplayName: "Project A",
projectCustomName: null,
projectRootPath: "/repo/a",
projectKind: "git",
};
initializeWorkspaces([workspace]);
const emittedProjectKeys = [selectWorkspaceStructureProjectKeys(useSessionStore.getState())];
const stop = useSessionStore.subscribe((state) => {
emittedProjectKeys.push(selectWorkspaceStructureProjectKeys(state));
});
try {
useSessionStore.getState().removeWorkspace(SERVER_ID, workspace.id);
useSessionStore.getState().addEmptyProject(SERVER_ID, emptyProject);
} finally {
stop();
}
expect(emittedProjectKeys).toEqual([["project-a"], ["project-a"]]);
});
it("changes for membership updates but not status-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
+1 -43
View File
@@ -229,30 +229,6 @@ function preserveWorkspaceMapIdentity(
return changed ? next : existing;
}
function emptyProjectDescriptorFromWorkspace(
workspace: WorkspaceDescriptor,
): EmptyProjectDescriptor {
return {
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
function hasWorkspaceInProject(
workspaces: ReadonlyMap<string, WorkspaceDescriptor>,
projectId: string,
): boolean {
for (const workspace of workspaces.values()) {
if (workspace.projectId === projectId) {
return true;
}
}
return false;
}
export type ExplorerEntryKind = "file" | "directory";
export type ExplorerFileKind = "text" | "image" | "binary";
export type ExplorerEncoding = "utf-8" | "base64" | "none";
@@ -1417,31 +1393,13 @@ export const useSessionStore = create<SessionStore>()(
if (!session || !workspaceKey) {
return prev;
}
const removedWorkspace = session.workspaces.get(workspaceKey);
if (!removedWorkspace) {
return prev;
}
const next = new Map(session.workspaces);
next.delete(workspaceKey);
let nextEmptyProjects = session.emptyProjects;
if (hasWorkspaceInProject(next, removedWorkspace.projectId)) {
if (nextEmptyProjects.has(removedWorkspace.projectId)) {
nextEmptyProjects = new Map(nextEmptyProjects);
nextEmptyProjects.delete(removedWorkspace.projectId);
}
} else {
const emptyProject = emptyProjectDescriptorFromWorkspace(removedWorkspace);
const existing = nextEmptyProjects.get(emptyProject.projectId);
if (!existing || !equal(existing, emptyProject)) {
nextEmptyProjects = new Map(nextEmptyProjects);
nextEmptyProjects.set(emptyProject.projectId, emptyProject);
}
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
[serverId]: { ...session, workspaces: next },
},
};
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.98",
"version": "0.1.97",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.98",
"@getpaseo/protocol": "0.1.98",
"@getpaseo/server": "0.1.98",
"@getpaseo/client": "0.1.97",
"@getpaseo/protocol": "0.1.97",
"@getpaseo/server": "0.1.97",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.98",
"version": "0.1.97",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.98",
"@getpaseo/relay": "0.1.98",
"@getpaseo/protocol": "0.1.97",
"@getpaseo/relay": "0.1.97",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.98",
"version": "0.1.97",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -1,182 +0,0 @@
import type { SpawnSyncReturns } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { inheritLoginShellEnv } from "./login-shell-env";
const zsh = "/bin/zsh";
const describeIfZsh = existsSync(zsh) ? describe : describe.skip;
const basePath = "/usr/bin:/bin:/usr/sbin:/sbin";
type LoginShellEnvInput = NonNullable<Parameters<typeof inheritLoginShellEnv>[0]>;
type LoginShellSpawnSync = NonNullable<LoginShellEnvInput["spawnSync"]>;
interface RecordedLog {
message: string;
fields: Record<string, unknown>;
}
class RecordingLoginShellLogger {
readonly infos: RecordedLog[] = [];
readonly warnings: RecordedLog[] = [];
info(message: string, fields: Record<string, unknown>): void {
this.infos.push({ message, fields });
}
warn(message: string, fields: Record<string, unknown>): void {
this.warnings.push({ message, fields });
}
}
function createEnv(home: string): NodeJS.ProcessEnv {
return {
HOME: home,
USER: "paseo-test",
LOGNAME: "paseo-test",
SHELL: zsh,
PATH: basePath,
};
}
function markerFromShellCommand(shellCommand: string): string {
const match = /"([0-9a-f]{12})" \+ JSON\.stringify\(process\.env\) \+ "\1"/.exec(shellCommand);
if (!match?.[1]) throw new Error(`missing env marker in shell command: ${shellCommand}`);
return match[1];
}
function expectNoRawStdout(fields: Record<string, unknown>): void {
expect(fields).not.toHaveProperty("stdout");
}
async function createShellHome(): Promise<string> {
return await mkdtemp(path.join(os.tmpdir(), "paseo-login-shell-env-"));
}
describeIfZsh("login shell env", () => {
const homes = new Set<string>();
afterEach(async () => {
await Promise.all([...homes].map((home) => rm(home, { recursive: true, force: true })));
homes.clear();
});
it("applies PATH from the user's login shell", async () => {
const home = await createShellHome();
homes.add(home);
const binDir = path.join(home, "tools");
await mkdir(binDir);
await writeFile(path.join(home, ".zprofile"), 'export PATH="$HOME/tools:$PATH"\n');
const env = createEnv(home);
const logger = new RecordingLoginShellLogger();
inheritLoginShellEnv({ env, logger });
expect(env.PATH?.split(path.delimiter)[0]).toBe(binDir);
expect(logger.infos.map((entry) => entry.message)).toEqual([
"[login-shell-env] start",
"[login-shell-env] applied",
]);
expect(logger.warnings).toEqual([]);
expect(logger.infos[1]?.fields).toMatchObject({
beforePath: basePath,
afterPath: env.PATH,
pathChanged: true,
shell: zsh,
});
});
it("loads the user's zshrc while resolving the login shell env", async () => {
const home = await createShellHome();
homes.add(home);
await writeFile(path.join(home, ".zshrc"), "export PASEO_TEST_ZSHRC_LOADED=1\n");
const env = createEnv(home);
const logger = new RecordingLoginShellLogger();
inheritLoginShellEnv({ env, logger });
expect(env.PASEO_TEST_ZSHRC_LOADED).toBe("1");
expect(logger.infos.map((entry) => entry.message)).toEqual([
"[login-shell-env] start",
"[login-shell-env] applied",
]);
expect(logger.warnings).toEqual([]);
});
it("keeps the inherited env and logs stdout diagnostics when shell startup fails", async () => {
const home = await createShellHome();
homes.add(home);
await writeFile(path.join(home, ".zshenv"), "print -r -- premarker\nexit 42\n");
const env = createEnv(home);
const logger = new RecordingLoginShellLogger();
inheritLoginShellEnv({ env, logger });
expect(env.PATH).toBe(basePath);
expect(logger.infos.map((entry) => entry.message)).toEqual(["[login-shell-env] start"]);
expect(logger.warnings).toHaveLength(1);
expect(logger.warnings[0]?.message).toBe("[login-shell-env] failed; keeping inherited env");
expect(logger.warnings[0]?.fields).toMatchObject({
reason: "non-zero-exit",
shell: zsh,
shellArgs: ["-i", "-l", "-c"],
status: 42,
stdoutLength: "premarker\n".length,
markerFound: false,
beforePath: basePath,
afterPath: basePath,
pathChanged: false,
});
expectNoRawStdout(logger.warnings[0]?.fields ?? {});
});
it("keeps the inherited env when a timed-out shell printed an env marker", async () => {
const home = await createShellHome();
homes.add(home);
const env = createEnv(home);
const logger = new RecordingLoginShellLogger();
const timedOutPath = path.join(home, "timed-out");
let stdout = "";
const timeoutError = Object.assign(new Error("spawnSync ETIMEDOUT"), {
code: "ETIMEDOUT",
});
const spawnSync: LoginShellSpawnSync = (_shell, args) => {
const shellCommand = String(Array.isArray(args) ? args.at(-1) : "");
const marker = markerFromShellCommand(shellCommand);
stdout = `${marker}${JSON.stringify({ ...env, PATH: timedOutPath })}${marker}`;
return {
pid: 0,
output: [stdout, stdout, ""],
stdout,
stderr: "",
status: null,
signal: "SIGTERM",
error: timeoutError,
} satisfies SpawnSyncReturns<string>;
};
inheritLoginShellEnv({ env, logger, spawnSync });
expect(env.PATH).toBe(basePath);
expect(logger.infos.map((entry) => entry.message)).toEqual(["[login-shell-env] start"]);
expect(logger.warnings).toHaveLength(1);
expect(logger.warnings[0]?.message).toBe("[login-shell-env] failed; keeping inherited env");
expect(logger.warnings[0]?.fields).toMatchObject({
reason: "spawn-error",
shell: zsh,
shellArgs: ["-i", "-l", "-c"],
status: null,
signal: "SIGTERM",
stdoutLength: stdout.length,
markerFound: true,
errorCode: "ETIMEDOUT",
beforePath: basePath,
afterPath: basePath,
pathChanged: false,
});
expectNoRawStdout(logger.warnings[0]?.fields ?? {});
});
});
+24 -189
View File
@@ -2,139 +2,35 @@
// https://github.com/microsoft/vscode/blob/main/src/vs/platform/shell/node/shellEnv.ts
// Licensed under the MIT License.
import type { SpawnSyncReturns } from "node:child_process";
import { spawnSync as defaultSpawnSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { userInfo as defaultUserInfo } from "node:os";
import { userInfo } from "node:os";
import { basename } from "node:path";
import defaultLog from "electron-log/main";
const RESOLVE_TIMEOUT_MS = 10_000;
const STDERR_LOG_LIMIT = 2000;
type LoginShellEnvLogger = Pick<typeof defaultLog, "info" | "warn">;
interface LoginShellEnvDependencies {
env?: NodeJS.ProcessEnv;
logger?: LoginShellEnvLogger;
platform?: NodeJS.Platform;
spawnSync?: typeof defaultSpawnSync;
userInfo?: typeof defaultUserInfo;
}
function truncateForLog(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
return trimmed.length > STDERR_LOG_LIMIT
? `${trimmed.slice(0, STDERR_LOG_LIMIT)}...(truncated)`
: trimmed;
}
function pathEnv(env: NodeJS.ProcessEnv | Record<string, string>): string | null {
return env.PATH ?? env.Path ?? null;
}
interface ShellEnvErrorDetails {
reason: string;
shell?: string;
shellArgs?: string[];
status?: number | null;
signal?: NodeJS.Signals | null;
stdoutLength?: number;
markerFound?: boolean;
stderr?: string;
}
class ShellEnvError extends Error {
constructor(
message: string,
readonly details: ShellEnvErrorDetails,
options?: { cause?: unknown },
) {
super(message, options);
this.name = "ShellEnvError";
}
}
function throwIfShellFailed(
result: SpawnSyncReturns<string>,
regex: RegExp,
shell: string,
shellArgs: string[],
): void {
if (result.error || result.signal) {
throw new ShellEnvError(
"login shell did not complete",
{
reason: result.error ? "spawn-error" : "signal",
shell,
shellArgs,
status: result.status,
signal: result.signal,
stdoutLength: result.stdout?.length ?? 0,
markerFound: regex.test(result.stdout ?? ""),
stderr: result.stderr,
},
{ cause: result.error },
);
}
if (result.status !== 0 && result.status !== null) {
throw new ShellEnvError("login shell exited non-zero", {
reason: "non-zero-exit",
shell,
shellArgs,
status: result.status,
signal: result.signal,
stdoutLength: result.stdout?.length ?? 0,
markerFound: regex.test(result.stdout ?? ""),
stderr: result.stderr,
});
}
if (!result.stdout) {
throw new ShellEnvError(
"login shell produced no stdout",
{
reason: "no-stdout",
shell,
shellArgs,
status: result.status,
signal: result.signal,
stdoutLength: result.stdout?.length ?? 0,
markerFound: false,
stderr: result.stderr,
},
{ cause: result.error },
);
}
}
function getSystemShell(
deps: Required<Pick<LoginShellEnvDependencies, "env" | "platform" | "userInfo">>,
): string {
const shell = deps.env.SHELL;
function getSystemShell(): string {
const shell = process.env.SHELL;
if (shell) return shell;
try {
const info = deps.userInfo();
const info = userInfo();
if (info.shell && info.shell !== "/bin/false") return info.shell;
} catch {}
return deps.platform === "darwin" ? "/bin/zsh" : "/bin/bash";
return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash";
}
function resolveShellEnv(deps: Required<LoginShellEnvDependencies>): Record<string, string> {
if (deps.platform === "win32") {
throw new ShellEnvError("login shell env is not resolved on Windows", { reason: "win32" });
}
function resolveShellEnv(): Record<string, string> | undefined {
if (process.platform === "win32") return undefined;
const savedRunAsNode = deps.env.ELECTRON_RUN_AS_NODE;
const savedNoAttach = deps.env.ELECTRON_NO_ATTACH_CONSOLE;
const savedRunAsNode = process.env.ELECTRON_RUN_AS_NODE;
const savedNoAttach = process.env.ELECTRON_NO_ATTACH_CONSOLE;
const mark = randomUUID().replace(/-/g, "").slice(0, 12);
const regex = new RegExp(mark + "({.*})" + mark);
const shell = getSystemShell(deps);
const shell = getSystemShell();
const name = basename(shell);
let command: string;
@@ -158,19 +54,12 @@ function resolveShellEnv(deps: Required<LoginShellEnvDependencies>): Record<stri
}
}
const shellEnv = { ...deps.env };
const shellEnv = { ...process.env };
delete shellEnv.PASEO_NODE_ENV;
delete shellEnv.PASEO_DESKTOP_MANAGED;
delete shellEnv.PASEO_SUPERVISED;
deps.logger.info("[login-shell-env] start", {
shell,
shellArgs,
timeoutMs: RESOLVE_TIMEOUT_MS,
beforePath: pathEnv(deps.env),
});
const result = deps.spawnSync(shell, [...shellArgs, command], {
const result = spawnSync(shell, [...shellArgs, command], {
encoding: "utf8",
timeout: RESOLVE_TIMEOUT_MS,
windowsHide: true,
@@ -181,21 +70,11 @@ function resolveShellEnv(deps: Required<LoginShellEnvDependencies>): Record<stri
},
});
throwIfShellFailed(result, regex, shell, shellArgs);
if (result.status !== 0 && result.status !== null) return undefined;
if (!result.stdout) return undefined;
const match = regex.exec(result.stdout);
if (!match?.[1]) {
throw new ShellEnvError("login shell output did not contain environment marker", {
reason: "marker-missing",
shell,
shellArgs,
status: result.status,
signal: result.signal,
stdoutLength: result.stdout.length,
markerFound: false,
stderr: result.stderr,
});
}
if (!match?.[1]) return undefined;
try {
const env = JSON.parse(match[1]) as Record<string, string>;
@@ -215,21 +94,8 @@ function resolveShellEnv(deps: Required<LoginShellEnvDependencies>): Record<stri
delete env.XDG_RUNTIME_DIR;
return env;
} catch (error) {
throw new ShellEnvError(
"failed to parse login shell environment JSON",
{
reason: "json-parse",
shell,
shellArgs,
status: result.status,
signal: result.signal,
stdoutLength: result.stdout.length,
markerFound: true,
stderr: result.stderr,
},
{ cause: error },
);
} catch {
return undefined;
}
}
@@ -241,44 +107,13 @@ function resolveShellEnv(deps: Required<LoginShellEnvDependencies>): Record<stri
*
* Approach borrowed from VS Code (src/vs/platform/shell/node/shellEnv.ts).
*/
export function inheritLoginShellEnv(input: LoginShellEnvDependencies = {}): void {
const deps: Required<LoginShellEnvDependencies> = {
env: input.env ?? process.env,
logger: input.logger ?? defaultLog,
platform: input.platform ?? process.platform,
spawnSync: input.spawnSync ?? defaultSpawnSync,
userInfo: input.userInfo ?? defaultUserInfo,
};
const beforePath = pathEnv(deps.env);
const startedAt = Date.now();
export function inheritLoginShellEnv(): void {
try {
const env = resolveShellEnv(deps);
Object.assign(deps.env, env);
deps.logger.info("[login-shell-env] applied", {
durationMs: Date.now() - startedAt,
beforePath,
afterPath: pathEnv(deps.env),
pathChanged: beforePath !== pathEnv(deps.env),
shell: deps.env.SHELL ?? null,
});
} catch (error) {
const details: ShellEnvErrorDetails =
error instanceof ShellEnvError
? error.details
: { reason: "throw", shell: deps.env.SHELL ?? undefined };
const cause = error instanceof Error ? error.cause : undefined;
deps.logger.warn("[login-shell-env] failed; keeping inherited env", {
...details,
durationMs: Date.now() - startedAt,
timeoutMs: RESOLVE_TIMEOUT_MS,
error: error instanceof Error ? error.message : String(error),
errorCode: (cause as NodeJS.ErrnoException | undefined)?.code ?? null,
stderr: truncateForLog(details.stderr),
beforePath,
afterPath: pathEnv(deps.env),
pathChanged: beforePath !== pathEnv(deps.env),
});
const env = resolveShellEnv();
if (env) {
Object.assign(process.env, env);
}
} catch {
// Keep inherited environment if shell lookup fails.
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.98",
"version": "0.1.97",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.98",
"version": "0.1.97",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.98",
"version": "0.1.97",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.98",
"version": "0.1.97",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.98",
"version": "0.1.97",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -65,10 +65,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.98",
"@getpaseo/highlight": "0.1.98",
"@getpaseo/protocol": "0.1.98",
"@getpaseo/relay": "0.1.98",
"@getpaseo/client": "0.1.97",
"@getpaseo/highlight": "0.1.97",
"@getpaseo/protocol": "0.1.97",
"@getpaseo/relay": "0.1.97",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -134,28 +134,6 @@ class TestAgentClient implements AgentClient {
}
}
class NativeArchiveRecordingClient extends TestAgentClient {
readonly archivedHandles: AgentPersistenceHandle[] = [];
readonly unarchivedHandles: AgentPersistenceHandle[] = [];
readArchivedAtDuringUnarchive: (() => Promise<string | null | undefined>) | null = null;
archivedAtDuringUnarchive: string | null | undefined;
unarchiveFailure: Error | null = null;
async archiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
this.archivedHandles.push(handle);
}
async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
this.unarchivedHandles.push(handle);
if (this.readArchivedAtDuringUnarchive) {
this.archivedAtDuringUnarchive = await this.readArchivedAtDuringUnarchive();
}
if (this.unarchiveFailure) {
throw this.unarchiveFailure;
}
}
}
class EnvProbeAgentClient extends TestAgentClient {
probe: Promise<{ probe: string | null; agentId: string | null }> | null = null;
@@ -4465,113 +4443,6 @@ test("fires onAgentArchived for stored-only snapshot archives", async () => {
expect(archivedIds).toEqual([storedOnly.id]);
});
test("unarchiveSnapshot skips native provider unarchive for active records", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-active-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new NativeArchiveRecordingClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
});
const agent = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Active unarchive target",
});
const unarchived = await manager.unarchiveSnapshot(agent.id);
expect(unarchived).toBe(false);
expect(client.unarchivedHandles).toEqual([]);
});
test("unarchiveSnapshot unarchives native provider storage before clearing archivedAt", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-native-unarchive-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new NativeArchiveRecordingClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
});
const agent = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Native unarchive target",
});
await manager.archiveAgent(agent.id);
client.readArchivedAtDuringUnarchive = async () => (await storage.get(agent.id))?.archivedAt;
const unarchived = await manager.unarchiveSnapshot(agent.id);
const stored = await storage.get(agent.id);
expect(unarchived).toBe(true);
expect(client.archivedHandles).toHaveLength(1);
expect(client.unarchivedHandles).toEqual(client.archivedHandles);
expect(client.archivedAtDuringUnarchive).toEqual(expect.any(String));
expect(stored?.archivedAt).toBeNull();
});
test("unarchiveSnapshotByHandle unarchives native provider storage for the matched snapshot", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-native-unarchive-handle-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new NativeArchiveRecordingClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
});
const agent = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Native unarchive by handle target",
});
await manager.archiveAgent(agent.id);
const archived = await storage.get(agent.id);
if (!archived?.persistence) {
throw new Error("expected archived snapshot to have persistence");
}
await manager.unarchiveSnapshotByHandle(archived.persistence);
const stored = await storage.get(agent.id);
expect(client.unarchivedHandles).toEqual(client.archivedHandles);
expect(stored?.archivedAt).toBeNull();
});
test("unarchiveSnapshot keeps the stored record archived when native unarchive fails", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-native-unarchive-failure-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new NativeArchiveRecordingClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
});
const agent = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Native unarchive failure target",
});
await manager.archiveAgent(agent.id);
client.unarchiveFailure = new Error("provider still archived");
await expect(manager.unarchiveSnapshot(agent.id)).rejects.toThrow("provider still archived");
const stored = await storage.get(agent.id);
expect(stored?.archivedAt).toEqual(expect.any(String));
expect(client.unarchivedHandles).toHaveLength(1);
});
test("archiveAgent cascade archives in-memory children with the full archive contract", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-contract-"));
const storagePath = join(workdir, "agents");
@@ -1503,12 +1503,9 @@ export class AgentManager {
return false;
}
await this.unarchiveNativeSession(record.provider, record.persistence);
await registry.upsert({
...record,
archivedAt: null,
updatedAt: new Date().toISOString(),
});
if (this.getAgent(agentId)) {
@@ -3772,16 +3769,6 @@ export class AgentManager {
}
}
private async unarchiveNativeSession(
provider: AgentProvider,
persistence: AgentPersistenceHandle | null | undefined,
): Promise<void> {
if (!persistence) return;
const client = this.clients.get(provider);
if (!client?.unarchiveNativeSession) return;
await client.unarchiveNativeSession(persistence);
}
private requireAgent(id: string): LiveManagedAgent {
const normalizedId = validateAgentId(id, "requireAgent");
const agent = this.agents.get(normalizedId);
@@ -90,12 +90,20 @@ export function startAgentRun(
* an archived agent unarchives it the same way.
*/
export async function unarchiveAgentState(
_agentStorage: AgentStorage,
agentStorage: AgentStorage,
agentManager: AgentManager,
agentId: string,
): Promise<boolean> {
const unarchived = await agentManager.unarchiveSnapshot(agentId);
if (!unarchived) return false;
const record = await agentStorage.get(agentId);
if (!record || !record.archivedAt) {
return false;
}
const updatedAt = new Date().toISOString();
await agentStorage.upsert({
...record,
archivedAt: null,
updatedAt,
});
agentManager.notifyAgentState(agentId);
return true;
}
@@ -683,11 +683,6 @@ export interface AgentClient {
* Called when Paseo archives an agent so the provider's own UI reflects the same state.
*/
archiveNativeSession?(handle: AgentPersistenceHandle): Promise<void>;
/**
* Unarchive a persisted session in the native provider.
* Called before Paseo clears its archived flag so provider resume can succeed.
*/
unarchiveNativeSession?(handle: AgentPersistenceHandle): Promise<void>;
/**
* Release any provider-owned resources held by this client (background
* processes, sockets, cached subprocesses, etc.). Called when the daemon
@@ -46,7 +46,9 @@ interface CreateAgentCommandDependencies {
terminalManager?: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id.
// Mints a fresh workspace for a cwd and returns its id. Used when an agent is
// created with no parent and no worktree: it owns a brand-new workspace rather
// than being attributed to an existing same-cwd workspace by path.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
}
@@ -79,7 +81,6 @@ export interface CreateAgentFromMcpInput {
title: string;
initialPrompt: string;
cwd?: string;
workspaceId?: string;
thinking?: string;
features?: Record<string, unknown>;
labels?: Record<string, string>;
@@ -95,7 +96,6 @@ export interface CreateAgentFromMcpInput {
} | null;
worktree?: {
worktreeName?: string;
branchName?: string;
baseBranch?: string;
refName?: string;
action?: "branch-off" | "checkout";
@@ -245,15 +245,15 @@ async function resolveMcpCreateAgent(
initialPrompt: input.initialPrompt,
});
// MCP callers resolve workspace ownership before this point. Worktree
// creation wins because the new agent lives in the fresh worktree workspace.
// Otherwise use the explicit workspace id, then the parent workspace for
// direct internal callers. Ownership is never resolved from cwd.
// A child agent created in its parent's working tree belongs to the parent's
// workspace. When a new worktree is created the child lives in that fresh
// workspace, so it is stamped with the new worktree's workspaceId instead
// (mirrors the session path) — keeping the agent discoverable by
// workspaceId-scoped archive. With neither a parent nor a worktree, the agent
// mints its own workspace; ownership is never resolved from cwd.
const workspaceId = setupContinuation
? createdWorkspaceId
: (input.workspaceId ??
parentAgent?.workspaceId ??
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd)));
: (parentAgent?.workspaceId ?? (await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd)));
const { modeId: resolvedMode, featureValues: resolvedFeatures } =
await dependencies.providerSnapshotManager.resolveCreateConfig({
@@ -420,7 +420,6 @@ async function resolveMcpCwd(params: {
input: {
cwd: params.cwd,
worktreeSlug: worktree.worktreeName,
branchName: worktree.branchName,
refName: worktree.refName,
action: worktree.action,
githubPrNumber: worktree.githubPrNumber,
@@ -374,7 +374,6 @@ test("importProviderSession imports a selected provider session without listing"
const agentManager = {
importProviderSession: vi.fn().mockResolvedValue(snapshot),
getTimeline: vi.fn().mockReturnValue(timeline),
unarchiveSnapshot: vi.fn().mockResolvedValue(false),
} as unknown as AgentManager;
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
@@ -415,7 +414,6 @@ test("importProviderSession passes labels through the manager import operation",
const agentManager = {
importProviderSession: vi.fn().mockResolvedValue(snapshot),
getTimeline: vi.fn().mockReturnValue([]),
unarchiveSnapshot: vi.fn().mockResolvedValue(false),
} as unknown as AgentManager;
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
@@ -201,24 +201,20 @@ async function makeCwd(prefix: string): Promise<string> {
async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<string> {
const cwd = typeof args?.cwd === "string" ? args.cwd : await makeCwd("agent-cwd");
const { cwd: _cwd, ...rest } = args ?? {};
const payload = await callToolStructured(topLevelClient, "create_agent", {
relationship: { kind: "detached" },
workspace: { kind: "create", source: { kind: "directory", path: cwd } },
cwd,
title: "Parity agent",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
settings: { modeId: "bypassPermissions" },
background: true,
...rest,
...args,
});
return str(payload.agentId);
}
async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> {
const payload = await callToolStructured(agentScopedClient, "create_agent", {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
title: "Parity child",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
@@ -292,8 +288,7 @@ beforeAll(async () => {
topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`);
const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
relationship: { kind: "detached" },
workspace: { kind: "create", source: { kind: "directory", path: parentAgentCwd } },
cwd: parentAgentCwd,
title: "MCP parity parent",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
@@ -343,10 +338,10 @@ describe("Suite A: Core Fixes", () => {
}
});
test("create_agent with detached relationship omits the parent agent label", async () => {
test("create_agent with detached true omits the parent agent label", async () => {
let agentId: string | null = null;
try {
agentId = await createChildAgent({ relationship: { kind: "detached" } });
agentId = await createChildAgent({ detached: true });
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(snapshot?.labels?.[PARENT_AGENT_ID_LABEL]).toBeUndefined();
} finally {
@@ -841,9 +836,9 @@ describe("Suite E: Worktree Tools", () => {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
target: {
kind: "branch-off",
worktreeSlug: branchName,
baseBranch: "main",
mode: "branch-off",
newBranch: branchName,
base: "main",
},
});
worktreePath = str(created.worktreePath);
@@ -872,9 +867,9 @@ describe("Suite E: Worktree Tools", () => {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
target: {
kind: "branch-off",
worktreeSlug: branchName,
baseBranch: "main",
mode: "branch-off",
newBranch: branchName,
base: "main",
},
});
worktreePath = str(created.worktreePath);
@@ -905,9 +900,9 @@ describe("Suite E: Worktree Tools", () => {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
target: {
kind: "branch-off",
worktreeSlug: branchName,
baseBranch: "main",
mode: "branch-off",
newBranch: branchName,
base: "main",
},
});
worktreePath = str(created.worktreePath);
@@ -629,33 +629,6 @@ describe("terminal MCP tools", () => {
describe("create_agent MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
const detachedDirectoryWorkspace = (path = existingCwd) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "create" as const, source: { kind: "directory" as const, path } },
});
const detachedWorktreeWorkspace = (
cwd: string,
target:
| { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string }
| { kind: "checkout-branch"; branch: string }
| { kind: "checkout-pr"; githubPrNumber: number },
) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "create" as const, source: { kind: "worktree" as const, cwd, target } },
});
const subagentCurrentWorkspace = (cwd?: string) => ({
relationship: { kind: "subagent" as const },
workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) },
});
const detachedCurrentWorkspace = (cwd?: string) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) },
});
const detachedExistingWorkspace = (workspaceId: string, cwd?: string) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "existing" as const, workspaceId, ...(cwd ? { cwd } : {}) },
});
const ensureWorkspaceForCreate = async () => "workspace-created";
it("requires a concise title no longer than 60 characters", async () => {
const { agentManager, agentStorage } = createTestDeps();
@@ -663,14 +636,13 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
expect(tool).toBeDefined();
const missingTitle = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
initialPrompt: "test",
@@ -679,7 +651,7 @@ describe("create_agent MCP tool", () => {
expect(missingTitle.error.issues[0].path).toEqual(["title"]);
const tooLong = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "x".repeat(61),
@@ -689,7 +661,7 @@ describe("create_agent MCP tool", () => {
expect(tooLong.error.issues[0].path).toEqual(["title"]);
const ok = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "Short title",
@@ -704,12 +676,11 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "Short title",
@@ -722,128 +693,6 @@ describe("create_agent MCP tool", () => {
).toBe(true);
});
it("requires an explicit workspace", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
relationship: { kind: "detached" },
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
});
expect(parsed.success).toBe(false);
expect(
parsed.error.issues.some(
(issue: { path: Array<string | number> }) => issue.path[0] === "workspace",
),
).toBe(true);
});
it("rejects caller-only relationship and workspace intents without a caller agent", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
}),
).rejects.toThrow("relationship subagent requires an agent-scoped MCP session");
await expect(
tool.handler({
...detachedCurrentWorkspace(),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
}),
).rejects.toThrow("workspace current requires an agent-scoped MCP session");
});
it("requires a caller workspace for current workspace intent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
}),
).rejects.toThrow("Caller agent parent-agent has no current workspace");
});
it("attaches create_agent to an existing workspace id", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "existing-workspace-agent",
cwd: existingCwd,
workspaceId: "wks_existing",
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Existing workspace" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
listActiveWorkspaces: async () => [
{ workspaceId: "wks_existing", cwd: existingCwd, kind: "worktree" },
],
logger,
});
const tool = registeredTool(server, "create_agent");
const response = await tool.handler({
...detachedExistingWorkspace("wks_existing"),
title: "Existing workspace",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(response.structuredContent.workspaceId).toBe("wks_existing");
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: existingCwd,
}),
undefined,
{ workspaceId: "wks_existing" },
);
});
it("accepts provider features and passes them through createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
@@ -859,12 +708,11 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const input = {
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: "Feature test",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -884,7 +732,7 @@ describe("create_agent MCP tool", () => {
featureValues: { fast_mode: true },
}),
undefined,
{ workspaceId: "workspace-created" },
undefined,
);
});
@@ -912,12 +760,11 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const response = await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: "Mode test",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -933,13 +780,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const missingProvider = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
settings: { modeId: "default" },
title: "Short title",
initialPrompt: "test",
@@ -952,7 +798,7 @@ describe("create_agent MCP tool", () => {
).toBe(true);
const providerWithoutModel = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
settings: { modeId: "default" },
title: "Short title",
provider: "codex",
@@ -961,7 +807,7 @@ describe("create_agent MCP tool", () => {
expect(providerWithoutModel.success).toBe(false);
const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
settings: { modeId: "default" },
title: "Short title",
provider: "codex/",
@@ -970,7 +816,7 @@ describe("create_agent MCP tool", () => {
expect(providerWithEmptyModel.success).toBe(false);
const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
settings: { modeId: "default" },
title: "Short title",
provider: "/gpt-5.4",
@@ -980,7 +826,7 @@ describe("create_agent MCP tool", () => {
await expect(
tool.handler({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
settings: { modeId: "default" },
title: "Short title",
provider: "codex/gpt-5.4",
@@ -990,7 +836,7 @@ describe("create_agent MCP tool", () => {
).rejects.toThrow("Unrecognized key");
});
it("accepts worktree workspace intent in create_agent input validation", async () => {
it("accepts optional worktree intent fields in create_agent input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
@@ -1001,19 +847,20 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
...detachedWorktreeWorkspace(existingCwd, {
kind: "checkout-pr",
githubPrNumber: 42,
}),
cwd: existingCwd,
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
worktreeName: "review-42",
action: "checkout",
refName: "head-ref",
githubPrNumber: 42,
});
expect(parsed.success).toBe(true);
});
it("accepts each create_worktree target kind", async () => {
it("accepts each create_worktree target mode", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
@@ -1024,10 +871,9 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_worktree");
for (const target of [
{ kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" },
{ kind: "branch-off" },
{ kind: "checkout-branch", branch: "head-ref" },
{ kind: "checkout-pr", githubPrNumber: 42 },
{ mode: "branch-off", newBranch: "feature-x", base: "main" },
{ mode: "checkout-branch", branch: "head-ref" },
{ mode: "checkout-pr", prNumber: 42 },
] as const) {
const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target });
expect(parsed.success).toBe(true);
@@ -1057,14 +903,13 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...detachedDirectoryWorkspace("/path/that/does/not/exist"),
cwd: "/path/that/does/not/exist",
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -1087,12 +932,11 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: " Fix auth bug ",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -1104,7 +948,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth bug",
}),
undefined,
{ workspaceId: "workspace-created" },
undefined,
);
});
@@ -1123,12 +967,11 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: " Fix auth ",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -1139,7 +982,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth",
}),
undefined,
{ workspaceId: "workspace-created" },
undefined,
);
});
@@ -1158,12 +1001,11 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: "Config test",
initialPrompt: "Do work",
provider: "codex/gpt-5.4",
@@ -1180,7 +1022,7 @@ describe("create_agent MCP tool", () => {
thinkingOptionId: "think-hard",
}),
undefined,
{ labels: { source: "mcp" }, workspaceId: "workspace-created" },
{ labels: { source: "mcp" } },
);
});
@@ -1235,15 +1077,12 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
worktreeSlug: "agent-worktree",
branchName: "feature/agent-worktree",
baseBranch: "main",
}),
cwd: repoDir,
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
worktreeName: "agent-worktree",
baseBranch: "main",
background: true,
});
@@ -1252,14 +1091,6 @@ describe("create_agent MCP tool", () => {
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
expect(setupContinuations).toEqual(["agent"]);
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const branchName = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(branchName).toBe("feature/agent-worktree");
// The agent is stamped with the freshly created worktree's workspaceId so
// workspaceId-scoped archive can find and tear it down later.
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
@@ -1325,13 +1156,12 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
baseBranch: "main",
}),
cwd: repoDir,
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Fix workspace creation naming",
action: "branch-off",
baseBranch: "main",
background: true,
});
@@ -1411,13 +1241,12 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "checkout-branch",
branch: "existing-feature",
}),
cwd: repoDir,
title: "Checkout agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this checkout from the prompt",
action: "checkout",
refName: "existing-feature",
background: true,
});
@@ -1504,13 +1333,11 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(REPO_CWD, {
kind: "checkout-pr",
githubPrNumber: 123,
}),
cwd: REPO_CWD,
title: "PR agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt",
githubPrNumber: 123,
background: true,
});
@@ -1581,17 +1408,11 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_worktree");
const response = await tool.handler({
cwd: repoDir,
target: {
kind: "branch-off",
worktreeSlug: "tool-worktree",
branchName: "feature/tool-worktree",
baseBranch: "main",
},
target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
});
expect(response.structuredContent.branchName).toBe("feature/tool-worktree");
expect(response.structuredContent.branchName).toBe("tool-worktree");
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
@@ -1662,7 +1483,7 @@ describe("create_agent MCP tool", () => {
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" },
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
});
const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath);
listActiveWorkspaces.mockImplementation(async () => [
@@ -1764,7 +1585,7 @@ describe("create_agent MCP tool", () => {
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" },
target: { mode: "branch-off", newBranch: "archive-multi-worktree", base: "main" },
});
const worktreePath = z.string().parse(created.structuredContent.worktreePath);
@@ -1840,7 +1661,7 @@ describe("create_agent MCP tool", () => {
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-slug-worktree", baseBranch: "main" },
target: { mode: "branch-off", newBranch: "archive-slug-worktree", base: "main" },
});
const response = await archiveTool.handler({
@@ -1915,7 +1736,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: "Custom provider agent",
settings: { modeId: "default" },
provider: "zai/custom-model",
@@ -1933,7 +1754,6 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "voice-agent",
cwd: baseDir,
workspaceId: "wks_voice",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
@@ -1960,7 +1780,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await tool.handler({
...subagentCurrentWorkspace("subdir"),
cwd: "subdir",
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -1976,7 +1796,6 @@ describe("create_agent MCP tool", () => {
[PARENT_AGENT_ID_LABEL]: "voice-agent",
source: "voice",
},
workspaceId: "wks_voice",
},
);
await rm(baseDir, { recursive: true, force: true });
@@ -1987,7 +1806,6 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
@@ -2003,7 +1821,6 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -2012,7 +1829,6 @@ describe("create_agent MCP tool", () => {
).rejects.toThrow(/Unrecognized key/);
const parsed = await tool.inputSchema.safeParseAsync({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -2022,8 +1838,7 @@ describe("create_agent MCP tool", () => {
throw new Error("Expected caller create_agent input to parse");
}
expect(parsed.data).toMatchObject({
relationship: { kind: "subagent" },
workspace: { kind: "current" },
detached: false,
notifyOnFinish: true,
});
});
@@ -2033,7 +1848,6 @@ describe("create_agent MCP tool", () => {
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
@@ -2062,7 +1876,6 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
const response = await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -2078,7 +1891,6 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
@@ -2101,10 +1913,10 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedCurrentWorkspace(),
title: "Detached",
provider: "codex/gpt-5.4",
initialPrompt: "Take over",
detached: true,
labels: {
[PARENT_AGENT_ID_LABEL]: "spoofed-parent",
source: "handoff",
@@ -2120,7 +1932,6 @@ describe("create_agent MCP tool", () => {
labels: {
source: "handoff",
},
workspaceId: "wks_parent",
},
);
});
@@ -2130,7 +1941,6 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent);
@@ -2157,7 +1967,6 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
const input = {
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -2180,7 +1989,6 @@ describe("create_agent MCP tool", () => {
labels: {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
},
workspaceId: "wks_parent",
},
);
});
@@ -2210,7 +2018,6 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
const result = await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -2240,12 +2047,11 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: "Injected config test",
settings: { modeId: "auto" },
provider: "codex/gpt-5.4",
@@ -2259,7 +2065,7 @@ describe("create_agent MCP tool", () => {
});
expect(configArg.mcpServers).toBeUndefined();
expect(agentIdArg).toBeUndefined();
expect(optionsArg).toEqual({ workspaceId: "workspace-created" });
expect(optionsArg).toBeUndefined();
});
it("rejects an explicit mode that is not valid for the target provider", async () => {
@@ -2272,14 +2078,13 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: providerSnapshot.manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: "Bad mode",
provider: "opencode/gpt-5.4",
settings: { modeId: "bypassPermissions" },
@@ -2320,13 +2125,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: "Dynamic mode",
provider: "codex/gpt-5.4",
settings: { modeId: "dynamic" },
@@ -2336,7 +2140,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "dynamic" }),
undefined,
{ workspaceId: "workspace-created" },
undefined,
);
});
@@ -2359,13 +2163,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: providerSnapshot.manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(existingCwd),
cwd: existingCwd,
title: "Legacy mode",
provider: "opencode/gpt-5.4",
settings: { modeId: "full-access" },
@@ -2375,7 +2178,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
undefined,
{ workspaceId: "workspace-created" },
undefined,
);
});
@@ -2384,7 +2187,6 @@ describe("create_agent MCP tool", () => {
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent;
@@ -2412,7 +2214,6 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "claude/claude-sonnet-4-20250514",
initialPrompt: "Do work",
@@ -2436,7 +2237,6 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent);
@@ -2458,7 +2258,6 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "opencode/gpt-5.4",
settings: { modeId: "build" },
@@ -2473,151 +2272,6 @@ describe("create_agent MCP tool", () => {
});
});
describe("send_agent_prompt MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
it("defaults agent-scoped prompts to background finish notifications", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
const childAgent = {
id: "child-agent",
cwd: existingCwd,
lifecycle: "running",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent;
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
if (agentId === "parent-agent") return parentAgent;
if (agentId === "child-agent") return childAgent;
return null;
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
const parsed = await tool.inputSchema.safeParseAsync({
agentId: "child-agent",
prompt: "Follow up",
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
throw new Error("Expected caller send_agent_prompt input to parse");
}
expect(parsed.data).toMatchObject({
background: true,
notifyOnFinish: true,
});
const response = await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1);
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
expect(response.structuredContent.guidance).toBe(
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
);
});
it("keeps top-level prompts blocking by default", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
const parsed = await tool.inputSchema.safeParseAsync({
agentId: "child-agent",
prompt: "Follow up",
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
throw new Error("Expected top-level send_agent_prompt input to parse");
}
expect(parsed.data).toMatchObject({
background: false,
notifyOnFinish: false,
});
await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith(
"child-agent",
expect.objectContaining({ waitForActive: true }),
);
});
it("does not arm a finish notification for blocking agent-scoped prompts", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
const childAgent = {
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent;
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
if (agentId === "parent-agent") return parentAgent;
if (agentId === "child-agent") return childAgent;
return null;
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
await invokeToolWithParsedInput(tool, {
agentId: "child-agent",
prompt: "Follow up",
background: false,
});
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith(
"child-agent",
expect.objectContaining({ waitForActive: true }),
);
});
});
describe("update_agent MCP tool", () => {
const logger = createTestLogger();
+141 -298
View File
@@ -34,7 +34,7 @@ import {
type ArchiveDependencies,
} from "../workspace-archive-service.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { createAgentCommand, type CreateAgentFromMcpInput } from "./create-agent/create.js";
import { createAgentCommand } from "./create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
@@ -100,7 +100,8 @@ export interface AgentMcpServerOptions {
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id.
// Mints a fresh workspace for a cwd and returns its id, used when an agent is
// created with no parent and no worktree.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
paseoHome?: string;
worktreesRoot?: string;
@@ -746,107 +747,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.describe("Draft provider feature values."),
})
.strict();
const AgentRelationshipInputSchema = z.discriminatedUnion("kind", [
z
.object({ kind: z.literal("subagent") })
.strict()
.describe("Create a child agent under this agent's subagent track."),
z
.object({ kind: z.literal("detached") })
.strict()
.describe("Create a root agent that does not appear in this agent's subagent track."),
]);
const AgentCreateWorktreeTargetInputSchema = z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("branch-off"),
worktreeSlug: z
.string()
.min(1)
.optional()
.describe("Optional worktree slug/path label. Omit to let Paseo generate one."),
branchName: z
.string()
.min(1)
.optional()
.describe("Optional git branch name. Defaults to the worktree slug."),
baseBranch: z
.string()
.min(1)
.optional()
.describe("Optional base branch. Defaults to the repository default branch."),
})
.strict()
.describe("Create a new branch in a new Paseo worktree."),
z
.object({
kind: z.literal("checkout-branch"),
branch: z.string().min(1).describe("Existing branch to check out."),
})
.strict()
.describe("Check out an existing branch in a new Paseo worktree."),
z
.object({
kind: z.literal("checkout-pr"),
githubPrNumber: z.number().int().positive().describe("GitHub pull request number."),
})
.strict()
.describe("Check out a GitHub pull request in a new Paseo worktree."),
]);
const AgentWorkspaceInputSchema = z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("current"),
cwd: z.string().optional().describe("Optional runtime cwd. Defaults to the caller's cwd."),
})
.strict()
.describe("Use the caller's current workspace."),
z
.object({
kind: z.literal("existing"),
workspaceId: z.string().min(1).describe("Existing workspace id to attach the agent to."),
cwd: z
.string()
.optional()
.describe("Optional runtime cwd. Defaults to the existing workspace cwd."),
})
.strict()
.describe("Attach the agent to an existing workspace."),
z
.object({
kind: z.literal("create"),
source: z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("directory"),
path: z
.string()
.optional()
.describe("Optional directory path. Defaults to the caller's cwd."),
})
.strict(),
z
.object({
kind: z.literal("worktree"),
cwd: z
.string()
.optional()
.describe("Optional source repository. Defaults to the caller's cwd."),
target: AgentCreateWorktreeTargetInputSchema,
})
.strict(),
]),
})
.strict()
.describe("Create a new workspace for the agent."),
]);
const commonCreateAgentInputSchema = {
relationship: AgentRelationshipInputSchema.describe(
"Whether the created agent is a subagent under you or a detached root agent.",
),
workspace: AgentWorkspaceInputSchema.describe(
"Workspace ownership/location for the created agent.",
),
const agentToAgentInputSchema = {
cwd: z
.string()
.optional()
.describe("Optional working directory. Defaults to your current working directory."),
title: z
.string()
.trim()
@@ -865,9 +770,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.trim()
.min(1, "initialPrompt is required")
.describe("Required first task to run immediately after creation."),
};
const agentToAgentInputSchema = {
...commonCreateAgentInputSchema,
detached: z
.boolean()
.optional()
.default(false)
.describe(
"If true, the created agent stands on its own: it does not appear in your subagent track and is not archived with you.",
),
notifyOnFinish: z
.boolean()
.optional()
@@ -876,8 +785,48 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"Get notified when the created agent finishes, errors, or needs permission. Set false only for truly fire-and-forget agents.",
),
};
const topLevelInputSchema = {
...commonCreateAgentInputSchema,
cwd: z
.string()
.describe("Required working directory for the agent (absolute, relative, or ~)."),
title: z
.string()
.trim()
.min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer")
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
provider: ProviderModelInputSchema.describe(
"Required provider/model pair, for example codex/gpt-5.4.",
),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
settings: CreateAgentSettingsInputSchema.optional().describe(
"Initial runtime settings for the new agent.",
),
initialPrompt: z
.string()
.trim()
.min(1, "initialPrompt is required")
.describe("Required first task to run immediately after creation."),
worktreeName: z
.string()
.optional()
.describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."),
baseBranch: z
.string()
.optional()
.describe("Required when worktreeName is set: the base branch to diff/merge against."),
refName: z.string().min(1).optional().describe("Optional source ref for worktree creation."),
action: z
.enum(["branch-off", "checkout"])
.optional()
.describe("Optional worktree creation action."),
githubPrNumber: z
.number()
.int()
.positive()
.optional()
.describe("Optional GitHub pull request number to checkout."),
background: z
.boolean()
.optional()
@@ -897,48 +846,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict();
const commonSendAgentPromptInputSchema = {
agentId: z.string(),
prompt: z.string(),
sessionMode: z.string().optional().describe("Optional mode to set before running the prompt."),
};
const agentToAgentSendAgentPromptInputSchema = {
...commonSendAgentPromptInputSchema,
background: z
.boolean()
.optional()
.default(true)
.describe(
"Run agent in background. Agent-scoped default is true so you can continue until the finish notification arrives. Set false only when you need a blocking response.",
),
notifyOnFinish: z
.boolean()
.optional()
.default(true)
.describe(
"Get notified when the prompted agent finishes, errors, or needs permission. Set false only for truly fire-and-forget prompts.",
),
};
const topLevelSendAgentPromptInputSchema = {
...commonSendAgentPromptInputSchema,
background: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
notifyOnFinish: z
.boolean()
.optional()
.default(false)
.describe(
"Agent-scoped only: get notified when the prompted agent finishes, errors, or needs permission.",
),
};
const sendAgentPromptInputSchema = callerAgentId
? agentToAgentSendAgentPromptInputSchema
: topLevelSendAgentPromptInputSchema;
const inspectProviderInputSchema = {
provider: ProviderOrProviderModelInputSchema.describe(
"Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).",
@@ -1002,14 +909,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
{
title: "Create agent",
description:
"Create an agent. Requires relationship, workspace, provider/model (for example codex/gpt-5.4), and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
"Create an agent tied to a working directory. Requires provider/model, for example codex/gpt-5.4. Do not guess; call list_providers and list_models first if uncertain. Optionally run an initial prompt immediately or create a git worktree for the agent.",
inputSchema: createAgentInputSchema,
outputSchema: {
agentId: z.string(),
type: AgentProviderEnum,
status: AgentStatusEnum,
cwd: z.string(),
workspaceId: z.string().optional(),
currentModeId: z.string().nullable(),
availableModes: z.array(ProviderModeSchema),
lastMessage: z.string().nullable().optional(),
@@ -1018,19 +924,19 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async (args: unknown) => {
const resolvedArgs = await resolveCreateAgentToolArgs(args);
const resolvedArgs = resolveCreateAgentToolArgs(args);
const { parsedArgs, worktree } = resolvedArgs;
let requestedBackground: boolean;
let notifyOnFinish: boolean;
let detached: boolean;
if (resolvedArgs.kind === "agent-scoped") {
requestedBackground = true;
notifyOnFinish = parsedArgs.notifyOnFinish;
detached = resolvedArgs.relationship.kind === "detached";
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish;
detached = resolvedArgs.parsedArgs.detached;
} else {
requestedBackground = resolvedArgs.parsedArgs.background;
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false;
detached = resolvedArgs.parsedArgs.relationship.kind === "detached";
detached = false;
}
const {
snapshot,
@@ -1055,8 +961,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
provider: parsedArgs.provider,
title: parsedArgs.title,
initialPrompt: parsedArgs.initialPrompt,
cwd: resolvedArgs.cwd,
workspaceId: resolvedArgs.workspaceId,
cwd: parsedArgs.cwd,
thinking: parsedArgs.settings?.thinkingOptionId,
features: parsedArgs.settings?.features,
labels: parsedArgs.labels,
@@ -1082,7 +987,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
type: snapshot.provider,
status: result.status,
cwd: liveSnapshot.cwd,
...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}),
currentModeId: liveSnapshot.currentModeId,
availableModes: liveSnapshot.availableModes,
lastMessage: result.lastMessage,
@@ -1114,7 +1018,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
type: snapshot.provider,
status: currentSnapshot.lifecycle,
cwd: currentSnapshot.cwd,
...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}),
currentModeId: currentSnapshot.currentModeId,
availableModes: currentSnapshot.availableModes,
lastMessage: null,
@@ -1130,138 +1033,48 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
| {
kind: "agent-scoped";
parsedArgs: AgentToAgentCreateAgentArgs;
relationship: AgentToAgentCreateAgentArgs["relationship"];
cwd: string | undefined;
workspaceId: string | undefined;
worktree: CreateAgentFromMcpInput["worktree"];
worktree: undefined;
}
| {
kind: "top-level";
parsedArgs: TopLevelCreateAgentArgs;
cwd: string | undefined;
workspaceId: string | undefined;
worktree: CreateAgentFromMcpInput["worktree"];
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
};
async function resolveCreateAgentToolArgs(args: unknown): Promise<ResolvedCreateAgentToolArgs> {
function resolveCreateAgentToolArgs(args: unknown): ResolvedCreateAgentToolArgs {
if (callerAgentId) {
const parsed = agentToAgentCreateAgentArgsSchema.parse(args);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace);
return {
kind: "agent-scoped",
parsedArgs: parsed,
relationship: parsed.relationship,
cwd,
workspaceId,
worktree,
parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args),
worktree: undefined,
};
}
const parsedArgs = topLevelCreateAgentArgsSchema.parse(args);
if (parsedArgs.relationship.kind === "subagent") {
throw new Error("relationship subagent requires an agent-scoped MCP session");
}
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace);
return {
kind: "top-level",
parsedArgs,
cwd,
workspaceId,
worktree,
worktree: resolveTopLevelCreateAgentWorktree(parsedArgs),
};
}
async function resolveCreateAgentWorkspace(
workspace: AgentToAgentCreateAgentArgs["workspace"] | TopLevelCreateAgentArgs["workspace"],
): Promise<{
cwd: string | undefined;
workspaceId: string | undefined;
worktree: CreateAgentFromMcpInput["worktree"];
}> {
if (workspace.kind === "current") {
if (!callerAgentId) {
throw new Error("workspace current requires an agent-scoped MCP session");
function resolveTopLevelCreateAgentWorktree(args: TopLevelCreateAgentArgs):
| {
worktreeName?: string;
baseBranch?: string;
refName?: string;
action?: "branch-off" | "checkout";
githubPrNumber?: number;
}
const callerAgent = resolveCallerAgent();
if (!callerAgent?.workspaceId) {
throw new Error(`Caller agent ${callerAgentId} has no current workspace`);
}
return {
cwd: workspace.cwd,
workspaceId: callerAgent.workspaceId,
worktree: undefined,
};
}
if (workspace.kind === "existing") {
if (!options.listActiveWorkspaces) {
throw new Error("Workspace lookup is not configured");
}
const existingWorkspace = (await options.listActiveWorkspaces()).find(
(candidate) => candidate.workspaceId === workspace.workspaceId,
);
if (!existingWorkspace) {
throw new Error(`Workspace ${workspace.workspaceId} not found`);
}
const cwd = workspace.cwd
? resolveScopedCwd(workspace.cwd, { required: true })
: existingWorkspace.cwd;
const lockedCwd = callerContext?.lockedCwd?.trim();
if (lockedCwd && !isSameOrDescendantPath(expandUserPath(lockedCwd), cwd)) {
throw new Error(`Workspace ${workspace.workspaceId} is outside the allowed cwd`);
}
return {
cwd,
workspaceId: workspace.workspaceId,
worktree: undefined,
};
}
if (workspace.source.kind === "directory") {
const cwd = resolveScopedCwd(workspace.source.path, { required: true });
if (!options.ensureWorkspaceForCreate) {
throw new Error("Workspace creation is not configured");
}
return {
cwd,
workspaceId: await options.ensureWorkspaceForCreate(cwd),
worktree: undefined,
};
}
const cwd = resolveScopedCwd(workspace.source.cwd, { required: true });
| undefined {
return {
cwd,
workspaceId: undefined,
worktree: resolveCreateAgentWorktree(workspace.source.target),
worktreeName: args.worktreeName,
baseBranch: args.baseBranch,
refName: args.refName,
action: args.action,
githubPrNumber: args.githubPrNumber,
};
}
function resolveCreateAgentWorktree(
target: z.infer<typeof AgentCreateWorktreeTargetInputSchema>,
): NonNullable<CreateAgentFromMcpInput["worktree"]> {
switch (target.kind) {
case "branch-off":
return {
action: "branch-off",
worktreeName: target.worktreeSlug,
branchName: target.branchName,
baseBranch: target.baseBranch,
};
case "checkout-branch":
return {
action: "checkout",
refName: target.branch,
};
case "checkout-pr":
return {
action: "checkout",
githubPrNumber: target.githubPrNumber,
};
default:
throw new Error("unreachable");
}
}
registerTool(
"wait_for_agent",
{
@@ -1344,27 +1157,40 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
{
title: "Send agent prompt",
description:
"Send a task to a running agent. Agent-scoped callers run in background by default; top-level callers wait by default.",
inputSchema: sendAgentPromptInputSchema,
"Send a task to a running agent. Returns immediately after the agent begins processing.",
inputSchema: {
agentId: z.string(),
prompt: z.string(),
sessionMode: z
.string()
.optional()
.describe("Optional mode to set before running the prompt."),
background: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
notifyOnFinish: z
.boolean()
.optional()
.default(false)
.describe(
"Agent-scoped only: get notified when this run finishes, errors, or needs permission.",
),
},
outputSchema: {
success: z.boolean(),
status: AgentStatusEnum,
lastMessage: z.string().nullable().optional(),
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
guidance: z.string().optional(),
},
},
async ({
agentId,
prompt,
sessionMode,
background = Boolean(callerAgentId),
notifyOnFinish = Boolean(callerAgentId),
}) => {
async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => {
if (agentManager.hasInFlightRun(agentId)) {
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
}
const shouldNotifyOnFinish = Boolean(callerAgentId && notifyOnFinish && background);
await sendPromptToAgent({
agentManager,
@@ -1375,7 +1201,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
logger: childLogger,
});
if (shouldNotifyOnFinish && callerAgentId) {
if (notifyOnFinish && callerAgentId) {
setupFinishNotification({
agentManager,
agentStorage,
@@ -1415,12 +1241,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
status: currentSnapshot?.lifecycle ?? "idle",
lastMessage: null,
permission: null,
...(shouldNotifyOnFinish
? {
guidance:
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
}
: {}),
};
const validJson = ensureValidJson(responseData);
@@ -2331,12 +2151,37 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"Create a Paseo-managed git worktree. Branch off a new branch, check out an existing branch, or check out a GitHub PR.",
inputSchema: {
cwd: z.string().optional().describe("Repository directory. Defaults to the agent's cwd."),
target: AgentCreateWorktreeTargetInputSchema.describe("What the worktree should contain."),
target: z
.discriminatedUnion("mode", [
z
.object({
mode: z.literal("branch-off"),
newBranch: z.string().min(1).describe("Name for the new branch."),
base: z
.string()
.min(1)
.optional()
.describe("Base ref. Defaults to the repo's default branch."),
})
.describe("Create a new branch off a base."),
z
.object({
mode: z.literal("checkout-branch"),
branch: z.string().min(1).describe("Existing branch to check out."),
})
.describe("Check out an existing branch."),
z
.object({
mode: z.literal("checkout-pr"),
prNumber: z.number().int().positive().describe("Pull request number."),
})
.describe("Check out a GitHub pull request."),
])
.describe("What the worktree should contain."),
},
outputSchema: {
branchName: z.string(),
worktreePath: z.string(),
workspaceId: z.string(),
},
},
async ({ cwd, target }) => {
@@ -2352,7 +2197,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!commandResult.ok) {
throw new WorktreeRequestError(commandResult.error);
}
const { worktree, workspace } = commandResult.createdWorktree;
const { worktree } = commandResult.createdWorktree;
await options.workspaceGitService?.listWorktrees?.(repoRoot, {
force: true,
reason: "mcp:create-worktree",
@@ -2363,7 +2208,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
structuredContent: ensureValidJson({
branchName: worktree.branchName,
worktreePath: worktree.worktreePath,
workspaceId: workspace.workspaceId,
}),
};
},
@@ -2576,9 +2420,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}
type McpCreateWorktreeTarget =
| { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string }
| { kind: "checkout-branch"; branch: string }
| { kind: "checkout-pr"; githubPrNumber: number };
| { mode: "branch-off"; newBranch: string; base?: string }
| { mode: "checkout-branch"; branch: string }
| { mode: "checkout-pr"; prNumber: number };
interface ArchiveWorktreeCommandContext {
agentManager: AgentManager;
@@ -2645,19 +2489,18 @@ function createMcpWorktreeCommandInput(
target: McpCreateWorktreeTarget,
): CreatePaseoWorktreeCommandInput {
const base = { cwd: repoRoot } as const;
switch (target.kind) {
switch (target.mode) {
case "branch-off":
return {
...base,
worktreeSlug: target.worktreeSlug,
branchName: target.branchName,
worktreeSlug: target.newBranch,
action: "branch-off",
...(target.baseBranch ? { refName: target.baseBranch } : {}),
...(target.base ? { refName: target.base } : {}),
};
case "checkout-branch":
return { ...base, action: "checkout", refName: target.branch };
case "checkout-pr":
return { ...base, action: "checkout", githubPrNumber: target.githubPrNumber };
return { ...base, action: "checkout", githubPrNumber: target.prNumber };
default:
throw new Error("unreachable");
}
@@ -21,7 +21,6 @@ import {
} from "./create-agent-mode.js";
import { normalizeAgentModelDefinition } from "./agent-sdk-types.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -72,13 +71,12 @@ export interface BuildProviderRegistryOptions {
runtimeSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
managedProcesses?: ManagedProcessRegistry;
isDev?: boolean;
}
interface ProviderClientFactoryOptions extends Pick<
BuildProviderRegistryOptions,
"workspaceGitService" | "managedProcesses"
"workspaceGitService"
> {
providerParams?: unknown;
customProvider?: {
@@ -128,10 +126,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
command: getCursorACPCommand(runtimeSettings),
env: runtimeSettings?.env,
}),
opencode: (logger, runtimeSettings, options) =>
new OpenCodeAgentClient(logger, runtimeSettings, {
managedProcesses: options?.managedProcesses,
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
pi: (logger, runtimeSettings, options) =>
new PiRpcAgentClient({
logger,
@@ -533,7 +528,7 @@ function createResolvedProviderClient(
function buildResolvedBuiltinProviders(
providerOverrides: Record<string, ProviderOverride>,
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
options: Pick<BuildProviderRegistryOptions, "workspaceGitService" | "managedProcesses">,
options: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
isDev: boolean,
): Map<string, ResolvedProvider> {
const resolvedProviders = new Map<string, ResolvedProvider>();
@@ -562,7 +557,6 @@ function buildResolvedBuiltinProviders(
createBaseClient: (logger) =>
factory(logger, mergedRuntimeSettings, {
workspaceGitService: options.workspaceGitService,
managedProcesses: options.managedProcesses,
providerParams: override?.params,
}),
});
@@ -574,7 +568,6 @@ function buildResolvedBuiltinProviders(
function addDerivedProviders(
resolvedProviders: Map<string, ResolvedProvider>,
providerOverrides: Record<string, ProviderOverride>,
options: Pick<BuildProviderRegistryOptions, "managedProcesses">,
): void {
for (const [providerId, override] of Object.entries(providerOverrides)) {
if (resolvedProviders.has(providerId) || BUILTIN_PROVIDER_IDS.includes(providerId)) {
@@ -660,7 +653,6 @@ function addDerivedProviders(
providerParams,
createBaseClient: (logger) =>
baseFactory(logger, mergedRuntimeSettings, {
managedProcesses: options.managedProcesses,
providerParams,
customProvider: {
id: providerId,
@@ -683,13 +675,10 @@ export function buildProviderRegistry(
runtimeSettings,
{
workspaceGitService: options?.workspaceGitService,
managedProcesses: options?.managedProcesses,
},
options?.isDev === true,
);
addDerivedProviders(resolvedProviders, providerOverrides, {
managedProcesses: options?.managedProcesses,
});
addDerivedProviders(resolvedProviders, providerOverrides);
return Object.fromEntries(
[...resolvedProviders.entries()].map(([provider, resolved]) => [
@@ -16,7 +16,6 @@ import type {
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -57,7 +56,6 @@ export interface ProviderSnapshotManagerOptions {
runtimeSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
managedProcesses?: ManagedProcessRegistry;
isDev?: boolean;
extraClients?: Partial<Record<AgentProvider, AgentClient>>;
refreshTimeoutMs?: number;
@@ -129,7 +127,6 @@ export class ProviderSnapshotManager {
private readonly refreshTimeoutMs: number;
private readonly logger: Logger;
private readonly workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
private readonly managedProcesses?: ManagedProcessRegistry;
private readonly isDev: boolean;
private readonly extraClients: Partial<Record<AgentProvider, AgentClient>>;
private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined;
@@ -141,7 +138,6 @@ export class ProviderSnapshotManager {
constructor(options: ProviderSnapshotManagerOptions) {
this.logger = options.logger;
this.workspaceGitService = options.workspaceGitService;
this.managedProcesses = options.managedProcesses;
this.isDev = options.isDev === true;
this.extraClients = options.extraClients ?? {};
this.runtimeSettings = options.runtimeSettings;
@@ -374,7 +370,6 @@ export class ProviderSnapshotManager {
runtimeSettings: this.runtimeSettings,
providerOverrides: this.providerOverrides,
workspaceGitService: this.workspaceGitService,
managedProcesses: this.managedProcesses,
isDev: this.isDev,
});
@@ -14,7 +14,6 @@ interface QueryMock {
close: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
getContextUsage: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
@@ -54,7 +53,6 @@ function createBaseQueryMock(nextImpl: QueryMock["next"]): QueryMock {
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
getContextUsage: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
@@ -949,7 +947,7 @@ test("captures Claude stderr in the turn failure diagnostic when stderr arrives
}
});
test("preserves bypass capability across query restarts triggered by thinking changes", async () => {
test("omits dangerous permission-skip allowance from default-mode launches", async () => {
const capturedOptions: Array<{
permissionMode?: string;
allowDangerouslySkipPermissions?: boolean;
@@ -966,11 +964,7 @@ test("preserves bypass capability across query restarts triggered by thinking ch
effort?: string;
};
}) => {
capturedOptions.push({
permissionMode: options.permissionMode,
allowDangerouslySkipPermissions: options.allowDangerouslySkipPermissions,
effort: options.effort,
});
capturedOptions.push(options);
return createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
},
@@ -979,21 +973,13 @@ test("preserves bypass capability across query restarts triggered by thinking ch
const session = await createSession();
try {
await session.setMode("bypassPermissions");
await session.setMode("acceptEdits");
await session.setThinkingOption("high");
await session.setMode("bypassPermissions");
await collectUntilTerminal(streamSession(session, "say exactly hello"));
expect(capturedOptions).toHaveLength(2);
expect(capturedOptions).toHaveLength(1);
expect(capturedOptions[0]).toMatchObject({
permissionMode: "default",
allowDangerouslySkipPermissions: true,
});
expect(capturedOptions[1]).toMatchObject({
permissionMode: "acceptEdits",
allowDangerouslySkipPermissions: true,
effort: "high",
});
expect(capturedOptions[0]).not.toHaveProperty("allowDangerouslySkipPermissions");
} finally {
await session.close();
}
@@ -1080,52 +1066,39 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
const internal: {
turnState: "idle" | "foreground" | "autonomous";
nextTurnOrdinal: number;
routeSdkMessageFromPump: (
message: Record<string, unknown>,
activeQuery: QueryMock,
) => Promise<void>;
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
autonomousTurn: { id: string } | null;
} = asInternals(session);
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
internal.turnState = "idle";
await internal.routeSdkMessageFromPump(
{
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "AUTO " },
},
internal.routeSdkMessageFromPump({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "AUTO " },
},
queryMock,
);
});
const firstRunId = internal.autonomousTurn?.id ?? null;
expect(firstRunId).toBe("autonomous-turn-1");
expect(internal.nextTurnOrdinal).toBe(2);
await internal.routeSdkMessageFromPump(
{
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "WAKE" },
},
internal.routeSdkMessageFromPump({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "WAKE" },
},
queryMock,
);
});
expect(internal.autonomousTurn?.id).toBe(firstRunId);
expect(internal.nextTurnOrdinal).toBe(2);
await internal.routeSdkMessageFromPump(
{
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
queryMock,
);
internal.routeSdkMessageFromPump({
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
});
expect(internal.autonomousTurn).toBeNull();
await session.close();
@@ -13,11 +13,11 @@ import {
normalizeClaudeAskUserQuestionUpdatedInput,
toClaudeSdkMcpConfig,
} from "./agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertUsage(message: SDKMessage): AgentUsage | undefined;
}
afterEach(() => {
@@ -561,7 +561,6 @@ describe("ClaudeAgentSession features", () => {
return: queryReturn,
applyFlagSettings: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
getContextUsage: vi.fn(async () => undefined),
[Symbol.asyncIterator](): AsyncIterator<SDKMessage, void> {
return {
next: async () => {
@@ -1045,11 +1044,6 @@ describe("ClaudeAgentClient.listImportableSessions", () => {
describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger();
interface QueryFactoryForTurnsOptions {
currentContextUsageByTurn?: Array<Record<string, unknown> | undefined>;
model?: string;
}
async function createSessionForTest(): Promise<TestClaudeSession> {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
const session = await client.createSession({
@@ -1059,39 +1053,11 @@ describe("ClaudeAgentSession context window usage", () => {
return session as unknown as TestClaudeSession;
}
async function createSessionForTurns(
turns: Array<Array<Record<string, unknown>>>,
options?: QueryFactoryForTurnsOptions,
): Promise<AgentSession> {
const client = new ClaudeAgentClient({
logger,
queryFactory: createQueryFactoryForTurns(turns, options),
resolveBinary: async () => "/test/claude/bin",
});
return await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: options?.model,
});
}
async function collectStreamEvents(session: AgentSession, prompt = "turn") {
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, prompt)) {
events.push(event);
}
return events;
}
function createQueryFactoryForTurns(
turns: Array<Array<Record<string, unknown>>>,
options?: QueryFactoryForTurnsOptions,
) {
function createQueryFactoryForTurns(turns: Array<Array<Record<string, unknown>>>) {
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const queuedMessages: Array<Record<string, unknown>> = [];
const waiters: Array<() => void> = [];
let turnIndex = 0;
let contextUsageIndex = 0;
const closedRef = { value: false };
function wakeNextWaiter() {
@@ -1140,11 +1106,6 @@ describe("ClaudeAgentSession context window usage", () => {
}),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
getContextUsage: vi.fn(async () => {
const usage = options?.currentContextUsageByTurn?.[contextUsageIndex];
contextUsageIndex += 1;
return usage;
}),
supportedModels: vi.fn(async () => []),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
@@ -1155,133 +1116,6 @@ describe("ClaudeAgentSession context window usage", () => {
});
}
function createInitMessage(sessionId = "session-1"): Record<string, unknown> {
return {
type: "system",
subtype: "init",
session_id: sessionId,
permissionMode: "default",
model: "claude-sonnet-4-6",
};
}
function createClaudeCurrentContextUsage(
totalTokens: number,
maxTokens: number,
): Record<string, unknown> {
return {
categories: [],
totalTokens,
maxTokens,
rawMaxTokens: maxTokens,
percentage: totalTokens / maxTokens,
gridRows: [],
model: "claude-sonnet-4-6",
memoryFiles: [],
mcpTools: [],
agents: [],
isAutoCompactEnabled: true,
apiUsage: null,
};
}
function createSuccessResult(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
type: "result",
subtype: "success",
duration_ms: 100,
duration_api_ms: 75,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0.25,
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-1",
session_id: "session-1",
...overrides,
};
}
function createMessageStartEvent(
usage: Record<string, unknown> = {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
): Record<string, unknown> {
return {
type: "stream_event",
event: {
type: "message_start",
message: { usage },
},
session_id: "session-1",
};
}
function createMessageDeltaEvent(outputTokens: number): Record<string, unknown> {
return {
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: outputTokens,
},
},
session_id: "session-1",
};
}
function createAgentToolStartEvent(): Record<string, unknown> {
return {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "toolu-agent-1",
name: "Agent",
input: {
description: "Check something in a subagent",
prompt: "Return a short answer",
},
},
},
session_id: "session-1",
};
}
function createSubagentTaskNotification(): Record<string, unknown> {
return {
type: "system",
subtype: "task_notification",
uuid: "task-note-1",
task_id: "task-1",
tool_use_id: "toolu-agent-1",
status: "running",
summary: "Subagent is working",
usage: {
total_tokens: 18_876,
tool_uses: 1,
duration_ms: 50,
input_tokens: 12_000,
cache_read_input_tokens: 6_000,
output_tokens: 876,
},
session_id: "session-1",
};
}
test("passes persistSession through to the Claude SDK query options", async () => {
const createResultTurn = (sessionId: string) => [
{
@@ -1356,7 +1190,6 @@ describe("ClaudeAgentSession context window usage", () => {
close: () => undefined,
setPermissionMode: async () => undefined,
setModel: async () => undefined,
getContextUsage: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [
{
@@ -1556,152 +1389,357 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("reports Claude's current context usage after an Agent subagent runs", async () => {
const session = await createSessionForTurns(
[
[
createInitMessage(),
createMessageStartEvent(),
createAgentToolStartEvent(),
createSubagentTaskNotification(),
createMessageDeltaEvent(25),
createSuccessResult({
usage: {
input_tokens: 9_000,
cache_creation_input_tokens: 300,
cache_read_input_tokens: 700,
output_tokens: 400,
},
}),
],
],
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage(
{
currentContextUsageByTurn: [createClaudeCurrentContextUsage(12_345, 200_000)],
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
},
{
"claude-sonnet-4-6": { contextWindow: 200_000 },
"claude-opus-4-6": { contextWindow: 1_000_000 },
},
);
try {
const result = await session.run("turn");
expect(result.usage).toEqual({
inputTokens: 9_000,
cachedInputTokens: 700,
outputTokens: 400,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 12_345,
});
} finally {
await session.close();
}
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowMaxTokens: 1_000_000,
contextWindowUsedTokens: 22,
});
});
test("does not report task notification tokens as parent context usage", async () => {
const session = await createSessionForTurns([
[
createInitMessage(),
createMessageStartEvent(),
createAgentToolStartEvent(),
createSubagentTaskNotification(),
{
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Subagent progress",
usage: {
total_tokens: 9_999,
tool_uses: 1,
duration_ms: 50,
},
uuid: "task-progress-1",
session_id: "session-1",
},
createMessageDeltaEvent(25),
createSuccessResult(),
],
]);
test("contextWindowUsedTokens falls back to result usage when no task_progress was received", async () => {
const session = await createSessionForTest();
try {
const result = await session.run("turn");
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(result.usage).toEqual({
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 25,
});
});
test("contextWindowUsedTokens is populated from task_progress usage data", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
input_tokens: 345,
cache_read_input_tokens: 55,
},
uuid: "task-progress-1",
session_id: "session-1",
});
const events = session.translateMessageToEvents({
type: "result",
subtype: "success",
duration_ms: 100,
duration_api_ms: 75,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0.25,
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-1",
session_id: "session-1",
});
expect(events).toContainEqual({
type: "turn_completed",
provider: "claude",
usage: {
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 175,
});
} finally {
await session.close();
}
contextWindowUsedTokens: 999,
},
});
});
test("falls back to the active result iteration when current and stream usage are unavailable", async () => {
const session = await createSessionForTurns([
[
createInitMessage(),
createSuccessResult({
test("task_progress emits a usage_updated event", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
},
uuid: "task-progress-1",
session_id: "session-1",
});
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 999,
},
});
});
test("task_notification emits a usage_updated event", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "system",
subtype: "task_notification",
uuid: "task-note-1",
task_id: "task-1",
status: "running",
summary: "Background task still running",
usage: {
total_tokens: 777,
tool_uses: 1,
duration_ms: 50,
},
session_id: "session-1",
} as unknown as SDKMessage);
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 777,
},
});
});
test("message_start stream events emit usage_updated with per-request usage", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 5_000,
cache_creation_input_tokens: 500,
cache_read_input_tokens: 600,
output_tokens: 700,
iterations: [
{
input_tokens: 100,
cache_creation_input_tokens: 10,
cache_read_input_tokens: 20,
output_tokens: 30,
},
{
input_tokens: 2,
cache_creation_input_tokens: 10,
cache_read_input_tokens: 100,
output_tokens: 5,
},
],
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
}),
],
]);
},
},
session_id: "session-1",
} as unknown as SDKMessage);
try {
const result = await session.run("turn");
expect(result.usage).toEqual({
inputTokens: 5_000,
cachedInputTokens: 600,
outputTokens: 700,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 117,
});
} finally {
await session.close();
}
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 150,
},
});
});
test("does not use aggregate result totals after the first result turn", async () => {
const session = await createSessionForTurns([
test("message_delta stream events update per-request usage", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as unknown as SDKMessage);
const events = session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-1",
} as unknown as SDKMessage);
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 175,
},
});
});
test("task_progress usage takes priority over derived result usage", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
input_tokens: 345,
cache_read_input_tokens: 55,
},
uuid: "task-progress-1",
session_id: "session-1",
});
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 999,
});
});
test("contextWindowUsedTokens persists across turns from last task_progress", async () => {
const queryFactory = createQueryFactoryForTurns([
[
createInitMessage(),
createMessageStartEvent(),
createMessageDeltaEvent(25),
createSuccessResult(),
{
type: "system",
subtype: "init",
session_id: "session-1",
permissionMode: "default",
model: "claude-sonnet-4-6",
},
{
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
input_tokens: 345,
cache_read_input_tokens: 55,
},
uuid: "task-progress-1",
session_id: "session-1",
},
{
type: "result",
subtype: "success",
duration_ms: 100,
duration_api_ms: 75,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0.25,
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-1",
session_id: "session-1",
},
],
[
createSuccessResult({
{
type: "result",
subtype: "success",
duration_ms: 110,
duration_api_ms: 80,
is_error: false,
num_turns: 1,
result: "still done",
stop_reason: null,
total_cost_usd: 0.1,
usage: {
input_tokens: 1_000,
cache_read_input_tokens: 200,
output_tokens: 300,
input_tokens: 11,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 6,
output_tokens: 8,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-2",
}),
session_id: "session-1",
},
],
]);
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const firstTurn = await session.run("turn 1");
@@ -1713,133 +1751,175 @@ describe("ClaudeAgentSession context window usage", () => {
outputTokens: 7,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 175,
contextWindowUsedTokens: 999,
});
// Turn 2 has no task_progress, so contextWindowUsedTokens retains the
// last known value from turn 1 rather than deriving from accumulated
// result.usage (which would be incorrect — those are session-level totals).
expect(secondTurn.usage).toEqual({
inputTokens: 1_000,
cachedInputTokens: 200,
outputTokens: 300,
inputTokens: 11,
cachedInputTokens: 6,
outputTokens: 8,
totalCostUsd: 0.1,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 999,
});
} finally {
await session.close();
}
});
test("message_start stream events emit usage_updated with per-request usage", async () => {
const session = await createSessionForTurns([
[createInitMessage(), createMessageStartEvent(), createSuccessResult()],
]);
test("convertUsage derives used tokens from result usage as fallback when task_progress is missing", async () => {
const session = await createSessionForTest();
try {
const events = await collectStreamEvents(session);
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 150,
},
}),
);
} finally {
await session.close();
}
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 22,
});
});
test("selected Claude models seed active context window usage with max tokens", async () => {
const session = await createSessionForTurns(
[[createInitMessage(), createMessageStartEvent(), createSuccessResult()]],
{ model: "claude-sonnet-4-6" },
);
test("convertUsage uses per-request stream usage when no task_progress is available", async () => {
const session = await createSessionForTest();
try {
const events = await collectStreamEvents(session);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 150,
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
}),
);
} finally {
await session.close();
}
});
},
},
session_id: "session-1",
} as unknown as SDKMessage);
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-1",
} as unknown as SDKMessage);
test("message_delta stream events update per-request usage", async () => {
const session = await createSessionForTurns([
[
createInitMessage(),
createMessageStartEvent(),
createMessageDeltaEvent(25),
createSuccessResult(),
],
]);
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
try {
const events = await collectStreamEvents(session);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 175,
},
}),
);
} finally {
await session.close();
}
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 175,
});
});
test("per-request stream usage is not cumulative across API calls in a turn", async () => {
const session = await createSessionForTurns([
[
createInitMessage(),
createMessageStartEvent(),
createMessageDeltaEvent(25),
createMessageStartEvent({
input_tokens: 40,
cache_creation_input_tokens: 5,
cache_read_input_tokens: 10,
}),
createMessageDeltaEvent(7),
createSuccessResult(),
],
]);
const session = await createSessionForTest();
try {
const events = await collectStreamEvents(session);
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as unknown as SDKMessage);
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-1",
} as unknown as SDKMessage);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
const secondStartEvents = session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
contextWindowUsedTokens: 55,
input_tokens: 40,
cache_creation_input_tokens: 5,
cache_read_input_tokens: 10,
},
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 62,
},
}),
);
} finally {
await session.close();
}
},
},
session_id: "session-1",
} as unknown as SDKMessage);
expect(secondStartEvents).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 55,
},
});
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 7,
},
},
session_id: "session-1",
} as unknown as SDKMessage);
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 62,
});
});
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
@@ -11,9 +11,9 @@ import {
type PermissionResult,
type PermissionUpdate,
type Query,
type SDKControlGetContextUsageResponse,
type SDKMessage,
type SDKPartialAssistantMessage,
type SDKTaskProgressMessage,
type SDKResultMessage,
type SDKSystemMessage,
type SDKUserMessage,
@@ -1603,21 +1603,25 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
function resolveInitialContextWindowSize(modelId: string | null | undefined): number | undefined {
const normalized = typeof modelId === "string" ? modelId.trim().toLowerCase() : "";
if (!normalized) {
function readUsageTotalTokens(usage: unknown): number | undefined {
if (!usage || typeof usage !== "object") {
return undefined;
}
if (normalized.includes("[1m]") || normalized.includes("context-1m")) {
return 1_000_000;
const totalTokens = (usage as { total_tokens?: unknown }).total_tokens;
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
return undefined;
}
if (normalized.includes("claude-fable-5")) {
return 1_000_000;
}
if (/(?:^|[~/_-])(?:claude[-_ ]*)?(opus|sonnet|haiku)(?:$|[-_ ./])/.test(normalized)) {
return 200_000;
}
return undefined;
return totalTokens;
}
function readContextWindowUsedTokensFromTaskProgress(
message: SDKTaskProgressMessage,
): number | undefined {
return readUsageTotalTokens(message.usage);
}
function readUsageFromTaskNotification(message: { usage?: unknown }): number | undefined {
return readUsageTotalTokens(message.usage);
}
function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined {
@@ -1654,211 +1658,6 @@ function readStreamRequestOutputTokens(event: Record<string, unknown>): number |
return outputTokens;
}
function readLastUsageIteration(usage: unknown): Record<string, unknown> | undefined {
const iterations = toObjectRecord(usage)?.iterations;
if (!Array.isArray(iterations)) {
return undefined;
}
for (let index = iterations.length - 1; index >= 0; index -= 1) {
const candidate = toObjectRecord(iterations[index]);
if (candidate) {
return candidate;
}
}
return undefined;
}
function readUsageTokenTotal(usage: Record<string, unknown>): number | undefined {
const usageWithCacheCreation = usage as typeof usage & {
cache_creation_input_tokens?: unknown;
};
const inputTokens =
typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens)
? usage.input_tokens
: 0;
const cacheCreationInputTokens =
typeof usageWithCacheCreation.cache_creation_input_tokens === "number" &&
Number.isFinite(usageWithCacheCreation.cache_creation_input_tokens)
? usageWithCacheCreation.cache_creation_input_tokens
: 0;
const cacheReadInputTokens =
typeof usage.cache_read_input_tokens === "number" &&
Number.isFinite(usage.cache_read_input_tokens)
? usage.cache_read_input_tokens
: 0;
const outputTokens =
typeof usage.output_tokens === "number" && Number.isFinite(usage.output_tokens)
? usage.output_tokens
: 0;
const total = inputTokens + cacheCreationInputTokens + cacheReadInputTokens + outputTokens;
return total > 0 ? total : undefined;
}
function readActiveUsageTokens(usage: unknown): number | undefined {
const activeUsage = readLastUsageIteration(usage);
return activeUsage ? readUsageTokenTotal(activeUsage) : undefined;
}
function readLegacyResultUsageTokens(usage: unknown): number | undefined {
const usageRecord = toObjectRecord(usage);
return usageRecord ? readUsageTokenTotal(usageRecord) : undefined;
}
interface ClaudeCurrentContextUsage {
totalTokens: number;
maxTokens?: number;
}
function readCurrentContextUsage(
value: SDKControlGetContextUsageResponse | unknown,
): ClaudeCurrentContextUsage | undefined {
const record = toObjectRecord(value);
if (!record) {
return undefined;
}
const totalTokens = record.totalTokens;
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
return undefined;
}
const maxTokens = record.maxTokens;
return {
totalTokens,
...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0
? { maxTokens }
: {}),
};
}
function isClaudeSubagentToolName(name: string | undefined): boolean {
return name === "Task" || name === "Agent";
}
class ClaudeContextUsageState {
private contextWindowMaxTokens: number | undefined;
private streamRequestInputTokens: number | undefined;
private streamRequestOutputTokens: number | undefined;
private completedResultTurns = 0;
constructor(initialContextWindowMaxTokens?: number) {
this.contextWindowMaxTokens = initialContextWindowMaxTokens;
}
beginTurn(): void {
this.streamRequestInputTokens = undefined;
this.streamRequestOutputTokens = undefined;
}
setInitialContextWindowMaxTokens(contextWindowMaxTokens: number | undefined): void {
this.contextWindowMaxTokens = contextWindowMaxTokens;
}
recordModelUsage(modelUsage: unknown): number | undefined {
const contextWindowMaxTokens = extractContextWindowSize(modelUsage);
if (contextWindowMaxTokens !== undefined) {
this.contextWindowMaxTokens = contextWindowMaxTokens;
}
return this.contextWindowMaxTokens;
}
recordCurrentContextUsage(usage: ClaudeCurrentContextUsage | undefined): void {
if (usage?.maxTokens !== undefined) {
this.contextWindowMaxTokens = usage.maxTokens;
}
}
buildStreamUsageEvent(event: unknown): AgentStreamEvent | null {
const streamEvent = toObjectRecord(event);
if (!streamEvent) {
return null;
}
const eventType = readTrimmedString(streamEvent.type);
if (eventType === "message_start") {
const inputTokens = readStreamRequestInputTokens(streamEvent);
if (typeof inputTokens !== "number") {
return null;
}
this.streamRequestInputTokens = inputTokens;
this.streamRequestOutputTokens = 0;
} else if (eventType === "message_delta") {
const outputTokens = readStreamRequestOutputTokens(streamEvent);
if (typeof outputTokens !== "number") {
return null;
}
this.streamRequestOutputTokens = outputTokens;
} else {
return null;
}
const usedTokens = this.streamUsedTokens();
if (usedTokens === undefined) {
return null;
}
return this.createUsageUpdatedEvent(usedTokens);
}
buildResultUsage(
message: SDKResultMessage,
modelUsage: unknown,
currentContextUsage: ClaudeCurrentContextUsage | undefined,
): AgentUsage | undefined {
try {
if (!message.usage) {
return undefined;
}
const usage: AgentUsage = {
inputTokens: message.usage.input_tokens,
cachedInputTokens: message.usage.cache_read_input_tokens,
outputTokens: message.usage.output_tokens,
totalCostUsd: message.total_cost_usd,
};
const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage);
this.recordCurrentContextUsage(currentContextUsage);
if (this.contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
} else if (modelContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = modelContextWindowMaxTokens;
}
const activeResultUsageTokens =
readActiveUsageTokens(message.usage) ??
(this.completedResultTurns === 0 ? readLegacyResultUsageTokens(message.usage) : undefined);
const usedTokens =
currentContextUsage?.totalTokens ?? this.streamUsedTokens() ?? activeResultUsageTokens;
if (usedTokens !== undefined) {
usage.contextWindowUsedTokens = usedTokens;
}
return usage;
} finally {
this.completedResultTurns += 1;
}
}
private streamUsedTokens(): number | undefined {
if (
typeof this.streamRequestInputTokens !== "number" ||
typeof this.streamRequestOutputTokens !== "number"
) {
return undefined;
}
return this.streamRequestInputTokens + this.streamRequestOutputTokens;
}
private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent {
const usage: AgentUsage = {
contextWindowUsedTokens,
};
if (this.contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
}
return {
type: "usage_updated",
provider: "claude",
usage,
};
}
}
class ClaudeAgentSession implements AgentSession {
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1904,7 +1703,10 @@ class ClaudeAgentSession implements AgentSession {
private pendingInterruptAbort = false;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
private readonly contextUsage: ClaudeContextUsageState;
private lastContextWindowUsedTokens: number | undefined;
private lastContextWindowMaxTokens: number | undefined;
private lastStreamRequestInputTokens: number | undefined;
private lastStreamRequestOutputTokens: number | undefined;
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
private readonly rewindTurnAnchors: ClaudeRewindTurnAnchor[] = [];
@@ -1922,9 +1724,6 @@ class ClaudeAgentSession implements AgentSession {
this.logger = options.logger.child({ agentId: this.agentId });
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary;
this.contextUsage = new ClaudeContextUsageState(
resolveInitialContextWindowSize(this.config.model),
);
const handle = options.handle;
if (handle) {
@@ -2041,7 +1840,6 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false;
this.contextUsage.beginTurn();
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
@@ -2168,9 +1966,6 @@ class ClaudeAgentSession implements AgentSession {
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) {
await this.applyFastModeFeature(false, activeQuery);
}
this.contextUsage.setInitialContextWindowMaxTokens(
resolveInitialContextWindowSize(this.config.model),
);
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
this.lastRuntimeModel = null;
this.cachedRuntimeInfo = null;
@@ -2889,10 +2684,6 @@ class ClaudeAgentSession implements AgentSession {
cwd: this.config.cwd,
includePartialMessages: true,
permissionMode: this.currentMode,
// Dynamic mode switching can recreate the underlying Claude query. Keep the
// bypass launch capability available so later setPermissionMode("bypassPermissions")
// calls do not fail after a model/thinking/rewind-driven restart.
allowDangerouslySkipPermissions: true,
agents: this.defaults?.agents,
canUseTool: this.handlePermissionRequest,
pathToClaudeCodeExecutable: claudeBinary,
@@ -3210,7 +3001,6 @@ class ClaudeAgentSession implements AgentSession {
id: this.createTurnId("autonomous"),
};
this.activeTurnHasAssistantText = false;
this.contextUsage.beginTurn();
this.notifySubscribers({ type: "turn_started", provider: "claude" });
this.syncTurnState("autonomous turn started");
}
@@ -3304,7 +3094,7 @@ class ClaudeAgentSession implements AgentSession {
if (await this.handleMissingResumedConversation(message, activeQuery)) {
return true;
}
await this.routeSdkMessageFromPump(message, activeQuery);
this.routeSdkMessageFromPump(message);
return false;
};
const drainActiveQuery = async (): Promise<boolean> => {
@@ -3380,7 +3170,7 @@ class ClaudeAgentSession implements AgentSession {
);
}
private async routeSdkMessageFromPump(message: SDKMessage, activeQuery: Query): Promise<void> {
private routeSdkMessageFromPump(message: SDKMessage): void {
if (this.shouldSuppressStaleResult(message)) {
return;
}
@@ -3410,12 +3200,26 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.parsed_event",
);
const events = await this.buildPumpedMessageEvents(
message,
activeQuery,
identifiers.messageId,
turnId,
);
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
});
const assistantTimelineEvents = this.timelineAssembler
.consume({
message,
runId: turnId,
messageIdHint: identifiers.messageId,
})
.map(
(item) =>
({
type: "timeline",
item,
provider: "claude",
}) satisfies AgentStreamEvent,
);
const events = [...messageEvents, ...assistantTimelineEvents];
if (events.length === 0) {
return;
@@ -3450,51 +3254,6 @@ class ClaudeAgentSession implements AgentSession {
this.dispatchEvents(events);
}
private async buildPumpedMessageEvents(
message: SDKMessage,
activeQuery: Query,
messageIdHint: string | null,
turnId: string | null,
): Promise<AgentStreamEvent[]> {
const currentContextUsage =
message.type === "result" && message.subtype === "success"
? await this.queryCurrentContextUsage(activeQuery)
: undefined;
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
currentContextUsage,
});
const assistantTimelineEvents = this.timelineAssembler
.consume({
message,
runId: turnId,
messageIdHint,
})
.map(
(item) =>
({
type: "timeline",
item,
provider: "claude",
}) satisfies AgentStreamEvent,
);
return [...messageEvents, ...assistantTimelineEvents];
}
private async queryCurrentContextUsage(
activeQuery: Query,
): Promise<ClaudeCurrentContextUsage | undefined> {
try {
const usage = await withTimeout(activeQuery.getContextUsage(), 3_000, "timeout");
return readCurrentContextUsage(usage);
} catch (error) {
this.logger.debug({ err: error }, "Claude context usage query failed");
return undefined;
}
}
private async handleMissingResumedConversation(
message: SDKMessage,
activeQuery: Query,
@@ -3562,7 +3321,6 @@ class ClaudeAgentSession implements AgentSession {
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
currentContextUsage?: ClaudeCurrentContextUsage;
},
): AgentStreamEvent[] {
const parentToolUseId =
@@ -3613,9 +3371,7 @@ class ClaudeAgentSession implements AgentSession {
this.appendStreamEventEvents(message, events, options);
break;
case "result":
this.appendResultEvents(message, events, {
currentContextUsage: options?.currentContextUsage,
});
this.appendResultEvents(message, events);
break;
default:
break;
@@ -3696,7 +3452,11 @@ class ClaudeAgentSession implements AgentSession {
return;
}
if (message.subtype === "task_progress") {
return;
this.lastContextWindowUsedTokens =
readContextWindowUsedTokensFromTaskProgress(message) ?? this.lastContextWindowUsedTokens;
if (typeof this.lastContextWindowUsedTokens === "number") {
events.push(this.createUsageUpdatedEvent(this.lastContextWindowUsedTokens));
}
}
}
@@ -3706,12 +3466,12 @@ class ClaudeAgentSession implements AgentSession {
): void {
// TODO: subagent timelines are best-effort. Subagent task_notifications
// arrive without parent_tool_use_id but with tool_use_id pointing at the
// the parent's subagent tool call, so they slip past the sidechain router and pollute
// parent's Task call, so they slip past the sidechain router and pollute
// the parent timeline. Drop them here; eventually thread them into the
// parent tool call's sub_agent log instead.
// parent Task tool call's sub_agent log instead.
const taskUseId = message.tool_use_id;
const cachedTool = taskUseId ? this.toolUseCache.get(taskUseId) : undefined;
if (isClaudeSubagentToolName(cachedTool?.name)) {
if (cachedTool?.name === "Task") {
return;
}
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message);
@@ -3722,6 +3482,11 @@ class ClaudeAgentSession implements AgentSession {
provider: "claude",
});
}
const usage = readUsageFromTaskNotification(message);
if (typeof usage === "number") {
this.lastContextWindowUsedTokens = usage;
events.push(this.createUsageUpdatedEvent(usage));
}
}
private appendUserMessageEvents(
@@ -3800,7 +3565,7 @@ class ClaudeAgentSession implements AgentSession {
events: AgentStreamEvent[],
options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined,
): void {
const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event);
const usageUpdatedEvent = this.trackStreamEventUsage(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
@@ -3816,9 +3581,8 @@ class ClaudeAgentSession implements AgentSession {
private appendResultEvents(
message: Extract<SDKMessage, { type: "result" }>,
events: AgentStreamEvent[],
options?: { currentContextUsage?: ClaudeCurrentContextUsage },
): void {
const usage = this.convertUsage(message, message.modelUsage, options?.currentContextUsage);
const usage = this.convertUsage(message, message.modelUsage);
if (message.subtype === "success") {
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
// run client-side in the Claude CLI with no model turn — output_tokens
@@ -3985,12 +3749,99 @@ class ClaudeAgentSession implements AgentSession {
return null;
}
private convertUsage(
message: SDKResultMessage,
modelUsage?: unknown,
currentContextUsage?: ClaudeCurrentContextUsage,
): AgentUsage | undefined {
return this.contextUsage.buildResultUsage(message, modelUsage, currentContextUsage);
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
if (!message.usage) {
return undefined;
}
const usage: AgentUsage = {
inputTokens: message.usage.input_tokens,
cachedInputTokens: message.usage.cache_read_input_tokens,
outputTokens: message.usage.output_tokens,
totalCostUsd: message.total_cost_usd,
};
const contextWindowMaxTokens = extractContextWindowSize(modelUsage ?? message.modelUsage);
if (contextWindowMaxTokens !== undefined) {
this.lastContextWindowMaxTokens = contextWindowMaxTokens;
usage.contextWindowMaxTokens = contextWindowMaxTokens;
} else if (this.lastContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens;
}
if (typeof this.lastContextWindowUsedTokens === "number") {
// task_progress.total_tokens is the accurate context window fill level.
// Prefer it over result.usage which contains accumulated session totals.
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
} else if (
typeof this.lastStreamRequestInputTokens === "number" &&
typeof this.lastStreamRequestOutputTokens === "number"
) {
usage.contextWindowUsedTokens =
this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens;
} else if (message.usage) {
// Fallback: derive from result.usage when no task_progress has been
// received yet. These values are accumulated across all API calls, but
// for the first turn they equal the per-call values so the estimate is
// reasonable. Once a task_progress arrives it takes over permanently.
const usageWithCacheCreation = message.usage as typeof message.usage & {
cache_creation_input_tokens?: number;
};
const derived =
(message.usage.input_tokens ?? 0) +
(usageWithCacheCreation.cache_creation_input_tokens ?? 0) +
(message.usage.cache_read_input_tokens ?? 0) +
(message.usage.output_tokens ?? 0);
if (Number.isFinite(derived) && derived > 0) {
usage.contextWindowUsedTokens = derived;
}
}
return usage;
}
private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent {
const usage: AgentUsage = {
contextWindowUsedTokens,
};
if (this.lastContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens;
}
return {
type: "usage_updated",
provider: "claude",
usage,
};
}
private trackStreamEventUsage(event: unknown): AgentStreamEvent | null {
const streamEvent = toObjectRecord(event);
if (!streamEvent) {
return null;
}
const eventType = readTrimmedString(streamEvent.type);
if (eventType === "message_start") {
const inputTokens = readStreamRequestInputTokens(streamEvent);
if (typeof inputTokens !== "number") {
return null;
}
this.lastStreamRequestInputTokens = inputTokens;
this.lastStreamRequestOutputTokens = 0;
} else if (eventType === "message_delta") {
const outputTokens = readStreamRequestOutputTokens(streamEvent);
if (typeof outputTokens !== "number") {
return null;
}
this.lastStreamRequestOutputTokens = outputTokens;
} else {
return null;
}
if (
typeof this.lastStreamRequestInputTokens !== "number" ||
typeof this.lastStreamRequestOutputTokens !== "number"
) {
return null;
}
return this.createUsageUpdatedEvent(
this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens,
);
}
private handlePermissionRequest: CanUseTool = async (
@@ -583,123 +583,6 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("unarchives a persisted Codex thread through app-server", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
"thread/unarchive": (params) => {
threadRequests.push({ method: "thread/unarchive", params });
return { thread: { id: "native-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
await provider.unarchiveNativeSession({
provider: "codex",
sessionId: "persisted-thread-id",
nativeHandle: "native-thread-id",
});
expect(threadRequests).toEqual([
{ method: "thread/unarchive", params: { threadId: "native-thread-id" } },
]);
appServer.assertNoErrors();
});
test("unarchives a persisted Codex thread using sessionId when nativeHandle is absent", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
"thread/unarchive": (params) => {
threadRequests.push({ method: "thread/unarchive", params });
return { thread: { id: "persisted-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
await provider.unarchiveNativeSession({
provider: "codex",
sessionId: "persisted-thread-id",
});
expect(threadRequests).toEqual([
{ method: "thread/unarchive", params: { threadId: "persisted-thread-id" } },
]);
appServer.assertNoErrors();
});
test("treats a readable Codex thread as already unarchived", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
"thread/unarchive": (params) => {
threadRequests.push({ method: "thread/unarchive", params });
return Promise.reject(
new Error(
"failed to unarchive thread: no archived rollout found for thread id active-thread-id",
),
);
},
"thread/read": (params) => {
threadRequests.push({ method: "thread/read", params });
return { thread: { id: "active-thread-id", turns: [] } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
await provider.unarchiveNativeSession({
provider: "codex",
sessionId: "active-thread-id",
});
expect(threadRequests).toEqual([
{ method: "thread/unarchive", params: { threadId: "active-thread-id" } },
{ method: "thread/read", params: { threadId: "active-thread-id" } },
]);
appServer.assertNoErrors();
});
test("propagates Codex unarchive failure when the thread cannot be read", async () => {
const threadRequests: Array<{ method: string; params: unknown }> = [];
const appServer = createFakeCodexAppServer({
"thread/unarchive": (params) => {
threadRequests.push({ method: "thread/unarchive", params });
return Promise.reject(
new Error(
"failed to unarchive thread: no archived rollout found for thread id missing-thread-id",
),
);
},
"thread/read": (params) => {
threadRequests.push({ method: "thread/read", params });
return Promise.reject(new Error("thread not found"));
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
await expect(
provider.unarchiveNativeSession({
provider: "codex",
sessionId: "missing-thread-id",
}),
).rejects.toThrow("no archived rollout found for thread id missing-thread-id");
expect(threadRequests).toEqual([
{ method: "thread/unarchive", params: { threadId: "missing-thread-id" } },
{ method: "thread/read", params: { threadId: "missing-thread-id" } },
]);
appServer.assertNoErrors();
});
test("rewinds the conversation to a freshly emitted Codex user message id", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
@@ -108,11 +108,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes(`no archived rollout found for thread id ${threadId}`);
}
const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000;
const CODEX_PROVIDER = "codex" as const;
@@ -5608,33 +5603,6 @@ export class CodexAppServerAgentClient implements AgentClient {
}
}
async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
const threadId = handle.nativeHandle ?? handle.sessionId;
if (!threadId) return;
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
try {
await client.request("thread/unarchive", { threadId });
} catch (error) {
if (!isCodexAlreadyUnarchivedError(error, threadId)) {
throw error;
}
try {
await client.request("thread/read", { threadId });
} catch {
throw error;
}
}
} finally {
await client.dispose();
}
}
async isAvailable(): Promise<boolean> {
const launch = await resolveCodexLaunch(this.runtimeSettings);
const availability = await checkCodexLaunchAvailable(launch);
@@ -84,7 +84,6 @@ import {
} from "./opencode/runtime.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -1215,7 +1214,6 @@ export const __openCodeInternals = {
interface OpenCodeAgentClientDeps {
runtime?: OpenCodeRuntime;
managedProcesses?: ManagedProcessRegistry;
}
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
@@ -1262,9 +1260,7 @@ export class OpenCodeAgentClient implements AgentClient {
this.runtime =
deps.runtime ??
new ProductionOpenCodeRuntime(
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
managedProcesses: deps.managedProcesses,
}),
OpenCodeServerManager.getInstance(this.logger, runtimeSettings),
);
}
@@ -1,41 +1,42 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type {
ManagedProcessRecord,
ManagedProcessRecordInput,
ManagedProcessRegistry,
ManagedProcessReapResult,
} from "../../managed-processes/managed-processes.js";
import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js";
import {
OpenCodeServerManager,
type OpenCodeCommandPrefixResolver,
type OpenCodePortAllocator,
type OpenCodeServerProcessSpawner,
} from "./opencode/server-manager.js";
import { OpenCodeServerManager, type OpenCodeServerGeneration } from "./opencode/server-manager.js";
type FakeServerProcess = EventEmitter & {
killed: boolean;
kill: ReturnType<typeof vi.fn>;
};
type FakeGeneration = OpenCodeServerGeneration & { process: FakeServerProcess };
describe("OpenCodeServerManager generations", () => {
test("rotation creates a new current server without killing a referenced old server", async () => {
const { manager, runtime } = createTestManager([4101, 4102]);
const manager = createTestManager();
const first = createGeneration(4101);
const second = createGeneration(4102);
stubGenerations(manager, [first, second]);
const oldAcquisition = await manager.acquire({ force: false });
const newAcquisition = await manager.acquire({ force: true });
expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101");
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
expect(runtime.terminatedPorts).toEqual([]);
expect(first.process.kill).not.toHaveBeenCalled();
expect(second.process.kill).not.toHaveBeenCalled();
newAcquisition.release();
oldAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4101]);
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
});
test("new acquisitions after rotation use the new server", async () => {
const { manager, runtime } = createTestManager([4201, 4202]);
const manager = createTestManager();
const first = createGeneration(4201);
const second = createGeneration(4202);
stubGenerations(manager, [first, second]);
const oldAcquisition = await manager.acquire({ force: false });
const rotatedAcquisition = await manager.acquire({ force: true });
@@ -44,14 +45,18 @@ describe("OpenCodeServerManager generations", () => {
const nextAcquisition = await manager.acquire({ force: false });
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
expect(runtime.terminatedPorts).toEqual([]);
expect(first.process.kill).not.toHaveBeenCalled();
nextAcquisition.release();
oldAcquisition.release();
});
test("concurrent forced acquisitions share one fresh generation", async () => {
const { manager, runtime } = createTestManager([4251, 4252, 4253]);
const manager = createTestManager();
const first = createGeneration(4251);
const second = createGeneration(4252);
const third = createGeneration(4253);
const startServer = stubGenerations(manager, [first, second, third]);
const initialAcquisition = await manager.acquire({ force: false });
initialAcquisition.release();
@@ -63,14 +68,17 @@ describe("OpenCodeServerManager generations", () => {
expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252");
expect(modesAcquisition.server.url).toBe("http://127.0.0.1:4252");
expect(runtime.launchedPorts).toEqual([4251, 4252]);
expect(startServer).toHaveBeenCalledTimes(2);
modesAcquisition.release();
modelsAcquisition.release();
});
test("release is idempotent", async () => {
const { manager, runtime } = createTestManager([4301, 4302]);
const manager = createTestManager();
const first = createGeneration(4301);
const second = createGeneration(4302);
stubGenerations(manager, [first, second]);
const oldAcquisition = await manager.acquire({ force: false });
const newAcquisition = await manager.acquire({ force: true });
@@ -79,33 +87,44 @@ describe("OpenCodeServerManager generations", () => {
oldAcquisition.release();
oldAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4301]);
expect(first.refCount).toBe(0);
expect(first.process.kill).toHaveBeenCalledTimes(1);
});
test("shutdown kills current and retired servers", async () => {
const { manager, runtime } = createTestManager([4401, 4402]);
const manager = createTestManager();
const first = createGeneration(4401);
const second = createGeneration(4402);
stubGenerations(manager, [first, second]);
await manager.acquire({ force: false });
await manager.acquire({ force: true });
await manager.shutdown();
expect(runtime.terminatedPorts).toEqual([4402, 4401]);
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
expect(second.process.kill).toHaveBeenCalledWith("SIGTERM");
});
test("shutdown still signals a process after an earlier kill signal if it has not exited", async () => {
const { manager, runtime } = createTestManager([4451]);
const manager = createTestManager();
const first = createGeneration(4451);
stubGenerations(manager, [first]);
await manager.acquire({ force: false });
runtime.processForPort(4451).markKillSignalSent();
first.process.killed = true;
await manager.shutdown();
expect(runtime.terminatedPorts).toEqual([4451]);
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
});
test("repeated rotations leave zero unreferenced retired servers", async () => {
const { manager, runtime } = createTestManager([4501, 4502, 4503]);
const manager = createTestManager();
const first = createGeneration(4501);
const second = createGeneration(4502);
const third = createGeneration(4503);
stubGenerations(manager, [first, second, third]);
const firstAcquisition = await manager.acquire({ force: false });
const secondAcquisition = await manager.acquire({ force: true });
@@ -114,204 +133,49 @@ describe("OpenCodeServerManager generations", () => {
thirdAcquisition.release();
firstAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4502, 4501]);
const retiredServers = (manager as unknown as { retiredServers: Set<FakeGeneration> })
.retiredServers;
expect(Array.from(retiredServers).filter((server) => server.refCount === 0)).toHaveLength(0);
expect(first.process.kill).toHaveBeenCalledTimes(1);
expect(second.process.kill).toHaveBeenCalledTimes(1);
});
});
describe("OpenCodeServerManager managed process ledger", () => {
test("records helper server starts and removes the record on process exit", async () => {
const { manager, runtime } = createTestManager([4601]);
await manager.acquire({ force: false });
expect(await runtime.managedProcesses.list()).toEqual([
{
id: "managed-process-1",
owner: { provider: "opencode", kind: "helper-server" },
pid: 14601,
command: "opencode",
args: ["serve", "--port", "4601"],
metadata: { port: 4601 },
identity: { commandLine: null, startedAt: null },
createdAt: "test-created-at",
},
]);
runtime.processForPort(4601).exitNormally();
await runtime.settle();
expect(await runtime.managedProcesses.list()).toEqual([]);
});
test("removes helper server records on shutdown", async () => {
const { manager, runtime } = createTestManager([4602]);
await manager.acquire({ force: false });
await manager.shutdown();
expect(runtime.terminatedPorts).toEqual([4602]);
expect(await runtime.managedProcesses.list()).toEqual([]);
});
});
function createTestManager(ports: number[]): {
manager: OpenCodeServerManager;
runtime: FakeOpenCodeServerRuntime;
} {
const runtime = new FakeOpenCodeServerRuntime(ports);
return {
manager: new OpenCodeServerManager({
logger: createTestLogger(),
managedProcesses: runtime.managedProcesses,
portAllocator: runtime.allocatePort,
resolveCommandPrefix: runtime.resolveCommandPrefix,
spawnServerProcess: runtime.spawnServerProcess,
terminateProcess: runtime.terminateProcess,
}),
runtime,
function createTestManager(): OpenCodeServerManager {
const ManagerConstructor = OpenCodeServerManager as unknown as {
new (logger: ReturnType<typeof createTestLogger>): OpenCodeServerManager;
};
return new ManagerConstructor(createTestLogger());
}
class FakeOpenCodeServerRuntime {
readonly managedProcesses = new FakeManagedProcesses();
readonly terminatedPorts: number[] = [];
private readonly ports: number[];
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
private readonly processesByPort = new Map<number, FakeOpenCodeProcess>();
constructor(ports: number[]) {
this.ports = [...ports];
}
get launchedPorts(): number[] {
return Array.from(this.processesByPort.keys());
}
readonly allocatePort: OpenCodePortAllocator = async () => {
const port = this.ports.shift();
if (!port) {
throw new Error("No fake OpenCode port available");
function stubGenerations(
manager: OpenCodeServerManager,
generations: FakeGeneration[],
): ReturnType<typeof vi.fn> {
const startServer = vi.fn(async () => {
const generation = generations.shift();
if (!generation) {
throw new Error("No fake OpenCode server generation available");
}
return port;
};
readonly resolveCommandPrefix: OpenCodeCommandPrefixResolver = async () => ({
command: "opencode",
args: [],
return generation;
});
readonly spawnServerProcess: OpenCodeServerProcessSpawner = (command, args) => {
const port = Number(args.at(-1));
const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port });
this.processesByChild.set(process.child, process);
this.processesByPort.set(port, process);
queueMicrotask(() => process.announceListening());
return process.child;
};
readonly terminateProcess: ProcessTerminator = async (target: TreeKillTarget) => {
const process = this.processForChild(target as ChildProcess);
this.terminatedPorts.push(process.port);
process.exitBySignal("SIGTERM");
return "terminated";
};
processForPort(port: number): FakeOpenCodeProcess {
const process = this.processesByPort.get(port);
if (!process) {
throw new Error(`No fake OpenCode process for port ${port}`);
}
return process;
}
async settle(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
private processForChild(child: ChildProcess): FakeOpenCodeProcess {
const process = this.processesByChild.get(child);
if (!process) {
throw new Error("Unknown fake OpenCode process");
}
return process;
}
(manager as unknown as { startServer: typeof startServer }).startServer = startServer;
return startServer;
}
class FakeOpenCodeProcess extends EventEmitter {
readonly stdout = new EventEmitter();
readonly stderr = new EventEmitter();
readonly child: ChildProcess;
readonly port: number;
readonly pid: number;
killed = false;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
constructor(options: { port: number; pid: number }) {
super();
this.port = options.port;
this.pid = options.pid;
this.child = this as unknown as ChildProcess;
}
announceListening(): void {
this.stdout.emit("data", Buffer.from("listening on"));
}
exitNormally(): void {
this.exitCode = 0;
this.emit("exit", 0, null);
}
exitBySignal(signal: NodeJS.Signals): void {
this.killed = true;
this.signalCode = signal;
this.emit("exit", null, signal);
}
markKillSignalSent(): void {
this.killed = true;
}
kill(signal?: NodeJS.Signals): boolean {
this.exitBySignal(signal ?? "SIGTERM");
function createGeneration(port: number): FakeGeneration {
const process = new EventEmitter() as FakeServerProcess;
process.killed = false;
process.kill = vi.fn((signal?: NodeJS.Signals) => {
process.killed = true;
process.emit("exit", signal ?? "SIGTERM");
return true;
}
}
class FakeManagedProcesses implements ManagedProcessRegistry {
private records: ManagedProcessRecord[] = [];
async record(input: ManagedProcessRecordInput): Promise<ManagedProcessRecord> {
const record: ManagedProcessRecord = {
id: `managed-process-${this.records.length + 1}`,
...input,
metadata: input.metadata ?? {},
identity: { commandLine: null, startedAt: null },
createdAt: "test-created-at",
};
this.records.push(record);
return record;
}
async remove(id: string): Promise<void> {
this.records = this.records.filter((record) => record.id !== id);
}
async list(): Promise<ManagedProcessRecord[]> {
return this.records;
}
async reapStale(): Promise<ManagedProcessReapResult> {
return {
checked: 0,
dead: 0,
mismatched: 0,
removed: 0,
terminated: 0,
errors: [],
};
}
});
return {
process,
port,
url: `http://127.0.0.1:${port}`,
refCount: 0,
retired: false,
};
}
@@ -1,12 +1,10 @@
import type { ChildProcess } from "node:child_process";
import net from "node:net";
import os from "node:os";
import type { Logger } from "pino";
import { findExecutable } from "../../../../executable-resolution/executable-resolution.js";
import { spawnProcess, type SpawnProcessOptions } from "../../../../utils/spawn.js";
import { terminateWithTreeKill, type ProcessTerminator } from "../../../../utils/tree-kill.js";
import type { ManagedProcessRegistry } from "../../../managed-processes/managed-processes.js";
import { spawnProcess } from "../../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import {
createProviderEnvSpec,
resolveProviderCommandPrefix,
@@ -35,25 +33,6 @@ export interface OpenCodeServerGeneration {
url: string;
refCount: number;
retired: boolean;
managedProcessId?: string;
}
export type OpenCodePortAllocator = () => Promise<number>;
export type OpenCodeCommandPrefixResolver = () => Promise<{ command: string; args: string[] }>;
export type OpenCodeServerProcessSpawner = (
command: string,
args: string[],
options: SpawnProcessOptions,
) => ChildProcess;
export interface OpenCodeServerManagerOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
managedProcesses?: ManagedProcessRegistry;
terminateProcess?: ProcessTerminator;
portAllocator?: OpenCodePortAllocator;
resolveCommandPrefix?: OpenCodeCommandPrefixResolver;
spawnServerProcess?: OpenCodeServerProcessSpawner;
}
export class OpenCodeServerManager implements OpenCodeServerManagerLike {
@@ -66,37 +45,20 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string;
private readonly managedProcesses?: ManagedProcessRegistry;
private readonly terminateProcess: ProcessTerminator;
private readonly portAllocator: OpenCodePortAllocator;
private readonly resolveCommandPrefix: OpenCodeCommandPrefixResolver;
private readonly spawnServerProcess: OpenCodeServerProcessSpawner;
constructor(options: OpenCodeServerManagerOptions) {
this.logger = options.logger;
this.runtimeSettings = options.runtimeSettings;
this.runtimeSettingsKey = JSON.stringify(this.runtimeSettings ?? {});
this.managedProcesses = options.managedProcesses;
this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill;
this.portAllocator = options.portAllocator ?? findAvailablePort;
this.resolveCommandPrefix =
options.resolveCommandPrefix ??
(() => resolveProviderCommandPrefix(this.runtimeSettings?.command, resolveOpenCodeBinary));
this.spawnServerProcess = options.spawnServerProcess ?? spawnProcess;
private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger;
this.runtimeSettings = runtimeSettings;
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
}
static getInstance(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
options: Omit<OpenCodeServerManagerOptions, "logger" | "runtimeSettings"> = {},
): OpenCodeServerManager {
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
if (!OpenCodeServerManager.instance) {
OpenCodeServerManager.instance = new OpenCodeServerManager({
logger,
runtimeSettings,
...options,
});
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
OpenCodeServerManager.registerExitHandler();
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
logger.warn(
@@ -227,28 +189,26 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
private async startServer(launchEnv?: Record<string, string>): Promise<OpenCodeServerGeneration> {
const port = await this.portAllocator();
const port = await findAvailablePort();
const url = `http://127.0.0.1:${port}`;
const launchPrefix = await this.resolveCommandPrefix();
const serverArgs = [...launchPrefix.args, "serve", "--port", String(port)];
const serverCwd = os.homedir();
const launchPrefix = await resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
return new Promise((resolve, reject) => {
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
cwd: serverCwd,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
}),
});
const managedProcessRecord = this.recordManagedServerProcess({
process: serverProcess,
command: launchPrefix.command,
args: serverArgs,
port,
});
const serverProcess = spawnProcess(
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(port)],
{
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
}),
},
);
let started = false;
let stderrBuffer = "";
@@ -285,17 +245,13 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
if (output.includes("listening on") && !started) {
started = true;
clearTimeout(timeout);
void (async () => {
const record = await managedProcessRecord;
resolve({
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
...(record ? { managedProcessId: record.id } : {}),
});
})();
resolve({
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
});
}
});
@@ -307,13 +263,11 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
serverProcess.on("error", (error) => {
clearTimeout(timeout);
this.removeManagedProcessRecordWhenResolved(managedProcessRecord);
const headline = error instanceof Error ? error.message : String(error);
reject(new Error(buildStartupErrorMessage(headline)));
});
serverProcess.on("exit", (code) => {
this.removeManagedProcessRecordWhenResolved(managedProcessRecord);
if (!started) {
clearTimeout(timeout);
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
@@ -356,7 +310,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
) {
return;
}
const result = await this.terminateProcess(server.process, {
const result = await terminateWithTreeKill(server.process, {
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
@@ -372,55 +326,6 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
"OpenCode server did not report exit after SIGKILL",
);
}
if (server.managedProcessId) {
await this.removeManagedProcessId(server.managedProcessId);
server.managedProcessId = undefined;
}
}
private async recordManagedServerProcess(options: {
process: ChildProcess;
command: string;
args: string[];
port: number;
}): Promise<{ id: string } | null> {
const pid = options.process.pid;
if (!this.managedProcesses || typeof pid !== "number" || pid <= 0) {
return null;
}
try {
return await this.managedProcesses.record({
owner: { provider: "opencode", kind: "helper-server" },
pid,
command: options.command,
args: options.args,
metadata: { port: options.port },
});
} catch (error) {
this.logger.warn(
{ err: error, pid, port: options.port },
"Failed to record OpenCode helper process",
);
return null;
}
}
private removeManagedProcessRecordWhenResolved(record: Promise<{ id: string } | null>): void {
void record.then((resolved) => {
if (resolved) {
return this.removeManagedProcessId(resolved.id);
}
return undefined;
});
}
private async removeManagedProcessId(id: string): Promise<void> {
try {
await this.managedProcesses?.remove(id);
} catch (error) {
this.logger.warn({ err: error, id }, "Failed to remove OpenCode helper process record");
}
}
}
@@ -1,90 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test } from "vitest";
import type {
ManagedProcessRecord,
ManagedProcessRecordInput,
ManagedProcessRegistry,
ManagedProcessReapResult,
} from "./managed-processes/managed-processes.js";
import { createPaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
let tempRoot: string | null = null;
let staticDir: string | null = null;
afterEach(async () => {
await Promise.all([
tempRoot ? rm(tempRoot, { recursive: true, force: true }) : Promise.resolve(),
staticDir ? rm(staticDir, { recursive: true, force: true }) : Promise.resolve(),
]);
tempRoot = null;
staticDir = null;
});
describe("daemon managed process bootstrap", () => {
test("reaps stale helper process records during daemon bootstrap", async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-managed-bootstrap-"));
staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const paseoHome = path.join(tempRoot, ".paseo");
const managedProcesses = new FakeManagedProcesses();
const daemon = await createPaseoDaemon(
{
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
managedProcesses,
} as PaseoDaemonConfig,
pino({ level: "silent" }),
);
try {
expect(managedProcesses.reapCount).toBe(1);
} finally {
await daemon.stop().catch(() => undefined);
}
});
});
class FakeManagedProcesses implements ManagedProcessRegistry {
reapCount = 0;
async record(input: ManagedProcessRecordInput): Promise<ManagedProcessRecord> {
return {
id: "unused",
...input,
metadata: input.metadata ?? {},
identity: { commandLine: null, startedAt: null },
createdAt: "unused",
};
}
async remove(): Promise<void> {}
async list(): Promise<ManagedProcessRecord[]> {
return [];
}
async reapStale(): Promise<ManagedProcessReapResult> {
this.reapCount += 1;
return {
checked: 1,
dead: 0,
mismatched: 0,
removed: 1,
terminated: 1,
errors: [],
};
}
}
-41
View File
@@ -142,12 +142,6 @@ import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./servi
import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import {
createManagedProcessRegistry,
createSystemManagedProcessTable,
type ManagedProcessRegistry,
} from "./managed-processes/managed-processes.js";
import { terminateWithTreeKill } from "../utils/tree-kill.js";
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
import {
createRequireBearerMiddleware,
@@ -361,7 +355,6 @@ export interface PaseoDaemonConfig {
log?: PersistedConfig["log"];
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
pushNotificationSender?: PushNotificationSender;
managedProcesses?: ManagedProcessRegistry;
}
export interface PaseoDaemon {
@@ -376,32 +369,6 @@ export interface PaseoDaemon {
getListenTarget(): ListenTarget | null;
}
function createBootstrapManagedProcessRegistry(
config: Pick<PaseoDaemonConfig, "paseoHome" | "managedProcesses">,
logger: Logger,
): ManagedProcessRegistry {
if (config.managedProcesses) {
return config.managedProcesses;
}
return createManagedProcessRegistry({
paseoHome: config.paseoHome,
processTable: createSystemManagedProcessTable(),
terminateProcess: terminateWithTreeKill,
logger,
});
}
async function reconcileManagedProcessLedger(
managedProcesses: ManagedProcessRegistry,
logger: Logger,
): Promise<void> {
const reapResult = await managedProcesses.reapStale();
if (reapResult.checked > 0 || reapResult.errors.length > 0) {
logger.info(reapResult, "Managed helper process ledger reconciled");
}
}
export async function createPaseoDaemon(
config: PaseoDaemonConfig,
rootLogger: Logger,
@@ -438,13 +405,6 @@ export async function createPaseoDaemon(
const serverId = getOrCreateServerId(config.paseoHome, { logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
const managedProcesses = createBootstrapManagedProcessRegistry(config, logger);
// Reconcile the helper-process ledger in the background so it never blocks the
// daemon from coming up; terminating a live leftover can take a few seconds.
// Best-effort, so a failure is logged here rather than crashing startup.
void reconcileManagedProcessLedger(managedProcesses, logger).catch((error) => {
logger.warn({ err: error }, "Failed to reconcile managed helper process ledger");
});
let relayTransport: RelayTransportController | null = null;
const staticDir = config.staticDir;
@@ -685,7 +645,6 @@ export async function createPaseoDaemon(
runtimeSettings: config.agentProviderSettings,
providerOverrides: config.providerOverrides,
workspaceGitService,
managedProcesses,
isDev: config.isDev === true,
extraClients: config.agentClients,
});
@@ -1,187 +0,0 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, test, vi } from "vitest";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function createControlledClaudeQueryFactory(resultGate: Promise<void>) {
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const queuedMessages: Array<Record<string, unknown>> = [];
const waiters: Array<() => void> = [];
let closed = false;
function wakeNextWaiter() {
waiters.shift()?.();
}
function enqueue(message: Record<string, unknown>) {
queuedMessages.push(message);
wakeNextWaiter();
}
void (async () => {
for await (const _message of prompt) {
enqueue({
type: "system",
subtype: "init",
session_id: "session-live-usage",
permissionMode: "default",
model: "claude-sonnet-4-6",
});
enqueue({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-live-usage",
});
enqueue({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-live-usage",
});
await resultGate;
enqueue({
type: "result",
subtype: "success",
duration_ms: 100,
duration_api_ms: 75,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0.25,
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-live-usage",
session_id: "session-live-usage",
});
break;
}
closed = true;
wakeNextWaiter();
})();
return {
next: vi.fn(async () => {
for (;;) {
if (queuedMessages.length > 0 || closed) {
break;
}
await new Promise<void>((resolve) => {
waiters.push(resolve);
});
}
if (queuedMessages.length === 0) {
return { done: true, value: undefined };
}
return { done: false, value: queuedMessages.shift() };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => {
closed = true;
wakeNextWaiter();
return undefined;
}),
close: vi.fn(() => {
closed = true;
wakeNextWaiter();
}),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
getContextUsage: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => []),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
});
}
describe("daemon E2E (claude live usage)", () => {
test("publishes renderable active context usage through agent_update", async () => {
const logger = pino({ level: "silent" });
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-live-usage-"));
const resultGate = deferred();
const daemon = await createTestPaseoDaemon({
agentClients: {
claude: new ClaudeAgentClient({
logger,
queryFactory: createControlledClaudeQueryFactory(resultGate.promise),
resolveBinary: async () => "/test/claude/bin",
}),
},
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "claude-live-usage" } });
const agent = await client.createAgent({
provider: "claude",
model: "claude-sonnet-4-6",
cwd,
title: "Claude live usage",
});
await client.sendMessage(agent.id, "Report live usage");
const activeSnapshot = await client.waitForAgentUpsert(
agent.id,
(snapshot) =>
snapshot.status === "running" &&
snapshot.lastUsage?.contextWindowMaxTokens === 200_000 &&
snapshot.lastUsage.contextWindowUsedTokens === 175,
10_000,
);
expect(activeSnapshot.lastUsage).toEqual({
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 175,
});
resultGate.resolve();
const finalState = await client.waitForFinish(agent.id, 10_000);
expect(finalState.status).toBe("idle");
} finally {
resultGate.resolve();
await client.close().catch(() => undefined);
await daemon.close();
}
});
});
@@ -1,391 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import {
createManagedProcessRegistry,
createPidTarget,
createSystemManagedProcessTable,
type ManagedProcessCommandRunner,
type ManagedProcessInspection,
type ManagedProcessSnapshot,
type ManagedProcessTable,
} from "./managed-processes.js";
import { spawnProcess } from "../../utils/spawn.js";
import {
terminateWithTreeKill,
type ProcessTerminator,
type TreeKillTarget,
} from "../../utils/tree-kill.js";
let tempHome: string | null = null;
afterEach(async () => {
if (tempHome) {
await rm(tempHome, { recursive: true, force: true });
tempHome = null;
}
});
describe("managed process registry", () => {
test("reaps a validated leftover helper process and deletes its record", async () => {
tempHome = await mkdtemp(path.join(tmpdir(), "paseo-managed-processes-"));
const processTable = new FakeProcessTable([
{
pid: 4101,
commandLine: "opencode serve --port 4101",
startedAt: "process-start-token",
},
]);
const terminator = new FakeProcessTerminator();
const registry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable,
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
await registry.record({
owner: { provider: "opencode", kind: "helper-server" },
pid: 4101,
command: "opencode",
args: ["serve", "--port", "4101"],
metadata: { port: 4101 },
});
const restartedRegistry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable,
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
const result = await restartedRegistry.reapStale();
expect(result).toEqual({
checked: 1,
dead: 0,
mismatched: 0,
removed: 1,
terminated: 1,
errors: [],
});
expect(terminator.terminatedPids).toEqual([4101]);
expect(await restartedRegistry.list()).toEqual([]);
});
test("deletes a dead helper process record without terminating a PID", async () => {
tempHome = await mkdtemp(path.join(tmpdir(), "paseo-managed-processes-"));
const processTable = new FakeProcessTable([
{
pid: 4102,
commandLine: "opencode serve --port 4102",
startedAt: "process-start-token",
},
]);
const terminator = new FakeProcessTerminator();
const registry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable,
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
await registry.record({
owner: { provider: "opencode", kind: "helper-server" },
pid: 4102,
command: "opencode",
args: ["serve", "--port", "4102"],
metadata: { port: 4102 },
});
const restartedRegistry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable: new FakeProcessTable([]),
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
const result = await restartedRegistry.reapStale();
expect(result).toEqual({
checked: 1,
dead: 1,
mismatched: 0,
removed: 1,
terminated: 0,
errors: [],
});
expect(terminator.terminatedPids).toEqual([]);
expect(await restartedRegistry.list()).toEqual([]);
});
test("removes a reused PID record without terminating the new process", async () => {
tempHome = await mkdtemp(path.join(tmpdir(), "paseo-managed-processes-"));
const terminator = new FakeProcessTerminator();
const registry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable: new FakeProcessTable([
{
pid: 4103,
commandLine: "opencode serve --port 4103",
startedAt: "original-start-token",
},
]),
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
await registry.record({
owner: { provider: "opencode", kind: "helper-server" },
pid: 4103,
command: "opencode",
args: ["serve", "--port", "4103"],
metadata: { port: 4103 },
});
const restartedRegistry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable: new FakeProcessTable([
{
pid: 4103,
commandLine: "opencode serve --port 4103",
startedAt: "new-process-start-token",
},
]),
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
const result = await restartedRegistry.reapStale();
expect(result).toEqual({
checked: 1,
dead: 0,
mismatched: 1,
removed: 1,
terminated: 0,
errors: [],
});
expect(terminator.terminatedPids).toEqual([]);
expect(await restartedRegistry.list()).toEqual([]);
});
test("keeps a helper record when inspection fails instead of orphaning a live process", async () => {
tempHome = await mkdtemp(path.join(tmpdir(), "paseo-managed-processes-"));
const terminator = new FakeProcessTerminator();
const registry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable: new FakeProcessTable([
{ pid: 4104, commandLine: "opencode serve --port 4104", startedAt: "process-start-token" },
]),
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
await registry.record({
owner: { provider: "opencode", kind: "helper-server" },
pid: 4104,
command: "opencode",
args: ["serve", "--port", "4104"],
metadata: { port: 4104 },
});
const restartedRegistry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable: new FakeProcessTable([], [4104]),
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
const result = await restartedRegistry.reapStale();
expect(result).toMatchObject({
checked: 1,
dead: 0,
mismatched: 0,
removed: 0,
terminated: 0,
});
expect(result.errors).toEqual([{ id: expect.any(String), message: "inspection failed" }]);
expect(terminator.terminatedPids).toEqual([]);
expect(await restartedRegistry.list()).toHaveLength(1);
});
test("does not terminate a reused PID whose command line only mentions the tokens", async () => {
tempHome = await mkdtemp(path.join(tmpdir(), "paseo-managed-processes-"));
const terminator = new FakeProcessTerminator();
const registry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable: new FakeProcessTable([], [4105]),
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
await registry.record({
owner: { provider: "opencode", kind: "helper-server" },
pid: 4105,
command: "opencode",
args: ["serve", "--port", "4105"],
metadata: { port: 4105 },
});
const restartedRegistry = createManagedProcessRegistry({
paseoHome: tempHome,
processTable: new FakeProcessTable([
{
pid: 4105,
commandLine: "node /tmp/serve.js --port 4105 # opencode helper",
startedAt: null,
},
]),
terminateProcess: terminator.terminate,
logger: createTestLogger(),
});
const result = await restartedRegistry.reapStale();
expect(result).toEqual({
checked: 1,
dead: 0,
mismatched: 1,
removed: 1,
terminated: 0,
errors: [],
});
expect(terminator.terminatedPids).toEqual([]);
expect(await restartedRegistry.list()).toEqual([]);
});
});
describe("managed process termination", () => {
test("stops as soon as a terminated process exits instead of escalating to SIGKILL", async () => {
const child = spawnProcess(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
stdio: "ignore",
});
const pid = child.pid;
if (!pid) {
throw new Error("Failed to spawn test process");
}
let forced = false;
const result = await terminateWithTreeKill(createPidTarget(pid), {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 1_000,
onForceSignal: () => {
forced = true;
},
});
expect(result).toBe("terminated");
expect(forced).toBe(false);
});
});
describe("system managed process table", () => {
test("reads POSIX process identity from ps", async () => {
const commandRunner = new FakeCommandRunner([
{
stdout: "Sat Jun 20 10:30:40 2026 opencode serve --port 4101\n",
stderr: "",
},
]);
const processTable = createSystemManagedProcessTable({
platform: "darwin",
commandRunner,
});
const inspection = await processTable.inspect(4101);
expect(inspection).toEqual({
status: "alive",
snapshot: {
pid: 4101,
commandLine: "opencode serve --port 4101",
startedAt: "Sat Jun 20 10:30:40 2026",
},
});
expect(commandRunner.commands).toEqual([
{
command: "ps",
args: ["-ww", "-p", "4101", "-o", "lstart=", "-o", "command="],
},
]);
});
test("reads Windows process identity from PowerShell", async () => {
const commandRunner = new FakeCommandRunner([
{
stdout: JSON.stringify({
ProcessId: 4101,
CommandLine: "C:\\opencode.exe serve --port 4101",
CreationDate: "20260620103040.000000+000",
}),
stderr: "",
},
]);
const processTable = createSystemManagedProcessTable({
platform: "win32",
commandRunner,
});
const inspection = await processTable.inspect(4101);
expect(inspection).toEqual({
status: "alive",
snapshot: {
pid: 4101,
commandLine: "C:\\opencode.exe serve --port 4101",
startedAt: "20260620103040.000000+000",
},
});
expect(commandRunner.commands).toEqual([
{
command: "powershell.exe",
args: [
"-NoProfile",
"-NonInteractive",
"-Command",
"$process = Get-CimInstance Win32_Process -Filter 'ProcessId = 4101'; if ($process) { $process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress }",
],
},
]);
});
});
class FakeProcessTable implements ManagedProcessTable {
private readonly snapshots: Map<number, ManagedProcessSnapshot>;
private readonly errorPids: Set<number>;
constructor(snapshots: ManagedProcessSnapshot[], errorPids: number[] = []) {
this.snapshots = new Map(snapshots.map((snapshot) => [snapshot.pid, snapshot]));
this.errorPids = new Set(errorPids);
}
async inspect(pid: number): Promise<ManagedProcessInspection> {
if (this.errorPids.has(pid)) {
return { status: "error", error: new Error("inspection failed") };
}
const snapshot = this.snapshots.get(pid);
return snapshot ? { status: "alive", snapshot } : { status: "not-found" };
}
}
class FakeProcessTerminator {
readonly terminatedPids: number[] = [];
readonly terminate: ProcessTerminator = async (target: TreeKillTarget) => {
this.terminatedPids.push(target.pid ?? -1);
return "terminated";
};
}
class FakeCommandRunner implements ManagedProcessCommandRunner {
readonly commands: Array<{ command: string; args: string[] }> = [];
private readonly responses: Array<{ stdout: string; stderr: string }>;
constructor(responses: Array<{ stdout: string; stderr: string }>) {
this.responses = [...responses];
}
async exec(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
this.commands.push({ command, args });
const response = this.responses.shift();
if (!response) {
throw new Error("No fake process-table command response available");
}
return response;
}
}
@@ -1,455 +0,0 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import { writeJsonFileAtomic } from "../atomic-file.js";
import { execCommand } from "../../utils/spawn.js";
import type { ProcessTerminator, TreeKillTarget } from "../../utils/tree-kill.js";
const MANAGED_PROCESS_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
const MANAGED_PROCESS_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
const MANAGED_PROCESS_EXIT_POLL_INTERVAL_MS = 50;
const MANAGED_PROCESS_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
// `ps -o lstart` emits a fixed-width 24-char ctime stamp, e.g. "Sat Jun 20 10:30:40 2026".
const POSIX_LSTART_WIDTH = 24;
const ManagedProcessRecordSchema = z.object({
id: z.string().min(1),
owner: z.object({
provider: z.string().min(1),
kind: z.string().min(1),
}),
pid: z.number().int().positive(),
command: z.string().min(1),
args: z.array(z.string()),
metadata: z.record(z.string(), z.unknown()).default({}),
identity: z.object({
commandLine: z.string().nullable(),
startedAt: z.string().nullable(),
}),
createdAt: z.string().min(1),
});
const WindowsProcessSnapshotSchema = z.object({
ProcessId: z.number().int().positive(),
CommandLine: z.string().nullable().optional(),
CreationDate: z.string().nullable().optional(),
});
export interface ManagedProcessSnapshot {
pid: number;
commandLine: string | null;
startedAt: string | null;
}
export type ManagedProcessInspection =
| { status: "alive"; snapshot: ManagedProcessSnapshot }
| { status: "not-found" }
| { status: "error"; error: unknown };
export interface ManagedProcessTable {
inspect(pid: number): Promise<ManagedProcessInspection>;
}
export interface ManagedProcessCommandRunner {
exec(command: string, args: string[]): Promise<{ stdout: string; stderr: string }>;
}
export interface ManagedProcessOwner {
provider: string;
kind: string;
}
export interface ManagedProcessRecordInput {
owner: ManagedProcessOwner;
pid: number;
command: string;
args: string[];
metadata?: Record<string, unknown>;
}
export interface ManagedProcessRecord extends ManagedProcessRecordInput {
id: string;
metadata: Record<string, unknown>;
identity: {
commandLine: string | null;
startedAt: string | null;
};
createdAt: string;
}
export interface ManagedProcessReapResult {
checked: number;
dead: number;
mismatched: number;
removed: number;
terminated: number;
errors: Array<{ id: string; message: string }>;
}
export interface ManagedProcessRegistry {
record(input: ManagedProcessRecordInput): Promise<ManagedProcessRecord>;
remove(id: string): Promise<void>;
list(): Promise<ManagedProcessRecord[]>;
reapStale(): Promise<ManagedProcessReapResult>;
}
interface ManagedProcessRegistryOptions {
paseoHome: string;
processTable: ManagedProcessTable;
terminateProcess: ProcessTerminator;
logger: Logger;
}
export function createManagedProcessRegistry(
options: ManagedProcessRegistryOptions,
): ManagedProcessRegistry {
return new FileBackedManagedProcessRegistry(options);
}
export function createSystemManagedProcessTable(options?: {
platform?: NodeJS.Platform;
commandRunner?: ManagedProcessCommandRunner;
}): ManagedProcessTable {
return new SystemManagedProcessTable({
platform: options?.platform ?? process.platform,
commandRunner: options?.commandRunner ?? {
exec: execCommand,
},
});
}
class SystemManagedProcessTable implements ManagedProcessTable {
private readonly platform: NodeJS.Platform;
private readonly commandRunner: ManagedProcessCommandRunner;
constructor(options: { platform: NodeJS.Platform; commandRunner: ManagedProcessCommandRunner }) {
this.platform = options.platform;
this.commandRunner = options.commandRunner;
}
async inspect(pid: number): Promise<ManagedProcessInspection> {
if (!Number.isInteger(pid) || pid <= 0) {
return { status: "not-found" };
}
try {
return this.platform === "win32"
? await this.inspectWindows(pid)
: await this.inspectPosix(pid);
} catch (error) {
return { status: "error", error };
}
}
private async inspectPosix(pid: number): Promise<ManagedProcessInspection> {
let stdout: string;
try {
({ stdout } = await this.commandRunner.exec("ps", [
"-ww",
"-p",
String(pid),
"-o",
"lstart=",
"-o",
"command=",
]));
} catch (error) {
// `ps -p <pid>` exits non-zero when no process matches the pid; a numeric
// exit code means ps ran and found nothing, distinct from ps failing to run.
return isCommandExitFailure(error) ? { status: "not-found" } : { status: "error", error };
}
const line = stdout.trimEnd();
if (!line) {
return { status: "not-found" };
}
const startedAt = line.slice(0, POSIX_LSTART_WIDTH).trim();
const commandLine = line.slice(POSIX_LSTART_WIDTH).trim();
return {
status: "alive",
snapshot: {
pid,
commandLine: commandLine || null,
startedAt: startedAt || null,
},
};
}
private async inspectWindows(pid: number): Promise<ManagedProcessInspection> {
const command = [
`$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}';`,
"if ($process) { $process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress }",
].join(" ");
const { stdout } = await this.commandRunner.exec("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
command,
]);
const trimmed = stdout.trim();
if (!trimmed) {
return { status: "not-found" };
}
const parsed = WindowsProcessSnapshotSchema.parse(JSON.parse(trimmed));
return {
status: "alive",
snapshot: {
pid,
commandLine: parsed.CommandLine ?? null,
startedAt: parsed.CreationDate ?? null,
},
};
}
}
class FileBackedManagedProcessRegistry implements ManagedProcessRegistry {
private readonly directory: string;
private readonly processTable: ManagedProcessTable;
private readonly terminateProcess: ProcessTerminator;
private readonly logger: Logger;
constructor(options: ManagedProcessRegistryOptions) {
this.directory = path.join(options.paseoHome, "runtime", "managed-processes");
this.processTable = options.processTable;
this.terminateProcess = options.terminateProcess;
this.logger = options.logger.child({ module: "managed-processes" });
}
async record(input: ManagedProcessRecordInput): Promise<ManagedProcessRecord> {
const inspection = await this.processTable.inspect(input.pid);
const snapshot = inspection.status === "alive" ? inspection.snapshot : null;
const record: ManagedProcessRecord = {
id: randomUUID(),
owner: input.owner,
pid: input.pid,
command: input.command,
args: input.args,
metadata: input.metadata ?? {},
identity: {
commandLine: snapshot?.commandLine ?? null,
startedAt: snapshot?.startedAt ?? null,
},
createdAt: new Date().toISOString(),
};
await writeJsonFileAtomic(this.recordPath(record.id), record);
return record;
}
async remove(id: string): Promise<void> {
await fs.rm(this.recordPath(id), { force: true });
}
async list(): Promise<ManagedProcessRecord[]> {
const entries = await this.readEntries();
return entries.map((entry) => entry.record);
}
async reapStale(): Promise<ManagedProcessReapResult> {
const result: ManagedProcessReapResult = {
checked: 0,
dead: 0,
mismatched: 0,
removed: 0,
terminated: 0,
errors: [],
};
for (const entry of await this.readEntries()) {
result.checked += 1;
try {
const inspection = await this.processTable.inspect(entry.record.pid);
if (inspection.status === "not-found") {
await fs.rm(entry.path, { force: true });
result.dead += 1;
result.removed += 1;
continue;
}
if (inspection.status === "error") {
// Inspection failed, so we cannot tell whether the helper is still
// alive. Keep the record and retry on the next reconcile rather than
// orphaning a live process by deleting its record without killing it.
const message =
inspection.error instanceof Error ? inspection.error.message : String(inspection.error);
result.errors.push({ id: entry.record.id, message });
this.logger.warn(
{
err: inspection.error,
id: entry.record.id,
pid: entry.record.pid,
owner: entry.record.owner,
},
"Could not inspect managed helper process; leaving record for next reconcile",
);
continue;
}
const snapshot = inspection.snapshot;
if (!processIdentityMatches(entry.record, snapshot)) {
await fs.rm(entry.path, { force: true });
result.mismatched += 1;
result.removed += 1;
continue;
}
await this.terminateProcess(createPidTarget(entry.record.pid), {
gracefulTimeoutMs: MANAGED_PROCESS_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: MANAGED_PROCESS_FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{
pid: entry.record.pid,
owner: entry.record.owner,
timeoutMs: MANAGED_PROCESS_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
},
"Managed helper process did not exit after SIGTERM; sending SIGKILL",
);
},
});
await fs.rm(entry.path, { force: true });
result.terminated += 1;
result.removed += 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result.errors.push({ id: entry.record.id, message });
this.logger.warn(
{ err: error, id: entry.record.id, pid: entry.record.pid, owner: entry.record.owner },
"Failed to reap managed helper process",
);
}
}
return result;
}
private recordPath(id: string): string {
if (!MANAGED_PROCESS_ID_PATTERN.test(id)) {
throw new Error(`Invalid managed process record id: ${id}`);
}
return path.join(this.directory, `${id}.json`);
}
private async readEntries(): Promise<Array<{ path: string; record: ManagedProcessRecord }>> {
let fileNames: string[];
try {
fileNames = await fs.readdir(this.directory);
} catch (error) {
if (isNodeErrorWithCode(error, "ENOENT")) {
return [];
}
throw error;
}
const entries: Array<{ path: string; record: ManagedProcessRecord }> = [];
for (const fileName of fileNames) {
if (!fileName.endsWith(".json")) {
continue;
}
const filePath = path.join(this.directory, fileName);
try {
const raw = await fs.readFile(filePath, "utf8");
const parsed = ManagedProcessRecordSchema.parse(JSON.parse(raw));
entries.push({ path: filePath, record: parsed });
} catch (error) {
// A single corrupt or partially-written record must not abort the whole
// reconcile and leave every other leftover un-reaped. Skip it.
this.logger.warn(
{ err: error, file: fileName },
"Skipping unreadable managed process record",
);
}
}
return entries;
}
}
function processIdentityMatches(
record: ManagedProcessRecord,
snapshot: ManagedProcessSnapshot,
): boolean {
if (record.identity.startedAt && snapshot.startedAt) {
if (record.identity.startedAt !== snapshot.startedAt) {
return false;
}
return snapshot.commandLine ? commandLineMatchesRecord(record, snapshot.commandLine) : true;
}
if (record.identity.commandLine && snapshot.commandLine) {
return (
normalizeCommandLine(record.identity.commandLine) ===
normalizeCommandLine(snapshot.commandLine)
);
}
return snapshot.commandLine ? commandLineMatchesRecord(record, snapshot.commandLine) : false;
}
function commandLineMatchesRecord(record: ManagedProcessRecord, commandLine: string): boolean {
// Require the command name and args as one contiguous run, not scattered
// tokens. Without exact process identity (lstart), a reused PID whose command
// line merely mentions "opencode", "serve" and the port elsewhere must not be
// mistaken for our leftover and killed.
const normalized = normalizeCommandLine(commandLine);
const commandName = path.basename(record.command).toLowerCase();
const signature = [commandName, ...record.args].map((token) => token.toLowerCase()).join(" ");
return normalized.includes(signature);
}
function normalizeCommandLine(commandLine: string): string {
return commandLine.replace(/\s+/g, " ").trim().toLowerCase();
}
export function createPidTarget(pid: number): TreeKillTarget {
return {
pid,
exitCode: null,
signalCode: null,
kill(signal?: NodeJS.Signals | number) {
process.kill(pid, signal);
return true;
},
// The reaper has no ChildProcess handle for a leftover from a previous
// daemon, so it observes exit by polling the pid. Without this, termination
// can never see a graceful SIGTERM exit and always waits out the full
// graceful+force window before escalating to SIGKILL.
once(_event, listener) {
const timer = setInterval(() => {
if (!isProcessAlive(pid)) {
clearInterval(timer);
listener();
}
}, MANAGED_PROCESS_EXIT_POLL_INTERVAL_MS);
timer.unref();
},
};
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return isNodeErrorWithCode(error, "EPERM");
}
}
function isCommandExitFailure(error: unknown): boolean {
// execFile rejects with a numeric `code` (the process exit status) when the
// command ran and exited non-zero; a string `code` (e.g. "ENOENT") means it
// never ran.
return typeof (error as { code?: unknown })?.code === "number";
}
function isNodeErrorWithCode(error: unknown, code: string): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === code
);
}
@@ -6,21 +6,18 @@ export type WorktreeCreationIntent = WorktreeSource;
export type ResolveWorktreeCreationIntentInput =
| {
worktreeSlug: string;
branchName?: string;
refName?: string;
action?: "branch-off";
githubPrNumber?: undefined;
}
| {
worktreeSlug?: string;
branchName?: string;
refName?: string;
action: "checkout";
githubPrNumber?: number;
}
| {
worktreeSlug?: string;
branchName?: string;
refName?: string;
action?: undefined;
githubPrNumber: number;
@@ -49,7 +46,7 @@ export async function resolveWorktreeCreationIntent(
return {
kind: "branch-off",
baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)),
branchName: input.branchName ?? input.worktreeSlug,
branchName: input.worktreeSlug,
};
}
@@ -87,14 +84,14 @@ export async function resolveWorktreeCreationIntent(
return {
kind: "branch-off",
baseBranch: input.refName.trim(),
branchName: input.branchName ?? input.worktreeSlug,
branchName: input.worktreeSlug,
};
}
return {
kind: "branch-off",
baseBranch: await resolveDefaultBranch(repoRoot, deps),
branchName: input.branchName ?? input.worktreeSlug,
branchName: input.worktreeSlug,
};
}
@@ -543,7 +543,6 @@ function createSessionForWorkspaceTests(
getAgent: () => null,
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
archiveSnapshot: async () => ({}),
unarchiveSnapshot: async () => true,
clearAgentAttention: async () => {},
notifyAgentState: () => {},
}),
+1 -6
View File
@@ -19,7 +19,6 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
export interface CreateWorktreeCoreInput {
cwd: string;
worktreeSlug?: string;
branchName?: string;
refName?: string;
action?: "branch-off" | "checkout";
githubPrNumber?: number;
@@ -50,9 +49,6 @@ export async function createWorktreeCore(
const requestedWorktreeSlug = input.worktreeSlug
? normalizeWorktreeSlug(input.worktreeSlug)
: undefined;
const requestedBranchName = input.branchName
? validateWorktreeSlug(input.branchName.trim())
: undefined;
let intentInput: ResolveWorktreeCreationIntentInput;
if (input.action === "checkout") {
@@ -73,7 +69,6 @@ export async function createWorktreeCore(
intentInput = {
action: "branch-off",
refName: input.refName,
branchName: requestedBranchName,
worktreeSlug,
};
}
@@ -86,7 +81,7 @@ export async function createWorktreeCore(
switch (intent.kind) {
case "branch-off": {
normalizedSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.branchName);
normalizedSlug = intent.branchName;
break;
}
case "checkout-branch": {
@@ -24,7 +24,6 @@ const KimiAuthSchema = z.object({
interface KimiQuotaProviderOptions {
logger: Logger;
fetch?: ProviderApiFetch;
homeDir?: string;
}
export class KimiQuotaProvider implements ProviderUsageFetcher {
@@ -33,12 +32,10 @@ export class KimiQuotaProvider implements ProviderUsageFetcher {
private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch;
private readonly homeDir?: string;
constructor(options: KimiQuotaProviderOptions) {
this.logger = options.logger;
this.fetchApi = options.fetch ?? fetch;
this.homeDir = options.homeDir;
}
async fetchUsage(): Promise<ProviderUsage> {
@@ -91,25 +88,13 @@ export class KimiQuotaProvider implements ProviderUsageFetcher {
}
private async readKimiToken(): Promise<string | null> {
const homeDir = this.homeDir ?? homedir();
const paths = [
join(
process.env["KIMI_CODE_HOME"] || join(homeDir, ".kimi-code"),
"credentials",
"kimi-code.json",
),
join(homeDir, ".kimi", "credentials", "kimi-code.json"),
];
for (const path of paths) {
if (!existsSync(path)) continue;
try {
const credentials = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
if (credentials.access_token) return credentials.access_token;
} catch {
continue;
}
const path = join(homedir(), ".kimi", "credentials", "kimi-code.json");
if (!existsSync(path)) return null;
try {
const credentials = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
return credentials.access_token ?? null;
} catch {
return null;
}
return null;
}
}
@@ -1,4 +1,4 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -36,20 +36,6 @@ function writeCodexAuth(dir: string, accessToken: string, refreshToken = "rt_cod
);
}
function writeKimiCredentials(dir: string, accessToken: string): void {
mkdirSync(join(dir, "credentials"), { recursive: true });
writeFileSync(
join(dir, "credentials", "kimi-code.json"),
JSON.stringify({
access_token: accessToken,
refresh_token: "rt_kimi",
expires_at: 1_798_812_800,
scope: "kimi-code",
token_type: "Bearer",
}),
);
}
function makeClaudeResponse(
overrides: Partial<{
five_hour: { utilization: number | string; resets_at: string };
@@ -313,7 +299,6 @@ describe("real provider usage fetchers", () => {
"GROK_TOKEN",
"KIMI_TOKEN",
"KIMI_API_KEY",
"KIMI_CODE_HOME",
"CODEX_HOME",
]) {
delete process.env[key];
@@ -335,11 +320,7 @@ describe("real provider usage fetchers", () => {
});
function service(
options: {
platform?: typeof process.platform;
keychain?: () => Promise<unknown | null>;
kimiHomeDir?: string;
} = {},
options: { platform?: typeof process.platform; keychain?: () => Promise<unknown | null> } = {},
) {
const logger = createLogger();
const fetchThroughTestDouble = ((url: RequestInfo | URL, init?: RequestInit) =>
@@ -360,11 +341,7 @@ describe("real provider usage fetchers", () => {
new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new GrokQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new KimiQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
homeDir: options.kimiHomeDir,
}),
new KimiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
],
cacheTtlMs: 0,
});
@@ -717,37 +694,4 @@ describe("real provider usage fetchers", () => {
],
});
});
it("fetches Kimi usage from the CLI credential home", async () => {
writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token");
let requestedUrl: string | null = null;
let authorization: string | null = null;
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
requestedUrl = url.toString();
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({
usage: {
limit: "200",
remaining: "150",
resetTime: "2026-06-23T05:12:17Z",
},
});
}) as unknown as typeof fetch;
const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi");
expect(requestedUrl).toBe("https://api.kimi.com/coding/v1/usages");
expect(authorization).toBe("Bearer kimi_cli_token");
expect(kimi).toMatchObject({
status: "available",
windows: [
expect.objectContaining({
id: "coding_usage",
usedPct: 25,
remainingPct: 75,
resetsAt: "2026-06-23T05:12:17Z",
}),
],
});
});
});
@@ -332,63 +332,11 @@ describe("searchWorkspaceEntries", () => {
]);
});
it("suffix mode resolves exact workspace file paths before broad traversal", async () => {
const targetPath = path.join(
workspaceDir,
"packages",
"server",
"src",
"services",
"quota-fetcher",
"providers",
"local.ts",
);
mkdirSync(path.dirname(targetPath), { recursive: true });
writeFileSync(targetPath, "");
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "packages/server/src/services/quota-fetcher/providers/local.ts",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
maxEntriesScanned: 1,
});
expect(results).toEqual([
{
path: "packages/server/src/services/quota-fetcher/providers/local.ts",
kind: "file",
},
]);
});
it("suffix mode resolves explicit hidden file paths without broad hidden traversal", async () => {
const targetPath = path.join(workspaceDir, ".dev", "paseo-home", "daemon.log");
mkdirSync(path.dirname(targetPath), { recursive: true });
writeFileSync(targetPath, "daemon log\n");
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: ".dev/paseo-home/daemon.log",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
maxEntriesScanned: 1,
});
expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]);
});
it("suffix mode finds files under allowlisted hidden workspace directories", async () => {
it("suffix mode finds files under hidden workspace directories", async () => {
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
writeFileSync(path.join(workspaceDir, ".github", "workflows", "ci.yml"), "");
const claudeResults = await searchWorkspaceEntries({
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "settings.local.json",
limit: 20,
@@ -396,34 +344,8 @@ describe("searchWorkspaceEntries", () => {
includeDirectories: false,
matchMode: "suffix",
});
const githubResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "ci.yml",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
});
expect(claudeResults).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]);
expect(githubResults).toEqual([{ path: ".github/workflows/ci.yml", kind: "file" }]);
});
it("does not broadly traverse unlisted hidden workspace directories", async () => {
mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true });
writeFileSync(path.join(workspaceDir, ".dev", "cache", "needle.ts"), "");
writeFileSync(path.join(workspaceDir, "src", "needle.ts"), "");
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "needle.ts",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
});
expect(results).toEqual([{ path: "src/needle.ts", kind: "file" }]);
expect(results).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]);
});
it("does not suggest hidden directories even when includeDirectories is true", async () => {
@@ -92,14 +92,6 @@ const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([
"__pycache__",
".git",
]);
const TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES = new Set([
".agents",
".claude",
".codex",
".github",
".paseo",
".vscode",
]);
export async function searchHomeDirectories(
options: SearchHomeDirectoriesOptions,
@@ -169,19 +161,6 @@ export async function searchWorkspaceEntries(
}
const matchMode = options.matchMode ?? "fuzzy";
const exactEntry =
queryParts.isPathQuery && matchMode === "suffix"
? await resolveWorkspaceExactEntry({
workspaceRoot,
query: options.query,
includeDirectories,
includeFiles,
})
: null;
if (exactEntry && limit <= 1) {
return [exactEntry];
}
if (queryParts.isPathQuery && matchMode !== "suffix") {
return searchWorkspaceWithinParentDirectory({
workspaceRoot,
@@ -197,7 +176,7 @@ export async function searchWorkspaceEntries(
matchMode === "suffix"
? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/")
: queryParts.searchTerm;
const entries = await searchWorkspaceAcrossTree({
return searchWorkspaceAcrossTree({
workspaceRoot,
searchTerm,
limit,
@@ -207,66 +186,6 @@ export async function searchWorkspaceEntries(
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
});
return exactEntry ? prependWorkspaceEntry(exactEntry, entries).slice(0, limit) : entries;
}
async function resolveWorkspaceExactEntry(input: {
workspaceRoot: string;
query: string;
includeDirectories: boolean;
includeFiles: boolean;
}): Promise<WorkspaceSuggestionEntry | null> {
const normalized = input.query
.trim()
.replace(/\\/g, "/")
.replace(/^\.\/+/, "")
.replace(/\/{2,}/g, "/");
if (!normalized) {
return null;
}
const candidatePath = path.isAbsolute(normalized)
? path.resolve(normalized)
: path.resolve(input.workspaceRoot, normalized);
let resolvedPath: string;
try {
resolvedPath = await realpath(candidatePath);
} catch {
return null;
}
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
return null;
}
const stats = await stat(resolvedPath).catch(() => null);
if (!stats) {
return null;
}
if (stats.isFile() && input.includeFiles) {
return {
path: normalizeRelativePath(input.workspaceRoot, resolvedPath),
kind: "file",
};
}
if (stats.isDirectory() && input.includeDirectories) {
return {
path: normalizeRelativePath(input.workspaceRoot, resolvedPath),
kind: "directory",
};
}
return null;
}
function prependWorkspaceEntry(
entry: WorkspaceSuggestionEntry,
entries: WorkspaceSuggestionEntry[],
): WorkspaceSuggestionEntry[] {
return [
entry,
...entries.filter(
(candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path,
),
];
}
function normalizeLimit(limit: number | undefined): number {
@@ -960,14 +879,7 @@ async function listWorkspaceChildEntries(input: {
if (isIgnoredSuggestionDirectoryName(dirent.name)) {
return false;
}
if (
isHiddenDirectoryName(dirent.name) &&
!dirent.isFile() &&
!isTraversableHiddenWorkspaceDirectoryName(dirent.name)
) {
return false;
}
// Allowlisted hidden directories remain traversable so file links like
// Hidden directories must remain traversable so file links like
// `.claude/settings.local.json` can resolve, but hidden files (e.g.
// `.DS_Store`) should never be suggested.
if (dirent.isFile() && isHiddenDirectoryName(dirent.name)) {
@@ -1073,10 +985,6 @@ function isIgnoredSuggestionDirectoryName(name: string): boolean {
return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name);
}
function isTraversableHiddenWorkspaceDirectoryName(name: string): boolean {
return TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES.has(name);
}
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
directoryListCache.set(cacheKey, entry);
pruneDirectoryListCache();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.98",
"version": "0.1.97",
"private": true,
"type": "module",
"scripts": {
+1 -11
View File
@@ -54,17 +54,7 @@ The receiving agent has zero context. Include:
## Launch
Create the agent via Paseo with a `[Handoff] <task>` title, the briefing as initial prompt, and `relationship: { kind: "detached" }`.
Use `workspace` for placement:
- No worktree: `workspace: { kind: "current" }`.
- Worktree: `workspace: { kind: "create", source: { kind: "worktree", target: { kind: "branch-off", worktreeSlug: "<short-task-slug>", branchName: "fix/<short-task-slug>" } } }`.
- Existing worktree already created by `create_worktree`: `workspace: { kind: "existing", workspaceId: "<returned-workspace-id>" }`.
Do not use `workspace: { kind: "current", cwd: "<worktreePath>" }` to place a handoff in a worktree; that keeps the agent in the caller's workspace with only a different runtime cwd.
Leave `notifyOnFinish` omitted unless the user explicitly wants no callback.
Create the agent via Paseo with a `[Handoff] <task>` title, the briefing as initial prompt, `detached: true`, and cwd set to the worktree path if `--worktree`. Leave `notifyOnFinish` omitted unless the user explicitly wants no callback.
Handoff agents are siblings/root agents, not your subagents. They must survive you being archived and must not appear in your subagent track.
+12 -24
View File
@@ -7,46 +7,34 @@ Paseo is a daemon that supervises AI coding agents on your machine. Control it t
## Worktrees
**`create_worktree`** — same target union as `create_agent.workspace.source.worktree.target`:
**`create_worktree`** — three modes:
- From a PR: `{ target: { kind: "checkout-pr", githubPrNumber: 503 } }`.
- Branch off a base: `{ target: { kind: "branch-off", worktreeSlug: "foo", branchName: "fix/foo", baseBranch: "main" } }`.
- Checkout an existing branch: `{ target: { kind: "checkout-branch", branch: "feat/bar" } }`.
- From a PR: `{ githubPrNumber: 503 }`.
- Branch off a base: `{ action: "branch-off", branchName: "fix/foo", baseBranch: "main" }`.
- Checkout an existing ref: `{ action: "checkout", refName: "feat/bar" }`.
Returns `{ branchName, worktreePath, workspaceId }`. Pass `cwd` to target a specific repo.
In `branch-off`, `worktreeSlug` controls the worktree path slug and `branchName` controls the git branch. If `branchName` is omitted, Paseo defaults it from `worktreeSlug`. The returned `branchName` is authoritative; checkout and PR flows may return a branch name that differs from any requested slug.
Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo.
**`list_worktrees`** — current repo (or pass `cwd`).
**`archive_worktree`** — `{ worktreePath }` or `{ worktreeSlug }`. Removes worktree and branch.
## Agents
**`create_agent`** — required: `relationship`, `workspace`, `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, … }`.
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `notifyOnFinish`, `settings`, `detached`. Returns `{ agentId, … }`.
Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent.
To create a new worktree and launch an agent in it, use `create_agent.workspace.source.kind = "worktree"`. Use `create_worktree` separately only when you need a worktree without launching an agent, or when you need a split flow; in a split flow, pass the returned `workspaceId` to `create_agent` with `workspace: { kind: "existing", workspaceId }`.
Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`.
### Agent relationships
`relationship` controls parentage only:
Agents you create default to **your subagents**: omit `detached` or pass `detached: false`. Use this for advisors, committee members, planners, implementers, auditors, loop workers, and any agent whose lifetime belongs to your task. Subagents appear under you and are archived with you.
- `{ kind: "subagent" }` — child under your subagents track. Use for advisors, committee members, planners, implementers, auditors, loop workers, and any agent whose lifetime belongs to your task.
- `{ kind: "detached" }` — root/sibling agent. Use for handoffs and fire-and-forget delegations the user may continue after you are archived.
Pass `detached: true` only when the agent you create should stand on its own, not help you finish your task. Use this for handoffs and fire-and-forget delegations the user may continue after you are archived. Detached agents do not appear in your subagent track and are not archived with you.
`workspace` controls placement only:
For subagents, leave `notifyOnFinish` omitted or set it to `true`. You will get notified when the created agent finishes, errors, or needs permission. Set `notifyOnFinish: false` only when the created agent is truly fire-and-forget and you do not need to follow up.
- `{ kind: "current" }` — same workspace as the caller, with optional `cwd`.
- `{ kind: "existing", workspaceId: string, cwd?: string }` — attach to an existing workspace, usually from `create_worktree`.
- `{ kind: "create", source: { kind: "directory", path?: string } }` — new workspace rooted at a directory.
- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "branch-off", worktreeSlug?: string, branchName?: string, baseBranch?: string } } }`
- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "checkout-branch", branch: string } } }`
- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "checkout-pr", githubPrNumber: number } } }`
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Agent-scoped prompt calls default to `background: true` and `notifyOnFinish: true`; top-level calls default to blocking with no callback. For a synchronous follow-up, pass `background: false` and use the returned result.
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent.
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.
@@ -106,7 +94,7 @@ If the file is missing, use sensible defaults and tell the user once.
Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows.
For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. **You must not call `wait_for_agent` on a notify-on-finish agent.** Move on to other work. The notification arrives on its own.
For `create_agent`, leave `notifyOnFinish` omitted or set it to `true` unless the created agent is truly fire-and-forget. You will get notified when the created agent finishes, errors, or needs permission. **You must not call `wait_for_agent` on a notify-on-finish agent.** Move on to other work. The notification arrives on its own.
Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you.