diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 04b70c0bb..801e0c067 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
+ workflow_dispatch:
jobs:
format:
@@ -88,6 +89,9 @@ jobs:
- name: Install dependencies
run: npm install
+ - name: Build highlight dependency
+ run: npm run build --workspace=@getpaseo/highlight
+
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
@@ -113,6 +117,12 @@ jobs:
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
+ - name: Build server dependency
+ run: npm run build --workspace=@getpaseo/server
+
+ - name: Install agent CLIs for provider tests
+ run: npm install -g @openai/codex@0.105.0 opencode-ai
+
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
env:
@@ -160,6 +170,9 @@ jobs:
- name: Install dependencies
run: npm install
+ - name: Install agent CLIs for provider tests
+ run: npm install -g @openai/codex@0.105.0 opencode-ai
+
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbbe7e337..117fa1869 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,41 @@
# Changelog
+## 0.1.54 - 2026-04-12
+
+### Added
+- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
+
+### Improved
+- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
+- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
+
+### Fixed
+- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
+- Windows: MCP configuration is no longer mangled when spawning Claude agents.
+- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
+
+## 0.1.53 - 2026-04-12
+
+### Added
+- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
+- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
+- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
+- Agent reload — `paseo agent reload` restarts an agent's underlying process from the CLI.
+- Middle-click to close tabs on desktop.
+- Keyboard shortcut to cycle themes.
+
+### Improved
+- Unavailable git actions now explain why in a toast instead of being silently greyed out.
+- Streaming markdown on mobile renders significantly faster.
+- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
+- Paseo tool calls in agent timelines show the Paseo logo and human-readable names.
+- Relay and pairing URLs are stripped from daemon logs.
+
+### Fixed
+- Closed agent tabs no longer reappear after reconnecting.
+- Desktop notification badge counts match across all workspaces.
+- Host switcher status syncs correctly when switching between hosts.
+
## 0.1.52 - 2026-04-10
### Added
diff --git a/CI_STATUS.md b/CI_STATUS.md
new file mode 100644
index 000000000..3c2798e24
--- /dev/null
+++ b/CI_STATUS.md
@@ -0,0 +1,19 @@
+# CI Test Status
+
+Tracking progress toward all-green CI.
+
+## CI Jobs
+
+| Job | Status | Notes |
+|-----|--------|-------|
+| format | unknown | `npx biome format .` |
+| typecheck | unknown | `npm run typecheck` |
+| server-tests | unknown | unit + integration (vitest) |
+| app-tests | unknown | unit tests (vitest) |
+| playwright | unknown | E2E tests (playwright) |
+| relay-tests | unknown | unit tests (vitest) |
+| cli-tests | unknown | local tests |
+
+## Log
+
+- 2026-04-10: Branch created, automated agents begin iterating
diff --git a/CLAUDE.md b/CLAUDE.md
index 78020044a..dfb9b88d0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
+npm run format # Auto-format with Biome
+npm run format:check # Check formatting without writing
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
@@ -45,6 +47,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.**
+- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
diff --git a/README.md b/README.md
index a43cf0b4f..4daa16f18 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,21 @@
Paseo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
One interface for all your Claude Code, Codex and OpenCode agents.
diff --git a/nix/package.nix b/nix/package.nix
index a3b1e4571..1ef702bf2 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
- npmDepsHash = "sha256-wdn8o/Z+17Hpo3vhp3GvS0kOmp+D3t78v5eCAI/DAZo=";
+ npmDepsHash = "sha256-URkLpcPEB530mm6w87YZ/ggyKIYHcHsQCfzA9f9xBZU=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
diff --git a/package-lock.json b/package-lock.json
index 124a4d1aa..f08ca897c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "paseo",
- "version": "0.1.52",
+ "version": "0.1.54",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
- "version": "0.1.52",
+ "version": "0.1.54",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36169,16 +36169,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
- "version": "0.1.52",
+ "version": "0.1.54",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
- "@getpaseo/expo-two-way-audio": "0.1.52",
- "@getpaseo/highlight": "0.1.52",
- "@getpaseo/server": "0.1.52",
+ "@getpaseo/expo-two-way-audio": "0.1.54",
+ "@getpaseo/highlight": "0.1.54",
+ "@getpaseo/server": "0.1.54",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -36253,6 +36253,7 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/react": "~19.2.0",
+ "@types/ws": "^8.18.1",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
@@ -36260,7 +36261,8 @@
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
- "wrangler": "^4.59.1"
+ "wrangler": "^4.59.1",
+ "ws": "^8.20.0"
}
},
"packages/app/node_modules/expo-clipboard": {
@@ -36284,6 +36286,28 @@
"react-native": "*"
}
},
+ "packages/app/node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"packages/app/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -36295,11 +36319,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
- "version": "0.1.52",
+ "version": "0.1.54",
"dependencies": {
"@clack/prompts": "^1.0.0",
- "@getpaseo/relay": "0.1.52",
- "@getpaseo/server": "0.1.52",
+ "@getpaseo/relay": "0.1.54",
+ "@getpaseo/server": "0.1.54",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36340,11 +36364,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
- "version": "0.1.52",
+ "version": "0.1.54",
"license": "AGPL-3.0-or-later",
"dependencies": {
- "@getpaseo/cli": "0.1.52",
- "@getpaseo/server": "0.1.52",
+ "@getpaseo/cli": "0.1.54",
+ "@getpaseo/server": "0.1.54",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -36379,7 +36403,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
- "version": "0.1.52",
+ "version": "0.1.54",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -36580,7 +36604,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
- "version": "0.1.52",
+ "version": "0.1.54",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -36606,7 +36630,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
- "version": "0.1.52",
+ "version": "0.1.54",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -36622,14 +36646,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
- "version": "0.1.52",
+ "version": "0.1.54",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
- "@getpaseo/highlight": "0.1.52",
- "@getpaseo/relay": "0.1.52",
+ "@getpaseo/highlight": "0.1.54",
+ "@getpaseo/relay": "0.1.54",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -36642,6 +36666,7 @@
"drizzle-orm": "^0.45.1",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
+ "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.1.0",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
@@ -37032,7 +37057,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
- "version": "0.1.52",
+ "version": "0.1.54",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
diff --git a/package.json b/package.json
index 587cceaa3..4360930e5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "paseo",
- "version": "0.1.52",
+ "version": "0.1.54",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts
index cdaf31c1c..c0b5b2c4f 100644
--- a/packages/app/e2e/archive-tab.spec.ts
+++ b/packages/app/e2e/archive-tab.spec.ts
@@ -19,13 +19,15 @@ test.describe("Archive tab reconciliation", () => {
let client: Awaited>;
let tempRepo: { path: string; cleanup: () => Promise };
+ test.describe.configure({ timeout: 120_000 });
+
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
});
test.afterAll(async () => {
- await client?.close();
+ await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts
index 7ecf0ac9b..b4c2cc829 100644
--- a/packages/app/e2e/helpers/agent-bottom-anchor.ts
+++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
+import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -83,33 +84,36 @@ export function createReplyTurn(label: string): {
};
}
+type DaemonClientConfig = {
+ url: string;
+ clientId: string;
+ clientType: "cli";
+ webSocketFactory?: NodeWebSocketFactory;
+};
+
async function loadDaemonClientConstructor(): Promise<
- new (config: {
- url: string;
- clientId: string;
- clientType: "cli";
- }) => DaemonClientInstance
+ new (
+ config: DaemonClientConfig,
+ ) => DaemonClientInstance
> {
- const repoRoot = path.resolve(process.cwd(), "../..");
+ const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
- DaemonClient: new (config: {
- url: string;
- clientId: string;
- clientType: "cli";
- }) => DaemonClientInstance;
+ DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise {
const DaemonClient = await loadDaemonClientConstructor();
+ const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-${randomUUID()}`,
clientType: "cli",
+ webSocketFactory,
});
await client.connect();
return client;
@@ -127,7 +131,7 @@ export async function seedBottomAnchorAgent(input: {
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
- model: "gpt-5.1-codex-mini",
+ model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts
index fe72677b8..c7d1d8f4e 100644
--- a/packages/app/e2e/helpers/archive-tab.ts
+++ b/packages/app/e2e/helpers/archive-tab.ts
@@ -3,8 +3,13 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
+import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
-import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
+import {
+ buildHostAgentDetailRoute,
+ buildHostSessionsRoute,
+ buildHostWorkspaceRoute,
+} from "@/utils/host-routes";
export type ArchiveTabAgent = {
id: string;
@@ -18,7 +23,7 @@ type ArchiveTabDaemonClient = {
createAgent(options: {
provider: string;
model: string;
- thinkingOptionId: string;
+ thinkingOptionId?: string;
modeId: string;
cwd: string;
title: string;
@@ -63,33 +68,36 @@ function buildSeededStoragePayload() {
};
}
+type ArchiveTabDaemonClientConfig = {
+ url: string;
+ clientId: string;
+ clientType: "cli";
+ webSocketFactory?: NodeWebSocketFactory;
+};
+
async function loadDaemonClientConstructor(): Promise<
- new (config: {
- url: string;
- clientId: string;
- clientType: "cli";
- }) => ArchiveTabDaemonClient
+ new (
+ config: ArchiveTabDaemonClientConfig,
+ ) => ArchiveTabDaemonClient
> {
- const repoRoot = path.resolve(process.cwd(), "../..");
+ const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
- DaemonClient: new (config: {
- url: string;
- clientId: string;
- clientType: "cli";
- }) => ArchiveTabDaemonClient;
+ DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise {
const DaemonClient = await loadDaemonClientConstructor();
+ const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
+ webSocketFactory,
});
await client.connect();
return client;
@@ -100,17 +108,18 @@ export async function createIdleAgent(
input: { cwd: string; title: string },
): Promise {
const created = await client.createAgent({
- provider: "codex",
- model: "gpt-5.1-codex-mini",
- thinkingOptionId: "low",
- modeId: "full-access",
+ provider: "opencode",
+ model: "opencode/gpt-5-nano",
+ modeId: "default",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
- throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
+ throw new Error(
+ `Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record).error ?? "unknown")}`,
+ );
}
return {
id: created.id,
diff --git a/packages/app/e2e/helpers/daemon-registry.ts b/packages/app/e2e/helpers/daemon-registry.ts
index dd0146890..b2e7e170d 100644
--- a/packages/app/e2e/helpers/daemon-registry.ts
+++ b/packages/app/e2e/helpers/daemon-registry.ts
@@ -2,7 +2,7 @@ export const TEST_HOST_LABEL = "localhost";
export const TEST_PROVIDER_PREFERENCES = {
claude: { model: "haiku" },
- codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
+ codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
} as const;
export function buildDirectTcpConnection(endpoint: string) {
diff --git a/packages/app/e2e/helpers/node-ws-factory.ts b/packages/app/e2e/helpers/node-ws-factory.ts
new file mode 100644
index 000000000..fed9af2a6
--- /dev/null
+++ b/packages/app/e2e/helpers/node-ws-factory.ts
@@ -0,0 +1,27 @@
+import WebSocket from "ws";
+
+type WebSocketLike = {
+ readyState: number;
+ send: (data: string | Uint8Array | ArrayBuffer) => void;
+ close: (code?: number, reason?: string) => void;
+ binaryType?: string;
+ on?: (event: string, listener: (...args: any[]) => void) => void;
+ off?: (event: string, listener: (...args: any[]) => void) => void;
+ removeListener?: (event: string, listener: (...args: any[]) => void) => void;
+ addEventListener?: (event: string, listener: (event: any) => void) => void;
+ removeEventListener?: (event: string, listener: (event: any) => void) => void;
+ onopen?: ((event: any) => void) | null;
+ onclose?: ((event: any) => void) | null;
+ onerror?: ((event: any) => void) | null;
+ onmessage?: ((event: any) => void) | null;
+};
+
+export type NodeWebSocketFactory = (
+ url: string,
+ options?: { headers?: Record },
+) => WebSocketLike;
+
+export function createNodeWebSocketFactory(): NodeWebSocketFactory {
+ return (url: string, options?: { headers?: Record }) =>
+ new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike;
+}
diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts
index 8ac45d251..026351e4f 100644
--- a/packages/app/e2e/helpers/terminal-perf.ts
+++ b/packages/app/e2e/helpers/terminal-perf.ts
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
+import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
@@ -49,29 +50,36 @@ function getServerId(): string {
return serverId;
}
+type TerminalPerfDaemonClientConfig = {
+ url: string;
+ clientId: string;
+ clientType: "cli";
+ webSocketFactory?: NodeWebSocketFactory;
+};
+
async function loadDaemonClientConstructor(): Promise<
- new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
+ new (
+ config: TerminalPerfDaemonClientConfig,
+ ) => TerminalPerfDaemonClient
> {
- const repoRoot = path.resolve(process.cwd(), "../..");
+ const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
- DaemonClient: new (config: {
- url: string;
- clientId: string;
- clientType: "cli";
- }) => TerminalPerfDaemonClient;
+ DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise {
const DaemonClient = await loadDaemonClientConstructor();
+ const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
+ webSocketFactory,
});
await client.connect();
return client;
@@ -83,6 +91,10 @@ export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): stri
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
+function buildWorkspaceUrl(cwd: string): string {
+ return buildHostWorkspaceRoute(getServerId(), cwd);
+}
+
export async function getTerminalBufferText(page: Page): Promise {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
@@ -124,33 +136,29 @@ export async function navigateToTerminal(
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
- const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
+ const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
await page.goto(workspaceRoute);
+ // The workspace layout consumes `?open=...`, returns null during the effect,
+ // then replaces the URL with the clean workspace route after preparing the tab.
+ const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
+ await page.waitForURL(
+ (url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
+ { timeout: 15_000 },
+ );
+
// Wait for daemon connection (sidebar shows host label)
- await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
+ await page
+ .getByText("localhost", { exact: true })
+ .first()
+ .waitFor({ state: "visible", timeout: 15_000 });
+
+ // The open intent should have prepared and focused the exact pre-created terminal tab.
+ const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
+ await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
+ await terminalTab.click();
- // The workspace should now query listTerminals and discover our terminal.
- // Click the terminal tab if it auto-appeared, or wait for it.
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
- const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
-
- if (!surfaceVisible) {
- // Terminal tab might not be focused — look for it in the tab row and click it
- const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
- const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
-
- if (tabExists) {
- await terminalTab.click();
- } else {
- // Terminal tab not yet created — click "New terminal tab" to create one through the UI
- const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
- await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
- await newTerminalBtn.click();
- }
- }
-
- // Wait for terminal surface to be visible
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
@@ -161,6 +169,7 @@ export async function navigateToTerminal(
// overlay may never appear if attachment is instant
});
+ await terminalSurface.scrollIntoViewIfNeeded();
await terminalSurface.click();
}
diff --git a/packages/app/e2e/terminal-performance.spec.ts b/packages/app/e2e/terminal-performance.spec.ts
index 9490343cb..912704989 100644
--- a/packages/app/e2e/terminal-performance.spec.ts
+++ b/packages/app/e2e/terminal-performance.spec.ts
@@ -57,7 +57,11 @@ test.describe("Terminal wire performance", () => {
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
- await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
+ await waitForTerminalContent(
+ page,
+ (text) => text.includes(sentinel),
+ THROUGHPUT_BUDGET_MS + 15_000,
+ );
const elapsedMs = Date.now() - startMs;
@@ -84,9 +88,10 @@ test.describe("Terminal wire performance", () => {
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
- expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
- THROUGHPUT_BUDGET_MS,
- );
+ expect(
+ elapsedMs,
+ `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
+ ).toBeLessThan(THROUGHPUT_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
diff --git a/packages/app/package.json b/packages/app/package.json
index f2064b26e..0e028248b 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
- "version": "0.1.52",
+ "version": "0.1.54",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
- "@getpaseo/expo-two-way-audio": "0.1.52",
- "@getpaseo/highlight": "0.1.52",
- "@getpaseo/server": "0.1.52",
+ "@getpaseo/expo-two-way-audio": "0.1.54",
+ "@getpaseo/highlight": "0.1.54",
+ "@getpaseo/server": "0.1.54",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -108,6 +108,7 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/react": "~19.2.0",
+ "@types/ws": "^8.18.1",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
@@ -115,6 +116,7 @@
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
- "wrangler": "^4.59.1"
+ "wrangler": "^4.59.1",
+ "ws": "^8.20.0"
}
}
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index ea9f1d914..27d6c740e 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -787,34 +787,14 @@ function RootStack() {
- {
- const serverValue = Array.isArray(params?.serverId)
- ? params.serverId[0]
- : params?.serverId;
- const workspaceValue = Array.isArray(params?.workspaceId)
- ? params.workspaceId[0]
- : params?.workspaceId;
- const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
- const workspaceId =
- typeof workspaceValue === "string"
- ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? workspaceValue.trim())
- : "";
- return `${serverId}:${workspaceId}`;
- }}
- />
-
-
-
-
-
-
+
+
+
+
+
+
);
diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx
index a42d57379..06e7f53ed 100644
--- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx
+++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
+import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
@@ -7,6 +8,14 @@ import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execut
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
+ return (
+
+
+
+ );
+}
+
+function HostAgentReadyRouteContent() {
const router = useRouter();
const params = useLocalSearchParams<{
serverId?: string;
diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx
index 1dcf0baeb..550bbc171 100644
--- a/packages/app/src/app/h/[serverId]/index.tsx
+++ b/packages/app/src/app/h/[serverId]/index.tsx
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
+import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
@@ -21,6 +22,14 @@ function getCurrentPathname(fallbackPathname: string): string {
}
export default function HostIndexRoute() {
+ return (
+
+
+
+ );
+}
+
+function HostIndexRouteContent() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
@@ -59,11 +68,6 @@ export default function HostIndexRoute() {
);
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
- visibleWorkspaces.sort((left, right) => {
- const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
- const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
- return rightTime - leftTime;
- });
const primaryAgent = visibleAgents[0];
const primaryAgentWorkspaceId = resolveWorkspaceIdByExecutionDirectory({
diff --git a/packages/app/src/app/h/[serverId]/open-project.tsx b/packages/app/src/app/h/[serverId]/open-project.tsx
index 829a2a623..cbb1bc713 100644
--- a/packages/app/src/app/h/[serverId]/open-project.tsx
+++ b/packages/app/src/app/h/[serverId]/open-project.tsx
@@ -1,7 +1,16 @@
import { useLocalSearchParams } from "expo-router";
+import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { OpenProjectScreen } from "@/screens/open-project-screen";
export default function HostOpenProjectRoute() {
+ return (
+
+
+
+ );
+}
+
+function HostOpenProjectRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
diff --git a/packages/app/src/app/h/[serverId]/sessions.tsx b/packages/app/src/app/h/[serverId]/sessions.tsx
index 29bc04c20..8dd5cb87a 100644
--- a/packages/app/src/app/h/[serverId]/sessions.tsx
+++ b/packages/app/src/app/h/[serverId]/sessions.tsx
@@ -1,7 +1,16 @@
import { useLocalSearchParams } from "expo-router";
+import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function HostAgentsRoute() {
+ return (
+
+
+
+ );
+}
+
+function HostAgentsRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
diff --git a/packages/app/src/app/h/[serverId]/settings.tsx b/packages/app/src/app/h/[serverId]/settings.tsx
index 38d368f98..28d1bf6a4 100644
--- a/packages/app/src/app/h/[serverId]/settings.tsx
+++ b/packages/app/src/app/h/[serverId]/settings.tsx
@@ -1,3 +1,10 @@
+import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
-export default SettingsScreen;
+export default function HostSettingsRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
index aa8c89726..ac38f8434 100644
--- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
+++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
@@ -1,9 +1,11 @@
-import { useEffect, useRef } from "react";
-import { useGlobalSearchParams, usePathname, useRouter } from "expo-router";
+import { useEffect, useRef, useState } from "react";
+import { useGlobalSearchParams, useLocalSearchParams, usePathname, useRootNavigationState } from "expo-router";
+import { Platform } from "react-native";
+import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import {
- buildHostWorkspaceRoute,
+ decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
@@ -38,9 +40,22 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
}
export default function HostWorkspaceLayout() {
- const router = useRouter();
+ return (
+
+
+
+ );
+}
+
+function HostWorkspaceLayoutContent() {
+ const rootNavigationState = useRootNavigationState();
const consumedIntentRef = useRef(null);
+ const [intentConsumed, setIntentConsumed] = useState(false);
const pathname = usePathname();
+ const params = useLocalSearchParams<{
+ serverId?: string | string[];
+ workspaceId?: string | string[];
+ }>();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
@@ -53,6 +68,9 @@ export default function HostWorkspaceLayout() {
if (!openValue) {
return;
}
+ if (!rootNavigationState?.key) {
+ return;
+ }
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
@@ -61,19 +79,30 @@ export default function HostWorkspaceLayout() {
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
- const route = openIntent
- ? prepareWorkspaceTab({
- serverId,
- workspaceId,
- target: getOpenIntentTarget(openIntent),
- pin: openIntent.kind === "agent",
- })
- : buildHostWorkspaceRoute(serverId, workspaceId);
+ if (openIntent) {
+ prepareWorkspaceTab({
+ serverId,
+ workspaceId,
+ target: getOpenIntentTarget(openIntent),
+ pin: openIntent.kind === "agent",
+ });
+ }
- router.replace(route as any);
- }, [openValue, router, serverId, workspaceId]);
+ // Expo Router's replace ignores query-param-only changes (findDivergentState
+ // skips search params). Strip ?open from the browser URL directly so the
+ // address bar reflects the clean workspace route.
+ if (Platform.OS === "web" && typeof window !== "undefined") {
+ const url = new URL(window.location.href);
+ if (url.searchParams.has("open")) {
+ url.searchParams.delete("open");
+ window.history.replaceState(null, "", url.toString());
+ }
+ }
- if (openValue) {
+ setIntentConsumed(true);
+ }, [openValue, rootNavigationState?.key, serverId, workspaceId]);
+
+ if (openValue && !intentConsumed) {
return null;
}
diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx
index 1180dcb8f..342a9a8e8 100644
--- a/packages/app/src/app/index.tsx
+++ b/packages/app/src/app/index.tsx
@@ -1,15 +1,8 @@
import { useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
-import {
- useHostRuntimeBootstrapState,
- useStoreReady,
-} from "@/app/_layout";
-import {
- getHostRuntimeStore,
- isHostRuntimeConnected,
- useHosts,
-} from "@/runtime/host-runtime";
+import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
+import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
@@ -58,9 +51,7 @@ export default function Index() {
return;
}
- const targetRoute = anyOnlineServerId
- ? buildHostRootRoute(anyOnlineServerId)
- : WELCOME_ROUTE;
+ const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
index 2bf142aba..6e2d750bb 100644
--- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
+++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
@@ -773,7 +773,8 @@ export function ModelDropdown({
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef(null);
- const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
+ const selectedLabel =
+ models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
const helperText = error
? undefined
diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx
index 402e1ca40..501963f7a 100644
--- a/packages/app/src/components/agent-status-bar.tsx
+++ b/packages/app/src/components/agent-status-bar.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useRef, useState } from "react";
+import { memo, useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
@@ -300,7 +300,8 @@ function ControlledStatusBar({
);
return map;
}, [modelOptions, provider]);
- const effectiveProviderDefinitions = providerDefinitions ??
+ const effectiveProviderDefinitions =
+ providerDefinitions ??
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
@@ -670,10 +671,7 @@ function ControlledStatusBar({
onClose={onDropdownClose}
renderTrigger={({ selectedModelLabel }) => (
@@ -850,7 +848,11 @@ function ControlledStatusBar({
const EMPTY_MODES: AgentMode[] = [];
-export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStatusBarProps) {
+export const AgentStatusBar = memo(function AgentStatusBar({
+ agentId,
+ serverId,
+ onDropdownClose,
+}: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => {
@@ -929,7 +931,10 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
}, [models]);
const favoriteKeys = useMemo(
- () => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
+ () =>
+ new Set(
+ (preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
+ ),
[preferences.favoriteModels],
);
@@ -948,7 +953,9 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
+ modeOptions.length > 0
+ ? modeOptions
+ : [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={agentProviderDefinitions}
@@ -967,15 +974,14 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
if (!client) {
return;
}
- void updatePreferences(
- (current) =>
- mergeProviderPreferences({
- preferences: current,
- provider: agent.provider,
- updates: {
- model: modelId,
- },
- }),
+ void updatePreferences((current) =>
+ mergeProviderPreferences({
+ preferences: current,
+ provider: agent.provider,
+ updates: {
+ model: modelId,
+ },
+ }),
).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error);
});
@@ -985,7 +991,9 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
}}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
- void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
+ void updatePreferences((current) =>
+ toggleFavoriteModel({ preferences: current, provider, modelId }),
+ ).catch((error) => {
console.warn("[AgentStatusBar] toggle favorite model failed", error);
});
}}
@@ -997,18 +1005,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
}
const activeModelId = modelSelection.activeModelId;
if (activeModelId) {
- void updatePreferences(
- (current) =>
- mergeProviderPreferences({
- preferences: current,
- provider: agent.provider,
- updates: {
- model: activeModelId,
- thinkingByModel: {
- [activeModelId]: thinkingOptionId,
- },
+ void updatePreferences((current) =>
+ mergeProviderPreferences({
+ preferences: current,
+ provider: agent.provider,
+ updates: {
+ model: activeModelId,
+ thinkingByModel: {
+ [activeModelId]: thinkingOptionId,
},
- }),
+ },
+ }),
).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error);
});
@@ -1022,17 +1029,16 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
if (!client) {
return;
}
- void updatePreferences(
- (current) =>
- mergeProviderPreferences({
- preferences: current,
- provider: agent.provider,
- updates: {
- featureValues: {
- [featureId]: value,
- },
+ void updatePreferences((current) =>
+ mergeProviderPreferences({
+ preferences: current,
+ provider: agent.provider,
+ updates: {
+ featureValues: {
+ [featureId]: value,
},
- }),
+ },
+ }),
).catch((error) => {
console.warn("[AgentStatusBar] persist feature preference failed", error);
});
@@ -1046,7 +1052,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
disabled={!client}
/>
);
-}
+});
export function DraftAgentStatusBar({
providerDefinitions,
@@ -1088,7 +1094,10 @@ export function DraftAgentStatusBar({
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
}, [thinkingOptions]);
const favoriteKeys = useMemo(
- () => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
+ () =>
+ new Set(
+ (preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
+ ),
[preferences.favoriteModels],
);
@@ -1107,7 +1116,9 @@ export function DraftAgentStatusBar({
onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys}
onToggleFavorite={(provider, modelId) => {
- void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
+ void updatePreferences((current) =>
+ toggleFavoriteModel({ preferences: current, provider, modelId }),
+ ).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
@@ -1154,7 +1165,9 @@ export function DraftAgentStatusBar({
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
- void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
+ void updatePreferences((current) =>
+ toggleFavoriteModel({ preferences: current, provider, modelId }),
+ ).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
diff --git a/packages/app/src/components/agent-status-bar.utils.ts b/packages/app/src/components/agent-status-bar.utils.ts
index baf563553..4001ed306 100644
--- a/packages/app/src/components/agent-status-bar.utils.ts
+++ b/packages/app/src/components/agent-status-bar.utils.ts
@@ -52,8 +52,7 @@ export function resolveAgentModelSelection(input: {
: null;
const preferredModelId =
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
- const fallbackModel =
- models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
+ const fallbackModel = models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
const selectedModel =
models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index 870fd63a1..e6ec93ec9 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -261,7 +261,10 @@ const AgentStreamViewComponent = forwardRef
);
case "thought": {
@@ -375,10 +380,7 @@ const AgentStreamViewComponent = forwardRef
+
);
}
@@ -921,7 +923,9 @@ function PermissionRequestCard({
) : null}
- {planMarkdown ? : null}
+ {planMarkdown ? (
+
+ ) : null}
{!isPlanRequest ? (
{
expect(estimateStreamItemHeight(item)).toBe(220);
});
+
+ it("uses cached assistant image metadata when available", () => {
+ clearAssistantImageMetadataCache();
+ setAssistantImageMetadata(
+ {
+ source: "https://example.com/tall.png",
+ },
+ { width: 800, height: 1600 },
+ );
+
+ const item: StreamItem = {
+ kind: "assistant_message",
+ id: "a-image",
+ text: "Look at this\n\n",
+ timestamp: createTimestamp(2),
+ };
+
+ expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
+ });
});
describe("web virtualization test overrides", () => {
diff --git a/packages/app/src/components/agent-stream-web-virtualization.ts b/packages/app/src/components/agent-stream-web-virtualization.ts
index 2a5bdb2ce..794413182 100644
--- a/packages/app/src/components/agent-stream-web-virtualization.ts
+++ b/packages/app/src/components/agent-stream-web-virtualization.ts
@@ -1,4 +1,5 @@
import type { StreamItem } from "@/types/stream";
+import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
@@ -45,7 +46,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
- return 220;
+ return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
case "tool_call":
return 136;
case "thought":
diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx
index f17b4cdd5..432849389 100644
--- a/packages/app/src/components/branch-switcher.tsx
+++ b/packages/app/src/components/branch-switcher.tsx
@@ -1,36 +1,49 @@
import { useRef } from "react";
import { Pressable, Text, View } from "react-native";
+import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
+import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
+import { useToast } from "@/contexts/toast-context";
+import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
interface BranchSwitcherProps {
currentBranchName: string | null;
title: string;
- branchOptions: ComboboxOption[];
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
- onBranchSelect: (branchId: string) => void;
+ serverId: string;
+ workspaceId: string;
+ isGitCheckout: boolean;
}
export function BranchSwitcher({
currentBranchName,
title,
- branchOptions,
- isOpen,
- onOpenChange,
- onBranchSelect,
+ serverId,
+ workspaceId,
+ isGitCheckout,
}: BranchSwitcherProps) {
const { theme } = useUnistyles();
const anchorRef = useRef(null);
+ const client = useHostRuntimeClient(serverId);
+ const isConnected = useHostRuntimeIsConnected(serverId);
+ const toast = useToast();
+ const queryClient = useQueryClient();
+
+ const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
+ client,
+ normalizedServerId: serverId,
+ normalizedWorkspaceId: workspaceId,
+ currentBranchName,
+ isGitCheckout,
+ isConnected,
+ toast,
+ queryClient,
+ });
if (!currentBranchName) {
return (
-
+
{title}
);
@@ -40,7 +53,7 @@ export function BranchSwitcher({
onOpenChange(true)}
+ onPress={() => setIsOpen(true)}
style={({ hovered, pressed }) => [
styles.branchSwitcherTrigger,
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
@@ -48,33 +61,23 @@ export function BranchSwitcher({
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
-
-
+
+
{title}
-
+
- }
+ leadingSlot={ }
/>
)}
/>
diff --git a/packages/app/src/components/combined-model-selector.test.ts b/packages/app/src/components/combined-model-selector.test.ts
index f428e1ff4..75847f0ab 100644
--- a/packages/app/src/components/combined-model-selector.test.ts
+++ b/packages/app/src/components/combined-model-selector.test.ts
@@ -42,14 +42,25 @@ describe("combined model selector helpers", () => {
];
it("keeps enough data to search by model and provider name", async () => {
- const rows = buildModelRows(providerDefinitions, new Map([
- ["claude", claudeModels],
- ["codex", codexModels],
- ]));
+ const rows = buildModelRows(
+ providerDefinitions,
+ new Map([
+ ["claude", claudeModels],
+ ["codex", codexModels],
+ ]),
+ );
expect(rows).toEqual([
- expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }),
- expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }),
+ expect.objectContaining({
+ providerLabel: "Claude",
+ modelLabel: "Sonnet 4.6",
+ modelId: "sonnet-4.6",
+ }),
+ expect.objectContaining({
+ providerLabel: "Codex",
+ modelLabel: "GPT-5.4",
+ modelId: "gpt-5.4",
+ }),
]);
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx
index 60e9e48a0..1f17fa652 100644
--- a/packages/app/src/components/combined-model-selector.tsx
+++ b/packages/app/src/components/combined-model-selector.tsx
@@ -11,17 +11,8 @@ import {
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
-import {
- ArrowLeft,
- ChevronDown,
- ChevronRight,
- Search,
- Star,
-} from "lucide-react-native";
-import type {
- AgentModelDefinition,
- AgentProvider,
-} from "@server/server/agent/agent-sdk-types";
+import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
+import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
const IS_WEB = Platform.OS === "web";
@@ -127,7 +118,10 @@ function sortFavoritesFirst(
function groupRowsByProvider(
rows: SelectorModelRow[],
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
- const grouped = new Map();
+ const grouped = new Map<
+ string,
+ { providerId: string; providerLabel: string; rows: SelectorModelRow[] }
+ >();
for (const row of rows) {
const existing = grouped.get(row.provider);
@@ -174,8 +168,7 @@ function ModelRow({
[onToggleFavorite, row.modelId, row.provider],
);
- const showDescription =
- row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
+ const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
return (
{groupedRows.map((group, index) => {
- const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
+ const providerDefinition = providerDefinitions.find(
+ (definition) => definition.id === group.providerId,
+ );
const ProvIcon = getProviderIcon(group.providerId);
const isInline = viewKind === "provider";
diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts
index b2ff9b78f..e96e5de72 100644
--- a/packages/app/src/components/combined-model-selector.utils.ts
+++ b/packages/app/src/components/combined-model-selector.utils.ts
@@ -8,7 +8,9 @@ export function resolveProviderLabel(
providerDefinitions: AgentProviderDefinition[],
providerId: string,
): string {
- return providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId;
+ return (
+ providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId
+ );
}
export function buildSelectedTriggerLabel(modelLabel: string): string {
@@ -19,7 +21,9 @@ export function buildModelRows(
providerDefinitions: AgentProviderDefinition[],
allProviderModels: Map,
): SelectorModelRow[] {
- const providerLabelMap = new Map(providerDefinitions.map((definition) => [definition.id, definition.label]));
+ const providerLabelMap = new Map(
+ providerDefinitions.map((definition) => [definition.id, definition.label]),
+ );
const rows: SelectorModelRow[] = [];
for (const definition of providerDefinitions) {
diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx
index cd517fe06..5d43cd0eb 100644
--- a/packages/app/src/components/composer.tsx
+++ b/packages/app/src/components/composer.tsx
@@ -608,14 +608,14 @@ export function Composer({
)}
-
- Interrupt
- {dictationCancelKeys ? (
-
- ) : null}
-
-
-
+
+ Interrupt
+ {dictationCancelKeys ? (
+
+ ) : null}
+
+
+
) : null;
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
@@ -661,9 +661,7 @@ export function Composer({
typeof agentState.contextWindowMaxTokens === "number" &&
typeof agentState.contextWindowUsedTokens === "number";
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
- const contextWindowUsedTokens = hasContextWindowMeter
- ? agentState.contextWindowUsedTokens
- : null;
+ const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
const beforeVoiceContent = (
diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx
index f324ae158..481aadb7a 100644
--- a/packages/app/src/components/explorer-sidebar.tsx
+++ b/packages/app/src/components/explorer-sidebar.tsx
@@ -270,7 +270,11 @@ export function ExplorerSidebar({
+
{/* Resize handle - absolutely positioned over left border */}
diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx
index b1d0230f9..0d4539cd5 100644
--- a/packages/app/src/components/file-explorer-pane.tsx
+++ b/packages/app/src/components/file-explorer-pane.tsx
@@ -118,23 +118,18 @@ export function FileExplorerPane({
: undefined,
);
- const {
- requestDirectoryListing,
- requestFileDownloadToken,
- selectExplorerEntry,
- } = useFileExplorerActions({
- serverId,
- workspaceId,
- workspaceRoot: normalizedWorkspaceRoot,
- });
+ const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
+ useFileExplorerActions({
+ serverId,
+ workspaceId,
+ workspaceRoot: normalizedWorkspaceRoot,
+ });
const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
);
- const setExpandedPathsForWorkspace = usePanelStore(
- (state) => state.setExpandedPathsForWorkspace,
- );
+ const setExpandedPathsForWorkspace = usePanelStore((state) => state.setExpandedPathsForWorkspace);
const expandedPaths = useMemo(
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
[expandedPathsArray],
@@ -177,7 +172,8 @@ export function FileExplorerPane({
recordHistory: false,
setCurrentPath: false,
});
- const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
+ const persistedPaths =
+ usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
@@ -201,10 +197,7 @@ export function FileExplorerPane({
if (newPaths.length === 0) {
return;
}
- setExpandedPathsForWorkspace(
- workspaceStateKey,
- [...Array.from(expandedPaths), ...newPaths],
- );
+ setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
newPaths.forEach((path) => {
if (!directories.has(path)) {
void requestDirectoryListing(path, {
@@ -234,10 +227,7 @@ export function FileExplorerPane({
Array.from(expandedPaths).filter((path) => path !== entry.path),
);
} else {
- setExpandedPathsForWorkspace(
- workspaceStateKey,
- [...Array.from(expandedPaths), entry.path],
- );
+ setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
if (!directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
diff --git a/packages/app/src/components/git-actions-policy.test.ts b/packages/app/src/components/git-actions-policy.test.ts
index cd02f515a..93777d63a 100644
--- a/packages/app/src/components/git-actions-policy.test.ts
+++ b/packages/app/src/components/git-actions-policy.test.ts
@@ -91,7 +91,8 @@ describe("git-actions-policy", () => {
expect(pushAction).toMatchObject({
disabled: false,
- unavailableMessage: "Push isn't available yet because there are newer changes to bring in first",
+ unavailableMessage:
+ "Push isn't available yet because there are newer changes to bring in first",
});
});
@@ -152,9 +153,9 @@ describe("git-actions-policy", () => {
"merge-branch",
"pr",
]);
- expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(
- true,
- );
+ expect(
+ actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
+ ).toBe(true);
});
it("only shows archive worktree for paseo worktrees", () => {
diff --git a/packages/app/src/components/git-actions-policy.ts b/packages/app/src/components/git-actions-policy.ts
index df1c9e7a9..c08964498 100644
--- a/packages/app/src/components/git-actions-policy.ts
+++ b/packages/app/src/components/git-actions-policy.ts
@@ -110,8 +110,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
successLabel: "Merged",
disabled: input.runtime["merge-branch"].disabled,
status: input.runtime["merge-branch"].status,
- unavailableMessage:
- input.runtime["merge-branch"].disabled ? undefined : getMergeBranchUnavailableMessage(input),
+ unavailableMessage: input.runtime["merge-branch"].disabled
+ ? undefined
+ : getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler,
});
@@ -123,10 +124,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled,
status: input.runtime["merge-from-base"].status,
- unavailableMessage:
- input.runtime["merge-from-base"].disabled
- ? undefined
- : getMergeFromBaseUnavailableMessage(input),
+ unavailableMessage: input.runtime["merge-from-base"].disabled
+ ? undefined
+ : getMergeFromBaseUnavailableMessage(input),
icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler,
});
@@ -214,19 +214,16 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
successLabel: "PR Created",
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
- unavailableMessage:
- input.runtime.pr.disabled ? undefined : getCreatePrUnavailableMessage(input),
+ unavailableMessage: input.runtime.pr.disabled
+ ? undefined
+ : getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
function canPull(input: BuildGitActionsInput): boolean {
- return (
- input.hasRemote &&
- !input.hasUncommittedChanges &&
- input.behindOfOrigin > 0
- );
+ return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
}
function canPush(input: BuildGitActionsInput): boolean {
diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/components/git-actions-split-button.tsx
index 7a5dffe12..f510424ca 100644
--- a/packages/app/src/components/git-actions-split-button.tsx
+++ b/packages/app/src/components/git-actions-split-button.tsx
@@ -98,9 +98,9 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={
- action.id === "archive-worktree" && archiveShortcutKeys
- ?
- : undefined
+ action.id === "archive-worktree" && archiveShortcutKeys ? (
+
+ ) : undefined
}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx
index fb25c718a..7a6a308ff 100644
--- a/packages/app/src/components/git-diff-pane.tsx
+++ b/packages/app/src/components/git-diff-pane.tsx
@@ -1,12 +1,4 @@
-import {
- useState,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- memo,
- type ReactElement,
-} from "react";
+import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement } from "react";
import { useRouter } from "expo-router";
import { DiffStat } from "@/components/diff-stat";
import {
@@ -129,12 +121,7 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
};
return (
-
+
{tokens.map((token, index) => (
{token.text}
@@ -184,13 +171,7 @@ function DiffGutterCell({
);
}
-function DiffTextLine({
- line,
- wrapLines,
-}: {
- line: DiffLine;
- wrapLines: boolean;
-}) {
+function DiffTextLine({ line, wrapLines }: { line: DiffLine; wrapLines: boolean }) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return (
@@ -260,12 +241,7 @@ function DiffLineView({
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
return (
-
+
+
+
{rows.map((row, i) => {
if (row.kind === "header") {
- return ;
+ return (
+
+ );
}
const line = side === "left" ? row.left : row.right;
- return ;
+ return (
+
+ );
})}
);
}
- return ;
+ return (
+
+ );
})}
@@ -541,7 +534,11 @@ function DiffFileBody({
let maxLineNo = 0;
for (const hunk of file.hunks) {
- maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
+ maxLineNo = Math.max(
+ maxLineNo,
+ hunk.oldStart + hunk.oldCount,
+ hunk.newStart + hunk.newCount,
+ );
}
const gutterWidth = lineNumberGutterWidth(maxLineNo);
@@ -549,8 +546,19 @@ function DiffFileBody({
const rows = buildSplitDiffRows(file);
return (
-
-
+
+
);
}
@@ -562,7 +570,13 @@ function DiffFileBody({
{computedLines.map(({ line, lineNumber, key }) => (
-
+
))}
@@ -574,7 +588,12 @@ function DiffFileBody({
{computedLines.map(({ line, lineNumber, key }) => (
-
+
))}
- 0 && { minWidth: availableWidth }]}>
+ 0 && { minWidth: availableWidth }]}
+ >
{computedLines.map(({ line, key }) => (
))}
@@ -701,10 +722,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const setDiffExpandedPathsForWorkspace = usePanelStore(
(state) => state.setDiffExpandedPathsForWorkspace,
);
- const expandedPaths = useMemo(
- () => new Set(expandedPathsArray ?? []),
- [expandedPathsArray],
- );
+ const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
const diffListRef = useRef>(null);
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx
index 4a5a28b22..f516934e9 100644
--- a/packages/app/src/components/headers/screen-header.tsx
+++ b/packages/app/src/components/headers/screen-header.tsx
@@ -23,7 +23,13 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
-export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
+export function ScreenHeader({
+ left,
+ right,
+ leftStyle,
+ rightStyle,
+ borderless,
+}: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor();
diff --git a/packages/app/src/components/host-route-bootstrap-boundary.tsx b/packages/app/src/components/host-route-bootstrap-boundary.tsx
new file mode 100644
index 000000000..363e0e850
--- /dev/null
+++ b/packages/app/src/components/host-route-bootstrap-boundary.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from "react";
+import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
+import { StartupSplashScreen } from "@/screens/startup-splash-screen";
+
+export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
+ const storeReady = useStoreReady();
+ const bootstrapState = useHostRuntimeBootstrapState();
+
+ if (!storeReady) {
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/packages/app/src/components/icons/editor-app-icons.tsx b/packages/app/src/components/icons/editor-app-icons.tsx
index a0aef6c01..468020f3b 100644
--- a/packages/app/src/components/icons/editor-app-icons.tsx
+++ b/packages/app/src/components/icons/editor-app-icons.tsx
@@ -24,17 +24,11 @@ const EDITOR_APP_IMAGES: Record = {
};
/* eslint-enable @typescript-eslint/no-require-imports */
-export function hasBundledEditorAppIcon(
- editorId: EditorTargetId,
-): editorId is KnownEditorTargetId {
+export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
return isKnownEditorTargetId(editorId);
}
-export function EditorAppIcon({
- editorId,
- size = 16,
- color,
-}: EditorAppIconProps) {
+export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
if (!hasBundledEditorAppIcon(editorId)) {
return ;
}
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 55594f13d..2ddebe1a4 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -162,7 +162,12 @@ export const LeftSidebar = memo(function LeftSidebar({
[daemons],
);
const renderHostOption = useCallback(
- ({ option, selected, active, onPress }: {
+ ({
+ option,
+ selected,
+ active,
+ onPress,
+ }: {
option: ComboboxOption;
selected: boolean;
active: boolean;
@@ -187,11 +192,8 @@ export const LeftSidebar = memo(function LeftSidebar({
serverId: activeServerId,
enabled: isOpen,
});
- const {
- collapsedProjectKeys,
- shortcutIndexByWorkspaceKey,
- toggleProjectCollapsed,
- } = useSidebarShortcutModel(projects);
+ const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
+ useSidebarShortcutModel(projects);
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -533,7 +535,12 @@ function MobileSidebar({
@@ -550,7 +557,6 @@ function MobileSidebar({
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
-
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
@@ -719,116 +725,121 @@ function DesktopSidebar({
}
return (
-
+
-
-
- {padding.top > 0 ? : null}
-
-
-
+
+
+ {padding.top > 0 ? : null}
+
+
+
+
-
- {isInitialLoad ? (
-
- ) : (
-
- )}
+ {isInitialLoad ? (
+
+ ) : (
+
+ )}
-
-
- [
- styles.hostTrigger,
- hovered && styles.hostTriggerHovered,
+
+
+ [
+ styles.hostTrigger,
+ hovered && styles.hostTriggerHovered,
+ ]}
+ onPress={() => setIsHostPickerOpen(true)}
+ disabled={hostOptions.length === 0}
+ >
+
+
+ {activeHostLabel}
+
+
+
+
+
+
+
+ {({ hovered }) => (
+
+ )}
+
+
+
+
+ Add project
+ {newAgentKeys ? : null}
+
+
+
+
+ {({ hovered }) => (
+
+ )}
+
+
+
+
+
+ {/* Resize handle - absolutely positioned over right border */}
+
+ setIsHostPickerOpen(true)}
- disabled={hostOptions.length === 0}
- >
-
-
- {activeHostLabel}
-
-
-
-
-
-
-
- {({ hovered }) => (
-
- )}
-
-
-
-
- Add project
- {newAgentKeys ? : null}
-
-
-
-
- {({ hovered }) => (
-
- )}
-
-
-
-
-
- {/* Resize handle - absolutely positioned over right border */}
-
-
-
+ />
+
);
diff --git a/packages/app/src/components/material-file-icons.ts b/packages/app/src/components/material-file-icons.ts
index c683dbc84..03e761198 100644
--- a/packages/app/src/components/material-file-icons.ts
+++ b/packages/app/src/components/material-file-icons.ts
@@ -1,128 +1,128 @@
// Auto-generated from material-icon-theme. Do not edit manually.
const SVG_ICONS: Record = {
- "_default": ` `,
- "astro": ` `,
- "c": ` `,
- "clojure": ` `,
- "console": ` `,
- "cpp": ` `,
- "csharp": ` `,
- "css": ` `,
- "dart": ` `,
- "database": ` `,
- "document": ` `,
- "elixir": ` `,
- "erlang": ` `,
- "go": ` `,
- "gradle": ` `,
- "graphql": ` `,
- "groovy": ` `,
- "h": ` `,
- "haskell": ` `,
- "hcl": ` `,
- "hpp": ` `,
- "html": ` `,
- "image": ` `,
- "java": ` `,
- "javascript": ` `,
- "json": ` `,
- "kotlin": ` `,
- "less": ` `,
- "lock": ` `,
- "lua": ` `,
- "markdown": ` `,
- "nix": ` `,
- "ocaml": ` `,
- "php": ` `,
- "python": ` `,
- "r": ` `,
- "react": ` `,
- "react_ts": ` `,
- "ruby": ` `,
- "rust": ` `,
- "sass": ` `,
- "scala": ` `,
- "settings": ` `,
- "svelte": ` `,
- "svg": ` `,
- "swift": ` `,
- "terraform": ` `,
- "toml": ` `,
- "typescript": ` `,
- "vue": ` `,
- "webassembly": ` `,
- "xml": ` `,
- "yaml": ` `,
- "zig": ` `,
+ _default: ` `,
+ astro: ` `,
+ c: ` `,
+ clojure: ` `,
+ console: ` `,
+ cpp: ` `,
+ csharp: ` `,
+ css: ` `,
+ dart: ` `,
+ database: ` `,
+ document: ` `,
+ elixir: ` `,
+ erlang: ` `,
+ go: ` `,
+ gradle: ` `,
+ graphql: ` `,
+ groovy: ` `,
+ h: ` `,
+ haskell: ` `,
+ hcl: ` `,
+ hpp: ` `,
+ html: ` `,
+ image: ` `,
+ java: ` `,
+ javascript: ` `,
+ json: ` `,
+ kotlin: ` `,
+ less: ` `,
+ lock: ` `,
+ lua: ` `,
+ markdown: ` `,
+ nix: ` `,
+ ocaml: ` `,
+ php: ` `,
+ python: ` `,
+ r: ` `,
+ react: ` `,
+ react_ts: ` `,
+ ruby: ` `,
+ rust: ` `,
+ sass: ` `,
+ scala: ` `,
+ settings: ` `,
+ svelte: ` `,
+ svg: ` `,
+ swift: ` `,
+ terraform: ` `,
+ toml: ` `,
+ typescript: ` `,
+ vue: ` `,
+ webassembly: ` `,
+ xml: ` `,
+ yaml: ` `,
+ zig: ` `,
};
const EXTENSION_TO_ICON: Record = {
- "astro": "astro",
- "bash": "console",
- "c": "c",
- "cfg": "settings",
- "clj": "clojure",
- "conf": "settings",
- "cpp": "cpp",
- "cs": "csharp",
- "css": "css",
- "dart": "dart",
- "erl": "erlang",
- "ex": "elixir",
- "exs": "elixir",
- "gif": "image",
- "go": "go",
- "gql": "graphql",
- "gradle": "gradle",
- "graphql": "graphql",
- "groovy": "groovy",
- "h": "h",
- "hcl": "hcl",
- "hpp": "hpp",
- "hs": "haskell",
- "html": "html",
- "ico": "image",
- "ini": "settings",
- "java": "java",
- "jpeg": "image",
- "jpg": "image",
- "js": "javascript",
- "json": "json",
- "jsx": "react",
- "kt": "kotlin",
- "less": "less",
- "lock": "lock",
- "lua": "lua",
- "markdown": "markdown",
- "md": "markdown",
- "ml": "ocaml",
- "nix": "nix",
- "php": "php",
- "png": "image",
- "py": "python",
- "r": "r",
- "rb": "ruby",
- "rs": "rust",
- "scala": "scala",
- "scss": "sass",
- "sh": "console",
- "sql": "database",
- "svelte": "svelte",
- "svg": "svg",
- "swift": "swift",
- "tf": "terraform",
- "toml": "toml",
- "ts": "typescript",
- "tsx": "react_ts",
- "txt": "document",
- "vue": "vue",
- "wasm": "webassembly",
- "webp": "image",
- "xml": "xml",
- "yaml": "yaml",
- "yml": "yaml",
- "zig": "zig",
+ astro: "astro",
+ bash: "console",
+ c: "c",
+ cfg: "settings",
+ clj: "clojure",
+ conf: "settings",
+ cpp: "cpp",
+ cs: "csharp",
+ css: "css",
+ dart: "dart",
+ erl: "erlang",
+ ex: "elixir",
+ exs: "elixir",
+ gif: "image",
+ go: "go",
+ gql: "graphql",
+ gradle: "gradle",
+ graphql: "graphql",
+ groovy: "groovy",
+ h: "h",
+ hcl: "hcl",
+ hpp: "hpp",
+ hs: "haskell",
+ html: "html",
+ ico: "image",
+ ini: "settings",
+ java: "java",
+ jpeg: "image",
+ jpg: "image",
+ js: "javascript",
+ json: "json",
+ jsx: "react",
+ kt: "kotlin",
+ less: "less",
+ lock: "lock",
+ lua: "lua",
+ markdown: "markdown",
+ md: "markdown",
+ ml: "ocaml",
+ nix: "nix",
+ php: "php",
+ png: "image",
+ py: "python",
+ r: "r",
+ rb: "ruby",
+ rs: "rust",
+ scala: "scala",
+ scss: "sass",
+ sh: "console",
+ sql: "database",
+ svelte: "svelte",
+ svg: "svg",
+ swift: "swift",
+ tf: "terraform",
+ toml: "toml",
+ ts: "typescript",
+ tsx: "react_ts",
+ txt: "document",
+ vue: "vue",
+ wasm: "webassembly",
+ webp: "image",
+ xml: "xml",
+ yaml: "yaml",
+ yml: "yaml",
+ zig: "zig",
};
export function getFileIconSvg(fileName: string): string {
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index cd7161ba9..60a0a02dc 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -9,6 +9,7 @@ import {
ViewStyle,
Platform,
} from "react-native";
+import * as React from "react";
import {
useState,
useEffect,
@@ -23,7 +24,8 @@ import {
cloneElement,
} from "react";
import type { ReactNode, ComponentType } from "react";
-import Markdown, { MarkdownIt } from "react-native-markdown-display";
+import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display";
+import { useQuery } from "@tanstack/react-query";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
@@ -72,11 +74,18 @@ import {
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
+import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
+import {
+ getAssistantImageMetadata,
+ setAssistantImageMetadata,
+} from "@/utils/assistant-image-metadata";
+import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
+import type { DaemonClient } from "@server/client/daemon-client";
interface UserMessageProps {
message: string;
@@ -399,6 +408,8 @@ interface AssistantMessageProps {
timestamp: number;
onInlinePathPress?: (target: InlinePathTarget) => void;
workspaceRoot?: string;
+ serverId?: string;
+ client?: DaemonClient | null;
disableOuterSpacing?: boolean;
}
@@ -425,8 +436,215 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: 13,
userSelect: Platform.OS === "web" ? "text" : "auto",
},
+ imageFrame: {
+ width: "100%",
+ minHeight: 160,
+ marginHorizontal: -theme.spacing[1],
+ },
+ imageSurface: {
+ width: "100%",
+ overflow: "hidden",
+ },
+ image: {
+ width: "100%",
+ height: "100%",
+ },
+ imageState: {
+ alignItems: "center",
+ justifyContent: "center",
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[6],
+ gap: theme.spacing[2],
+ },
+ imageErrorText: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ textAlign: "center",
+ },
}));
+const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
+
+const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({
+ uri,
+ alt,
+ containerStyle,
+ source,
+ workspaceRoot,
+ serverId,
+}: {
+ uri: string;
+ alt?: string;
+ containerStyle?: StyleProp;
+ source: string;
+ workspaceRoot?: string;
+ serverId?: string;
+}) {
+ const cachedMetadata = useMemo(
+ () => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
+ [serverId, source, workspaceRoot],
+ );
+ const [aspectRatio, setAspectRatio] = useState(
+ cachedMetadata?.aspectRatio ?? null,
+ );
+
+ useEffect(() => {
+ if (cachedMetadata) {
+ setAspectRatio(cachedMetadata.aspectRatio);
+ return;
+ }
+
+ setAspectRatio(null);
+ let cancelled = false;
+
+ Image.getSize(
+ uri,
+ (width, height) => {
+ if (cancelled) {
+ return;
+ }
+ if (width > 0 && height > 0) {
+ const metadata = setAssistantImageMetadata(
+ { source, workspaceRoot, serverId },
+ { width, height },
+ );
+ setAspectRatio(metadata?.aspectRatio ?? width / height);
+ }
+ },
+ () => {
+ if (cancelled) {
+ return;
+ }
+ setAspectRatio(null);
+ },
+ );
+
+ return () => {
+ cancelled = true;
+ };
+ }, [cachedMetadata, serverId, source, uri, workspaceRoot]);
+
+ const surfaceStyle = useMemo>(
+ () => [
+ assistantMessageStylesheet.imageSurface,
+ aspectRatio ? { aspectRatio } : { minHeight: ASSISTANT_IMAGE_MIN_HEIGHT },
+ ],
+ [aspectRatio],
+ );
+
+ return (
+
+
+
+
+
+ );
+});
+
+function AssistantMarkdownImage({
+ source,
+ alt,
+ hasLeadingContent,
+ client,
+ workspaceRoot,
+ serverId,
+}: {
+ source: string;
+ alt?: string;
+ hasLeadingContent: boolean;
+ client?: DaemonClient | null;
+ workspaceRoot?: string;
+ serverId?: string;
+}) {
+ const { theme } = useUnistyles();
+ const resolution = useMemo(
+ () => resolveAssistantImageSource({ source, workspaceRoot }),
+ [source, workspaceRoot],
+ );
+ const containerStyle = useMemo>(
+ () => ({
+ marginTop: hasLeadingContent ? theme.spacing[4] : 0,
+ marginBottom: 0,
+ }),
+ [hasLeadingContent, theme],
+ );
+
+ const query = useQuery({
+ queryKey: [
+ "assistantMarkdownImage",
+ serverId ?? "unknown-server",
+ resolution?.kind === "file_rpc" ? resolution.cwd : null,
+ resolution?.kind === "file_rpc" ? resolution.path : null,
+ ],
+ enabled: Boolean(client && resolution?.kind === "file_rpc"),
+ staleTime: 30_000,
+ queryFn: async () => {
+ if (!client || !resolution || resolution.kind !== "file_rpc") {
+ return null;
+ }
+
+ const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file");
+ if (payload.error) {
+ throw new Error(payload.error);
+ }
+ if (!payload.file || payload.file.kind !== "image" || !payload.file.content) {
+ throw new Error("Image preview unavailable.");
+ }
+
+ return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`;
+ },
+ });
+
+ const directUri = resolution?.kind === "direct" ? resolution.uri : null;
+ const resolvedUri = directUri ?? query.data ?? null;
+
+ if (resolvedUri) {
+ return (
+
+ );
+ }
+
+ if (query.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {query.error instanceof Error ? query.error.message : "Unable to load image preview."}
+
+
+ );
+}
+
function MarkdownLink({
href,
style,
@@ -712,11 +930,35 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
},
}));
+interface MemoizedMarkdownBlockProps {
+ text: string;
+ styles: ReturnType;
+ rules: RenderRules;
+ parser: MarkdownIt;
+ onLinkPress: (url: string) => boolean;
+}
+
+const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
+ text,
+ styles,
+ rules,
+ parser,
+ onLinkPress,
+}: MemoizedMarkdownBlockProps) {
+ return (
+
+ {text}
+
+ );
+});
+
export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp,
onInlinePathPress,
workspaceRoot,
+ serverId,
+ client,
disableOuterSpacing,
}: AssistantMessageProps) {
const { theme, rt } = useUnistyles();
@@ -753,7 +995,7 @@ export const AssistantMessage = memo(function AssistantMessage({
[onInlinePathPress, workspaceRoot],
);
- const markdownRules = useMemo(() => {
+ const markdownRules = useMemo(() => {
return {
text: (
node: any,
@@ -868,14 +1110,11 @@ export const AssistantMessage = memo(function AssistantMessage({
);
},
- paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
- const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
- return (
-
- {children}
-
- );
- },
+ paragraph: (node: any, children: ReactNode[], _parent: any, styles: any) => (
+
+ {children}
+
+ ),
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
),
+ image: (node: any, _children: ReactNode[], parent: any, styles: any) => {
+ const paragraphNode = Array.isArray(parent)
+ ? parent.find((ancestor: any) => ancestor?.type === "paragraph")
+ : null;
+ const paragraphChildren = Array.isArray(paragraphNode?.children)
+ ? paragraphNode.children
+ : [];
+ const imageIndex = paragraphChildren.findIndex((child: any) => child?.key === node.key);
+ const hasLeadingContent = imageIndex > 0;
+
+ return (
+
+ );
+ },
};
- }, [handleLinkPress, markdownParser, onInlinePathPress]);
+ }, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]);
+
+ const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
return (
-
- {message}
-
+ {blocks.map((block, index) => (
+
+
+
+ ))}
);
});
@@ -1938,7 +2207,11 @@ export const ToolCall = memo(function ToolCall({
if (effectiveDetail?.type === "plan") {
return (
-
+
);
}
diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx
index 20ff08967..06f6fd7ea 100644
--- a/packages/app/src/components/provider-diagnostic-sheet.tsx
+++ b/packages/app/src/components/provider-diagnostic-sheet.tsx
@@ -24,7 +24,8 @@ export function ProviderDiagnosticSheet({
const [diagnostic, setDiagnostic] = useState(null);
const [loading, setLoading] = useState(false);
- const providerLabel = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
+ const providerLabel =
+ AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return;
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index 0a1066914..6778dbc0b 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -48,7 +48,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
-import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
+import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { buildHostNewWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
@@ -57,7 +57,7 @@ import {
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
-import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
+import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
import {
ContextMenu,
ContextMenuContent,
@@ -116,6 +116,19 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
+function getWorkspacePrIconColor(
+ theme: ReturnType["theme"],
+ state: PrHint["state"],
+) {
+ switch (state) {
+ case "merged":
+ return theme.colors.palette.purple[500];
+ case "open":
+ return theme.colors.palette.green[500];
+ case "closed":
+ return theme.colors.palette.red[500];
+ }
+}
interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[];
@@ -306,11 +319,7 @@ function WorkspaceStatusIndicator({
}
const KindIcon =
- workspaceKind === "local_checkout"
- ? Monitor
- : workspaceKind === "worktree"
- ? FolderGit2
- : null;
+ workspaceKind === "local_checkout" ? Monitor : workspaceKind === "worktree" ? FolderGit2 : null;
if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
@@ -1868,10 +1877,7 @@ export function SidebarWorkspaceList({
const creatingWorkspaceTimeoutsRef = useRef>>(
new Map(),
);
- const isDesktopApp = getIsElectronRuntime();
- const altDown = useKeyboardShortcutsStore((state) => state.altDown);
- const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown);
- const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown);
+ const showShortcutBadges = useShowShortcutBadges();
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
@@ -2052,34 +2058,31 @@ export function SidebarWorkspaceList({
[getWorkspaceOrder, serverId, setWorkspaceOrder],
);
- const handleWorktreeCreated = useCallback(
- (workspaceId: string) => {
- setCreatingWorkspaceIds((current) => {
- const next = new Set(current);
- next.add(workspaceId);
- return next;
- });
- const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId);
- if (existingTimeout) {
- clearTimeout(existingTimeout);
- }
- creatingWorkspaceTimeoutsRef.current.set(
- workspaceId,
- setTimeout(() => {
- creatingWorkspaceTimeoutsRef.current.delete(workspaceId);
- setCreatingWorkspaceIds((current) => {
- if (!current.has(workspaceId)) {
- return current;
- }
- const next = new Set(current);
- next.delete(workspaceId);
- return next;
- });
- }, 3000),
- );
- },
- [],
- );
+ const handleWorktreeCreated = useCallback((workspaceId: string) => {
+ setCreatingWorkspaceIds((current) => {
+ const next = new Set(current);
+ next.add(workspaceId);
+ return next;
+ });
+ const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId);
+ if (existingTimeout) {
+ clearTimeout(existingTimeout);
+ }
+ creatingWorkspaceTimeoutsRef.current.set(
+ workspaceId,
+ setTimeout(() => {
+ creatingWorkspaceTimeoutsRef.current.delete(workspaceId);
+ setCreatingWorkspaceIds((current) => {
+ if (!current.has(workspaceId)) {
+ return current;
+ }
+ const next = new Set(current);
+ next.delete(workspaceId);
+ return next;
+ });
+ }, 3000),
+ );
+ }, []);
const renderProject = useCallback(
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) => {
@@ -2129,12 +2132,7 @@ export function SidebarWorkspaceList({
No projects yet
Add a project to get started
-
+
Add project
diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx
index a8a0adb04..9a2adeec6 100644
--- a/packages/app/src/components/split-container.tsx
+++ b/packages/app/src/components/split-container.tsx
@@ -173,7 +173,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
paneId,
buildPaneContentModel,
}: MountedTabSlotProps) {
-
const content = useMemo(
() =>
buildPaneContentModel({
@@ -867,12 +866,7 @@ function SplitPaneView({
return (
-
+
- {mountedPaneTabIds.length > 0 ? (
- mountedPaneTabIds.map((tabId) => {
- const tabDescriptor = tabDescriptorMap.get(tabId);
- if (!tabDescriptor) {
- return null;
- }
+ {mountedPaneTabIds.length > 0
+ ? mountedPaneTabIds.map((tabId) => {
+ const tabDescriptor = tabDescriptorMap.get(tabId);
+ if (!tabDescriptor) {
+ return null;
+ }
- return (
-
- );
- })
- ) : (
- (renderPaneEmptyState?.() ?? null)
- )}
+ return (
+
+ );
+ })
+ : (renderPaneEmptyState?.() ?? null)}
diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx
index af7ef4aca..ef1089f91 100644
--- a/packages/app/src/components/terminal-pane.tsx
+++ b/packages/app/src/components/terminal-pane.tsx
@@ -82,12 +82,7 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
-export function TerminalPane({
- serverId,
- cwd,
- terminalId,
- isPaneFocused,
-}: TerminalPaneProps) {
+export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) {
const isScreenFocused = useIsFocused();
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
@@ -108,7 +103,10 @@ export function TerminalPane({
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef(null);
- const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
+ const workspaceTerminalSession = useMemo(
+ () => getWorkspaceTerminalSession({ scopeKey }),
+ [scopeKey],
+ );
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState(null);
const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS);
@@ -473,26 +471,32 @@ export function TerminalPane({
],
);
- const handleTerminalResize = useStableEvent(
- (input: { rows: number; cols: number }) => {
- const { rows, cols } = input;
- if (!client || !terminalId || !isPaneFocused || !isScreenFocused || !isAppVisible || rows <= 0 || cols <= 0) {
- return;
- }
- const normalizedRows = Math.floor(rows);
- const normalizedCols = Math.floor(cols);
- const previous = lastReportedSizeRef.current;
- if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
- return;
- }
- lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
- client.sendTerminalInput(terminalId, {
- type: "resize",
- rows: normalizedRows,
- cols: normalizedCols,
- });
- },
- );
+ const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => {
+ const { rows, cols } = input;
+ if (
+ !client ||
+ !terminalId ||
+ !isPaneFocused ||
+ !isScreenFocused ||
+ !isAppVisible ||
+ rows <= 0 ||
+ cols <= 0
+ ) {
+ return;
+ }
+ const normalizedRows = Math.floor(rows);
+ const normalizedCols = Math.floor(cols);
+ const previous = lastReportedSizeRef.current;
+ if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
+ return;
+ }
+ lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
+ client.sendTerminalInput(terminalId, {
+ type: "resize",
+ rows: normalizedRows,
+ cols: normalizedCols,
+ });
+ });
const handleTerminalKey = useCallback(
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx
index bc16ed2c4..195b95d58 100644
--- a/packages/app/src/components/ui/button.tsx
+++ b/packages/app/src/components/ui/button.tsx
@@ -136,21 +136,32 @@ export function Button({
return {leftIcon} ;
}
- const color = variant === "default"
- ? theme.colors.accentForeground
- : variant === "ghost"
- ? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
- : theme.colors.foreground;
+ const color =
+ variant === "default"
+ ? theme.colors.accentForeground
+ : variant === "ghost"
+ ? isGhostHovered
+ ? theme.colors.foreground
+ : theme.colors.foregroundMuted
+ : theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function
- if (typeof leftIcon === "function" && !leftIcon.prototype?.isReactComponent && leftIcon.length > 0) {
+ if (
+ typeof leftIcon === "function" &&
+ !leftIcon.prototype?.isReactComponent &&
+ leftIcon.length > 0
+ ) {
return {(leftIcon as (color: string) => ReactElement)(color)} ;
}
// Component type
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
- return ;
+ return (
+
+
+
+ );
}
return (
diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx
index 2e19032bf..b9a1f5f2c 100644
--- a/packages/app/src/components/use-web-scrollbar.tsx
+++ b/packages/app/src/components/use-web-scrollbar.tsx
@@ -105,7 +105,9 @@ export function useWebElementScrollbar(
if (!enabled) return null;
- return ;
+ return (
+
+ );
}
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx
index 9ef3e4d81..dde92399f 100644
--- a/packages/app/src/components/welcome-screen.tsx
+++ b/packages/app/src/components/welcome-screen.tsx
@@ -331,10 +331,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
You need the Paseo desktop app or server running on your computer first.
- openExternalUrl("https://paseo.sh")}
- >
+ openExternalUrl("https://paseo.sh")}>
Get started at paseo.sh
diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx
index 2d1271d4c..ea59bec6f 100644
--- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx
+++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx
@@ -91,13 +91,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
- }, [
- isOpen,
- translateX,
- backdropOpacity,
- windowWidth,
- isGesturing,
- ]);
+ }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = () => {
"worklet";
diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts
index 6f77b1e21..f849656fa 100644
--- a/packages/app/src/contexts/session-context.service-status.test.ts
+++ b/packages/app/src/contexts/session-context.service-status.test.ts
@@ -17,7 +17,6 @@ function workspace(input: {
workspaceKind: "checkout",
name: "main",
status: "running",
- activityAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx
index f196c7b19..a6c77db19 100644
--- a/packages/app/src/desktop/components/desktop-updates-section.tsx
+++ b/packages/app/src/desktop/components/desktop-updates-section.tsx
@@ -3,15 +3,7 @@ import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
-import {
- ArrowUpRight,
- Play,
- Pause,
- RotateCw,
- Copy,
- FileText,
- Activity,
-} from "lucide-react-native";
+import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings";
@@ -106,7 +98,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
- }, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]);
+ }, [
+ daemonActionLabel,
+ daemonStatus?.status,
+ isRestartingDaemon,
+ refetch,
+ setStatus,
+ showSection,
+ ]);
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
@@ -394,7 +393,9 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
snapPoints={["70%", "92%"]}
>
- {daemonLogs?.logPath ?? "Log path unavailable."}
+
+ {daemonLogs?.logPath ?? "Log path unavailable."}
+
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx
index cfc1b8510..ea1b8b909 100644
--- a/packages/app/src/desktop/components/integrations-section.tsx
+++ b/packages/app/src/desktop/components/integrations-section.tsx
@@ -87,7 +87,9 @@ export function IntegrationsSection() {
}
+ leftIcon={
+
+ }
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
@@ -98,7 +100,9 @@ export function IntegrationsSection() {
}
+ leftIcon={
+
+ }
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts
index 5c16bfcdd..edbb2b0cf 100644
--- a/packages/app/src/desktop/host.ts
+++ b/packages/app/src/desktop/host.ts
@@ -38,10 +38,7 @@ export interface DesktopOpenerBridge {
}
export interface DesktopMenuBridge {
- showContextMenu?: (input?: {
- kind?: "terminal";
- hasSelection?: boolean;
- }) => Promise;
+ showContextMenu?: (input?: { kind?: "terminal"; hasSelection?: boolean }) => Promise;
}
export interface DesktopWindowControlsOverlayUpdate {
diff --git a/packages/app/src/desktop/permissions/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts
index 5db79afd5..899b4e663 100644
--- a/packages/app/src/desktop/permissions/desktop-permissions.test.ts
+++ b/packages/app/src/desktop/permissions/desktop-permissions.test.ts
@@ -14,7 +14,9 @@ const originalGlobals: GlobalSnapshot = {
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
windowDescriptor: Object.getOwnPropertyDescriptor(globalThis, "window"),
paseoDesktop:
- typeof window === "undefined" ? undefined : (window as { paseoDesktop?: unknown }).paseoDesktop,
+ typeof globalThis.window === "undefined"
+ ? undefined
+ : (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop,
};
function ensureWindow(): { paseoDesktop?: unknown } {
@@ -55,8 +57,8 @@ function restoreGlobals(): void {
delete (globalThis as { window?: unknown }).window;
}
- if (typeof window !== "undefined") {
- (window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop;
+ if (typeof globalThis.window !== "undefined") {
+ (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop;
}
}
diff --git a/packages/app/src/desktop/permissions/use-desktop-permissions.ts b/packages/app/src/desktop/permissions/use-desktop-permissions.ts
index 551f368cb..cfaa05a05 100644
--- a/packages/app/src/desktop/permissions/use-desktop-permissions.ts
+++ b/packages/app/src/desktop/permissions/use-desktop-permissions.ts
@@ -128,7 +128,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
body: "If you can see this, desktop notifications work.",
});
if (!sent) {
- setTestNotificationError("Notification was not delivered. Check System Settings > Notifications.");
+ setTestNotificationError(
+ "Notification was not delivered. Check System Settings > Notifications.",
+ );
}
} catch (error) {
setTestNotificationError("Failed to send notification.");
diff --git a/packages/app/src/desktop/updates/update-banner.tsx b/packages/app/src/desktop/updates/update-banner.tsx
index 82d864130..70aedf327 100644
--- a/packages/app/src/desktop/updates/update-banner.tsx
+++ b/packages/app/src/desktop/updates/update-banner.tsx
@@ -40,7 +40,12 @@ export function UpdateBanner() {
if (!isDesktopApp) return null;
if (dismissed) return null;
- if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
+ if (
+ status !== "available" &&
+ status !== "installed" &&
+ status !== "installing" &&
+ status !== "error"
+ )
return null;
const isInstalled = status === "installed";
diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts
index f871958c7..945f4e02b 100644
--- a/packages/app/src/hooks/use-agent-form-state.ts
+++ b/packages/app/src/hooks/use-agent-form-state.ts
@@ -256,7 +256,7 @@ function resolveFormState(
if (!userModified.thinkingOptionId) {
const effectiveModelId = result.model.trim();
const preferredThinking = effectiveModelId
- ? providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? ""
+ ? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
: "";
if (initialThinkingOptionId.length > 0) {
@@ -329,11 +329,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
onlineServerIds = [],
} = options;
- const {
- preferences,
- isLoading: isPreferencesLoading,
- updatePreferences,
- } = useFormPreferences();
+ const { preferences, isLoading: isPreferencesLoading, updatePreferences } = useFormPreferences();
const daemons = useHosts();
@@ -503,13 +499,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
]);
// User setters - mark fields as modified and persist to preferences
- const setSelectedServerIdFromUser = useCallback(
- (value: string | null) => {
- setFormState((prev) => ({ ...prev, serverId: value }));
- setUserModified((prev) => ({ ...prev, serverId: true }));
- },
- [],
- );
+ const setSelectedServerIdFromUser = useCallback((value: string | null) => {
+ setFormState((prev) => ({ ...prev, serverId: value }));
+ setUserModified((prev) => ({ ...prev, serverId: true }));
+ }, []);
const setProviderFromUser = useCallback(
(provider: AgentProvider) => {
@@ -520,8 +513,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
- const isValidModel = (m: string) =>
- providerModels?.some((am) => am.id === m) ?? false;
+ const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(providerModels);
const nextModelId =
@@ -533,10 +525,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const nextModeId =
providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)
? providerPrefs.mode
- : providerDef?.defaultModeId ?? "";
+ : (providerDef?.defaultModeId ?? "");
const preferredThinking = nextModelId
- ? providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? ""
+ ? (providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? "")
: "";
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: providerModels,
@@ -591,13 +583,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
);
- const setModeFromUser = useCallback(
- (modeId: string) => {
- setFormState((prev) => ({ ...prev, modeId }));
- setUserModified((prev) => ({ ...prev, modeId: true }));
- },
- [],
- );
+ const setModeFromUser = useCallback((modeId: string) => {
+ setFormState((prev) => ({ ...prev, modeId }));
+ setUserModified((prev) => ({ ...prev, modeId: true }));
+ }, []);
const setModelFromUser = useCallback(
(modelId: string) => {
@@ -620,25 +609,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[availableModels, userModified.thinkingOptionId],
);
- const setThinkingOptionFromUser = useCallback(
- (thinkingOptionId: string) => {
- setFormState((prev) => ({ ...prev, thinkingOptionId }));
- setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
- },
- [],
- );
+ const setThinkingOptionFromUser = useCallback((thinkingOptionId: string) => {
+ setFormState((prev) => ({ ...prev, thinkingOptionId }));
+ setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
+ }, []);
const setWorkingDir = useCallback((value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value }));
}, []);
- const setWorkingDirFromUser = useCallback(
- (value: string) => {
- setFormState((prev) => ({ ...prev, workingDir: value }));
- setUserModified((prev) => ({ ...prev, workingDir: true }));
- },
- [],
- );
+ const setWorkingDirFromUser = useCallback((value: string) => {
+ setFormState((prev) => ({ ...prev, workingDir: value }));
+ setUserModified((prev) => ({ ...prev, workingDir: true }));
+ }, []);
const setSelectedServerId = useCallback((value: string | null) => {
setFormState((prev) => ({ ...prev, serverId: value }));
diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts
index 0e3ec4fdc..a76804b1f 100644
--- a/packages/app/src/hooks/use-archive-agent.test.ts
+++ b/packages/app/src/hooks/use-archive-agent.test.ts
@@ -104,18 +104,8 @@ describe("useArchiveAgent", () => {
it("applies archived agent close results to session state and cached lists", async () => {
const queryClient = new QueryClient();
- useSessionStore
- .getState()
- .initializeSession("server-a", {} as DaemonClient);
- useSessionStore.getState().setAgents(
- "server-a",
- new Map([
- [
- "agent-1",
- makeAgent(),
- ],
- ]),
- );
+ useSessionStore.getState().initializeSession("server-a", {} as DaemonClient);
+ useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]]));
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
@@ -130,7 +120,10 @@ describe("useArchiveAgent", () => {
});
expect(
- useSessionStore.getState().sessions["server-a"]?.agents.get("agent-1")?.archivedAt?.toISOString(),
+ useSessionStore
+ .getState()
+ .sessions["server-a"]?.agents.get("agent-1")
+ ?.archivedAt?.toISOString(),
).toBe("2026-04-01T04:00:00.000Z");
expect(queryClient.getQueryData(["sidebarAgentsList", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }],
diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts
index 922f2d229..36d635e0a 100644
--- a/packages/app/src/hooks/use-archive-agent.ts
+++ b/packages/app/src/hooks/use-archive-agent.ts
@@ -216,9 +216,7 @@ interface ApplyArchivedAgentCloseResultsInput {
invalidateQueries?: boolean;
}
-export function applyArchivedAgentCloseResults(
- input: ApplyArchivedAgentCloseResultsInput,
-): void {
+export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseResultsInput): void {
if (input.results.length === 0) {
return;
}
diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts
index 543abeb50..ea63dd58e 100644
--- a/packages/app/src/hooks/use-branch-switcher.ts
+++ b/packages/app/src/hooks/use-branch-switcher.ts
@@ -81,8 +81,7 @@ export function useBranchSwitcher({
if (!client) return;
const shouldStash = await confirmDialog({
title: "Uncommitted changes",
- message:
- "You have uncommitted changes. Stash them before switching branches?",
+ message: "You have uncommitted changes. Stash them before switching branches?",
confirmLabel: "Stash & Switch",
cancelLabel: "Cancel",
});
@@ -135,7 +134,8 @@ export function useBranchSwitcher({
if (targetStash) {
const shouldRestore = await confirmDialog({
title: "Restore stashed changes?",
- message: "This branch has stashed changes from a previous session. Would you like to restore them?",
+ message:
+ "This branch has stashed changes from a previous session. Would you like to restore them?",
confirmLabel: "Restore",
cancelLabel: "Later",
});
@@ -157,7 +157,14 @@ export function useBranchSwitcher({
}
})();
},
- [client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, stashAndSwitch, toast],
+ [
+ client,
+ currentBranchName,
+ invalidateStashAndCheckout,
+ normalizedWorkspaceId,
+ stashAndSwitch,
+ toast,
+ ],
);
return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout };
diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts
index a06654171..801b1033a 100644
--- a/packages/app/src/hooks/use-checkout-pr-status-query.ts
+++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts
@@ -54,11 +54,12 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null
return {
url: status.url,
number,
- state: status.isMerged || status.state === "merged"
- ? "merged"
- : status.state === "open"
- ? "open"
- : "closed",
+ state:
+ status.isMerged || status.state === "merged"
+ ? "merged"
+ : status.state === "open"
+ ? "open"
+ : "closed",
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
diff --git a/packages/app/src/hooks/use-daemon-config.ts b/packages/app/src/hooks/use-daemon-config.ts
new file mode 100644
index 000000000..bb27cbd95
--- /dev/null
+++ b/packages/app/src/hooks/use-daemon-config.ts
@@ -0,0 +1,68 @@
+import { useCallback, useEffect, useMemo } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@server/shared/messages";
+import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
+
+export function daemonConfigQueryKey(serverId: string | null) {
+ return ["daemon-config", serverId] as const;
+}
+
+interface UseDaemonConfigResult {
+ config: MutableDaemonConfig | null;
+ isLoading: boolean;
+ patchConfig: (patch: MutableDaemonConfigPatch) => Promise;
+}
+
+export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult {
+ const queryClient = useQueryClient();
+ const client = useHostRuntimeClient(serverId ?? "");
+ const isConnected = useHostRuntimeIsConnected(serverId ?? "");
+ const queryKey = useMemo(() => daemonConfigQueryKey(serverId), [serverId]);
+
+ const configQuery = useQuery({
+ queryKey,
+ enabled: Boolean(serverId && client && isConnected),
+ staleTime: Infinity,
+ queryFn: async () => {
+ if (!client) {
+ throw new Error("Host is not connected");
+ }
+ const result = await client.getDaemonConfig();
+ return result.config;
+ },
+ });
+
+ useEffect(() => {
+ if (!client || !isConnected || !serverId) {
+ return;
+ }
+
+ return client.on("status", (message) => {
+ if (message.type !== "status") {
+ return;
+ }
+ if (message.payload.status !== "daemon_config_changed") {
+ return;
+ }
+ queryClient.setQueryData(queryKey, message.payload.config as MutableDaemonConfig);
+ });
+ }, [client, isConnected, queryClient, queryKey, serverId]);
+
+ const patchConfig = useCallback(
+ async (patch: MutableDaemonConfigPatch) => {
+ if (!client) {
+ return undefined;
+ }
+ const result = await client.patchDaemonConfig(patch);
+ queryClient.setQueryData(queryKey, result.config);
+ return result.config;
+ },
+ [client, queryClient, queryKey],
+ );
+
+ return {
+ config: configQuery.data ?? null,
+ isLoading: configQuery.isLoading,
+ patchConfig,
+ };
+}
diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts
index 3f9a4dce4..32ff06118 100644
--- a/packages/app/src/hooks/use-draft-agent-features.ts
+++ b/packages/app/src/hooks/use-draft-agent-features.ts
@@ -1,9 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
-import type {
- AgentProvider,
- AgentSessionConfig,
-} from "@server/server/agent/agent-sdk-types";
+import type { AgentProvider, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
import {
@@ -100,16 +97,16 @@ export function useDraftAgentFeatures(input: {
}, [availableFeatures, localFeatureValues]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
- const setFeatureValue = useCallback((featureId: string, value: unknown) => {
- setLocalFeatureValues((current) => {
- if (Object.is(current[featureId], value)) {
- return current;
- }
+ const setFeatureValue = useCallback(
+ (featureId: string, value: unknown) => {
+ setLocalFeatureValues((current) => {
+ if (Object.is(current[featureId], value)) {
+ return current;
+ }
- return { ...current, [featureId]: value };
- });
- void updatePreferences(
- (current) =>
+ return { ...current, [featureId]: value };
+ });
+ void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider,
@@ -119,10 +116,12 @@ export function useDraftAgentFeatures(input: {
},
},
}),
- ).catch((error) => {
- console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
- });
- }, [provider, updatePreferences]);
+ ).catch((error) => {
+ console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
+ });
+ },
+ [provider, updatePreferences],
+ );
return {
features,
diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts
index cd14ebf66..0bc898510 100644
--- a/packages/app/src/hooks/use-form-preferences.ts
+++ b/packages/app/src/hooks/use-form-preferences.ts
@@ -31,12 +31,14 @@ const providerPreferencesSchema = z.object({
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
- favoriteModels: z.array(
- z.object({
- provider: z.string(),
- modelId: z.string(),
- }),
- ).optional(),
+ favoriteModels: z
+ .array(
+ z.object({
+ provider: z.string(),
+ modelId: z.string(),
+ }),
+ )
+ .optional(),
});
export type ProviderPreferences = z.infer;
@@ -148,8 +150,7 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const prev =
queryClient.getQueryData(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES;
- const next =
- typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
+ const next = typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
diff --git a/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts
index de337f8af..a56627101 100644
--- a/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts
+++ b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts
@@ -27,8 +27,7 @@ export function useKeyboardShortcutOverrides(): UseKeyboardShortcutOverridesRetu
const setOverride = useCallback(
async (bindingId: string, comboString: string) => {
- const prev =
- queryClient.getQueryData>(QUERY_KEY) ?? EMPTY_OVERRIDES;
+ const prev = queryClient.getQueryData>(QUERY_KEY) ?? EMPTY_OVERRIDES;
const next = { ...prev, [bindingId]: comboString };
queryClient.setQueryData>(QUERY_KEY, next);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
@@ -38,8 +37,7 @@ export function useKeyboardShortcutOverrides(): UseKeyboardShortcutOverridesRetu
const removeOverride = useCallback(
async (bindingId: string) => {
- const prev =
- queryClient.getQueryData>(QUERY_KEY) ?? EMPTY_OVERRIDES;
+ const prev = queryClient.getQueryData>(QUERY_KEY) ?? EMPTY_OVERRIDES;
const { [bindingId]: _, ...next } = prev;
queryClient.setQueryData>(QUERY_KEY, next);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
diff --git a/packages/app/src/hooks/use-settings.test.ts b/packages/app/src/hooks/use-settings.test.ts
index f051839c6..84eae3479 100644
--- a/packages/app/src/hooks/use-settings.test.ts
+++ b/packages/app/src/hooks/use-settings.test.ts
@@ -57,6 +57,7 @@ describe("use-settings", () => {
expect(result).toEqual({
theme: "light",
manageBuiltInDaemon: false,
+ sendBehavior: "interrupt",
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
diff --git a/packages/app/src/hooks/use-show-shortcut-badges.ts b/packages/app/src/hooks/use-show-shortcut-badges.ts
new file mode 100644
index 000000000..d83685f84
--- /dev/null
+++ b/packages/app/src/hooks/use-show-shortcut-badges.ts
@@ -0,0 +1,5 @@
+import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
+
+export function useShowShortcutBadges(): boolean {
+ return useKeyboardShortcutsStore((state) => state.showShortcutBadges);
+}
diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts
index 34d3cfb03..960d285dc 100644
--- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts
+++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts
@@ -16,7 +16,7 @@ function item(key: string): OrderedItem {
}
function workspace(
- input: Pick &
+ input: Pick &
Partial<
Pick<
WorkspaceDescriptor,
@@ -39,7 +39,6 @@ function workspace(
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name,
status: input.status,
- activityAt: input.activityAt,
diffStat: null,
scripts: input.scripts ?? [],
};
@@ -130,7 +129,6 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
projectId: "project-1",
name: "feat/hard-cut",
status: "failed",
- activityAt: new Date("2026-01-01T00:00:00.000Z"),
}),
];
@@ -156,7 +154,6 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
projectId: "project-1",
name: "main",
status: "running",
- activityAt: new Date("2026-01-01T00:00:00.000Z"),
scripts: [runningScript, stoppedScript],
}),
],
@@ -168,21 +165,19 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true);
});
- it("preserves stored project order even when activity changes", () => {
+ it("preserves stored project order even when input order differs", () => {
const initialWorkspaces: WorkspaceDescriptor[] = [
workspace({
id: "/repo/b",
projectId: "project-b",
name: "feat/b",
status: "running",
- activityAt: new Date("2026-01-02T00:00:00.000Z"),
}),
workspace({
id: "/repo/a",
projectId: "project-a",
name: "feat/a",
status: "running",
- activityAt: new Date("2026-01-01T00:00:00.000Z"),
}),
];
@@ -199,19 +194,17 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
const updatedProjects = buildSidebarProjectsFromWorkspaces({
serverId: "srv",
workspaces: [
- workspace({
- id: "/repo/b",
- projectId: "project-b",
- name: "feat/b",
- status: "running",
- activityAt: new Date("2026-01-02T00:00:00.000Z"),
- }),
workspace({
id: "/repo/a",
projectId: "project-a",
name: "feat/a",
status: "running",
- activityAt: new Date("2026-01-03T00:00:00.000Z"),
+ }),
+ workspace({
+ id: "/repo/b",
+ projectId: "project-b",
+ name: "feat/b",
+ status: "running",
}),
],
projectOrder: seededOrder,
@@ -219,8 +212,8 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
});
expect(updatedProjects.map((project) => project.projectKey)).toEqual([
- "project-b",
"project-a",
+ "project-b",
]);
});
@@ -233,21 +226,18 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
projectId: "project-c",
name: "feat/c",
status: "running",
- activityAt: new Date("2026-01-04T00:00:00.000Z"),
}),
workspace({
id: "/repo/b",
projectId: "project-b",
name: "feat/b",
status: "running",
- activityAt: new Date("2026-01-02T00:00:00.000Z"),
}),
workspace({
id: "/repo/a",
projectId: "project-a",
name: "feat/a",
status: "running",
- activityAt: new Date("2026-01-01T00:00:00.000Z"),
}),
],
projectOrder: ["project-b", "project-a", "project-c"],
@@ -270,14 +260,12 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
projectId: "project-1",
name: "main",
status: "running",
- activityAt: new Date("2026-01-02T00:00:00.000Z"),
}),
workspace({
id: "/repo/feature",
projectId: "project-1",
name: "feature",
status: "running",
- activityAt: new Date("2026-01-01T00:00:00.000Z"),
}),
],
projectOrder: ["project-1"],
@@ -297,14 +285,12 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
projectId: "project-1",
name: "main",
status: "running",
- activityAt: new Date("2026-01-02T00:00:00.000Z"),
}),
workspace({
id: "/repo/feature",
projectId: "project-1",
name: "feature",
status: "running",
- activityAt: new Date("2026-01-03T00:00:00.000Z"),
}),
],
projectOrder: ["project-1"],
@@ -314,8 +300,8 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
});
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
- "/repo/main",
"/repo/feature",
+ "/repo/main",
]);
});
});
diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts
index 8711661f5..598dddf56 100644
--- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts
+++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts
@@ -4,10 +4,10 @@ import {
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
useSessionStore,
+ type WorkspaceDescriptor,
} from "@/stores/session-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
-import type { WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
const EMPTY_ORDER: string[] = [];
@@ -25,7 +25,6 @@ export interface SidebarWorkspaceEntry {
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
- activityAt: Date | null;
statusBucket: SidebarStateBucket;
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptor["scripts"];
@@ -40,7 +39,6 @@ export interface SidebarProjectEntry {
statusBucket: SidebarStateBucket;
activeCount: number;
totalWorkspaces: number;
- latestActivityAt: Date | null;
workspaces: SidebarWorkspaceEntry[];
}
@@ -73,15 +71,6 @@ function compareWorkspaceBaseline(
left: SidebarWorkspaceEntry,
right: SidebarWorkspaceEntry,
): number {
- if (left.activityAt && right.activityAt) {
- const dateDelta = right.activityAt.getTime() - left.activityAt.getTime();
- if (dateDelta !== 0) {
- return dateDelta;
- }
- } else if (left.activityAt || right.activityAt) {
- return left.activityAt ? -1 : 1;
- }
-
const nameDelta = left.name.localeCompare(right.name, undefined, {
numeric: true,
sensitivity: "base",
@@ -97,15 +86,6 @@ function compareWorkspaceBaseline(
}
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
- if (left.latestActivityAt && right.latestActivityAt) {
- const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime();
- if (dateDelta !== 0) {
- return dateDelta;
- }
- } else if (left.latestActivityAt || right.latestActivityAt) {
- return left.latestActivityAt ? -1 : 1;
- }
-
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
@@ -132,7 +112,6 @@ export function buildSidebarProjectsFromWorkspaces(input: {
statusBucket: "done",
activeCount: 0,
totalWorkspaces: 0,
- latestActivityAt: null,
workspaces: [],
} satisfies SidebarProjectEntry);
@@ -145,7 +124,6 @@ export function buildSidebarProjectsFromWorkspaces(input: {
projectKind: workspace.projectKind,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
- activityAt: workspace.activityAt,
statusBucket: workspace.status,
diffStat: workspace.diffStat,
scripts: workspace.scripts,
@@ -158,12 +136,6 @@ export function buildSidebarProjectsFromWorkspaces(input: {
project.activeCount += 1;
}
project.statusBucket = aggregateBucket(project.statusBucket, workspace.status);
- if (
- !project.latestActivityAt ||
- (workspace.activityAt && workspace.activityAt.getTime() > project.latestActivityAt.getTime())
- ) {
- project.latestActivityAt = workspace.activityAt;
- }
byProject.set(workspace.projectId, project);
}
@@ -262,23 +234,9 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
return `${serverId.trim()}::${projectKey.trim()}`;
}
-function toWorkspaceDescriptor(payload: {
- id: string;
- projectId: string;
- projectDisplayName: string;
- projectRootPath: string;
- workspaceDirectory: string;
- projectKind: WorkspaceDescriptor["projectKind"];
- workspaceKind: WorkspaceDescriptor["workspaceKind"];
- name: string;
- status: WorkspaceDescriptor["status"];
- activityAt: string | null;
- scripts?: WorkspaceDescriptorPayload["scripts"];
-}): WorkspaceDescriptor {
- return normalizeWorkspaceDescriptor({
- ...payload,
- scripts: payload.scripts ?? [],
- });
+function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
+ return normalizeWorkspaceDescriptor(payload);
+
}
export function useSidebarWorkspacesList(options?: {
diff --git a/packages/app/src/keyboard/focus-scope.ts b/packages/app/src/keyboard/focus-scope.ts
index 340a8cd41..cd06538e9 100644
--- a/packages/app/src/keyboard/focus-scope.ts
+++ b/packages/app/src/keyboard/focus-scope.ts
@@ -58,7 +58,9 @@ export function resolveKeyboardFocusScope(input: {
return "command-center";
}
- if (candidates.some((element) => Boolean(element.closest("[data-testid='message-input-root']")))) {
+ if (
+ candidates.some((element) => Boolean(element.closest("[data-testid='message-input-root']")))
+ ) {
return "message-input";
}
diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts
index 605803d9f..15c20d3f9 100644
--- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts
+++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts
@@ -261,7 +261,7 @@ describe("keyboard-shortcuts", () => {
action: "sidebar.toggle.left",
},
{
- name: "binds Mod+. to toggle both sidebars on non-mac",
+ name: "routes Mod+. to toggle both sidebars on non-mac",
event: { key: ".", code: "Period", ctrlKey: true },
context: { isMac: false },
action: "sidebar.toggle.both",
@@ -344,6 +344,11 @@ describe("keyboard-shortcuts", () => {
event: { key: "k", code: "KeyK", ctrlKey: true },
context: { isMac: false, focusScope: "terminal" },
},
+ {
+ name: "does not bind Ctrl+B on non-mac while terminal is focused",
+ event: { key: "b", code: "KeyB", ctrlKey: true },
+ context: { isMac: false, focusScope: "terminal" },
+ },
{
name: "does not route message-input actions when terminal is focused",
event: { key: "d", code: "KeyD", metaKey: true },
diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts
index 6d7c3cd06..78d4770fd 100644
--- a/packages/app/src/keyboard/keyboard-shortcuts.ts
+++ b/packages/app/src/keyboard/keyboard-shortcuts.ts
@@ -33,12 +33,7 @@ export type KeyboardShortcutHelpRow = {
note?: string;
};
-export type ShortcutSectionId =
- | "navigation"
- | "tabs-panes"
- | "projects"
- | "panels"
- | "agent-input";
+export type ShortcutSectionId = "navigation" | "tabs-panes" | "projects" | "panels" | "agent-input";
export type KeyboardShortcutHelpSection = {
id: ShortcutSectionId;
@@ -53,7 +48,6 @@ type KeyboardShortcutPlatformContext = {
isDesktop: boolean;
};
-
interface ShortcutWhen {
/** true = mac only, false = non-mac only */
mac?: boolean;
@@ -924,9 +918,7 @@ function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
SHORTCUT_BINDINGS.map(parseBinding);
-export function buildEffectiveBindings(
- overrides: Record,
-): ParsedShortcutBinding[] {
+export function buildEffectiveBindings(overrides: Record): ParsedShortcutBinding[] {
return DEFAULT_BINDINGS.map(function (binding) {
const override = overrides[binding.id];
if (override === undefined) {
@@ -1252,7 +1244,13 @@ export function buildKeyboardShortcutHelpSections(
});
}
- const sectionOrder: ShortcutSectionId[] = ["navigation", "tabs-panes", "projects", "panels", "agent-input"];
+ const sectionOrder: ShortcutSectionId[] = [
+ "navigation",
+ "tabs-panes",
+ "projects",
+ "panels",
+ "agent-input",
+ ];
return sectionOrder.flatMap((sectionId) => {
const rows = rowsBySection.get(sectionId) ?? [];
diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx
index e8e35f812..62e1d6da0 100644
--- a/packages/app/src/panels/agent-panel.tsx
+++ b/packages/app/src/panels/agent-panel.tsx
@@ -448,7 +448,7 @@ function ChatAgentContent({
const agentState = useSessionStore(
useShallow((state) => {
- const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null;
+ const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null;
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
@@ -678,17 +678,20 @@ function ChatAgentContent({
isPendingCreateForPanel && (!authoritativeStatus || isAuthoritativeBootstrapping);
const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping;
- const agent: AgentScreenAgent | null =
- agentState.serverId && agentState.id && agentState.status && agentState.cwd
- ? {
- serverId: agentState.serverId,
- id: agentState.id,
- status: agentState.status,
- cwd: agentState.cwd,
- lastError: agentState.lastError ?? null,
- projectPlacement,
- }
- : null;
+ const agent = useMemo(
+ () =>
+ agentState.serverId && agentState.id && agentState.status && agentState.cwd
+ ? {
+ serverId: agentState.serverId,
+ id: agentState.id,
+ status: agentState.status,
+ cwd: agentState.cwd,
+ lastError: agentState.lastError ?? null,
+ projectPlacement,
+ }
+ : null,
+ [agentState.serverId, agentState.id, agentState.status, agentState.cwd, agentState.lastError, projectPlacement],
+ );
const placeholderAgent: AgentScreenAgent | null = useMemo(() => {
if (!shouldUseOptimisticStream || !agentId) {
@@ -807,6 +810,98 @@ function ChatAgentContent({
setMissingAgentState({ kind: "idle" });
}, [agentId, serverId]);
+ useEffect(() => {
+ if (!agentId) {
+ return;
+ }
+ if (agentState.id || shouldUseOptimisticStream) {
+ if (missingAgentState.kind !== "idle") {
+ setMissingAgentState({ kind: "idle" });
+ }
+ return;
+ }
+ if (!isConnected || !hasSession) {
+ return;
+ }
+ if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") {
+ return;
+ }
+
+ setMissingAgentState({ kind: "resolving" });
+ const attemptToken = ++initAttemptTokenRef.current;
+
+ ensureAgentIsInitialized(agentId)
+ .then(async () => {
+ if (attemptToken !== initAttemptTokenRef.current) {
+ return;
+ }
+ const currentAgent = useSessionStore.getState().sessions[serverId]?.agents.get(agentId);
+ if (!currentAgent) {
+ const result = await client.fetchAgent(agentId);
+ if (attemptToken !== initAttemptTokenRef.current) {
+ return;
+ }
+ if (!result) {
+ setMissingAgentState({
+ kind: "not_found",
+ message: `Agent not found: ${agentId}`,
+ });
+ return;
+ }
+ const normalized = normalizeAgentSnapshot(result.agent, serverId);
+ const hydrated = {
+ ...normalized,
+ projectPlacement: result.project,
+ };
+ setAgents(serverId, (previous) => {
+ const next = new Map(previous);
+ next.set(hydrated.id, hydrated);
+ return next;
+ });
+ setPendingPermissions(serverId, (previous) => {
+ const next = new Map(previous);
+ for (const [key, pending] of next.entries()) {
+ if (pending.agentId === hydrated.id) {
+ next.delete(key);
+ }
+ }
+ for (const request of hydrated.pendingPermissions) {
+ const key = derivePendingPermissionKey(hydrated.id, request);
+ next.set(key, { key, agentId: hydrated.id, request });
+ }
+ return next;
+ });
+ }
+ if (attemptToken !== initAttemptTokenRef.current) {
+ return;
+ }
+ setMissingAgentState({ kind: "idle" });
+ })
+ .catch((error) => {
+ if (attemptToken !== initAttemptTokenRef.current) {
+ return;
+ }
+ const message = toErrorMessage(error);
+ if (isNotFoundErrorMessage(message)) {
+ setMissingAgentState({ kind: "not_found", message });
+ return;
+ }
+ setMissingAgentState({ kind: "error", message });
+ });
+ }, [
+ agentState.id,
+ agentId,
+ client,
+ ensureAgentIsInitialized,
+ hasSession,
+ isConnected,
+ missingAgentState.kind,
+ serverId,
+ setAgents,
+ setPendingPermissions,
+ shouldUseOptimisticStream,
+ ]);
+
if (viewState.tag === "not_found") {
return (
diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts
index a7331ec91..610d6f4c9 100644
--- a/packages/app/src/runtime/host-runtime.test.ts
+++ b/packages/app/src/runtime/host-runtime.test.ts
@@ -585,6 +585,46 @@ describe("HostRuntimeController", () => {
});
});
+ it("does not emit legacy typed reason-code transition logs", async () => {
+ const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
+ try {
+ const host = makeHost({
+ connections: [
+ {
+ id: "direct:lan:6767",
+ type: "directTcp",
+ endpoint: "lan:6767",
+ },
+ ],
+ });
+ const clients: FakeDaemonClient[] = [];
+ const controller = new HostRuntimeController({
+ host,
+ deps: makeDeps(
+ {
+ "direct:lan:6767": 12,
+ },
+ clients,
+ ),
+ });
+
+ await controller.start({ autoProbe: false });
+ clients[0]?.setConnectionState({
+ status: "disconnected",
+ reason: "transport closed",
+ });
+
+ const transitionPayloads = infoSpy.mock.calls
+ .filter((call) => call[0] === "[HostRuntimeTransition]")
+ .map((call) => call[1] as { reasonCode?: string | null });
+ const lastTransition = transitionPayloads[transitionPayloads.length - 1] ?? null;
+
+ expect(lastTransition?.reasonCode).toBeUndefined();
+ } finally {
+ infoSpy.mockRestore();
+ }
+ });
+
it("marks directory loading on first connection before any directory sync succeeds", async () => {
const host = makeHost();
const clients: FakeDaemonClient[] = [];
@@ -1201,12 +1241,7 @@ describe("HostRuntimeStore", () => {
archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null,
attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null,
};
- return new Map([
- [
- stale.id,
- staleAgent,
- ],
- ]);
+ return new Map([[stale.id, staleAgent]]);
});
store.syncHosts([host]);
@@ -1322,7 +1357,7 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
- it("keeps a custom host label when re-pairing with an advertised hostname", async () => {
+ it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
@@ -1345,7 +1380,7 @@ describe("HostRuntimeStore", () => {
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
- expect(pairedHost?.label).toBe("Custom name");
+ expect(pairedHost?.label).toBe("mbp");
store.syncHosts([]);
});
diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts
index 864d69068..cd4a37caa 100644
--- a/packages/app/src/runtime/host-runtime.ts
+++ b/packages/app/src/runtime/host-runtime.ts
@@ -16,10 +16,7 @@ import {
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
-import {
- shouldUseDesktopDaemon,
- startDesktopDaemon,
-} from "@/desktop/daemon/desktop-daemon";
+import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { getOrCreateClientId } from "@/utils/client-id";
@@ -1078,8 +1075,7 @@ export class HostRuntimeController {
}
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
-const DEFAULT_LOCALHOST_ENDPOINT =
- process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
+const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
@@ -1287,10 +1283,7 @@ export class HostRuntimeStore {
});
}
- async upsertConnectionFromOffer(
- offer: ConnectionOffer,
- label?: string,
- ): Promise {
+ async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise {
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
@@ -1560,10 +1553,7 @@ export class HostRuntimeStore {
return { ok: true };
}
- if (
- snapshot.activeConnectionId === connectionId &&
- snapshot.connectionStatus === "error"
- ) {
+ if (snapshot.activeConnectionId === connectionId && snapshot.connectionStatus === "error") {
return {
ok: false,
error: new Error(snapshot.lastError ?? "Connection failed before coming online."),
diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx
index 0d6859172..ee68eadc1 100644
--- a/packages/app/src/screens/open-project-screen.tsx
+++ b/packages/app/src/screens/open-project-screen.tsx
@@ -8,7 +8,12 @@ import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
-import { useIsCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
+import {
+ useIsCompactFormFactor,
+ HEADER_INNER_HEIGHT,
+ HEADER_INNER_HEIGHT_MOBILE,
+ HEADER_TOP_PADDING_MOBILE,
+} from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
@@ -42,7 +47,12 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
) : null}
- void openProjectPicker()} testID="open-project-submit">
+ void openProjectPicker()}
+ testID="open-project-submit"
+ >
Add a project
diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx
index 6a11bf39c..e09292295 100644
--- a/packages/app/src/screens/settings-screen.tsx
+++ b/packages/app/src/screens/settings-screen.tsx
@@ -65,6 +65,7 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
+import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useIsCompactFormFactor } from "@/constants/layout";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
@@ -360,12 +361,21 @@ function HostsSection(props: HostsSectionProps) {
}
interface GeneralSectionProps {
+ routeServerId: string;
settings: AppSettings;
handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
}
-function ThemeIcon({ theme, size, color }: { theme: AppSettings["theme"]; size: number; color: string }) {
+function ThemeIcon({
+ theme,
+ size,
+ color,
+}: {
+ theme: AppSettings["theme"];
+ size: number;
+ color: string;
+}) {
switch (theme) {
case "light":
return ;
@@ -404,11 +414,14 @@ const THEME_LABELS: Record = {
};
function GeneralSection({
+ routeServerId,
settings,
handleThemeChange,
handleSendBehaviorChange,
}: GeneralSectionProps) {
const { theme } = useUnistyles();
+ const isConnected = useHostRuntimeIsConnected(routeServerId);
+ const { config, patchConfig } = useDaemonConfig(routeServerId);
const iconSize = theme.iconSize.md;
const iconColor = theme.colors.foregroundMuted;
@@ -422,15 +435,10 @@ function GeneralSection({
[
- styles.themeTrigger,
- pressed && { opacity: 0.85 },
- ]}
+ style={({ pressed }) => [styles.themeTrigger, pressed && { opacity: 0.85 }]}
>
-
- {THEME_LABELS[settings.theme]}
-
+ {THEME_LABELS[settings.theme]}
@@ -475,6 +483,31 @@ function GeneralSection({
]}
/>
+ {routeServerId.length > 0 && isConnected ? (
+
+
+ Inject Paseo tools
+
+ Automatically inject Paseo MCP tools into new agents
+
+
+ {
+ void patchConfig({
+ mcp: {
+ injectIntoAgents: value === "on",
+ },
+ });
+ }}
+ options={[
+ { value: "on", label: "On" },
+ { value: "off", label: "Off" },
+ ]}
+ />
+
+ ) : null}
);
@@ -501,10 +534,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
0
+ status === "error" &&
+ typeof entry?.error === "string" &&
+ entry.error.trim().length > 0
? entry.error.trim()
: null;
@@ -563,11 +595,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
: "Not installed"
}
variant={
- status === "ready"
- ? "success"
- : status === "error"
- ? "error"
- : "muted"
+ status === "ready" ? "success" : status === "error" ? "error" : "muted"
}
/>
+
{sections.map((section) => (
{
const isSelected = section.id === selectedSectionId;
const IconComponent = section.icon;
- const showSeparator =
- section.id === "integrations" || section.id === "providers";
+ const showSeparator = section.id === "integrations" || section.id === "providers";
return (
{showSeparator ? : null}
@@ -788,10 +812,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
-
+
@@ -1100,12 +1121,14 @@ export default function SettingsScreen() {
handleSaveEditDaemon,
handleRemoveConnection,
handleRemoveDaemon,
- restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
+ restartConfirmationMessage:
+ "This will restart the daemon. The app will reconnect automatically.",
waitForCondition,
isMountedRef,
};
const generalProps: GeneralSectionProps = {
+ routeServerId,
settings,
handleThemeChange,
handleSendBehaviorChange,
diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx
index 09b5b07ac..9d760a311 100644
--- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx
+++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx
@@ -81,11 +81,7 @@ function ShortcutRow({
Done
) : null}
-
+
{isCapturing ? "Cancel" : "Rebind"}
>
@@ -182,9 +178,7 @@ export function KeyboardShortcutsSection() {
Shortcuts
-
- Keyboard shortcuts are only available on desktop.
-
+ Keyboard shortcuts are only available on desktop.
);
diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx
index d5358c98b..30146504a 100644
--- a/packages/app/src/screens/startup-splash-screen.tsx
+++ b/packages/app/src/screens/startup-splash-screen.tsx
@@ -7,10 +7,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
-import {
- getDesktopDaemonLogs,
- type DesktopDaemonLogs,
-} from "@/desktop/daemon/desktop-daemon";
+import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
type StartupSplashScreenProps = {
@@ -216,7 +213,11 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
: phase === "connecting"
? [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
- { key: "connecting", label: "Connecting to local server...", status: "active" as const },
+ {
+ key: "connecting",
+ label: "Connecting to local server...",
+ status: "active" as const,
+ },
]
: [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
@@ -292,12 +293,11 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
- The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
+ The local server failed to start. If this keeps happening, please report the issue on
+ GitHub and include the logs below.
-
- {bootstrapState.error}
-
+ {bootstrapState.error}
{daemonLogs?.logPath ? {daemonLogs.logPath} : null}
diff --git a/packages/app/src/screens/workspace/use-mounted-tab-set.ts b/packages/app/src/screens/workspace/use-mounted-tab-set.ts
index 35eeaddbb..ec52b5da7 100644
--- a/packages/app/src/screens/workspace/use-mounted-tab-set.ts
+++ b/packages/app/src/screens/workspace/use-mounted-tab-set.ts
@@ -34,9 +34,7 @@ export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSet
const allTabIdsKey = allTabIds.join("\u0000");
const availableTabIds = useMemo(() => new Set(allTabIds), [allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
- const lruRef = useRef(
- activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : [],
- );
+ const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
index 03f140e40..306d02bd0 100644
--- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
+++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
@@ -22,6 +22,7 @@ import {
ArrowRightToLine,
Columns2,
Copy,
+ RotateCw,
Rows2,
SquarePen,
SquareTerminal,
@@ -184,173 +185,181 @@ function TabChip({
return (
-
-
-
- [
- styles.tab,
- Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const),
- {
- minWidth: resolvedTabWidth,
- width: resolvedTabWidth,
- maxWidth: resolvedTabWidth,
- },
- ]}
- onHoverIn={() => {
- setHovered(true);
- setHoveredTabKey(tab.key);
- }}
- onHoverOut={() => {
- setHovered(false);
- setHoveredTabKey((current) => (current === tab.key ? null : current));
- }}
- onPressIn={() => {
- onNavigateTab(tab.tabId);
- }}
- onPress={() => {
- onNavigateTab(tab.tabId);
- }}
- accessibilityLabel={tooltipLabel}
- >
- {isActive && (
-
- )}
-
-
-
-
- {showLabel ? (
- presentation.titleState === "loading" ? (
-
- ) : (
-
- {presentation.label}
-
- )
- ) : null}
-
-
- {showCloseButton ? (
- {
- event.stopPropagation?.();
- }}
- onHoverIn={() => {
- setHoveredTabKey(tab.key);
- setHoveredCloseTabKey(tab.key);
- }}
- onHoverOut={() => {
- setHoveredTabKey((current) => (current === tab.key ? null : current));
- setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
- }}
- onPress={(event) => {
- event.stopPropagation?.();
- void onCloseTab(tab.tabId);
- }}
- style={({ hovered, pressed }) => [
- styles.tabCloseButton,
- styles.tabCloseButtonShown,
- (hovered || pressed) && styles.tabCloseButtonActive,
- ]}
- >
- {({ hovered, pressed }) =>
- isClosingTab ? (
-
+
+
+ [
+ styles.tab,
+ Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const),
+ {
+ minWidth: resolvedTabWidth,
+ width: resolvedTabWidth,
+ maxWidth: resolvedTabWidth,
+ },
+ ]}
+ onHoverIn={() => {
+ setHovered(true);
+ setHoveredTabKey(tab.key);
+ }}
+ onHoverOut={() => {
+ setHovered(false);
+ setHoveredTabKey((current) => (current === tab.key ? null : current));
+ }}
+ onPressIn={() => {
+ onNavigateTab(tab.tabId);
+ }}
+ onPress={() => {
+ onNavigateTab(tab.tabId);
+ }}
+ accessibilityLabel={tooltipLabel}
+ >
+ {isActive && (
+
+ )}
+
+
+
+
+ {showLabel ? (
+ presentation.titleState === "loading" ? (
+
) : (
-
+
+ {presentation.label}
+
)
- }
-
- ) : null}
-
-
-
- {tab.target.kind === "agent" ? (
-
- {tooltipLabel}
- {tab.target.agentId.slice(0, 7)}
-
- ) : (
- {tooltipLabel}
- )}
-
-
+ ) : null}
+
-
- {menuEntries.map((entry) =>
- entry.kind === "separator" ? (
-
- ) : (
- {
- const iconColor = theme.colors.foregroundMuted;
- switch (entry.icon) {
- case "copy":
- return ;
- case "arrow-left-to-line":
- return ;
- case "arrow-right-to-line":
- return ;
- case "copy-x":
- return ;
- case "x":
- return ;
- default:
- return undefined;
+ {showCloseButton ? (
+ {
+ event.stopPropagation?.();
+ }}
+ onHoverIn={() => {
+ setHoveredTabKey(tab.key);
+ setHoveredCloseTabKey(tab.key);
+ }}
+ onHoverOut={() => {
+ setHoveredTabKey((current) => (current === tab.key ? null : current));
+ setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
+ }}
+ onPress={(event) => {
+ event.stopPropagation?.();
+ void onCloseTab(tab.tabId);
+ }}
+ style={({ hovered, pressed }) => [
+ styles.tabCloseButton,
+ styles.tabCloseButtonShown,
+ (hovered || pressed) && styles.tabCloseButtonActive,
+ ]}
+ >
+ {({ hovered, pressed }) =>
+ isClosingTab ? (
+
+ ) : (
+
+ )
+ }
+
+ ) : null}
+
+
+
+ {tab.target.kind === "agent" ? (
+
+ {tooltipLabel}
+ {tab.target.agentId.slice(0, 7)}
+
+ ) : (
+ {tooltipLabel}
+ )}
+
+
+
+
+ {menuEntries.map((entry) =>
+ entry.kind === "separator" ? (
+
+ ) : (
+ {
+ const iconColor = theme.colors.foregroundMuted;
+ switch (entry.icon) {
+ case "copy":
+ return ;
+ case "rotate-cw":
+ return ;
+ case "arrow-left-to-line":
+ return ;
+ case "arrow-right-to-line":
+ return ;
+ case "copy-x":
+ return ;
+ case "x":
+ return ;
+ default:
+ return undefined;
+ }
+ })()}
+ trailing={
+ entry.hint ? {entry.hint} : undefined
}
- })()}
- trailing={
- entry.hint ? (
- {entry.hint}
- ) : undefined
- }
- >
- {entry.label}
-
- ),
- )}
-
-
+ >
+ {entry.label}
+
+ ),
+ )}
+
+
);
}
diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx
index 31457d675..6204f280f 100644
--- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx
+++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx
@@ -3,10 +3,7 @@ import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import type {
- EditorTargetDescriptorPayload,
- EditorTargetId,
-} from "@server/shared/messages";
+import type { EditorTargetDescriptorPayload, EditorTargetId } from "@server/shared/messages";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import {
DropdownMenu,
@@ -16,10 +13,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
-import {
- resolvePreferredEditorId,
- usePreferredEditor,
-} from "@/hooks/use-preferred-editor";
+import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path";
interface WorkspaceOpenInEditorButtonProps {
@@ -27,10 +21,7 @@ interface WorkspaceOpenInEditorButtonProps {
cwd: string;
}
-export function WorkspaceOpenInEditorButton({
- serverId,
- cwd,
-}: WorkspaceOpenInEditorButtonProps) {
+export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEditorButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
@@ -173,9 +164,9 @@ export function WorkspaceOpenInEditorButton({
/>
}
trailing={
- editor.id === effectivePreferredEditorId
- ?
- : undefined
+ editor.id === effectivePreferredEditorId ? (
+
+ ) : undefined
}
onSelect={() => handleOpenEditor(editor.id)}
>
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index 810bf0425..57dfab969 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -83,7 +83,6 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
-import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
@@ -108,6 +107,7 @@ import {
} from "@/screens/workspace/workspace-header-source";
import {
deriveWorkspaceAgentVisibility,
+ shouldPruneWorkspaceAgentTab,
workspaceAgentVisibilityEqual,
} from "@/screens/workspace/workspace-agent-visibility";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
@@ -123,11 +123,13 @@ import {
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation";
+import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const EMPTY_UI_TABS: WorkspaceTab[] = [];
+const EMPTY_PINNED_AGENT_IDS = new Set();
const EMPTY_SET = new Set();
type WorkspaceScreenProps = {
@@ -789,23 +791,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
? trimNonEmpty(checkoutQuery.data.currentBranch)
: null;
- const {
- branchOptions,
- isOpen: isBranchSwitcherOpen,
- setIsOpen: setIsBranchSwitcherOpen,
- handleBranchSelect,
- invalidateStashAndCheckout,
- } = useBranchSwitcher({
- client,
- normalizedServerId,
- normalizedWorkspaceId,
- currentBranchName,
- isGitCheckout,
- isConnected,
- toast,
- queryClient,
- });
-
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -892,11 +877,11 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
+ const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
+ const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore((state) => state.convertDraftToAgent);
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
- const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
- const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
@@ -904,6 +889,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
+ const pinnedAgentIds = useWorkspaceLayoutStore((state) =>
+ persistenceKey
+ ? (state.pinnedAgentIdsByWorkspace[persistenceKey] ?? EMPTY_PINNED_AGENT_IDS)
+ : EMPTY_PINNED_AGENT_IDS,
+ );
+ const hiddenAgentIds = useWorkspaceLayoutStore((state) =>
+ persistenceKey ? (state.hiddenAgentIdsByWorkspace[persistenceKey] ?? EMPTY_SET) : EMPTY_SET,
+ );
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const { closingTabIds, closeTab } = useCloseTabs();
const closeWorkspaceTabWithCleanup = useCallback(
@@ -1008,6 +1001,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return;
}
+ const terminalIds = new Set(terminals.map((terminal) => terminal.id));
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
if (tab.target.kind !== "draft") {
return false;
@@ -1016,16 +1010,56 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
});
- reconcileWorkspaceTabs(persistenceKey, {
- agentsHydrated: hasHydratedAgents,
- terminalsHydrated: terminalsQuery.isSuccess,
- activeAgentIds: workspaceAgentVisibility.activeAgentIds,
- knownAgentIds: workspaceAgentVisibility.knownAgentIds,
- standaloneTerminalIds: terminals.map((terminal) => terminal.id),
- hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
- });
+ for (const agentId of workspaceAgentVisibility.activeAgentIds) {
+ if (hiddenAgentIds.has(agentId)) {
+ continue;
+ }
+ const representedByTarget = uiTabs.some(
+ (tab) => tab.target.kind === "agent" && tab.target.agentId === agentId,
+ );
+ const representedByDeterministicTabId = uiTabs.some(
+ (tab) => tab.tabId === `agent_${agentId}`,
+ );
+ if (
+ hasActivePendingDraftCreateInWorkspace &&
+ !representedByTarget &&
+ !representedByDeterministicTabId
+ ) {
+ continue;
+ }
+ ensureWorkspaceTab({ kind: "agent", agentId });
+ }
+ for (const terminal of terminals) {
+ ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id });
+ }
+
+ const canPruneAgentTabs = hasHydratedAgents;
+ const canPruneTerminalTabs = terminalsQuery.isSuccess;
+ for (const tab of uiTabs) {
+ if (
+ canPruneAgentTabs &&
+ tab.target.kind === "agent" &&
+ !pinnedAgentIds.has(tab.target.agentId) &&
+ shouldPruneWorkspaceAgentTab({
+ agentId: tab.target.agentId,
+ agentsHydrated: hasHydratedAgents,
+ knownAgentIds: workspaceAgentVisibility.knownAgentIds,
+ activeAgentIds: workspaceAgentVisibility.activeAgentIds,
+ })
+ ) {
+ closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target });
+ }
+ if (
+ canPruneTerminalTabs &&
+ tab.target.kind === "terminal" &&
+ !terminalIds.has(tab.target.terminalId)
+ ) {
+ closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target });
+ }
+ }
}, [
hasHydratedAgents,
+ hiddenAgentIds,
pendingByDraftId,
persistenceKey,
reconcileWorkspaceTabs,
@@ -1517,12 +1551,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
- [
- client,
- closeTab,
- closeWorkspaceTabWithCleanup,
- persistenceKey,
- ],
+ [client, closeTab, closeWorkspaceTabWithCleanup, persistenceKey],
);
const handleCloseTabsToLeftInPane = useCallback(
@@ -2026,233 +2055,234 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
{(!isFocusModeEnabled || isMobile) && (
-
-
-
- {isWorkspaceHeaderLoading ? (
- <>
-
-
- >
- ) : (
- <>
-
-
- {workspaceHeader.subtitle}
-
- >
- )}
-
-
- {({ hovered, open }) => {
- const Icon = isMobile ? EllipsisVertical : Ellipsis;
- return (
-
- );
- }}
-
-
- }
- onSelect={handleCreateDraftTab}
- >
- New agent
-
- }
- disabled={createTerminalMutation.isPending}
- onSelect={handleCreateTerminal}
- >
- New terminal
-
- }
- disabled={!workspaceDirectory}
- onSelect={handleCopyWorkspacePath}
- >
- Copy workspace path
-
- {currentBranchName ? (
- }
- onSelect={handleCopyBranchName}
+
+
+
+ {isWorkspaceHeaderLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
- Copy branch name
-
- ) : null}
-
- }
- onSelect={handleOpenSetupTab}
+ {workspaceHeader.subtitle}
+
+ >
+ )}
+
+
- Show setup
-
-
-
-
- >
- }
- right={
-
- {!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
-
- ) : null}
- {!isMobile ? (
-
- ) : null}
- {!isMobile && isGitCheckout ? (
- <>
- {workspaceDirectory ? (
+ {({ hovered, open }) => {
+ const Icon = isMobile ? EllipsisVertical : Ellipsis;
+ return (
+
+ );
+ }}
+
+
+ }
+ onSelect={handleCreateDraftTab}
+ >
+ New agent
+
+
+ }
+ disabled={createTerminalMutation.isPending}
+ onSelect={handleCreateTerminal}
+ >
+ New terminal
+
+ }
+ disabled={!isAbsolutePath(normalizedWorkspaceId)}
+ onSelect={handleCopyWorkspacePath}
+ >
+ Copy workspace path
+
+ {currentBranchName ? (
+ }
+ onSelect={handleCopyBranchName}
+ >
+ Copy branch name
+
+ ) : null}
+
+ }
+ onSelect={handleOpenSetupTab}
+ >
+ Show setup
+
+
+
+
+ >
+ }
+ right={
+
+ {!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
+
+ ) : null}
+ {!isMobile ? (
+
+ ) : null}
+ {!isMobile && isGitCheckout ? (
+ <>
- ) : null}
-
-
- [
- styles.sourceControlButton,
- workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
- (hovered || pressed || isExplorerOpen) &&
- styles.sourceControlButtonHovered,
- ]}
- >
- {({ hovered, pressed }) => {
- const active = isExplorerOpen || hovered || pressed;
- const iconColor = active
- ? theme.colors.foreground
- : theme.colors.foregroundMuted;
- return (
- <>
-
- {workspaceDescriptor?.diffStat ? (
-
+
+ [
+ styles.sourceControlButton,
+ workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
+ (hovered || pressed || isExplorerOpen) &&
+ styles.sourceControlButtonHovered,
+ ]}
+ >
+ {({ hovered, pressed }) => {
+ const active = isExplorerOpen || hovered || pressed;
+ const iconColor = active
+ ? theme.colors.foreground
+ : theme.colors.foregroundMuted;
+ return (
+ <>
+
- ) : null}
- >
- );
- }}
-
-
-
-
- Toggle explorer
-
-
-
-
- >
- ) : null}
- {!isMobile && !isGitCheckout ? (
-
- {({ hovered }) => {
- const color =
- isExplorerOpen || hovered
- ? theme.colors.foreground
- : theme.colors.foregroundMuted;
- return ;
- }}
-
- ) : null}
- {isMobile ? (
-
- {({ hovered }) => {
- const color =
- isExplorerOpen || hovered
- ? theme.colors.foreground
- : theme.colors.foregroundMuted;
- return isGitCheckout ? (
-
- ) : (
-
- );
- }}
-
- ) : null}
-
- }
- />
+ {workspaceDescriptor?.diffStat ? (
+
+
+ +{workspaceDescriptor.diffStat.additions}
+
+
+ -{workspaceDescriptor.diffStat.deletions}
+
+
+ ) : null}
+ >
+ );
+ }}
+
+
+
+
+ Toggle explorer
+
+
+
+
+ >
+ ) : null}
+ {!isMobile && !isGitCheckout ? (
+
+ {({ hovered }) => {
+ const color =
+ isExplorerOpen || hovered
+ ? theme.colors.foreground
+ : theme.colors.foregroundMuted;
+ return ;
+ }}
+
+ ) : null}
+ {isMobile ? (
+
+ {({ hovered }) => {
+ const color =
+ isExplorerOpen || hovered
+ ? theme.colors.foreground
+ : theme.colors.foregroundMuted;
+ return isGitCheckout ? (
+
+ ) : (
+
+ );
+ }}
+
+ ) : null}
+
+ }
+ />
)}
{isMobile ? (
diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts
index 438883a02..1d747d84f 100644
--- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts
+++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts
@@ -18,7 +18,6 @@ describe("workspace source of truth consumption", () => {
workspaceKind: "checkout",
name: "feat/workspace-sot",
status: "running",
- activityAt: new Date("2026-03-01T00:00:00.000Z"),
diffStat: null,
scripts: [],
};
diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts
index 08e25f2c2..e5fff4b8d 100644
--- a/packages/app/src/screens/workspace/workspace-tab-menu.ts
+++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts
@@ -8,13 +8,7 @@ export type WorkspaceTabMenuEntry =
kind: "item";
key: string;
label: string;
- icon?:
- | "copy"
- | "rotate-cw"
- | "arrow-left-to-line"
- | "arrow-right-to-line"
- | "copy-x"
- | "x";
+ icon?: "copy" | "rotate-cw" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
hint?: string;
tooltip?: string;
disabled?: boolean;
diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts
index a8a114324..a3b2e2eca 100644
--- a/packages/app/src/stores/keyboard-shortcuts-store.ts
+++ b/packages/app/src/stores/keyboard-shortcuts-store.ts
@@ -1,6 +1,8 @@
import { create } from "zustand";
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
+const SHORTCUT_BADGE_DELAY_MS = 150;
+
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
projectPickerOpen: boolean;
@@ -8,6 +10,7 @@ interface KeyboardShortcutsState {
capturingShortcut: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
+ showShortcutBadges: boolean;
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
/** All visible workspace targets in top-to-bottom visual order. */
@@ -24,13 +27,37 @@ interface KeyboardShortcutsState {
resetModifiers: () => void;
}
-export const useKeyboardShortcutsStore = create((set) => ({
+let badgeTimer: ReturnType | null = null;
+
+function updateBadgeTimer(
+ set: (partial: Partial) => void,
+ get: () => KeyboardShortcutsState,
+) {
+ const { altDown, cmdOrCtrlDown } = get();
+ const modifierDown = altDown || cmdOrCtrlDown;
+
+ if (badgeTimer) {
+ clearTimeout(badgeTimer);
+ badgeTimer = null;
+ }
+
+ if (modifierDown) {
+ badgeTimer = setTimeout(() => {
+ set({ showShortcutBadges: true });
+ }, SHORTCUT_BADGE_DELAY_MS);
+ } else {
+ set({ showShortcutBadges: false });
+ }
+}
+
+export const useKeyboardShortcutsStore = create((set, get) => ({
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
cmdOrCtrlDown: false,
+ showShortcutBadges: false,
sidebarShortcutWorkspaceTargets: [],
visibleWorkspaceTargets: [],
@@ -38,10 +65,19 @@ export const useKeyboardShortcutsStore = create((set) =>
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
- setAltDown: (down) => set({ altDown: down }),
- setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
+ setAltDown: (down) => {
+ set({ altDown: down });
+ updateBadgeTimer(set, get);
+ },
+ setCmdOrCtrlDown: (down) => {
+ set({ cmdOrCtrlDown: down });
+ updateBadgeTimer(set, get);
+ },
setSidebarShortcutWorkspaceTargets: (targets) =>
set({ sidebarShortcutWorkspaceTargets: targets }),
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
- resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
+ resetModifiers: () => {
+ set({ altDown: false, cmdOrCtrlDown: false });
+ updateBadgeTimer(set, get);
+ },
}));
diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts
index d78557644..2191af301 100644
--- a/packages/app/src/stores/session-store.test.ts
+++ b/packages/app/src/stores/session-store.test.ts
@@ -23,7 +23,6 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
- activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -62,7 +61,6 @@ describe("normalizeWorkspaceDescriptor", () => {
scripts,
});
- expect(workspace.activityAt).toBeNull();
expect(workspace.scripts).toEqual([
{
scriptName: "web",
diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts
index 9f21e2c3e..c77e9d720 100644
--- a/packages/app/src/stores/session-store.ts
+++ b/packages/app/src/stores/session-store.ts
@@ -122,7 +122,6 @@ export interface WorkspaceDescriptor {
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
name: string;
status: WorkspaceDescriptorPayload["status"];
- activityAt: Date | null;
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptorPayload["scripts"];
}
@@ -130,7 +129,6 @@ export interface WorkspaceDescriptor {
export function normalizeWorkspaceDescriptor(
payload: WorkspaceDescriptorPayload,
): WorkspaceDescriptor {
- const activityAt = payload.activityAt ? new Date(payload.activityAt) : null;
return {
id: normalizeWorkspaceIdentity(String(payload.id)) ?? String(payload.id),
projectId: String(payload.projectId),
@@ -141,7 +139,6 @@ export function normalizeWorkspaceDescriptor(
workspaceKind: payload.workspaceKind,
name: payload.name,
status: payload.status,
- activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
diffStat: payload.diffStat ?? null,
scripts: (payload.scripts ?? []).map((s) => ({ ...s })),
};
diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts
index 64155aeda..35b407c16 100644
--- a/packages/app/src/stores/workspace-layout-store.test.ts
+++ b/packages/app/src/stores/workspace-layout-store.test.ts
@@ -1005,4 +1005,32 @@ describe("workspace-layout-store actions", () => {
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
});
+
+ it("pinning an agent clears hidden intent", () => {
+ const workspaceKey = createWorkspaceKey();
+ const store = useWorkspaceLayoutStore.getState();
+
+ store.hideAgent(workspaceKey, "agent-1");
+ expect(
+ useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey],
+ ).toBeDefined();
+
+ store.pinAgent(workspaceKey, "agent-1");
+
+ const state = useWorkspaceLayoutStore.getState();
+ expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
+ expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
+ });
+
+ it("retargeting a tab to an agent clears hidden intent", () => {
+ const workspaceKey = createWorkspaceKey();
+ const store = useWorkspaceLayoutStore.getState();
+
+ store.hideAgent(workspaceKey, "agent-1");
+ const tabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
+ store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
+
+ const state = useWorkspaceLayoutStore.getState();
+ expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
+ });
});
diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts
index 8f0784891..774831a2c 100644
--- a/packages/app/src/styles/theme.ts
+++ b/packages/app/src/styles/theme.ts
@@ -530,7 +530,13 @@ export const theme = darkTheme;
// Export a union type that works for both themes
export type Theme = typeof darkTheme | typeof lightTheme;
-type UnistylesThemeKey = "light" | "dark" | "darkZinc" | "darkMidnight" | "darkClaude" | "darkGhostty";
+type UnistylesThemeKey =
+ | "light"
+ | "dark"
+ | "darkZinc"
+ | "darkMidnight"
+ | "darkClaude"
+ | "darkGhostty";
export const THEME_TO_UNISTYLES: Record = {
light: "light",
diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
index 947f9b623..d2148e576 100644
--- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
+++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
@@ -317,7 +317,9 @@ export class TerminalEmulatorRuntime {
!shouldInterceptDomTerminalKey({
key: normalizedKey,
ctrlKey: event.ctrlKey,
+ shiftKey: event.shiftKey,
altKey: event.altKey,
+ metaKey: event.metaKey,
pendingModifiers: this.pendingModifiers,
})
) {
diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts
index 97867cf6f..e3570115c 100644
--- a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts
+++ b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts
@@ -60,9 +60,7 @@ function extractState(terminal: ClientTerminal | HeadlessTerminal): SnapshotStat
};
}
-function extractCursorState(
- terminal: ClientTerminal | HeadlessTerminal,
-): SnapshotState["cursor"] {
+function extractCursorState(terminal: ClientTerminal | HeadlessTerminal): SnapshotState["cursor"] {
const buffer = terminal.buffer.active;
const coreService = (terminal as any)._core?.coreService;
const cursorStyle = coreService?.decPrivateModes?.cursorStyle;
diff --git a/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts
new file mode 100644
index 000000000..2a12f80e4
--- /dev/null
+++ b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import { splitMarkdownBlocks } from "../split-markdown-blocks";
+
+describe("splitMarkdownBlocks", () => {
+ it("returns a single block for a single paragraph", () => {
+ expect(splitMarkdownBlocks("Hello world")).toEqual(["Hello world"]);
+ });
+
+ it("splits two paragraphs separated by a double newline", () => {
+ expect(splitMarkdownBlocks("First paragraph\n\nSecond paragraph")).toEqual([
+ "First paragraph",
+ "Second paragraph",
+ ]);
+ });
+
+ it("keeps a fenced code block with internal double newlines as one block", () => {
+ expect(splitMarkdownBlocks("```ts\nconst a = 1;\n\nconst b = 2;\n```")).toEqual([
+ "```ts\nconst a = 1;\n\nconst b = 2;\n```",
+ ]);
+ });
+
+ it("does not treat 4-space-indented backticks as a fence", () => {
+ expect(splitMarkdownBlocks("Before\n\n ```\n code\n ```\n\nAfter")).toEqual([
+ "Before",
+ " ```\n code\n ```",
+ "After",
+ ]);
+ });
+
+ it("handles tilde fences", () => {
+ expect(splitMarkdownBlocks("Before\n\n~~~\ncode\n~~~\n\nAfter")).toEqual([
+ "Before",
+ "~~~\ncode\n~~~",
+ "After",
+ ]);
+ });
+
+ it("splits mixed paragraph, code fence, and paragraph content into three blocks", () => {
+ expect(
+ splitMarkdownBlocks(
+ "Intro paragraph\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro paragraph",
+ ),
+ ).toEqual(["Intro paragraph", "```ts\nconst a = 1;\n\nconst b = 2;\n```", "Outro paragraph"]);
+ });
+
+ it("keeps everything from an unclosed fence start as one block for streaming content", () => {
+ expect(splitMarkdownBlocks("Before fence\n\n```ts\nconst a = 1;\n\nconst b = 2;")).toEqual([
+ "Before fence",
+ "```ts\nconst a = 1;\n\nconst b = 2;",
+ ]);
+ });
+
+ it("returns an empty array for empty input", () => {
+ expect(splitMarkdownBlocks("")).toEqual([]);
+ });
+
+ it("splits a heading followed by a paragraph into two blocks", () => {
+ expect(splitMarkdownBlocks("# Heading\n\nParagraph text")).toEqual([
+ "# Heading",
+ "Paragraph text",
+ ]);
+ });
+
+ it("keeps consecutive list items together when there is no double newline", () => {
+ expect(splitMarkdownBlocks("- First item\n- Second item\n- Third item")).toEqual([
+ "- First item\n- Second item\n- Third item",
+ ]);
+ });
+
+ it("treats triple newlines as a split point and filters empty blocks", () => {
+ expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([
+ "First paragraph",
+ "Second paragraph",
+ ]);
+ });
+});
diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts
new file mode 100644
index 000000000..4c7054983
--- /dev/null
+++ b/packages/app/src/utils/assistant-image-metadata.test.ts
@@ -0,0 +1,58 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ clearAssistantImageMetadataCache,
+ estimateAssistantMessageHeightFromCache,
+ extractAssistantImageSources,
+ getAssistantImageMetadata,
+ setAssistantImageMetadata,
+} from "./assistant-image-metadata";
+
+describe("assistant image metadata", () => {
+ beforeEach(() => {
+ clearAssistantImageMetadataCache();
+ });
+
+ it("extracts markdown image sources", () => {
+ expect(
+ extractAssistantImageSources(
+ 'Before\n\n\n\n',
+ ),
+ ).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]);
+ });
+
+ it("reuses cached metadata across canonical and raw source keys", () => {
+ setAssistantImageMetadata(
+ {
+ source: "/tmp/paseo-codex-screenshot.png",
+ workspaceRoot: "/Users/moboudra/dev/paseo",
+ serverId: "server-1",
+ },
+ { width: 1200, height: 800 },
+ );
+
+ expect(
+ getAssistantImageMetadata({
+ source: "/tmp/paseo-codex-screenshot.png",
+ }),
+ ).toEqual({
+ width: 1200,
+ height: 800,
+ aspectRatio: 1.5,
+ });
+ });
+
+ it("estimates assistant message height from cached image metadata", () => {
+ setAssistantImageMetadata(
+ {
+ source: "https://example.com/landscape.png",
+ },
+ { width: 1200, height: 800 },
+ );
+
+ expect(
+ estimateAssistantMessageHeightFromCache(
+ "Here is the screenshot\n\n",
+ ),
+ ).toBeGreaterThan(220);
+ });
+});
diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts
new file mode 100644
index 000000000..64e8180c6
--- /dev/null
+++ b/packages/app/src/utils/assistant-image-metadata.ts
@@ -0,0 +1,213 @@
+import { MAX_CONTENT_WIDTH } from "@/constants/layout";
+import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
+
+export interface AssistantImageMetadata {
+ width: number;
+ height: number;
+ aspectRatio: number;
+}
+
+const assistantImageMetadataCache = new Map();
+const assistantImageParseCache = new Map();
+const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
+const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500;
+
+const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g;
+const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8;
+const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
+const ASSISTANT_IMAGE_BLOCK_GAP = 24;
+const ASSISTANT_MESSAGE_BASE_HEIGHT = 96;
+const ASSISTANT_MESSAGE_MIN_HEIGHT = 220;
+const ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT = 40;
+
+function touchCacheEntry(cache: Map, key: K, value: V, limit: number): void {
+ cache.delete(key);
+ cache.set(key, value);
+ if (cache.size <= limit) {
+ return;
+ }
+ const oldestKey = cache.keys().next().value;
+ if (oldestKey !== undefined) {
+ cache.delete(oldestKey);
+ }
+}
+
+function normalizeAssistantImageSourceToken(value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
+ const inner = trimmed.slice(1, -1).trim();
+ return inner || null;
+ }
+
+ const titleMatch = /^(.*?)(?:\s+(['"]).*?\2)?$/.exec(trimmed);
+ const source = titleMatch?.[1]?.trim() ?? trimmed;
+ return source || null;
+}
+
+function createSourceAliasKey(source: string): string {
+ return `source:${source}`;
+}
+
+function createResolutionKey(input: {
+ source: string;
+ workspaceRoot?: string;
+ serverId?: string;
+}): string | null {
+ const resolution = resolveAssistantImageSource({
+ source: input.source,
+ workspaceRoot: input.workspaceRoot,
+ });
+ if (!resolution) {
+ return null;
+ }
+ if (resolution.kind === "direct") {
+ return `direct:${resolution.uri}`;
+ }
+ return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`;
+}
+
+function getAssistantImageMetadataKeys(input: {
+ source: string;
+ workspaceRoot?: string;
+ serverId?: string;
+}): string[] {
+ const source = input.source.trim();
+ if (!source) {
+ return [];
+ }
+
+ const keys = [createSourceAliasKey(source)];
+ const resolutionKey = createResolutionKey(input);
+ if (resolutionKey) {
+ keys.unshift(resolutionKey);
+ }
+ return [...new Set(keys)];
+}
+
+export function getAssistantImageMetadata(input: {
+ source: string;
+ workspaceRoot?: string;
+ serverId?: string;
+}): AssistantImageMetadata | null {
+ for (const key of getAssistantImageMetadataKeys(input)) {
+ const metadata = assistantImageMetadataCache.get(key);
+ if (metadata) {
+ touchCacheEntry(
+ assistantImageMetadataCache,
+ key,
+ metadata,
+ ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
+ );
+ return metadata;
+ }
+ }
+ return null;
+}
+
+export function setAssistantImageMetadata(
+ input: {
+ source: string;
+ workspaceRoot?: string;
+ serverId?: string;
+ },
+ dimensions: { width: number; height: number },
+): AssistantImageMetadata | null {
+ const { width, height } = dimensions;
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ return null;
+ }
+
+ const metadata: AssistantImageMetadata = {
+ width,
+ height,
+ aspectRatio: width / height,
+ };
+
+ for (const key of getAssistantImageMetadataKeys(input)) {
+ touchCacheEntry(
+ assistantImageMetadataCache,
+ key,
+ metadata,
+ ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
+ );
+ }
+
+ return metadata;
+}
+
+export function extractAssistantImageSources(markdown: string): string[] {
+ const cachedParse = assistantImageParseCache.get(markdown);
+ if (cachedParse) {
+ touchCacheEntry(
+ assistantImageParseCache,
+ markdown,
+ cachedParse,
+ ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
+ );
+ return cachedParse.sources;
+ }
+
+ const sources: string[] = [];
+ for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
+ const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
+ if (normalized) {
+ sources.push(normalized);
+ }
+ }
+ const hasNonImageText = markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0;
+ touchCacheEntry(
+ assistantImageParseCache,
+ markdown,
+ { sources, hasNonImageText },
+ ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
+ );
+ return sources;
+}
+
+export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
+ const cachedParse = assistantImageParseCache.get(markdown);
+ const parsed =
+ cachedParse ??
+ (() => {
+ const sources = extractAssistantImageSources(markdown);
+ const nextParsed = assistantImageParseCache.get(markdown);
+ return nextParsed ?? { sources, hasNonImageText: true };
+ })();
+ if (parsed.sources.length === 0) {
+ return null;
+ }
+
+ const knownHeights = parsed.sources
+ .map((source) => getAssistantImageMetadata({ source }))
+ .filter((metadata): metadata is AssistantImageMetadata => metadata !== null)
+ .map((metadata) =>
+ Math.max(
+ ASSISTANT_IMAGE_MIN_HEIGHT,
+ Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio),
+ ),
+ );
+
+ if (knownHeights.length === 0) {
+ return null;
+ }
+
+ const baseHeight = parsed.hasNonImageText
+ ? ASSISTANT_MESSAGE_BASE_HEIGHT
+ : ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT;
+
+ const estimatedHeight =
+ baseHeight +
+ knownHeights.reduce((sum, height) => sum + height, 0) +
+ ASSISTANT_IMAGE_BLOCK_GAP * knownHeights.length;
+
+ return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight);
+}
+
+export function clearAssistantImageMetadataCache(): void {
+ assistantImageMetadataCache.clear();
+ assistantImageParseCache.clear();
+}
diff --git a/packages/app/src/utils/assistant-image-source.test.ts b/packages/app/src/utils/assistant-image-source.test.ts
new file mode 100644
index 000000000..755d27cfc
--- /dev/null
+++ b/packages/app/src/utils/assistant-image-source.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveAssistantImageSource } from "./assistant-image-source";
+
+describe("resolveAssistantImageSource", () => {
+ it("passes through direct image URIs", () => {
+ expect(resolveAssistantImageSource({ source: "https://example.com/image.png" })).toEqual({
+ kind: "direct",
+ uri: "https://example.com/image.png",
+ });
+ expect(resolveAssistantImageSource({ source: "data:image/png;base64,abc" })).toEqual({
+ kind: "direct",
+ uri: "data:image/png;base64,abc",
+ });
+ });
+
+ it("uses the workspace root for relative paths", () => {
+ expect(
+ resolveAssistantImageSource({
+ source: "screenshots/output.png",
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ kind: "file_rpc",
+ cwd: "/Users/test/project",
+ path: "screenshots/output.png",
+ });
+ });
+
+ it("uses the workspace root for absolute paths inside the workspace", () => {
+ expect(
+ resolveAssistantImageSource({
+ source: "/Users/test/project/screenshots/output.png",
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ kind: "file_rpc",
+ cwd: "/Users/test/project",
+ path: "/Users/test/project/screenshots/output.png",
+ });
+ });
+
+ it("falls back to filesystem root for absolute paths outside the workspace", () => {
+ expect(
+ resolveAssistantImageSource({
+ source: "/tmp/paseo-codex-screenshot.png",
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ kind: "file_rpc",
+ cwd: "/",
+ path: "/tmp/paseo-codex-screenshot.png",
+ });
+ });
+
+ it("normalizes file URIs into file RPC requests", () => {
+ expect(
+ resolveAssistantImageSource({
+ source: "file:///tmp/paseo-codex-screenshot.png",
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ kind: "file_rpc",
+ cwd: "/",
+ path: "/tmp/paseo-codex-screenshot.png",
+ });
+ });
+
+ it("falls back to the drive root for Windows absolute paths", () => {
+ expect(
+ resolveAssistantImageSource({
+ source: "C:/Users/test/Desktop/screenshot.png",
+ workspaceRoot: "D:/repo",
+ }),
+ ).toEqual({
+ kind: "file_rpc",
+ cwd: "C:/",
+ path: "C:/Users/test/Desktop/screenshot.png",
+ });
+ });
+});
diff --git a/packages/app/src/utils/assistant-image-source.ts b/packages/app/src/utils/assistant-image-source.ts
new file mode 100644
index 000000000..fda471483
--- /dev/null
+++ b/packages/app/src/utils/assistant-image-source.ts
@@ -0,0 +1,109 @@
+import { fileUriToPath } from "@/attachments/utils";
+import { isAbsolutePath } from "@/utils/path";
+
+export type AssistantImageSourceResolution =
+ | { kind: "direct"; uri: string }
+ | { kind: "file_rpc"; cwd: string; path: string };
+
+function trimTrailingSeparators(value: string): string {
+ if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
+ return value.replace(/\\/g, "/");
+ }
+ return value.replace(/[\\/]+$/, "");
+}
+
+function normalizeForPathComparison(value: string): string {
+ const normalized = trimTrailingSeparators(value.replace(/\\/g, "/"));
+ if (/^[A-Za-z]:\//.test(normalized)) {
+ return `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`;
+ }
+ return normalized;
+}
+
+function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
+ const candidate = normalizeForPathComparison(candidatePath);
+ const root = normalizeForPathComparison(rootPath);
+ if (!candidate || !root) {
+ return false;
+ }
+ if (root === "/") {
+ return candidate.startsWith("/");
+ }
+ if (candidate === root) {
+ return true;
+ }
+ return candidate.startsWith(`${root}/`);
+}
+
+function deriveFallbackRootFromAbsolutePath(value: string): string | null {
+ if (value.startsWith("/")) {
+ return "/";
+ }
+
+ const driveMatch = /^([A-Za-z]:)[\\/]/.exec(value);
+ if (driveMatch?.[1]) {
+ return `${driveMatch[1]}/`;
+ }
+
+ const uncMatch = /^(\\\\[^\\]+\\[^\\]+)/.exec(value);
+ if (uncMatch?.[1]) {
+ return uncMatch[1];
+ }
+
+ return null;
+}
+
+export function resolveAssistantImageSource(input: {
+ source: string;
+ workspaceRoot?: string;
+}): AssistantImageSourceResolution | null {
+ const source = input.source.trim();
+ if (!source) {
+ return null;
+ }
+
+ if (/^(https?:|data:|blob:)/i.test(source)) {
+ return { kind: "direct", uri: source };
+ }
+
+ const sourcePath = source.startsWith("file://") ? fileUriToPath(source) : source;
+ if (!sourcePath) {
+ return null;
+ }
+
+ if (!isAbsolutePath(sourcePath)) {
+ const workspaceRoot = input.workspaceRoot?.trim();
+ if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
+ return null;
+ }
+ return {
+ kind: "file_rpc",
+ cwd: workspaceRoot,
+ path: sourcePath,
+ };
+ }
+
+ const workspaceRoot = input.workspaceRoot?.trim();
+ if (
+ workspaceRoot &&
+ isAbsolutePath(workspaceRoot) &&
+ isPathWithinRoot(sourcePath, workspaceRoot)
+ ) {
+ return {
+ kind: "file_rpc",
+ cwd: workspaceRoot,
+ path: sourcePath,
+ };
+ }
+
+ const fallbackRoot = deriveFallbackRootFromAbsolutePath(sourcePath);
+ if (!fallbackRoot) {
+ return null;
+ }
+
+ return {
+ kind: "file_rpc",
+ cwd: fallbackRoot,
+ path: sourcePath,
+ };
+}
diff --git a/packages/app/src/utils/desktop-badge-state.ts b/packages/app/src/utils/desktop-badge-state.ts
index 9669492c5..00a2a7832 100644
--- a/packages/app/src/utils/desktop-badge-state.ts
+++ b/packages/app/src/utils/desktop-badge-state.ts
@@ -2,9 +2,7 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
export type DesktopBadgeWorkspaceStatus = WorkspaceDescriptor["status"];
-export function isWorkspaceActionableForDesktopBadge(
- status: DesktopBadgeWorkspaceStatus,
-): boolean {
+export function isWorkspaceActionableForDesktopBadge(status: DesktopBadgeWorkspaceStatus): boolean {
return status === "attention" || status === "needs_input" || status === "failed";
}
diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts
index e72f5dfdd..6d22d812c 100644
--- a/packages/app/src/utils/desktop-window.ts
+++ b/packages/app/src/utils/desktop-window.ts
@@ -95,9 +95,11 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
}, [isFullscreen]);
}
-export function useWindowControlsPadding(
- role: WindowControlsPaddingRole,
-): { left: number; right: number; top: number } {
+export function useWindowControlsPadding(role: WindowControlsPaddingRole): {
+ left: number;
+ right: number;
+ top: number;
+} {
const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen);
const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
diff --git a/packages/app/src/utils/diff-rendering.test.ts b/packages/app/src/utils/diff-rendering.test.ts
index c63f18e5f..5e919b877 100644
--- a/packages/app/src/utils/diff-rendering.test.ts
+++ b/packages/app/src/utils/diff-rendering.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
-import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens } from "./diff-rendering";
+import {
+ formatDiffContentText,
+ formatDiffGutterText,
+ hasVisibleDiffTokens,
+} from "./diff-rendering";
describe("diff-rendering", () => {
it("keeps header gutters tall even when they do not show a line number", () => {
diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts
index 1dcd5aa60..263dd043d 100644
--- a/packages/app/src/utils/host-routes.ts
+++ b/packages/app/src/utils/host-routes.ts
@@ -314,11 +314,7 @@ export function buildHostWorkspaceOpenRoute(
return `${base}?open=${encodeURIComponent(normalizedOpenIntent)}` as const;
}
-export function buildHostAgentDetailRoute(
- serverId: string,
- agentId: string,
- workspaceId?: string,
-) {
+export function buildHostAgentDetailRoute(serverId: string, agentId: string, workspaceId?: string) {
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (normalizedWorkspaceId) {
const normalizedAgentId = trimNonEmpty(agentId);
diff --git a/packages/app/src/utils/path.ts b/packages/app/src/utils/path.ts
index 6013fe79f..6a762b0b5 100644
--- a/packages/app/src/utils/path.ts
+++ b/packages/app/src/utils/path.ts
@@ -1,5 +1,3 @@
export function isAbsolutePath(value: string): boolean {
- return (
- value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value)
- );
+ return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
}
diff --git a/packages/app/src/utils/sidebar-animation-state.ts b/packages/app/src/utils/sidebar-animation-state.ts
index 9572b8975..32a25943a 100644
--- a/packages/app/src/utils/sidebar-animation-state.ts
+++ b/packages/app/src/utils/sidebar-animation-state.ts
@@ -17,8 +17,7 @@ interface SidebarAnimationTargets {
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
- input.previousIsOpen !== input.nextIsOpen ||
- input.previousWindowWidth !== input.nextWindowWidth
+ input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth
);
}
diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts
index d13da244a..13662c3dd 100644
--- a/packages/app/src/utils/sidebar-project-row-model.test.ts
+++ b/packages/app/src/utils/sidebar-project-row-model.test.ts
@@ -16,7 +16,6 @@ function workspace(overrides: Partial = {}): SidebarWorks
projectKind: "git",
workspaceKind: "checkout",
name: "paseo",
- activityAt: null,
statusBucket: "done",
diffStat: null,
scripts: [],
@@ -34,7 +33,6 @@ function project(overrides: Partial = {}): SidebarProjectEn
statusBucket: "done",
activeCount: 0,
totalWorkspaces: 1,
- latestActivityAt: null,
workspaces: [workspace()],
...overrides,
};
@@ -132,7 +130,7 @@ describe("buildSidebarProjectRowModel", () => {
});
describe("isSidebarProjectFlattened", () => {
- it("returns true only for single-workspace directory projects", () => {
+ it("returns true only for single-workspace non-git projects", () => {
expect(
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
).toBe(false);
diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts
index 4f899d788..bf0910c30 100644
--- a/packages/app/src/utils/sidebar-project-row-model.ts
+++ b/packages/app/src/utils/sidebar-project-row-model.ts
@@ -50,8 +50,7 @@ export function buildSidebarProjectRowModel(input: {
};
}
- const collapsible =
- input.project.projectKind === "git" || input.project.workspaces.length > 1;
+ const collapsible = input.project.projectKind === "git" || input.project.workspaces.length > 1;
return {
kind: "project_section",
diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts
index a00c77f84..72e7435a5 100644
--- a/packages/app/src/utils/sidebar-shortcuts.test.ts
+++ b/packages/app/src/utils/sidebar-shortcuts.test.ts
@@ -14,7 +14,6 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
projectKind: "git",
workspaceKind: "checkout",
name: cwd,
- activityAt: null,
statusBucket: "done",
diffStat: null,
scripts: [],
@@ -31,7 +30,6 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
statusBucket: "done",
activeCount: 0,
totalWorkspaces: workspaces.length,
- latestActivityAt: null,
workspaces,
};
}
@@ -79,7 +77,7 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
});
- it("respects collapsed state for single-workspace git projects", () => {
+ it("still excludes collapsed single-workspace git projects because they are not flattened", () => {
const projects = [project("p1", [workspace("s1", "/repo/main")])];
const model = buildSidebarShortcutModel({
diff --git a/packages/app/src/utils/split-markdown-blocks.ts b/packages/app/src/utils/split-markdown-blocks.ts
new file mode 100644
index 000000000..3d7e831ad
--- /dev/null
+++ b/packages/app/src/utils/split-markdown-blocks.ts
@@ -0,0 +1,57 @@
+function getFenceDelimiter(line: string) {
+ const match = /^( {0,3})(`{3,}|~{3,})/.exec(line);
+ return match?.[2] ?? null;
+}
+
+export function splitMarkdownBlocks(text: string): string[] {
+ if (text.length === 0) {
+ return [];
+ }
+
+ const blocks: string[] = [];
+ let currentLines: string[] = [];
+ let activeFenceCharacter: "`" | "~" | null = null;
+ let activeFenceLength = 0;
+ let sawBlockSeparator = false;
+
+ for (const line of text.split("\n")) {
+ const isBlankLine = line.trim().length === 0;
+
+ if (!activeFenceCharacter && isBlankLine) {
+ if (currentLines.length > 0) {
+ sawBlockSeparator = true;
+ }
+ continue;
+ }
+
+ if (!activeFenceCharacter && sawBlockSeparator) {
+ blocks.push(currentLines.join("\n"));
+ currentLines = [];
+ sawBlockSeparator = false;
+ }
+
+ currentLines.push(line);
+
+ const fenceDelimiter = getFenceDelimiter(line);
+ if (!fenceDelimiter) {
+ continue;
+ }
+
+ if (!activeFenceCharacter) {
+ activeFenceCharacter = fenceDelimiter[0] as "`" | "~";
+ activeFenceLength = fenceDelimiter.length;
+ continue;
+ }
+
+ if (fenceDelimiter[0] === activeFenceCharacter && fenceDelimiter.length >= activeFenceLength) {
+ activeFenceCharacter = null;
+ activeFenceLength = 0;
+ }
+ }
+
+ if (currentLines.length > 0) {
+ blocks.push(currentLines.join("\n"));
+ }
+
+ return blocks.filter((block) => block.length > 0);
+}
diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts
index 2a627d587..96ac8340e 100644
--- a/packages/app/src/utils/terminal-keys.test.ts
+++ b/packages/app/src/utils/terminal-keys.test.ts
@@ -57,7 +57,9 @@ describe("terminal key helpers", () => {
shouldInterceptDomTerminalKey({
key: "Escape",
ctrlKey: false,
+ shiftKey: false,
altKey: false,
+ metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
}),
).toBe(false);
@@ -65,7 +67,9 @@ describe("terminal key helpers", () => {
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: true,
+ shiftKey: false,
altKey: false,
+ metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
}),
).toBe(false);
@@ -73,7 +77,9 @@ describe("terminal key helpers", () => {
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: false,
+ shiftKey: false,
altKey: false,
+ metaKey: false,
pendingModifiers: { ctrl: true, shift: false, alt: false },
}),
).toBe(true);
@@ -81,12 +87,73 @@ describe("terminal key helpers", () => {
shouldInterceptDomTerminalKey({
key: "Escape",
ctrlKey: false,
+ shiftKey: false,
altKey: false,
+ metaKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: true },
}),
).toBe(true);
});
+ it("intercepts Enter with DOM shift modifier for CSI u encoding", () => {
+ expect(
+ shouldInterceptDomTerminalKey({
+ key: "Enter",
+ ctrlKey: false,
+ shiftKey: true,
+ altKey: false,
+ metaKey: false,
+ pendingModifiers: { ctrl: false, shift: false, alt: false },
+ }),
+ ).toBe(true);
+ });
+
+ it("intercepts Enter with any DOM modifier for CSI u encoding", () => {
+ expect(
+ shouldInterceptDomTerminalKey({
+ key: "Enter",
+ ctrlKey: true,
+ shiftKey: false,
+ altKey: false,
+ metaKey: false,
+ pendingModifiers: { ctrl: false, shift: false, alt: false },
+ }),
+ ).toBe(true);
+ expect(
+ shouldInterceptDomTerminalKey({
+ key: "Enter",
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: true,
+ metaKey: false,
+ pendingModifiers: { ctrl: false, shift: false, alt: false },
+ }),
+ ).toBe(true);
+ expect(
+ shouldInterceptDomTerminalKey({
+ key: "Enter",
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ metaKey: true,
+ pendingModifiers: { ctrl: false, shift: false, alt: false },
+ }),
+ ).toBe(true);
+ });
+
+ it("does not intercept plain Enter without modifiers", () => {
+ expect(
+ shouldInterceptDomTerminalKey({
+ key: "Enter",
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ metaKey: false,
+ pendingModifiers: { ctrl: false, shift: false, alt: false },
+ }),
+ ).toBe(false);
+ });
+
it("detects pending modifier state", () => {
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts
index 55c7c523a..bafd5d2e8 100644
--- a/packages/app/src/utils/terminal-keys.ts
+++ b/packages/app/src/utils/terminal-keys.ts
@@ -85,10 +85,21 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers)
export function shouldInterceptDomTerminalKey(args: {
key: string;
ctrlKey: boolean;
+ shiftKey: boolean;
altKey: boolean;
+ metaKey: boolean;
pendingModifiers: PendingTerminalModifiers;
}): boolean {
- return hasPendingTerminalModifiers(args.pendingModifiers);
+ if (hasPendingTerminalModifiers(args.pendingModifiers)) {
+ return true;
+ }
+ // xterm.js sends plain \r for Enter regardless of modifiers.
+ // Intercept modified Enter so it gets CSI u encoding (Kitty keyboard protocol),
+ // which Claude Code and other TUI apps use for Shift+Enter newlines.
+ if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
+ return true;
+ }
+ return false;
}
export function mergeTerminalModifiers(args: {
diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts
index be524faad..db4ddc953 100644
--- a/packages/app/src/utils/tool-call-icon.ts
+++ b/packages/app/src/utils/tool-call-icon.ts
@@ -11,6 +11,8 @@ import {
Wrench,
} from "lucide-react-native";
import type { ToolCallDetail, ToolCallIconName } from "@server/server/agent/agent-sdk-types";
+import { isPaseoToolName } from "@server/server/agent/tool-name-normalization";
+import { PaseoLogo } from "@/components/icons/paseo-logo";
export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>;
@@ -57,6 +59,9 @@ export function resolveToolCallIcon(
if (lowerName === "speak") {
return MicVocal;
}
+ if (isPaseoToolName(lowerName)) {
+ return PaseoLogo;
+ }
if (lowerName === "task") {
return Bot;
}
diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts
index e5252fc73..3e7f78fe0 100644
--- a/packages/app/src/utils/workspace-archive-navigation.test.ts
+++ b/packages/app/src/utils/workspace-archive-navigation.test.ts
@@ -17,8 +17,7 @@ function workspace(
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
- status: input.status ?? "running",
- activityAt: input.activityAt ?? null,
+ status: input.status ?? "done",
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-execution.test.ts
index 6b7c4932d..f9a48a4d6 100644
--- a/packages/app/src/utils/workspace-execution.test.ts
+++ b/packages/app/src/utils/workspace-execution.test.ts
@@ -21,7 +21,6 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name ?? "main",
status: input.status ?? "running",
- activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
diff --git a/packages/app/src/voice/voice-runtime.test.ts b/packages/app/src/voice/voice-runtime.test.ts
index 5afae5101..d95b40be9 100644
--- a/packages/app/src/voice/voice-runtime.test.ts
+++ b/packages/app/src/voice/voice-runtime.test.ts
@@ -126,7 +126,7 @@ describe("voice runtime", () => {
expect(runtime.getSnapshot().phase).toBe("waiting");
});
- it("moves from waiting to playing on the first assistant audio", async () => {
+ it("moves from listening to playing on the first assistant audio", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
@@ -232,6 +232,7 @@ describe("voice runtime", () => {
});
expect(adapter.audioPlayed).not.toHaveBeenCalled();
+ playResolvers.shift()?.(0.1);
playResolvers.shift()!(0.1);
await vi.waitFor(() => {
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-0");
@@ -241,11 +242,11 @@ describe("voice runtime", () => {
playResolvers.shift()!(0.1);
await vi.waitFor(() => {
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-1");
- expect(runtime.getSnapshot().phase).toBe("waiting");
+ expect(runtime.getSnapshot().phase).toBe("playing");
});
});
- it("returns to waiting after assistant playback when the turn is still active", async () => {
+ it("leaves playback phase unchanged after assistant playback while the turn is still active", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
@@ -255,7 +256,7 @@ describe("voice runtime", () => {
runtime.onAssistantAudioStarted("server-1");
runtime.onAssistantAudioFinished("server-1");
- expect(runtime.getSnapshot().phase).toBe("waiting");
+ expect(runtime.getSnapshot().phase).toBe("playing");
expect(engine.play).toHaveBeenCalled();
});
@@ -301,8 +302,8 @@ describe("voice runtime", () => {
runtime.onServerSpeechStateChanged("server-1", true);
- expect(engine.stop).toHaveBeenCalledTimes(1);
- expect(engine.clearQueue).toHaveBeenCalledTimes(1);
+ expect(engine.stop).toHaveBeenCalledTimes(2);
+ expect(engine.clearQueue).toHaveBeenCalledTimes(2);
resolvePlay(0.1);
});
@@ -336,7 +337,7 @@ describe("voice runtime", () => {
runtime.handleCaptureVolume(0.5);
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(false);
expect(adapter.abortRequest).not.toHaveBeenCalled();
- expect(engine.stop).not.toHaveBeenCalled();
+ expect(runtime.getSnapshot().phase).toBe("playing");
});
it("keeps the meter white state driven by server speech detection", async () => {
@@ -375,6 +376,44 @@ describe("voice runtime", () => {
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(true);
});
+ it("drops queued voice chunks that arrive after server speech interrupts playback", async () => {
+ const adapter = createSessionAdapter();
+ const { runtime, engine } = createRuntime();
+ runtime.registerSession(adapter);
+
+ await runtime.startVoice("server-1", "agent-1");
+ runtime.onTurnEvent("server-1", "agent-1", "turn_started");
+ vi.mocked(engine.play).mockClear();
+
+ runtime.handleAudioOutput(
+ "server-1",
+ createAudioPayload({
+ id: "chunk-0",
+ groupId: "group-1",
+ chunkIndex: 0,
+ isLastChunk: false,
+ }),
+ );
+ await vi.waitFor(() => {
+ expect(engine.play).toHaveBeenCalledTimes(1);
+ });
+
+ runtime.onServerSpeechStateChanged("server-1", true);
+ runtime.handleAudioOutput(
+ "server-1",
+ createAudioPayload({
+ id: "chunk-1",
+ groupId: "group-1",
+ chunkIndex: 1,
+ isLastChunk: true,
+ }),
+ );
+
+ expect(engine.stop).toHaveBeenCalled();
+ expect(engine.clearQueue).toHaveBeenCalled();
+ expect(vi.mocked(adapter.audioPlayed).mock.calls.flat()).not.toContain("chunk-1");
+ });
+
it("authoritatively stops and suppresses later voice audio", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
diff --git a/packages/app/test-stubs/xterm-addon-ligatures.ts b/packages/app/test-stubs/xterm-addon-ligatures.ts
new file mode 100644
index 000000000..b7949387e
--- /dev/null
+++ b/packages/app/test-stubs/xterm-addon-ligatures.ts
@@ -0,0 +1 @@
+export class LigaturesAddon {}
diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts
index c34a8ab2b..a1986df8c 100644
--- a/packages/app/vitest.config.ts
+++ b/packages/app/vitest.config.ts
@@ -15,6 +15,7 @@ export default defineConfig({
test: {
environment: "node",
exclude: [...configDefaults.exclude, "e2e/**"],
+ setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
/**
* Expo pulls in native tooling (xcode, etc.) that executes files relying on `process.send`.
* Vitest's default worker pool uses worker_threads, which intentionally stub that API and
@@ -22,6 +23,11 @@ export default defineConfig({
* keeps `process.send` intact so the app tests can boot before hitting the intentional failures.
*/
pool: "forks",
+ poolOptions: {
+ forks: {
+ maxForks: 2,
+ },
+ },
server: {
deps: {
fallbackCJS: true,
@@ -53,6 +59,10 @@ export default defineConfig({
find: "react-dom",
replacement: resolvePackageEntry("react-dom"),
},
+ {
+ find: "@xterm/addon-ligatures",
+ replacement: path.resolve(__dirname, "test-stubs/xterm-addon-ligatures.ts"),
+ },
],
},
});
diff --git a/packages/app/vitest.setup.ts b/packages/app/vitest.setup.ts
new file mode 100644
index 000000000..3b2b6fbc7
--- /dev/null
+++ b/packages/app/vitest.setup.ts
@@ -0,0 +1,94 @@
+// @ts-nocheck
+import { vi } from "vitest";
+
+const globalWithTestShims = globalThis as typeof globalThis & Record;
+
+globalWithTestShims.__DEV__ = false;
+
+if (typeof globalThis.self === "undefined") {
+ globalWithTestShims.self = globalThis;
+}
+
+if (typeof globalThis.expo === "undefined") {
+ class ExpoEventEmitter {
+ addListener() {
+ return {
+ remove() {},
+ };
+ }
+ removeListener() {}
+ removeAllListeners() {}
+ emit() {}
+ listenerCount() {
+ return 0;
+ }
+ }
+
+ class ExpoSharedObject extends ExpoEventEmitter {}
+ class ExpoSharedRef extends ExpoSharedObject {}
+ class ExpoNativeModule extends ExpoEventEmitter {}
+
+ globalWithTestShims.expo = {
+ EventEmitter: ExpoEventEmitter,
+ SharedObject: ExpoSharedObject,
+ SharedRef: ExpoSharedRef,
+ NativeModule: ExpoNativeModule,
+ modules: {},
+ };
+}
+
+if (typeof globalThis.requestAnimationFrame !== "function") {
+ globalThis.requestAnimationFrame = (callback: FrameRequestCallback) =>
+ setTimeout(() => callback(Date.now()), 0) as unknown as number;
+}
+
+if (typeof globalThis.cancelAnimationFrame !== "function") {
+ globalThis.cancelAnimationFrame = (handle: number) => {
+ clearTimeout(handle);
+ };
+}
+
+vi.mock("react-native-unistyles", () => ({
+ StyleSheet: {
+ create: (styles: T) => styles,
+ },
+ useUnistyles: () => ({
+ theme: {},
+ rt: {},
+ breakpoint: undefined,
+ }),
+ UnistylesRuntime: {
+ setTheme: vi.fn(),
+ themeName: "light",
+ },
+}));
+
+vi.mock("@xterm/addon-ligatures", () => ({
+ LigaturesAddon: class LigaturesAddon {},
+}));
+
+vi.mock("react-native-svg", () => {
+ const Stub = () => null;
+ return {
+ __esModule: true,
+ default: Stub,
+ Circle: Stub,
+ Defs: Stub,
+ G: Stub,
+ Line: Stub,
+ LinearGradient: Stub,
+ Path: Stub,
+ Rect: Stub,
+ Stop: Stub,
+ SvgCss: Stub,
+ SvgCssUri: Stub,
+ SvgFromXml: Stub,
+ SvgUri: Stub,
+ SvgXml: Stub,
+ Use: Stub,
+ };
+});
+
+vi.mock("expo-linking", () => ({
+ openURL: vi.fn().mockResolvedValue(undefined),
+}));
diff --git a/packages/cli/package.json b/packages/cli/package.json
index c0006121b..6e5dbe0c1 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
- "version": "0.1.52",
+ "version": "0.1.54",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
- "@getpaseo/relay": "0.1.52",
- "@getpaseo/server": "0.1.52",
+ "@getpaseo/relay": "0.1.54",
+ "@getpaseo/server": "0.1.54",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 336d3015a..5da470821 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -62,45 +62,39 @@ export function createCli(): Command {
.option("--no-color", "disable colored output");
// Primary agent commands (top-level)
- addJsonAndDaemonHostOptions(
- addLsOptions(program.command("ls")),
- ).action(withOutput(runLsCommand));
+ addJsonAndDaemonHostOptions(addLsOptions(program.command("ls"))).action(withOutput(runLsCommand));
- addJsonAndDaemonHostOptions(
- addRunOptions(program.command("run")),
- ).action(withOutput(runRunCommand));
+ addJsonAndDaemonHostOptions(addRunOptions(program.command("run"))).action(
+ withOutput(runRunCommand),
+ );
- addDaemonHostOption(
- addAttachOptions(program.command("attach")),
- ).action(runAttachCommand);
+ addDaemonHostOption(addAttachOptions(program.command("attach"))).action(runAttachCommand);
- addDaemonHostOption(
- addLogsOptions(program.command("logs")),
- ).action(runLogsCommand);
+ addDaemonHostOption(addLogsOptions(program.command("logs"))).action(runLogsCommand);
- addJsonAndDaemonHostOptions(
- addStopOptions(program.command("stop")),
- ).action(withOutput(runStopCommand));
+ addJsonAndDaemonHostOptions(addStopOptions(program.command("stop"))).action(
+ withOutput(runStopCommand),
+ );
- addJsonAndDaemonHostOptions(
- addDeleteOptions(program.command("delete")),
- ).action(withOutput(runDeleteCommand));
+ addJsonAndDaemonHostOptions(addDeleteOptions(program.command("delete"))).action(
+ withOutput(runDeleteCommand),
+ );
- addJsonAndDaemonHostOptions(
- addSendOptions(program.command("send")),
- ).action(withOutput(runSendCommand));
+ addJsonAndDaemonHostOptions(addSendOptions(program.command("send"))).action(
+ withOutput(runSendCommand),
+ );
- addJsonAndDaemonHostOptions(
- addInspectOptions(program.command("inspect")),
- ).action(withOutput(runInspectCommand));
+ addJsonAndDaemonHostOptions(addInspectOptions(program.command("inspect"))).action(
+ withOutput(runInspectCommand),
+ );
- addJsonAndDaemonHostOptions(
- addWaitOptions(program.command("wait")),
- ).action(withOutput(runWaitCommand));
+ addJsonAndDaemonHostOptions(addWaitOptions(program.command("wait"))).action(
+ withOutput(runWaitCommand),
+ );
- addJsonAndDaemonHostOptions(
- addArchiveOptions(program.command("archive")),
- ).action(withOutput(runArchiveCommand));
+ addJsonAndDaemonHostOptions(addArchiveOptions(program.command("archive"))).action(
+ withOutput(runArchiveCommand),
+ );
// Top-level local daemon shortcuts
program.addCommand(onboardCommand());
diff --git a/packages/cli/src/commands/agent/archive.ts b/packages/cli/src/commands/agent/archive.ts
index 6dc2a57a2..0219eed80 100644
--- a/packages/cli/src/commands/agent/archive.ts
+++ b/packages/cli/src/commands/agent/archive.ts
@@ -26,7 +26,7 @@ export const archiveSchema: OutputSchema = {
export function addArchiveOptions(cmd: Command): Command {
return cmd
- .description('Archive an agent (soft-delete)')
+ .description("Archive an agent (soft-delete)")
.argument("", "Agent ID, prefix, or name")
.option("--force", "Force archive running agent (interrupts active run first)");
}
diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts
index 329f0403d..69afbaf7d 100644
--- a/packages/cli/src/commands/agent/index.ts
+++ b/packages/cli/src/commands/agent/index.ts
@@ -10,6 +10,7 @@ import { addSendOptions, runSendCommand } from "./send.js";
import { addInspectOptions, runInspectCommand } from "./inspect.js";
import { addWaitOptions, runWaitCommand } from "./wait.js";
import { addAttachOptions, runAttachCommand } from "./attach.js";
+import { addReloadOptions, runReloadCommand } from "./reload.js";
import { runUpdateCommand } from "./update.js";
import { withOutput } from "../../output/index.js";
import {
@@ -22,41 +23,35 @@ export function createAgentCommand(): Command {
const agent = new Command("agent").description("Manage agents (advanced operations)");
// Primary agent commands (same as top-level)
- addJsonAndDaemonHostOptions(
- addLsOptions(agent.command("ls")),
- ).action(withOutput(runLsCommand));
+ addJsonAndDaemonHostOptions(addLsOptions(agent.command("ls"))).action(withOutput(runLsCommand));
- addJsonAndDaemonHostOptions(
- addRunOptions(agent.command("run")),
- ).action(withOutput(runRunCommand));
+ addJsonAndDaemonHostOptions(addRunOptions(agent.command("run"))).action(
+ withOutput(runRunCommand),
+ );
- addDaemonHostOption(
- addAttachOptions(agent.command("attach")),
- ).action(runAttachCommand);
+ addDaemonHostOption(addAttachOptions(agent.command("attach"))).action(runAttachCommand);
- addDaemonHostOption(
- addLogsOptions(agent.command("logs")),
- ).action(runLogsCommand);
+ addDaemonHostOption(addLogsOptions(agent.command("logs"))).action(runLogsCommand);
- addJsonAndDaemonHostOptions(
- addStopOptions(agent.command("stop")),
- ).action(withOutput(runStopCommand));
+ addJsonAndDaemonHostOptions(addStopOptions(agent.command("stop"))).action(
+ withOutput(runStopCommand),
+ );
- addJsonAndDaemonHostOptions(
- addDeleteOptions(agent.command("delete")),
- ).action(withOutput(runDeleteCommand));
+ addJsonAndDaemonHostOptions(addDeleteOptions(agent.command("delete"))).action(
+ withOutput(runDeleteCommand),
+ );
- addJsonAndDaemonHostOptions(
- addSendOptions(agent.command("send")),
- ).action(withOutput(runSendCommand));
+ addJsonAndDaemonHostOptions(addSendOptions(agent.command("send"))).action(
+ withOutput(runSendCommand),
+ );
- addJsonAndDaemonHostOptions(
- addInspectOptions(agent.command("inspect")),
- ).action(withOutput(runInspectCommand));
+ addJsonAndDaemonHostOptions(addInspectOptions(agent.command("inspect"))).action(
+ withOutput(runInspectCommand),
+ );
- addJsonAndDaemonHostOptions(
- addWaitOptions(agent.command("wait")),
- ).action(withOutput(runWaitCommand));
+ addJsonAndDaemonHostOptions(addWaitOptions(agent.command("wait"))).action(
+ withOutput(runWaitCommand),
+ );
// Advanced agent commands (less common operations)
addJsonAndDaemonHostOptions(
@@ -68,9 +63,13 @@ export function createAgentCommand(): Command {
.option("--list", "List available modes for this agent"),
).action(withOutput(runModeCommand));
- addJsonAndDaemonHostOptions(
- addArchiveOptions(agent.command("archive")),
- ).action(withOutput(runArchiveCommand));
+ addJsonAndDaemonHostOptions(addArchiveOptions(agent.command("archive"))).action(
+ withOutput(runArchiveCommand),
+ );
+
+ addJsonAndDaemonHostOptions(addReloadOptions(agent.command("reload"))).action(
+ withOutput(runReloadCommand),
+ );
addJsonAndDaemonHostOptions(
agent
diff --git a/packages/cli/src/commands/agent/reload.ts b/packages/cli/src/commands/agent/reload.ts
new file mode 100644
index 000000000..7693f3b5c
--- /dev/null
+++ b/packages/cli/src/commands/agent/reload.ts
@@ -0,0 +1,106 @@
+import { Command } from "commander";
+import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
+import type {
+ CommandOptions,
+ SingleResult,
+ OutputSchema,
+ CommandError,
+} from "../../output/index.js";
+
+export interface AgentReloadResult {
+ agentId: string;
+ status: "reloaded";
+ timelineSize: number;
+}
+
+export const reloadSchema: OutputSchema = {
+ idField: "agentId",
+ columns: [
+ { header: "AGENT ID", field: "agentId" },
+ { header: "STATUS", field: "status" },
+ { header: "TIMELINE", field: "timelineSize" },
+ ],
+};
+
+export function addReloadOptions(cmd: Command): Command {
+ return cmd
+ .description("Reload an agent (restarts the underlying process)")
+ .argument("", "Agent ID, prefix, or name");
+}
+
+export interface AgentReloadOptions extends CommandOptions {
+ host?: string;
+}
+
+export type AgentReloadCommandResult = SingleResult;
+
+export async function runReloadCommand(
+ agentIdArg: string,
+ options: AgentReloadOptions,
+ _command: Command,
+): Promise {
+ const host = getDaemonHost({ host: options.host as string | undefined });
+
+ if (!agentIdArg || agentIdArg.trim().length === 0) {
+ const error: CommandError = {
+ code: "MISSING_AGENT_ID",
+ message: "Agent ID is required",
+ details: "Usage: paseo agent reload ",
+ };
+ throw error;
+ }
+
+ let client;
+ try {
+ client = await connectToDaemon({ host: options.host as string | undefined });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const error: CommandError = {
+ code: "DAEMON_NOT_RUNNING",
+ message: `Cannot connect to daemon at ${host}: ${message}`,
+ details: "Start the daemon with: paseo daemon start",
+ };
+ throw error;
+ }
+
+ try {
+ const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
+ const agents = agentsPayload.entries.map((entry) => entry.agent);
+ const agentId = resolveAgentId(agentIdArg, agents);
+ if (!agentId) {
+ const error: CommandError = {
+ code: "AGENT_NOT_FOUND",
+ message: `Agent not found: ${agentIdArg}`,
+ details: 'Use "paseo ls" to list available agents',
+ };
+ throw error;
+ }
+
+ const result = await client.refreshAgent(agentId);
+
+ await client.close();
+
+ return {
+ type: "single",
+ data: {
+ agentId: result.agentId,
+ status: "reloaded",
+ timelineSize: result.timelineSize ?? 0,
+ },
+ schema: reloadSchema,
+ };
+ } catch (err) {
+ await client.close().catch(() => {});
+
+ if (err && typeof err === "object" && "code" in err) {
+ throw err;
+ }
+
+ const message = err instanceof Error ? err.message : String(err);
+ const error: CommandError = {
+ code: "RELOAD_FAILED",
+ message: `Failed to reload agent: ${message}`,
+ };
+ throw error;
+ }
+}
diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts
index fd7f90604..00474f41a 100644
--- a/packages/cli/src/commands/agent/run.ts
+++ b/packages/cli/src/commands/agent/run.ts
@@ -1,4 +1,4 @@
-import { Command } from "commander";
+import { Command, Option } from "commander";
import {
getStructuredAgentResponse,
StructuredAgentResponseError,
@@ -22,7 +22,8 @@ export function addRunOptions(cmd: Command): Command {
.description("Create and start an agent with a task")
.argument("", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
- .option("--name ", "Assign a name/title to the agent")
+ .option("--title ", "Assign a title to the agent")
+ .addOption(new Option("--name ", "Hidden alias for --title").hideHelp())
.option(
"--provider ",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
@@ -82,6 +83,7 @@ export const agentRunSchema: OutputSchema = {
export interface AgentRunOptions extends CommandOptions {
detach?: boolean;
+ title?: string;
name?: string;
provider?: string;
model?: string;
@@ -324,6 +326,7 @@ export async function runRunCommand(
}
const resolvedProviderModel = resolveProviderAndModel(options);
+ const resolvedTitle = options.title ?? options.name;
let client;
try {
@@ -413,7 +416,7 @@ export async function runRunCommand(
structuredAgent = await client.createAgent({
provider: resolvedProviderModel.provider,
cwd,
- title: options.name,
+ title: resolvedTitle,
modeId: options.mode,
model: resolvedProviderModel.model,
thinkingOptionId,
@@ -512,7 +515,7 @@ export async function runRunCommand(
const agent = await client.createAgent({
provider: resolvedProviderModel.provider,
cwd,
- title: options.name,
+ title: resolvedTitle,
modeId: options.mode,
model: resolvedProviderModel.model,
thinkingOptionId,
diff --git a/packages/cli/src/commands/chat/index.ts b/packages/cli/src/commands/chat/index.ts
index 9b6f363e9..197b5844e 100644
--- a/packages/cli/src/commands/chat/index.ts
+++ b/packages/cli/src/commands/chat/index.ts
@@ -20,9 +20,9 @@ export function createChatCommand(): Command {
.option("--purpose ", "Room purpose/description"),
).action(withOutput(runCreateCommand));
- addJsonAndDaemonHostOptions(
- chat.command("ls").description("List chat rooms"),
- ).action(withOutput(runLsCommand));
+ addJsonAndDaemonHostOptions(chat.command("ls").description("List chat rooms")).action(
+ withOutput(runLsCommand),
+ );
addJsonAndDaemonHostOptions(
chat
diff --git a/packages/cli/src/commands/chat/post.ts b/packages/cli/src/commands/chat/post.ts
index 03339d160..58c63afa7 100644
--- a/packages/cli/src/commands/chat/post.ts
+++ b/packages/cli/src/commands/chat/post.ts
@@ -25,7 +25,9 @@ export async function runPostCommand(
body,
replyToMessageId: options.replyTo,
});
- const [message] = await attachAgentNamesToMessages(client, [toChatMessageRow(payload.message!)]);
+ const [message] = await attachAgentNamesToMessages(client, [
+ toChatMessageRow(payload.message!),
+ ]);
return {
type: "single",
data: message!,
diff --git a/packages/cli/src/commands/chat/schema.ts b/packages/cli/src/commands/chat/schema.ts
index aa1209d1f..269c8d998 100644
--- a/packages/cli/src/commands/chat/schema.ts
+++ b/packages/cli/src/commands/chat/schema.ts
@@ -60,7 +60,9 @@ function renderChatMessageBlock(message: ChatMessageRow): string {
const authorLabel = message.authorName
? `${message.authorName} (${message.author})`
: message.author;
- const lines = [`┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`];
+ const lines = [
+ `┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`,
+ ];
if (message.replyTo !== "-") {
lines.push(`│ reply-to: msg ${message.replyTo}`);
diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts
index 7643bc549..5cb4f5b80 100644
--- a/packages/cli/src/commands/daemon/index.ts
+++ b/packages/cli/src/commands/daemon/index.ts
@@ -34,6 +34,7 @@ export function createDaemonCommand(): Command {
.option("--port ", "Port for restarted daemon listen target")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
+ .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option(
"--allowed-hosts ",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts
index 957ca64ce..7d00a3271 100644
--- a/packages/cli/src/commands/daemon/local-daemon.ts
+++ b/packages/cli/src/commands/daemon/local-daemon.ts
@@ -12,6 +12,7 @@ export interface DaemonStartOptions {
foreground?: boolean;
relay?: boolean;
mcp?: boolean;
+ injectMcp?: boolean;
allowedHosts?: string;
}
@@ -95,6 +96,9 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] {
if (options.mcp === false) {
args.push("--no-mcp");
}
+ if (options.injectMcp === false) {
+ args.push("--no-inject-mcp");
+ }
return args;
}
@@ -125,12 +129,7 @@ function resolveDaemonRunnerEntry(): string {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
if (packageJson.name === "@getpaseo/server") {
- const distRunner = path.join(
- currentDir,
- "dist",
- "scripts",
- "supervisor-entrypoint.js",
- );
+ const distRunner = path.join(currentDir, "dist", "scripts", "supervisor-entrypoint.js");
if (existsSync(distRunner)) {
return distRunner;
}
diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts
index 18b07fbed..82df597f5 100644
--- a/packages/cli/src/commands/daemon/restart.ts
+++ b/packages/cli/src/commands/daemon/restart.ts
@@ -60,6 +60,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions {
port: typeof options.port === "string" ? options.port : undefined,
relay: typeof options.relay === "boolean" ? options.relay : undefined,
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
+ injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
};
diff --git a/packages/cli/src/commands/daemon/runtime-toolchain.ts b/packages/cli/src/commands/daemon/runtime-toolchain.ts
index 6b4184761..3b41d020f 100644
--- a/packages/cli/src/commands/daemon/runtime-toolchain.ts
+++ b/packages/cli/src/commands/daemon/runtime-toolchain.ts
@@ -32,10 +32,15 @@ function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
}
const resolved = result.stdout.trim();
- return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" };
+ return resolved
+ ? { nodePath: resolved }
+ : { nodePath: null, error: "ps returned an empty command path" };
}
-function runProcessProbe(command: string, args: string[]): {
+function runProcessProbe(
+ command: string,
+ args: string[],
+): {
resolved: string | null;
error?: string;
} {
@@ -52,16 +57,25 @@ function runProcessProbe(command: string, args: string[]): {
const details = result.stderr?.trim();
return {
resolved: null,
- error: details ? `${command} failed: ${details}` : `${command} exited with code ${result.status ?? 1}`,
+ error: details
+ ? `${command} failed: ${details}`
+ : `${command} exited with code ${result.status ?? 1}`,
};
}
const resolved = result.stdout.trim();
- return resolved ? { resolved } : { resolved: null, error: `${command} returned no executable path` };
+ return resolved
+ ? { resolved }
+ : { resolved: null, error: `${command} returned no executable path` };
}
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
- const probes: Array<{ label: string; command: string; args: string[]; parseValue?: (stdout: string) => string | null }> = [
+ const probes: Array<{
+ label: string;
+ command: string;
+ args: string[];
+ parseValue?: (stdout: string) => string | null;
+ }> = [
{
label: "powershell-cim",
command: "powershell",
@@ -103,7 +117,10 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
}
}
- return { nodePath: null, error: errors.join("; ") || "could not resolve executable path from PID" };
+ return {
+ nodePath: null,
+ error: errors.join("; ") || "could not resolve executable path from PID",
+ };
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts
index 5c0111f13..1e5cd2264 100644
--- a/packages/cli/src/commands/daemon/start.ts
+++ b/packages/cli/src/commands/daemon/start.ts
@@ -18,6 +18,7 @@ export function startCommand(): Command {
.option("--foreground", "Run in foreground (don't daemonize)")
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
+ .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option(
"--allowed-hosts ",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts
index aa3ec6cc1..49a582873 100644
--- a/packages/cli/src/commands/daemon/status.ts
+++ b/packages/cli/src/commands/daemon/status.ts
@@ -2,11 +2,7 @@ import type { Command } from "commander";
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { promisify } from "node:util";
-import {
- getOrCreateServerId,
- findExecutable,
- applyProviderEnv,
-} from "@getpaseo/server";
+import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server";
const execFileAsync = promisify(execFile);
import { tryConnectToDaemon } from "../../utils/client.js";
@@ -173,7 +169,9 @@ const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: "OpenCode", binary: "opencode" },
];
-async function checkProviderBinary(binary: string): Promise<{ path: string | null; version: string | null }> {
+async function checkProviderBinary(
+ binary: string,
+): Promise<{ path: string | null; version: string | null }> {
const binaryPath = await findExecutable(binary);
if (!binaryPath) {
return { path: null, version: null };
diff --git a/packages/cli/src/commands/loop/index.ts b/packages/cli/src/commands/loop/index.ts
index 64e975517..95359ce59 100644
--- a/packages/cli/src/commands/loop/index.ts
+++ b/packages/cli/src/commands/loop/index.ts
@@ -10,25 +10,23 @@ import { addLoopStopOptions, runLoopStopCommand } from "./stop.js";
export function createLoopCommand(): Command {
const loop = new Command("loop").description("Run iterative worker loops");
- addJsonAndDaemonHostOptions(
- addLoopRunOptions(loop.command("run")),
- ).action(withOutput(runLoopRunCommand));
+ addJsonAndDaemonHostOptions(addLoopRunOptions(loop.command("run"))).action(
+ withOutput(runLoopRunCommand),
+ );
- addJsonAndDaemonHostOptions(
- addLoopLsOptions(loop.command("ls")),
- ).action(withOutput(runLoopLsCommand));
+ addJsonAndDaemonHostOptions(addLoopLsOptions(loop.command("ls"))).action(
+ withOutput(runLoopLsCommand),
+ );
- addJsonAndDaemonHostOptions(
- addLoopInspectOptions(loop.command("inspect")),
- ).action(withOutput(runLoopInspectCommand));
+ addJsonAndDaemonHostOptions(addLoopInspectOptions(loop.command("inspect"))).action(
+ withOutput(runLoopInspectCommand),
+ );
- addDaemonHostOption(
- addLoopLogsOptions(loop.command("logs")),
- ).action(runLoopLogsCommand);
+ addDaemonHostOption(addLoopLogsOptions(loop.command("logs"))).action(runLoopLogsCommand);
- addJsonAndDaemonHostOptions(
- addLoopStopOptions(loop.command("stop")),
- ).action(withOutput(runLoopStopCommand));
+ addJsonAndDaemonHostOptions(addLoopStopOptions(loop.command("stop"))).action(
+ withOutput(runLoopStopCommand),
+ );
return loop;
}
diff --git a/packages/cli/src/commands/loop/inspect.ts b/packages/cli/src/commands/loop/inspect.ts
index 8f571a0ee..a2b9812be 100644
--- a/packages/cli/src/commands/loop/inspect.ts
+++ b/packages/cli/src/commands/loop/inspect.ts
@@ -1,11 +1,6 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
-import type {
- CommandOptions,
- CommandError,
- OutputSchema,
- ListResult,
-} from "../../output/index.js";
+import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface InspectRow {
@@ -44,10 +39,16 @@ function toRows(loop: LoopRecord): InspectRow[] {
{ key: "VerifierModel", value: loop.verifierModel ?? "null" },
{ key: "Prompt", value: loop.prompt },
{ key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" },
- { key: "VerifyChecks", value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]" },
+ {
+ key: "VerifyChecks",
+ value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]",
+ },
{ key: "Archive", value: String(loop.archive) },
{ key: "SleepMs", value: String(loop.sleepMs) },
- { key: "MaxIterations", value: loop.maxIterations === null ? "null" : String(loop.maxIterations) },
+ {
+ key: "MaxIterations",
+ value: loop.maxIterations === null ? "null" : String(loop.maxIterations),
+ },
{ key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) },
{ key: "CreatedAt", value: loop.createdAt },
{ key: "UpdatedAt", value: loop.updatedAt },
diff --git a/packages/cli/src/commands/loop/ls.ts b/packages/cli/src/commands/loop/ls.ts
index 3c0051244..95d068586 100644
--- a/packages/cli/src/commands/loop/ls.ts
+++ b/packages/cli/src/commands/loop/ls.ts
@@ -1,11 +1,6 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
-import type {
- CommandOptions,
- CommandError,
- OutputSchema,
- ListResult,
-} from "../../output/index.js";
+import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js";
import type { LoopDaemonClient, LoopListItem } from "./types.js";
interface LoopListRow {
diff --git a/packages/cli/src/commands/schedule/delete.ts b/packages/cli/src/commands/schedule/delete.ts
index 05ea5e933..f1f4ba2ca 100644
--- a/packages/cli/src/commands/schedule/delete.ts
+++ b/packages/cli/src/commands/schedule/delete.ts
@@ -1,7 +1,11 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import type { OutputSchema } from "../../output/index.js";
-import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
+import {
+ connectScheduleClient,
+ toScheduleCommandError,
+ type ScheduleCommandOptions,
+} from "./shared.js";
interface ScheduleDeleteRow {
id: string;
diff --git a/packages/cli/src/commands/schedule/index.ts b/packages/cli/src/commands/schedule/index.ts
index 1a02d683b..ba85c57cf 100644
--- a/packages/cli/src/commands/schedule/index.ts
+++ b/packages/cli/src/commands/schedule/index.ts
@@ -25,15 +25,12 @@ export function createScheduleCommand(): Command {
.option("--expires-in ", "Time to live for the schedule"),
).action(withOutput(runCreateCommand));
- addJsonAndDaemonHostOptions(
- schedule.command("ls").description("List schedules"),
- ).action(withOutput(runLsCommand));
+ addJsonAndDaemonHostOptions(schedule.command("ls").description("List schedules")).action(
+ withOutput(runLsCommand),
+ );
addJsonAndDaemonHostOptions(
- schedule
- .command("inspect")
- .description("Inspect a schedule")
- .argument("", "Schedule ID"),
+ schedule.command("inspect").description("Inspect a schedule").argument("", "Schedule ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
@@ -44,10 +41,7 @@ export function createScheduleCommand(): Command {
).action(withOutput(runLogsCommand));
addJsonAndDaemonHostOptions(
- schedule
- .command("pause")
- .description("Pause a schedule")
- .argument("", "Schedule ID"),
+ schedule.command("pause").description("Pause a schedule").argument("", "Schedule ID"),
).action(withOutput(runPauseCommand));
addJsonAndDaemonHostOptions(
@@ -58,10 +52,7 @@ export function createScheduleCommand(): Command {
).action(withOutput(runResumeCommand));
addJsonAndDaemonHostOptions(
- schedule
- .command("delete")
- .description("Delete a schedule")
- .argument("", "Schedule ID"),
+ schedule.command("delete").description("Delete a schedule").argument("", "Schedule ID"),
).action(withOutput(runDeleteCommand));
return schedule;
diff --git a/packages/cli/src/commands/schedule/inspect.ts b/packages/cli/src/commands/schedule/inspect.ts
index de968e6a4..d9044382f 100644
--- a/packages/cli/src/commands/schedule/inspect.ts
+++ b/packages/cli/src/commands/schedule/inspect.ts
@@ -5,7 +5,11 @@ import {
createScheduleInspectSchema,
type ScheduleInspectRow,
} from "./schema.js";
-import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
+import {
+ connectScheduleClient,
+ toScheduleCommandError,
+ type ScheduleCommandOptions,
+} from "./shared.js";
export async function runInspectCommand(
id: string,
diff --git a/packages/cli/src/commands/schedule/logs.ts b/packages/cli/src/commands/schedule/logs.ts
index d10251063..b411822d9 100644
--- a/packages/cli/src/commands/schedule/logs.ts
+++ b/packages/cli/src/commands/schedule/logs.ts
@@ -1,7 +1,11 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js";
-import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
+import {
+ connectScheduleClient,
+ toScheduleCommandError,
+ type ScheduleCommandOptions,
+} from "./shared.js";
export async function runLogsCommand(
id: string,
diff --git a/packages/cli/src/commands/schedule/schema.ts b/packages/cli/src/commands/schedule/schema.ts
index cc64b7985..bf3383d73 100644
--- a/packages/cli/src/commands/schedule/schema.ts
+++ b/packages/cli/src/commands/schedule/schema.ts
@@ -19,7 +19,9 @@ export interface ScheduleInspectRow {
value: string;
}
-export function createScheduleInspectSchema(record: ScheduleRecord): OutputSchema {
+export function createScheduleInspectSchema(
+ record: ScheduleRecord,
+): OutputSchema {
return {
idField: "key",
columns: [
diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts
index 7946c7ec4..a9d3c93e5 100644
--- a/packages/cli/src/commands/schedule/shared.ts
+++ b/packages/cli/src/commands/schedule/shared.ts
@@ -33,11 +33,7 @@ export async function connectScheduleClient(
}
}
-export function toScheduleCommandError(
- code: string,
- action: string,
- error: unknown,
-): CommandError {
+export function toScheduleCommandError(code: string, action: string, error: unknown): CommandError {
if (error && typeof error === "object" && "code" in error) {
return error as CommandError;
}
diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts
index 7f47bee18..b3a6e518d 100644
--- a/packages/cli/src/commands/terminal/ls.ts
+++ b/packages/cli/src/commands/terminal/ls.ts
@@ -20,7 +20,8 @@ export async function runLsCommand(
const cwd = options.all ? undefined : (options.cwd ?? process.cwd());
try {
- const payload = cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd);
+ const payload =
+ cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd);
return {
type: "list",
data: payload.terminals.map((terminal) => toTerminalRow(terminal, payload.cwd ?? cwd)),
diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts
index 22ce604d9..956c40c02 100644
--- a/packages/cli/src/utils/client.ts
+++ b/packages/cli/src/utils/client.ts
@@ -10,7 +10,7 @@ export interface ConnectOptions {
}
const DEFAULT_HOST = "localhost:6767";
-const DEFAULT_TIMEOUT = 5000;
+const DEFAULT_TIMEOUT = 15000;
const PID_FILENAME = "paseo.pid";
type DaemonTarget =
@@ -207,6 +207,7 @@ export async function connectToDaemon(options?: ConnectOptions): Promise }) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
@@ -216,23 +217,11 @@ export async function connectToDaemon(options?: ConnectOptions): Promise[0]);
const connectPromise = client.connect();
- let timeoutHandle: ReturnType | null = null;
- const timeoutPromise = new Promise((_, reject) => {
- timeoutHandle = setTimeout(() => {
- reject(new Error(`Connection timeout after ${timeout}ms`));
- }, timeout);
- });
try {
- await Promise.race([connectPromise, timeoutPromise]);
- if (timeoutHandle) {
- clearTimeout(timeoutHandle);
- }
+ await connectPromise;
return client;
} catch (err) {
- if (timeoutHandle) {
- clearTimeout(timeoutHandle);
- }
lastError = err;
await client.close().catch(() => {});
}
diff --git a/packages/cli/src/utils/paths.ts b/packages/cli/src/utils/paths.ts
index e8339e1e6..7e6e27a4e 100644
--- a/packages/cli/src/utils/paths.ts
+++ b/packages/cli/src/utils/paths.ts
@@ -21,7 +21,6 @@ export function isSameOrDescendantPath(basePath: string, candidatePath: string):
}
return (
- normalizedCandidate === normalizedBase ||
- normalizedCandidate.startsWith(normalizedBase + "/")
+ normalizedCandidate === normalizedBase || normalizedCandidate.startsWith(normalizedBase + "/")
);
}
diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts
index f30600a81..d7bca6aec 100644
--- a/packages/cli/tests/03-daemon.test.ts
+++ b/packages/cli/tests/03-daemon.test.ts
@@ -60,7 +60,7 @@ try {
const result = await daemonCommand(["status"]);
assert.strictEqual(result.exitCode, 0, "status should succeed when daemon is stopped");
const output = result.stdout.toLowerCase();
- assert(output.includes("status"), "status table should include Status row");
+ assert(output.includes("local daemon"), "status table should include Local Daemon row");
assert(output.includes("stopped"), "status should report stopped");
console.log("✓ daemon status reports stopped when not running\n");
}
@@ -84,7 +84,7 @@ try {
assert.strictEqual(result.exitCode, 0, "--json status should succeed");
const status = JSON.parse(result.stdout);
assert.strictEqual(typeof status.serverId, "string", "json status should include serverId");
- assert.strictEqual(status.status, "stopped", "json status should report stopped");
+ assert.strictEqual(status.localDaemon, "stopped", "json status should report stopped");
assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home");
assert.strictEqual(
status.hostname,
diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts
index 5761f3290..6757f959b 100644
--- a/packages/cli/tests/05-agent-run.test.ts
+++ b/packages/cli/tests/05-agent-run.test.ts
@@ -54,7 +54,7 @@ try {
assert.strictEqual(result.exitCode, 0, "run --help should exit 0");
assert(result.stdout.includes("-d"), "help should mention -d flag");
assert(result.stdout.includes("--detach"), "help should mention --detach flag");
- assert(result.stdout.includes("--name"), "help should mention --name option");
+ assert(result.stdout.includes("--title"), "help should mention --title option");
assert(result.stdout.includes("--provider"), "help should mention --provider option");
assert(result.stdout.includes("--mode"), "help should mention --mode option");
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts
index dda23cc66..c51f1d6a1 100644
--- a/packages/cli/tests/15-provider.test.ts
+++ b/packages/cli/tests/15-provider.test.ts
@@ -31,6 +31,11 @@ type ProviderModel = {
};
const EXPECTED_CLAUDE_MODELS = [
+ {
+ id: "claude-opus-4-6[1m]",
+ model: "Opus 4.6 1M",
+ descriptionFragment: "1M context window",
+ },
{
id: "claude-sonnet-4-6",
model: "Sonnet 4.6",
@@ -53,9 +58,7 @@ let claudeModelsFromJson: ProviderModel[] = [];
const ctx = await createE2ETestContext({ timeout: 120000 });
-async function runProviderModelsJson(
- provider: string,
-): Promise {
+async function runProviderModelsJson(provider: string): Promise {
const transientNeedles = ["transport closed", "timed out", "timeout", "socket", "econn"];
for (let attempt = 1; attempt <= 3; attempt++) {
@@ -137,7 +140,7 @@ try {
assert.strictEqual(result.exitCode, 0, "should exit 0");
const data = JSON.parse(result.stdout.trim());
assert(Array.isArray(data), "output should be an array");
- assert.strictEqual(data.length, 3, "should have 3 providers");
+ assert.strictEqual(data.length, 5, "should have 5 providers");
assert(
data.some((p: { provider: string }) => p.provider === "claude"),
"should include claude",
@@ -150,6 +153,14 @@ try {
data.some((p: { provider: string }) => p.provider === "opencode"),
"should include opencode",
);
+ assert(
+ data.some((p: { provider: string }) => p.provider === "copilot"),
+ "should include copilot",
+ );
+ assert(
+ data.some((p: { provider: string }) => p.provider === "pi"),
+ "should include pi",
+ );
console.log("✓ provider ls --json outputs valid JSON\n");
}
@@ -159,10 +170,12 @@ try {
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const lines = result.stdout.trim().split("\n");
- assert.strictEqual(lines.length, 3, "should have 3 lines");
+ assert.strictEqual(lines.length, 5, "should have 5 lines");
assert(lines.includes("claude"), "should include claude");
assert(lines.includes("codex"), "should include codex");
assert(lines.includes("opencode"), "should include opencode");
+ assert(lines.includes("copilot"), "should include copilot");
+ assert(lines.includes("pi"), "should include pi");
console.log("✓ provider ls --quiet outputs provider names only\n");
}
@@ -178,13 +191,21 @@ try {
{
console.log("Test 6: provider models codex includes concrete codex model IDs");
const data = await runProviderModelsJson("codex");
- assert(data.length >= 6, "codex model list should include current codex lineup");
+ assert(data.length >= 1, "codex model list should not be empty");
const ids = data.map((m) => m.id);
assert.strictEqual(new Set(ids).size, ids.length, "codex model IDs should be unique");
- assert(ids.includes("gpt-5.3-codex"), "codex output should include gpt-5.3-codex");
- assert(ids.includes("gpt-5.3-codex-spark"), "codex output should include gpt-5.3-codex-spark");
- assert(ids.includes("gpt-5.1-codex-max"), "codex output should include gpt-5.1-codex-max");
- assert(ids.includes("gpt-5.1-codex-mini"), "codex output should include gpt-5.1-codex-mini");
+ assert(
+ ids.every((id) => id.startsWith("gpt-")),
+ "all codex model IDs should be from the gpt family",
+ );
+ assert(
+ ids.some((id) => id.includes("codex")),
+ "codex model list should include at least one codex-optimized model",
+ );
+ assert(
+ data.every((m) => m.model && m.id && m.description),
+ "every codex model should have model, id, and description fields",
+ );
console.log("✓ provider models codex includes concrete codex model IDs\n");
}
@@ -192,23 +213,19 @@ try {
{
console.log("Test 7: provider models opencode returns namespaced model IDs");
const data = await runProviderModelsJson("opencode");
- assert(data.length >= 3, "opencode model list should not be empty");
+ assert(data.length >= 1, "opencode model list should not be empty");
const ids = data.map((m) => m.id);
assert(
data.every((m) => m.id.includes("/")),
"opencode model IDs should be provider-namespaced",
);
assert(
- ids.includes("opencode/gpt-5-nano"),
- "opencode output should include opencode/gpt-5-nano",
+ ids.some((id) => id.startsWith("opencode/")),
+ "opencode output should include at least one first-party opencode model",
);
assert(
- ids.some((id) => id.startsWith("openrouter/openai/")),
- "opencode output should include OpenRouter OpenAI models",
- );
- assert(
- ids.includes("openrouter/openai/gpt-5.3-codex"),
- "opencode output should include openrouter/openai/gpt-5.3-codex",
+ data.every((m) => m.model && m.id && m.description !== undefined),
+ "every opencode model should have model, id, and description fields",
);
console.log("✓ provider models opencode returns namespaced model IDs\n");
}
diff --git a/packages/cli/tests/22-daemon-stop-supervisor.test.ts b/packages/cli/tests/22-daemon-stop-supervisor.test.ts
index c706a8ea8..c74902a17 100644
--- a/packages/cli/tests/22-daemon-stop-supervisor.test.ts
+++ b/packages/cli/tests/22-daemon-stop-supervisor.test.ts
@@ -69,7 +69,7 @@ function readProcessCommand(pid: number): string | null {
}
type DaemonStatus = {
- status: string | null;
+ localDaemon: string | null;
pid: number | null;
};
@@ -77,19 +77,19 @@ async function readDaemonStatus(paseoHome: string): Promise {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
- return { status: null, pid: null };
+ return { localDaemon: null, pid: null };
}
try {
- const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
- const status = typeof parsed.status === "string" ? parsed.status : null;
+ const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
+ const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null;
const pid =
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null;
- return { status, pid };
+ return { localDaemon, pid };
} catch {
- return { status: null, pid: null };
+ return { localDaemon: null, pid: null };
}
}
@@ -123,8 +123,8 @@ try {
console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME");
supervisorProcess = spawn(
- "npx",
- ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
+ process.execPath,
+ ["--import", "tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
{
cwd: cliRoot,
env: {
@@ -149,7 +149,9 @@ try {
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
- return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
+ return (
+ status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid)
+ );
},
120000,
"daemon did not become running in time",
@@ -157,7 +159,11 @@ try {
const statusBeforeStop = await readDaemonStatus(paseoHome);
const daemonPid = statusBeforeStop.pid;
- assert.strictEqual(statusBeforeStop.status, "running", "daemon should be running before stop");
+ assert.strictEqual(
+ statusBeforeStop.localDaemon,
+ "running",
+ "daemon should be running before stop",
+ );
assert(daemonPid !== null, "daemon pid should exist once daemon starts");
assert(isProcessRunning(daemonPid), "daemon process should be running");
const pidLockBeforeStop = await readPidLockState(paseoHome);
@@ -165,8 +171,7 @@ try {
const command = readProcessCommand(daemonPid);
assert(command !== null, "pid lock pid should resolve to a running process command");
assert(
- command.includes("supervisor-entrypoint.ts") ||
- command.includes("supervisor-entrypoint.js"),
+ command.includes("supervisor-entrypoint.ts") || command.includes("supervisor-entrypoint.js"),
`pid lock pid should be supervisor-entrypoint process, got: ${command}`,
);
console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`);
@@ -181,7 +186,7 @@ try {
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
- return status.status === "stopped";
+ return status.localDaemon === "stopped";
},
15000,
"daemon status did not transition to stopped after stop",
@@ -207,7 +212,7 @@ try {
const statusAfterStop = await readDaemonStatus(paseoHome);
assert.strictEqual(
- statusAfterStop.status,
+ statusAfterStop.localDaemon,
"stopped",
"daemon should remain stopped after stop command",
);
diff --git a/packages/cli/tests/23-daemon-sigint-supervisor.test.ts b/packages/cli/tests/23-daemon-sigint-supervisor.test.ts
index b7cbffb7c..76875c55a 100644
--- a/packages/cli/tests/23-daemon-sigint-supervisor.test.ts
+++ b/packages/cli/tests/23-daemon-sigint-supervisor.test.ts
@@ -62,7 +62,7 @@ function signalProcessGroup(pid: number, signal: NodeJS.Signals): boolean {
}
type DaemonStatus = {
- status: string | null;
+ localDaemon: string | null;
pid: number | null;
};
@@ -70,19 +70,19 @@ async function readDaemonStatus(paseoHome: string): Promise {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
- return { status: null, pid: null };
+ return { localDaemon: null, pid: null };
}
try {
- const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
- const status = typeof parsed.status === "string" ? parsed.status : null;
+ const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
+ const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null;
const pid =
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null;
- return { status, pid };
+ return { localDaemon, pid };
} catch {
- return { status: null, pid: null };
+ return { localDaemon: null, pid: null };
}
}
@@ -134,8 +134,8 @@ try {
console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME");
supervisorProcess = spawn(
- "npx",
- ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
+ process.execPath,
+ ["--import", "tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
{
cwd: cliRoot,
env: {
@@ -161,7 +161,9 @@ try {
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
- return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
+ return (
+ status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid)
+ );
},
120000,
"daemon did not become running in time",
@@ -187,7 +189,7 @@ try {
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
- return status.status === "stopped";
+ return status.localDaemon === "stopped";
},
15000,
"daemon status did not transition to stopped after SIGINT",
diff --git a/packages/cli/tests/25-daemon-restart-supervisor.test.ts b/packages/cli/tests/25-daemon-restart-supervisor.test.ts
index c1361dca4..f3ff2db31 100644
--- a/packages/cli/tests/25-daemon-restart-supervisor.test.ts
+++ b/packages/cli/tests/25-daemon-restart-supervisor.test.ts
@@ -70,7 +70,7 @@ function readWorkerPid(supervisorPid: number): number | null {
}
type DaemonStatus = {
- status: string | null;
+ localDaemon: string | null;
pid: number | null;
};
@@ -78,19 +78,19 @@ async function readDaemonStatus(paseoHome: string): Promise {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
- return { status: null, pid: null };
+ return { localDaemon: null, pid: null };
}
try {
- const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
- const status = typeof parsed.status === "string" ? parsed.status : null;
+ const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
+ const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null;
const pid =
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null;
- return { status, pid };
+ return { localDaemon, pid };
} catch {
- return { status: null, pid: null };
+ return { localDaemon: null, pid: null };
}
}
@@ -125,8 +125,8 @@ try {
console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME");
supervisorProcess = spawn(
- "npx",
- ["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
+ process.execPath,
+ ["--import", "tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
{
cwd: cliRoot,
env: {
@@ -151,7 +151,9 @@ try {
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
- return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
+ return (
+ status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid)
+ );
},
120000,
"daemon did not become running in time",
@@ -160,7 +162,7 @@ try {
const statusBeforeRestart = await readDaemonStatus(paseoHome);
const supervisorPid = statusBeforeRestart.pid;
assert.strictEqual(
- statusBeforeRestart.status,
+ statusBeforeRestart.localDaemon,
"running",
"daemon should be running before restart",
);
@@ -211,7 +213,7 @@ try {
const statusAfterRestart = await readDaemonStatus(paseoHome);
assert.strictEqual(
- statusAfterRestart.status,
+ statusAfterRestart.localDaemon,
"running",
"daemon should stay running after restart",
);
diff --git a/packages/cli/tests/26-daemon-restart-unsupervised.test.ts b/packages/cli/tests/26-daemon-restart-unsupervised.test.ts
index 172f1f752..d2f1f1d6a 100644
--- a/packages/cli/tests/26-daemon-restart-unsupervised.test.ts
+++ b/packages/cli/tests/26-daemon-restart-unsupervised.test.ts
@@ -10,12 +10,9 @@ import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { $ } from "zx";
import { tryConnectToDaemon } from "../src/utils/client.ts";
import { getAvailablePort } from "./helpers/network.ts";
-$.verbose = false;
-
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
@@ -40,31 +37,6 @@ function isProcessRunning(pid: number): boolean {
}
}
-type DaemonStatus = {
- status: string | null;
- pid: number | null;
-};
-
-async function readDaemonStatus(paseoHome: string): Promise {
- const result =
- await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
- if (result.exitCode !== 0) {
- return { status: null, pid: null };
- }
-
- try {
- const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
- const status = typeof parsed.status === "string" ? parsed.status : null;
- const pid =
- typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
- ? parsed.pid
- : null;
- return { status, pid };
- } catch {
- return { status: null, pid: null };
- }
-}
-
async function waitFor(
check: () => Promise | boolean,
timeoutMs: number,
@@ -100,6 +72,21 @@ function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promis
});
}
+async function canConnectToDaemon(host: string, timeoutMs: number): Promise {
+ const deadline = Date.now() + timeoutMs;
+
+ while (Date.now() < deadline) {
+ const client = await tryConnectToDaemon({ host, timeout: 500 }).catch(() => null);
+ if (client) {
+ await client.close().catch(() => undefined);
+ return true;
+ }
+ await sleep(pollIntervalMs);
+ }
+
+ return false;
+}
+
async function readPidLockPid(paseoHome: string): Promise {
const pidPath = join(paseoHome, "paseo.pid");
try {
@@ -122,48 +109,40 @@ const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
let daemonProcess: ChildProcess | null = null;
+let recentDaemonLogs = "";
try {
console.log("Test 1: start unsupervised daemon worker directly");
- daemonProcess = spawn(
- process.execPath,
- [...process.execArgv, "--import", "tsx", "../server/src/server/index.ts"],
- {
- cwd: cliRoot,
- env: {
- ...process.env,
- ...testEnv,
- PASEO_HOME: paseoHome,
- PASEO_LISTEN: host,
- PASEO_RELAY_ENABLED: "false",
- CI: "true",
- },
- stdio: ["ignore", "pipe", "pipe"],
+ daemonProcess = spawn(process.execPath, ["--import", "tsx", "../server/src/server/index.ts"], {
+ cwd: cliRoot,
+ env: {
+ ...process.env,
+ ...testEnv,
+ PASEO_HOME: paseoHome,
+ PASEO_LISTEN: host,
+ PASEO_RELAY_ENABLED: "false",
+ CI: "true",
},
- );
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ daemonProcess.stdout?.on("data", (chunk) => {
+ recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
+ });
+ daemonProcess.stderr?.on("data", (chunk) => {
+ recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
+ });
await waitFor(
- async () => {
- const status = await readDaemonStatus(paseoHome);
- return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
- },
+ async () =>
+ Boolean(daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) &&
+ (await canConnectToDaemon(host, 1000)),
120000,
"daemon did not become running in time",
);
- const statusBeforeRestart = await readDaemonStatus(paseoHome);
- assert.strictEqual(
- statusBeforeRestart.status,
- "running",
- "daemon should be running before restart",
- );
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
- assert.strictEqual(
- statusBeforeRestart.pid,
- daemonProcess.pid,
- "status pid should match daemon process pid",
- );
const lockPid = await readPidLockPid(paseoHome);
assert.strictEqual(lockPid, daemonProcess.pid, "unsupervised worker should own pid lock");
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`);
@@ -186,15 +165,16 @@ try {
const exit = await exitPromise;
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`);
- assert.strictEqual(exit.code, 0, `daemon should exit with status 0, got code=${exit.code}`);
+ assert.strictEqual(
+ exit.code,
+ 0,
+ `daemon should exit with status 0, got code=${exit.code}\nRecent daemon logs:\n${recentDaemonLogs}`,
+ );
await waitFor(
- async () => {
- const status = await readDaemonStatus(paseoHome);
- return status.status === "stopped";
- },
+ async () => (await readPidLockPid(paseoHome)) === null,
15000,
- "daemon status did not transition to stopped after unsupervised restart request",
+ "pid lock was not released after unsupervised restart request",
);
console.log("✓ unsupervised restart exited cleanly with code 0\n");
diff --git a/packages/cli/tests/30-chat.test.ts b/packages/cli/tests/30-chat.test.ts
index 11df5a603..6b741d480 100644
--- a/packages/cli/tests/30-chat.test.ts
+++ b/packages/cli/tests/30-chat.test.ts
@@ -27,12 +27,7 @@ try {
{
console.log("Test 2: chat post/read/wait work");
- const posted = await ctx.paseo([
- "chat",
- "post",
- "coord-room",
- "first message for @agent-1",
- ], {
+ const posted = await ctx.paseo(["chat", "post", "coord-room", "first message for @agent-1"], {
env: { PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000111" },
});
assert.strictEqual(posted.exitCode, 0, posted.stderr);
diff --git a/packages/cli/tests/31-loop-schedule.test.ts b/packages/cli/tests/31-loop-schedule.test.ts
index 2ccc00035..b3098b8df 100644
--- a/packages/cli/tests/31-loop-schedule.test.ts
+++ b/packages/cli/tests/31-loop-schedule.test.ts
@@ -28,7 +28,10 @@ try {
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
- assert(listedJson.some((item: { id: string }) => item.id === createdJson.id), listed.stdout);
+ assert(
+ listedJson.some((item: { id: string }) => item.id === createdJson.id),
+ listed.stdout,
+ );
const inspected = await ctx.paseo(["schedule", "inspect", createdJson.id, "--json"]);
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
@@ -53,7 +56,16 @@ try {
{
console.log("Test 2: loop run/ls/inspect/logs/stop work");
const run = await ctx.paseo(
- ["loop", "run", "Return any response", "--name", "smoke-loop", "--verify-check", "true", "--json"],
+ [
+ "loop",
+ "run",
+ "Return any response",
+ "--name",
+ "smoke-loop",
+ "--verify-check",
+ "true",
+ "--json",
+ ],
{ timeout: 30000 },
);
assert.strictEqual(run.exitCode, 0, run.stderr);
@@ -64,7 +76,10 @@ try {
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
- assert(listedJson.some((item: { id: string }) => item.id === runJson.id), listed.stdout);
+ assert(
+ listedJson.some((item: { id: string }) => item.id === runJson.id),
+ listed.stdout,
+ );
let status = "running";
for (let attempt = 0; attempt < 40; attempt += 1) {
diff --git a/packages/cli/tests/32-open-project.test.ts b/packages/cli/tests/32-open-project.test.ts
index b08d0f336..3ab2f5e1f 100644
--- a/packages/cli/tests/32-open-project.test.ts
+++ b/packages/cli/tests/32-open-project.test.ts
@@ -4,16 +4,12 @@ import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
-import {
- classifyInvocation,
- isExistingDirectory,
- isPathLikeArg,
-} from "../src/classify.ts";
+import { classifyInvocation, isExistingDirectory, isPathLikeArg } from "../src/classify.ts";
import { openDesktopWithProject } from "../src/commands/open.ts";
console.log("📋 Phase 32: Open Project CLI Tests\n");
- console.log(" Testing path-like detection exports...");
+console.log(" Testing path-like detection exports...");
assert.equal(isPathLikeArg("."), true);
assert.equal(isPathLikeArg("./app"), true);
assert.equal(isPathLikeArg("/tmp/app"), true);
diff --git a/packages/cli/tests/e2e/opencode-invalid-model.test.ts b/packages/cli/tests/e2e/opencode-invalid-model.test.ts
index ef047ac15..31a22f849 100644
--- a/packages/cli/tests/e2e/opencode-invalid-model.test.ts
+++ b/packages/cli/tests/e2e/opencode-invalid-model.test.ts
@@ -27,13 +27,14 @@ async function cleanup(): Promise {
}
async function test_invalid_opencode_model_does_not_report_completed_while_still_running() {
- const result = await ctx.paseo(
- ["run", "--provider", "opencode/adklasldkdas", "hello"],
- { timeout: 45_000 },
- );
+ const result = await ctx.paseo(["run", "--provider", "opencode/adklasldkdas", "hello"], {
+ timeout: 45_000,
+ });
const output = `${result.stdout}\n${result.stderr}`;
- const agentId = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0];
+ const agentId = output.match(
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i,
+ )?.[0];
if (result.exitCode !== 0) {
assert(
@@ -46,7 +47,11 @@ async function test_invalid_opencode_model_does_not_report_completed_while_still
assert(agentId, `expected run output to include an agent id\nstdout:\n${result.stdout}`);
const inspect = await ctx.paseo(["inspect", agentId], { timeout: 15_000 });
- assert.strictEqual(inspect.exitCode, 0, `inspect failed\nstdout:\n${inspect.stdout}\nstderr:\n${inspect.stderr}`);
+ assert.strictEqual(
+ inspect.exitCode,
+ 0,
+ `inspect failed\nstdout:\n${inspect.stdout}\nstderr:\n${inspect.stderr}`,
+ );
const runReportedCompleted = result.stdout.includes("completed");
const inspectStillRunning = inspect.stdout.includes("Status running");
diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts
index eebb941b6..144081fbe 100644
--- a/packages/cli/tests/helpers/test-daemon.ts
+++ b/packages/cli/tests/helpers/test-daemon.ts
@@ -411,8 +411,10 @@ export async function createE2ETestContext(options?: { timeout?: number }): Prom
> {
const ctx = await startTestDaemon({ timeout: options?.timeout });
- const paseo = (args: string[], opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }) =>
- runPaseoCli(ctx, args, opts);
+ const paseo = (
+ args: string[],
+ opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
+ ) => runPaseoCli(ctx, args, opts);
return {
...ctx,
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 9f55feebb..487e4fb99 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
- "version": "0.1.52",
+ "version": "0.1.54",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -13,8 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
- "@getpaseo/cli": "0.1.52",
- "@getpaseo/server": "0.1.52",
+ "@getpaseo/cli": "0.1.54",
+ "@getpaseo/server": "0.1.54",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
diff --git a/packages/desktop/scripts/after-pack.js b/packages/desktop/scripts/after-pack.js
index 258393187..618ec2ae9 100644
--- a/packages/desktop/scripts/after-pack.js
+++ b/packages/desktop/scripts/after-pack.js
@@ -76,7 +76,11 @@ function pruneSharpLibvips(nodeModules, platform, arch) {
if (!fs.existsSync(imgDir)) return;
for (const entry of fs.readdirSync(imgDir)) {
- if (entry.startsWith("sharp-") && entry !== prefix && !entry.startsWith(`sharp-${platform}-${arch}`)) {
+ if (
+ entry.startsWith("sharp-") &&
+ entry !== prefix &&
+ !entry.startsWith(`sharp-${platform}-${arch}`)
+ ) {
rmSafe(path.join(imgDir, entry));
}
}
diff --git a/packages/desktop/scripts/verify-electron-cdp.mjs b/packages/desktop/scripts/verify-electron-cdp.mjs
index 029c6b1ab..15165640f 100644
--- a/packages/desktop/scripts/verify-electron-cdp.mjs
+++ b/packages/desktop/scripts/verify-electron-cdp.mjs
@@ -505,8 +505,7 @@ async function main() {
dragRegionCheck.candidate.explicitNoDragInteractive.length > 0,
details: {
candidate: dragRegionCheck.candidate,
- explicitNoDragInteractive:
- dragRegionCheck.candidate?.explicitNoDragInteractive ?? [],
+ explicitNoDragInteractive: dragRegionCheck.candidate?.explicitNoDragInteractive ?? [],
},
screenshot: dragScreenshot,
};
diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts
index b642db159..c07873cf5 100644
--- a/packages/desktop/src/daemon/daemon-manager.ts
+++ b/packages/desktop/src/daemon/daemon-manager.ts
@@ -149,7 +149,6 @@ function logDesktopDaemonLifecycle(message: string, details?: Record {
if (current.status === "running") {
const appVersion = normalizeVersion(resolveDesktopAppVersion());
const daemonVersion = normalizeVersion(current.version);
- if (
- current.desktopManaged &&
- appVersion &&
- daemonVersion &&
- appVersion !== daemonVersion
- ) {
+ if (current.desktopManaged && appVersion && daemonVersion && appVersion !== daemonVersion) {
logDesktopDaemonLifecycle("daemon version mismatch, restarting", {
appVersion,
daemonVersion,
diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts
index 76d95f8e8..93e3fb02f 100644
--- a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts
+++ b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts
@@ -65,11 +65,7 @@ describe("node-entrypoint-launcher", () => {
it("passes --open-project through as a normal CLI arg", () => {
expect(
parseCliPassthroughArgsFromArgv({
- argv: [
- "/Applications/Paseo.app/Contents/MacOS/Paseo",
- "--open-project",
- "/tmp/project",
- ],
+ argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--open-project", "/tmp/project"],
isDefaultApp: false,
forceCli: false,
}),
@@ -93,7 +89,8 @@ describe("node-entrypoint-launcher", () => {
createNodeEntrypointInvocation({
execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
isPackaged: true,
- packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
+ packagedRunnerPath:
+ "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
entrypoint: CLI_ENTRYPOINT,
argvMode: "node-script",
args: ["ls", "--json"],
@@ -142,7 +139,8 @@ describe("node-entrypoint-launcher", () => {
createNodeEntrypointInvocation({
execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
isPackaged: true,
- packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
+ packagedRunnerPath:
+ "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
entrypoint: CLI_ENTRYPOINT,
argvMode: "node-script",
args: ["--dev"],
diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.ts
index e223b57e0..b2626efcc 100644
--- a/packages/desktop/src/daemon/node-entrypoint-launcher.ts
+++ b/packages/desktop/src/daemon/node-entrypoint-launcher.ts
@@ -70,7 +70,13 @@ export function createNodeEntrypointInvocation(
return {
command: input.execPath,
- args: ["--disable-warning=DEP0040", input.packagedRunnerPath, input.argvMode, input.entrypoint.entryPath, ...input.args],
+ args: [
+ "--disable-warning=DEP0040",
+ input.packagedRunnerPath,
+ input.argvMode,
+ input.entrypoint.entryPath,
+ ...input.args,
+ ],
env,
};
}
diff --git a/packages/desktop/src/daemon/node-entrypoint-runner.ts b/packages/desktop/src/daemon/node-entrypoint-runner.ts
index 0eac149a9..e90579ae2 100644
--- a/packages/desktop/src/daemon/node-entrypoint-runner.ts
+++ b/packages/desktop/src/daemon/node-entrypoint-runner.ts
@@ -10,7 +10,9 @@ async function main(): Promise {
}
process.argv =
- argvMode === "bare" ? [process.argv[0] ?? "node", ...args] : [process.argv[0] ?? "node", entryPath, ...args];
+ argvMode === "bare"
+ ? [process.argv[0] ?? "node", ...args]
+ : [process.argv[0] ?? "node", entryPath, ...args];
await import(pathToFileURL(entryPath).href);
}
diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts
index 0399d27e7..8eaae01da 100644
--- a/packages/desktop/src/daemon/runtime-paths.ts
+++ b/packages/desktop/src/daemon/runtime-paths.ts
@@ -78,7 +78,13 @@ function resolvePackagedAsarPath(): string {
}
function resolvePackagedNodeEntrypointRunnerPath(): string {
- return path.join(process.resourcesPath, "app.asar.unpacked", "dist", "daemon", "node-entrypoint-runner.js");
+ return path.join(
+ process.resourcesPath,
+ "app.asar.unpacked",
+ "dist",
+ "daemon",
+ "node-entrypoint-runner.js",
+ );
}
function assertPathExists(input: { label: string; filePath: string }): string {
@@ -117,12 +123,7 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
}
const serverPackage = resolveServerPackageInfo();
- const distRunner = path.join(
- serverPackage.root,
- "dist",
- "scripts",
- "supervisor-entrypoint.js",
- );
+ const distRunner = path.join(serverPackage.root, "dist", "scripts", "supervisor-entrypoint.js");
if (existsSync(distRunner)) {
return {
entryPath: distRunner,
diff --git a/packages/desktop/src/integrations/integrations-manager.ts b/packages/desktop/src/integrations/integrations-manager.ts
index 17746a70a..f137c8eda 100644
--- a/packages/desktop/src/integrations/integrations-manager.ts
+++ b/packages/desktop/src/integrations/integrations-manager.ts
@@ -238,13 +238,21 @@ export async function getCliInstallStatus(): Promise {
// Skills Installation
// ---------------------------------------------------------------------------
-async function copySkillFile(sourceFile: string, destDir: string, skillName: string): Promise {
+async function copySkillFile(
+ sourceFile: string,
+ destDir: string,
+ skillName: string,
+): Promise {
const destSkillDir = path.join(destDir, skillName);
await fs.mkdir(destSkillDir, { recursive: true });
await fs.copyFile(sourceFile, path.join(destSkillDir, "SKILL.md"));
}
-async function symlinkSkillDir(skillName: string, targetDir: string, linkParentDir: string): Promise {
+async function symlinkSkillDir(
+ skillName: string,
+ targetDir: string,
+ linkParentDir: string,
+): Promise {
await fs.mkdir(linkParentDir, { recursive: true });
const target = path.join(targetDir, skillName);
const linkPath = path.join(linkParentDir, skillName);
diff --git a/packages/desktop/src/open-project-routing.test.ts b/packages/desktop/src/open-project-routing.test.ts
index 33d004844..8cced8763 100644
--- a/packages/desktop/src/open-project-routing.test.ts
+++ b/packages/desktop/src/open-project-routing.test.ts
@@ -57,11 +57,7 @@ describe("open-project-routing", () => {
expect(
parseOpenProjectPathFromArgv({
- argv: [
- "/Applications/Paseo.app/Contents/MacOS/Paseo",
- "--open-project",
- projectPath,
- ],
+ argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--open-project", projectPath],
isDefaultApp: false,
}),
).toBe(projectPath);
diff --git a/packages/desktop/src/open-project-routing.ts b/packages/desktop/src/open-project-routing.ts
index 12ab75f16..6b51f722a 100644
--- a/packages/desktop/src/open-project-routing.ts
+++ b/packages/desktop/src/open-project-routing.ts
@@ -22,9 +22,7 @@ export function parseOpenProjectPathFromArgv(input: {
}): string | null {
const effectiveArgs = input.argv
.slice(input.isDefaultApp ? 2 : 1)
- .filter(
- (arg) => !OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix)),
- );
+ .filter((arg) => !OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix)));
const positionalProjectPath = effectiveArgs.find(
(arg) => !arg.startsWith("-") && isExistingDirectoryAbsolutePath(arg),
diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts
index 583e9a24d..329a67d2a 100644
--- a/packages/desktop/src/window/window-manager.ts
+++ b/packages/desktop/src/window/window-manager.ts
@@ -94,7 +94,9 @@ function readOverlayColor(input: unknown): string | null {
return input;
}
-export function readWindowControlsOverlayUpdate(input: unknown): WindowControlsOverlayUpdate | null {
+export function readWindowControlsOverlayUpdate(
+ input: unknown,
+): WindowControlsOverlayUpdate | null {
if (!input || typeof input !== "object") {
return null;
}
diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json
index 10b941306..00c8c1c40 100644
--- a/packages/expo-two-way-audio/package.json
+++ b/packages/expo-two-way-audio/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
- "version": "0.1.52",
+ "version": "0.1.54",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
diff --git a/packages/highlight/package.json b/packages/highlight/package.json
index 09a34fd48..ee08617f9 100644
--- a/packages/highlight/package.json
+++ b/packages/highlight/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
- "version": "0.1.52",
+ "version": "0.1.54",
"type": "module",
"publishConfig": {
"access": "public"
diff --git a/packages/relay/package.json b/packages/relay/package.json
index ce0583708..d4da0765f 100644
--- a/packages/relay/package.json
+++ b/packages/relay/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
- "version": "0.1.52",
+ "version": "0.1.54",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
diff --git a/packages/server/package.json b/packages/server/package.json
index f9b566d30..5b64d658f 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
- "version": "0.1.52",
+ "version": "0.1.54",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -61,8 +61,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
- "@getpaseo/highlight": "0.1.52",
- "@getpaseo/relay": "0.1.52",
+ "@getpaseo/highlight": "0.1.54",
+ "@getpaseo/relay": "0.1.54",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -75,6 +75,7 @@
"drizzle-orm": "^0.45.1",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
+ "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.1.0",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
diff --git a/packages/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts
index 581b04ebb..c8d0d87e6 100644
--- a/packages/server/scripts/dev-runner.ts
+++ b/packages/server/scripts/dev-runner.ts
@@ -10,7 +10,17 @@ dotenv.config({
const daemonRunnerEntry = fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url));
const result = spawnSync(
process.execPath,
- ["--inspect", "--heapsnapshot-near-heap-limit=3", "--max-old-space-size=3072", "--report-on-fatalerror", "--report-directory=/tmp/paseo-reports", ...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
+ [
+ "--inspect",
+ "--heapsnapshot-near-heap-limit=3",
+ "--max-old-space-size=3072",
+ "--report-on-fatalerror",
+ "--report-directory=/tmp/paseo-reports",
+ ...process.execArgv,
+ daemonRunnerEntry,
+ "--dev",
+ ...process.argv.slice(2),
+ ],
{
stdio: "inherit",
env: process.env,
diff --git a/packages/server/scripts/test-mcp-inject.ts b/packages/server/scripts/test-mcp-inject.ts
new file mode 100644
index 000000000..49e9bb666
--- /dev/null
+++ b/packages/server/scripts/test-mcp-inject.ts
@@ -0,0 +1,194 @@
+import { mkdir, mkdtemp, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+import pino from "pino";
+
+import { ClaudeAgentClient } from "../src/server/agent/providers/claude-agent.js";
+import { CodexAppServerAgentClient } from "../src/server/agent/providers/codex-app-server-agent.js";
+import {
+ getFullAccessConfig,
+ isProviderAvailable,
+} from "../src/server/daemon-e2e/agent-configs.js";
+import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
+import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
+
+function collectAssistantText(entries: Array<{ item: { type: string; text?: string } }>): string {
+ return entries
+ .filter(
+ (entry): entry is { item: { type: "assistant_message"; text: string } } =>
+ entry.item.type === "assistant_message" && typeof entry.item.text === "string",
+ )
+ .map((entry) => entry.item.text)
+ .join("\n");
+}
+
+type ToolCallRecord = {
+ name: string;
+ status: string;
+};
+
+type ProviderRunResult = {
+ provider: "claude" | "codex";
+ agentId: string;
+ assistantText: string;
+ toolCalls: ToolCallRecord[];
+};
+
+async function verifyInjectedMcpForProvider(
+ client: DaemonClient,
+ provider: "claude" | "codex",
+ cwd: string,
+): Promise {
+ const created = await client.createAgent({
+ cwd,
+ title: `mcp-inject-real-${provider}`,
+ ...getFullAccessConfig(provider),
+ });
+ const agentId = created.id;
+
+ try {
+ const prompt = [
+ "List all your available MCP tools.",
+ "If you have a tool called list_agents or create_agent from a paseo MCP server, call list_agents once.",
+ "After checking, reply with exactly PASEO_MCP_FOUND.",
+ "If you do not have those tools, reply with exactly PASEO_MCP_NOT_FOUND.",
+ "Do not say anything else.",
+ ].join(" ");
+
+ await client.sendMessage(agentId, prompt);
+
+ const finished = await client.waitForFinish(agentId, 240_000);
+ if (finished.status !== "idle") {
+ throw new Error(`Agent did not finish successfully (status=${finished.status})`);
+ }
+
+ const timeline = await client.fetchAgentTimeline(agentId, {
+ direction: "tail",
+ limit: 0,
+ projection: "canonical",
+ });
+ const assistantText = collectAssistantText(timeline.entries);
+ const toolCalls = timeline.entries
+ .filter(
+ (
+ entry,
+ ): entry is typeof entry & {
+ item: { type: "tool_call"; name: string; status: string };
+ } => entry.item.type === "tool_call" && typeof entry.item.name === "string",
+ )
+ .map((entry) => ({
+ name: entry.item.name,
+ status: entry.item.status,
+ }));
+
+ if (!assistantText.includes("PASEO_MCP_FOUND")) {
+ throw new Error(
+ `Expected assistant to confirm Paseo MCP availability. Assistant text:\n${assistantText}`,
+ );
+ }
+
+ const listAgentsCalls = toolCalls.filter(
+ (call) =>
+ call.name === "list_agents" ||
+ call.name === "paseo.list_agents" ||
+ call.name.endsWith("__list_agents"),
+ );
+ if (listAgentsCalls.length === 0) {
+ throw new Error(
+ `Expected agent to call list_agents. Tool calls:\n${JSON.stringify(toolCalls, null, 2)}`,
+ );
+ }
+ if (!listAgentsCalls.some((call) => call.status === "completed")) {
+ throw new Error(
+ `Expected list_agents to complete successfully. Tool calls:\n${JSON.stringify(toolCalls, null, 2)}`,
+ );
+ }
+ if (listAgentsCalls.some((call) => call.status === "failed")) {
+ throw new Error(
+ `Expected list_agents to succeed. Tool calls:\n${JSON.stringify(toolCalls, null, 2)}`,
+ );
+ }
+
+ return {
+ provider,
+ agentId,
+ assistantText,
+ toolCalls,
+ };
+ } catch (error) {
+ await client.archiveAgent(agentId).catch(() => undefined);
+ throw error;
+ }
+}
+
+async function main(): Promise {
+ if (!isProviderAvailable("claude")) {
+ throw new Error(
+ "Claude is not available in this environment. Ensure the `claude` binary and credentials are configured.",
+ );
+ }
+
+ const logger = pino({ level: "silent" });
+ const rootCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-mcp-inject-real-"));
+ const claudeCwd = path.join(rootCwd, "claude");
+ const codexCwd = path.join(rootCwd, "codex");
+ const daemon = await createTestPaseoDaemon({
+ agentClients: {
+ claude: new ClaudeAgentClient({ logger }),
+ ...(isProviderAvailable("codex") ? { codex: new CodexAppServerAgentClient(logger) } : {}),
+ },
+ logger,
+ });
+ const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
+ const createdAgentIds: string[] = [];
+
+ try {
+ await mkdir(claudeCwd, { recursive: true });
+ await mkdir(codexCwd, { recursive: true });
+
+ await client.connect();
+ await client.fetchAgents({
+ subscribe: { subscriptionId: "mcp-inject-real-claude" },
+ });
+
+ const results: ProviderRunResult[] = [];
+
+ const claudeResult = await verifyInjectedMcpForProvider(client, "claude", claudeCwd);
+ createdAgentIds.push(claudeResult.agentId);
+ results.push(claudeResult);
+ console.log(`[PASS] Claude MCP injection verified for agent ${claudeResult.agentId}`);
+
+ if (isProviderAvailable("codex")) {
+ const codexResult = await verifyInjectedMcpForProvider(client, "codex", codexCwd);
+ createdAgentIds.push(codexResult.agentId);
+ results.push(codexResult);
+ console.log(`[PASS] Codex MCP injection verified for agent ${codexResult.agentId}`);
+ } else {
+ console.log("[SKIP] Codex is not available in this environment");
+ }
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ results,
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ for (const agentId of createdAgentIds) {
+ await client.archiveAgent(agentId).catch(() => undefined);
+ }
+ await client.close().catch(() => undefined);
+ await daemon.close().catch(() => undefined);
+ await rm(rootCwd, { recursive: true, force: true });
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/packages/server/src/client/daemon-client-transport.test.ts b/packages/server/src/client/daemon-client-transport.test.ts
index c4298c8a6..3bb89903c 100644
--- a/packages/server/src/client/daemon-client-transport.test.ts
+++ b/packages/server/src/client/daemon-client-transport.test.ts
@@ -1,3 +1,4 @@
+import { EventEmitter } from "node:events";
import { describe, expect, test, vi } from "vitest";
import {
createEncryptedTransport,
@@ -114,6 +115,22 @@ describe("daemon-client transport helpers", () => {
expect(ws.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function));
});
+ test("createWebSocketTransportFactory suppresses close-before-open ws errors", () => {
+ class MockNodeWebSocket extends EventEmitter {
+ readyState = 0;
+ send = vi.fn();
+ close = vi.fn((code?: number, reason?: string) => {
+ this.emit("error", new Error("WebSocket was closed before the connection was established"));
+ this.emit("close", { code, reason });
+ });
+ }
+
+ const ws = new MockNodeWebSocket();
+ const transport = createWebSocketTransportFactory(() => ws)({ url: "ws://example.test" });
+
+ expect(() => transport.close(1001, "Connection timed out")).not.toThrow();
+ });
+
test("describeTransportClose prefers reason, then message, then code", () => {
expect(describeTransportClose({ reason: "peer closed" })).toBe("peer closed");
expect(describeTransportClose({ message: "closed" })).toBe("closed");
diff --git a/packages/server/src/client/daemon-client-websocket-transport.ts b/packages/server/src/client/daemon-client-websocket-transport.ts
index ea0f2bfff..fdf4a9a38 100644
--- a/packages/server/src/client/daemon-client-websocket-transport.ts
+++ b/packages/server/src/client/daemon-client-websocket-transport.ts
@@ -32,7 +32,19 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
}
ws.send(data);
},
- close: (code?: number, reason?: string) => ws.close(code, reason),
+ close: (code?: number, reason?: string) => {
+ // Node's `ws` may emit an `error` when a connecting socket is closed before the
+ // handshake completes. Keep a temporary no-op handler attached so cleanup during
+ // connect timeouts does not crash the CLI with an unhandled error event.
+ const suppressEarlyCloseError = bindTemporaryEarlyCloseErrorHandler(ws);
+ try {
+ ws.close(code, reason);
+ } finally {
+ if (typeof ws.on !== "function" && typeof ws.addEventListener !== "function") {
+ suppressEarlyCloseError();
+ }
+ }
+ },
onOpen: (handler) => bindWsHandler(ws, "open", handler),
onClose: (handler) => bindWsHandler(ws, "close", handler),
onError: (handler) => bindWsHandler(ws, "error", handler),
@@ -41,6 +53,52 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
};
}
+function bindTemporaryEarlyCloseErrorHandler(ws: WebSocketLike): () => void {
+ const noop = () => {};
+
+ if (typeof ws.addEventListener === "function") {
+ ws.addEventListener("error", noop);
+ const removeOnClose = bindWsHandler(ws, "close", () => {
+ removeOnClose();
+ if (typeof ws.removeEventListener === "function") {
+ ws.removeEventListener("error", noop);
+ }
+ });
+ return () => {
+ removeOnClose();
+ if (typeof ws.removeEventListener === "function") {
+ ws.removeEventListener("error", noop);
+ }
+ };
+ }
+
+ if (typeof ws.on === "function") {
+ ws.on("error", noop);
+ const removeOnClose = bindWsHandler(ws, "close", () => {
+ removeOnClose();
+ if (typeof ws.off === "function") {
+ ws.off("error", noop);
+ return;
+ }
+ if (typeof ws.removeListener === "function") {
+ ws.removeListener("error", noop);
+ }
+ });
+ return () => {
+ removeOnClose();
+ if (typeof ws.off === "function") {
+ ws.off("error", noop);
+ return;
+ }
+ if (typeof ws.removeListener === "function") {
+ ws.removeListener("error", noop);
+ }
+ };
+ }
+
+ return () => {};
+}
+
export function bindWsHandler(
ws: WebSocketLike,
event: "open" | "close" | "error" | "message",
diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts
index eb73a8538..65a9c5d33 100644
--- a/packages/server/src/client/daemon-client.test.ts
+++ b/packages/server/src/client/daemon-client.test.ts
@@ -759,7 +759,7 @@ describe("DaemonClient", () => {
expect(request.message.type).toBe("subscribe_checkout_diff_request");
expect(request.message.subscriptionId).toBe("checkout-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
- expect(request.message.compare).toEqual({ mode: "uncommitted" });
+ expect(request.message.compare).toEqual({ mode: "uncommitted", ignoreWhitespace: false });
mock.triggerMessage(
JSON.stringify({
@@ -818,7 +818,11 @@ describe("DaemonClient", () => {
};
expect(subscribeRequest.message.type).toBe("subscribe_checkout_diff_request");
expect(subscribeRequest.message.cwd).toBe("/tmp/project");
- expect(subscribeRequest.message.compare).toEqual({ mode: "base", baseRef: "main" });
+ expect(subscribeRequest.message.compare).toEqual({
+ mode: "base",
+ baseRef: "main",
+ ignoreWhitespace: false,
+ });
mock.triggerMessage(
JSON.stringify({
diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts
index 08a4723d8..a1b320e10 100644
--- a/packages/server/src/client/daemon-client.ts
+++ b/packages/server/src/client/daemon-client.ts
@@ -74,7 +74,7 @@ import type {
AgentProvider,
AgentSessionConfig,
} from "../server/agent/agent-sdk-types.js";
-import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js";
+import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "../shared/messages.js";
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
import {
asUint8Array,
@@ -285,10 +285,7 @@ type ChatCreatePayload = Extract<
SessionOutboundMessage,
{ type: "chat/create/response" }
>["payload"];
-type ChatListPayload = Extract<
- SessionOutboundMessage,
- { type: "chat/list/response" }
->["payload"];
+type ChatListPayload = Extract["payload"];
type ChatInspectPayload = Extract<
SessionOutboundMessage,
{ type: "chat/inspect/response" }
@@ -297,38 +294,17 @@ type ChatDeletePayload = Extract<
SessionOutboundMessage,
{ type: "chat/delete/response" }
>["payload"];
-type ChatPostPayload = Extract<
- SessionOutboundMessage,
- { type: "chat/post/response" }
->["payload"];
-type ChatReadPayload = Extract<
- SessionOutboundMessage,
- { type: "chat/read/response" }
->["payload"];
-type ChatWaitPayload = Extract<
- SessionOutboundMessage,
- { type: "chat/wait/response" }
->["payload"];
-type LoopRunPayload = Extract<
- SessionOutboundMessage,
- { type: "loop/run/response" }
->["payload"];
-type LoopListPayload = Extract<
- SessionOutboundMessage,
- { type: "loop/list/response" }
->["payload"];
+type ChatPostPayload = Extract["payload"];
+type ChatReadPayload = Extract["payload"];
+type ChatWaitPayload = Extract["payload"];
+type LoopRunPayload = Extract["payload"];
+type LoopListPayload = Extract["payload"];
type LoopInspectPayload = Extract<
SessionOutboundMessage,
{ type: "loop/inspect/response" }
>["payload"];
-type LoopLogsPayload = Extract<
- SessionOutboundMessage,
- { type: "loop/logs/response" }
->["payload"];
-type LoopStopPayload = Extract<
- SessionOutboundMessage,
- { type: "loop/stop/response" }
->["payload"];
+type LoopLogsPayload = Extract["payload"];
+type LoopStopPayload = Extract["payload"];
type ScheduleCreatePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/create/response" }
@@ -529,10 +505,18 @@ type WaitHandle = {
};
type RpcWaitResult = { kind: "ok"; value: T } | { kind: "error"; error: DaemonRpcError };
-type CorrelatedResponseMessage = Extract<
+type GetDaemonConfigResponse = Extract<
SessionOutboundMessage,
- { payload: { requestId: string } }
+ { type: "get_daemon_config_response" }
>;
+type SetDaemonConfigResponse = Extract<
+ SessionOutboundMessage,
+ { type: "set_daemon_config_response" }
+>;
+type CorrelatedResponseMessage =
+ | Extract
+ | GetDaemonConfigResponse
+ | SetDaemonConfigResponse;
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
type CorrelatedResponsePayload = Extract<
CorrelatedResponseMessage,
@@ -2484,11 +2468,7 @@ export class DaemonClient {
});
}
- async stashPop(
- cwd: string,
- stashIndex: number,
- requestId?: string,
- ): Promise {
+ async stashPop(cwd: string, stashIndex: number, requestId?: string): Promise {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
@@ -2784,6 +2764,34 @@ export class DaemonClient {
});
}
+ async getDaemonConfig(
+ requestId?: string,
+ ): Promise<{ requestId: string; config: MutableDaemonConfig }> {
+ return this.sendCorrelatedSessionRequest({
+ requestId,
+ message: {
+ type: "get_daemon_config_request",
+ },
+ responseType: "get_daemon_config_response",
+ timeout: 10000,
+ });
+ }
+
+ async patchDaemonConfig(
+ config: MutableDaemonConfigPatch,
+ requestId?: string,
+ ): Promise<{ requestId: string; config: MutableDaemonConfig }> {
+ return this.sendCorrelatedSessionRequest({
+ requestId,
+ message: {
+ type: "set_daemon_config_request",
+ config,
+ },
+ responseType: "set_daemon_config_response",
+ timeout: 10000,
+ });
+ }
+
async refreshProvidersSnapshot(options?: {
cwd?: string;
requestId?: string;
@@ -3433,8 +3441,7 @@ export class DaemonClient {
}
async loopLogs(options: string | LoopLogsOptions, afterSeq?: number): Promise {
- const normalized =
- typeof options === "string" ? { id: options, afterSeq } : options;
+ const normalized = typeof options === "string" ? { id: options, afterSeq } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
@@ -4018,10 +4025,6 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
throw new Error("createAgent requires provider and cwd");
}
- if (!merged.modeId) {
- merged.modeId = getAgentProviderDefinition(merged.provider).defaultModeId ?? undefined;
- }
-
return {
...merged,
provider: merged.provider,
diff --git a/packages/server/src/poc-commands/commands-poc.test.ts b/packages/server/src/poc-commands/commands-poc.test.ts
index f6af6c8bb..ec06a4ab8 100644
--- a/packages/server/src/poc-commands/commands-poc.test.ts
+++ b/packages/server/src/poc-commands/commands-poc.test.ts
@@ -16,10 +16,7 @@
*/
import { describe, expect, test } from "vitest";
-import {
- query,
- type SDKUserMessage,
-} from "@anthropic-ai/claude-agent-sdk";
+import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailableSync } from "../utils/executable.js";
const hasClaudeCredentials =
@@ -34,79 +31,87 @@ function createEmptyPrompt(): AsyncGenerator {
describe("Claude Agent SDK Commands POC", () => {
describe("supportedCommands() API", () => {
- test.runIf(canRunClaudeIntegration)("should return an array of SlashCommand objects", async () => {
- // Use the pattern from claude-agent.ts:
- // Create a query with empty prompt generator for control methods
- const emptyPrompt = createEmptyPrompt();
+ test.runIf(canRunClaudeIntegration)(
+ "should return an array of SlashCommand objects",
+ async () => {
+ // Use the pattern from claude-agent.ts:
+ // Create a query with empty prompt generator for control methods
+ const emptyPrompt = createEmptyPrompt();
- const claudeQuery = query({
- prompt: emptyPrompt,
- options: {
- cwd: process.cwd(),
- permissionMode: "plan",
- includePartialMessages: false,
- settingSources: ["user", "project"], // Required to load skills
- },
- });
+ const claudeQuery = query({
+ prompt: emptyPrompt,
+ options: {
+ cwd: process.cwd(),
+ permissionMode: "plan",
+ includePartialMessages: false,
+ settingSources: ["user", "project"], // Required to load skills
+ },
+ });
- try {
- // supportedCommands() is a control method - works without iterating
- const commands = await claudeQuery.supportedCommands();
+ try {
+ // supportedCommands() is a control method - works without iterating
+ const commands = await claudeQuery.supportedCommands();
- // Should be an array
- expect(Array.isArray(commands)).toBe(true);
+ // Should be an array
+ expect(Array.isArray(commands)).toBe(true);
- // Verify structure
- if (commands.length > 0) {
- const firstCommand = commands[0];
- expect(typeof firstCommand.name).toBe("string");
- expect(typeof firstCommand.description).toBe("string");
- expect(typeof firstCommand.argumentHint).toBe("string");
- expect(firstCommand.name.startsWith("/")).toBe(false);
- }
- } finally {
- if (typeof claudeQuery.return === "function") {
- try {
- await claudeQuery.return();
- } catch {
- // ignore shutdown errors
+ // Verify structure
+ if (commands.length > 0) {
+ const firstCommand = commands[0];
+ expect(typeof firstCommand.name).toBe("string");
+ expect(typeof firstCommand.description).toBe("string");
+ expect(typeof firstCommand.argumentHint).toBe("string");
+ expect(firstCommand.name.startsWith("/")).toBe(false);
+ }
+ } finally {
+ if (typeof claudeQuery.return === "function") {
+ try {
+ await claudeQuery.return();
+ } catch {
+ // ignore shutdown errors
+ }
}
}
- }
- }, 30000);
+ },
+ 30000,
+ );
- test.runIf(canRunClaudeIntegration)("should have valid SlashCommand structure for all commands", async () => {
- const emptyPrompt = createEmptyPrompt();
+ test.runIf(canRunClaudeIntegration)(
+ "should have valid SlashCommand structure for all commands",
+ async () => {
+ const emptyPrompt = createEmptyPrompt();
- const claudeQuery = query({
- prompt: emptyPrompt,
- options: {
- cwd: process.cwd(),
- permissionMode: "plan",
- settingSources: ["user", "project"],
- },
- });
+ const claudeQuery = query({
+ prompt: emptyPrompt,
+ options: {
+ cwd: process.cwd(),
+ permissionMode: "plan",
+ settingSources: ["user", "project"],
+ },
+ });
- try {
- const commands = await claudeQuery.supportedCommands();
+ try {
+ const commands = await claudeQuery.supportedCommands();
- expect(commands.length).toBeGreaterThan(0);
+ expect(commands.length).toBeGreaterThan(0);
- // Verify all commands have valid structure
- for (const cmd of commands) {
- expect(cmd).toHaveProperty("name");
- expect(cmd).toHaveProperty("description");
- expect(cmd).toHaveProperty("argumentHint");
- expect(typeof cmd.name).toBe("string");
- expect(typeof cmd.description).toBe("string");
- expect(typeof cmd.argumentHint).toBe("string");
- expect(cmd.name.length).toBeGreaterThan(0);
- expect(cmd.name.startsWith("/")).toBe(false);
+ // Verify all commands have valid structure
+ for (const cmd of commands) {
+ expect(cmd).toHaveProperty("name");
+ expect(cmd).toHaveProperty("description");
+ expect(cmd).toHaveProperty("argumentHint");
+ expect(typeof cmd.name).toBe("string");
+ expect(typeof cmd.description).toBe("string");
+ expect(typeof cmd.argumentHint).toBe("string");
+ expect(cmd.name.length).toBeGreaterThan(0);
+ expect(cmd.name.startsWith("/")).toBe(false);
+ }
+ } finally {
+ await claudeQuery.return?.();
}
- } finally {
- await claudeQuery.return?.();
- }
- }, 30000);
+ },
+ 30000,
+ );
});
describe("Command Execution", () => {
diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts
index b0d98611f..119c1d2e0 100644
--- a/packages/server/src/server/agent/agent-management-mcp.ts
+++ b/packages/server/src/server/agent/agent-management-mcp.ts
@@ -26,17 +26,15 @@ import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino";
-import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
-import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
+import type { AgentProvider } from "./agent-sdk-types.js";
+import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
import {
AgentPermissionRequestPayloadSchema,
AgentPermissionResponseSchema,
AgentSnapshotPayloadSchema,
- serializeAgentSnapshot,
} from "../messages.js";
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
-import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import {
appendTimelineItemIfAgentKnown,
@@ -48,171 +46,49 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
+import type { ScheduleService } from "../schedule/service.js";
+import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
+import { AGENT_PROVIDER_DEFINITIONS, type ProviderDefinition } from "./provider-registry.js";
+import {
+ AgentModelSchema,
+ AgentProviderEnum,
+ AgentStatusEnum,
+ ProviderSummarySchema,
+ parseDurationString,
+ sanitizePermissionRequest,
+ serializeSnapshotWithMetadata,
+ startAgentRun,
+ toScheduleSummary,
+ waitForAgentWithTimeout,
+} from "./mcp-shared.js";
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
getDaemonTcpPort?: () => number | null;
+ scheduleService?: ScheduleService | null;
+ providerRegistry?: Record | null;
paseoHome?: string;
logger: Logger;
}
-const AgentProviderEnum = z.enum(
- AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
- AgentProvider,
- ...AgentProvider[],
- ],
-);
-
-const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
-
-// 50 seconds - surface friendly message before SDK tool timeout (~60s)
-const AGENT_WAIT_TIMEOUT_MS = 50000;
-
-async function waitForAgentWithTimeout(
- agentManager: AgentManager,
- agentId: string,
- options?: {
- signal?: AbortSignal;
- waitForActive?: boolean;
- },
-): Promise {
- const timeoutController = new AbortController();
- const combinedController = new AbortController();
-
- const timeoutId = setTimeout(() => {
- timeoutController.abort(new Error("wait timeout"));
- }, AGENT_WAIT_TIMEOUT_MS);
-
- const forwardAbort = (reason: unknown) => {
- if (!combinedController.signal.aborted) {
- combinedController.abort(reason);
- }
- };
-
- if (options?.signal) {
- if (options.signal.aborted) {
- forwardAbort(options.signal.reason);
- } else {
- options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
- once: true,
- });
- }
- }
-
- timeoutController.signal.addEventListener(
- "abort",
- () => forwardAbort(timeoutController.signal.reason),
- { once: true },
- );
-
- try {
- const result = await agentManager.waitForAgentEvent(agentId, {
- signal: combinedController.signal,
- waitForActive: options?.waitForActive,
- });
- return result;
- } catch (error) {
- if (error instanceof Error && error.message === "wait timeout") {
- const snapshot = agentManager.getAgent(agentId);
- const timeline = agentManager.getTimeline(agentId);
- const recentActivity = curateAgentActivity(timeline.slice(-5));
- const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
- return {
- status: snapshot?.lifecycle ?? "idle",
- permission: null,
- lastMessage: message,
- };
- }
- throw error;
- } finally {
- clearTimeout(timeoutId);
- }
-}
-
-function startAgentRun(
- agentManager: AgentManager,
- agentId: string,
- prompt: AgentPromptInput,
- logger: Logger,
- options?: { replaceRunning?: boolean },
-): void {
- const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
- const iterator = shouldReplace
- ? agentManager.replaceAgentRun(agentId, prompt)
- : agentManager.streamAgent(agentId, prompt);
- void (async () => {
- try {
- for await (const _ of iterator) {
- // Events are broadcast via AgentManager subscribers.
- }
- } catch (error) {
- logger.error({ err: error, agentId }, "Agent stream failed");
- }
- })();
-}
-
-function sanitizePermissionRequest(
- permission: AgentPermissionRequest | null | undefined,
-): AgentPermissionRequest | null {
- if (!permission) {
- return null;
- }
- const sanitized: AgentPermissionRequest = { ...permission };
- if (sanitized.title === undefined) {
- delete sanitized.title;
- }
- if (sanitized.description === undefined) {
- delete sanitized.description;
- }
- if (sanitized.input === undefined) {
- delete sanitized.input;
- }
- if (sanitized.suggestions === undefined) {
- delete sanitized.suggestions;
- }
- if (sanitized.actions === undefined) {
- delete sanitized.actions;
- }
- if (sanitized.metadata === undefined) {
- delete sanitized.metadata;
- }
- return sanitized;
-}
-
-async function resolveAgentTitle(
- agentStorage: AgentSnapshotStore,
- agentId: string,
- logger: Logger,
-): Promise {
- try {
- const record = await agentStorage.get(agentId);
- return record?.title ?? null;
- } catch (error) {
- logger.error({ err: error, agentId }, "Failed to load agent title");
- return null;
- }
-}
-
-async function serializeSnapshotWithMetadata(
- agentStorage: AgentSnapshotStore,
- snapshot: ManagedAgent,
- logger: Logger,
-) {
- const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
- return serializeAgentSnapshot(snapshot, { title });
-}
-
export async function createAgentManagementMcpServer(
options: AgentManagementMcpOptions,
): Promise {
- const { agentManager, agentStorage, logger } = options;
+ const { agentManager, agentStorage, scheduleService, providerRegistry, logger } = options;
const childLogger = logger.child({
module: "agent",
component: "agent-management-mcp",
});
const waitTracker = new WaitForAgentTracker(logger);
+ const resolveNewAgentScheduleTarget = (params?: { provider?: AgentProvider; cwd?: string }) => ({
+ type: "new-agent" as const,
+ config: {
+ provider: params?.provider ?? ("claude" as AgentProvider),
+ cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
+ },
+ });
const server = new McpServer({
name: "paseo-agent-management",
@@ -229,14 +105,20 @@ export async function createAgentManagementMcpServer(
.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."),
- agentType: AgentProviderEnum.optional().describe(
+ provider: AgentProviderEnum.optional().describe(
"Optional agent implementation to spawn. Defaults to 'claude'.",
),
+ model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"),
+ thinking: z.string().optional().describe("Thinking option ID"),
+ labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z
.string()
.optional()
.describe("Optional task to start immediately after creation (non-blocking)."),
- initialMode: z.string().describe("Required session mode to configure before the first run."),
+ mode: z
+ .string()
+ .optional()
+ .describe("Optional session mode to configure before the first run."),
worktreeName: z
.string()
.optional()
@@ -257,7 +139,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"create_agent",
{
- title: "Create Agent",
+ title: "Create agent",
description:
"Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.",
inputSchema,
@@ -281,22 +163,28 @@ export async function createAgentManagementMcpServer(
async (args) => {
const {
cwd,
- agentType,
+ provider,
initialPrompt,
- initialMode,
+ mode,
worktreeName,
baseBranch,
background = false,
title,
+ model,
+ thinking,
+ labels,
} = args as {
cwd: string;
- agentType?: AgentProvider;
+ provider?: AgentProvider;
initialPrompt?: string;
- initialMode: string;
+ mode?: string;
worktreeName?: string;
baseBranch?: string;
background?: boolean;
title: string;
+ model?: string;
+ thinking?: string;
+ labels?: Record;
};
let resolvedCwd = expandUserPath(cwd);
@@ -321,14 +209,20 @@ export async function createAgentManagementMcpServer(
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
}
- const provider: AgentProvider = agentType ?? "claude";
+ const resolvedProvider: AgentProvider = provider ?? "claude";
const normalizedTitle = title?.trim() ?? null;
- const snapshot = await agentManager.createAgent({
- provider,
- cwd: resolvedCwd,
- modeId: initialMode,
- title: normalizedTitle ?? undefined,
- });
+ const snapshot = await agentManager.createAgent(
+ {
+ provider: resolvedProvider,
+ cwd: resolvedCwd,
+ modeId: mode,
+ title: normalizedTitle ?? undefined,
+ model,
+ thinkingOptionId: thinking,
+ },
+ undefined,
+ labels ? { labels } : undefined,
+ );
if (worktreeBootstrap) {
void runAsyncWorktreeBootstrap({
@@ -424,7 +318,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"wait_for_agent",
{
- title: "Wait For Agent",
+ title: "Wait for agent",
description:
"Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.",
inputSchema: {
@@ -502,7 +396,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"send_agent_prompt",
{
- title: "Send Agent Prompt",
+ title: "Send agent prompt",
description:
"Send a task to a running agent. Returns immediately after the agent begins processing.",
inputSchema: {
@@ -592,7 +486,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"get_agent_status",
{
- title: "Get Agent Status",
+ title: "Get agent status",
description:
"Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.",
inputSchema: {
@@ -627,7 +521,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"list_agents",
{
- title: "List Agents",
+ title: "List agents",
description: "List all live agents managed by the server.",
inputSchema: {},
outputSchema: {
@@ -651,7 +545,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"cancel_agent",
{
- title: "Cancel Agent Run",
+ title: "Cancel agent run",
description: "Abort the agent's current run but keep the agent alive for future tasks.",
inputSchema: {
agentId: z.string(),
@@ -672,10 +566,33 @@ export async function createAgentManagementMcpServer(
},
);
+ server.registerTool(
+ "archive_agent",
+ {
+ title: "Archive agent",
+ description:
+ "Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.",
+ inputSchema: {
+ agentId: z.string(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ agentId }) => {
+ await agentManager.archiveAgent(agentId);
+ waitTracker.cancel(agentId, "Agent archived");
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
server.registerTool(
"kill_agent",
{
- title: "Kill Agent",
+ title: "Kill agent",
description: "Terminate an agent session permanently.",
inputSchema: {
agentId: z.string(),
@@ -694,10 +611,284 @@ export async function createAgentManagementMcpServer(
},
);
+ server.registerTool(
+ "update_agent",
+ {
+ title: "Update agent",
+ description: "Update an agent name and/or labels.",
+ inputSchema: {
+ agentId: z.string(),
+ name: z.string().optional(),
+ labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ agentId, name, labels }) => {
+ const trimmedName = name?.trim();
+ if (trimmedName) {
+ const record = await agentStorage.get(agentId);
+ if (!record) {
+ throw new Error(`Agent ${agentId} not found`);
+ }
+ await agentStorage.upsert({
+ ...record,
+ title: trimmedName,
+ updatedAt: new Date().toISOString(),
+ });
+ agentManager.notifyAgentState(agentId);
+ }
+
+ if (labels) {
+ await agentManager.setLabels(agentId, labels);
+ }
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "create_schedule",
+ {
+ title: "Create schedule",
+ description: "Create a recurring schedule that runs on an agent or a new agent.",
+ inputSchema: {
+ prompt: z.string().trim().min(1, "prompt is required"),
+ every: z.string().optional(),
+ cron: z.string().optional(),
+ name: z.string().optional(),
+ target: z.enum(["self", "new-agent"]).optional(),
+ provider: AgentProviderEnum.optional(),
+ cwd: z.string().optional(),
+ maxRuns: z.number().int().positive().optional(),
+ expiresIn: z.string().optional(),
+ },
+ outputSchema: ScheduleSummarySchema.shape,
+ },
+ async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ const cadenceCount = Number(every !== undefined) + Number(cron !== undefined);
+ if (cadenceCount !== 1) {
+ throw new Error("Specify exactly one of every or cron");
+ }
+ if (target === "self") {
+ throw new Error("target=self requires a caller agent");
+ }
+
+ const schedule = await scheduleService.create({
+ prompt: prompt.trim(),
+ cadence: every
+ ? { type: "every" as const, everyMs: parseDurationString(every) }
+ : { type: "cron" as const, expression: cron!.trim() },
+ target: resolveNewAgentScheduleTarget({ provider, cwd }),
+ ...(name?.trim() ? { name: name.trim() } : {}),
+ ...(maxRuns === undefined ? {} : { maxRuns }),
+ ...(expiresIn === undefined
+ ? {}
+ : { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }),
+ });
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson(toScheduleSummary(schedule)),
+ };
+ },
+ );
+
+ server.registerTool(
+ "list_schedules",
+ {
+ title: "List schedules",
+ description: "List all schedules managed by the daemon.",
+ inputSchema: {},
+ outputSchema: {
+ schedules: z.array(ScheduleSummarySchema),
+ },
+ },
+ async () => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ const schedules = (await scheduleService.list()).map((schedule) =>
+ toScheduleSummary(schedule),
+ );
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ schedules }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "inspect_schedule",
+ {
+ title: "Inspect schedule",
+ description: "Inspect a schedule and its run history.",
+ inputSchema: {
+ id: z.string(),
+ },
+ outputSchema: StoredScheduleSchema.shape,
+ },
+ async ({ id }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ const schedule = await scheduleService.inspect(id);
+ return {
+ content: [],
+ structuredContent: ensureValidJson(schedule),
+ };
+ },
+ );
+
+ server.registerTool(
+ "pause_schedule",
+ {
+ title: "Pause schedule",
+ description: "Pause an active schedule.",
+ inputSchema: {
+ id: z.string(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ id }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ await scheduleService.pause(id);
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "resume_schedule",
+ {
+ title: "Resume schedule",
+ description: "Resume a paused schedule.",
+ inputSchema: {
+ id: z.string(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ id }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ await scheduleService.resume(id);
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "delete_schedule",
+ {
+ title: "Delete schedule",
+ description: "Delete a schedule permanently.",
+ inputSchema: {
+ id: z.string(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ id }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ await scheduleService.delete(id);
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "list_providers",
+ {
+ title: "List providers",
+ description: "List available agent providers and their modes.",
+ inputSchema: {},
+ outputSchema: {
+ providers: z.array(ProviderSummarySchema),
+ },
+ },
+ async () => ({
+ content: [],
+ structuredContent: ensureValidJson({
+ providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({
+ id: provider.id,
+ label: provider.label,
+ modes: provider.modes.map((mode) => ({
+ id: mode.id,
+ label: mode.label,
+ ...(mode.description ? { description: mode.description } : {}),
+ })),
+ })),
+ }),
+ }),
+ );
+
+ server.registerTool(
+ "list_models",
+ {
+ title: "List models",
+ description: "List models for an agent provider.",
+ inputSchema: {
+ provider: AgentProviderEnum,
+ },
+ outputSchema: {
+ provider: z.string(),
+ models: z.array(AgentModelSchema),
+ },
+ },
+ async ({ provider }) => {
+ if (!providerRegistry) {
+ throw new Error("Provider registry is not configured");
+ }
+
+ const definition = providerRegistry[provider];
+ if (!definition) {
+ throw new Error(`Provider ${provider} is not configured`);
+ }
+
+ const models = await definition.fetchModels();
+ return {
+ content: [],
+ structuredContent: ensureValidJson({
+ provider,
+ models,
+ }),
+ };
+ },
+ );
+
server.registerTool(
"get_agent_activity",
{
- title: "Get Agent Activity",
+ title: "Get agent activity",
description: "Return recent agent timeline entries as a curated summary.",
inputSchema: {
agentId: z.string(),
@@ -747,7 +938,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"set_agent_mode",
{
- title: "Set Agent Session Mode",
+ title: "Set agent session mode",
description:
"Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).",
inputSchema: {
@@ -771,7 +962,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"list_pending_permissions",
{
- title: "List Pending Permissions",
+ title: "List pending permissions",
description:
"Return all pending permission requests across all agents with the normalized payloads.",
inputSchema: {},
@@ -805,7 +996,7 @@ export async function createAgentManagementMcpServer(
server.registerTool(
"respond_to_permission",
{
- title: "Respond To Permission",
+ title: "Respond to permission",
description:
"Approve or deny a pending permission request with an AgentManager-compatible response payload.",
inputSchema: {
diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts
index 34c6ba6a0..361119607 100644
--- a/packages/server/src/server/agent/agent-manager.test.ts
+++ b/packages/server/src/server/agent/agent-manager.test.ts
@@ -100,6 +100,22 @@ class TestAgentClient implements AgentClient {
return new TestAgentSession(config);
}
+ async listModels() {
+ return [
+ {
+ provider: "codex",
+ id: "gpt-5.4",
+ label: "GPT-5.4",
+ isDefault: true,
+ },
+ {
+ provider: "codex",
+ id: "gpt-5.4-mini",
+ label: "GPT-5.4 Mini",
+ },
+ ];
+ }
+
async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial,
@@ -317,7 +333,7 @@ class StreamingAssistantClient implements AgentClient {
describe("AgentManager", () => {
const logger = createTestLogger();
- test("normalizeConfig does not inject default model when omitted", async () => {
+ test("normalizeConfig injects the provider default model when omitted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -335,7 +351,8 @@ describe("AgentManager", () => {
cwd: workdir,
});
- expect(snapshot.model).toBeUndefined();
+ expect(snapshot.config.model).toBe("gpt-5.4");
+ expect(snapshot.config.modeId).toBe("auto");
});
test("normalizeConfig strips legacy 'default' model id", async () => {
@@ -357,7 +374,8 @@ describe("AgentManager", () => {
model: "default",
});
- expect(snapshot.model).toBeUndefined();
+ expect(snapshot.config.model).toBe("gpt-5.4");
+ expect(snapshot.config.modeId).toBe("auto");
});
test("createAgent passes daemon launch env through the provider launch context", async () => {
@@ -397,6 +415,8 @@ describe("AgentManager", () => {
expect(client.lastConfig).toEqual({
provider: "codex",
cwd: workdir,
+ model: "gpt-5.4",
+ modeId: "auto",
});
expect(client.lastLaunchContext).toEqual({
env: {
@@ -405,6 +425,100 @@ describe("AgentManager", () => {
});
});
+ test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => {
+ const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
+ const storagePath = join(workdir, "agents");
+ const storage = new AgentStorage(storagePath, logger);
+
+ class CaptureClient extends TestAgentClient {
+ lastConfig: AgentSessionConfig | null = null;
+
+ override async createSession(config: AgentSessionConfig): Promise {
+ this.lastConfig = config;
+ return new TestAgentSession(config);
+ }
+ }
+
+ const client = new CaptureClient();
+ const manager = new AgentManager({
+ clients: {
+ codex: client,
+ },
+ registry: storage,
+ logger,
+ mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
+ idFactory: () => "00000000-0000-4000-8000-000000000103",
+ });
+
+ const snapshot = await manager.createAgent({
+ provider: "codex",
+ cwd: workdir,
+ mcpServers: {
+ custom: {
+ type: "stdio",
+ command: "custom-mcp",
+ },
+ },
+ });
+
+ expect(snapshot.config.mcpServers).toEqual({
+ paseo: {
+ type: "http",
+ url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
+ },
+ custom: {
+ type: "stdio",
+ command: "custom-mcp",
+ },
+ });
+ expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
+ });
+
+ test("createAgent preserves a user-provided paseo MCP config", async () => {
+ const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
+ const storagePath = join(workdir, "agents");
+ const storage = new AgentStorage(storagePath, logger);
+
+ class CaptureClient extends TestAgentClient {
+ lastConfig: AgentSessionConfig | null = null;
+
+ override async createSession(config: AgentSessionConfig): Promise {
+ this.lastConfig = config;
+ return new TestAgentSession(config);
+ }
+ }
+
+ const client = new CaptureClient();
+ const manager = new AgentManager({
+ clients: {
+ codex: client,
+ },
+ registry: storage,
+ logger,
+ mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
+ idFactory: () => "00000000-0000-4000-8000-000000000104",
+ });
+
+ const snapshot = await manager.createAgent({
+ provider: "codex",
+ cwd: workdir,
+ mcpServers: {
+ paseo: {
+ type: "http",
+ url: "https://example.com/custom-paseo",
+ },
+ },
+ });
+
+ expect(snapshot.config.mcpServers).toEqual({
+ paseo: {
+ type: "http",
+ url: "https://example.com/custom-paseo",
+ },
+ });
+ expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
+ });
+
test("createAgent fails when cwd does not exist", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -1067,7 +1181,12 @@ describe("AgentManager", () => {
});
await this.gate;
if (this.delayedInterrupted) {
- this.pushEvent({ type: "turn_canceled", provider: this.provider, reason: "Interrupted", turnId });
+ this.pushEvent({
+ type: "turn_canceled",
+ provider: this.provider,
+ reason: "Interrupted",
+ turnId,
+ });
} else {
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}
@@ -1890,7 +2009,7 @@ describe("AgentManager", () => {
cwd: workdir,
});
- expect(snapshot.runtimeInfo?.model ?? null).toBeNull();
+ expect(snapshot.runtimeInfo?.model).toBe("gpt-5.4");
await manager.runAgent(snapshot.id, "hello");
@@ -2013,7 +2132,12 @@ describe("AgentManager", () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
if (turnNum === 1) {
await allowFirstRunToEnd.promise;
- this.pushEvent({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
+ this.pushEvent({
+ type: "turn_canceled",
+ provider: this.provider,
+ reason: "interrupted",
+ turnId,
+ });
} else {
await allowSecondRunToEnd.promise;
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
@@ -2197,7 +2321,7 @@ describe("AgentManager", () => {
await secondStartEntered.promise;
const replaceGapSnapshot = manager.getAgent(snapshot.id) as
- | ({ pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string })
+ | { pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string }
| undefined;
expect(replaceGapSnapshot?.pendingReplacement).toBe(false);
expect(replaceGapSnapshot?.activeForegroundTurnId).toBeNull();
@@ -2269,14 +2393,22 @@ describe("AgentManager", () => {
// Push autonomous events through the session's subscribe() callbacks
const autonomousTurnId = "autonomous-turn-1";
- capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
+ capturedSession!.pushEvent({
+ type: "turn_started",
+ provider: "codex",
+ turnId: autonomousTurnId,
+ });
capturedSession!.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE" },
turnId: autonomousTurnId,
});
- capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
+ capturedSession!.pushEvent({
+ type: "turn_completed",
+ provider: "codex",
+ turnId: autonomousTurnId,
+ });
await settled;
const updated = manager.getAgent(snapshot.id);
@@ -2346,7 +2478,11 @@ describe("AgentManager", () => {
},
{ agentId: snapshot.id, replayState: false },
);
- capturedSession.pushEvent({ type: "turn_started", provider: "codex", turnId: "autonomous-cancel-1" });
+ capturedSession.pushEvent({
+ type: "turn_started",
+ provider: "codex",
+ turnId: "autonomous-cancel-1",
+ });
});
const beforeCancel = manager.getAgent(snapshot.id);
@@ -2389,8 +2525,16 @@ describe("AgentManager", () => {
const autonomousTurnId = "autonomous-wait-1";
const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true });
- capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
- capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
+ capturedSession!.pushEvent({
+ type: "turn_started",
+ provider: "codex",
+ turnId: autonomousTurnId,
+ });
+ capturedSession!.pushEvent({
+ type: "turn_completed",
+ provider: "codex",
+ turnId: autonomousTurnId,
+ });
const result = await waitPromise;
expect(result.status).toBe("idle");
@@ -2451,7 +2595,11 @@ describe("AgentManager", () => {
await new Promise((resolve) => {
const unsub = manager.subscribe(
(event) => {
- if (event.type === "agent_state" && event.agent.id === snapshot.id && event.agent.lifecycle === "running") {
+ if (
+ event.type === "agent_state" &&
+ event.agent.id === snapshot.id &&
+ event.agent.lifecycle === "running"
+ ) {
unsub();
resolve();
}
@@ -2462,14 +2610,22 @@ describe("AgentManager", () => {
// Push autonomous events while foreground is active
const autonomousTurnId = "autonomous-during-fg-1";
- capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
+ capturedSession!.pushEvent({
+ type: "turn_started",
+ provider: "codex",
+ turnId: autonomousTurnId,
+ });
capturedSession!.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "AUTONOMOUS_DURING_FOREGROUND" },
turnId: autonomousTurnId,
});
- capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
+ capturedSession!.pushEvent({
+ type: "turn_completed",
+ provider: "codex",
+ turnId: autonomousTurnId,
+ });
releaseForeground.resolve();
const foregroundEvents = await foregroundResults;
@@ -2525,7 +2681,11 @@ describe("AgentManager", () => {
const settled = new Promise((resolve) => {
manager.subscribe(
(event) => {
- if (event.type === "agent_state" && event.agent.id === snapshot.id && event.agent.lifecycle === "idle") {
+ if (
+ event.type === "agent_state" &&
+ event.agent.id === snapshot.id &&
+ event.agent.lifecycle === "idle"
+ ) {
resolve();
}
if (event.type === "agent_stream" && event.agentId === snapshot.id) {
@@ -2537,14 +2697,22 @@ describe("AgentManager", () => {
});
const autonomousTurnId = "autonomous-isolation-1";
- capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
+ capturedSession!.pushEvent({
+ type: "turn_started",
+ provider: "codex",
+ turnId: autonomousTurnId,
+ });
capturedSession!.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "EVENT_AFTER_ERROR" },
turnId: autonomousTurnId,
});
- capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
+ capturedSession!.pushEvent({
+ type: "turn_completed",
+ provider: "codex",
+ turnId: autonomousTurnId,
+ });
await settled;
@@ -2684,7 +2852,9 @@ describe("AgentManager", () => {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subs.add(callback);
- return () => { this.subs.delete(callback); };
+ return () => {
+ this.subs.delete(callback);
+ };
}
async *streamHistory(): AsyncGenerator {}
@@ -2967,7 +3137,12 @@ describe("AgentManager", () => {
const turnId = `fail-turn-${attempt}`;
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
- this.pushEvent({ type: "turn_failed", provider: this.provider, error: `boom-${attempt}`, turnId });
+ this.pushEvent({
+ type: "turn_failed",
+ provider: this.provider,
+ error: `boom-${attempt}`,
+ turnId,
+ });
}, 0);
return { turnId };
}
@@ -3112,7 +3287,12 @@ describe("AgentManager", () => {
const turnId = "turn-failed-1";
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
- this.pushEvent({ type: "turn_failed", provider: this.provider, error: "invalid model id", turnId });
+ this.pushEvent({
+ type: "turn_failed",
+ provider: this.provider,
+ error: "invalid model id",
+ turnId,
+ });
}, 0);
return { turnId };
}
@@ -3364,7 +3544,9 @@ describe("AgentManager", () => {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subs.add(callback);
- return () => { this.subs.delete(callback); };
+ return () => {
+ this.subs.delete(callback);
+ };
}
async *streamHistory(): AsyncGenerator {}
@@ -3658,7 +3840,9 @@ describe("AgentManager", () => {
}
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
- seen.push(`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`);
+ seen.push(
+ `state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
+ );
return;
}
if (event.type === "agent_stream" && event.event.type === "permission_resolved") {
@@ -3706,12 +3890,18 @@ describe("AgentManager", () => {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
- return () => { this.subscribers.delete(callback); };
+ return () => {
+ this.subscribers.delete(callback);
+ };
}
private pushEvent(event: AgentStreamEvent): void {
for (const cb of this.subscribers) {
- try { cb(event); } catch { /* isolation */ }
+ try {
+ cb(event);
+ } catch {
+ /* isolation */
+ }
}
}
diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts
index 659e701ec..f8cc7c7a4 100644
--- a/packages/server/src/server/agent/agent-manager.ts
+++ b/packages/server/src/server/agent/agent-manager.ts
@@ -45,7 +45,7 @@ import type {
AgentTimelineRow,
AgentTimelineStore,
} from "./agent-timeline-store-types.js";
-import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
+import { AGENT_PROVIDER_IDS, getAgentProviderDefinition } from "./provider-manifest.js";
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
export type {
@@ -96,6 +96,7 @@ export type AgentManagerOptions = {
onAgentAttention?: AgentAttentionCallback;
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
+ mcpBaseUrl?: string;
logger: Logger;
};
@@ -309,6 +310,7 @@ export class AgentManager {
private readonly durableTimelineStore?: AgentTimelineStore;
private readonly previousStatuses = new Map();
private readonly backgroundTasks = new Set>();
+ private mcpBaseUrl: string | null;
private onAgentAttention?: AgentAttentionCallback;
private logger: Logger;
@@ -317,6 +319,7 @@ export class AgentManager {
this.registry = options?.registry;
this.durableTimelineStore = options?.durableTimelineStore;
this.onAgentAttention = options?.onAgentAttention;
+ this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
if (options?.clients) {
for (const [provider, client] of Object.entries(options.clients)) {
@@ -335,6 +338,10 @@ export class AgentManager {
this.onAgentAttention = callback;
}
+ setMcpBaseUrl(url: string | null): void {
+ this.mcpBaseUrl = url;
+ }
+
public getMetricsSnapshot(): AgentMetricsSnapshot {
const byLifecycle: Record = {};
let withActiveForegroundTurn = 0;
@@ -599,9 +606,21 @@ export class AgentManager {
initialPrompt?: string;
},
): Promise {
- // Generate agent ID early so we can use it in MCP config
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
- const normalizedConfig = await this.normalizeConfig(config);
+ const injectedConfig =
+ this.mcpBaseUrl == null
+ ? config
+ : {
+ ...config,
+ mcpServers: {
+ paseo: {
+ type: "http" as const,
+ url: `${this.mcpBaseUrl}?callerAgentId=${resolvedAgentId}`,
+ },
+ ...(config.mcpServers ?? {}),
+ },
+ };
+ const normalizedConfig = await this.normalizeConfig(injectedConfig);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = this.requireClient(normalizedConfig.provider);
const available = await client.isAvailable();
@@ -1273,10 +1292,7 @@ export class AgentManager {
}
const pendingRun = this.getPendingForegroundRun(agentId);
- if (
- (snapshot.lifecycle === "running" || pendingRun?.started) &&
- !snapshot.pendingReplacement
- ) {
+ if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) {
return;
}
@@ -1353,11 +1369,7 @@ export class AgentManager {
return true;
}
- if (
- !currentPendingRun &&
- !current.activeForegroundTurnId &&
- !current.pendingReplacement
- ) {
+ if (!currentPendingRun && !current.activeForegroundTurnId && !current.pendingReplacement) {
finishErr(new Error(`Agent ${agentId} run finished before starting`));
return true;
}
@@ -1419,8 +1431,7 @@ export class AgentManager {
const pendingRun = this.getPendingForegroundRun(agentId);
const foregroundTurnId = agent.activeForegroundTurnId;
const hasForegroundTurn = Boolean(foregroundTurnId);
- const isAutonomousRunning =
- agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun;
+ const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun;
if (!hasForegroundTurn && !isAutonomousRunning && !pendingRun) {
return false;
@@ -2186,9 +2197,7 @@ export class AgentManager {
},
): Promise {
const eventTurnId = (event as { turnId?: string }).turnId;
- const isForegroundEvent = Boolean(
- eventTurnId && agent.activeForegroundTurnId === eventTurnId,
- );
+ const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
// Only update timestamp for live events, not history replay
if (!options?.fromHistory) {
@@ -2229,11 +2238,7 @@ export class AgentManager {
}
// Suppress user_message echoes for the active foreground turn —
// these are already recorded by recordUserMessage().
- if (
- !options?.fromHistory &&
- event.item.type === "user_message" &&
- isForegroundEvent
- ) {
+ if (!options?.fromHistory && event.item.type === "user_message" && isForegroundEvent) {
const eventMessageId = normalizeMessageId(event.item.messageId);
const eventText = event.item.text;
if (eventMessageId) {
@@ -2274,7 +2279,7 @@ export class AgentManager {
void this.refreshRuntimeInfo(agent);
break;
case "turn_failed":
- this.logger.trace(
+ this.logger.warn(
{
agentId: agent.id,
lifecycle: agent.lifecycle,
@@ -2631,9 +2636,7 @@ export class AgentManager {
}
}
- private async normalizeConfig(
- config: AgentSessionConfig,
- ): Promise {
+ private async normalizeConfig(config: AgentSessionConfig): Promise {
const normalized: AgentSessionConfig = { ...config };
// Always resolve cwd to absolute path for consistent history file lookup
@@ -2661,7 +2664,31 @@ export class AgentManager {
if (typeof normalized.model === "string") {
const trimmed = normalized.model.trim();
- normalized.model = trimmed.length > 0 ? trimmed : undefined;
+ normalized.model = trimmed.length > 0 && trimmed !== "default" ? trimmed : undefined;
+ }
+
+ if (!normalized.model) {
+ const client = this.clients.get(normalized.provider);
+ if (client) {
+ try {
+ const models = await client.listModels();
+ const defaultModel = models.find((model) => model.isDefault) ?? models[0];
+ if (defaultModel) {
+ normalized.model = defaultModel.id;
+ }
+ } catch {
+ // Provider may not support model listing — leave model undefined
+ }
+ }
+ }
+
+ if (!normalized.modeId) {
+ try {
+ normalized.modeId =
+ getAgentProviderDefinition(normalized.provider).defaultModeId ?? undefined;
+ } catch {
+ // Unknown provider
+ }
}
return normalized;
diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts
index 294fbd3f0..ad95ce1b6 100644
--- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts
+++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts
@@ -151,8 +151,8 @@ describe("agent MCP end-to-end (offline)", () => {
args: {
cwd: agentCwd,
title: "MCP e2e smoke",
- agentType: "claude",
- initialMode: "bypassPermissions",
+ provider: "claude",
+ mode: "bypassPermissions",
initialPrompt,
background: false,
},
@@ -182,6 +182,121 @@ describe("agent MCP end-to-end (offline)", () => {
}
}, 30_000);
+ test("create_agent auto-injects paseo MCP by default and can be disabled", async () => {
+ const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
+ const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
+ const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
+ const port = await getAvailablePort();
+
+ const daemonConfig: PaseoDaemonConfig = {
+ listen: `127.0.0.1:${port}`,
+ paseoHome,
+ corsAllowedOrigins: [],
+ allowedHosts: true,
+ mcpEnabled: true,
+ staticDir,
+ mcpDebug: false,
+ agentClients: createTestAgentClients(),
+ agentStoragePath: path.join(paseoHome, "agents"),
+ };
+
+ const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
+ await daemon.start();
+
+ const transport = new StreamableHTTPClientTransport(
+ new URL(`http://127.0.0.1:${port}/mcp/agents`),
+ );
+ const client = (await experimental_createMCPClient({ transport })) as McpClient;
+
+ const disabledPaseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-disabled-"));
+ const disabledStaticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-disabled-"));
+ const disabledAgentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-disabled-"));
+ const disabledPort = await getAvailablePort();
+ const disabledDaemonConfig: PaseoDaemonConfig = {
+ listen: `127.0.0.1:${disabledPort}`,
+ paseoHome: disabledPaseoHome,
+ corsAllowedOrigins: [],
+ allowedHosts: true,
+ mcpEnabled: true,
+ mcpInjectIntoAgents: false,
+ staticDir: disabledStaticDir,
+ mcpDebug: false,
+ agentClients: createTestAgentClients(),
+ agentStoragePath: path.join(disabledPaseoHome, "agents"),
+ };
+ const disabledDaemon = await createPaseoDaemon(disabledDaemonConfig, pino({ level: "silent" }));
+ await disabledDaemon.start();
+
+ const disabledTransport = new StreamableHTTPClientTransport(
+ new URL(`http://127.0.0.1:${disabledPort}/mcp/agents`),
+ );
+ const disabledClient = (await experimental_createMCPClient({
+ transport: disabledTransport,
+ })) as McpClient;
+
+ let agentId: string | null = null;
+ let disabledAgentId: string | null = null;
+ try {
+ const result = (await client.callTool({
+ name: "create_agent",
+ args: {
+ cwd: agentCwd,
+ title: "Injected MCP",
+ provider: "claude",
+ mode: "bypassPermissions",
+ initialPrompt: "reply with done and stop",
+ background: true,
+ },
+ })) as McpToolResult;
+ const payload = getStructuredContent(result);
+ agentId = (payload?.agentId as string | undefined) ?? null;
+ expect(agentId).toBeTruthy();
+
+ const injectedAgent = daemon.agentManager.getAgent(agentId!);
+ expect(injectedAgent?.config.mcpServers).toMatchObject({
+ paseo: {
+ type: "http",
+ url: `http://127.0.0.1:${port}/mcp/agents?callerAgentId=${agentId!}`,
+ },
+ });
+
+ const disabledResult = (await disabledClient.callTool({
+ name: "create_agent",
+ args: {
+ cwd: disabledAgentCwd,
+ title: "No injected MCP",
+ provider: "claude",
+ mode: "bypassPermissions",
+ initialPrompt: "reply with done and stop",
+ background: true,
+ },
+ })) as McpToolResult;
+ const disabledPayload = getStructuredContent(disabledResult);
+ disabledAgentId = (disabledPayload?.agentId as string | undefined) ?? null;
+ expect(disabledAgentId).toBeTruthy();
+
+ const disabledAgent = disabledDaemon.agentManager.getAgent(disabledAgentId!);
+ expect(disabledAgent?.config.mcpServers?.paseo).toBeUndefined();
+ } finally {
+ if (agentId) {
+ await client.callTool({ name: "kill_agent", args: { agentId } });
+ }
+ if (disabledAgentId) {
+ await disabledClient.callTool({ name: "kill_agent", args: { agentId: disabledAgentId } });
+ }
+ await disabledClient.close();
+ await disabledDaemon.stop();
+ await rm(disabledPaseoHome, { recursive: true, force: true });
+ await rm(disabledStaticDir, { recursive: true, force: true });
+ await rm(disabledAgentCwd, { recursive: true, force: true });
+ await client.close();
+ await daemon.stop();
+ await rm(paseoHome, { recursive: true, force: true });
+ await rm(staticDir, { recursive: true, force: true });
+ await rm(agentCwd, { recursive: true, force: true });
+ }
+ }, 30_000);
+
test("create_agent with worktree is async and boots terminals only after setup success", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
@@ -247,8 +362,8 @@ describe("agent MCP end-to-end (offline)", () => {
args: {
cwd: repoRoot,
title: "MCP worktree setup terminals",
- agentType: "claude",
- initialMode: "bypassPermissions",
+ provider: "claude",
+ mode: "bypassPermissions",
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
baseBranch: "main",
diff --git a/packages/server/src/server/agent/agent-metadata-generator.test.ts b/packages/server/src/server/agent/agent-metadata-generator.test.ts
index f2937de70..ade9543ce 100644
--- a/packages/server/src/server/agent/agent-metadata-generator.test.ts
+++ b/packages/server/src/server/agent/agent-metadata-generator.test.ts
@@ -11,7 +11,7 @@ import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { createWorktree, validateBranchSlug } from "../../utils/worktree.js";
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
const shouldRun = !process.env.CI && !!process.env.OPENAI_API_KEY;
diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts
index 3c9f77304..12ef789e7 100644
--- a/packages/server/src/server/agent/agent-projections.ts
+++ b/packages/server/src/server/agent/agent-projections.ts
@@ -2,6 +2,7 @@ import type { AgentSnapshotPayload } from "../messages.js";
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentCapabilityFlags,
+ AgentFeature,
AgentMetadata,
AgentMode,
AgentPermissionRequest,
@@ -61,7 +62,7 @@ export function toStoredAgentRecord(
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
runtimeInfo,
- features: agent.features,
+ features: normalizeFeatures(agent.features),
persistence,
lastError: agent.lastError ?? undefined,
requiresAttention: agent.attention.requiresAttention,
@@ -99,7 +100,7 @@ export function toAgentPayload(
capabilities: cloneCapabilities(agent.capabilities),
currentModeId: agent.currentModeId,
availableModes: cloneAvailableModes(agent.availableModes),
- features: agent.features,
+ features: normalizeFeatures(agent.features),
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
persistence: sanitizePersistenceHandle(agent.persistence),
title: options?.title ?? null,
@@ -201,6 +202,10 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
return modes.map((mode) => ({ ...mode }));
}
+function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
+ return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
+}
+
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
if (value === undefined) {
return undefined;
diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts
index f5f0df66a..da63dae7f 100644
--- a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts
+++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts
@@ -15,7 +15,7 @@ import { createAgentMcpServer } from "./mcp-server.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js";
import pino from "pino";
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
const hasOpenAICredentials = !!process.env.OPENAI_API_KEY;
diff --git a/packages/server/src/server/agent/agent-response-loop.test.ts b/packages/server/src/server/agent/agent-response-loop.test.ts
index 7920ffbf9..be894c7a5 100644
--- a/packages/server/src/server/agent/agent-response-loop.test.ts
+++ b/packages/server/src/server/agent/agent-response-loop.test.ts
@@ -156,7 +156,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
schema,
providers: [
{ provider: "claude", model: "haiku" },
- { provider: "codex", model: "gpt-5.1-codex-mini" },
+ { provider: "codex", model: "gpt-5.4-mini" },
],
runner: async (options) => {
calls.push({
@@ -186,7 +186,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
schema,
providers: [
{ provider: "claude", model: "haiku" },
- { provider: "codex", model: "gpt-5.1-codex-mini" },
+ { provider: "codex", model: "gpt-5.4-mini" },
],
runner: async (options) => {
calls.push({
@@ -198,7 +198,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
});
expect(result).toEqual({ summary: "ok" });
- expect(calls).toEqual([{ provider: "codex", model: "gpt-5.1-codex-mini" }]);
+ expect(calls).toEqual([{ provider: "codex", model: "gpt-5.4-mini" }]);
});
it("falls back when an available provider fails", async () => {
@@ -216,7 +216,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
schema,
providers: [
{ provider: "claude", model: "haiku" },
- { provider: "codex", model: "gpt-5.1-codex-mini" },
+ { provider: "codex", model: "gpt-5.4-mini" },
],
runner: async (options) => {
calls.push({
@@ -233,7 +233,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
expect(result).toEqual({ summary: "ok" });
expect(calls).toEqual([
{ provider: "claude", model: "haiku" },
- { provider: "codex", model: "gpt-5.1-codex-mini" },
+ { provider: "codex", model: "gpt-5.4-mini" },
]);
});
@@ -252,7 +252,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
schema,
providers: [
{ provider: "claude", model: "haiku" },
- { provider: "codex", model: "gpt-5.1-codex-mini" },
+ { provider: "codex", model: "gpt-5.4-mini" },
{ provider: "opencode", model: "opencode/gpt-5-nano" },
],
runner: async () => {
diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts
index 256d4ab11..d99b2c115 100644
--- a/packages/server/src/server/agent/agent-response-loop.ts
+++ b/packages/server/src/server/agent/agent-response-loop.ts
@@ -93,7 +93,7 @@ export interface StructuredAgentGenerationWithFallbackOptions {
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
{ provider: "claude", model: "haiku" },
- { provider: "codex", model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
+ { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
{ provider: "opencode", model: "opencode/gpt-5-nano" },
] as const;
diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts
index b4bd27730..cf8b4a093 100644
--- a/packages/server/src/server/agent/agent-sdk-types.ts
+++ b/packages/server/src/server/agent/agent-sdk-types.ts
@@ -315,7 +315,12 @@ export type AgentStreamEvent =
}
| { type: "turn_canceled"; provider: AgentProvider; reason: string; turnId?: string }
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider; turnId?: string }
- | { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest; turnId?: string }
+ | {
+ type: "permission_requested";
+ provider: AgentProvider;
+ request: AgentPermissionRequest;
+ turnId?: string;
+ }
| {
type: "permission_resolved";
provider: AgentProvider;
diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts
new file mode 100644
index 000000000..d949f72d0
--- /dev/null
+++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts
@@ -0,0 +1,672 @@
+import os from "node:os";
+import path from "node:path";
+import { execSync } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { afterAll, beforeAll, describe, expect, test } from "vitest";
+import { experimental_createMCPClient } from "ai";
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
+
+import { AGENT_WAIT_TIMEOUT_MS } from "./mcp-shared.js";
+import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
+
+type StructuredContent = { [key: string]: unknown };
+
+type McpToolResult = {
+ structuredContent?: StructuredContent;
+ content?: Array<{ structuredContent?: StructuredContent } | StructuredContent>;
+ isError?: boolean;
+};
+
+type McpClient = {
+ callTool: (input: { name: string; args?: StructuredContent }) => Promise;
+ close: () => Promise;
+};
+
+function formatHostForHttpUrl(host: string): string {
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
+}
+
+function buildExpectedAgentMcpUrl(params: { host: string; port: number; agentId: string }): string {
+ const baseUrl = new URL(
+ "/mcp/agents",
+ `http://${formatHostForHttpUrl(params.host)}:${params.port}`,
+ );
+ baseUrl.searchParams.set("callerAgentId", params.agentId);
+ return baseUrl.toString();
+}
+
+function getStructuredContent(result: McpToolResult): StructuredContent | null {
+ if (result.structuredContent && typeof result.structuredContent === "object") {
+ return result.structuredContent;
+ }
+ const content = result.content?.[0];
+ if (content && typeof content === "object" && "structuredContent" in content) {
+ const structured = (content as { structuredContent?: StructuredContent }).structuredContent;
+ if (structured) {
+ return structured;
+ }
+ }
+ if (content && typeof content === "object") {
+ return content as StructuredContent;
+ }
+ return null;
+}
+
+async function createMcpClient(url: string): Promise {
+ const transport = new StreamableHTTPClientTransport(new URL(url));
+ return (await experimental_createMCPClient({ transport })) as McpClient;
+}
+
+async function callToolStructured(
+ client: McpClient,
+ name: string,
+ args?: StructuredContent,
+): Promise {
+ const result = (await client.callTool({ name, args: args ?? {} })) as McpToolResult;
+ const payload = getStructuredContent(result);
+ if (!payload) {
+ throw new Error(`${name} returned no structured payload`);
+ }
+ return payload;
+}
+
+async function expectToolError(
+ client: McpClient,
+ name: string,
+ args: StructuredContent,
+ pattern: RegExp,
+): Promise {
+ const result = (await client.callTool({ name, args })) as McpToolResult;
+ expect(result.isError).toBe(true);
+ const content = result.content?.[0] as { text?: string } | undefined;
+ expect(content?.text ?? "").toMatch(pattern);
+}
+
+async function sleep(ms: number): Promise {
+ await new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function waitFor(options: {
+ timeoutMs: number;
+ intervalMs?: number;
+ check: () => Promise | T | null;
+ label: string;
+}): Promise {
+ const start = Date.now();
+ while (Date.now() - start < options.timeoutMs) {
+ const result = await options.check();
+ if (result !== null) {
+ return result;
+ }
+ await sleep(options.intervalMs ?? 50);
+ }
+ throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}`);
+}
+
+describe("MCP parity end-to-end", () => {
+ let tempRoot: string;
+ let daemonHandle: TestPaseoDaemon;
+ let topLevelClient: McpClient;
+ let agentScopedClient: McpClient;
+ let parentAgentId: string;
+ let parentAgentCwd: string;
+ let worktreeRepoCwd: string;
+
+ async function makeCwd(prefix: string): Promise {
+ return await mkdtemp(path.join(tempRoot, `${prefix}-`));
+ }
+
+ async function createTopLevelAgent(args?: Partial): Promise {
+ const cwd = (args?.cwd as string | undefined) ?? (await makeCwd("agent-cwd"));
+ const payload = await callToolStructured(topLevelClient, "create_agent", {
+ cwd,
+ title: "Parity agent",
+ provider: "claude",
+ initialPrompt: "say done and stop",
+ mode: "bypassPermissions",
+ background: true,
+ ...args,
+ });
+ return payload.agentId as string;
+ }
+
+ async function createChildAgent(args?: Partial): Promise {
+ const payload = await callToolStructured(agentScopedClient, "create_agent", {
+ title: "Parity child",
+ provider: "claude",
+ initialPrompt: "say done and stop",
+ background: true,
+ ...args,
+ });
+ return payload.agentId as string;
+ }
+
+ async function archiveAgentIfPresent(agentId: string | null | undefined): Promise {
+ if (!agentId) {
+ return;
+ }
+ try {
+ await topLevelClient.callTool({ name: "archive_agent", args: { agentId } });
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+
+ async function deleteScheduleIfPresent(id: string | null | undefined): Promise {
+ if (!id) {
+ return;
+ }
+ try {
+ await topLevelClient.callTool({ name: "delete_schedule", args: { id } });
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+
+ async function killTerminalIfPresent(terminalId: string | null | undefined): Promise {
+ if (!terminalId) {
+ return;
+ }
+ try {
+ await agentScopedClient.callTool({ name: "kill_terminal", args: { terminalId } });
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+
+ async function archiveWorktreeIfPresent(params: {
+ cwd: string;
+ worktreePath?: string | null;
+ worktreeSlug?: string | null;
+ }): Promise {
+ if (!params.worktreePath && !params.worktreeSlug) {
+ return;
+ }
+ try {
+ await topLevelClient.callTool({
+ name: "archive_worktree",
+ args: {
+ cwd: params.cwd,
+ ...(params.worktreePath ? { worktreePath: params.worktreePath } : {}),
+ ...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}),
+ },
+ });
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+
+ beforeAll(async () => {
+ tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-"));
+ parentAgentCwd = await makeCwd("parent-agent-cwd");
+ worktreeRepoCwd = await makeCwd("worktree-repo");
+
+ daemonHandle = await createTestPaseoDaemon();
+ topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`);
+
+ const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
+ cwd: parentAgentCwd,
+ title: "MCP parity parent",
+ provider: "claude",
+ initialPrompt: "say done and stop",
+ mode: "bypassPermissions",
+ background: true,
+ });
+ parentAgentId = parentPayload.agentId as string;
+
+ agentScopedClient = await createMcpClient(
+ `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${parentAgentId}`,
+ );
+
+ execSync("git init -b main", { cwd: worktreeRepoCwd, stdio: "pipe" });
+ execSync("git config user.email 'test@example.com'", { cwd: worktreeRepoCwd, stdio: "pipe" });
+ execSync("git config user.name 'Test User'", { cwd: worktreeRepoCwd, stdio: "pipe" });
+ await writeFile(path.join(worktreeRepoCwd, "README.md"), "# repo\n", "utf8");
+ execSync("git add README.md", { cwd: worktreeRepoCwd, stdio: "pipe" });
+ execSync("git -c commit.gpgsign=false commit -m 'init'", {
+ cwd: worktreeRepoCwd,
+ stdio: "pipe",
+ });
+ }, 30_000);
+
+ afterAll(async () => {
+ await archiveAgentIfPresent(parentAgentId);
+ await agentScopedClient?.close();
+ await topLevelClient?.close();
+ await daemonHandle?.close();
+ await rm(tempRoot, { recursive: true, force: true });
+ });
+
+ describe("Suite A: Core Fixes", () => {
+ test("AGENT_WAIT_TIMEOUT_MS is 30000", () => {
+ expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000);
+ });
+
+ test("create_agent with callerAgentId sets paseo.parent-agent-id label", async () => {
+ let agentId: string | null = null;
+ try {
+ agentId = await createChildAgent();
+ const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
+ expect(snapshot?.labels).toMatchObject({
+ "paseo.parent-agent-id": parentAgentId,
+ });
+ } finally {
+ await archiveAgentIfPresent(agentId);
+ }
+ });
+
+ test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => {
+ let agentId: string | null = null;
+ try {
+ const listenTarget = daemonHandle.daemon.getListenTarget();
+ expect(listenTarget?.type).toBe("tcp");
+
+ const snapshot = await daemonHandle.daemon.agentManager.createAgent({
+ provider: "claude",
+ cwd: await makeCwd("manager-direct-agent-cwd"),
+ title: "Manager direct parity agent",
+ modeId: "bypassPermissions",
+ });
+ agentId = snapshot.id;
+
+ const expectedUrl = buildExpectedAgentMcpUrl({
+ host: listenTarget!.host,
+ port: listenTarget!.port,
+ agentId,
+ });
+
+ expect(snapshot.config.mcpServers).toMatchObject({
+ paseo: {
+ type: "http",
+ url: expectedUrl,
+ },
+ });
+
+ const liveAgent = daemonHandle.daemon.agentManager.getAgent(agentId);
+ expect(liveAgent?.config.mcpServers).toMatchObject({
+ paseo: {
+ type: "http",
+ url: expectedUrl,
+ },
+ });
+ } finally {
+ await archiveAgentIfPresent(agentId);
+ }
+ });
+
+ test("create_agent accepts model param", async () => {
+ let agentId: string | null = null;
+ try {
+ agentId = await createTopLevelAgent({ model: "claude-test-model" });
+ const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
+ expect(snapshot?.config.model).toBe("claude-test-model");
+ } finally {
+ await archiveAgentIfPresent(agentId);
+ }
+ });
+
+ test("create_agent accepts labels param", async () => {
+ let agentId: string | null = null;
+ try {
+ agentId = await createTopLevelAgent({ labels: { team: "infra" } });
+ const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
+ expect(snapshot?.labels).toMatchObject({ team: "infra" });
+ } finally {
+ await archiveAgentIfPresent(agentId);
+ }
+ });
+
+ test("archive_agent archives an agent", async () => {
+ let agentId: string | null = null;
+ try {
+ agentId = await createTopLevelAgent();
+ const archivedAgentId = agentId;
+ await callToolStructured(topLevelClient, "archive_agent", { agentId });
+ agentId = null;
+
+ const agents = daemonHandle.daemon.agentManager.listAgents();
+ expect(agents.some((agent) => agent.id === archivedAgentId)).toBe(false);
+ } finally {
+ await archiveAgentIfPresent(agentId);
+ }
+ });
+
+ test("update_agent updates name and labels", async () => {
+ let agentId: string | null = null;
+ try {
+ agentId = await createTopLevelAgent();
+ await callToolStructured(topLevelClient, "update_agent", {
+ agentId,
+ name: "Renamed parity agent",
+ labels: { team: "infra", surface: "mcp" },
+ });
+
+ const stored = await daemonHandle.daemon.agentStorage.get(agentId);
+ const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
+ expect(stored?.title).toBe("Renamed parity agent");
+ expect(snapshot?.labels).toMatchObject({
+ team: "infra",
+ surface: "mcp",
+ });
+ } finally {
+ await archiveAgentIfPresent(agentId);
+ }
+ });
+ });
+
+ describe("Suite B: Terminal Tools", () => {
+ test("create_terminal and list_terminals", async () => {
+ let terminalId: string | null = null;
+ try {
+ const created = await callToolStructured(agentScopedClient, "create_terminal", {
+ name: "Parity terminal",
+ });
+ terminalId = created.id as string;
+
+ const listed = await callToolStructured(agentScopedClient, "list_terminals");
+ const terminals = listed.terminals as Array;
+ expect(terminals).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: terminalId,
+ name: "Parity terminal",
+ cwd: parentAgentCwd,
+ }),
+ ]),
+ );
+ } finally {
+ await killTerminalIfPresent(terminalId);
+ }
+ });
+
+ test("send_terminal_keys and capture_terminal", async () => {
+ let terminalId: string | null = null;
+ try {
+ const created = await callToolStructured(agentScopedClient, "create_terminal", {
+ name: "Parity capture terminal",
+ });
+ terminalId = created.id as string;
+
+ await callToolStructured(agentScopedClient, "send_terminal_keys", {
+ terminalId,
+ keys: "echo hello\r",
+ literal: true,
+ });
+ await sleep(500);
+
+ const captured = await waitFor({
+ timeoutMs: 10_000,
+ intervalMs: 100,
+ label: "terminal output to contain hello",
+ check: async () => {
+ const payload = await callToolStructured(agentScopedClient, "capture_terminal", {
+ terminalId,
+ scrollback: true,
+ });
+ const lines = (payload.lines as string[] | undefined) ?? [];
+ return lines.some((line) => line.includes("hello")) ? payload : null;
+ },
+ });
+
+ expect(captured.lines).toEqual(expect.arrayContaining([expect.stringContaining("hello")]));
+ } finally {
+ await killTerminalIfPresent(terminalId);
+ }
+ });
+
+ test("kill_terminal removes terminal", async () => {
+ let terminalId: string | null = null;
+ try {
+ const created = await callToolStructured(agentScopedClient, "create_terminal", {
+ name: "Parity kill terminal",
+ });
+ terminalId = created.id as string;
+
+ await callToolStructured(agentScopedClient, "kill_terminal", { terminalId });
+ terminalId = null;
+
+ const listed = await waitFor({
+ timeoutMs: 5_000,
+ intervalMs: 100,
+ label: "terminal removal",
+ check: async () => {
+ const payload = await callToolStructured(agentScopedClient, "list_terminals");
+ const terminals = payload.terminals as Array;
+ return terminals.some((terminal) => terminal.id === created.id) ? null : payload;
+ },
+ });
+ const terminals = listed.terminals as Array;
+ expect(terminals.some((terminal) => terminal.id === created.id)).toBe(false);
+ } finally {
+ await killTerminalIfPresent(terminalId);
+ }
+ });
+
+ test("kill_terminal with invalid id throws", async () => {
+ await expectToolError(
+ agentScopedClient,
+ "kill_terminal",
+ { terminalId: "missing-terminal-id" },
+ /not found/i,
+ );
+ });
+ });
+
+ describe("Suite C: Schedule Tools", () => {
+ test("create_schedule and list_schedules", async () => {
+ let scheduleId: string | null = null;
+ try {
+ const created = await callToolStructured(topLevelClient, "create_schedule", {
+ prompt: "say hello",
+ every: "5m",
+ name: "Parity schedule list",
+ });
+ scheduleId = created.id as string;
+
+ const listed = await callToolStructured(topLevelClient, "list_schedules");
+ const schedules = listed.schedules as Array;
+ expect(schedules).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: scheduleId,
+ name: "Parity schedule list",
+ }),
+ ]),
+ );
+ } finally {
+ await deleteScheduleIfPresent(scheduleId);
+ }
+ });
+
+ test("inspect_schedule returns details", async () => {
+ let scheduleId: string | null = null;
+ try {
+ const created = await callToolStructured(topLevelClient, "create_schedule", {
+ prompt: "say hello",
+ every: "5m",
+ name: "Parity inspect schedule",
+ });
+ scheduleId = created.id as string;
+
+ const inspected = await callToolStructured(topLevelClient, "inspect_schedule", {
+ id: scheduleId,
+ });
+ expect(inspected).toMatchObject({
+ id: scheduleId,
+ name: "Parity inspect schedule",
+ prompt: "say hello",
+ status: "active",
+ });
+ } finally {
+ await deleteScheduleIfPresent(scheduleId);
+ }
+ });
+
+ test("pause and resume schedule", async () => {
+ let scheduleId: string | null = null;
+ try {
+ const created = await callToolStructured(topLevelClient, "create_schedule", {
+ prompt: "say hello",
+ every: "5m",
+ name: "Parity pause schedule",
+ });
+ scheduleId = created.id as string;
+
+ await callToolStructured(topLevelClient, "pause_schedule", { id: scheduleId });
+ const paused = await callToolStructured(topLevelClient, "inspect_schedule", {
+ id: scheduleId,
+ });
+ expect(paused.status).toBe("paused");
+
+ await callToolStructured(topLevelClient, "resume_schedule", { id: scheduleId });
+ const resumed = await callToolStructured(topLevelClient, "inspect_schedule", {
+ id: scheduleId,
+ });
+ expect(resumed.status).toBe("active");
+ } finally {
+ await deleteScheduleIfPresent(scheduleId);
+ }
+ });
+
+ test("delete_schedule removes schedule", async () => {
+ let scheduleId: string | null = null;
+ try {
+ const created = await callToolStructured(topLevelClient, "create_schedule", {
+ prompt: "say hello",
+ every: "5m",
+ name: "Parity delete schedule",
+ });
+ scheduleId = created.id as string;
+
+ await callToolStructured(topLevelClient, "delete_schedule", { id: scheduleId });
+ scheduleId = null;
+
+ const listed = await callToolStructured(topLevelClient, "list_schedules");
+ const schedules = listed.schedules as Array;
+ expect(schedules.some((schedule) => schedule.id === created.id)).toBe(false);
+ } finally {
+ await deleteScheduleIfPresent(scheduleId);
+ }
+ });
+
+ test("create_schedule target self with callerAgentId", async () => {
+ let scheduleId: string | null = null;
+ try {
+ const created = await callToolStructured(agentScopedClient, "create_schedule", {
+ prompt: "say hello",
+ every: "5m",
+ name: "Parity self schedule",
+ target: "self",
+ });
+ scheduleId = created.id as string;
+ expect(created.target).toMatchObject({
+ type: "agent",
+ agentId: parentAgentId,
+ });
+ } finally {
+ await deleteScheduleIfPresent(scheduleId);
+ }
+ });
+
+ test("create_schedule target self without callerAgentId throws", async () => {
+ await expectToolError(
+ topLevelClient,
+ "create_schedule",
+ {
+ prompt: "say hello",
+ every: "5m",
+ target: "self",
+ },
+ /requires a caller agent/i,
+ );
+ });
+ });
+
+ describe("Suite D: Provider Tools", () => {
+ test("list_providers returns providers", async () => {
+ const payload = await callToolStructured(topLevelClient, "list_providers");
+ const providers = payload.providers as Array;
+ expect(Array.isArray(providers)).toBe(true);
+ expect(providers.length).toBeGreaterThan(0);
+ expect(providers[0]).toEqual(
+ expect.objectContaining({
+ id: expect.any(String),
+ label: expect.any(String),
+ modes: expect.any(Array),
+ }),
+ );
+ });
+
+ test("list_models returns models for provider", async () => {
+ const payload = await callToolStructured(topLevelClient, "list_models", {
+ provider: "claude",
+ });
+ expect(payload.provider).toBe("claude");
+ expect(Array.isArray(payload.models)).toBe(true);
+ });
+ });
+
+ describe("Suite E: Worktree Tools", () => {
+ test("list_worktrees on empty repo", async () => {
+ const payload = await callToolStructured(topLevelClient, "list_worktrees", {
+ cwd: worktreeRepoCwd,
+ });
+ expect(payload.worktrees).toEqual([]);
+ });
+
+ test("create_worktree and list_worktrees", async () => {
+ let worktreePath: string | null = null;
+ const branchName = `parity-create-${Date.now()}`;
+ try {
+ const created = await callToolStructured(topLevelClient, "create_worktree", {
+ cwd: worktreeRepoCwd,
+ branchName,
+ baseBranch: "main",
+ });
+ worktreePath = created.worktreePath as string;
+
+ const listed = await callToolStructured(topLevelClient, "list_worktrees", {
+ cwd: worktreeRepoCwd,
+ });
+ const worktrees = listed.worktrees as Array;
+ expect(worktrees).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: worktreePath,
+ branchName,
+ }),
+ ]),
+ );
+ } finally {
+ await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
+ }
+ });
+
+ test("archive_worktree removes worktree", async () => {
+ let worktreePath: string | null = null;
+ const branchName = `parity-archive-${Date.now()}`;
+ try {
+ const created = await callToolStructured(topLevelClient, "create_worktree", {
+ cwd: worktreeRepoCwd,
+ branchName,
+ baseBranch: "main",
+ });
+ worktreePath = created.worktreePath as string;
+
+ await callToolStructured(topLevelClient, "archive_worktree", {
+ cwd: worktreeRepoCwd,
+ worktreePath,
+ });
+ worktreePath = null;
+
+ const listed = await callToolStructured(topLevelClient, "list_worktrees", {
+ cwd: worktreeRepoCwd,
+ });
+ const worktrees = listed.worktrees as Array;
+ expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
+ } finally {
+ await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
+ }
+ });
+ });
+});
diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts
index e7d2f231e..92654052f 100644
--- a/packages/server/src/server/agent/mcp-server.test.ts
+++ b/packages/server/src/server/agent/mcp-server.test.ts
@@ -23,7 +23,10 @@ function createTestDeps(): TestDeps {
waitForAgentEvent: vi.fn(),
recordUserMessage: vi.fn(),
setAgentMode: vi.fn(),
+ setLabels: vi.fn().mockResolvedValue(undefined),
setTitle: vi.fn().mockResolvedValue(undefined),
+ archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
+ notifyAgentState: vi.fn(),
getAgent: vi.fn(),
streamAgent: vi.fn(() => (async function* noop() {})()),
respondToPermission: vi.fn(),
@@ -34,6 +37,7 @@ function createTestDeps(): TestDeps {
const agentStorageSpies = {
get: vi.fn().mockResolvedValue(null),
setTitle: vi.fn().mockResolvedValue(undefined),
+ upsert: vi.fn().mockResolvedValue(undefined),
applySnapshot: vi.fn(),
list: vi.fn(),
remove: vi.fn(),
@@ -61,7 +65,7 @@ describe("create_agent MCP tool", () => {
const missingTitle = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
- initialMode: "default",
+ mode: "default",
initialPrompt: "test",
});
expect(missingTitle.success).toBe(false);
@@ -69,7 +73,7 @@ describe("create_agent MCP tool", () => {
const tooLong = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
- initialMode: "default",
+ mode: "default",
title: "x".repeat(61),
initialPrompt: "test",
});
@@ -78,7 +82,7 @@ describe("create_agent MCP tool", () => {
const ok = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
- initialMode: "default",
+ mode: "default",
title: "Short title",
initialPrompt: "test",
});
@@ -91,7 +95,7 @@ describe("create_agent MCP tool", () => {
const tool = (server as any)._registeredTools["create_agent"];
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
- initialMode: "default",
+ mode: "default",
title: "Short title",
});
expect(parsed.success).toBe(false);
@@ -174,6 +178,41 @@ describe("create_agent MCP tool", () => {
);
});
+ it("passes optional model, thinking, and labels through createAgent", async () => {
+ const { agentManager, agentStorage, spies } = createTestDeps();
+ spies.agentManager.createAgent.mockResolvedValue({
+ id: "agent-789",
+ cwd: "/tmp/repo",
+ lifecycle: "idle",
+ currentModeId: null,
+ availableModes: [],
+ config: { title: "Config test", model: "claude-sonnet-4-20250514" },
+ } as ManagedAgent);
+
+ const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
+ const tool = (server as any)._registeredTools["create_agent"];
+ await tool.callback({
+ cwd: existingCwd,
+ title: "Config test",
+ mode: "default",
+ initialPrompt: "Do work",
+ model: "claude-sonnet-4-20250514",
+ thinking: "think-hard",
+ labels: { source: "mcp" },
+ });
+
+ expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ cwd: existingCwd,
+ title: "Config test",
+ model: "claude-sonnet-4-20250514",
+ thinkingOptionId: "think-hard",
+ }),
+ undefined,
+ { labels: { source: "mcp" } },
+ );
+ });
+
it("allows caller agents to override cwd and applies caller context labels", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-"));
@@ -209,7 +248,7 @@ describe("create_agent MCP tool", () => {
await tool.callback({
cwd: "subdir",
title: "Child",
- agentType: "codex",
+ provider: "codex",
initialPrompt: "Do work",
});
@@ -218,10 +257,49 @@ describe("create_agent MCP tool", () => {
cwd: subdir,
}),
undefined,
- { labels: { source: "voice" } },
+ {
+ labels: {
+ "paseo.parent-agent-id": "voice-agent",
+ source: "voice",
+ },
+ },
);
await rm(baseDir, { recursive: true, force: true });
});
+
+ it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => {
+ const { agentManager, agentStorage, spies } = createTestDeps();
+ spies.agentManager.createAgent.mockResolvedValue({
+ id: "agent-injected-123",
+ cwd: "/tmp/repo",
+ lifecycle: "idle",
+ currentModeId: null,
+ availableModes: [],
+ config: { title: "Injected config test" },
+ } as ManagedAgent);
+
+ const server = await createAgentMcpServer({
+ agentManager,
+ agentStorage,
+ logger,
+ });
+ const tool = (server as any)._registeredTools["create_agent"];
+ await tool.callback({
+ cwd: existingCwd,
+ title: "Injected config test",
+ mode: "default",
+ initialPrompt: "Do work",
+ });
+
+ const [configArg, agentIdArg, optionsArg] = spies.agentManager.createAgent.mock.calls[0];
+ expect(configArg).toMatchObject({
+ cwd: existingCwd,
+ title: "Injected config test",
+ });
+ expect(configArg.mcpServers).toBeUndefined();
+ expect(agentIdArg).toBeUndefined();
+ expect(optionsArg).toBeUndefined();
+ });
});
describe("speak MCP tool", () => {
@@ -278,3 +356,54 @@ describe("speak MCP tool", () => {
expect(tool).toBeUndefined();
});
});
+
+describe("agent snapshot MCP serialization", () => {
+ const logger = createTestLogger();
+
+ it("normalizes null features to an empty array for list_agents", async () => {
+ const { agentManager, agentStorage, spies } = createTestDeps();
+ spies.agentManager.listAgents = vi.fn().mockReturnValue([
+ {
+ id: "agent-null-features",
+ provider: "claude",
+ cwd: "/tmp/repo",
+ config: {},
+ runtimeInfo: undefined,
+ createdAt: new Date("2026-04-11T00:00:00.000Z"),
+ updatedAt: new Date("2026-04-11T00:00:00.000Z"),
+ lastUserMessageAt: null,
+ lifecycle: "idle",
+ capabilities: {
+ supportsStreaming: false,
+ supportsSessionPersistence: false,
+ supportsDynamicModes: false,
+ supportsMcpServers: true,
+ supportsReasoningStream: false,
+ supportsToolInvocations: true,
+ },
+ currentModeId: null,
+ availableModes: [],
+ features: null,
+ pendingPermissions: new Map(),
+ persistence: null,
+ labels: {},
+ attention: { requiresAttention: false },
+ } as unknown as ManagedAgent,
+ ]);
+
+ const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
+ const tool = (server as any)._registeredTools["list_agents"];
+ const response = await tool.callback({});
+ const structured = response.structuredContent;
+
+ expect(structured).toEqual({
+ agents: [
+ expect.objectContaining({
+ id: "agent-null-features",
+ features: [],
+ }),
+ ],
+ });
+ expect(Array.isArray(structured.agents[0].features)).toBe(true);
+ });
+});
diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts
index ae83386b5..da3c1e432 100644
--- a/packages/server/src/server/agent/mcp-server.ts
+++ b/packages/server/src/server/agent/mcp-server.ts
@@ -5,17 +5,15 @@ import type { Logger } from "pino";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
-import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
-import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
+import type { AgentProvider } from "./agent-sdk-types.js";
+import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
import {
AgentPermissionRequestPayloadSchema,
AgentPermissionResponseSchema,
AgentSnapshotPayloadSchema,
- serializeAgentSnapshot,
} from "../messages.js";
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
-import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import {
appendTimelineItemIfAgentKnown,
@@ -27,13 +25,33 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
+import { captureTerminalLines } from "../../terminal/terminal.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
+import type { ScheduleService } from "../schedule/service.js";
+import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
+import { AGENT_PROVIDER_DEFINITIONS, type ProviderDefinition } from "./provider-registry.js";
+import { deletePaseoWorktree, listPaseoWorktrees } from "../../utils/worktree.js";
+import {
+ AgentModelSchema,
+ AgentProviderEnum,
+ AgentStatusEnum,
+ ProviderSummarySchema,
+ parseDurationString,
+ sanitizePermissionRequest,
+ setupFinishNotification,
+ serializeSnapshotWithMetadata,
+ startAgentRun,
+ toScheduleSummary,
+ waitForAgentWithTimeout,
+} from "./mcp-shared.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
getDaemonTcpPort?: () => number | null;
+ scheduleService?: ScheduleService | null;
+ providerRegistry?: Record | null;
paseoHome?: string;
/**
* ID of the agent that is connecting to this MCP server.
@@ -92,18 +110,6 @@ function mapModeAcrossProviders(
return sourceMode;
}
-const AgentProviderEnum = z.enum(
- AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
- AgentProvider,
- ...AgentProvider[],
- ],
-);
-
-const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
-
-// 50 seconds - surface friendly message before SDK tool timeout (~60s)
-const AGENT_WAIT_TIMEOUT_MS = 50000;
-
type McpToolContext = RequestHandlerExtra;
function resolveChildAgentCwd(params: {
@@ -125,152 +131,59 @@ function resolveChildAgentCwd(params: {
return resolvePathFromBase(params.parentCwd, requestedCwd);
}
-/**
- * Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
- * Returns a friendly message when timeout occurs, rather than letting
- * the SDK tool timeout trigger a generic "tool failed" error.
- */
-async function waitForAgentWithTimeout(
- agentManager: AgentManager,
- agentId: string,
- options?: {
- signal?: AbortSignal;
- waitForActive?: boolean;
- },
-): Promise {
- const timeoutController = new AbortController();
- const combinedController = new AbortController();
+const TerminalSummarySchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ cwd: z.string(),
+});
- const timeoutId = setTimeout(() => {
- timeoutController.abort(new Error("wait timeout"));
- }, AGENT_WAIT_TIMEOUT_MS);
+const WorktreeSummarySchema = z.object({
+ path: z.string(),
+ createdAt: z.string(),
+ branchName: z.string().optional(),
+ head: z.string().optional(),
+});
- const forwardAbort = (reason: unknown) => {
- if (!combinedController.signal.aborted) {
- combinedController.abort(reason);
- }
- };
-
- // Forward external signal abort
- if (options?.signal) {
- if (options.signal.aborted) {
- forwardAbort(options.signal.reason);
- } else {
- options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
- once: true,
- });
- }
+function resolveTerminalKeyToken(key: string, literal: boolean): string {
+ if (literal) {
+ return key;
}
- // Forward timeout abort
- timeoutController.signal.addEventListener(
- "abort",
- () => forwardAbort(timeoutController.signal.reason),
- { once: true },
- );
-
- try {
- const result = await agentManager.waitForAgentEvent(agentId, {
- signal: combinedController.signal,
- waitForActive: options?.waitForActive,
- });
- return result;
- } catch (error) {
- if (error instanceof Error && error.message === "wait timeout") {
- const snapshot = agentManager.getAgent(agentId);
- const timeline = agentManager.getTimeline(agentId);
- const recentActivity = curateAgentActivity(timeline.slice(-5));
- const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
- return {
- status: snapshot?.lifecycle ?? "idle",
- permission: null,
- lastMessage: message,
- };
- }
- throw error;
- } finally {
- clearTimeout(timeoutId);
+ switch (key) {
+ case "Enter":
+ return "\r";
+ case "Tab":
+ return "\t";
+ case "Escape":
+ return "\u001b";
+ case "Space":
+ return " ";
+ case "BSpace":
+ return "\u007f";
+ case "C-c":
+ return "\u0003";
+ case "C-d":
+ return "\u0004";
+ case "C-z":
+ return "\u001a";
+ case "C-l":
+ return "\u000c";
+ case "C-a":
+ return "\u0001";
+ case "C-e":
+ return "\u0005";
+ default:
+ return key;
}
}
-function startAgentRun(
- agentManager: AgentManager,
- agentId: string,
- prompt: AgentPromptInput,
- logger: Logger,
- options?: { replaceRunning?: boolean },
-): void {
- const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
- const iterator = shouldReplace
- ? agentManager.replaceAgentRun(agentId, prompt)
- : agentManager.streamAgent(agentId, prompt);
- void (async () => {
- try {
- for await (const _ of iterator) {
- // Events are broadcast via AgentManager subscribers.
- }
- } catch (error) {
- logger.error({ err: error, agentId }, "Agent stream failed");
- }
- })();
-}
-
-function sanitizePermissionRequest(
- permission: AgentPermissionRequest | null | undefined,
-): AgentPermissionRequest | null {
- if (!permission) {
- return null;
- }
- const sanitized: AgentPermissionRequest = { ...permission };
- if (sanitized.title === undefined) {
- delete sanitized.title;
- }
- if (sanitized.description === undefined) {
- delete sanitized.description;
- }
- if (sanitized.input === undefined) {
- delete sanitized.input;
- }
- if (sanitized.suggestions === undefined) {
- delete sanitized.suggestions;
- }
- if (sanitized.actions === undefined) {
- delete sanitized.actions;
- }
- if (sanitized.metadata === undefined) {
- delete sanitized.metadata;
- }
- return sanitized;
-}
-
-async function resolveAgentTitle(
- agentStorage: AgentSnapshotStore,
- agentId: string,
- logger: Logger,
-): Promise {
- try {
- const record = await agentStorage.get(agentId);
- return record?.title ?? null;
- } catch (error) {
- logger.error({ err: error, agentId }, "Failed to load agent title");
- return null;
- }
-}
-
-async function serializeSnapshotWithMetadata(
- agentStorage: AgentSnapshotStore,
- snapshot: ManagedAgent,
- logger: Logger,
-) {
- const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
- return serializeAgentSnapshot(snapshot, { title });
-}
-
export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise {
const {
agentManager,
agentStorage,
terminalManager,
+ scheduleService,
+ providerRegistry,
callerAgentId,
resolveSpeakHandler,
resolveCallerContext,
@@ -285,6 +198,82 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
version: "2.0.0",
});
+ const resolveCallerAgent = () => {
+ if (!callerAgentId) {
+ return null;
+ }
+ const parentAgent = agentManager.getAgent(callerAgentId);
+ if (!parentAgent) {
+ throw new Error(`Parent agent ${callerAgentId} not found`);
+ }
+ return parentAgent;
+ };
+
+ const resolveScopedCwd = (requestedCwd?: string, options?: { required?: boolean }): string => {
+ const callerAgent = resolveCallerAgent();
+ if (callerAgent) {
+ return resolveChildAgentCwd({
+ parentCwd: callerAgent.cwd,
+ requestedCwd,
+ lockedCwd: callerContext?.lockedCwd,
+ allowCustomCwd: callerContext?.allowCustomCwd ?? true,
+ });
+ }
+
+ const trimmedCwd = requestedCwd?.trim();
+ if (!trimmedCwd) {
+ if (options?.required) {
+ throw new Error("cwd is required");
+ }
+ throw new Error("cwd is required when no caller agent is available");
+ }
+
+ return expandUserPath(trimmedCwd);
+ };
+
+ const resolveNewAgentScheduleTarget = () => {
+ const callerAgent = resolveCallerAgent();
+ if (callerAgent) {
+ return {
+ type: "new-agent" as const,
+ config: {
+ provider: callerAgent.provider,
+ cwd: callerAgent.cwd,
+ ...(callerAgent.currentModeId ? { modeId: callerAgent.currentModeId } : {}),
+ ...(callerAgent.config.model ? { model: callerAgent.config.model } : {}),
+ ...(callerAgent.config.thinkingOptionId
+ ? { thinkingOptionId: callerAgent.config.thinkingOptionId }
+ : {}),
+ ...(callerAgent.config.approvalPolicy
+ ? { approvalPolicy: callerAgent.config.approvalPolicy }
+ : {}),
+ ...(callerAgent.config.sandboxMode
+ ? { sandboxMode: callerAgent.config.sandboxMode }
+ : {}),
+ ...(typeof callerAgent.config.networkAccess === "boolean"
+ ? { networkAccess: callerAgent.config.networkAccess }
+ : {}),
+ ...(typeof callerAgent.config.webSearch === "boolean"
+ ? { webSearch: callerAgent.config.webSearch }
+ : {}),
+ ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}),
+ ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}),
+ ...(callerAgent.config.systemPrompt
+ ? { systemPrompt: callerAgent.config.systemPrompt }
+ : {}),
+ ...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
+ },
+ };
+ }
+
+ return {
+ type: "new-agent" as const,
+ config: {
+ provider: "claude" as AgentProvider,
+ cwd: process.cwd(),
+ },
+ };
+ };
const agentToAgentInputSchema = {
cwd: z
.string()
@@ -296,9 +285,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.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."),
- agentType: AgentProviderEnum.optional().describe(
+ provider: AgentProviderEnum.optional().describe(
"Optional agent implementation to spawn. Defaults to 'claude'.",
),
+ model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"),
+ thinking: z.string().optional().describe("Thinking option ID"),
+ labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z
.string()
.trim()
@@ -311,6 +303,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.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(
+ "Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.",
+ ),
};
const topLevelInputSchema = {
@@ -323,15 +322,21 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.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."),
- agentType: AgentProviderEnum.optional().describe(
+ provider: AgentProviderEnum.optional().describe(
"Optional agent implementation to spawn. Defaults to 'claude'.",
),
+ model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"),
+ thinking: z.string().optional().describe("Thinking option ID"),
+ labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z
.string()
.trim()
.min(1, "initialPrompt is required")
.describe("Required first task to run immediately after creation."),
- initialMode: z.string().describe("Required session mode to configure before the first run."),
+ mode: z
+ .string()
+ .optional()
+ .describe("Optional session mode to configure before the first run."),
worktreeName: z
.string()
.optional()
@@ -347,14 +352,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.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(
+ "Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission. Requires a caller agent context.",
+ ),
};
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema);
- const topLevelCreateAgentArgsSchema = z.object({
- ...topLevelInputSchema,
- initialMode: topLevelInputSchema.initialMode.optional(),
- });
+ const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema);
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
server.registerTool(
@@ -402,7 +411,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
server.registerTool(
"create_agent",
{
- title: "Create Agent",
+ title: "Create agent",
description:
"Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.",
inputSchema: createAgentInputSchema,
@@ -428,6 +437,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
let initialPrompt: string;
let background = false;
let normalizedTitle: string | null;
+ let model: string | undefined;
+ let thinking: string | undefined;
+ let labels: Record | undefined;
+ let notifyOnFinish = false;
let resolvedCwd: string;
let resolvedMode: string | undefined;
@@ -436,10 +449,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (callerAgentId) {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
- provider = callerArgs.agentType ?? "claude";
+ provider = callerArgs.provider ?? "claude";
initialPrompt = callerArgs.initialPrompt;
background = callerArgs.background ?? false;
normalizedTitle = callerArgs.title.trim();
+ model = callerArgs.model;
+ thinking = callerArgs.thinking;
+ labels = callerArgs.labels;
+ notifyOnFinish = callerArgs.notifyOnFinish ?? false;
const parentAgent = agentManager.getAgent(callerAgentId);
if (!parentAgent) {
@@ -457,11 +474,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}
} else {
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
- provider = topLevelArgs.agentType ?? "claude";
+ provider = topLevelArgs.provider ?? "claude";
initialPrompt = topLevelArgs.initialPrompt;
background = topLevelArgs.background ?? false;
normalizedTitle = topLevelArgs.title.trim();
- const { cwd, initialMode, worktreeName, baseBranch } = topLevelArgs;
+ model = topLevelArgs.model;
+ thinking = topLevelArgs.thinking;
+ labels = topLevelArgs.labels;
+ notifyOnFinish = topLevelArgs.notifyOnFinish ?? false;
+ const { cwd, mode, worktreeName, baseBranch } = topLevelArgs;
resolvedCwd = expandUserPath(cwd);
@@ -481,22 +502,26 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
shouldBootstrapWorktree = worktreeBootstrap.shouldBootstrap;
}
- resolvedMode = initialMode;
+ resolvedMode = mode;
}
- const childAgentDefaultLabels =
- callerAgentId && callerContext?.childAgentDefaultLabels
- ? callerContext.childAgentDefaultLabels
- : undefined;
+ const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels;
+ const mergedLabels = {
+ ...(callerAgentId ? { "paseo.parent-agent-id": callerAgentId } : {}),
+ ...(childAgentDefaultLabels ?? {}),
+ ...(labels ?? {}),
+ };
const snapshot = await agentManager.createAgent(
{
provider,
cwd: resolvedCwd,
modeId: resolvedMode,
title: normalizedTitle ?? undefined,
+ model,
+ thinkingOptionId: thinking,
},
undefined,
- childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined,
+ Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined,
);
if (worktreeConfig) {
@@ -542,6 +567,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
try {
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
+ if (notifyOnFinish && callerAgentId) {
+ setupFinishNotification({
+ agentManager,
+ childAgentId: snapshot.id,
+ callerAgentId,
+ logger: childLogger,
+ });
+ }
// If not running in background, wait for completion
if (!background) {
@@ -592,7 +625,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
server.registerTool(
"wait_for_agent",
{
- title: "Wait For Agent",
+ title: "Wait for agent",
description:
"Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.",
inputSchema: {
@@ -669,7 +702,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
server.registerTool(
"send_agent_prompt",
{
- title: "Send Agent Prompt",
+ title: "Send agent prompt",
description:
"Send a task to a running agent. Returns immediately after the agent begins processing.",
inputSchema: {
@@ -686,6 +719,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.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(
+ "Send a notification prompt to the caller agent when this agent finishes, errors, or needs permission.",
+ ),
},
outputSchema: {
success: z.boolean(),
@@ -694,7 +734,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
},
},
- async ({ agentId, prompt, sessionMode, background = false }) => {
+ async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => {
const snapshot = agentManager.getAgent(agentId);
if (!snapshot) {
throw new Error(`Agent ${agentId} not found`);
@@ -719,6 +759,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
startAgentRun(agentManager, agentId, prompt, childLogger, {
replaceRunning: true,
});
+ if (notifyOnFinish && callerAgentId) {
+ setupFinishNotification({
+ agentManager,
+ childAgentId: agentId,
+ callerAgentId,
+ logger: childLogger,
+ });
+ }
// If not running in background, wait for completion
if (!background) {
@@ -764,7 +812,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
server.registerTool(
"get_agent_status",
{
- title: "Get Agent Status",
+ title: "Get agent status",
description:
"Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.",
inputSchema: {
@@ -799,7 +847,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
server.registerTool(
"list_agents",
{
- title: "List Agents",
+ title: "List agents",
description: "List all live agents managed by the server.",
inputSchema: {},
outputSchema: {
@@ -823,7 +871,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
server.registerTool(
"cancel_agent",
{
- title: "Cancel Agent Run",
+ title: "Cancel agent run",
description: "Abort the agent's current run but keep the agent alive for future tasks.",
inputSchema: {
agentId: z.string(),
@@ -844,10 +892,33 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
+ server.registerTool(
+ "archive_agent",
+ {
+ title: "Archive agent",
+ description:
+ "Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.",
+ inputSchema: {
+ agentId: z.string(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ agentId }) => {
+ await agentManager.archiveAgent(agentId);
+ waitTracker.cancel(agentId, "Agent archived");
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
server.registerTool(
"kill_agent",
{
- title: "Kill Agent",
+ title: "Kill agent",
description: "Terminate an agent session permanently.",
inputSchema: {
agentId: z.string(),
@@ -866,10 +937,584 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
+ server.registerTool(
+ "update_agent",
+ {
+ title: "Update agent",
+ description: "Update an agent name and/or labels.",
+ inputSchema: {
+ agentId: z.string(),
+ name: z.string().optional(),
+ labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ agentId, name, labels }) => {
+ const trimmedName = name?.trim();
+ if (trimmedName) {
+ const record = await agentStorage.get(agentId);
+ if (!record) {
+ throw new Error(`Agent ${agentId} not found`);
+ }
+ await agentStorage.upsert({
+ ...record,
+ title: trimmedName,
+ updatedAt: new Date().toISOString(),
+ });
+ agentManager.notifyAgentState(agentId);
+ }
+
+ if (labels) {
+ await agentManager.setLabels(agentId, labels);
+ }
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "list_terminals",
+ {
+ title: "List terminals",
+ description: "List terminals for a working directory or across all working directories.",
+ inputSchema: {
+ cwd: z
+ .string()
+ .optional()
+ .describe("Optional working directory. Defaults to the caller agent cwd."),
+ all: z.boolean().optional().describe("List terminals across all working directories."),
+ },
+ outputSchema: {
+ terminals: z.array(TerminalSummarySchema),
+ },
+ },
+ async ({ cwd, all }) => {
+ if (!terminalManager) {
+ throw new Error("Terminal manager is not configured");
+ }
+
+ const terminals = all
+ ? (
+ await Promise.all(
+ terminalManager.listDirectories().map(async (directory) =>
+ (
+ await terminalManager.getTerminals(directory)
+ ).map((terminal) => ({
+ id: terminal.id,
+ name: terminal.name,
+ cwd: terminal.cwd,
+ })),
+ ),
+ )
+ ).flat()
+ : (await terminalManager.getTerminals(resolveScopedCwd(cwd, { required: true }))).map(
+ (terminal) => ({
+ id: terminal.id,
+ name: terminal.name,
+ cwd: terminal.cwd,
+ }),
+ );
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ terminals }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "create_terminal",
+ {
+ title: "Create terminal",
+ description: "Create a terminal session for a working directory.",
+ inputSchema: {
+ cwd: z
+ .string()
+ .optional()
+ .describe("Optional working directory. Defaults to the caller agent cwd."),
+ name: z.string().optional().describe("Optional terminal name."),
+ },
+ outputSchema: TerminalSummarySchema.shape,
+ },
+ async ({ cwd, name }) => {
+ if (!terminalManager) {
+ throw new Error("Terminal manager is not configured");
+ }
+
+ const terminal = await terminalManager.createTerminal({
+ cwd: resolveScopedCwd(cwd, { required: true }),
+ ...(name?.trim() ? { name: name.trim() } : {}),
+ });
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({
+ id: terminal.id,
+ name: terminal.name,
+ cwd: terminal.cwd,
+ }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "kill_terminal",
+ {
+ title: "Kill terminal",
+ description: "Kill an existing terminal session.",
+ inputSchema: {
+ terminalId: z.string(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ terminalId }) => {
+ if (!terminalManager) {
+ throw new Error("Terminal manager is not configured");
+ }
+
+ const terminal = terminalManager.getTerminal(terminalId);
+ if (!terminal) {
+ throw new Error(`Terminal ${terminalId} not found`);
+ }
+
+ terminal.kill();
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "capture_terminal",
+ {
+ title: "Capture terminal",
+ description: "Capture plain-text terminal output lines from a terminal session.",
+ inputSchema: {
+ terminalId: z.string(),
+ start: z.number().optional(),
+ end: z.number().optional(),
+ scrollback: z.boolean().optional(),
+ stripAnsi: z.boolean().optional().default(true),
+ },
+ outputSchema: {
+ terminalId: z.string(),
+ lines: z.array(z.string()),
+ totalLines: z.number().int().nonnegative(),
+ },
+ },
+ async ({ terminalId, start, end, scrollback, stripAnsi = true }) => {
+ if (!terminalManager) {
+ throw new Error("Terminal manager is not configured");
+ }
+
+ const terminal = terminalManager.getTerminal(terminalId);
+ if (!terminal) {
+ throw new Error(`Terminal ${terminalId} not found`);
+ }
+
+ const capture = captureTerminalLines(terminal, {
+ start: scrollback ? 0 : start,
+ end,
+ stripAnsi,
+ });
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({
+ terminalId,
+ lines: capture.lines,
+ totalLines: capture.totalLines,
+ }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "send_terminal_keys",
+ {
+ title: "Send terminal keys",
+ description: "Send literal text or special key tokens to a terminal session.",
+ inputSchema: {
+ terminalId: z.string(),
+ keys: z.string(),
+ literal: z.boolean().optional(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ terminalId, keys, literal = false }) => {
+ if (!terminalManager) {
+ throw new Error("Terminal manager is not configured");
+ }
+
+ const terminal = terminalManager.getTerminal(terminalId);
+ if (!terminal) {
+ throw new Error(`Terminal ${terminalId} not found`);
+ }
+
+ terminal.send({
+ type: "input",
+ data: resolveTerminalKeyToken(keys, literal),
+ });
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "create_schedule",
+ {
+ title: "Create schedule",
+ description: "Create a recurring schedule that runs on an agent or a new agent.",
+ inputSchema: {
+ prompt: z.string().trim().min(1, "prompt is required"),
+ every: z.string().optional(),
+ cron: z.string().optional(),
+ name: z.string().optional(),
+ target: z.enum(["self", "new-agent"]).optional(),
+ maxRuns: z.number().int().positive().optional(),
+ expiresIn: z.string().optional(),
+ },
+ outputSchema: ScheduleSummarySchema.shape,
+ },
+ async ({ prompt, every, cron, name, target, maxRuns, expiresIn }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ const cadenceCount = Number(every !== undefined) + Number(cron !== undefined);
+ if (cadenceCount !== 1) {
+ throw new Error("Specify exactly one of every or cron");
+ }
+
+ const scheduleTarget =
+ target === "self"
+ ? (() => {
+ if (!callerAgentId) {
+ throw new Error("target=self requires a caller agent");
+ }
+ return { type: "agent" as const, agentId: callerAgentId };
+ })()
+ : resolveNewAgentScheduleTarget();
+
+ const schedule = await scheduleService.create({
+ prompt: prompt.trim(),
+ cadence: every
+ ? { type: "every" as const, everyMs: parseDurationString(every) }
+ : { type: "cron" as const, expression: cron!.trim() },
+ target: scheduleTarget,
+ ...(name?.trim() ? { name: name.trim() } : {}),
+ ...(maxRuns === undefined ? {} : { maxRuns }),
+ ...(expiresIn === undefined
+ ? {}
+ : { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }),
+ });
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson(toScheduleSummary(schedule)),
+ };
+ },
+ );
+
+ server.registerTool(
+ "list_schedules",
+ {
+ title: "List schedules",
+ description: "List all schedules managed by the daemon.",
+ inputSchema: {},
+ outputSchema: {
+ schedules: z.array(ScheduleSummarySchema),
+ },
+ },
+ async () => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ const schedules = (await scheduleService.list()).map((schedule) =>
+ toScheduleSummary(schedule),
+ );
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ schedules }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "inspect_schedule",
+ {
+ title: "Inspect schedule",
+ description: "Inspect a schedule and its run history.",
+ inputSchema: {
+ id: z.string(),
+ },
+ outputSchema: StoredScheduleSchema.shape,
+ },
+ async ({ id }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ const schedule = await scheduleService.inspect(id);
+ return {
+ content: [],
+ structuredContent: ensureValidJson(schedule),
+ };
+ },
+ );
+
+ server.registerTool(
+ "pause_schedule",
+ {
+ title: "Pause schedule",
+ description: "Pause an active schedule.",
+ inputSchema: {
+ id: z.string(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ id }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ await scheduleService.pause(id);
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "resume_schedule",
+ {
+ title: "Resume schedule",
+ description: "Resume a paused schedule.",
+ inputSchema: {
+ id: z.string(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ id }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ await scheduleService.resume(id);
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "delete_schedule",
+ {
+ title: "Delete schedule",
+ description: "Delete a schedule permanently.",
+ inputSchema: {
+ id: z.string(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ id }) => {
+ if (!scheduleService) {
+ throw new Error("Schedule service is not configured");
+ }
+
+ await scheduleService.delete(id);
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "list_providers",
+ {
+ title: "List providers",
+ description: "List available agent providers and their modes.",
+ inputSchema: {},
+ outputSchema: {
+ providers: z.array(ProviderSummarySchema),
+ },
+ },
+ async () => ({
+ content: [],
+ structuredContent: ensureValidJson({
+ providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({
+ id: provider.id,
+ label: provider.label,
+ modes: provider.modes.map((mode) => ({
+ id: mode.id,
+ label: mode.label,
+ ...(mode.description ? { description: mode.description } : {}),
+ })),
+ })),
+ }),
+ }),
+ );
+
+ server.registerTool(
+ "list_models",
+ {
+ title: "List models",
+ description: "List models for an agent provider.",
+ inputSchema: {
+ provider: AgentProviderEnum,
+ },
+ outputSchema: {
+ provider: z.string(),
+ models: z.array(AgentModelSchema),
+ },
+ },
+ async ({ provider }) => {
+ if (!providerRegistry) {
+ throw new Error("Provider registry is not configured");
+ }
+
+ const definition = providerRegistry[provider];
+ if (!definition) {
+ throw new Error(`Provider ${provider} is not configured`);
+ }
+
+ const models = await definition.fetchModels();
+ return {
+ content: [],
+ structuredContent: ensureValidJson({
+ provider,
+ models,
+ }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "list_worktrees",
+ {
+ title: "List worktrees",
+ description: "List Paseo-managed git worktrees for a repository.",
+ inputSchema: {
+ cwd: z
+ .string()
+ .optional()
+ .describe("Optional repository cwd. Defaults to the caller agent cwd."),
+ },
+ outputSchema: {
+ worktrees: z.array(WorktreeSummarySchema),
+ },
+ },
+ async ({ cwd }) => {
+ const resolvedCwd = resolveScopedCwd(cwd, { required: true });
+ const worktrees = await listPaseoWorktrees({
+ cwd: resolvedCwd,
+ paseoHome: options.paseoHome,
+ });
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ worktrees }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "create_worktree",
+ {
+ title: "Create worktree",
+ description: "Create a Paseo-managed git worktree.",
+ inputSchema: {
+ cwd: z
+ .string()
+ .optional()
+ .describe("Optional repository cwd. Defaults to the caller agent cwd."),
+ branchName: z.string(),
+ baseBranch: z.string(),
+ },
+ outputSchema: {
+ branchName: z.string(),
+ worktreePath: z.string(),
+ },
+ },
+ async ({ cwd, branchName, baseBranch }) => {
+ const worktree = await createAgentWorktree({
+ branchName,
+ cwd: resolveScopedCwd(cwd, { required: true }),
+ baseBranch,
+ worktreeSlug: branchName,
+ paseoHome: options.paseoHome,
+ });
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({
+ branchName,
+ worktreePath: worktree.worktreePath,
+ }),
+ };
+ },
+ );
+
+ server.registerTool(
+ "archive_worktree",
+ {
+ title: "Archive worktree",
+ description: "Delete a Paseo-managed git worktree.",
+ inputSchema: {
+ cwd: z
+ .string()
+ .optional()
+ .describe("Optional repository cwd. Defaults to the caller agent cwd."),
+ worktreePath: z.string().optional(),
+ worktreeSlug: z.string().optional(),
+ },
+ outputSchema: {
+ success: z.boolean(),
+ },
+ },
+ async ({ cwd, worktreePath, worktreeSlug }) => {
+ await deletePaseoWorktree({
+ cwd: resolveScopedCwd(cwd, { required: true }),
+ worktreePath,
+ worktreeSlug,
+ paseoHome: options.paseoHome,
+ });
+
+ return {
+ content: [],
+ structuredContent: ensureValidJson({ success: true }),
+ };
+ },
+ );
+
server.registerTool(
"get_agent_activity",
{
- title: "Get Agent Activity",
+ title: "Get agent activity",
description: "Return recent agent timeline entries as a curated summary.",
inputSchema: {
agentId: z.string(),
@@ -919,7 +1564,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
server.registerTool(
"set_agent_mode",
{
- title: "Set Agent Session Mode",
+ title: "Set agent session mode",
description:
"Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).",
inputSchema: {
@@ -943,7 +1588,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
server.registerTool(
"list_pending_permissions",
{
- title: "List Pending Permissions",
+ title: "List pending permissions",
description:
"Return all pending permission requests across all agents with the normalized payloads.",
inputSchema: {},
@@ -977,7 +1622,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
server.registerTool(
"respond_to_permission",
{
- title: "Respond To Permission",
+ title: "Respond to permission",
description:
"Approve or deny a pending permission request with an AgentManager-compatible response payload.",
inputSchema: {
diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts
new file mode 100644
index 000000000..a018b80ae
--- /dev/null
+++ b/packages/server/src/server/agent/mcp-shared.ts
@@ -0,0 +1,319 @@
+import { z } from "zod";
+import type { Logger } from "pino";
+
+import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
+import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
+import { curateAgentActivity } from "./activity-curator.js";
+import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
+import type { AgentStorage } from "./agent-storage.js";
+import { serializeAgentSnapshot } from "../messages.js";
+import { StoredScheduleSchema } from "../schedule/types.js";
+
+export const AgentProviderEnum = z.enum(
+ AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
+ AgentProvider,
+ ...AgentProvider[],
+ ],
+);
+
+export const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
+
+export const ProviderModeSchema = z.object({
+ id: z.string(),
+ label: z.string(),
+ description: z.string().optional(),
+});
+
+export const ProviderSummarySchema = z.object({
+ id: z.string(),
+ label: z.string(),
+ modes: z.array(ProviderModeSchema),
+});
+
+export const AgentSelectOptionSchema = z.object({
+ id: z.string(),
+ label: z.string(),
+ description: z.string().optional(),
+ isDefault: z.boolean().optional(),
+ metadata: z.record(z.unknown()).optional(),
+});
+
+export const AgentModelSchema = z.object({
+ provider: z.string(),
+ id: z.string(),
+ label: z.string(),
+ description: z.string().optional(),
+ isDefault: z.boolean().optional(),
+ metadata: z.record(z.unknown()).optional(),
+ thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
+ defaultThinkingOptionId: z.string().optional(),
+});
+
+// 30 seconds - surface friendly message before SDK tool timeout (~60s)
+export const AGENT_WAIT_TIMEOUT_MS = 30000;
+
+export type StartAgentRunOptions = {
+ replaceRunning?: boolean;
+};
+
+/**
+ * Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
+ * Returns a friendly message when timeout occurs, rather than letting
+ * the SDK tool timeout trigger a generic "tool failed" error.
+ */
+export async function waitForAgentWithTimeout(
+ agentManager: AgentManager,
+ agentId: string,
+ options?: {
+ signal?: AbortSignal;
+ waitForActive?: boolean;
+ },
+): Promise {
+ const timeoutController = new AbortController();
+ const combinedController = new AbortController();
+
+ const timeoutId = setTimeout(() => {
+ timeoutController.abort(new Error("wait timeout"));
+ }, AGENT_WAIT_TIMEOUT_MS);
+
+ const forwardAbort = (reason: unknown) => {
+ if (!combinedController.signal.aborted) {
+ combinedController.abort(reason);
+ }
+ };
+
+ if (options?.signal) {
+ if (options.signal.aborted) {
+ forwardAbort(options.signal.reason);
+ } else {
+ options.signal.addEventListener("abort", () => forwardAbort(options.signal!.reason), {
+ once: true,
+ });
+ }
+ }
+
+ timeoutController.signal.addEventListener(
+ "abort",
+ () => forwardAbort(timeoutController.signal.reason),
+ { once: true },
+ );
+
+ try {
+ const result = await agentManager.waitForAgentEvent(agentId, {
+ signal: combinedController.signal,
+ waitForActive: options?.waitForActive,
+ });
+ return result;
+ } catch (error) {
+ if (error instanceof Error && error.message === "wait timeout") {
+ const snapshot = agentManager.getAgent(agentId);
+ const timeline = agentManager.getTimeline(agentId);
+ const recentActivity = curateAgentActivity(timeline.slice(-5));
+ const waitedSeconds = Math.round(AGENT_WAIT_TIMEOUT_MS / 1000);
+ const message = `Awaiting the agent timed out after ${waitedSeconds}s. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
+ return {
+ status: snapshot?.lifecycle ?? "idle",
+ permission: null,
+ lastMessage: message,
+ };
+ }
+ throw error;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+export function startAgentRun(
+ agentManager: AgentManager,
+ agentId: string,
+ prompt: AgentPromptInput,
+ logger: Logger,
+ options?: StartAgentRunOptions,
+): void {
+ const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
+ const iterator = shouldReplace
+ ? agentManager.replaceAgentRun(agentId, prompt)
+ : agentManager.streamAgent(agentId, prompt);
+ void (async () => {
+ try {
+ for await (const _ of iterator) {
+ // Events are broadcast via AgentManager subscribers.
+ }
+ } catch (error) {
+ logger.error({ err: error, agentId }, "Agent stream failed");
+ }
+ })();
+}
+
+interface SetupFinishNotificationParams {
+ agentManager: AgentManager;
+ childAgentId: string;
+ callerAgentId: string;
+ logger: Logger;
+}
+
+export function setupFinishNotification(params: SetupFinishNotificationParams): void {
+ const { agentManager, childAgentId, callerAgentId, logger } = params;
+ let hasSeenRunning = false;
+ let fired = false;
+ let unsubscribe: (() => void) | null = null;
+
+ function notify(reason: "finished" | "errored" | "needs permission"): void {
+ if (fired) {
+ return;
+ }
+ fired = true;
+ unsubscribe?.();
+
+ if (!agentManager.getAgent(callerAgentId)) {
+ return;
+ }
+
+ const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId;
+ const prompt = `\nAgent ${childAgentId} (${title}) ${reason}.\n `;
+
+ startAgentRun(agentManager, callerAgentId, prompt, logger, {
+ replaceRunning: true,
+ });
+ }
+
+ unsubscribe = agentManager.subscribe(
+ (event) => {
+ if (fired) {
+ return;
+ }
+
+ if (event.type === "agent_state") {
+ if (event.agent.lifecycle === "running") {
+ hasSeenRunning = true;
+ return;
+ }
+ if (event.agent.lifecycle === "error") {
+ notify("errored");
+ return;
+ }
+ if (event.agent.lifecycle === "idle" && hasSeenRunning) {
+ notify("finished");
+ return;
+ }
+ if (event.agent.lifecycle === "closed") {
+ fired = true;
+ unsubscribe?.();
+ return;
+ }
+ return;
+ }
+
+ if (event.event.type === "permission_requested") {
+ notify("needs permission");
+ }
+ },
+ { agentId: childAgentId, replayState: false },
+ );
+
+ // Check if the child is already running (catches the case where
+ // the lifecycle flipped before our subscribe call was processed).
+ // Do NOT treat an immediate "idle" as "finished" — the agent may
+ // not have started yet (streamAgent sets a pending run before
+ // transitioning to "running").
+ const childSnapshot = agentManager.getAgent(childAgentId);
+ if (!childSnapshot || childSnapshot.lifecycle === "closed") {
+ unsubscribe();
+ return;
+ }
+ if (childSnapshot.lifecycle === "running") {
+ hasSeenRunning = true;
+ } else if (childSnapshot.lifecycle === "error") {
+ notify("errored");
+ }
+}
+
+export function sanitizePermissionRequest(
+ permission: AgentPermissionRequest | null | undefined,
+): AgentPermissionRequest | null {
+ if (!permission) {
+ return null;
+ }
+ const sanitized: AgentPermissionRequest = { ...permission };
+ if (sanitized.title === undefined) {
+ delete sanitized.title;
+ }
+ if (sanitized.description === undefined) {
+ delete sanitized.description;
+ }
+ if (sanitized.input === undefined) {
+ delete sanitized.input;
+ }
+ if (sanitized.suggestions === undefined) {
+ delete sanitized.suggestions;
+ }
+ if (sanitized.actions === undefined) {
+ delete sanitized.actions;
+ }
+ if (sanitized.metadata === undefined) {
+ delete sanitized.metadata;
+ }
+ return sanitized;
+}
+
+export async function resolveAgentTitle(
+ agentStorage: AgentStorage,
+ agentId: string,
+ logger: Logger,
+): Promise {
+ try {
+ const record = await agentStorage.get(agentId);
+ return record?.title ?? null;
+ } catch (error) {
+ logger.error({ err: error, agentId }, "Failed to load agent title");
+ return null;
+ }
+}
+
+export async function serializeSnapshotWithMetadata(
+ agentStorage: AgentStorage,
+ snapshot: ManagedAgent,
+ logger: Logger,
+) {
+ const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
+ return serializeAgentSnapshot(snapshot, { title });
+}
+
+export function parseDurationString(input: string): number {
+ const trimmed = input.trim();
+ if (/^\d+$/.test(trimmed)) {
+ return Number.parseInt(trimmed, 10) * 1000;
+ }
+
+ let totalMs = 0;
+ let hasMatch = false;
+ const regex = /(\d+)([smh])/g;
+ let match: RegExpExecArray | null;
+
+ while ((match = regex.exec(trimmed)) !== null) {
+ hasMatch = true;
+ const value = Number.parseInt(match[1], 10);
+ switch (match[2]) {
+ case "s":
+ totalMs += value * 1000;
+ break;
+ case "m":
+ totalMs += value * 60 * 1000;
+ break;
+ case "h":
+ totalMs += value * 60 * 60 * 1000;
+ break;
+ }
+ }
+
+ if (!hasMatch) {
+ throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
+ }
+
+ return totalMs;
+}
+
+export function toScheduleSummary(schedule: z.infer) {
+ const { runs: _runs, ...summary } = schedule;
+ return summary;
+}
diff --git a/packages/server/src/server/agent/model-catalog.e2e.test.ts b/packages/server/src/server/agent/model-catalog.e2e.test.ts
index 0b623d2de..fda1b99cf 100644
--- a/packages/server/src/server/agent/model-catalog.e2e.test.ts
+++ b/packages/server/src/server/agent/model-catalog.e2e.test.ts
@@ -1,8 +1,8 @@
-import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import type { AgentModelDefinition } from "../agent-sdk-types.js";
-import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
+import { createDaemonTestContext } from "../test-utils/index.js";
function isBinaryInstalled(binary: string): boolean {
try {
@@ -16,10 +16,7 @@ function isBinaryInstalled(binary: string): boolean {
const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
-function modelMatchesFamily(
- model: AgentModelDefinition,
- family: "sonnet" | "haiku",
-): boolean {
+function modelMatchesFamily(model: AgentModelDefinition, family: "sonnet" | "haiku"): boolean {
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
value.toLowerCase(),
);
@@ -27,34 +24,34 @@ function modelMatchesFamily(
}
describe("provider model catalogs (e2e)", () => {
- let ctx: DaemonTestContext;
-
- beforeEach(async () => {
- ctx = await createDaemonTestContext();
- });
-
- afterEach(async () => {
- await ctx.cleanup();
- }, 60_000);
-
test("Claude catalog exposes Sonnet and Haiku variants", async () => {
- const result = await ctx.client.listProviderModels("claude");
+ const ctx = await createDaemonTestContext();
+ try {
+ const result = await ctx.client.listProviderModels("claude");
- expect(result.error).toBeNull();
- expect(result.models.length).toBeGreaterThan(0);
+ expect(result.error).toBeNull();
+ expect(result.models.length).toBeGreaterThan(0);
- expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
- expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
+ expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
+ expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
+ } finally {
+ await ctx.cleanup();
+ }
}, 180_000);
test.runIf(hasCodex)(
"Codex catalog exposes gpt-5.1-codex",
async () => {
- const result = await ctx.client.listProviderModels("codex");
+ const ctx = await createDaemonTestContext();
+ try {
+ const result = await ctx.client.listProviderModels("codex");
- expect(result.error).toBeNull();
- const ids = result.models.map((model) => model.id);
- expect(ids.some((id) => id.startsWith("gpt-5.1-codex"))).toBe(true);
+ expect(result.error).toBeNull();
+ const ids = result.models.map((model) => model.id);
+ expect(ids.some((id) => id.includes("codex"))).toBe(true);
+ } finally {
+ await ctx.cleanup();
+ }
},
180_000,
);
@@ -62,22 +59,27 @@ describe("provider model catalogs (e2e)", () => {
test.runIf(hasOpenCode)(
"OpenCode catalog returns models from multiple providers",
async () => {
- const result = await ctx.client.listProviderModels("opencode");
+ const ctx = await createDaemonTestContext();
+ try {
+ const result = await ctx.client.listProviderModels("opencode");
- expect(result.error).toBeNull();
- expect(result.models.length).toBeGreaterThan(0);
+ expect(result.error).toBeNull();
+ expect(result.models.length).toBeGreaterThan(0);
- for (const model of result.models) {
- expect(model.provider).toBe("opencode");
- expect(model.id).toContain("/");
- expect(model.label).toBeTruthy();
- expect(model.metadata).toBeDefined();
- expect(model.metadata?.providerId).toBeTruthy();
- expect(model.metadata?.modelId).toBeTruthy();
+ for (const model of result.models) {
+ expect(model.provider).toBe("opencode");
+ expect(model.id).toContain("/");
+ expect(model.label).toBeTruthy();
+ expect(model.metadata).toBeDefined();
+ expect(model.metadata?.providerId).toBeTruthy();
+ expect(model.metadata?.modelId).toBeTruthy();
+ }
+
+ const providerIds = new Set(result.models.map((m) => m.metadata?.providerId));
+ expect(providerIds.size).toBeGreaterThan(0);
+ } finally {
+ await ctx.cleanup();
}
-
- const providerIds = new Set(result.models.map((m) => m.metadata?.providerId));
- expect(providerIds.size).toBeGreaterThan(0);
},
180_000,
);
diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts
index 1cc2c44f9..4f18542ec 100644
--- a/packages/server/src/server/agent/provider-manifest.ts
+++ b/packages/server/src/server/agent/provider-manifest.ts
@@ -138,7 +138,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
voice: {
enabled: true,
defaultModeId: "auto",
- defaultModel: "gpt-5.1-codex-mini",
+ defaultModel: "gpt-5.4-mini",
},
},
{
diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts
index 93ddfba54..726c56514 100644
--- a/packages/server/src/server/agent/provider-registry.ts
+++ b/packages/server/src/server/agent/provider-registry.ts
@@ -59,7 +59,8 @@ const PROVIDER_CLIENT_FACTORIES: Record = {
runtimeSettings: runtimeSettings?.copilot,
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
- pi: (logger, runtimeSettings) => new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
+ pi: (logger, runtimeSettings) =>
+ new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
};
function getProviderClientFactory(provider: string): ProviderClientFactory {
diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts
index e47495505..44110d40e 100644
--- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts
+++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts
@@ -236,9 +236,7 @@ describe("ProviderSnapshotManager", () => {
});
manager.refresh("/tmp/project");
- expect(manager.getSnapshot("/tmp/project")).toEqual([
- { provider: "codex", status: "loading" },
- ]);
+ expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe(
@@ -267,9 +265,7 @@ describe("ProviderSnapshotManager", () => {
manager.refresh("/tmp/project");
- expect(manager.getSnapshot("/tmp/project")).toEqual([
- { provider: "codex", status: "loading" },
- ]);
+ expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
manager.refresh("/tmp/project");
manager.refresh("/tmp/project");
@@ -347,8 +343,12 @@ describe("ProviderSnapshotManager", () => {
manager.getSnapshot("/tmp/project-b");
await vi.waitFor(() => {
- expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe("ready");
- expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe("ready");
+ expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe(
+ "ready",
+ );
+ expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe(
+ "ready",
+ );
});
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe(
@@ -381,19 +381,24 @@ function createRegistry(handles: MockProviderHandle[]): {
registry: Object.fromEntries(
handles.map((handle) => [handle.definition.id, handle.definition]),
) as Record,
- handles: Object.fromEntries(
- handles.map((handle) => [handle.definition.id, handle]),
- ) as Record,
+ handles: Object.fromEntries(handles.map((handle) => [handle.definition.id, handle])) as Record<
+ AgentProvider,
+ MockProviderHandle
+ >,
};
}
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
- const fetchModels = vi.fn(async (listOptions?: { cwd?: string }) =>
- options.fetchModels?.(listOptions?.cwd) ?? [createModel(options.provider, `${options.provider}-default`)],
+ const fetchModels = vi.fn(
+ async (listOptions?: { cwd?: string }) =>
+ options.fetchModels?.(listOptions?.cwd) ?? [
+ createModel(options.provider, `${options.provider}-default`),
+ ],
);
- const fetchModes = vi.fn(async (listOptions?: { cwd?: string }) =>
- options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)],
+ const fetchModes = vi.fn(
+ async (listOptions?: { cwd?: string }) =>
+ options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)],
);
const definition: ProviderDefinition = {
diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts
index 78b56ce70..b23a21d36 100644
--- a/packages/server/src/server/agent/provider-snapshot-manager.ts
+++ b/packages/server/src/server/agent/provider-snapshot-manager.ts
@@ -3,19 +3,13 @@ import { resolve } from "node:path";
import type { Logger } from "pino";
-import type {
- AgentProvider,
- ProviderSnapshotEntry,
-} from "./agent-sdk-types.js";
+import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
const DEFAULT_CWD_KEY = "__default__";
-type ProviderSnapshotChangeListener = (
- entries: ProviderSnapshotEntry[],
- cwd?: string,
-) => void;
+type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
export class ProviderSnapshotManager {
private readonly snapshots = new Map>();
@@ -146,7 +140,10 @@ export class ProviderSnapshotManager {
status: "error",
error: toErrorMessage(error),
});
- this.logger.warn({ err: error, provider, cwd: cwdKey }, "Failed to refresh provider snapshot");
+ this.logger.warn(
+ { err: error, provider, cwd: cwdKey },
+ "Failed to refresh provider snapshot",
+ );
this.emitChange(cwdKey);
}
}
diff --git a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts
index 02ff31a7c..11bd1923d 100644
--- a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts
+++ b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts
@@ -55,10 +55,7 @@ function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamE
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
return events.filter(
- (e) =>
- e.type === "timeline" &&
- e.item.type === "user_message" &&
- e.item.text === text,
+ (e) => e.type === "timeline" && e.item.type === "user_message" && e.item.text === text,
);
}
@@ -185,12 +182,8 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
}
// Invariant 4: Autonomous turns have distinct turnIds from foreground turns
- const allTurnIds = new Set(
- events.filter(hasTurnId).map((e) => e.turnId),
- );
- const autonomousTurnIds = [...allTurnIds].filter(
- (id) => !foregroundTurnIds.includes(id),
- );
+ const allTurnIds = new Set(events.filter(hasTurnId).map((e) => e.turnId));
+ const autonomousTurnIds = [...allTurnIds].filter((id) => !foregroundTurnIds.includes(id));
for (const autoId of autonomousTurnIds) {
expect(foregroundTurnIds).not.toContain(autoId);
}
@@ -201,300 +194,332 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
// ---------------------------------------------------------------------------
describe("Agent event stream redesign — integration", () => {
- test.skipIf(!canRun)("Test 1: Basic foreground turn", async () => {
- const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
+ test.skipIf(!canRun)(
+ "Test 1: Basic foreground turn",
+ async () => {
+ const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
- try {
- const { turnId, events } = await startTurnAndCollectEvents(
- handle.session,
- "respond with just the word hello",
- );
+ try {
+ const { turnId, events } = await startTurnAndCollectEvents(
+ handle.session,
+ "respond with just the word hello",
+ );
- const turnStarted = events.find(
- (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
- );
- expect(turnStarted).toBeDefined();
-
- const terminal = events.find(
- (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
- );
- expect(terminal).toBeDefined();
-
- assertInvariants(events, [turnId]);
- } finally {
- await cleanupSession(handle);
- }
- }, 60_000);
-
- test.skipIf(!canRun)("Test 2: No duplicate user_messages — THE BUG", async () => {
- const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
-
- try {
- const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
- extraMs: 3_000,
- });
-
- expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
-
- // No turn_started after terminal for the same turnId
- const terminalIdx = events.findIndex(
- (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
- );
- const staleTurnStarted = events.slice(terminalIdx + 1).filter(
- (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
- );
- expect(staleTurnStarted.length).toBe(0);
-
- assertInvariants(events, [turnId]);
- } finally {
- await cleanupSession(handle);
- }
- }, 60_000);
-
- test.skipIf(!canRun)("Test 3: Lifecycle doesn't get stuck in running", async () => {
- const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
-
- try {
- const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
- extraMs: 3_000,
- });
-
- const terminalIdx = events.findIndex(
- (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
- );
- const afterTerminal = events.slice(terminalIdx + 1);
-
- // No subsequent turn_started for same turnId
- expect(
- afterTerminal.filter(
+ const turnStarted = events.find(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
- ).length,
- ).toBe(0);
+ );
+ expect(turnStarted).toBeDefined();
- // Any turn_started after terminal must have a different turnId
- for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
- expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
+ const terminal = events.find(
+ (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
+ );
+ expect(terminal).toBeDefined();
+
+ assertInvariants(events, [turnId]);
+ } finally {
+ await cleanupSession(handle);
}
+ },
+ 60_000,
+ );
- assertInvariants(events, [turnId]);
- } finally {
- await cleanupSession(handle);
- }
- }, 60_000);
+ test.skipIf(!canRun)(
+ "Test 2: No duplicate user_messages — THE BUG",
+ async () => {
+ const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
- test.skipIf(!canRun)("Test 4: Autonomous run", async () => {
- const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
- const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
-
- try {
- const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
- handle.session,
- [
- "Use the Task tool to start a background sub-agent.",
- "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
- "Do not wait for task completion.",
- "Reply immediately with exactly: SPAWNED",
- `When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
- ].join(" "),
- {
- extraMs: 10_000,
- timeoutMs: 60_000,
- },
- );
-
- const fgTerminalIdx = events.findIndex(
- (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
- );
- const afterForeground = events.slice(fgTerminalIdx + 1);
-
- // Autonomous turn_started with a different turnId
- const autoStarts = afterForeground.filter(
- (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
- ) as EventWithTurnId[];
- if (autoStarts.length === 0) {
- assertInvariants(events, [fgTurnId]);
- return;
- }
-
- const autoTurnId = autoStarts[0]!.turnId;
- expect(fgTurnId).not.toBe(autoTurnId);
-
- // Autonomous turn reaches terminal
- expect(
- afterForeground.find(
- (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId,
- ),
- ).toBeDefined();
-
- assertInvariants(events, [fgTurnId]);
- } finally {
- await cleanupSession(handle);
- }
- }, 90_000);
-
- test.skipIf(!canRun)("Test 5: Interruption", async () => {
- const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
-
- try {
- let turnId: string | null = null;
- const events = await new Promise((resolve, reject) => {
- const collected: AgentStreamEvent[] = [];
- let interrupted = false;
-
- const timeout = setTimeout(() => {
- unsubscribe();
- reject(new Error("Timed out after 45000ms waiting for terminal event"));
- }, 45_000);
-
- const unsubscribe = handle.session.subscribe((event) => {
- collected.push(event);
- if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
- turnId = event.turnId;
- }
-
- // Once we see turn_started, fire the interrupt
- if (
- !interrupted &&
- turnId &&
- event.type === "turn_started" &&
- hasTurnId(event) &&
- event.turnId === turnId
- ) {
- interrupted = true;
- handle.session.interrupt().catch(() => undefined);
- }
-
- // Resolve when we get a terminal event for this turn
- if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
- clearTimeout(timeout);
- unsubscribe();
- resolve(collected);
- }
+ try {
+ const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
+ extraMs: 3_000,
});
- void handle.session
- .startTurn("write a very long essay about the history of computing")
- .then((result) => {
- if (turnId && turnId !== result.turnId) {
+ expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
+
+ // No turn_started after terminal for the same turnId
+ const terminalIdx = events.findIndex(
+ (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
+ );
+ const staleTurnStarted = events
+ .slice(terminalIdx + 1)
+ .filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId);
+ expect(staleTurnStarted.length).toBe(0);
+
+ assertInvariants(events, [turnId]);
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 60_000,
+ );
+
+ test.skipIf(!canRun)(
+ "Test 3: Lifecycle doesn't get stuck in running",
+ async () => {
+ const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
+
+ try {
+ const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
+ extraMs: 3_000,
+ });
+
+ const terminalIdx = events.findIndex(
+ (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
+ );
+ const afterTerminal = events.slice(terminalIdx + 1);
+
+ // No subsequent turn_started for same turnId
+ expect(
+ afterTerminal.filter(
+ (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
+ ).length,
+ ).toBe(0);
+
+ // Any turn_started after terminal must have a different turnId
+ for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
+ expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
+ }
+
+ assertInvariants(events, [turnId]);
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 60_000,
+ );
+
+ test.skipIf(!canRun)(
+ "Test 4: Autonomous run",
+ async () => {
+ const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
+ const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
+
+ try {
+ const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
+ handle.session,
+ [
+ "Use the Task tool to start a background sub-agent.",
+ "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
+ "Do not wait for task completion.",
+ "Reply immediately with exactly: SPAWNED",
+ `When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
+ ].join(" "),
+ {
+ extraMs: 10_000,
+ timeoutMs: 60_000,
+ },
+ );
+
+ const fgTerminalIdx = events.findIndex(
+ (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
+ );
+ const afterForeground = events.slice(fgTerminalIdx + 1);
+
+ // Autonomous turn_started with a different turnId
+ const autoStarts = afterForeground.filter(
+ (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
+ ) as EventWithTurnId[];
+ if (autoStarts.length === 0) {
+ assertInvariants(events, [fgTurnId]);
+ return;
+ }
+
+ const autoTurnId = autoStarts[0]!.turnId;
+ expect(fgTurnId).not.toBe(autoTurnId);
+
+ // Autonomous turn reaches terminal
+ expect(
+ afterForeground.find(
+ (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId,
+ ),
+ ).toBeDefined();
+
+ assertInvariants(events, [fgTurnId]);
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 90_000,
+ );
+
+ test.skipIf(!canRun)(
+ "Test 5: Interruption",
+ async () => {
+ const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
+
+ try {
+ let turnId: string | null = null;
+ const events = await new Promise((resolve, reject) => {
+ const collected: AgentStreamEvent[] = [];
+ let interrupted = false;
+
+ const timeout = setTimeout(() => {
+ unsubscribe();
+ reject(new Error("Timed out after 45000ms waiting for terminal event"));
+ }, 45_000);
+
+ const unsubscribe = handle.session.subscribe((event) => {
+ collected.push(event);
+ if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
+ turnId = event.turnId;
+ }
+
+ // Once we see turn_started, fire the interrupt
+ if (
+ !interrupted &&
+ turnId &&
+ event.type === "turn_started" &&
+ hasTurnId(event) &&
+ event.turnId === turnId
+ ) {
+ interrupted = true;
+ handle.session.interrupt().catch(() => undefined);
+ }
+
+ // Resolve when we get a terminal event for this turn
+ if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
- reject(
- new Error(
- `Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
- ),
- );
- return;
+ resolve(collected);
}
- turnId = result.turnId;
- })
- .catch((error) => {
- clearTimeout(timeout);
- unsubscribe();
- reject(error);
});
- });
- expect(turnId).toBeDefined();
+ void handle.session
+ .startTurn("write a very long essay about the history of computing")
+ .then((result) => {
+ if (turnId && turnId !== result.turnId) {
+ clearTimeout(timeout);
+ unsubscribe();
+ reject(
+ new Error(
+ `Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
+ ),
+ );
+ return;
+ }
+ turnId = result.turnId;
+ })
+ .catch((error) => {
+ clearTimeout(timeout);
+ unsubscribe();
+ reject(error);
+ });
+ });
- // turn_canceled or turn_failed arrives for that turnId
- const terminal = events.find(
- (e) =>
- (e.type === "turn_canceled" || e.type === "turn_failed") &&
- hasTurnId(e) &&
- e.turnId === turnId,
- );
- expect(terminal).toBeDefined();
+ expect(turnId).toBeDefined();
- // No further events for that turnId after terminal
- const terminalIdx = events.indexOf(terminal!);
- expect(
- events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
- ).toBe(0);
+ // turn_canceled or turn_failed arrives for that turnId
+ const terminal = events.find(
+ (e) =>
+ (e.type === "turn_canceled" || e.type === "turn_failed") &&
+ hasTurnId(e) &&
+ e.turnId === turnId,
+ );
+ expect(terminal).toBeDefined();
- assertInvariants(events, [turnId]);
- } finally {
- await cleanupSession(handle);
- }
- }, 60_000);
+ // No further events for that turnId after terminal
+ const terminalIdx = events.indexOf(terminal!);
+ expect(
+ events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
+ ).toBe(0);
- test.skipIf(!canRun)("Test 6: Sequential foreground turns", async () => {
- const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
+ assertInvariants(events, [turnId]);
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 60_000,
+ );
- try {
- const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
- handle.session,
- "say first",
- );
+ test.skipIf(!canRun)(
+ "Test 6: Sequential foreground turns",
+ async () => {
+ const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
- const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
- handle.session,
- "say second",
- );
+ try {
+ const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
+ handle.session,
+ "say first",
+ );
- const allEvents = [...events1, ...events2];
+ const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
+ handle.session,
+ "say second",
+ );
- expect(turnId1).not.toBe(turnId2);
+ const allEvents = [...events1, ...events2];
- // No events from turn 1 after turn 2 starts
- const turn2StartIdx = allEvents.findIndex(
- (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
- );
- expect(
- allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
- .length,
- ).toBe(0);
+ expect(turnId1).not.toBe(turnId2);
- assertInvariants(allEvents, [turnId1, turnId2]);
- } finally {
- await cleanupSession(handle);
- }
- }, 90_000);
+ // No events from turn 1 after turn 2 starts
+ const turn2StartIdx = allEvents.findIndex(
+ (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
+ );
+ expect(
+ allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
+ .length,
+ ).toBe(0);
- test.skipIf(!canRun)("Test 7: Fast-fail", async () => {
- const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
+ assertInvariants(allEvents, [turnId1, turnId2]);
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 90_000,
+ );
- try {
- const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
- extraMs: 3_000,
- });
+ test.skipIf(!canRun)(
+ "Test 7: Fast-fail",
+ async () => {
+ const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
- // At most one turn_started
- expect(
- events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
- .length,
- ).toBeLessThanOrEqual(1);
+ try {
+ const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
+ extraMs: 3_000,
+ });
- // Terminal present
- const terminal = events.find(
- (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
- );
- expect(terminal).toBeDefined();
+ // At most one turn_started
+ expect(
+ events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
+ .length,
+ ).toBeLessThanOrEqual(1);
- // No stale turn_started after terminal
- const terminalIdx = events.indexOf(terminal!);
- expect(
- events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length,
- ).toBe(0);
+ // Terminal present
+ const terminal = events.find(
+ (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
+ );
+ expect(terminal).toBeDefined();
- assertInvariants(events, [turnId]);
- } finally {
- await cleanupSession(handle);
- }
- }, 60_000);
+ // No stale turn_started after terminal
+ const terminalIdx = events.indexOf(terminal!);
+ expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(
+ 0,
+ );
- test.skipIf(!canRun)("Test 8: User message dedup by text", async () => {
- const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
+ assertInvariants(events, [turnId]);
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 60_000,
+ );
- try {
- const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
- extraMs: 3_000,
- });
+ test.skipIf(!canRun)(
+ "Test 8: User message dedup by text",
+ async () => {
+ const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
- expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
+ try {
+ const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
+ extraMs: 3_000,
+ });
- assertInvariants(events, [turnId]);
- } finally {
- await cleanupSession(handle);
- }
- }, 60_000);
+ expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
+
+ assertInvariants(events, [turnId]);
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 60_000,
+ );
});
diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts
index a5bad22f5..f413a412b 100644
--- a/packages/server/src/server/agent/providers/acp-agent.test.ts
+++ b/packages/server/src/server/agent/providers/acp-agent.test.ts
@@ -75,23 +75,19 @@ describe("deriveModesFromACP", () => {
});
test("falls back to config options when explicit mode state is absent", () => {
- const result = deriveModesFromACP(
- [{ id: "fallback", label: "Fallback" }],
- null,
- [
- {
- id: "mode",
- name: "Mode",
- category: "mode",
- type: "select",
- currentValue: "acceptEdits",
- options: [
- { value: "default", name: "Always Ask" },
- { value: "acceptEdits", name: "Accept File Edits" },
- ],
- },
- ],
- );
+ const result = deriveModesFromACP([{ id: "fallback", label: "Fallback" }], null, [
+ {
+ id: "mode",
+ name: "Mode",
+ category: "mode",
+ type: "select",
+ currentValue: "acceptEdits",
+ options: [
+ { value: "default", name: "Always Ask" },
+ { value: "acceptEdits", name: "Accept File Edits" },
+ ],
+ },
+ ]);
expect(result).toEqual({
modes: [
@@ -103,13 +99,43 @@ describe("deriveModesFromACP", () => {
});
test("returns an empty mode list when fallback modes are empty and config only exposes thought levels", () => {
- const result = deriveModesFromACP(
- [],
- null,
+ const result = deriveModesFromACP([], null, [
+ {
+ id: "thought_level",
+ name: "Thinking",
+ category: "thought_level",
+ type: "select",
+ currentValue: "medium",
+ options: [
+ { value: "low", name: "Low" },
+ { value: "medium", name: "Medium" },
+ { value: "high", name: "High" },
+ ],
+ },
+ ]);
+
+ expect(result).toEqual({
+ modes: [],
+ currentModeId: null,
+ });
+ });
+});
+
+describe("deriveModelDefinitionsFromACP", () => {
+ test("attaches shared thinking options to ACP model state", () => {
+ const result = deriveModelDefinitionsFromACP(
+ "claude-acp",
+ {
+ availableModels: [
+ { modelId: "haiku", name: "Haiku", description: "Fast" },
+ { modelId: "sonnet", name: "Sonnet", description: "Balanced" },
+ ],
+ currentModelId: "haiku",
+ },
[
{
- id: "thought_level",
- name: "Thinking",
+ id: "reasoning",
+ name: "Reasoning",
category: "thought_level",
type: "select",
currentValue: "medium",
@@ -122,36 +148,6 @@ describe("deriveModesFromACP", () => {
],
);
- expect(result).toEqual({
- modes: [],
- currentModeId: null,
- });
- });
-});
-
-describe("deriveModelDefinitionsFromACP", () => {
- test("attaches shared thinking options to ACP model state", () => {
- const result = deriveModelDefinitionsFromACP("claude-acp", {
- availableModels: [
- { modelId: "haiku", name: "Haiku", description: "Fast" },
- { modelId: "sonnet", name: "Sonnet", description: "Balanced" },
- ],
- currentModelId: "haiku",
- }, [
- {
- id: "reasoning",
- name: "Reasoning",
- category: "thought_level",
- type: "select",
- currentValue: "medium",
- options: [
- { value: "low", name: "Low" },
- { value: "medium", name: "Medium" },
- { value: "high", name: "High" },
- ],
- },
- ]);
-
expect(result).toEqual([
{
provider: "claude-acp",
@@ -160,9 +156,27 @@ describe("deriveModelDefinitionsFromACP", () => {
description: "Fast",
isDefault: true,
thinkingOptions: [
- { id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
- { id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
- { id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
+ {
+ id: "low",
+ label: "Low",
+ description: undefined,
+ isDefault: false,
+ metadata: undefined,
+ },
+ {
+ id: "medium",
+ label: "Medium",
+ description: undefined,
+ isDefault: true,
+ metadata: undefined,
+ },
+ {
+ id: "high",
+ label: "High",
+ description: undefined,
+ isDefault: false,
+ metadata: undefined,
+ },
],
defaultThinkingOptionId: "medium",
},
@@ -173,9 +187,27 @@ describe("deriveModelDefinitionsFromACP", () => {
description: "Balanced",
isDefault: false,
thinkingOptions: [
- { id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
- { id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
- { id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
+ {
+ id: "low",
+ label: "Low",
+ description: undefined,
+ isDefault: false,
+ metadata: undefined,
+ },
+ {
+ id: "medium",
+ label: "Medium",
+ description: undefined,
+ isDefault: true,
+ metadata: undefined,
+ },
+ {
+ id: "high",
+ label: "High",
+ description: undefined,
+ isDefault: false,
+ metadata: undefined,
+ },
],
defaultThinkingOptionId: "medium",
},
@@ -604,9 +636,7 @@ describe("ACPAgentSession", () => {
thinkingOptionId: null,
modeId: "xhigh",
})),
- getAvailableModes: vi.fn(async () => [
- { id: "xhigh", label: "xhigh" },
- ]),
+ getAvailableModes: vi.fn(async () => [{ id: "xhigh", label: "xhigh" }]),
getCurrentMode: vi.fn(async () => "xhigh"),
setMode: vi.fn(),
getPendingPermissions: vi.fn(() => []),
diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts
index 43fc4e370..caff01eb0 100644
--- a/packages/server/src/server/agent/providers/acp-agent.ts
+++ b/packages/server/src/server/agent/providers/acp-agent.ts
@@ -1,7 +1,4 @@
-import {
- type ChildProcess,
- type ChildProcessWithoutNullStreams,
-} from "node:child_process";
+import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
@@ -108,8 +105,7 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
terminal: true,
};
-const COPILOT_AUTOPILOT_MODE =
- "https://agentclientprotocol.com/protocol/session-modes#autopilot";
+const COPILOT_AUTOPILOT_MODE = "https://agentclientprotocol.com/protocol/session-modes#autopilot";
type ACPAgentClientOptions = {
provider: string;
@@ -486,14 +482,15 @@ export class ACPAgentClient implements AgentClient {
}
}
- protected async spawnProcess(
- launchEnv?: Record,
- ): Promise {
+ protected async spawnProcess(launchEnv?: Record): Promise {
const { command, args } = await this.resolveLaunchCommand();
const child = spawnProcess(command, args, {
cwd: process.cwd(),
env: {
- ...applyProviderEnv(process.env as Record, this.runtimeSettings),
+ ...applyProviderEnv(
+ process.env as Record,
+ this.runtimeSettings,
+ ),
...(launchEnv ?? {}),
},
stdio: ["pipe", "pipe", "pipe"],
@@ -805,7 +802,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
};
}
- async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> {
+ async startTurn(
+ prompt: AgentPromptInput,
+ _options?: AgentRunOptions,
+ ): Promise<{ turnId: string }> {
if (this.closed) {
throw new Error(`${this.provider} session is closed`);
}
@@ -1156,9 +1156,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.activeForegroundTurnId = null;
}
- async requestPermission(
- params: RequestPermissionRequest,
- ): Promise {
+ async requestPermission(params: RequestPermissionRequest): Promise {
if (shouldAutoApprovePermissionRequest(this.provider, this.currentMode)) {
const selectedOption = selectPermissionOption(params.options, { behavior: "allow" });
return selectedOption
@@ -1178,12 +1176,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (this.toolSnapshotTransformer) {
toolSnapshot = this.toolSnapshotTransformer(toolSnapshot);
}
- const request = mapPermissionRequest(
- this.provider,
- requestId,
- params,
- toolSnapshot,
- );
+ const request = mapPermissionRequest(this.provider, requestId, params, toolSnapshot);
const promise = new Promise((resolve, reject) => {
this.pendingPermissions.set(requestId, {
@@ -1243,11 +1236,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> {
const terminalId = randomUUID();
- const env = Object.fromEntries((params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]));
+ const env = Object.fromEntries(
+ (params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
+ );
const child = spawnProcess(params.command, params.args ?? [], {
cwd: params.cwd ?? this.config.cwd,
env: {
- ...applyProviderEnv(process.env as Record, this.runtimeSettings),
+ ...applyProviderEnv(
+ process.env as Record,
+ this.runtimeSettings,
+ ),
...env,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -1272,9 +1270,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
rejectExit,
};
- child.stdout!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
- child.stderr!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
- child.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error))));
+ child.stdout!.on("data", (chunk: Buffer | string) =>
+ appendTerminalOutput(entry, chunk.toString()),
+ );
+ child.stderr!.on("data", (chunk: Buffer | string) =>
+ appendTerminalOutput(entry, chunk.toString()),
+ );
+ child.once("error", (error) =>
+ rejectExit(error instanceof Error ? error : new Error(String(error))),
+ );
child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal };
entry.exit = exit;
@@ -1329,7 +1333,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const child = spawnProcess(command, args, {
cwd: this.config.cwd,
env: {
- ...applyProviderEnv(process.env as Record, this.runtimeSettings),
+ ...applyProviderEnv(
+ process.env as Record,
+ this.runtimeSettings,
+ ),
...(this.launchEnv ?? {}),
},
stdio: ["pipe", "pipe", "pipe"],
@@ -1519,7 +1526,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private deriveAvailableModels(
models: SessionModelState | null | undefined,
): AgentModelDefinition[] {
- const availableModels = deriveModelDefinitionsFromACP(this.provider, models, this.configOptions);
+ const availableModels = deriveModelDefinitionsFromACP(
+ this.provider,
+ models,
+ this.configOptions,
+ );
return this.modelTransformer ? this.modelTransformer(availableModels) : availableModels;
}
@@ -1579,7 +1590,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
- private finishTurn(event: Extract): void {
+ private finishTurn(
+ event: Extract,
+ ): void {
this.activeForegroundTurnId = null;
this.suppressUserEchoMessageId = null;
this.suppressUserEchoText = null;
@@ -1624,7 +1637,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return parts.length > 0 ? parts.join(" | ") : undefined;
}
- private getSelectConfigOption(category: string): Extract | null {
+ private getSelectConfigOption(
+ category: string,
+ ): Extract | null {
const option = this.configOptions.find(
(entry): entry is Extract =>
entry.type === "select" && entry.category === category,
@@ -1644,7 +1659,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
function flattenSelectOptions(
options: Extract["options"],
): Array<{ value: string; name: string; description?: string | null; group?: string }> {
- const flattened: Array<{ value: string; name: string; description?: string | null; group?: string }> = [];
+ const flattened: Array<{
+ value: string;
+ name: string;
+ description?: string | null;
+ group?: string;
+ }> = [];
for (const option of options) {
if ("value" in option) {
flattened.push(option);
@@ -1756,7 +1776,9 @@ function extractPromptText(prompt: AgentPromptInput): string {
return prompt;
}
return prompt
- .filter((block): block is Extract => block.type === "text")
+ .filter(
+ (block): block is Extract => block.type === "text",
+ )
.map((block) => block.text)
.join("");
}
@@ -1768,7 +1790,9 @@ function contentBlockToText(content: ContentBlock): string {
case "resource_link":
return content.title ?? content.uri;
case "resource":
- return "text" in content.resource ? content.resource.text : `[resource:${content.resource.mimeType ?? "binary"}]`;
+ return "text" in content.resource
+ ? content.resource.text
+ : `[resource:${content.resource.mimeType ?? "binary"}]`;
case "image":
return "[image]";
case "audio":
@@ -1789,8 +1813,8 @@ function mergeToolSnapshot(
title: (update.title ?? previous?.title ?? toolCallId) as string,
kind: update.kind ?? previous?.kind ?? null,
status: update.status ?? previous?.status ?? null,
- content: update.content !== undefined ? update.content : previous?.content ?? null,
- locations: update.locations !== undefined ? update.locations : previous?.locations ?? null,
+ content: update.content !== undefined ? update.content : (previous?.content ?? null),
+ locations: update.locations !== undefined ? update.locations : (previous?.locations ?? null),
rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput,
rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput,
...(isFull ? {} : {}),
@@ -1857,7 +1881,10 @@ function mapToolStatus(status: ToolCallStatus | null | undefined): ToolCallTimel
}
}
-function mapToolDetail(snapshot: ACPToolSnapshot, terminals: Map): ToolCallDetail {
+function mapToolDetail(
+ snapshot: ACPToolSnapshot,
+ terminals: Map,
+): ToolCallDetail {
const firstLocation = snapshot.locations?.[0]?.path;
const textContent = extractToolText(snapshot.content);
const diffContent = extractDiffContent(snapshot.content);
@@ -1885,7 +1912,7 @@ function mapToolDetail(snapshot: ACPToolSnapshot, terminals: Map => item.type === "diff");
+ const diff = content?.find(
+ (item): item is Extract => item.type === "diff",
+ );
return diff ? { oldText: diff.oldText ?? undefined, newText: diff.newText } : null;
}
@@ -2067,10 +2096,7 @@ function readRecord(value: unknown): Record | null {
: null;
}
-function readString(
- record: Record | null,
- keys: string[],
-): string | undefined {
+function readString(record: Record | null, keys: string[]): string | undefined {
if (!record) {
return undefined;
}
@@ -2083,10 +2109,7 @@ function readString(
return undefined;
}
-function readNumber(
- record: Record | null,
- keys: string[],
-): number | undefined {
+function readNumber(record: Record | null, keys: string[]): number | undefined {
if (!record) {
return undefined;
}
@@ -2135,7 +2158,9 @@ function stringifyUnknown(value: unknown): string | undefined {
}
}
-function coerceSessionConfigMetadata(metadata: AgentMetadata | undefined): Partial {
+function coerceSessionConfigMetadata(
+ metadata: AgentMetadata | undefined,
+): Partial {
if (!metadata || typeof metadata !== "object") {
return {};
}
diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts
index a9e58a59a..74d25c9fe 100644
--- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts
@@ -182,36 +182,40 @@ describe("ClaudeAgentSession integration", () => {
}
});
- test.runIf(canRunClaudeIntegration)("streams a basic response turn end-to-end", async () => {
- const handle = await createSession({
- cwdPrefix: "claude-agent-basic-response-",
- });
-
- try {
- const events = await collectUntilTerminal(
- streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
- );
-
- expect(events[0]).toMatchObject({
- type: "turn_started",
- provider: "claude",
+ test.runIf(canRunClaudeIntegration)(
+ "streams a basic response turn end-to-end",
+ async () => {
+ const handle = await createSession({
+ cwdPrefix: "claude-agent-basic-response-",
});
- expect(
- events.some(
- (event) =>
- event.type === "timeline" &&
- event.item.type === "assistant_message" &&
- compactText(event.item.text).includes("hello_world"),
- ),
- ).toBe(true);
- expect(events.at(-1)).toMatchObject({
- type: "turn_completed",
- provider: "claude",
- });
- } finally {
- await cleanupSession(handle);
- }
- }, 60_000);
+
+ try {
+ const events = await collectUntilTerminal(
+ streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
+ );
+
+ expect(events[0]).toMatchObject({
+ type: "turn_started",
+ provider: "claude",
+ });
+ expect(
+ events.some(
+ (event) =>
+ event.type === "timeline" &&
+ event.item.type === "assistant_message" &&
+ compactText(event.item.text).includes("hello_world"),
+ ),
+ ).toBe(true);
+ expect(events.at(-1)).toMatchObject({
+ type: "turn_completed",
+ provider: "claude",
+ });
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 60_000,
+ );
test.runIf(canRunClaudeIntegration)(
"keeps bypassPermissions available after a thinking-option restart",
@@ -276,100 +280,104 @@ describe("ClaudeAgentSession integration", () => {
60_000,
);
- test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => {
- const handle = await createSession({
- cwdPrefix: "claude-agent-basic-tool-",
- });
-
- try {
- const events = await collectUntilTerminal(
- streamSession(
- handle.session,
- [
- "Use the Bash tool.",
- "Run exactly: echo TOOL_TEST_OUTPUT",
- "After the command completes, reply with exactly: TOOL_DONE",
- ].join(" "),
- ),
- );
-
- const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
- const completedBashCall = getLatestCompletedBashCall(events);
-
- expect(bashCalls.length).toBeGreaterThan(0);
- expect(completedBashCall).toBeDefined();
- expect(completedBashCall?.detail.type).toBe("shell");
- expect(
- completedBashCall?.detail.type === "shell" &&
- completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
- ).toBe(true);
- expect(compactText(getAssistantText(events))).toContain("tool_done");
- expect(events.at(-1)).toMatchObject({
- type: "turn_completed",
- provider: "claude",
+ test.runIf(canRunClaudeIntegration)(
+ "runs a real Bash tool call and completes it",
+ async () => {
+ const handle = await createSession({
+ cwdPrefix: "claude-agent-basic-tool-",
});
- } finally {
- await cleanupSession(handle);
- }
- }, 60_000);
+
+ try {
+ const events = await collectUntilTerminal(
+ streamSession(
+ handle.session,
+ [
+ "Use the Bash tool.",
+ "Run exactly: echo TOOL_TEST_OUTPUT",
+ "After the command completes, reply with exactly: TOOL_DONE",
+ ].join(" "),
+ ),
+ );
+
+ const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
+ const completedBashCall = getLatestCompletedBashCall(events);
+
+ expect(bashCalls.length).toBeGreaterThan(0);
+ expect(completedBashCall).toBeDefined();
+ expect(completedBashCall?.detail.type).toBe("shell");
+ expect(
+ completedBashCall?.detail.type === "shell" &&
+ completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
+ ).toBe(true);
+ expect(compactText(getAssistantText(events))).toContain("tool_done");
+ expect(events.at(-1)).toMatchObject({
+ type: "turn_completed",
+ provider: "claude",
+ });
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 60_000,
+ );
test.runIf(canRunClaudeIntegration)(
"interrupts a running Bash turn and continues on the same query",
async () => {
- const handle = await createSession({
- cwdPrefix: "claude-agent-interrupt-continue-",
- });
-
- try {
- const firstStream = streamSession(
- handle.session,
- [
- "Use the Bash tool.",
- "Run exactly: sleep 10",
- "Do not use a background task.",
- "Do not do anything after starting the command.",
- ].join(" "),
- );
-
- const initialEvents = await collectUntil(
- firstStream,
- (event) =>
- event.type === "timeline" &&
- event.item.type === "tool_call" &&
- event.item.name.toLowerCase() === "bash",
- 45_000,
- );
- const firstQuery = getInternalQuery(handle.session);
-
- expect(firstQuery).toBeTruthy();
-
- await handle.session.interrupt();
-
- const canceledEvents = await collectUntilTerminal(firstStream, {
- timeoutMs: 20_000,
+ const handle = await createSession({
+ cwdPrefix: "claude-agent-interrupt-continue-",
});
- const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
- expect(
- allFirstTurnEvents.some(
- (event) => event.type === "turn_canceled" && event.provider === "claude",
- ),
- ).toBe(true);
+ try {
+ const firstStream = streamSession(
+ handle.session,
+ [
+ "Use the Bash tool.",
+ "Run exactly: sleep 10",
+ "Do not use a background task.",
+ "Do not do anything after starting the command.",
+ ].join(" "),
+ );
- const followUpEvents = await collectUntilTerminal(
- streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
- );
- const secondQuery = getInternalQuery(handle.session);
+ const initialEvents = await collectUntil(
+ firstStream,
+ (event) =>
+ event.type === "timeline" &&
+ event.item.type === "tool_call" &&
+ event.item.name.toLowerCase() === "bash",
+ 45_000,
+ );
+ const firstQuery = getInternalQuery(handle.session);
- expect(secondQuery).toBe(firstQuery);
- expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
- expect(followUpEvents.at(-1)).toMatchObject({
- type: "turn_completed",
- provider: "claude",
- });
- } finally {
- await cleanupSession(handle);
- }
+ expect(firstQuery).toBeTruthy();
+
+ await handle.session.interrupt();
+
+ const canceledEvents = await collectUntilTerminal(firstStream, {
+ timeoutMs: 20_000,
+ });
+ const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
+
+ expect(
+ allFirstTurnEvents.some(
+ (event) => event.type === "turn_canceled" && event.provider === "claude",
+ ),
+ ).toBe(true);
+
+ const followUpEvents = await collectUntilTerminal(
+ streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
+ );
+ const secondQuery = getInternalQuery(handle.session);
+
+ expect(secondQuery).toBe(firstQuery);
+ expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
+ expect(followUpEvents.at(-1)).toMatchObject({
+ type: "turn_completed",
+ provider: "claude",
+ });
+ } finally {
+ await cleanupSession(handle);
+ }
},
60_000,
);
@@ -377,106 +385,110 @@ describe("ClaudeAgentSession integration", () => {
test.runIf(canRunClaudeIntegration)(
"creates an autonomous live turn when a background task completes",
async () => {
- const handle = await createSession({
- cwdPrefix: "claude-agent-autonomous-",
- });
- const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
-
- try {
- const foregroundEvents = await collectUntilTerminal(
- streamSession(
- handle.session,
- [
- "Use the Task tool to start a background sub-agent.",
- "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
- "Do not wait for task completion.",
- "Reply immediately with exactly: SPAWNED",
- `When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
- ].join(" "),
- ),
- { timeoutMs: 45_000 },
- );
-
- expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
-
- const liveEvents = await collectSubscribedUntil(
- handle.session,
- (event) => isTerminalEvent(event),
- 45_000,
- );
-
- expect(
- liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
- ).toBe(true);
- expect(compactText(getAssistantText(liveEvents))).toContain(
- autonomousWakeToken.toLowerCase(),
- );
- expect(liveEvents.at(-1)).toMatchObject({
- type: "turn_completed",
- provider: "claude",
+ const handle = await createSession({
+ cwdPrefix: "claude-agent-autonomous-",
});
- } finally {
- await cleanupSession(handle);
- }
+ const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
+
+ try {
+ const foregroundEvents = await collectUntilTerminal(
+ streamSession(
+ handle.session,
+ [
+ "Use the Task tool to start a background sub-agent.",
+ "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
+ "Do not wait for task completion.",
+ "Reply immediately with exactly: SPAWNED",
+ `When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
+ ].join(" "),
+ ),
+ { timeoutMs: 45_000 },
+ );
+
+ expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
+
+ const liveEvents = await collectSubscribedUntil(
+ handle.session,
+ (event) => isTerminalEvent(event),
+ 45_000,
+ );
+
+ expect(
+ liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
+ ).toBe(true);
+ expect(compactText(getAssistantText(liveEvents))).toContain(
+ autonomousWakeToken.toLowerCase(),
+ );
+ expect(liveEvents.at(-1)).toMatchObject({
+ type: "turn_completed",
+ provider: "claude",
+ });
+ } finally {
+ await cleanupSession(handle);
+ }
},
60_000,
);
- test.runIf(canRunClaudeIntegration)("surfaces permission requests and resumes after approval", async () => {
- const handle = await createSession({
- cwdPrefix: "claude-agent-permission-",
- modeId: "default",
- });
- const permissionFile = path.join(handle.cwd, "permission.txt");
+ test.runIf(canRunClaudeIntegration)(
+ "surfaces permission requests and resumes after approval",
+ async () => {
+ const handle = await createSession({
+ cwdPrefix: "claude-agent-permission-",
+ modeId: "default",
+ });
+ const permissionFile = path.join(handle.cwd, "permission.txt");
- try {
- const events = await collectUntilTerminal(
- streamSession(
- handle.session,
- [
- "Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
- "If approval is required, wait for approval.",
- "After the command succeeds, reply with exactly: PERM_DONE",
- ].join(" "),
- ),
- {
- timeoutMs: 45_000,
- onEvent: async (event) => {
- if (event.type !== "permission_requested") {
- return;
- }
- await handle.session.respondToPermission(event.request.id, {
- behavior: "allow",
- });
+ try {
+ const events = await collectUntilTerminal(
+ streamSession(
+ handle.session,
+ [
+ "Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
+ "If approval is required, wait for approval.",
+ "After the command succeeds, reply with exactly: PERM_DONE",
+ ].join(" "),
+ ),
+ {
+ timeoutMs: 45_000,
+ onEvent: async (event) => {
+ if (event.type !== "permission_requested") {
+ return;
+ }
+ await handle.session.respondToPermission(event.request.id, {
+ behavior: "allow",
+ });
+ },
},
- },
- );
+ );
- const permissionRequest = events.find(
- (event): event is Extract =>
- event.type === "permission_requested",
- );
- const permissionResolved = events.find(
- (event): event is Extract =>
- event.type === "permission_resolved",
- );
- const completedBashCall = getLatestCompletedBashCall(events);
+ const permissionRequest = events.find(
+ (event): event is Extract =>
+ event.type === "permission_requested",
+ );
+ const permissionResolved = events.find(
+ (event): event is Extract =>
+ event.type === "permission_resolved",
+ );
+ const completedBashCall = getLatestCompletedBashCall(events);
- expect(permissionRequest?.request.kind).toBe("tool");
- expect(permissionResolved).toMatchObject({
- type: "permission_resolved",
- provider: "claude",
- resolution: { behavior: "allow" },
- });
- expect(completedBashCall).toBeDefined();
- expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
- expect(compactText(getAssistantText(events))).toContain("perm_done");
- expect(events.at(-1)).toMatchObject({
- type: "turn_completed",
- provider: "claude",
- });
- } finally {
- await cleanupSession(handle);
- }
- }, 60_000);
+ expect(permissionRequest?.request.kind).toBe("tool");
+ expect(permissionResolved).toMatchObject({
+ type: "permission_resolved",
+ provider: "claude",
+ resolution: { behavior: "allow" },
+ });
+ expect(completedBashCall).toBeDefined();
+ expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
+ expect(compactText(getAssistantText(events))).toContain("perm_done");
+ expect(events.at(-1)).toMatchObject({
+ type: "turn_completed",
+ provider: "claude",
+ });
+ } finally {
+ await cleanupSession(handle);
+ }
+ },
+ 60_000,
+ );
});
diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts
index b33d82758..2a23ef455 100644
--- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts
@@ -212,7 +212,9 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
.join("");
}
-function subscribeToEvents(session: { subscribe: (callback: (event: AgentStreamEvent) => void) => () => void }) {
+function subscribeToEvents(session: {
+ subscribe: (callback: (event: AgentStreamEvent) => void) => () => void;
+}) {
const queue = createAsyncQueue();
const unsubscribe = session.subscribe((event) => {
queue.push(event);
@@ -484,9 +486,9 @@ describe("ClaudeAgentSession interrupt regression", () => {
expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
- expect(
- observedSecondTurnEvents.filter((event) => event.type === "turn_started").length,
- ).toBe(1);
+ expect(observedSecondTurnEvents.filter((event) => event.type === "turn_started").length).toBe(
+ 1,
+ );
expect(
observedSecondTurnEvents.some(
(event) => event.type === "turn_failed" || event.type === "turn_canceled",
diff --git a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts
index 0f490c08a..cdbc1a696 100644
--- a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts
@@ -806,9 +806,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
effort: options.effort,
});
- return createBaseQueryMock(
- vi.fn(async () => ({ done: true, value: undefined })),
- );
+ return createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
},
);
diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts
index 35c608c5f..c4eb812a7 100644
--- a/packages/server/src/server/agent/providers/claude-agent.test.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.test.ts
@@ -165,8 +165,7 @@ describe("convertClaudeHistoryEntry", () => {
type: "user",
message: {
role: "user",
- content:
- "Set model to claude-opus-4-6 ",
+ content: "Set model to claude-opus-4-6 ",
},
userType: "external",
};
@@ -176,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
message: {
role: "user",
content:
- 'Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m ',
+ "Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m ",
},
};
diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts
index 06b2efdfa..dc111c902 100644
--- a/packages/server/src/server/agent/providers/claude-agent.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.ts
@@ -10,7 +10,6 @@ import {
type AgentDefinition,
type CanUseTool,
type McpServerConfig as ClaudeSdkMcpServerConfig,
-
type Options,
type PermissionMode,
type PermissionResult,
@@ -35,10 +34,7 @@ import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
} from "./claude/task-notification-tool-call.js";
-import {
- getClaudeModels,
- normalizeClaudeRuntimeModelId,
-} from "./claude/claude-models.js";
+import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude/claude-models.js";
import { parsePartialJsonObject } from "./claude/partial-json.js";
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
import {
@@ -77,10 +73,7 @@ import type {
McpServerConfig,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
-import {
- applyProviderEnv,
- type ProviderRuntimeSettings,
-} from "../provider-launch-config.js";
+import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
@@ -222,8 +215,7 @@ function applyRuntimeSettingsToClaudeOptions(
// PATH lookup failures in the managed runtime bundle.
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
// or the user overrides the command via runtime settings, use that directly.
- const isDefaultRuntime =
- resolved.command === "node" || resolved.command === "bun";
+ const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun";
const command = isDefaultRuntime ? process.execPath : resolved.command;
const child = spawnProcess(command, resolved.args, {
cwd: spawnOptions.cwd,
@@ -233,6 +225,10 @@ function applyRuntimeSettingsToClaudeOptions(
},
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
+ // Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON
+ // containing double quotes, which cmd.exe mangles (strips quotes, breaks parsing).
+ // The command is always a resolved binary path, so shell routing is unnecessary.
+ shell: false,
});
if (typeof options.stderr === "function") {
child.stderr?.on("data", (chunk: Buffer | string) => {
@@ -413,7 +409,8 @@ function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
}
-const LOCAL_COMMAND_STDOUT_PATTERN = /^\s*[\s\S]*<\/local-command-stdout>\s*$/;
+const LOCAL_COMMAND_STDOUT_PATTERN =
+ /^\s*[\s\S]*<\/local-command-stdout>\s*$/;
function isClaudeLocalCommandStdout(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
@@ -1141,7 +1138,7 @@ export class ClaudeAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
- const resolvedBinary = await findExecutable("claude") ?? "not found";
+ const resolvedBinary = (await findExecutable("claude")) ?? "not found";
const available = await this.isAvailable();
const version = await resolveClaudeVersion(this.runtimeSettings);
let modelsValue = "Not checked";
@@ -1183,16 +1180,22 @@ export class ClaudeAgentClient implements AgentClient {
}
}
-async function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): Promise {
+async function resolveClaudeVersion(
+ runtimeSettings?: ProviderRuntimeSettings,
+): Promise {
const command = runtimeSettings?.command;
try {
if (command?.mode === "replace") {
- const { stdout } = await execFileAsync(command.argv[0]!, [...command.argv.slice(1), "--version"], {
- encoding: "utf8",
- timeout: 5_000,
- windowsHide: true,
- });
+ const { stdout } = await execFileAsync(
+ command.argv[0]!,
+ [...command.argv.slice(1), "--version"],
+ {
+ encoding: "utf8",
+ timeout: 5_000,
+ windowsHide: true,
+ },
+ );
return stdout.trim() || null;
}
@@ -1236,9 +1239,7 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
-function readUsageTotalTokens(
- usage: unknown,
-): number | undefined {
+function readUsageTotalTokens(usage: unknown): number | undefined {
if (!usage || typeof usage !== "object") {
return undefined;
}
@@ -2094,10 +2095,9 @@ class ClaudeAgentSession implements AgentSession {
: process.env["PATH"] !== undefined
? "PATH"
: null,
- pathIncludesClaudeLocalBin:
- (process.env["Path"] ?? process.env["PATH"] ?? "")
- .toLowerCase()
- .includes("\\.local\\bin"),
+ pathIncludesClaudeLocalBin: (process.env["Path"] ?? process.env["PATH"] ?? "")
+ .toLowerCase()
+ .includes("\\.local\\bin"),
},
"Resolved Claude executable",
);
@@ -2231,8 +2231,7 @@ class ClaudeAgentSession implements AgentSession {
}
private isAbortError(message: SDKMessage): boolean {
- const errors =
- "errors" in message && Array.isArray(message.errors) ? message.errors : [];
+ const errors = "errors" in message && Array.isArray(message.errors) ? message.errors : [];
return errors.some((e: string) => /\baborted\b/i.test(e));
}
@@ -2273,9 +2272,11 @@ class ClaudeAgentSession implements AgentSession {
if (this.getRecentStderrDiagnostic()) {
return;
}
- const message =
- typeof error === "string" ? error : error instanceof Error ? error.message : "";
- if (!/\bprocess exited with code\b/i.test(message) && !/\bterminated by signal\b/i.test(message)) {
+ const message = typeof error === "string" ? error : error instanceof Error ? error.message : "";
+ if (
+ !/\bprocess exited with code\b/i.test(message) &&
+ !/\bterminated by signal\b/i.test(message)
+ ) {
return;
}
@@ -2518,11 +2519,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
}
- if (
- message.type === "result" &&
- message.subtype !== "success" &&
- this.isAbortError(message)
- ) {
+ if (message.type === "result" && message.subtype !== "success" && this.isAbortError(message)) {
this.logger.debug("Suppressing abort result by content");
return;
}
@@ -2990,9 +2987,7 @@ class ClaudeAgentSession implements AgentSession {
outputTokens: message.usage.output_tokens,
totalCostUsd: message.total_cost_usd,
};
- const contextWindowMaxTokens = extractContextWindowSize(
- modelUsage ?? message.modelUsage,
- );
+ const contextWindowMaxTokens = extractContextWindowSize(modelUsage ?? message.modelUsage);
if (contextWindowMaxTokens !== undefined) {
this.lastContextWindowMaxTokens = contextWindowMaxTokens;
usage.contextWindowMaxTokens = contextWindowMaxTokens;
@@ -3109,8 +3104,7 @@ class ClaudeAgentSession implements AgentSession {
input,
detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
- actions:
- kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
+ actions: kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
metadata: Object.keys(metadata).length ? metadata : undefined,
};
diff --git a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts
index 62b373c98..df2f2bcfe 100644
--- a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts
+++ b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts
@@ -65,88 +65,92 @@ describe("Claude SDK direct behavior", () => {
}
});
- test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => {
- const cwd = tmpCwd();
- const input = new Pushable();
- const claudeBinary = await findExecutable("claude");
+ test.runIf(canRunClaudeIntegration)(
+ "shows what happens after interrupt()",
+ async () => {
+ const cwd = tmpCwd();
+ const input = new Pushable();
+ const claudeBinary = await findExecutable("claude");
- // Use same options as claude-agent.ts
- const q = query({
- prompt: input,
- options: {
- cwd,
- includePartialMessages: true,
- permissionMode: "bypassPermissions",
- ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
- systemPrompt: {
- type: "preset",
- preset: "claude_code",
+ // Use same options as claude-agent.ts
+ const q = query({
+ prompt: input,
+ options: {
+ cwd,
+ includePartialMessages: true,
+ permissionMode: "bypassPermissions",
+ ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
+ systemPrompt: {
+ type: "preset",
+ preset: "claude_code",
+ },
+ settingSources: ["user", "project"],
},
- settingSources: ["user", "project"],
- },
- });
-
- try {
- // Send first message
- input.push({
- type: "user",
- message: { role: "user", content: "Say exactly: MESSAGE_ONE" },
- parent_tool_use_id: null,
- session_id: "",
});
- // Collect events until we see assistant, then interrupt
- const msg1Events: SDKMessage[] = [];
- for await (const event of q) {
- msg1Events.push(event);
+ try {
+ // Send first message
+ input.push({
+ type: "user",
+ message: { role: "user", content: "Say exactly: MESSAGE_ONE" },
+ parent_tool_use_id: null,
+ session_id: "",
+ });
- if (event.type === "assistant") {
- // Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in)
- input.push({
- type: "user",
- message: { role: "user", content: "Say exactly: MESSAGE_TWO" },
- parent_tool_use_id: null,
- session_id: "",
- });
- await q.interrupt();
- break;
+ // Collect events until we see assistant, then interrupt
+ const msg1Events: SDKMessage[] = [];
+ for await (const event of q) {
+ msg1Events.push(event);
+
+ if (event.type === "assistant") {
+ // Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in)
+ input.push({
+ type: "user",
+ message: { role: "user", content: "Say exactly: MESSAGE_TWO" },
+ parent_tool_use_id: null,
+ session_id: "",
+ });
+ await q.interrupt();
+ break;
+ }
+ if (event.type === "result") {
+ break;
+ }
}
- if (event.type === "result") {
- break;
+
+ // MSG2 was already pushed before interrupt
+ const msg2Events: SDKMessage[] = [];
+ for await (const event of q) {
+ msg2Events.push(event);
+
+ if (event.type === "result") {
+ break;
+ }
}
- }
- // MSG2 was already pushed before interrupt
- const msg2Events: SDKMessage[] = [];
- for await (const event of q) {
- msg2Events.push(event);
-
- if (event.type === "result") {
- break;
- }
- }
-
- // Analyze response
- let responseText = "";
- for (const event of msg2Events) {
- if (event.type === "assistant" && "message" in event && event.message?.content) {
- const content = event.message.content;
- if (Array.isArray(content)) {
- for (const block of content) {
- if (block.type === "text" && block.text) {
- responseText += block.text;
+ // Analyze response
+ let responseText = "";
+ for (const event of msg2Events) {
+ if (event.type === "assistant" && "message" in event && event.message?.content) {
+ const content = event.message.content;
+ if (Array.isArray(content)) {
+ for (const block of content) {
+ if (block.type === "text" && block.text) {
+ responseText += block.text;
+ }
}
}
}
}
- }
- const sawResult = msg2Events.some((event) => event.type === "result");
- // The SDK may short-circuit after interrupt without a result event.
- expect(sawResult || responseText.length === 0).toBe(true);
- } finally {
- input.end();
- rmSync(cwd, { recursive: true, force: true });
- }
- }, 120000);
+ const sawResult = msg2Events.some((event) => event.type === "result");
+ // The SDK may short-circuit after interrupt without a result event.
+ expect(sawResult || responseText.length === 0).toBe(true);
+ } finally {
+ input.end();
+ rmSync(cwd, { recursive: true, force: true });
+ }
+ },
+ 120000,
+ );
});
diff --git a/packages/server/src/server/agent/providers/claude/claude-models.ts b/packages/server/src/server/agent/providers/claude/claude-models.ts
index 08c14071e..3d11cf7fa 100644
--- a/packages/server/src/server/agent/providers/claude/claude-models.ts
+++ b/packages/server/src/server/agent/providers/claude/claude-models.ts
@@ -45,9 +45,7 @@ export function getClaudeModels(): AgentModelDefinition[] {
* Normalize a runtime model string (from SDK init message) to a known model ID.
* Handles the `[1m]` suffix that the SDK appends for 1M context sessions.
*/
-export function normalizeClaudeRuntimeModelId(
- value: string | null | undefined,
-): string | null {
+export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return null;
diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts
index 89d1b0016..dfad94986 100644
--- a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts
+++ b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts
@@ -61,7 +61,11 @@ function responseCompleted(id: string): Record {
};
}
-function functionCallEvent(callId: string, name: string, argumentsJson: string): Record {
+function functionCallEvent(
+ callId: string,
+ name: string,
+ argumentsJson: string,
+): Record {
return {
type: "response.output_item.done",
item: {
@@ -116,7 +120,11 @@ function requestUserInputSse(callId: string): string {
}
function assistantMessageSse(text: string): string {
- return sse([responseCreated("resp-2"), assistantMessageEvent("msg-1", text), responseCompleted("resp-2")]);
+ return sse([
+ responseCreated("resp-2"),
+ assistantMessageEvent("msg-1", text),
+ responseCompleted("resp-2"),
+ ]);
}
async function startMockResponsesServer(sequence: string[]): Promise<{
@@ -276,10 +284,7 @@ describe("Codex app-server provider (e2e)", () => {
label: "question permission request",
predicate: (
event,
- ): event is Extract<
- AgentStreamEvent,
- { type: "permission_requested" }
- > =>
+ ): event is Extract =>
event.type === "permission_requested" &&
event.request.provider === "codex" &&
event.request.kind === "question" &&
diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts
index f175d177a..2cc4ccc4a 100644
--- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts
+++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, test, vi } from "vitest";
import { existsSync, rmSync } from "node:fs";
-import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
+import type {
+ AgentLaunchContext,
+ AgentSession,
+ AgentSessionConfig,
+ AgentStreamEvent,
+} from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
codexAppServerTurnInputFromPrompt,
@@ -381,10 +386,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
- options: [
- { label: "Coffee", description: "Default" },
- { label: "Tea" },
- ],
+ options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
});
@@ -411,10 +413,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
- options: [
- { label: "Coffee", description: "Default" },
- { label: "Tea" },
- ],
+ options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
@@ -441,10 +440,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
- options: [
- { label: "Coffee", description: "Default" },
- { label: "Tea" },
- ],
+ options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
@@ -457,10 +453,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
- options: [
- { label: "Coffee", description: "Default" },
- { label: "Tea" },
- ],
+ options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts
index 2fc50ca03..b2640dd65 100644
--- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts
+++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts
@@ -158,10 +158,7 @@ function isObjectSchemaNode(schema: Record): boolean {
);
}
-function normalizeCodexOutputSchemaNode(
- schema: unknown,
- path: string,
-): unknown {
+function normalizeCodexOutputSchemaNode(schema: unknown, path: string): unknown {
if (Array.isArray(schema)) {
return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${path}[${index}]`));
}
@@ -820,7 +817,10 @@ function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): st
return normalizePlanMarkdown(lines.join("\n"));
}
-function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolCallTimelineItem | null {
+function mapCodexPlanToToolCall(params: {
+ callId: string;
+ text: string;
+}): ToolCallTimelineItem | null {
const text = normalizePlanMarkdown(params.text);
if (!text) {
return null;
@@ -838,9 +838,10 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC
};
}
-function buildPlanPermissionActions(
- options?: { includeResumeAction?: boolean; resumeLabel?: string },
-): AgentPermissionAction[] {
+function buildPlanPermissionActions(options?: {
+ includeResumeAction?: boolean;
+ resumeLabel?: string;
+}): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "reject",
@@ -2569,9 +2570,7 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
- private findCollaborationMode(
- target: "code" | "plan",
- ): {
+ private findCollaborationMode(target: "code" | "plan"): {
name: string;
mode?: string | null;
model?: string | null;
@@ -3199,7 +3198,9 @@ class CodexAppServerAgentSession implements AgentSession {
const questions = pending.questions ?? [];
const itemId =
- typeof pendingRequest?.metadata?.itemId === "string" ? pendingRequest.metadata.itemId : requestId;
+ typeof pendingRequest?.metadata?.itemId === "string"
+ ? pendingRequest.metadata.itemId
+ : requestId;
if (response.behavior === "allow") {
const mappedAnswers = mapCodexQuestionResponseByHeader({
questions,
@@ -3211,9 +3212,7 @@ class CodexAppServerAgentSession implements AgentSession {
questions
.map((question) => {
const fallback = question.options[0]?.label?.trim();
- return fallback
- ? [question.id, { answers: [fallback] }]
- : null;
+ return fallback ? [question.id, { answers: [fallback] }] : null;
})
.filter((entry): entry is [string, { answers: string[] }] => entry !== null),
);
@@ -4009,7 +4008,9 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings,
) {}
- private async spawnAppServer(launchEnv?: Record): Promise {
+ private async spawnAppServer(
+ launchEnv?: Record,
+ ): Promise {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
this.logger.trace(
{
@@ -4226,7 +4227,10 @@ export class CodexAppServerAgentClient implements AgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
- { label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
+ {
+ label: "Version",
+ value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
+ },
];
let status = formatDiagnosticStatus(available);
diff --git a/packages/server/src/server/agent/providers/codex-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts
index 16554d3e4..a850317e5 100644
--- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts
+++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts
@@ -1,11 +1,6 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
-const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = [
- "gpt-5",
- "gpt-4.1",
- "o3",
- "o4-mini",
-] as const;
+const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = ["gpt-5", "gpt-4.1", "o3", "o4-mini"] as const;
export const CODEX_FAST_MODE_FEATURE: Omit = {
type: "toggle",
diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts
index 8b0657c05..ec0726286 100644
--- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts
+++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts
@@ -68,7 +68,10 @@ const CodexEditToolNameSchema = z.union([
z.literal("apply_diff"),
]);
const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
-const CodexSpeakToolNameSchema = z.string().min(1).refine((name) => isSpeakToolName(name.trim()));
+const CodexSpeakToolNameSchema = z
+ .string()
+ .min(1)
+ .refine((name) => isSpeakToolName(name.trim()));
const CodexToolKindSchema = z.enum([
"shell",
@@ -734,9 +737,10 @@ function parseFileChangeEntries(
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
}
-function resolveFileChangeTextFields(
- file: CodexFileChangeEntry | undefined,
-): { unifiedDiff?: string; newString?: string } {
+function resolveFileChangeTextFields(file: CodexFileChangeEntry | undefined): {
+ unifiedDiff?: string;
+ newString?: string;
+} {
if (!file) {
return {};
}
diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts
index a2ff16175..50f151503 100644
--- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts
+++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts
@@ -97,7 +97,10 @@ export class CopilotACPAgentClient extends ACPAgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
- { label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
+ {
+ label: "Version",
+ value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
+ },
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),
diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts
index 7869d79a3..ed12c63ee 100644
--- a/packages/server/src/server/agent/providers/diagnostic-utils.ts
+++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts
@@ -10,17 +10,11 @@ type DiagnosticEntry = {
value: string;
};
-export function formatProviderDiagnostic(
- providerName: string,
- entries: DiagnosticEntry[],
-): string {
+export function formatProviderDiagnostic(providerName: string, entries: DiagnosticEntry[]): string {
return [providerName, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
}
-export function formatProviderDiagnosticError(
- providerName: string,
- error: unknown,
-): string {
+export function formatProviderDiagnosticError(providerName: string, error: unknown): string {
return formatProviderDiagnostic(providerName, [
{
label: "Error",
diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts
index 13ef380d0..4edaaaad6 100644
--- a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts
@@ -79,10 +79,7 @@ describe("opencode agent commands E2E", () => {
});
const token = `RAW_PROMPT_TOKEN_${Date.now()}`;
- await ctx.client.sendMessage(
- agent.id,
- `/not-a-real-command respond with exactly: ${token}`,
- );
+ await ctx.client.sendMessage(agent.id, `/not-a-real-command respond with exactly: ${token}`);
const state = await ctx.client.waitForFinish(agent.id, 30_000);
expect(state.status).toBe("idle");
diff --git a/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts
index d59ea9831..0f944b0c2 100644
--- a/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts
@@ -144,9 +144,7 @@ describe("opencode agent error handling (real)", () => {
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(elapsed).toBeLessThan(30_000);
- console.log(
- `[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`,
- );
+ console.log(`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`);
} finally {
await session.close().catch(() => undefined);
}
@@ -176,9 +174,9 @@ describe("opencode agent error handling (real)", () => {
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
- expect(
- (terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase(),
- ).toMatch(/insufficient balance|resource package|recharge/);
+ expect((terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase()).toMatch(
+ /insufficient balance|resource package|recharge/,
+ );
} finally {
await session.close().catch(() => undefined);
}
diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts
index 8a39152dc..24f035c0e 100644
--- a/packages/server/src/server/agent/providers/opencode-agent.test.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts
@@ -124,7 +124,7 @@ const hasOpenCode = isBinaryInstalled("opencode");
m.id.includes("gpt-4.1-nano") ||
m.id.includes("gpt-4.1-mini") ||
m.id.includes("gpt-5-nano") ||
- m.id.includes("gpt-5.1-codex-mini") ||
+ m.id.includes("gpt-5.4-mini") ||
m.id.includes("gpt-4o-mini"),
);
diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts
index 1c0194981..304ba7e68 100644
--- a/packages/server/src/server/agent/providers/opencode-agent.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent.ts
@@ -379,13 +379,16 @@ function buildOpenCodeModelDefinition(
}
function resolveOpenCodeSelectedModelContextWindow(
- providers: {
- connected?: string[];
- all?: Array<{
- id: string;
- models?: Record;
- }>;
- } | null | undefined,
+ providers:
+ | {
+ connected?: string[];
+ all?: Array<{
+ id: string;
+ models?: Record;
+ }>;
+ }
+ | null
+ | undefined,
modelId: string | null | undefined,
): number | undefined {
if (!providers) {
@@ -399,13 +402,18 @@ function resolveOpenCodeSelectedModelContextWindow(
return lookup.get(modelLookupKey);
}
-function buildOpenCodeModelContextWindowLookup(providers: {
- connected?: string[];
- all?: Array<{
- id: string;
- models?: Record;
- }>;
-} | null | undefined): Map {
+function buildOpenCodeModelContextWindowLookup(
+ providers:
+ | {
+ connected?: string[];
+ all?: Array<{
+ id: string;
+ models?: Record;
+ }>;
+ }
+ | null
+ | undefined,
+): Map {
const lookup = new Map();
if (!providers) {
return lookup;
@@ -881,16 +889,10 @@ export class OpenCodeAgentClient implements AgentClient {
const client = createOpencodeClient({ baseUrl: url, directory });
const timeoutPromise = new Promise((_, reject) => {
- setTimeout(
- () => reject(new Error("OpenCode app.agents timed out after 10s")),
- 10_000,
- );
+ setTimeout(() => reject(new Error("OpenCode app.agents timed out after 10s")), 10_000);
});
- const response = await Promise.race([
- client.app.agents({ directory }),
- timeoutPromise,
- ]);
+ const response = await Promise.race([client.app.agents({ directory }), timeoutPromise]);
if (response.error || !response.data) {
return DEFAULT_MODES;
@@ -970,7 +972,10 @@ export class OpenCodeAgentClient implements AgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
- { label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
+ {
+ label: "Version",
+ value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
+ },
{ label: "Server", value: serverStatus },
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
@@ -1043,8 +1048,6 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
-
-
export function translateOpenCodeEvent(
event: OpenCodeEvent,
state: OpenCodeEventTranslationState,
@@ -1080,10 +1083,7 @@ export function translateOpenCodeEvent(
}
}
- if (
- !state.emittedStructuredMessageIds.has(info.id) &&
- info.time?.completed !== undefined
- ) {
+ if (!state.emittedStructuredMessageIds.has(info.id) && info.time?.completed !== undefined) {
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
state.emittedStructuredMessageIds.add(info.id);
@@ -1229,16 +1229,19 @@ export function translateOpenCodeEvent(
if (!q.question || !q.header) {
return [];
}
- const options = q.options?.map((o) => ({
- label: o.label,
- ...(o.description ? { description: o.description } : {}),
- })) ?? [];
- return [{
- question: q.question,
- header: q.header,
- options,
- ...(q.multiple === true ? { multiSelect: true } : {}),
- }];
+ const options =
+ q.options?.map((o) => ({
+ label: o.label,
+ ...(o.description ? { description: o.description } : {}),
+ })) ?? [];
+ return [
+ {
+ question: q.question,
+ header: q.header,
+ options,
+ ...(q.multiple === true ? { multiSelect: true } : {}),
+ },
+ ];
});
if (questions.length === 0) {
@@ -1362,8 +1365,9 @@ class OpenCodeAgentSession implements AgentSession {
this.logger = logger;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
- this.selectedModelContextWindowMaxTokens =
- this.resolveConfiguredModelContextWindowMaxTokens(config.model);
+ this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
+ config.model,
+ );
}
get id(): string | null {
@@ -1383,8 +1387,9 @@ class OpenCodeAgentSession implements AgentSession {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
this.config.model = normalizedModelId ?? undefined;
- this.selectedModelContextWindowMaxTokens =
- this.resolveConfiguredModelContextWindowMaxTokens(this.config.model);
+ this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
+ this.config.model,
+ );
}
async setThinkingOption(thinkingOptionId: string | null): Promise {
@@ -1499,8 +1504,7 @@ class OpenCodeAgentSession implements AgentSession {
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
- this.accumulatedUsage =
- contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
+ this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = buildOpenCodePromptParts(prompt);
const model = this.parseModel(this.config.model);
@@ -1520,71 +1524,77 @@ class OpenCodeAgentSession implements AgentSession {
// Handle both success and error in the response handler as a fallback —
// finishForegroundTurn's guard prevents duplicate terminal events if the
// SSE stream already delivered the event.
- void this.client.session.command({
- sessionID: this.sessionId,
- directory: this.config.cwd,
- command: slashCommand.commandName,
- arguments: slashCommand.args ?? "",
- ...(this.config.model ? { model: this.config.model } : {}),
- ...(effectiveMode ? { agent: effectiveMode } : {}),
- ...(effectiveVariant ? { variant: effectiveVariant } : {}),
- }).then((response) => {
- if (response.error) {
- const errorMsg = normalizeTurnFailureError(response.error);
+ void this.client.session
+ .command({
+ sessionID: this.sessionId,
+ directory: this.config.cwd,
+ command: slashCommand.commandName,
+ arguments: slashCommand.args ?? "",
+ ...(this.config.model ? { model: this.config.model } : {}),
+ ...(effectiveMode ? { agent: effectiveMode } : {}),
+ ...(effectiveVariant ? { variant: effectiveVariant } : {}),
+ })
+ .then((response) => {
+ if (response.error) {
+ const errorMsg = normalizeTurnFailureError(response.error);
+ this.finishForegroundTurn(
+ { type: "turn_failed", provider: "opencode", error: errorMsg },
+ turnId,
+ );
+ } else {
+ this.finishForegroundTurn(
+ { type: "turn_completed", provider: "opencode", usage: undefined },
+ turnId,
+ );
+ }
+ })
+ .catch((err) => {
this.finishForegroundTurn(
- { type: "turn_failed", provider: "opencode", error: errorMsg },
+ { type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
);
- } else {
- this.finishForegroundTurn(
- { type: "turn_completed", provider: "opencode", usage: undefined },
- turnId,
- );
- }
- }).catch((err) => {
- this.finishForegroundTurn(
- { type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
- turnId,
- );
- });
+ });
} else {
- void this.client.session.promptAsync({
- sessionID: this.sessionId,
- directory: this.config.cwd,
- parts,
- ...(options?.outputSchema
- ? {
- format: {
- type: "json_schema" as const,
- schema: options.outputSchema as Record,
+ void this.client.session
+ .promptAsync({
+ sessionID: this.sessionId,
+ directory: this.config.cwd,
+ parts,
+ ...(options?.outputSchema
+ ? {
+ format: {
+ type: "json_schema" as const,
+ schema: options.outputSchema as Record,
+ },
+ }
+ : {}),
+ ...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
+ ...(model ? { model } : {}),
+ ...(effectiveMode ? { agent: effectiveMode } : {}),
+ ...(effectiveVariant ? { variant: effectiveVariant } : {}),
+ })
+ .then((promptResponse) => {
+ if (promptResponse.error) {
+ this.finishForegroundTurn(
+ {
+ type: "turn_failed",
+ provider: "opencode",
+ error: normalizeTurnFailureError(promptResponse.error),
},
- }
- : {}),
- ...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
- ...(model ? { model } : {}),
- ...(effectiveMode ? { agent: effectiveMode } : {}),
- ...(effectiveVariant ? { variant: effectiveVariant } : {}),
- }).then((promptResponse) => {
- if (promptResponse.error) {
+ turnId,
+ );
+ }
+ })
+ .catch((error) => {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
- error: normalizeTurnFailureError(promptResponse.error),
+ error: normalizeTurnFailureError(error),
},
turnId,
);
- }
- }).catch((error) => {
- this.finishForegroundTurn(
- {
- type: "turn_failed",
- provider: "opencode",
- error: normalizeTurnFailureError(error),
- },
- turnId,
- );
- });
+ });
}
return { turnId };
@@ -1620,7 +1630,11 @@ class OpenCodeAgentSession implements AgentSession {
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
- if (e.type === "turn_completed" || e.type === "turn_failed" || e.type === "turn_canceled") {
+ if (
+ e.type === "turn_completed" ||
+ e.type === "turn_failed" ||
+ e.type === "turn_canceled"
+ ) {
if (e.type === "turn_failed") {
this.finishForegroundTurn(
{
@@ -2070,12 +2084,9 @@ class OpenCodeAgentSession implements AgentSession {
if (hasNormalizedOpenCodeUsage(this.accumulatedUsage)) {
translatedEvent.usage = this.accumulatedUsage;
}
- const contextWindowMaxTokens =
- this.resolveSelectedModelContextWindowMaxTokens();
+ const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
this.accumulatedUsage =
- contextWindowMaxTokens !== undefined
- ? { contextWindowMaxTokens }
- : {};
+ contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
}
}
diff --git a/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts
index 869cac313..f13fedf11 100644
--- a/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts
+++ b/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts
@@ -26,9 +26,7 @@ describe("OpenCode assistant message", () => {
const result = await session.run("Say hello back in one sentence.");
- const assistantItems = result.timeline.filter(
- (item) => item.type === "assistant_message",
- );
+ const assistantItems = result.timeline.filter((item) => item.type === "assistant_message");
expect(assistantItems.length).toBeGreaterThan(0);
expect(result.finalText.length).toBeGreaterThan(0);
} finally {
@@ -69,5 +67,4 @@ describe("OpenCode assistant message", () => {
},
60_000,
);
-
});
diff --git a/packages/server/src/server/agent/providers/pi-acp-agent.ts b/packages/server/src/server/agent/providers/pi-acp-agent.ts
index 7d3d9588d..4260f9524 100644
--- a/packages/server/src/server/agent/providers/pi-acp-agent.ts
+++ b/packages/server/src/server/agent/providers/pi-acp-agent.ts
@@ -3,11 +3,7 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { Logger } from "pino";
-import type {
- ClientSideConnection,
- SessionConfigOption,
- ToolKind,
-} from "@agentclientprotocol/sdk";
+import type { ClientSideConnection, SessionConfigOption, ToolKind } from "@agentclientprotocol/sdk";
import type {
AgentLaunchContext,
@@ -30,11 +26,7 @@ import type {
} from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
-import {
- ACPAgentClient,
- type ACPToolSnapshot,
- type SessionStateResponse,
-} from "./acp-agent.js";
+import { ACPAgentClient, type ACPToolSnapshot, type SessionStateResponse } from "./acp-agent.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -102,9 +94,7 @@ function transformPiToolSnapshot(snapshot: ACPToolSnapshot): ACPToolSnapshot {
* This transformer remaps them so the base ACP class treats them as thinking
* options instead of permission modes.
*/
-export function transformPiSessionResponse(
- response: SessionStateResponse,
-): SessionStateResponse {
+export function transformPiSessionResponse(response: SessionStateResponse): SessionStateResponse {
const modes = response.modes;
if (!modes?.availableModes?.length) {
return response;
@@ -126,10 +116,7 @@ export function transformPiSessionResponse(
return {
...response,
modes: undefined,
- configOptions: [
- thinkingOption,
- ...(response.configOptions ?? []),
- ],
+ configOptions: [thinkingOption, ...(response.configOptions ?? [])],
};
}
@@ -228,10 +215,7 @@ class PiACPAgentSession implements AgentSession {
return this.inner.getPendingPermissions();
}
- async respondToPermission(
- requestId: string,
- response: AgentPermissionResponse,
- ): Promise {
+ async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise {
await this.inner.respondToPermission(requestId, response);
}
diff --git a/packages/server/src/server/agent/tool-name-normalization.test.ts b/packages/server/src/server/agent/tool-name-normalization.test.ts
new file mode 100644
index 000000000..4c20f2001
--- /dev/null
+++ b/packages/server/src/server/agent/tool-name-normalization.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+
+import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
+
+describe("isPaseoToolName", () => {
+ it("detects Claude Code format", () => {
+ expect(isPaseoToolName("mcp__paseo__create_agent")).toBe(true);
+ expect(isPaseoToolName("mcp__paseo__list_agents")).toBe(true);
+ });
+
+ it("detects paseo_voice variant", () => {
+ expect(isPaseoToolName("mcp__paseo_voice__create_agent")).toBe(true);
+ expect(isPaseoToolName("paseo_voice.create_agent")).toBe(true);
+ });
+
+ it("excludes speak tools", () => {
+ expect(isPaseoToolName("mcp__paseo_voice__speak")).toBe(false);
+ expect(isPaseoToolName("mcp__paseo__speak")).toBe(false);
+ expect(isPaseoToolName("paseo.speak")).toBe(false);
+ });
+
+ it("detects Codex dot format", () => {
+ expect(isPaseoToolName("paseo.create_agent")).toBe(true);
+ });
+
+ it("rejects non-paseo tools", () => {
+ expect(isPaseoToolName("Bash")).toBe(false);
+ expect(isPaseoToolName("Read")).toBe(false);
+ expect(isPaseoToolName("mcp__other_server__some_tool")).toBe(false);
+ });
+});
+
+describe("getPaseoToolLeafName", () => {
+ it("extracts leaf from Claude Code format", () => {
+ expect(getPaseoToolLeafName("mcp__paseo__create_agent")).toBe("create_agent");
+ });
+
+ it("extracts leaf from Codex format", () => {
+ expect(getPaseoToolLeafName("paseo.create_agent")).toBe("create_agent");
+ expect(getPaseoToolLeafName("paseo.list_agents")).toBe("list_agents");
+ });
+
+ it("returns null for non-paseo tools", () => {
+ expect(getPaseoToolLeafName("Bash")).toBeNull();
+ });
+});
diff --git a/packages/server/src/server/agent/tool-name-normalization.ts b/packages/server/src/server/agent/tool-name-normalization.ts
index d6784e7b1..a920305d1 100644
--- a/packages/server/src/server/agent/tool-name-normalization.ts
+++ b/packages/server/src/server/agent/tool-name-normalization.ts
@@ -39,6 +39,49 @@ export function isLikelyNamespacedToolName(name: string): boolean {
return false;
}
+export function isPaseoToolName(name: string): boolean {
+ const normalized = normalizeToolName(name);
+ if (isSpeakToolName(normalized)) {
+ return false;
+ }
+ if (normalized.includes("__")) {
+ const segments = normalized.split("__").filter((s) => s.length > 0);
+ return (
+ segments.length >= 3 &&
+ segments[0] === "mcp" &&
+ (segments[1] === "paseo" || segments[1]!.startsWith("paseo_"))
+ );
+ }
+ if (normalized.includes(".")) {
+ const firstSegment = normalized.split(".")[0]!;
+ return firstSegment === "paseo" || firstSegment.startsWith("paseo_");
+ }
+ return false;
+}
+
+export function getPaseoToolLeafName(name: string): string | null {
+ const normalized = normalizeToolName(name);
+ if (normalized.includes("__")) {
+ const segments = normalized.split("__").filter((s) => s.length > 0);
+ if (
+ segments.length >= 3 &&
+ segments[0] === "mcp" &&
+ (segments[1] === "paseo" || segments[1]!.startsWith("paseo_"))
+ ) {
+ return segments.slice(2).join("__");
+ }
+ return null;
+ }
+ if (normalized.includes(".")) {
+ const firstSegment = normalized.split(".")[0]!;
+ if (firstSegment === "paseo" || firstSegment.startsWith("paseo_")) {
+ return normalized.split(".").slice(1).join(".");
+ }
+ return null;
+ }
+ return null;
+}
+
export function isLikelyExternalToolName(name: string): boolean {
const normalized = normalizeToolName(name);
if (!normalized) {
diff --git a/packages/server/src/server/background-git-fetch-manager.test.ts b/packages/server/src/server/background-git-fetch-manager.test.ts
deleted file mode 100644
index aa2b1f8f9..000000000
--- a/packages/server/src/server/background-git-fetch-manager.test.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-
-const execFileMock = vi.hoisted(() =>
- vi.fn(
- (
- _file: string,
- _args: string[],
- _options: unknown,
- callback: (error: Error | null, stdout?: string, stderr?: string) => void,
- ) => {
- callback(null, "", "");
- },
- ),
-);
-
-vi.mock("node:child_process", async () => {
- const actual = await vi.importActual("node:child_process");
- return {
- ...actual,
- execFile: execFileMock,
- };
-});
-
-import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
-
-async function flushPromises(): Promise {
- await Promise.resolve();
- await Promise.resolve();
-}
-
-function createLogger() {
- const logger = {
- child: () => logger,
- debug: vi.fn(),
- warn: vi.fn(),
- };
- return logger;
-}
-
-describe("BackgroundGitFetchManager", () => {
- beforeEach(() => {
- vi.useFakeTimers();
- execFileMock.mockReset();
- execFileMock.mockImplementation(
- (
- _file: string,
- _args: string[],
- _options: unknown,
- callback: (error: Error | null, stdout?: string, stderr?: string) => void,
- ) => {
- callback(null, "", "");
- },
- );
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- test("creates a fetch timer for a repo with an origin remote", async () => {
- const logger = createLogger();
- const manager = new BackgroundGitFetchManager({ logger: logger as any });
-
- const subscription = await manager.subscribe(
- { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
- vi.fn(),
- );
- await flushPromises();
-
- const managerAny = manager as any;
- const target = managerAny.targets.get("/tmp/repo/.git");
- expect(target).toBeDefined();
- expect(target.intervalId).toBeTruthy();
- expect(execFileMock).toHaveBeenNthCalledWith(
- 1,
- "git",
- ["remote", "get-url", "origin"],
- expect.objectContaining({
- cwd: "/tmp/repo",
- env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
- }),
- expect.any(Function),
- );
- expect(execFileMock).toHaveBeenNthCalledWith(
- 2,
- "git",
- ["fetch", "origin", "--prune"],
- expect.objectContaining({
- cwd: "/tmp/repo",
- env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
- }),
- expect.any(Function),
- );
-
- subscription.unsubscribe();
- manager.dispose();
- });
-
- test("dedupes multiple subscribers for the same repo root behind one timer", async () => {
- const logger = createLogger();
- const manager = new BackgroundGitFetchManager({ logger: logger as any });
-
- const listenerOne = vi.fn();
- const listenerTwo = vi.fn();
- const subscriptionOne = await manager.subscribe(
- { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
- listenerOne,
- );
- const subscriptionTwo = await manager.subscribe(
- { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo-worktree" },
- listenerTwo,
- );
- await flushPromises();
-
- const managerAny = manager as any;
- const target = managerAny.targets.get("/tmp/repo/.git");
- expect(managerAny.targets.size).toBe(1);
- expect(target.listeners).toEqual(new Set([listenerOne, listenerTwo]));
- expect(execFileMock.mock.calls.filter((call) => call[1][0] === "remote")).toHaveLength(1);
-
- subscriptionOne.unsubscribe();
- subscriptionTwo.unsubscribe();
- manager.dispose();
- });
-
- test("cleans up the timer when the last subscriber unsubscribes", async () => {
- const logger = createLogger();
- const manager = new BackgroundGitFetchManager({ logger: logger as any });
-
- const subscription = await manager.subscribe(
- { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
- vi.fn(),
- );
- await flushPromises();
-
- const managerAny = manager as any;
- const target = managerAny.targets.get("/tmp/repo/.git");
- const intervalId = target.intervalId;
- const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
-
- subscription.unsubscribe();
-
- expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
- expect(managerAny.targets.size).toBe(0);
-
- clearIntervalSpy.mockRestore();
- manager.dispose();
- });
-
- test("logs fetch errors without crashing", async () => {
- const logger = createLogger();
- execFileMock.mockImplementation(
- (
- _file: string,
- args: string[],
- _options: unknown,
- callback: (error: Error | null, stdout?: string, stderr?: string) => void,
- ) => {
- if (args[0] === "remote") {
- callback(null, "", "");
- return;
- }
- callback(new Error("fetch failed"));
- },
- );
- const manager = new BackgroundGitFetchManager({ logger: logger as any });
-
- await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, vi.fn());
- await flushPromises();
-
- expect(logger.debug).toHaveBeenCalledWith(
- { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
- "Running background git fetch",
- );
- expect(logger.warn).toHaveBeenCalledWith(
- {
- err: expect.any(Error),
- repoGitRoot: "/tmp/repo/.git",
- cwd: "/tmp/repo",
- },
- "Background git fetch failed",
- );
-
- manager.dispose();
- });
-
- test("calls listeners when a fetch completes", async () => {
- const logger = createLogger();
- const manager = new BackgroundGitFetchManager({ logger: logger as any });
- const listener = vi.fn();
-
- await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, listener);
- await flushPromises();
-
- expect(listener).toHaveBeenCalledTimes(1);
-
- await vi.advanceTimersByTimeAsync(180_000);
- await flushPromises();
-
- expect(listener).toHaveBeenCalledTimes(2);
-
- manager.dispose();
- });
-
- test("does not create a timer when the repo has no origin remote", async () => {
- const logger = createLogger();
- execFileMock.mockImplementation(
- (
- _file: string,
- _args: string[],
- _options: unknown,
- callback: (error: Error | null, stdout?: string, stderr?: string) => void,
- ) => {
- callback(new Error("missing origin"));
- },
- );
- const manager = new BackgroundGitFetchManager({ logger: logger as any });
-
- const subscription = await manager.subscribe(
- { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
- vi.fn(),
- );
-
- expect((manager as any).targets.size).toBe(0);
- subscription.unsubscribe();
- manager.dispose();
- });
-
- test("dispose clears timers and listeners", async () => {
- const logger = createLogger();
- const manager = new BackgroundGitFetchManager({ logger: logger as any });
-
- const listener = vi.fn();
- await manager.subscribe({ repoGitRoot: "/tmp/repo-one/.git", cwd: "/tmp/repo-one" }, listener);
- await manager.subscribe({ repoGitRoot: "/tmp/repo-two/.git", cwd: "/tmp/repo-two" }, vi.fn());
- await flushPromises();
-
- const managerAny = manager as any;
- const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
-
- manager.dispose();
-
- expect(clearIntervalSpy).toHaveBeenCalledTimes(2);
- expect(managerAny.targets.size).toBe(0);
-
- clearIntervalSpy.mockRestore();
- });
-});
diff --git a/packages/server/src/server/background-git-fetch-manager.ts b/packages/server/src/server/background-git-fetch-manager.ts
deleted file mode 100644
index 190220254..000000000
--- a/packages/server/src/server/background-git-fetch-manager.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import { execFile } from "node:child_process";
-import { promisify } from "node:util";
-import type pino from "pino";
-import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
-
-const execFileAsync = promisify(execFile);
-
-const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
-
-type BackgroundGitFetchTarget = {
- repoGitRoot: string;
- cwd: string;
- listeners: Set<() => void>;
- intervalId: NodeJS.Timeout | null;
- fetchInFlight: boolean;
-};
-
-export class BackgroundGitFetchManager {
- private readonly logger: pino.Logger;
- private readonly targets = new Map();
-
- constructor(options: { logger: pino.Logger }) {
- this.logger = options.logger.child({ module: "background-git-fetch-manager" });
- }
-
- async subscribe(
- params: { repoGitRoot: string; cwd: string },
- listener: () => void,
- ): Promise<{ unsubscribe: () => void }> {
- const existingTarget = this.targets.get(params.repoGitRoot);
- if (existingTarget) {
- existingTarget.listeners.add(listener);
- return {
- unsubscribe: () => {
- this.removeListener(params.repoGitRoot, listener);
- },
- };
- }
-
- const hasOrigin = await this.hasOriginRemote(params.cwd);
- if (!hasOrigin) {
- return { unsubscribe: () => {} };
- }
-
- const targetAfterProbe = this.targets.get(params.repoGitRoot);
- if (targetAfterProbe) {
- targetAfterProbe.listeners.add(listener);
- return {
- unsubscribe: () => {
- this.removeListener(params.repoGitRoot, listener);
- },
- };
- }
-
- const target: BackgroundGitFetchTarget = {
- repoGitRoot: params.repoGitRoot,
- cwd: params.cwd,
- listeners: new Set([listener]),
- intervalId: setInterval(() => {
- void this.runFetch(target);
- }, BACKGROUND_GIT_FETCH_INTERVAL_MS),
- fetchInFlight: false,
- };
- this.targets.set(params.repoGitRoot, target);
- void this.runFetch(target);
-
- return {
- unsubscribe: () => {
- this.removeListener(params.repoGitRoot, listener);
- },
- };
- }
-
- dispose(): void {
- for (const target of this.targets.values()) {
- this.closeTarget(target);
- }
- this.targets.clear();
- }
-
- private closeTarget(target: BackgroundGitFetchTarget): void {
- if (target.intervalId) {
- clearInterval(target.intervalId);
- target.intervalId = null;
- }
- target.listeners.clear();
- }
-
- private removeListener(targetKey: string, listener: () => void): void {
- const target = this.targets.get(targetKey);
- if (!target) {
- return;
- }
-
- target.listeners.delete(listener);
- if (target.listeners.size > 0) {
- return;
- }
-
- this.closeTarget(target);
- this.targets.delete(targetKey);
- }
-
- private async hasOriginRemote(cwd: string): Promise {
- try {
- await execFileAsync("git", ["remote", "get-url", "origin"], {
- cwd,
- env: {
- ...READ_ONLY_GIT_ENV,
- GIT_TERMINAL_PROMPT: "0",
- },
- });
- return true;
- } catch {
- return false;
- }
- }
-
- private async runFetch(target: BackgroundGitFetchTarget): Promise {
- if (target.fetchInFlight) {
- return;
- }
-
- target.fetchInFlight = true;
- this.logger.debug(
- { repoGitRoot: target.repoGitRoot, cwd: target.cwd },
- "Running background git fetch",
- );
-
- try {
- await execFileAsync("git", ["fetch", "origin", "--prune"], {
- cwd: target.cwd,
- env: {
- ...process.env,
- GIT_TERMINAL_PROMPT: "0",
- },
- });
- } catch (error) {
- this.logger.warn(
- { err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
- "Background git fetch failed",
- );
- } finally {
- target.fetchInFlight = false;
- for (const listener of target.listeners) {
- listener();
- }
- }
- }
-}
diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts
index 06770fb68..2ee0f7e5f 100644
--- a/packages/server/src/server/bootstrap.ts
+++ b/packages/server/src/server/bootstrap.ts
@@ -6,7 +6,6 @@ import { randomUUID } from "node:crypto";
import { hostname as getHostname } from "node:os";
import path from "node:path";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
-import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Logger } from "pino";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
@@ -96,7 +95,11 @@ import { createSpeechService } from "./speech/speech-runtime.js";
import { AgentManager } from "./agent/agent-manager.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
-import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
+import {
+ buildProviderRegistry,
+ createAllClients,
+ shutdownProviders,
+} from "./agent/provider-registry.js";
import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js";
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
import { DbProjectRegistry } from "./db/db-project-registry.js";
@@ -109,6 +112,7 @@ import { FileBackedChatService } from "./chat/chat-service.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
+import { DaemonConfigStore } from "./daemon-config-store.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -134,28 +138,18 @@ import { resolveVoiceMcpBridgeFromRuntime } from "./voice-mcp-bridge-command.js"
type AgentMcpTransportMap = Map;
-function resolveVoiceMcpBridgeCommand(
- logger: Logger,
-): { command: string; baseArgs: string[] } | null {
- try {
- const decision = resolveVoiceMcpBridgeFromRuntime({
- bootstrapModuleUrl: import.meta.url,
- execPath: process.execPath,
- explicitScriptPath: process.env.PASEO_MCP_STDIO_SOCKET_BRIDGE_SCRIPT,
- });
- logger.info(
- {
- source: decision.source,
- command: decision.resolved.command,
- baseArgs: decision.resolved.baseArgs,
- },
- "Resolved voice MCP bridge command",
- );
- return decision.resolved;
- } catch (err) {
- logger.warn({ err }, "Voice MCP bridge script not available — voice MCP via stdio disabled");
+function formatHostForHttpUrl(host: string): string {
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
+}
+
+function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null {
+ if (!listenTarget || listenTarget.type !== "tcp") {
return null;
}
+ return new URL(
+ "/mcp/agents",
+ `http://${formatHostForHttpUrl(listenTarget.host)}:${listenTarget.port}`,
+ ).toString();
}
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
@@ -185,6 +179,7 @@ export type PaseoDaemonConfig = {
corsAllowedOrigins: string[];
allowedHosts?: AllowedHostsConfig;
mcpEnabled?: boolean;
+ mcpInjectIntoAgents?: boolean;
staticDir: string;
mcpDebug: boolean;
agentClients: Partial>;
@@ -225,6 +220,13 @@ export async function createPaseoDaemon(
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
const daemonVersion = resolveDaemonVersion(import.meta.url);
let database: PaseoDatabaseHandle | null = null;
+ const daemonConfigStore = new DaemonConfigStore(
+ config.paseoHome,
+ {
+ mcp: { injectIntoAgents: config.mcpInjectIntoAgents ?? true },
+ },
+ logger,
+ );
try {
const serverId = getOrCreateServerId(config.paseoHome, { logger });
@@ -418,6 +420,9 @@ export async function createPaseoDaemon(
terminalManager,
logger,
});
+ const providerRegistry = buildProviderRegistry(logger, {
+ runtimeSettings: config.agentProviderSettings,
+ });
const projectRegistry = new DbProjectRegistry(database.db);
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
@@ -508,6 +513,7 @@ export async function createPaseoDaemon(
};
const mcpEnabled = config.mcpEnabled ?? true;
+ let agentMcpBaseUrl: string | null = null;
if (mcpEnabled) {
const agentMcpRoute = "/mcp/agents";
const agentMcpTransports: AgentMcpTransportMap = new Map();
@@ -519,6 +525,8 @@ export async function createPaseoDaemon(
terminalManager,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
+ scheduleService,
+ providerRegistry,
paseoHome: config.paseoHome,
callerAgentId,
enableVoiceTools: false,
@@ -657,61 +665,6 @@ export async function createPaseoDaemon(
});
logger.info({ elapsed: elapsed() }, "Speech service created");
- wsServer = new VoiceAssistantWebSocketServer(
- httpServer,
- logger,
- serverId,
- agentManager,
- agentStorage,
- downloadTokenStore,
- config.paseoHome,
- createInMemoryAgentMcpTransport,
- { allowedOrigins, allowedHosts: config.allowedHosts },
- speechService,
- terminalManager,
- {
- voiceAgentMcpStdio: voiceMcpBridgeCommand
- ? {
- command: voiceMcpBridgeCommand.command,
- baseArgs: [...voiceMcpBridgeCommand.baseArgs],
- env: {
- ELECTRON_RUN_AS_NODE: "1",
- PASEO_HOME: config.paseoHome,
- },
- }
- : null,
- ensureVoiceMcpSocketForAgent: (agentId) =>
- voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ??
- Promise.reject(new Error("Voice MCP bridge manager is not initialized")),
- removeVoiceMcpSocketForAgent: (agentId) =>
- voiceMcpBridgeManager?.removeBridgeForCaller(agentId) ?? Promise.resolve(),
- },
- {
- finalTimeoutMs: config.dictationFinalTimeoutMs,
- },
- config.agentProviderSettings,
- daemonVersion,
- (intent) => {
- try {
- config.onLifecycleIntent?.(intent);
- } catch (error) {
- logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
- }
- },
- projectRegistry,
- workspaceRegistry,
- chatService,
- loopService,
- scheduleService,
- checkoutDiffManager,
- scriptRouteStore,
- scriptRuntimeStore,
- handleBranchChange,
- () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
- () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
- (hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
- );
-
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
const start = async () => {
@@ -725,6 +678,12 @@ export async function createPaseoDaemon(
httpServer.off("error", onError);
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
+ const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
+ agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
+ agentManager.setMcpBaseUrl(agentMcpBaseUrl);
+ daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
+ agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
+ });
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
@@ -746,6 +705,62 @@ export async function createPaseoDaemon(
);
}
+ wsServer = new VoiceAssistantWebSocketServer(
+ httpServer,
+ logger,
+ serverId,
+ agentManager,
+ agentStorage,
+ downloadTokenStore,
+ config.paseoHome,
+ daemonConfigStore,
+ mcpBaseUrl,
+ { allowedOrigins, allowedHosts: config.allowedHosts },
+ speechService,
+ terminalManager,
+ {
+ voiceAgentMcpStdio: voiceMcpBridgeCommand
+ ? {
+ command: voiceMcpBridgeCommand.command,
+ baseArgs: [...voiceMcpBridgeCommand.baseArgs],
+ env: {
+ ELECTRON_RUN_AS_NODE: "1",
+ PASEO_HOME: config.paseoHome,
+ },
+ }
+ : null,
+ ensureVoiceMcpSocketForAgent: (agentId) =>
+ voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ??
+ Promise.reject(new Error("Voice MCP bridge manager is not initialized")),
+ removeVoiceMcpSocketForAgent: (agentId) =>
+ voiceMcpBridgeManager?.removeBridgeForCaller(agentId) ?? Promise.resolve(),
+ },
+ {
+ finalTimeoutMs: config.dictationFinalTimeoutMs,
+ },
+ config.agentProviderSettings,
+ daemonVersion,
+ (intent) => {
+ try {
+ config.onLifecycleIntent?.(intent);
+ } catch (error) {
+ logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
+ }
+ },
+ projectRegistry,
+ workspaceRegistry,
+ chatService,
+ loopService,
+ scheduleService,
+ checkoutDiffManager,
+ scriptRouteStore,
+ scriptRuntimeStore,
+ handleBranchChange,
+ () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
+ () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
+ (hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
+ );
+
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
process.send({
type: "paseo:ready",
diff --git a/packages/server/src/server/chat/chat-service.ts b/packages/server/src/server/chat/chat-service.ts
index 3bf31006a..bdee71dc8 100644
--- a/packages/server/src/server/chat/chat-service.ts
+++ b/packages/server/src/server/chat/chat-service.ts
@@ -260,7 +260,9 @@ export class FileBackedChatService {
if (existing.length > 0) {
return existing;
}
- const knownMessage = this.getRoomMessages(room.id).some((message) => message.id === afterMessageId);
+ const knownMessage = this.getRoomMessages(room.id).some(
+ (message) => message.id === afterMessageId,
+ );
if (!knownMessage) {
throw new ChatServiceError(
"chat_message_not_found",
@@ -341,7 +343,9 @@ export class FileBackedChatService {
private async persist(): Promise {
const payload: ChatStorePayload = {
- rooms: Array.from(this.rooms.values()).sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
+ rooms: Array.from(this.rooms.values()).sort((left, right) =>
+ left.createdAt.localeCompare(right.createdAt),
+ ),
messages: Array.from(this.messagesByRoomId.values())
.flat()
.sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts
index 22f0fda8c..b683cd38a 100644
--- a/packages/server/src/server/checkout-diff-manager.test.ts
+++ b/packages/server/src/server/checkout-diff-manager.test.ts
@@ -5,9 +5,15 @@ const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, w
vi.hoisted(() => {
const hoistedWatchCalls: Array<{ path: string; close: ReturnType }> = [];
return {
- execMock: vi.fn((_command: string, _options: unknown, callback: (error: null, result: { stdout: string; stderr: string }) => void) => {
- callback(null, { stdout: "/tmp/repo\n", stderr: "" });
- }),
+ execMock: vi.fn(
+ (
+ _command: string,
+ _options: unknown,
+ callback: (error: null, result: { stdout: string; stderr: string }) => void,
+ ) => {
+ callback(null, { stdout: "/tmp/repo\n", stderr: "" });
+ },
+ ),
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
readdirMock: vi.fn(async (directory: string) => {
diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts
index a0a5dddf8..3043c3c17 100644
--- a/packages/server/src/server/config.ts
+++ b/packages/server/src/server/config.ts
@@ -16,10 +16,27 @@ const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
+function parseBooleanEnv(value: string | undefined): boolean | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+
+ const normalized = value.trim().toLowerCase();
+ if (["1", "true", "yes", "on"].includes(normalized)) {
+ return true;
+ }
+ if (["0", "false", "no", "off"].includes(normalized)) {
+ return false;
+ }
+
+ return undefined;
+}
+
export type CliConfigOverrides = Partial<{
listen: string;
relayEnabled: boolean;
mcpEnabled: boolean;
+ mcpInjectIntoAgents: boolean;
allowedHosts: AllowedHostsConfig;
}>;
@@ -51,7 +68,10 @@ export function loadConfig(
// - unix:///path/to/socket (Unix socket)
// Default is TCP at 127.0.0.1:6767
const listen =
- options?.cli?.listen ?? env.PASEO_LISTEN ?? persisted.daemon?.listen ?? `127.0.0.1:${env.PORT ?? DEFAULT_PORT}`;
+ options?.cli?.listen ??
+ env.PASEO_LISTEN ??
+ persisted.daemon?.listen ??
+ `127.0.0.1:${env.PORT ?? DEFAULT_PORT}`;
const envCorsOrigins = env.PASEO_CORS_ORIGINS
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
@@ -65,9 +85,15 @@ export function loadConfig(
options?.cli?.allowedHosts,
]);
- const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? false;
+ const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
+ const mcpInjectIntoAgents =
+ options?.cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false;
- const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true;
+ const relayEnabled =
+ options?.cli?.relayEnabled ??
+ parseBooleanEnv(env.PASEO_RELAY_ENABLED) ??
+ persisted.daemon?.relay?.enabled ??
+ true;
const relayEndpoint =
env.PASEO_RELAY_ENDPOINT ?? persisted.daemon?.relay?.endpoint ?? DEFAULT_RELAY_ENDPOINT;
@@ -100,6 +126,7 @@ export function loadConfig(
),
allowedHosts,
mcpEnabled,
+ mcpInjectIntoAgents,
mcpDebug: env.MCP_DEBUG === "1",
agentStoragePath: path.join(paseoHome, "agents"),
staticDir: "public",
diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts
new file mode 100644
index 000000000..33f5b17ce
--- /dev/null
+++ b/packages/server/src/server/daemon-config-store.ts
@@ -0,0 +1,161 @@
+import {
+ loadPersistedConfig,
+ savePersistedConfig,
+ type PersistedConfig,
+} from "./persisted-config.js";
+import { MutableDaemonConfigSchema, MutableDaemonConfigPatchSchema } from "../shared/messages.js";
+
+export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "../shared/messages.js";
+
+type MutableDaemonConfig = import("../shared/messages.js").MutableDaemonConfig;
+type MutableDaemonConfigPatch = import("../shared/messages.js").MutableDaemonConfigPatch;
+
+type LoggerLike = {
+ child(bindings: Record): LoggerLike;
+ info(...args: any[]): void;
+};
+
+type ConfigListener = (config: MutableDaemonConfig) => void;
+type FieldChangeHandler = (value: unknown) => void;
+
+function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
+ return logger?.child({ module: "daemon-config-store" });
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function deepMerge>(
+ current: T,
+ patch: Record,
+): T {
+ const next: Record = { ...current };
+
+ for (const [key, patchValue] of Object.entries(patch)) {
+ if (patchValue === undefined) {
+ continue;
+ }
+ const currentValue = next[key];
+ if (isRecord(currentValue) && isRecord(patchValue)) {
+ next[key] = deepMerge(currentValue, patchValue);
+ continue;
+ }
+ next[key] = patchValue;
+ }
+
+ return next as T;
+}
+
+function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
+ return path
+ .split(".")
+ .reduce((value, segment) => (isRecord(value) ? value[segment] : undefined), config);
+}
+
+function isEqualValue(a: unknown, b: unknown): boolean {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
+
+export class DaemonConfigStore {
+ private current: MutableDaemonConfig;
+ private readonly paseoHome: string;
+ private readonly logger: LoggerLike | undefined;
+ private readonly changeListeners = new Set();
+ private readonly fieldChangeHandlers = new Map>();
+
+ constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) {
+ this.paseoHome = paseoHome;
+ this.logger = getLogger(logger);
+ this.current = MutableDaemonConfigSchema.parse(initial);
+ }
+
+ public get(): MutableDaemonConfig {
+ return this.current;
+ }
+
+ public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
+ const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
+ const next = MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch));
+
+ const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => {
+ return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path));
+ });
+
+ if (changedFieldPaths.length === 0 && isEqualValue(this.current, next)) {
+ return this.current;
+ }
+
+ // Persist before updating in-memory state so that if persistence fails,
+ // runtime and disk stay consistent.
+ this.persistConfig(next);
+ this.current = next;
+
+ for (const path of changedFieldPaths) {
+ const handlers = this.fieldChangeHandlers.get(path);
+ if (!handlers) {
+ continue;
+ }
+ const value = getValueAtPath(next, path);
+ for (const handler of handlers) {
+ handler(value);
+ }
+ }
+
+ for (const listener of this.changeListeners) {
+ listener(next);
+ }
+
+ return next;
+ }
+
+ public onFieldChange(path: string, handler: FieldChangeHandler): () => void {
+ const handlers = this.fieldChangeHandlers.get(path) ?? new Set();
+ handlers.add(handler);
+ this.fieldChangeHandlers.set(path, handlers);
+
+ return () => {
+ const currentHandlers = this.fieldChangeHandlers.get(path);
+ if (!currentHandlers) {
+ return;
+ }
+ currentHandlers.delete(handler);
+ if (currentHandlers.size === 0) {
+ this.fieldChangeHandlers.delete(path);
+ }
+ };
+ }
+
+ public onChange(listener: ConfigListener): () => void {
+ this.changeListeners.add(listener);
+ return () => {
+ this.changeListeners.delete(listener);
+ };
+ }
+
+ private persistConfig(config: MutableDaemonConfig): void {
+ const persisted = loadPersistedConfig(this.paseoHome, this.logger);
+ const nextPersisted = mergeMutableConfigIntoPersistedConfig({
+ persisted,
+ mutable: config,
+ });
+ savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
+ }
+}
+
+function mergeMutableConfigIntoPersistedConfig(params: {
+ persisted: PersistedConfig;
+ mutable: MutableDaemonConfig;
+}): PersistedConfig {
+ const { persisted, mutable } = params;
+ return {
+ ...persisted,
+ daemon: {
+ ...persisted.daemon,
+ mcp: {
+ ...persisted.daemon?.mcp,
+ injectIntoAgents: mutable.mcp.injectIntoAgents,
+ },
+ },
+ };
+}
diff --git a/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts
index 50bc04828..a5ea9b8f7 100644
--- a/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts
@@ -19,8 +19,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
-// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+// Use gpt-5.4-mini with low thinking preset for faster test execution
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts
index df9ea8953..ca66c17ae 100644
--- a/packages/server/src/server/daemon-e2e/agent-configs.ts
+++ b/packages/server/src/server/daemon-e2e/agent-configs.ts
@@ -34,7 +34,7 @@ export const agentConfigs = {
},
codex: {
provider: "codex",
- model: "gpt-5.1-codex-mini",
+ model: "gpt-5.4-mini",
thinkingOptionId: "low",
modes: {
full: "full-access",
@@ -131,10 +131,4 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
/**
* Helper to run a test for each provider.
*/
-export const allProviders: AgentProvider[] = [
- "claude",
- "codex",
- "copilot",
- "opencode",
- "pi",
-];
+export const allProviders: AgentProvider[] = ["claude", "codex", "copilot", "opencode", "pi"];
diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts
index 3d2f5f2b9..0e413a5e4 100644
--- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts
@@ -18,8 +18,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
-// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+// Use gpt-5.4-mini with low thinking preset for faster test execution
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts
index 25f0dd784..d9950c5a7 100644
--- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts
@@ -7,7 +7,7 @@ import { execSync } from "child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createWorktree } from "../../utils/worktree.js";
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
function tmpCwd(prefix: string): string {
diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts
index c49219006..e4db483ea 100644
--- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts
@@ -467,11 +467,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
// wake; if none arrives within the expected sleep window, verify the
// agent settled to idle (notification was already processed).
const autonomousWake = await client
- .waitForAgentUpsert(
- agent.id,
- (snapshot) => snapshot.status === "running",
- 15_000,
- )
+ .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000)
.catch(() => null);
if (autonomousWake) {
@@ -536,11 +532,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
// HELLO. When it races with HELLO, the notification is handled during
// the foreground turn and there is no separate autonomous running edge.
const autonomousWake = await client
- .waitForAgentUpsert(
- agent.id,
- (snapshot) => snapshot.status === "running",
- 15_000,
- )
+ .waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000)
.catch(() => null);
if (autonomousWake) {
diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts
index dbce142ae..6fa381b84 100644
--- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts
@@ -18,8 +18,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
-// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+// Use gpt-5.4-mini with low thinking preset for faster test execution
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts
index 6294c5512..73c829067 100644
--- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts
@@ -18,8 +18,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
-// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+// Use gpt-5.4-mini with low thinking preset for faster test execution
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts
index 2f5381c86..f63218a35 100644
--- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts
@@ -174,8 +174,8 @@ function getWorktreeTerminalBootstrapEntries(
return terminals as WorktreeTerminalBootstrapEntry[];
}
-// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+// Use gpt-5.4-mini with low thinking preset for faster test execution
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
diff --git a/packages/server/src/server/daemon-e2e/images.e2e.test.ts b/packages/server/src/server/daemon-e2e/images.e2e.test.ts
index ef7b33ff7..461c8d4d3 100644
--- a/packages/server/src/server/daemon-e2e/images.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/images.e2e.test.ts
@@ -19,7 +19,7 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
diff --git a/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts b/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts
index 0126e761b..ab7fe2de4 100644
--- a/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts
@@ -24,7 +24,10 @@ function collectAgentUpdates(client: DaemonClient): {
return { updates, unsub };
}
-function lastUpsertFor(updates: AgentUpdatePayload[], agentId: string): AgentUpsertPayload | undefined {
+function lastUpsertFor(
+ updates: AgentUpdatePayload[],
+ agentId: string,
+): AgentUpsertPayload | undefined {
return updates
.filter((u): u is AgentUpsertPayload => u.kind === "upsert" && u.agent.id === agentId)
.at(-1);
@@ -125,9 +128,7 @@ describe("mode-switch update propagation", () => {
const modeUpdate = client2Updates.find(
(u): u is AgentUpsertPayload =>
- u.kind === "upsert" &&
- u.agent.id === agent.id &&
- u.agent.currentModeId === "acceptEdits",
+ u.kind === "upsert" && u.agent.id === agent.id && u.agent.currentModeId === "acceptEdits",
);
expect(modeUpdate).toBeDefined();
diff --git a/packages/server/src/server/daemon-e2e/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts
index dc98d1aa0..7691e6371 100644
--- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts
@@ -1,25 +1,6 @@
-import { describe, test, expect, beforeEach, afterEach } from "vitest";
-import {
- mkdtempSync,
- writeFileSync,
- existsSync,
- rmSync,
- mkdirSync,
- readFileSync,
- readdirSync,
-} from "fs";
-import { tmpdir } from "os";
-import path from "path";
+import { describe, test, expect } from "vitest";
import { execFileSync } from "node:child_process";
-import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
-import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
-import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
-
-function tmpCwd(): string {
- return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
-}
-
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+import { createDaemonTestContext } from "../test-utils/index.js";
function isBinaryInstalled(binary: string): boolean {
try {
@@ -34,25 +15,44 @@ const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
describe("daemon E2E", () => {
- let ctx: DaemonTestContext;
-
- beforeEach(async () => {
- ctx = await createDaemonTestContext();
- });
-
- afterEach(async () => {
- await ctx.cleanup();
- }, 60000);
-
describe("listProviderModels", () => {
test.runIf(hasCodex)(
"returns model list for Codex provider",
async () => {
- // List models for Codex provider - no agent needed
- const result = await ctx.client.listProviderModels("codex");
+ const ctx = await createDaemonTestContext();
+ try {
+ // List models for Codex provider - no agent needed
+ const result = await ctx.client.listProviderModels("codex");
+
+ // Verify response structure
+ expect(result.provider).toBe("codex");
+ expect(result.error).toBeNull();
+ expect(result.fetchedAt).toBeTruthy();
+
+ // Should return at least one model
+ expect(result.models).toBeTruthy();
+ expect(result.models.length).toBeGreaterThan(0);
+
+ // Verify model structure
+ const model = result.models[0];
+ expect(model.provider).toBe("codex");
+ expect(model.id).toBeTruthy();
+ expect(model.label).toBeTruthy();
+ } finally {
+ await ctx.cleanup();
+ }
+ },
+ 60000, // 1 minute timeout
+ );
+
+ test("returns model list for Claude provider", async () => {
+ const ctx = await createDaemonTestContext();
+ try {
+ // List models for Claude provider - no agent needed
+ const result = await ctx.client.listProviderModels("claude");
// Verify response structure
- expect(result.provider).toBe("codex");
+ expect(result.provider).toBe("claude");
expect(result.error).toBeNull();
expect(result.fetchedAt).toBeTruthy();
@@ -62,49 +62,35 @@ describe("daemon E2E", () => {
// Verify model structure
const model = result.models[0];
- expect(model.provider).toBe("codex");
+ expect(model.provider).toBe("claude");
expect(model.id).toBeTruthy();
expect(model.label).toBeTruthy();
- },
- 60000, // 1 minute timeout
- );
-
- test("returns model list for Claude provider", async () => {
- // List models for Claude provider - no agent needed
- const result = await ctx.client.listProviderModels("claude");
-
- // Verify response structure
- expect(result.provider).toBe("claude");
- expect(result.error).toBeNull();
- expect(result.fetchedAt).toBeTruthy();
-
- // Should return at least one model
- expect(result.models).toBeTruthy();
- expect(result.models.length).toBeGreaterThan(0);
-
- // Verify model structure
- const model = result.models[0];
- expect(model.provider).toBe("claude");
- expect(model.id).toBeTruthy();
- expect(model.label).toBeTruthy();
- }, 60000); // 1 minute timeout
+ } finally {
+ await ctx.cleanup();
+ }
+ }, 180000);
test.runIf(hasOpenCode)(
"returns model list for OpenCode provider",
async () => {
- const result = await ctx.client.listProviderModels("opencode");
+ const ctx = await createDaemonTestContext();
+ try {
+ const result = await ctx.client.listProviderModels("opencode");
- expect(result.provider).toBe("opencode");
- expect(result.error).toBeNull();
- expect(result.fetchedAt).toBeTruthy();
+ expect(result.provider).toBe("opencode");
+ expect(result.error).toBeNull();
+ expect(result.fetchedAt).toBeTruthy();
- expect(result.models).toBeTruthy();
- expect(result.models.length).toBeGreaterThan(0);
+ expect(result.models).toBeTruthy();
+ expect(result.models.length).toBeGreaterThan(0);
- const model = result.models[0];
- expect(model.provider).toBe("opencode");
- expect(model.id).toBeTruthy();
- expect(model.label).toBeTruthy();
+ const model = result.models[0];
+ expect(model.provider).toBe("opencode");
+ expect(model.id).toBeTruthy();
+ expect(model.label).toBeTruthy();
+ } finally {
+ await ctx.cleanup();
+ }
},
60000,
);
diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts
index d8213d660..e3d000f9f 100644
--- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts
@@ -64,9 +64,9 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
);
expect(assistantMessages.length).toBeGreaterThan(0);
- expect(
- assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK")),
- ).toBe(true);
+ expect(assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK"))).toBe(
+ true,
+ );
} finally {
await client.close().catch(() => undefined);
await daemon.close();
diff --git a/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts
index 4f9289854..1f7f47ba9 100644
--- a/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts
@@ -17,7 +17,9 @@ function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = ["gpt-5-nano", "gpt-4.1-nano", "mini", "free"],
): string {
- const preferred = models.find((model) => preferences.some((fragment) => model.id.includes(fragment)));
+ const preferred = models.find((model) =>
+ preferences.some((fragment) => model.id.includes(fragment)),
+ );
return preferred?.id ?? models[0]!.id;
}
diff --git a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts
index 4d4ceb1fa..f0539ba1a 100644
--- a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts
@@ -325,12 +325,10 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
expect(finish.status).toBe("idle");
const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id);
- expect(
- postSendAssistantTexts.some((text) => text.includes("[System Error]")),
- ).toBe(false);
- expect(
- postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
- ).toBe(false);
+ expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
+ expect(postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(
+ false,
+ );
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 });
const assistantTexts = getTimelineAssistantTexts(timeline);
@@ -400,9 +398,9 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
expect(finish.status).toBe("idle");
const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id);
- expect(
- postInterruptAssistantTexts.some((text) => text.includes("[System Error]")),
- ).toBe(false);
+ expect(postInterruptAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(
+ false,
+ );
expect(
postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
).toBe(false);
diff --git a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts
index d1f424ce3..159191cf4 100644
--- a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts
@@ -18,8 +18,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
-// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+// Use gpt-5.4-mini with low thinking preset for faster test execution
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts
index b616a487b..5b427c590 100644
--- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts
@@ -71,7 +71,10 @@ function getAgentStatuses(messages: SessionOutboundMessage[], agentId: string):
.map((message) => message.payload.agent.status);
}
-function getStatusesBeforeFirstAssistant(messages: SessionOutboundMessage[], agentId: string): string[] {
+function getStatusesBeforeFirstAssistant(
+ messages: SessionOutboundMessage[],
+ agentId: string,
+): string[] {
const firstAssistantIndex = messages.findIndex(
(message) =>
message.type === "agent_stream" &&
@@ -126,7 +129,9 @@ async function waitForRunningToolCall(
.map((entry) => entry.item.text) ?? [];
const limitText = assistantTexts.find((text) => hasProviderLimitText(text));
if (limitText) {
- throw new Error(`Claude could not reach the tool call because the provider rejected the run: ${limitText}`);
+ throw new Error(
+ `Claude could not reach the tool call because the provider rejected the run: ${limitText}`,
+ );
}
if (
timeline?.entries.some(
@@ -252,9 +257,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
});
// No system error messages should leak into the timeline
- const hasSystemError = assistantTexts.some((text) =>
- text.includes("[System Error]"),
- );
+ const hasSystemError = assistantTexts.some((text) => text.includes("[System Error]"));
expect(hasSystemError).toBe(false);
expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts
index 83f777de7..3c822196b 100644
--- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts
@@ -1106,7 +1106,12 @@ describe("daemon E2E terminal", () => {
type: "input",
data: "echo hello world\r",
});
- await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("hello world"), 15000);
+ await waitForTerminalOutput(
+ ctx.client,
+ terminalId,
+ (text) => text.includes("hello world"),
+ 15000,
+ );
const capture = await ctx.client.captureTerminal(terminalId);
@@ -1188,7 +1193,12 @@ describe("daemon E2E terminal", () => {
type: "input",
data: "printf '\\033[31mred text\\033[0m\\n'\r",
});
- await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("red text"), 15000);
+ await waitForTerminalOutput(
+ ctx.client,
+ terminalId,
+ (text) => text.includes("red text"),
+ 15000,
+ );
const capture = await ctx.client.captureTerminal(terminalId);
const capturedText = capture.lines.join("\n");
diff --git a/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts b/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts
index 51dac983b..1026812e9 100644
--- a/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts
@@ -19,8 +19,8 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
-// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
-const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
+// Use gpt-5.4-mini with low thinking preset for faster test execution
+const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
type ToolCallItem = Extract;
diff --git a/packages/server/src/server/editor-targets.ts b/packages/server/src/server/editor-targets.ts
index b2549dd7f..91fb36380 100644
--- a/packages/server/src/server/editor-targets.ts
+++ b/packages/server/src/server/editor-targets.ts
@@ -6,11 +6,7 @@ import type {
EditorTargetId,
KnownEditorTargetId,
} from "../shared/messages.js";
-import {
- findExecutable,
- quoteWindowsArgument,
- quoteWindowsCommand,
-} from "../utils/executable.js";
+import { findExecutable, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
type EditorTargetDefinition = {
id: KnownEditorTargetId;
@@ -148,7 +144,9 @@ export async function openInEditorTarget(
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
const args =
- platform === "win32" ? launch.args.map((argument) => quoteWindowsArgument(argument)) : launch.args;
+ platform === "win32"
+ ? launch.args.map((argument) => quoteWindowsArgument(argument))
+ : launch.args;
await new Promise((resolve, reject) => {
let child: ChildProcess;
diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts
index 095afc138..828c60f45 100644
--- a/packages/server/src/server/exports.ts
+++ b/packages/server/src/server/exports.ts
@@ -30,10 +30,13 @@ export {
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
// Provider binary resolution
+export { applyProviderEnv } from "./agent/provider-launch-config.js";
export {
- applyProviderEnv,
-} from "./agent/provider-launch-config.js";
-export { findExecutable, findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
+ findExecutable,
+ findExecutableSync,
+ quoteWindowsArgument,
+ quoteWindowsCommand,
+} from "../utils/executable.js";
export { spawnProcess } from "../utils/spawn.js";
// Provider manifest (source of truth for provider definitions)
diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts
index 87f104963..4768c6a72 100644
--- a/packages/server/src/server/index.ts
+++ b/packages/server/src/server/index.ts
@@ -3,7 +3,7 @@ import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";
import { createRootLogger } from "./logger.js";
import { loadPersistedConfig } from "./persisted-config.js";
-import { PidLockError } from "./pid-lock.js";
+import { acquirePidLock, PidLockError, releasePidLock, updatePidLock } from "./pid-lock.js";
import type { DaemonLifecycleIntent } from "./bootstrap.js";
type SupervisorLifecycleMessage =
@@ -22,6 +22,8 @@ async function main() {
let daemon: Awaited> | null = null;
let shutdownPromise: Promise | null = null;
let exitHookInstalled = false;
+ const supervised = process.env.PASEO_SUPERVISED === "1" && typeof process.send === "function";
+ let pidLockAcquired = false;
try {
paseoHome = resolvePaseoHome();
@@ -40,6 +42,9 @@ async function main() {
if (process.argv.includes("--no-mcp")) {
config.mcpEnabled = false;
}
+ if (process.argv.includes("--no-inject-mcp")) {
+ config.mcpInjectIntoAgents = false;
+ }
const installExitHook = () => {
if (exitHookInstalled || !shutdownPromise) {
@@ -73,6 +78,10 @@ async function main() {
return 1;
}
await daemon.stop();
+ if (pidLockAcquired) {
+ await releasePidLock(paseoHome);
+ pidLockAcquired = false;
+ }
clearTimeout(forceExit);
logger.info("Server closed");
return options?.successExitCode ?? 0;
@@ -131,6 +140,11 @@ async function main() {
};
try {
+ if (!supervised) {
+ await acquirePidLock(paseoHome, null);
+ pidLockAcquired = true;
+ }
+
daemon = await createPaseoDaemon(
{
...config,
@@ -139,6 +153,10 @@ async function main() {
logger,
);
} catch (err) {
+ if (pidLockAcquired) {
+ await releasePidLock(paseoHome);
+ pidLockAcquired = false;
+ }
if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
@@ -149,7 +167,22 @@ async function main() {
try {
await daemon.start();
+ if (!supervised) {
+ const listenTarget = daemon.getListenTarget();
+ const listen =
+ listenTarget?.type === "tcp"
+ ? `${listenTarget.host}:${listenTarget.port}`
+ : listenTarget?.path;
+ if (!listen) {
+ throw new Error("Daemon did not expose a listen target after startup");
+ }
+ await updatePidLock(paseoHome, { listen });
+ }
} catch (err) {
+ if (pidLockAcquired) {
+ await releasePidLock(paseoHome);
+ pidLockAcquired = false;
+ }
if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
diff --git a/packages/server/src/server/logger.test.ts b/packages/server/src/server/logger.test.ts
index d8c090dbb..2de8ba1a5 100644
--- a/packages/server/src/server/logger.test.ts
+++ b/packages/server/src/server/logger.test.ts
@@ -25,13 +25,13 @@ describe("resolveLogConfig", () => {
it("returns dual-sink defaults when no config or env vars", () => {
const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({
- level: "trace",
+ level: "debug",
console: {
level: "info",
format: "pretty",
},
file: {
- level: "trace",
+ level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
@@ -179,13 +179,13 @@ describe("resolveLogConfig", () => {
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
- level: "trace",
+ level: "debug",
console: {
level: "warn",
format: "pretty",
},
file: {
- level: "trace",
+ level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts
index 3385584f5..1e0d9bed6 100644
--- a/packages/server/src/server/loop-service.test.ts
+++ b/packages/server/src/server/loop-service.test.ts
@@ -102,7 +102,10 @@ class ScriptedAgentSession implements AgentSession {
};
}
- async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> {
+ async startTurn(
+ prompt: AgentPromptInput,
+ _options?: AgentRunOptions,
+ ): Promise<{ turnId: string }> {
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
const turnId = `turn-${++this.turnCount}`;
this.interrupted = false;
@@ -183,7 +186,12 @@ class ScriptedAgentSession implements AgentSession {
turnId,
});
if (this.interrupted) {
- this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
+ this.emit({
+ type: "turn_canceled",
+ provider: this.provider,
+ reason: "interrupted",
+ turnId,
+ });
return;
}
this.emit({
@@ -236,7 +244,7 @@ describe("LoopService", () => {
if (config.title?.includes("worker")) {
return `worker run ${state.workerRuns}`;
}
- return "{\"passed\":true,\"reason\":\"not used\"}";
+ return '{"passed":true,"reason":"not used"}';
},
}),
},
@@ -282,7 +290,7 @@ describe("LoopService", () => {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
verifierConfigs.push(config);
- return "{\"passed\":true,\"reason\":\"verified\"}";
+ return '{"passed":true,"reason":"verified"}';
},
}),
},
@@ -338,7 +346,7 @@ describe("LoopService", () => {
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
return "created done.txt";
}
- return "{\"passed\":true,\"reason\":\"done.txt exists\"}";
+ return '{"passed":true,"reason":"done.txt exists"}';
},
}),
},
@@ -394,8 +402,8 @@ describe("LoopService", () => {
}
const exists = pathExists(path.join(workspaceDir, "done.txt"));
return exists
- ? "{\"passed\":true,\"reason\":\"done.txt exists\"}"
- : "{\"passed\":false,\"reason\":\"done.txt missing\"}";
+ ? '{"passed":true,"reason":"done.txt exists"}'
+ : '{"passed":false,"reason":"done.txt missing"}';
},
}),
},
@@ -437,7 +445,7 @@ describe("LoopService", () => {
await blocker;
return "finished";
}
- return "{\"passed\":true,\"reason\":\"ok\"}";
+ return '{"passed":true,"reason":"ok"}';
},
}),
},
diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts
index ead9e2bf0..c3a2d4ab9 100644
--- a/packages/server/src/server/loop-service.ts
+++ b/packages/server/src/server/loop-service.ts
@@ -598,7 +598,9 @@ export class LoopService {
iteration: LoopIterationRecord,
signal: AbortSignal,
): Promise {
- const agent = await this.options.agentManager.createAgent(this.buildWorkerConfig(loop, iteration));
+ const agent = await this.options.agentManager.createAgent(
+ this.buildWorkerConfig(loop, iteration),
+ );
iteration.workerAgentId = agent.id;
loop.activeWorkerAgentId = agent.id;
loop.updatedAt = nowIso();
@@ -688,9 +690,7 @@ export class LoopService {
iteration: iteration.index,
source: "verify-check",
level: result.passed ? "info" : "error",
- text: output
- ? `exit ${result.exitCode}\n${output}`
- : `exit ${result.exitCode}`,
+ text: output ? `exit ${result.exitCode}\n${output}` : `exit ${result.exitCode}`,
});
loop.updatedAt = nowIso();
await this.persist();
@@ -736,7 +736,10 @@ export class LoopService {
try {
const result = await getStructuredAgentResponse({
caller: async (nextPrompt) => {
- const run = await this.options.agentManager.runAgent(verifierAgent.id, this.toPrompt(nextPrompt));
+ const run = await this.options.agentManager.runAgent(
+ verifierAgent.id,
+ this.toPrompt(nextPrompt),
+ );
return this.resolveFinalText(run.timeline, run.finalText);
},
prompt: loop.verifyPrompt,
@@ -788,7 +791,10 @@ export class LoopService {
};
}
- private buildVerifierConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig {
+ private buildVerifierConfig(
+ loop: LoopRecord,
+ iteration: LoopIterationRecord,
+ ): AgentSessionConfig {
return {
provider: loop.verifierProvider ?? loop.provider,
cwd: loop.cwd,
@@ -815,7 +821,11 @@ export class LoopService {
return text;
}
- private finishLoop(loop: LoopRecord, status: Exclude, message: string): void {
+ private finishLoop(
+ loop: LoopRecord,
+ status: Exclude,
+ message: string,
+ ): void {
loop.status = status;
loop.completedAt = nowIso();
loop.updatedAt = loop.completedAt;
@@ -830,10 +840,7 @@ export class LoopService {
});
}
- private appendLog(
- loop: LoopRecord,
- entry: Omit,
- ): void {
+ private appendLog(loop: LoopRecord, entry: Omit): void {
loop.logs.push({
seq: loop.nextLogSeq,
timestamp: nowIso(),
@@ -852,7 +859,9 @@ export class LoopService {
if (exact) {
return exact;
}
- const matches = Array.from(this.loops.values()).filter((record) => record.id.startsWith(trimmed));
+ const matches = Array.from(this.loops.values()).filter((record) =>
+ record.id.startsWith(trimmed),
+ );
if (matches.length === 1) {
return matches[0]!;
}
diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts
index 7302fb472..34b9515e8 100644
--- a/packages/server/src/server/persisted-config.ts
+++ b/packages/server/src/server/persisted-config.ts
@@ -126,8 +126,9 @@ export const PersistedConfigSchema = z
mcp: z
.object({
enabled: z.boolean().optional(),
+ injectIntoAgents: z.boolean().optional(),
})
- .strict()
+ .passthrough()
.optional(),
cors: z
.object({
diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts
index 22510d478..88c0047c8 100644
--- a/packages/server/src/server/persistence-hooks.test.ts
+++ b/packages/server/src/server/persistence-hooks.test.ts
@@ -29,7 +29,7 @@ describe("persistence hooks", () => {
config: {
title: "Voice agent (created)",
modeId: "default",
- model: "gpt-5.1-codex-mini",
+ model: "gpt-5.4-mini",
thinkingOptionId: "minimal",
systemPrompt: "Use speak first.",
mcpServers: {
@@ -45,7 +45,7 @@ describe("persistence hooks", () => {
expect(buildConfigOverrides(record)).toMatchObject({
cwd: "/tmp/project",
modeId: "plan",
- model: "gpt-5.1-codex-mini",
+ model: "gpt-5.4-mini",
thinkingOptionId: "minimal",
title: "Voice agent (created)",
systemPrompt: "Use speak first.",
@@ -66,7 +66,7 @@ describe("persistence hooks", () => {
config: {
title: "Creation title",
modeId: "default",
- model: "gpt-5.1-codex-mini",
+ model: "gpt-5.4-mini",
systemPrompt: "Confirm and speak first.",
mcpServers: {
paseo: {
@@ -82,7 +82,7 @@ describe("persistence hooks", () => {
provider: "codex",
cwd: "/tmp/project",
modeId: "plan",
- model: "gpt-5.1-codex-mini",
+ model: "gpt-5.4-mini",
title: "Creation title",
systemPrompt: "Confirm and speak first.",
mcpServers: {
diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts
index 286ee1218..305858119 100644
--- a/packages/server/src/server/pid-lock.ts
+++ b/packages/server/src/server/pid-lock.ts
@@ -127,10 +127,7 @@ export async function updatePidLock(
const existingLock = JSON.parse(content) as PidLockInfo;
if (existingLock.pid !== lockOwnerPid) {
- throw new PidLockError(
- `Cannot update PID lock owned by PID ${existingLock.pid}`,
- existingLock,
- );
+ throw new PidLockError(`Cannot update PID lock owned by PID ${existingLock.pid}`, existingLock);
}
const updatedLock: PidLockInfo = {
diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts
index 4ee2fe7c0..4e6059200 100644
--- a/packages/server/src/server/relay-transport.ts
+++ b/packages/server/src/server/relay-transport.ts
@@ -149,7 +149,7 @@ export function startRelayTransport({
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
- relayLogger.info({ url, connectionId }, "relay_control_connected");
+ relayLogger.info({ connectionId }, "relay_control_connected");
};
socket.on("open", () => {
@@ -203,7 +203,7 @@ export function startRelayTransport({
try {
socket.send(JSON.stringify({ type: "ping", ts: now }));
} catch (error) {
- relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
+ relayLogger.warn({ err: error, connectionId }, "relay_control_ping_send_failed");
try {
socket.terminate();
} catch {
@@ -214,14 +214,14 @@ export function startRelayTransport({
try {
socket.send(JSON.stringify({ type: "ping", ts: Date.now() }));
} catch (error) {
- relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
+ relayLogger.warn({ err: error, connectionId }, "relay_control_ping_send_failed");
try {
socket.terminate();
} catch {
// ignore
}
}
- relayLogger.debug({ url, connectionId }, "relay_control_open_waiting_for_ready");
+ relayLogger.debug({ connectionId }, "relay_control_open_waiting_for_ready");
});
socket.on("close", (code, reason) => {
@@ -244,7 +244,7 @@ export function startRelayTransport({
socket.on("error", (err) => {
if (controlWs !== socket) return;
- relayLogger.warn({ err, url, connectionId }, "relay_error");
+ relayLogger.warn({ err, connectionId }, "relay_error");
// close event will schedule reconnect
});
@@ -319,7 +319,7 @@ export function startRelayTransport({
const openTimeout = setTimeout(() => {
if (stopped) return;
if (socket.readyState === WebSocket.OPEN) return;
- relayLogger.warn({ url, connectionId }, "relay_data_open_timeout_terminating");
+ relayLogger.warn({ connectionId }, "relay_data_open_timeout_terminating");
try {
socket.terminate();
} catch {
@@ -329,7 +329,7 @@ export function startRelayTransport({
socket.on("open", () => {
clearTimeout(openTimeout);
- relayLogger.info({ url, connectionId }, "relay_data_connected");
+ relayLogger.info({ connectionId }, "relay_data_connected");
if (attached) return;
attached = true;
const externalMetadata: ExternalSocketMetadata = {
@@ -361,7 +361,7 @@ export function startRelayTransport({
});
socket.on("error", (err) => {
- relayLogger.warn({ err, url, connectionId }, "relay_data_error");
+ relayLogger.warn({ err, connectionId }, "relay_data_error");
});
};
diff --git a/packages/server/src/server/schedule/cron.test.ts b/packages/server/src/server/schedule/cron.test.ts
index e5e9e7ad4..af9418e20 100644
--- a/packages/server/src/server/schedule/cron.test.ts
+++ b/packages/server/src/server/schedule/cron.test.ts
@@ -21,8 +21,8 @@ describe("schedule cron cadence", () => {
});
test("rejects invalid cron expressions", () => {
- expect(() =>
- validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" }),
- ).toThrow("Cron expressions must have 5 fields");
+ expect(() => validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" })).toThrow(
+ "Cron expressions must have 5 fields",
+ );
});
});
diff --git a/packages/server/src/server/schedule/cron.ts b/packages/server/src/server/schedule/cron.ts
index 91b0ee690..29e9a732e 100644
--- a/packages/server/src/server/schedule/cron.ts
+++ b/packages/server/src/server/schedule/cron.ts
@@ -33,8 +33,7 @@ function parseField(
}
const [base, stepSource] = part.split("/");
- const step =
- stepSource === undefined ? 1 : Number.parseInt(stepSource, 10);
+ const step = stepSource === undefined ? 1 : Number.parseInt(stepSource, 10);
if (!Number.isInteger(step) || step <= 0) {
throw new Error(`Invalid cron ${bounds.name} step`);
}
diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts
index c71cf6c9a..9547d3e7e 100644
--- a/packages/server/src/server/schedule/service.test.ts
+++ b/packages/server/src/server/schedule/service.test.ts
@@ -171,6 +171,45 @@ describe("ScheduleService", () => {
);
});
+ test("advances stale nextRunAt on daemon restart", async () => {
+ const service1 = new ScheduleService({
+ paseoHome: tempDir,
+ logger: createTestLogger(),
+ agentManager: new AgentManager({ logger: createTestLogger() }),
+ agentStorage,
+ now: () => now,
+ runner: async () => ({ agentId: null, output: "ok" }),
+ });
+
+ const created = await service1.create({
+ prompt: "Periodic check",
+ cadence: { type: "every", everyMs: 60_000 },
+ target: {
+ type: "new-agent",
+ config: { provider: "claude", cwd: tempDir },
+ },
+ });
+
+ expect(created.nextRunAt).toBe("2026-01-01T00:01:00.000Z");
+ await service1.stop();
+
+ // Simulate daemon restart 10 minutes later
+ now = new Date("2026-01-01T00:10:00.000Z");
+ const service2 = new ScheduleService({
+ paseoHome: tempDir,
+ logger: createTestLogger(),
+ agentManager: new AgentManager({ logger: createTestLogger() }),
+ agentStorage,
+ now: () => now,
+ runner: async () => ({ agentId: null, output: "ok" }),
+ });
+ await service2.start();
+
+ const inspected = await service2.inspect(created.id);
+ expect(new Date(inspected.nextRunAt!).getTime()).toBeGreaterThan(now.getTime());
+ await service2.stop();
+ });
+
test("keeps schedules paused when an in-flight run finishes after pause", async () => {
let releaseRun: (() => void) | null = null;
const runStarted = new Promise((resolve) => {
diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts
index 318223fbc..3687bd5e4 100644
--- a/packages/server/src/server/schedule/service.ts
+++ b/packages/server/src/server/schedule/service.ts
@@ -4,12 +4,13 @@ import type { Logger } from "pino";
import { AgentManager } from "../agent/agent-manager.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js";
-import type {
- AgentPromptInput,
- AgentSessionConfig,
-} from "../agent/agent-sdk-types.js";
+import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { curateAgentActivity } from "../agent/activity-curator.js";
-import { buildConfigOverrides, buildSessionConfig, extractTimestamps } from "../persistence-hooks.js";
+import {
+ buildConfigOverrides,
+ buildSessionConfig,
+ extractTimestamps,
+} from "../persistence-hooks.js";
import { ScheduleStore } from "./store.js";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
import type {
@@ -250,23 +251,41 @@ export class ScheduleService {
const schedules = await this.store.list();
const now = this.now();
for (const schedule of schedules) {
- const runningIndex = schedule.runs.findIndex((run) => run.status === "running");
- if (runningIndex === -1) {
- continue;
+ let updated = { ...schedule };
+ let dirty = false;
+
+ // Mark any in-flight runs as failed
+ const runningIndex = updated.runs.findIndex((run) => run.status === "running");
+ if (runningIndex !== -1) {
+ const runs = [...updated.runs];
+ runs[runningIndex] = {
+ ...runs[runningIndex],
+ status: "failed",
+ endedAt: now.toISOString(),
+ error: "Daemon restarted before the scheduled run completed",
+ };
+ updated = { ...updated, runs };
+ dirty = true;
+ }
+
+ // Advance stale nextRunAt for active schedules
+ if (
+ updated.status === "active" &&
+ updated.nextRunAt &&
+ new Date(updated.nextRunAt).getTime() <= now.getTime()
+ ) {
+ let nextRunAt = computeNextRunAt(updated.cadence, new Date(updated.nextRunAt));
+ while (nextRunAt.getTime() <= now.getTime()) {
+ nextRunAt = computeNextRunAt(updated.cadence, nextRunAt);
+ }
+ updated = { ...updated, nextRunAt: nextRunAt.toISOString() };
+ dirty = true;
+ }
+
+ if (dirty) {
+ updated = { ...updated, updatedAt: now.toISOString() };
+ await this.store.put(updated);
}
- const runs = [...schedule.runs];
- runs[runningIndex] = {
- ...runs[runningIndex],
- status: "failed",
- endedAt: now.toISOString(),
- error: "Daemon restarted before the scheduled run completed",
- };
- const nextSchedule = {
- ...schedule,
- runs,
- updatedAt: now.toISOString(),
- };
- await this.store.put(nextSchedule);
}
}
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index 7e2a95ae4..a7d7558f6 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -1,9 +1,10 @@
+import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { watch, type FSWatcher } from "node:fs";
-import { readFile } from "fs/promises";
+import { readFile, stat } from "fs/promises";
import { exec, execFile } from "node:child_process";
import { promisify } from "util";
-import { join, resolve, sep } from "path";
+import { resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
@@ -67,6 +68,7 @@ import {
} from "./persistence-hooks.js";
import { experimental_createMCPClient } from "ai";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
@@ -74,8 +76,9 @@ import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import { readGitCommand } from "./workspace-git-metadata.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
+import type { DaemonConfigStore } from "./daemon-config-store.js";
+import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
-export type AgentMcpTransportFactory = () => Promise;
import { buildProviderRegistry } from "./agent/provider-registry.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { AgentManager } from "./agent/agent-manager.js";
@@ -103,7 +106,6 @@ import type {
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
- McpServerConfig,
AgentSessionConfig,
AgentStreamEvent,
AgentProvider,
@@ -122,7 +124,6 @@ import type {
} from "./workspace-registry.js";
import { AgentLoadingService } from "./agent-loading-service.js";
import {
- buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
stripVoiceModeSystemPrompt,
wrapSpokenInput,
@@ -135,9 +136,7 @@ import {
} from "./file-explorer/service.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
-import {
- type WorktreeConfig,
-} from "../utils/worktree.js";
+import { type WorktreeConfig } from "../utils/worktree.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import {
@@ -159,11 +158,7 @@ import {
import { getProjectIcon } from "../utils/project-icon.js";
import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
-import {
- READ_ONLY_GIT_ENV,
- resolveCheckoutGitDir,
- toCheckoutError,
-} from "./checkout-git-utils.js";
+import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
@@ -171,10 +166,7 @@ import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
import type pino from "pino";
import { resolveClientMessageId } from "./client-message-id.js";
-import {
- ChatServiceError,
- FileBackedChatService,
-} from "./chat/chat-service.js";
+import { ChatServiceError, FileBackedChatService } from "./chat/chat-service.js";
import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
@@ -220,8 +212,6 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS);
}
-const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
-const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
const MAX_TERMINAL_STREAM_SLOTS = 256;
const pendingAgentInitializations = new Map>();
@@ -352,6 +342,7 @@ type WorkspaceUpdatesSubscriptionState = {
filter?: WorkspaceUpdatesFilter;
isBootstrapping: boolean;
pendingUpdatesByWorkspaceId: Map;
+ lastEmittedByWorkspaceId: Map;
};
type FetchWorkspacesCursor = {
sort: FetchWorkspacesRequestSort[];
@@ -378,12 +369,10 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS,
);
const AgentIdSchema = z.string().uuid();
-const VOICE_MCP_SERVER_NAME = "paseo_voice";
const VOICE_INTERRUPT_CONFIRMATION_MS = 500;
type VoiceModeBaseConfig = {
systemPrompt?: string;
- mcpServers?: Record