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

+

+ + GitHub stars + + + GitHub release + + + X + + + Discord + +

+

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![Screenshot](https://example.com/tall.png)", + 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 - 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() { 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" } /> ) : null} - @@ -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![local](/tmp/paseo.png)\n\n![remote](https://example.com/test.png "Remote")', + ), + ).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![Screenshot](https://example.com/landscape.png)", + ), + ).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 <name>", "Hidden alias for --title").hideHelp()) .option( "--provider <provider>", "Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)", @@ -82,6 +83,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = { 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 <text>", "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>", "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 <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 <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 <duration>", "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("<id>", "Schedule ID"), + schedule.command("inspect").description("Inspect a schedule").argument("<id>", "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("<id>", "Schedule ID"), + schedule.command("pause").description("Pause a schedule").argument("<id>", "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("<id>", "Schedule ID"), + schedule.command("delete").description("Delete a schedule").argument("<id>", "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<ScheduleInspectRow> { +export function createScheduleInspectSchema( + record: ScheduleRecord, +): OutputSchema<ScheduleInspectRow> { 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<DaemonC url: target.url, clientId, clientType: "cli", + connectTimeoutMs: timeout, webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) => nodeWebSocketFactory(url, { headers: config?.headers, @@ -216,23 +217,11 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC } as unknown as ConstructorParameters<typeof DaemonClient>[0]); const connectPromise = client.connect(); - let timeoutHandle: ReturnType<typeof setTimeout> | null = null; - const timeoutPromise = new Promise<never>((_, 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<ProviderModel[]> { +async function runProviderModelsJson(provider: string): Promise<ProviderModel[]> { 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<DaemonStatus> { 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<DaemonStatus> { 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<DaemonStatus> { 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<DaemonStatus> { - 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> | boolean, timeoutMs: number, @@ -100,6 +72,21 @@ function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promis }); } +async function canConnectToDaemon(host: string, timeoutMs: number): Promise<boolean> { + 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<number | null> { 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<void> { } 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<string, unk }); } - function toTrimmedString(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -244,12 +243,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> { 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<void> { } 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<InstallStatus> { // Skills Installation // --------------------------------------------------------------------------- -async function copySkillFile(sourceFile: string, destDir: string, skillName: string): Promise<void> { +async function copySkillFile( + sourceFile: string, + destDir: string, + skillName: string, +): Promise<void> { 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<void> { +async function symlinkSkillDir( + skillName: string, + targetDir: string, + linkParentDir: string, +): Promise<void> { 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<ProviderRunResult> { + 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<void> { + 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<SessionOutboundMessage, { type: "chat/list/response" }>["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<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 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<SessionOutboundMessage, { type: "loop/logs/response" }>["payload"]; +type LoopStopPayload = Extract<SessionOutboundMessage, { type: "loop/stop/response" }>["payload"]; type ScheduleCreatePayload = Extract< SessionOutboundMessage, { type: "schedule/create/response" } @@ -529,10 +505,18 @@ type WaitHandle<T> = { }; type RpcWaitResult<T> = { 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<SessionOutboundMessage, { payload: { requestId: string } }> + | GetDaemonConfigResponse + | SetDaemonConfigResponse; type CorrelatedResponseType = CorrelatedResponseMessage["type"]; type CorrelatedResponsePayload<TType extends CorrelatedResponseType> = Extract< CorrelatedResponseMessage, @@ -2484,11 +2468,7 @@ export class DaemonClient { }); } - async stashPop( - cwd: string, - stashIndex: number, - requestId?: string, - ): Promise<StashPopPayload> { + async stashPop(cwd: string, stashIndex: number, requestId?: string): Promise<StashPopPayload> { 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<LoopLogsPayload> { - 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<SDKUserMessage, void, undefined> { 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<AgentProvider, ProviderDefinition> | 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<WaitForAgentResult> { - 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<string | null> { - 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<McpServer> { - 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<string, string>; }; 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<AgentSessionConfig>, @@ -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<AgentSession> { + 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<AgentSession> { + 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<void>((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<void>((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<AgentStreamEvent> {} @@ -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<AgentStreamEvent> {} @@ -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<string, AgentLifecycleStatus>(); private readonly backgroundTasks = new Set<Promise<void>>(); + 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<string, number> = {}; let withActiveForegroundTurn = 0; @@ -599,9 +606,21 @@ export class AgentManager { initialPrompt?: string; }, ): Promise<ManagedAgent> { - // 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<void> { 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<AgentSessionConfig> { + private async normalizeConfig(config: AgentSessionConfig): Promise<AgentSessionConfig> { 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<T> { 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<unknown>; + close: () => Promise<void>; +}; + +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<McpClient> { + const transport = new StreamableHTTPClientTransport(new URL(url)); + return (await experimental_createMCPClient({ transport })) as McpClient; +} + +async function callToolStructured( + client: McpClient, + name: string, + args?: StructuredContent, +): Promise<StructuredContent> { + 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<void> { + 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<void> { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor<T>(options: { + timeoutMs: number; + intervalMs?: number; + check: () => Promise<T | null> | T | null; + label: string; +}): Promise<T> { + 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<string> { + return await mkdtemp(path.join(tempRoot, `${prefix}-`)); + } + + async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<string> { + 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<StructuredContent>): Promise<string> { + 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<void> { + if (!agentId) { + return; + } + try { + await topLevelClient.callTool({ name: "archive_agent", args: { agentId } }); + } catch { + // ignore cleanup errors + } + } + + async function deleteScheduleIfPresent(id: string | null | undefined): Promise<void> { + if (!id) { + return; + } + try { + await topLevelClient.callTool({ name: "delete_schedule", args: { id } }); + } catch { + // ignore cleanup errors + } + } + + async function killTerminalIfPresent(terminalId: string | null | undefined): Promise<void> { + 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<void> { + 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<StructuredContent>; + 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<StructuredContent>; + return terminals.some((terminal) => terminal.id === created.id) ? null : payload; + }, + }); + const terminals = listed.terminals as Array<StructuredContent>; + 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<StructuredContent>; + 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<StructuredContent>; + 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<StructuredContent>; + 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<StructuredContent>; + 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<StructuredContent>; + 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<AgentProvider, ProviderDefinition> | 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<ServerRequest, ServerNotification>; 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<WaitForAgentResult> { - 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<string | null> { - 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<McpServer> { 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<string, string> | 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<WaitForAgentResult> { + 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 = `<paseo-system>\nAgent ${childAgentId} (${title}) ${reason}.\n</paseo-system>`; + + 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<string | null> { + 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<typeof StoredScheduleSchema>) { + 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<string, ProviderClientFactory> = { 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<AgentProvider, ProviderDefinition>, - handles: Object.fromEntries( - handles.map((handle) => [handle.definition.id, handle]), - ) as Record<AgentProvider, MockProviderHandle>, + 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<string, Map<AgentProvider, ProviderSnapshotEntry>>(); @@ -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<AgentStreamEvent[]>((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<AgentStreamEvent[]>((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<string, string>, - ): Promise<SpawnedACPProcess> { + protected async spawnProcess(launchEnv?: Record<string, string>): Promise<SpawnedACPProcess> { const { command, args } = await this.resolveLaunchCommand(); const child = spawnProcess(command, args, { cwd: process.cwd(), env: { - ...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings), + ...applyProviderEnv( + process.env as Record<string, string | undefined>, + 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<RequestPermissionResponse> { + async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> { 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<RequestPermissionResponse>((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<string, string | undefined>, this.runtimeSettings), + ...applyProviderEnv( + process.env as Record<string, string | undefined>, + 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<string, string | undefined>, this.runtimeSettings), + ...applyProviderEnv( + process.env as Record<string, string | undefined>, + 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<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>): void { + private finishTurn( + event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>, + ): 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<SessionConfigOption, { type: "select" }> | null { + private getSelectConfigOption( + category: string, + ): Extract<SessionConfigOption, { type: "select" }> | null { const option = this.configOptions.find( (entry): entry is Extract<SessionConfigOption, { type: "select" }> => entry.type === "select" && entry.category === category, @@ -1644,7 +1659,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { function flattenSelectOptions( options: Extract<SessionConfigOption, { type: "select" }>["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<AgentPromptContentBlock, { type: "text" }> => block.type === "text") + .filter( + (block): block is Extract<AgentPromptContentBlock, { type: "text" }> => 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<string, TerminalEntry>): ToolCallDetail { +function mapToolDetail( + snapshot: ACPToolSnapshot, + terminals: Map<string, TerminalEntry>, +): 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<string, Termina newString: snapshot.kind === "delete" ? "" - : diffContent?.newText ?? readString(rawInput, ["newText", "newString"]), + : (diffContent?.newText ?? readString(rawInput, ["newText", "newString"])), unifiedDiff: textContent ?? undefined, }; case "search": @@ -1975,7 +2002,9 @@ function extractToolText(content: ToolCallContent[] | null | undefined): string function extractDiffContent( content: ToolCallContent[] | null | undefined, ): { oldText?: string | null; newText: string } | null { - const diff = content?.find((item): item is Extract<ToolCallContent, { type: "diff" }> => item.type === "diff"); + const diff = content?.find( + (item): item is Extract<ToolCallContent, { type: "diff" }> => item.type === "diff", + ); return diff ? { oldText: diff.oldText ?? undefined, newText: diff.newText } : null; } @@ -2067,10 +2096,7 @@ function readRecord(value: unknown): Record<string, unknown> | null { : null; } -function readString( - record: Record<string, unknown> | null, - keys: string[], -): string | undefined { +function readString(record: Record<string, unknown> | null, keys: string[]): string | undefined { if (!record) { return undefined; } @@ -2083,10 +2109,7 @@ function readString( return undefined; } -function readNumber( - record: Record<string, unknown> | null, - keys: string[], -): number | undefined { +function readNumber(record: Record<string, unknown> | 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<AgentSessionConfig> { +function coerceSessionConfigMetadata( + metadata: AgentMetadata | undefined, +): Partial<AgentSessionConfig> { 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<AgentStreamEvent, { type: "permission_requested" }> => - event.type === "permission_requested", - ); - const permissionResolved = events.find( - (event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> => - event.type === "permission_resolved", - ); - const completedBashCall = getLatestCompletedBashCall(events); + const permissionRequest = events.find( + (event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> => + event.type === "permission_requested", + ); + const permissionResolved = events.find( + (event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> => + 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<AgentStreamEvent>(); 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: - "<local-command-stdout>Set model to claude-opus-4-6</local-command-stdout>", + content: "<local-command-stdout>Set model to claude-opus-4-6</local-command-stdout>", }, userType: "external", }; @@ -176,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => { message: { role: "user", content: - '<local-command-stdout>Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m</local-command-stdout>', + "<local-command-stdout>Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m</local-command-stdout>", }, }; 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*<local-command-stdout>[\s\S]*<\/local-command-stdout>\s*$/; +const LOCAL_COMMAND_STDOUT_PATTERN = + /^\s*<local-command-stdout>[\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<string | null> { +async function resolveClaudeVersion( + runtimeSettings?: ProviderRuntimeSettings, +): Promise<string | null> { 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<SDKUserMessage>(); - const claudeBinary = await findExecutable("claude"); + test.runIf(canRunClaudeIntegration)( + "shows what happens after interrupt()", + async () => { + const cwd = tmpCwd(); + const input = new Pushable<SDKUserMessage>(); + 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<string, unknown> { }; } -function functionCallEvent(callId: string, name: string, argumentsJson: string): Record<string, unknown> { +function functionCallEvent( + callId: string, + name: string, + argumentsJson: string, +): Record<string, unknown> { 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<AgentStreamEvent, { type: "permission_requested" }> => 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<string, unknown>): 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<string, string>): Promise<ChildProcessWithoutNullStreams> { + private async spawnAppServer( + launchEnv?: Record<string, string>, + ): Promise<ChildProcessWithoutNullStreams> { 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<AgentFeatureToggle, "value"> = { 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<string, unknown>; - }>; - } | null | undefined, + providers: + | { + connected?: string[]; + all?: Array<{ + id: string; + models?: Record<string, unknown>; + }>; + } + | 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<string, unknown>; - }>; -} | null | undefined): Map<string, number> { +function buildOpenCodeModelContextWindowLookup( + providers: + | { + connected?: string[]; + all?: Array<{ + id: string; + models?: Record<string, unknown>; + }>; + } + | null + | undefined, +): Map<string, number> { const lookup = new Map<string, number>(); if (!providers) { return lookup; @@ -881,16 +889,10 @@ export class OpenCodeAgentClient implements AgentClient { const client = createOpencodeClient({ baseUrl: url, directory }); const timeoutPromise = new Promise<never>((_, 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<void> { @@ -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<string, unknown>, + 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<string, unknown>, + }, + } + : {}), + ...(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<void> { + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> { 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<typeof import("node:child_process")>("node:child_process"); - return { - ...actual, - execFile: execFileMock, - }; -}); - -import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; - -async function flushPromises(): Promise<void> { - 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<string, BackgroundGitFetchTarget>(); - - 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<boolean> { - 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<void> { - 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<string, StreamableHTTPServerTransport>; -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<Record<AgentProvider, AgentClient>>; @@ -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<void> { 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<typeof vi.fn> }> = []; 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<string, unknown>): 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<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function deepMerge<T extends Record<string, unknown>>( + current: T, + patch: Record<string, unknown>, +): T { + const next: Record<string, unknown> = { ...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<unknown>((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<ConfigListener>(); + private readonly fieldChangeHandlers = new Map<string, Set<FieldChangeHandler>>(); + + 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<FieldChangeHandler>(); + 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<AgentTimelineItem, { type: "tool_call" }>; 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<void>((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<ReturnType<typeof createPaseoDaemon>> | null = null; let shutdownPromise: Promise<number> | 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<boolean> { - 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<LoopStatus, "running">, message: string): void { + private finishLoop( + loop: LoopRecord, + status: Exclude<LoopStatus, "running">, + 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<LoopLogEntry, "seq" | "timestamp">, - ): void { + private appendLog(loop: LoopRecord, entry: Omit<LoopLogEntry, "seq" | "timestamp">): 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<void>((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<Transport>; 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<string, Promise<ManagedAgent>>(); @@ -352,6 +342,7 @@ type WorkspaceUpdatesSubscriptionState = { filter?: WorkspaceUpdatesFilter; isBootstrapping: boolean; pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>; + lastEmittedByWorkspaceId: Map<string, WorkspaceUpdatePayload>; }; 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<string, McpServerConfig>; }; interface AudioBufferState { @@ -426,6 +415,9 @@ export type SessionOptions = { agentLoadingService?: AgentLoadingService; backgroundGitFetchManager: BackgroundGitFetchManager; createAgentMcpTransport: AgentMcpTransportFactory; + workspaceGitService: WorkspaceGitService; + daemonConfigStore: DaemonConfigStore; + mcpBaseUrl?: string | null; stt: Resolvable<SpeechToTextProvider | null>; tts: Resolvable<TextToSpeechProvider | null>; terminalManager: TerminalManager | null; @@ -441,7 +433,6 @@ export type SessionOptions = { getDaemonTcpHost?: () => string | null; resolveScriptHealth?: (hostname: string) => ScriptHealthState | null; voice?: { - voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable<TurnDetectionProvider | null>; }; voiceBridge?: { @@ -449,8 +440,6 @@ export type SessionOptions = { unregisterVoiceSpeakHandler?: (agentId: string) => void; registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void; unregisterVoiceCallerContext?: (agentId: string) => void; - ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>; - removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>; }; dictation?: { finalTimeoutMs?: number; @@ -598,6 +587,9 @@ export class Session { private readonly agentLoadingService: AgentLoadingService; private readonly backgroundGitFetchManager: BackgroundGitFetchManager; private readonly createAgentMcpTransport: AgentMcpTransportFactory; + private readonly workspaceGitService: WorkspaceGitService; + private readonly daemonConfigStore: DaemonConfigStore; + private readonly mcpBaseUrl: string | null; private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>; @@ -640,6 +632,7 @@ export class Session { private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>(); private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>(); private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; + private readonly workspaceGitSubscriptions = new Map<string, () => void>(); private readonly registerVoiceSpeakHandler?: ( agentId: string, handler: VoiceSpeakHandler, @@ -650,8 +643,6 @@ export class Session { context: VoiceCallerContext, ) => void; private readonly unregisterVoiceCallerContext?: (agentId: string) => void; - private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>; - private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>; private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot; private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private voiceModeAgentId: string | null = null; @@ -679,6 +670,9 @@ export class Session { agentLoadingService, backgroundGitFetchManager, createAgentMcpTransport, + workspaceGitService, + daemonConfigStore, + mcpBaseUrl, stt, tts, terminalManager, @@ -724,8 +718,10 @@ export class Session { logger: this.sessionLogger, }); this.backgroundGitFetchManager = backgroundGitFetchManager; - this.backgroundGitFetchManager = backgroundGitFetchManager; this.createAgentMcpTransport = createAgentMcpTransport; + this.workspaceGitService = workspaceGitService; + this.daemonConfigStore = daemonConfigStore; + this.mcpBaseUrl = mcpBaseUrl ?? null; this.terminalManager = terminalManager; this.providerSnapshotManager = providerSnapshotManager ?? null; this.scriptRouteStore = scriptRouteStore ?? null; @@ -759,14 +755,11 @@ export class Session { this.providerSnapshotManager?.off("change", handleProviderSnapshotChange); }; } - this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null; this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null); this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler; this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext; this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; - this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent; - this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent; this.getSpeechReadiness = dictation?.getSpeechReadiness; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.abortController = new AbortController(); @@ -976,12 +969,17 @@ export class Session { } /** - * Initialize Agent MCP client for this session using in-memory transport + * Initialize Agent MCP client for this session using the daemon's HTTP MCP endpoint. */ private async initializeAgentMcp(): Promise<void> { try { - // Create an in-memory transport connected to the Agent MCP server - const transport = await this.createAgentMcpTransport(); + if (!this.mcpBaseUrl) { + this.sessionLogger.info( + "Skipping Agent MCP initialization because no MCP base URL is configured", + ); + return; + } + const transport = new StreamableHTTPClientTransport(new URL(this.mcpBaseUrl)); this.agentMcpClient = await experimental_createMCPClient({ transport, @@ -1107,7 +1105,10 @@ export class Session { if (storedUpdatedAt) { const liveUpdatedAt = Date.parse(payload.updatedAt); const persistedUpdatedAt = Date.parse(storedUpdatedAt); - if (!Number.isNaN(persistedUpdatedAt) && (Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt)) { + if ( + !Number.isNaN(persistedUpdatedAt) && + (Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt) + ) { payload.updatedAt = storedUpdatedAt; } } @@ -1489,169 +1490,407 @@ export class Session { this.peakInflightRequests = this.inflightRequests; } try { - this.sessionLogger.trace( - { messageType: msg.type, payloadBytes: JSON.stringify(msg).length }, - "inbound message", - ); - try { - switch (msg.type) { - case "voice_audio_chunk": - await this.handleAudioChunk(msg); - break; + this.sessionLogger.trace( + { messageType: msg.type, payloadBytes: JSON.stringify(msg).length }, + "inbound message", + ); + try { + switch (msg.type) { + case "voice_audio_chunk": + await this.handleAudioChunk(msg); + break; - case "abort_request": - await this.handleAbort(); - break; + case "abort_request": + await this.handleAbort(); + break; - case "audio_played": - this.handleAudioPlayed(msg.id); - break; + case "audio_played": + this.handleAudioPlayed(msg.id); + break; - case "fetch_agents_request": - await this.handleFetchAgents(msg); - break; + case "fetch_agents_request": + await this.handleFetchAgents(msg); + break; - case "fetch_workspaces_request": - await this.handleFetchWorkspacesRequest(msg); - break; + case "fetch_workspaces_request": + await this.handleFetchWorkspacesRequest(msg); + break; - case "fetch_agent_request": - await this.handleFetchAgent(msg.agentId, msg.requestId); - break; + case "fetch_agent_request": + await this.handleFetchAgent(msg.agentId, msg.requestId); + break; - case "delete_agent_request": - await this.handleDeleteAgentRequest(msg.agentId, msg.requestId); - break; + case "delete_agent_request": + await this.handleDeleteAgentRequest(msg.agentId, msg.requestId); + break; - case "archive_agent_request": - await this.handleArchiveAgentRequest(msg.agentId, msg.requestId); - break; + case "archive_agent_request": + await this.handleArchiveAgentRequest(msg.agentId, msg.requestId); + break; - case "close_items_request": - await this.handleCloseItemsRequest(msg); - break; + case "close_items_request": + await this.handleCloseItemsRequest(msg); + break; - case "update_agent_request": - await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId); - break; + case "update_agent_request": + await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId); + break; - case "set_voice_mode": - await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId); - break; + case "set_voice_mode": + await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId); + break; - case "send_agent_message_request": - await this.handleSendAgentMessageRequest(msg); - break; + case "send_agent_message_request": + await this.handleSendAgentMessageRequest(msg); + break; - case "wait_for_finish_request": - await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs); - break; + case "wait_for_finish_request": + await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs); + break; - case "dictation_stream_start": - { - const unavailable = this.resolveVoiceFeatureUnavailableContext("dictation"); - if (unavailable) { - this.emit({ - type: "dictation_stream_error", - payload: { - dictationId: msg.dictationId, - error: unavailable.message, - retryable: unavailable.retryable, - reasonCode: unavailable.reasonCode, - missingModelIds: unavailable.missingModelIds, - }, - }); - break; + case "get_daemon_config_request": + this.emit({ + type: "get_daemon_config_response", + payload: { + requestId: msg.requestId, + config: this.daemonConfigStore.get(), + }, + }); + break; + + case "set_daemon_config_request": + this.emit({ + type: "set_daemon_config_response", + payload: { + requestId: msg.requestId, + config: this.daemonConfigStore.patch(msg.config), + }, + }); + break; + + case "dictation_stream_start": + { + const unavailable = this.resolveVoiceFeatureUnavailableContext("dictation"); + if (unavailable) { + this.emit({ + type: "dictation_stream_error", + payload: { + dictationId: msg.dictationId, + error: unavailable.message, + retryable: unavailable.retryable, + reasonCode: unavailable.reasonCode, + missingModelIds: unavailable.missingModelIds, + }, + }); + break; + } } + await this.dictationStreamManager.handleStart(msg.dictationId, msg.format); + break; + + case "dictation_stream_chunk": + await this.dictationStreamManager.handleChunk({ + dictationId: msg.dictationId, + seq: msg.seq, + audioBase64: msg.audio, + format: msg.format, + }); + break; + + case "dictation_stream_finish": + await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq); + break; + + case "dictation_stream_cancel": + this.dictationStreamManager.handleCancel(msg.dictationId); + break; + + case "create_agent_request": + await this.handleCreateAgentRequest(msg); + break; + + case "resume_agent_request": + await this.handleResumeAgentRequest(msg); + break; + + case "refresh_agent_request": + await this.handleRefreshAgentRequest(msg); + break; + + case "cancel_agent_request": + await this.handleCancelAgentRequest(msg.agentId); + break; + + case "restart_server_request": + await this.handleRestartServerRequest(msg.requestId, msg.reason); + break; + + case "shutdown_server_request": + await this.handleShutdownServerRequest(msg.requestId); + break; + + case "fetch_agent_timeline_request": + await this.handleFetchAgentTimelineRequest(msg); + break; + + case "set_agent_mode_request": + await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId); + break; + + case "set_agent_model_request": + await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId); + break; + + case "set_agent_feature_request": + await this.handleSetAgentFeatureRequest( + msg.agentId, + msg.featureId, + msg.value, + msg.requestId, + ); + break; + + case "set_agent_thinking_request": + await this.handleSetAgentThinkingRequest( + msg.agentId, + msg.thinkingOptionId, + msg.requestId, + ); + break; + + case "agent_permission_response": + await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response); + break; + + case "checkout_status_request": + await this.handleCheckoutStatusRequest(msg); + break; + + case "validate_branch_request": + await this.handleValidateBranchRequest(msg); + break; + + case "branch_suggestions_request": + await this.handleBranchSuggestionsRequest(msg); + break; + + case "directory_suggestions_request": + await this.handleDirectorySuggestionsRequest(msg); + break; + + case "subscribe_checkout_diff_request": + await this.handleSubscribeCheckoutDiffRequest(msg); + break; + + case "unsubscribe_checkout_diff_request": + this.handleUnsubscribeCheckoutDiffRequest(msg); + break; + + case "checkout_switch_branch_request": + await this.handleCheckoutSwitchBranchRequest(msg); + break; + + case "stash_save_request": + await this.handleStashSaveRequest(msg); + break; + + case "stash_pop_request": + await this.handleStashPopRequest(msg); + break; + + case "stash_list_request": + await this.handleStashListRequest(msg); + break; + + case "checkout_commit_request": + await this.handleCheckoutCommitRequest(msg); + break; + + case "checkout_merge_request": + await this.handleCheckoutMergeRequest(msg); + break; + + case "checkout_merge_from_base_request": + await this.handleCheckoutMergeFromBaseRequest(msg); + break; + + case "checkout_pull_request": + await this.handleCheckoutPullRequest(msg); + break; + + case "checkout_push_request": + await this.handleCheckoutPushRequest(msg); + break; + + case "checkout_pr_create_request": + await this.handleCheckoutPrCreateRequest(msg); + break; + + case "checkout_pr_status_request": + await this.handleCheckoutPrStatusRequest(msg); + break; + + case "paseo_worktree_list_request": + await this.handlePaseoWorktreeListRequest(msg); + break; + + case "paseo_worktree_archive_request": + await this.handlePaseoWorktreeArchiveRequest(msg); + break; + + case "create_paseo_worktree_request": + await this.handleCreatePaseoWorktreeRequest(msg); + break; + + case "list_available_editors_request": + await this.handleListAvailableEditorsRequest(msg); + break; + + case "open_in_editor_request": + await this.handleOpenInEditorRequest(msg); + break; + + case "open_project_request": + await this.handleOpenProjectRequest(msg); + break; + + case "archive_workspace_request": + await this.handleArchiveWorkspaceRequest(msg); + break; + + case "file_explorer_request": + await this.handleFileExplorerRequest(msg); + break; + + case "project_icon_request": + await this.handleProjectIconRequest(msg); + break; + + case "file_download_token_request": + await this.handleFileDownloadTokenRequest(msg); + break; + + case "list_provider_models_request": + await this.handleListProviderModelsRequest(msg); + break; + + case "list_provider_modes_request": + await this.handleListProviderModesRequest(msg); + break; + + case "list_provider_features_request": + await this.handleListProviderFeaturesRequest(msg); + break; + + case "list_available_providers_request": + await this.handleListAvailableProvidersRequest(msg); + break; + + case "get_providers_snapshot_request": + await this.handleGetProvidersSnapshotRequest(msg); + break; + + case "refresh_providers_snapshot_request": + await this.handleRefreshProvidersSnapshotRequest(msg); + break; + + case "provider_diagnostic_request": + await this.handleProviderDiagnosticRequest(msg); + break; + + case "clear_agent_attention": + await this.handleClearAgentAttention(msg.agentId); + break; + + case "client_heartbeat": + this.handleClientHeartbeat(msg); + break; + + case "ping": { + const now = Date.now(); + this.emit({ + type: "pong", + payload: { + requestId: msg.requestId, + clientSentAt: msg.clientSentAt, + serverReceivedAt: now, + serverSentAt: now, + }, + }); + break; } - await this.dictationStreamManager.handleStart(msg.dictationId, msg.format); - break; - case "dictation_stream_chunk": - await this.dictationStreamManager.handleChunk({ - dictationId: msg.dictationId, - seq: msg.seq, - audioBase64: msg.audio, - format: msg.format, - }); - break; + case "list_commands_request": + await this.handleListCommandsRequest(msg); + break; - case "dictation_stream_finish": - await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq); - break; + case "register_push_token": + this.handleRegisterPushToken(msg.token); + break; - case "dictation_stream_cancel": - this.dictationStreamManager.handleCancel(msg.dictationId); - break; + case "subscribe_terminals_request": + this.handleSubscribeTerminalsRequest(msg); + break; - case "create_agent_request": - await this.handleCreateAgentRequest(msg); - break; + case "unsubscribe_terminals_request": + this.handleUnsubscribeTerminalsRequest(msg); + break; - case "resume_agent_request": - await this.handleResumeAgentRequest(msg); - break; + case "list_terminals_request": + await this.handleListTerminalsRequest(msg); + break; - case "refresh_agent_request": - await this.handleRefreshAgentRequest(msg); - break; + case "create_terminal_request": + await this.handleCreateTerminalRequest(msg); + break; - case "cancel_agent_request": - await this.handleCancelAgentRequest(msg.agentId); - break; + case "subscribe_terminal_request": + await this.handleSubscribeTerminalRequest(msg); + break; - case "restart_server_request": - await this.handleRestartServerRequest(msg.requestId, msg.reason); - break; + case "unsubscribe_terminal_request": + this.handleUnsubscribeTerminalRequest(msg); + break; - case "shutdown_server_request": - await this.handleShutdownServerRequest(msg.requestId); - break; + case "terminal_input": + this.handleTerminalInput(msg); + break; - case "fetch_agent_timeline_request": - await this.handleFetchAgentTimelineRequest(msg); - break; + case "kill_terminal_request": + await this.handleKillTerminalRequest(msg); + break; - case "set_agent_mode_request": - await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId); - break; + case "capture_terminal_request": + await this.handleCaptureTerminalRequest(msg); + break; - case "set_agent_model_request": - await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId); - break; + case "chat/create": + await this.handleChatCreateRequest(msg); + break; - case "set_agent_feature_request": - await this.handleSetAgentFeatureRequest( - msg.agentId, - msg.featureId, - msg.value, - msg.requestId, - ); - break; + case "chat/list": + await this.handleChatListRequest(msg); + break; - case "set_agent_thinking_request": - await this.handleSetAgentThinkingRequest( - msg.agentId, - msg.thinkingOptionId, - msg.requestId, - ); - break; + case "chat/inspect": + await this.handleChatInspectRequest(msg); + break; - case "agent_permission_response": - await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response); - break; + case "chat/delete": + await this.handleChatDeleteRequest(msg); + break; - case "checkout_status_request": - await this.handleCheckoutStatusRequest(msg); - break; + case "chat/post": + await this.handleChatPostRequest(msg); + break; - case "validate_branch_request": - await this.handleValidateBranchRequest(msg); - break; + case "chat/read": + await this.handleChatReadRequest(msg); + break; - case "branch_suggestions_request": - await this.handleBranchSuggestionsRequest(msg); - break; + case "chat/wait": + await this.handleChatWaitRequest(msg); + break; case "github_search_request": await this.handleGitHubSearchRequest(msg); @@ -1661,50 +1900,6 @@ export class Session { await this.handleDirectorySuggestionsRequest(msg); break; - case "subscribe_checkout_diff_request": - await this.handleSubscribeCheckoutDiffRequest(msg); - break; - - case "unsubscribe_checkout_diff_request": - this.handleUnsubscribeCheckoutDiffRequest(msg); - break; - - case "checkout_switch_branch_request": - await this.handleCheckoutSwitchBranchRequest(msg); - break; - - case "stash_save_request": - await this.handleStashSaveRequest(msg); - break; - - case "stash_pop_request": - await this.handleStashPopRequest(msg); - break; - - case "stash_list_request": - await this.handleStashListRequest(msg); - break; - - case "checkout_commit_request": - await this.handleCheckoutCommitRequest(msg); - break; - - case "checkout_merge_request": - await this.handleCheckoutMergeRequest(msg); - break; - - case "checkout_merge_from_base_request": - await this.handleCheckoutMergeFromBaseRequest(msg); - break; - - case "checkout_pull_request": - await this.handleCheckoutPullRequest(msg); - break; - - case "checkout_push_request": - await this.handleCheckoutPushRequest(msg); - break; - case "checkout_pr_create_request": await this.handleCheckoutPrCreateRequest(msg); break; @@ -1932,36 +2127,36 @@ export class Session { break; } } catch (error: any) { - const err = error instanceof Error ? error : new Error(String(error)); - this.sessionLogger.error({ err }, "Error handling message"); + const err = error instanceof Error ? error : new Error(String(error)); + this.sessionLogger.error({ err }, "Error handling message"); - const requestId = (msg as { requestId?: unknown }).requestId; - if (typeof requestId === "string") { - try { - this.emit({ - type: "rpc_error", - payload: { - requestId, - requestType: msg.type, - error: `Request failed: ${err.message}`, - code: "handler_error", - }, - }); - } catch (emitError) { - this.sessionLogger.error({ err: emitError }, "Failed to emit rpc_error"); + const requestId = (msg as { requestId?: unknown }).requestId; + if (typeof requestId === "string") { + try { + this.emit({ + type: "rpc_error", + payload: { + requestId, + requestType: msg.type, + error: `Request failed: ${err.message}`, + code: "handler_error", + }, + }); + } catch (emitError) { + this.sessionLogger.error({ err: emitError }, "Failed to emit rpc_error"); + } } - } - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Error: ${err.message}`, - }, - }); - } + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Error: ${err.message}`, + }, + }); + } } finally { this.inflightRequests--; } @@ -2517,41 +2712,8 @@ export class Session { return parsed.data; } - private cloneMcpServers( - servers: Record<string, McpServerConfig> | undefined, - ): Record<string, McpServerConfig> | undefined { - if (!servers) { - return undefined; - } - return JSON.parse(JSON.stringify(servers)) as Record<string, McpServerConfig>; - } - - private buildVoiceModeMcpServers( - existing: Record<string, McpServerConfig> | undefined, - socketPath: string, - ): Record<string, McpServerConfig> { - const mcpStdio = this.voiceAgentMcpStdio; - if (!mcpStdio) { - throw new Error("Voice MCP stdio bridge is not configured"); - } - return { - ...(existing ?? {}), - [VOICE_MCP_SERVER_NAME]: buildVoiceAgentMcpServerConfig({ - command: mcpStdio.command, - baseArgs: mcpStdio.baseArgs, - socketPath, - env: mcpStdio.env, - }), - }; - } - private async enableVoiceModeForAgent(agentId: string): Promise<string> { const startedAt = Date.now(); - const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent; - if (!ensureVoiceSocket) { - throw new Error("Voice MCP socket bridge is not configured"); - } - this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start"); const existing = await this.ensureAgentLoaded(agentId); this.sessionLogger.info( @@ -2559,22 +2721,14 @@ export class Session { "enableVoiceModeForAgent.ensureAgentLoaded.done", ); - this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureVoiceSocket.start"); - const socketPath = await ensureVoiceSocket(agentId); - this.sessionLogger.info( - { agentId, socketPath, elapsedMs: Date.now() - startedAt }, - "enableVoiceModeForAgent.ensureVoiceSocket.done", - ); this.registerVoiceBridgeForAgent(agentId); const baseConfig: VoiceModeBaseConfig = { systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt), - mcpServers: this.cloneMcpServers(existing.config.mcpServers), }; this.voiceModeBaseConfig = baseConfig; const refreshOverrides: Partial<AgentSessionConfig> = { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true), - mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath), }; try { @@ -2591,7 +2745,6 @@ export class Session { } catch (error) { this.unregisterVoiceSpeakHandler?.(agentId); this.unregisterVoiceCallerContext?.(agentId); - await this.removeVoiceMcpSocketForAgent?.(agentId).catch(() => undefined); this.voiceModeBaseConfig = null; throw error; } @@ -2608,19 +2761,12 @@ export class Session { this.unregisterVoiceSpeakHandler?.(agentId); this.unregisterVoiceCallerContext?.(agentId); - await this.removeVoiceMcpSocketForAgent?.(agentId).catch((error) => { - this.sessionLogger.warn( - { err: error, agentId }, - "Failed to remove voice MCP socket bridge on disable", - ); - }); if (restoreAgentConfig && this.voiceModeBaseConfig) { const baseConfig = this.voiceModeBaseConfig; try { await this.agentManager.reloadAgentSession(agentId, { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false), - mcpServers: this.cloneMcpServers(baseConfig.mcpServers), }); } catch (error) { this.sessionLogger.warn( @@ -3224,9 +3370,7 @@ export class Session { cwd: expandTilde(draftConfig.cwd), ...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}), ...(draftConfig.model ? { model: draftConfig.model } : {}), - ...(draftConfig.thinkingOptionId - ? { thinkingOptionId: draftConfig.thinkingOptionId } - : {}), + ...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}), ...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}), }; } @@ -4214,40 +4358,18 @@ export class Session { } } - private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise<string> { - try { - const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim(); - if (commonDir.length > 0) { - return resolve(gitDir, commonDir); - } - } catch { - // Regular repos do not have a commondir file. - } - return gitDir; - } - - private closeWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): void { - if (target.debounceTimer) { - clearTimeout(target.debounceTimer); - target.debounceTimer = null; - } - for (const watcher of target.watchers) { - watcher.close(); - } - target.watchers = []; - } - - private removeWorkspaceGitWatchTarget(cwd: string): void { + private removeWorkspaceGitSubscription(cwd: string): void { const workspaceId = normalizePersistedWorkspaceId(cwd); const target = this.workspaceGitWatchTargets.get(workspaceId); - if (!target) { - return; + if (target) { + const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId); + unsubscribeFetch?.(); + this.workspaceGitFetchSubscriptions.delete(workspaceId); + this.closeWorkspaceGitWatchTarget(target); + this.workspaceGitWatchTargets.delete(workspaceId); } - const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId); - unsubscribeFetch?.(); - this.workspaceGitFetchSubscriptions.delete(workspaceId); - this.closeWorkspaceGitWatchTarget(target); - this.workspaceGitWatchTargets.delete(workspaceId); + this.workspaceGitSubscriptions.get(workspaceId)?.(); + this.workspaceGitSubscriptions.delete(workspaceId); } private workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string { @@ -4401,12 +4523,20 @@ export class Session { cwd: string, options: { isGit: boolean }, ): Promise<void> { + const workspaceId = normalizePersistedWorkspaceId(cwd); if (!options.isGit) { - this.removeWorkspaceGitWatchTarget(cwd); + this.removeWorkspaceGitSubscription(workspaceId); return; } - await this.ensureWorkspaceGitWatchTarget(cwd); + if (this.workspaceGitSubscriptions.has(workspaceId)) { + return; + } + + const subscription = await this.workspaceGitService.subscribe({ cwd: workspaceId }, () => { + void this.emitWorkspaceUpdateForCwd(workspaceId); + }); + this.workspaceGitSubscriptions.set(workspaceId, subscription.unsubscribe); } private async handleSubscribeCheckoutDiffRequest( @@ -4848,14 +4978,20 @@ export class Session { const { cwd, requestId } = msg; try { - const prStatus = await getPullRequestStatus(cwd); + await this.workspaceGitService.refresh(cwd, { priority: "high" }); + const snapshot = await this.workspaceGitService.getSnapshot(cwd); this.emit({ type: "checkout_pr_status_response", payload: { cwd, - status: prStatus.status, - githubFeaturesEnabled: prStatus.githubFeaturesEnabled, - error: null, + status: snapshot.github.pullRequest, + githubFeaturesEnabled: snapshot.github.featuresEnabled, + error: snapshot.github.error + ? { + code: "UNKNOWN", + message: snapshot.github.error.message, + } + : null, requestId, }, }); @@ -5524,25 +5660,6 @@ export class Session { return "done"; } - private accumulateLatestActivityAt( - current: string | null, - agent: AgentSnapshotPayload, - ): string | null { - const candidateRaw = agent.lastUserMessageAt ?? agent.updatedAt; - const candidateMs = Date.parse(candidateRaw); - if (Number.isNaN(candidateMs)) { - return current; - } - if (!current) { - return new Date(candidateMs).toISOString(); - } - const currentMs = Date.parse(current); - if (Number.isNaN(currentMs) || candidateMs > currentMs) { - return new Date(candidateMs).toISOString(); - } - return current; - } - private async describeWorkspaceRecord( workspace: PersistedWorkspaceRecord, projectRecord?: PersistedProjectRecord | null, @@ -5584,6 +5701,35 @@ export class Session { }; } + private buildWorkspaceGitRuntimePayload( + snapshot: WorkspaceGitRuntimeSnapshot, + ): NonNullable<WorkspaceDescriptorPayload["gitRuntime"]> | null { + if (!snapshot.git.isGit) { + return null; + } + + return { + currentBranch: snapshot.git.currentBranch, + remoteUrl: snapshot.git.remoteUrl, + isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree, + isDirty: snapshot.git.isDirty, + aheadBehind: snapshot.git.aheadBehind, + aheadOfOrigin: snapshot.git.aheadOfOrigin, + behindOfOrigin: snapshot.git.behindOfOrigin, + }; + } + + private buildWorkspaceGitHubRuntimePayload( + snapshot: WorkspaceGitRuntimeSnapshot, + ): NonNullable<WorkspaceDescriptorPayload["githubRuntime"]> { + return { + featuresEnabled: snapshot.github.featuresEnabled, + pullRequest: snapshot.github.pullRequest, + error: snapshot.github.error, + refreshedAt: snapshot.github.refreshedAt, + }; + } + private async describeWorkspaceRecordWithGitData( workspace: PersistedWorkspaceRecord, projectRecord?: PersistedProjectRecord | null, @@ -5666,20 +5812,11 @@ export class Session { if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) { existing.status = bucket; } - existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent); } return descriptorsByWorkspaceId; } - private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> { - return Array.from( - (await this.buildWorkspaceDescriptorMap({ - includeGitData: false, - })).values(), - ); - } - private resolveRegisteredWorkspaceIdForCwd( cwd: string, workspaces: PersistedWorkspaceRecord[], @@ -5707,7 +5844,13 @@ export class Session { } private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> { - return this.listWorkspaceDescriptorsSnapshot(); + return Array.from( + ( + await this.buildWorkspaceDescriptorMap({ + includeGitData: true, + }) + ).values(), + ); } private normalizeFetchWorkspacesSort( @@ -5950,6 +6093,8 @@ export class Session { subscription.pendingUpdatesByWorkspaceId.set(workspaceId, payload); return; } + const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; + subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); this.emit({ type: "workspace_update", payload, @@ -6105,6 +6250,7 @@ export class Session { private async archiveWorkspaceRecord(workspaceId: number, archivedAt?: string): Promise<void> { const existingWorkspace = await this.workspaceRegistry.get(workspaceId); if (!existingWorkspace || existingWorkspace.archivedAt) { + this.removeWorkspaceGitSubscription(String(workspaceId)); return; } @@ -6112,6 +6258,7 @@ export class Session { await this.workspaceRegistry.archive(workspaceId, nextArchivedAt); await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory); this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.directory); + this.removeWorkspaceGitSubscription(String(workspaceId)); const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, @@ -6144,7 +6291,7 @@ export class Session { private async emitWorkspaceUpdatesForWorkspaceIds( workspaceIds: Iterable<string>, - options?: { dedupeGitState?: boolean; skipReconcile?: boolean }, + options?: { skipReconcile?: boolean }, ): Promise<void> { const subscription = this.workspaceUpdatesSubscription; if (!subscription) { @@ -6185,6 +6332,7 @@ export class Session { this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace); if (!nextWorkspace) { + subscription.lastEmittedByWorkspaceId.delete(workspaceId); this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "remove", id: workspaceId, @@ -6192,10 +6340,21 @@ export class Session { continue; } - this.bufferOrEmitWorkspaceUpdate(subscription, { + const nextPayload: WorkspaceUpdatePayload = { kind: "upsert", workspace: nextWorkspace, - }); + }; + + const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); + if ( + lastEmitted && + lastEmitted.kind === "upsert" && + equal(lastEmitted.workspace, nextWorkspace) + ) { + continue; + } + + this.bufferOrEmitWorkspaceUpdate(subscription, nextPayload); } if (!options?.skipReconcile) { @@ -6203,30 +6362,9 @@ export class Session { } } - private scheduleWorkspaceGitBootstrapUpdates(options: { - subscriptionId: string; - workspaces: Iterable<WorkspaceDescriptorPayload>; - }): void { - const gitWorkspaceIds = Array.from(options.workspaces, (workspace) => workspace) - .filter((workspace) => workspace.projectKind === "git") - .map((workspace) => workspace.id); - if (gitWorkspaceIds.length === 0) { - return; - } - - queueMicrotask(() => { - if (this.workspaceUpdatesSubscription?.subscriptionId !== options.subscriptionId) { - return; - } - void this.emitWorkspaceUpdatesForWorkspaceIds(gitWorkspaceIds, { - skipReconcile: true, - }); - }); - } - private async emitWorkspaceUpdateForCwd( cwd: string, - options?: { dedupeGitState?: boolean }, + options?: { skipReconcile?: boolean }, ): Promise<void> { const activeWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => !workspace.archivedAt, @@ -6323,6 +6461,7 @@ export class Session { filter: request.filter, isBootstrapping: true, pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), }; } @@ -6350,10 +6489,6 @@ export class Session { if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId }); void this.reconcileAndEmitWorkspaceUpdates(); - this.scheduleWorkspaceGitBootstrapUpdates({ - subscriptionId, - workspaces: payload.entries, - }); } } catch (error) { if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { @@ -7746,14 +7881,10 @@ export class Session { } this.checkoutDiffSubscriptions.clear(); - for (const unsubscribe of this.workspaceGitFetchSubscriptions.values()) { + for (const unsubscribe of this.workspaceGitSubscriptions.values()) { unsubscribe(); } - this.workspaceGitFetchSubscriptions.clear(); - for (const target of this.workspaceGitWatchTargets.values()) { - this.closeWorkspaceGitWatchTarget(target); - } - this.workspaceGitWatchTargets.clear(); + this.workspaceGitSubscriptions.clear(); } // ============================================================================ @@ -7782,8 +7913,7 @@ export class Session { } private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void { - const message = - error instanceof Error ? error.message : "Chat request failed"; + const message = error instanceof Error ? error.message : "Chat request failed"; const code = error instanceof ChatServiceError ? error.code : "chat_request_failed"; this.sessionLogger.error({ err: error, requestType: request.type }, "Chat request failed"); this.emit({ @@ -7961,7 +8091,10 @@ export class Session { private toScheduleSummary( schedule: Awaited<ReturnType<ScheduleService["inspect"]>>, - ): Extract<SessionOutboundMessage, { type: "schedule/list/response" }>["payload"]["schedules"][number] { + ): Extract< + SessionOutboundMessage, + { type: "schedule/list/response" } + >["payload"]["schedules"][number] { const { runs: _runs, ...summary } = schedule; return summary; } @@ -8772,5 +8905,4 @@ export class Session { this.detachTerminalStream(terminalId, { emitExit: false }); } } - } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index e2dd5f54b..2ee287bc1 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,98 +1,84 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { Session } from "./session.js"; -import { - createPersistedProjectRecord, - createPersistedWorkspaceRecord, -} from "./workspace-registry.js"; +import type { + WorkspaceGitListener, + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, +} from "./workspace-git-service.js"; -const { watchCalls, watchMock } = vi.hoisted(() => { - const hoistedWatchCalls: Array<{ - path: string; - listener: () => void; - close: ReturnType<typeof vi.fn>; - }> = []; - - const hoistedWatchMock = vi.fn( - (watchPath: string, _options: { recursive: boolean }, listener: () => void) => { - const close = vi.fn(); - const watcher = { - close, - on: vi.fn().mockReturnThis(), - }; - hoistedWatchCalls.push({ - path: watchPath, - listener, - close, - }); - return watcher as any; - }, - ); - - return { - watchCalls: hoistedWatchCalls, - watchMock: hoistedWatchMock, - }; -}); - -const resolveCheckoutGitDirMock = vi.hoisted(() => vi.fn(async () => null)); - -vi.mock("node:fs", async () => { - const actual = await vi.importActual<typeof import("node:fs")>("node:fs"); - return { - ...actual, - watch: watchMock, - }; -}); - -vi.mock("./checkout-git-utils.js", () => ({ - READ_ONLY_GIT_ENV: { - ...process.env, - GIT_OPTIONAL_LOCKS: "0", +function createWorkspaceRuntimeSnapshot( + cwd: string, + overrides?: { + git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>; + github?: Partial<WorkspaceGitRuntimeSnapshot["github"]>; }, - resolveCheckoutGitDir: resolveCheckoutGitDirMock, - toCheckoutError: vi.fn((error: unknown) => ({ - message: error instanceof Error ? error.message : String(error), - })), -})); +): WorkspaceGitRuntimeSnapshot { + const base: WorkspaceGitRuntimeSnapshot = { + cwd, + git: { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + diffStat: { additions: 1, deletions: 0 }, + }, + github: { + featuresEnabled: true, + pullRequest: null, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }; -vi.mock("@getpaseo/highlight", () => ({ - highlightCode: vi.fn(async () => ""), - isLanguageSupported: vi.fn(() => false), -})); + return { + cwd, + git: { + ...base.git, + ...overrides?.git, + }, + github: { + ...base.github, + ...overrides?.github, + pullRequest: + overrides?.github && "pullRequest" in overrides.github + ? (overrides.github.pullRequest ?? null) + : base.github.pullRequest, + error: + overrides?.github && "error" in overrides.github + ? (overrides.github.error ?? null) + : base.github.error, + }, + }; +} function createSessionForWorkspaceGitWatchTests(): { session: Session; emitted: Array<{ type: string; payload: unknown }>; - projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>; - workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>; - backgroundGitFetchManager: { + workspaceGitService: WorkspaceGitService & { subscribe: ReturnType<typeof vi.fn>; - subscriptions: Array<{ - params: { repoGitRoot: string; cwd: string }; - listener: () => void; - unsubscribe: ReturnType<typeof vi.fn>; - }>; - }; - logger: { - child: () => unknown; - trace: ReturnType<typeof vi.fn>; - debug: ReturnType<typeof vi.fn>; - info: ReturnType<typeof vi.fn>; - warn: ReturnType<typeof vi.fn>; - error: ReturnType<typeof vi.fn>; + peekSnapshot: ReturnType<typeof vi.fn>; + getSnapshot: ReturnType<typeof vi.fn>; + refresh: ReturnType<typeof vi.fn>; + dispose: ReturnType<typeof vi.fn>; }; + subscriptions: Array<{ + params: { cwd: string }; + listener: WorkspaceGitListener; + unsubscribe: ReturnType<typeof vi.fn>; + }>; } { const emitted: Array<{ type: string; payload: unknown }> = []; - const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>(); - const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>(); - let nextProjectId = 1; - let nextWorkspaceId = 1; - const backgroundGitFetchSubscriptions: Array<{ - params: { repoGitRoot: string; cwd: string }; - listener: () => void; + const projects = new Map<string, any>(); + const workspaces = new Map<string, any>(); + const subscriptions: Array<{ + params: { cwd: string }; + listener: WorkspaceGitListener; unsubscribe: ReturnType<typeof vi.fn>; }> = []; const logger = { @@ -103,18 +89,23 @@ function createSessionForWorkspaceGitWatchTests(): { warn: vi.fn(), error: vi.fn(), }; - const backgroundGitFetchManager = { - subscribe: vi.fn( - async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => { - const unsubscribe = vi.fn(); - backgroundGitFetchSubscriptions.push({ - params, - listener, - unsubscribe, - }); - return { unsubscribe }; - }, - ), + const workspaceGitService = { + subscribe: vi.fn(async (params: { cwd: string }, listener: WorkspaceGitListener) => { + const unsubscribe = vi.fn(); + subscriptions.push({ + params, + listener, + unsubscribe, + }); + return { + initial: createWorkspaceRuntimeSnapshot(params.cwd), + unsubscribe, + }; + }), + peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), + getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), + refresh: vi.fn(async () => {}), + dispose: vi.fn(), }; const session = new Session({ @@ -203,10 +194,8 @@ function createSessionForWorkspaceGitWatchTests(): { }), dispose: () => {}, } as any, - backgroundGitFetchManager: backgroundGitFetchManager as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + workspaceGitService: workspaceGitService as any, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: null, @@ -217,13 +206,8 @@ function createSessionForWorkspaceGitWatchTests(): { return { session, emitted, - projects, - workspaces, - backgroundGitFetchManager: { - subscribe: backgroundGitFetchManager.subscribe, - subscriptions: backgroundGitFetchSubscriptions, - }, - logger, + workspaceGitService: workspaceGitService as any, + subscriptions, }; } @@ -262,20 +246,9 @@ function seedGitWorkspace(input: { } describe("workspace git watch targets", () => { - beforeEach(() => { - watchCalls.length = 0; - watchMock.mockClear(); - resolveCheckoutGitDirMock.mockReset(); - resolveCheckoutGitDirMock.mockResolvedValue(null); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - test("debounces watcher events and skips unchanged branch/diff snapshots", async () => { - const { session, emitted, projects, workspaces } = createSessionForWorkspaceGitWatchTests(); + test("emits one workspace_update when the workspace git service emits a changed snapshot", async () => { + const { session, emitted, workspaceGitService, subscriptions } = + createSessionForWorkspaceGitWatchTests(); const sessionAny = session as any; seedGitWorkspace({ projects, @@ -285,13 +258,12 @@ describe("workspace git watch targets", () => { cwd: "/tmp/repo", name: "main", }); - - resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git"); sessionAny.workspaceUpdatesSubscription = { subscriptionId: "sub-1", filter: undefined, isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), }; let descriptor = { @@ -308,28 +280,30 @@ describe("workspace git watch targets", () => { workspaceDirectory: "/tmp/repo", }; - sessionAny.buildWorkspaceDescriptorMap = async () => - new Map([[descriptor.id, descriptor]]); + sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); - await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]); + await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); - expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ - "/tmp/repo/.git/HEAD", - "/tmp/repo/.git/refs/heads", - ]); - - watchCalls[0]!.listener(); - watchCalls[1]!.listener(); - await vi.advanceTimersByTimeAsync(500); - - expect(emitted.filter((message) => message.type === "workspace_update")).toHaveLength(0); + expect(workspaceGitService.subscribe).toHaveBeenCalledWith( + { cwd: "/tmp/repo" }, + expect.any(Function), + ); descriptor = { ...descriptor, name: "renamed-branch", }; - watchCalls[0]!.listener(); - await vi.advanceTimersByTimeAsync(500); + + subscriptions[0]?.listener( + createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "renamed-branch", + }, + }), + ); + + await Promise.resolve(); + await Promise.resolve(); const workspaceUpdates = emitted.filter( (message) => message.type === "workspace_update", @@ -344,245 +318,110 @@ describe("workspace git watch targets", () => { }, }); - descriptor = { - ...descriptor, - diffStat: { additions: 3, deletions: 1 }, - }; - watchCalls[1]!.listener(); - await vi.advanceTimersByTimeAsync(500); - - expect(emitted.filter((message) => message.type === "workspace_update")).toHaveLength(2); - await session.cleanup(); }); - test("closes watchers when a workspace is archived and when the session closes", async () => { - const { session, projects, workspaces } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; + test("checkout_pr_status_request reads pull request status from the workspace git service snapshot", async () => { + const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); - seedGitWorkspace({ - projects, - workspaces, - projectId: 2, - workspaceId: 20, - cwd: "/tmp/repo-one", - name: "main", - }); - seedGitWorkspace({ - projects, - workspaces, - projectId: 3, - workspaceId: 30, - cwd: "/tmp/repo-two", - name: "main", - }); - - resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) => path.join(cwd, ".git")); - - await sessionAny.primeWorkspaceGitWatchFingerprints([ - { - id: "/tmp/repo-one", - projectId: "/tmp/repo-one", - projectDisplayName: "repo-one", - projectRootPath: "/tmp/repo-one", - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - activityAt: null, - workspaceDirectory: "/tmp/repo-one", - }, - ]); - expect(sessionAny.workspaceGitWatchTargets.size).toBe(1); - expect(watchCalls).toHaveLength(2); - - await sessionAny.archiveWorkspaceRecord(20, "2026-03-21T00:00:00.000Z"); - - expect(sessionAny.workspaceGitWatchTargets.size).toBe(0); - expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true); - - watchCalls.length = 0; - watchMock.mockClear(); - - await sessionAny.primeWorkspaceGitWatchFingerprints([ - { - id: "/tmp/repo-two", - projectId: "/tmp/repo-two", - projectDisplayName: "repo-two", - projectRootPath: "/tmp/repo-two", - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - activityAt: null, - workspaceDirectory: "/tmp/repo-two", - }, - ]); - expect(sessionAny.workspaceGitWatchTargets.size).toBe(1); - expect(watchCalls).toHaveLength(2); - - await session.cleanup(); - - expect(sessionAny.workspaceGitWatchTargets.size).toBe(0); - expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true); - }); - - test("resolves refs from the shared git dir for linked worktrees", async () => { - const { session } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; - const tempDir = mkdtempSync(path.join(tmpdir(), "session-workspace-git-watch-")); - const gitDir = path.join(tempDir, "repo", ".git", "worktrees", "feature"); - - mkdirSync(gitDir, { recursive: true }); - writeFileSync(path.join(gitDir, "commondir"), "../..\n"); - - try { - expect(await sessionAny.resolveWorkspaceGitRefsRoot(gitDir)).toBe( - path.join(tempDir, "repo", ".git"), - ); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - await session.cleanup(); - } - }); - - test("subscribes to the background fetch manager when a git watch target is created", async () => { - const { session, projects, workspaces, backgroundGitFetchManager } = - createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; - - seedGitWorkspace({ - projects, - workspaces, - projectId: 4, - workspaceId: 40, - cwd: "/tmp/repo", - name: "main", - }); - resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git"); - - await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true }); - - expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledWith( - { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, - expect.any(Function), + workspaceGitService.getSnapshot.mockResolvedValue( + createWorkspaceRuntimeSnapshot("/tmp/repo", { + github: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/456", + title: "Runtime centralization", + state: "merged", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: true, + }, + refreshedAt: "2026-04-12T00:05:00.000Z", + }, + }), ); - expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1); - await session.cleanup(); - }); - - test("stores separate background fetch subscriptions per workspace and unsubscribes removed targets", async () => { - const { session, projects, workspaces, backgroundGitFetchManager } = - createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; - - seedGitWorkspace({ - projects, - workspaces, - projectId: 5, - workspaceId: 50, + await session.handleMessage({ + type: "checkout_pr_status_request", cwd: "/tmp/repo", - name: "main", + requestId: "req-pr-status", }); - seedGitWorkspace({ - projects, - workspaces, - projectId: 6, - workspaceId: 60, - cwd: "/tmp/repo-feature", - name: "feature", - }); - resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) => - cwd === "/tmp/repo" ? "/tmp/repo/.git" : "/tmp/repo/.git/worktrees/feature", - ); - sessionAny.resolveWorkspaceGitRefsRoot = vi.fn(async () => "/tmp/repo/.git"); - await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true }); - await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo-feature", { isGit: true }); - - expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledTimes(2); - expect(backgroundGitFetchManager.subscriptions[0]?.params).toEqual({ - repoGitRoot: "/tmp/repo/.git", + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect( + emitted.find((message) => message.type === "checkout_pr_status_response")?.payload, + ).toEqual({ cwd: "/tmp/repo", - }); - expect(backgroundGitFetchManager.subscriptions[1]?.params).toEqual({ - repoGitRoot: "/tmp/repo/.git", - cwd: "/tmp/repo-feature", - }); - - sessionAny.removeWorkspaceGitWatchTarget("/tmp/repo"); - - expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1); - expect(backgroundGitFetchManager.subscriptions[1]?.unsubscribe).not.toHaveBeenCalled(); - expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1); - - await session.cleanup(); - }); - - test("refreshes the workspace when the background fetch manager callback fires and unsubscribes on cleanup", async () => { - const { session, emitted, projects, workspaces, backgroundGitFetchManager } = - createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; - - seedGitWorkspace({ - projects, - workspaces, - projectId: 7, - workspaceId: 70, - cwd: "/tmp/repo", - name: "main", - }); - resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git"); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - }; - sessionAny.reconcileActiveWorkspaceRecords = async () => new Set(); - - let descriptor = { - id: "/tmp/repo", - projectId: "/tmp/repo", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - activityAt: null, - diffStat: { additions: 1, deletions: 0 }, - }; - - sessionAny.buildWorkspaceDescriptorMap = async () => - new Map([[descriptor.id, descriptor]]); - - await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true }); - sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]); - - descriptor = { - ...descriptor, - name: "updated-after-fetch", - }; - - backgroundGitFetchManager.subscriptions[0]?.listener(); - await vi.advanceTimersByTimeAsync(500); - - const workspaceUpdates = emitted.filter( - (message) => message.type === "workspace_update", - ) as any[]; - expect(workspaceUpdates).toHaveLength(1); - expect(workspaceUpdates[0]?.payload).toMatchObject({ - kind: "upsert", - workspace: { - id: "/tmp/repo", - name: "updated-after-fetch", + status: { + url: "https://github.com/acme/repo/pull/456", + title: "Runtime centralization", + state: "merged", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: true, }, + githubFeaturesEnabled: true, + error: null, + requestId: "req-pr-status", + }); + }); + + test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => { + const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); + let refreshed = false; + + workspaceGitService.refresh.mockImplementation(async () => { + refreshed = true; + }); + workspaceGitService.getSnapshot.mockImplementation(async (cwd: string) => + createWorkspaceRuntimeSnapshot(cwd, { + github: { + pullRequest: refreshed + ? { + url: "https://github.com/acme/repo/pull/457", + title: "After explicit refresh", + state: "merged", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: true, + } + : { + url: "https://github.com/acme/repo/pull/456", + title: "Before explicit refresh", + state: "open", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: false, + }, + refreshedAt: refreshed ? "2026-04-12T00:10:00.000Z" : "2026-04-12T00:05:00.000Z", + }, + }), + ); + + await session.handleMessage({ + type: "checkout_pr_status_request", + cwd: "/tmp/repo", + requestId: "req-pr-refresh", }); - await session.cleanup(); - - expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1); + expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", { + priority: "high", + }); + expect( + emitted.find((message) => message.type === "checkout_pr_status_response")?.payload, + ).toEqual({ + cwd: "/tmp/repo", + status: { + url: "https://github.com/acme/repo/pull/457", + title: "After explicit refresh", + state: "merged", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: true, + }, + githubFeaturesEnabled: true, + error: null, + requestId: "req-pr-refresh", + }); }); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index f0e01f727..b2a773d45 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { Session } from "./session.js"; import type { AgentSnapshotPayload } from "../shared/messages.js"; +import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -66,7 +67,123 @@ function makeAgent(input: { }; } -function createSessionForWorkspaceTests(options: { appVersion?: string | null } = {}): { +function createNoopWorkspaceGitService() { + return { + subscribe: async (params: { cwd: string }) => ({ + initial: { + cwd: params.cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }, + unsubscribe: () => {}, + }), + peekSnapshot: (_cwd: string) => null, + getSnapshot: async (cwd: string) => ({ + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }), + refresh: async () => {}, + dispose: () => {}, + }; +} + +function createWorkspaceRuntimeSnapshot( + cwd: string, + overrides?: { + git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>; + github?: Partial<WorkspaceGitRuntimeSnapshot["github"]>; + }, +): WorkspaceGitRuntimeSnapshot { + const base: WorkspaceGitRuntimeSnapshot = { + cwd, + git: { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + diffStat: { additions: 1, deletions: 0 }, + }, + github: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "Runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "feature/runtime-payloads", + isMerged: false, + }, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }; + + return { + cwd, + git: { + ...base.git, + ...overrides?.git, + }, + github: { + ...base.github, + ...overrides?.github, + pullRequest: + overrides?.github && "pullRequest" in overrides.github + ? (overrides.github.pullRequest ?? null) + : base.github.pullRequest, + error: + overrides?.github && "error" in overrides.github + ? (overrides.github.error ?? null) + : base.github.error, + }, + }; +} + +function createSessionForWorkspaceTests(options: { + appVersion?: string | null; + workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>; +} = {}): { session: Session; emitted: Array<{ type: string; payload: unknown }>; projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>; @@ -85,10 +202,6 @@ function createSessionForWorkspaceTests(options: { appVersion?: string | null } warn: vi.fn(), error: vi.fn(), }; - const backgroundGitFetchManager = { - subscribe: vi.fn(async () => ({ unsubscribe: vi.fn() })), - }; - const session = new Session({ clientId: "test-client", appVersion: options.appVersion ?? null, @@ -179,10 +292,8 @@ function createSessionForWorkspaceTests(options: { appVersion?: string | null } }), dispose: () => {}, } as any, - backgroundGitFetchManager: backgroundGitFetchManager as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + workspaceGitService: (options.workspaceGitService ?? createNoopWorkspaceGitService()) as any, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: null, @@ -361,9 +472,8 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + workspaceGitService: createNoopWorkspaceGitService() as any, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: null, @@ -498,9 +608,8 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + workspaceGitService: createNoopWorkspaceGitService() as any, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: { @@ -526,9 +635,8 @@ describe("workspace aggregation", () => { expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1"); expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1"); - const closePayload = emitted.find((message) => message.type === "close_items_response")?.payload; - expect(closePayload).toEqual({ - agents: [{ agentId: "agent-1", archivedAt: expect.any(String) }], + expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ + agents: [{ agentId: "agent-1", archivedAt }], terminals: [{ terminalId: "term-1", success: true }], requestId: "req-close-items", }); @@ -693,9 +801,8 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + workspaceGitService: createNoopWorkspaceGitService() as any, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: { @@ -838,9 +945,8 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, + workspaceGitService: createNoopWorkspaceGitService() as any, + mcpBaseUrl: null, stt: null, tts: null, terminalManager: { @@ -867,9 +973,8 @@ describe("workspace aggregation", () => { expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-bad"); expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-good"); expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1"); - const closePayload = emitted.find((message) => message.type === "close_items_response")?.payload; - expect(closePayload).toEqual({ - agents: [{ agentId: "agent-good", archivedAt: expect.any(String) }], + expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ + agents: [{ agentId: "agent-good", archivedAt }], terminals: [{ terminalId: "term-1", success: true }], requestId: "req-close-best-effort", }); @@ -967,10 +1072,9 @@ describe("workspace aggregation", () => { }); expect(result.entries[0]).toMatchObject({ - id: "/tmp/repo/.paseo/worktrees/feature-name", - name: "feature-name", - projectKind: "git", - workspaceKind: "worktree", + id: "/tmp/repo", + status: "running", + activityAt: null, }); }); @@ -995,6 +1099,7 @@ describe("workspace aggregation", () => { filter: undefined, isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), }; (session as any).reconcileActiveWorkspaceRecords = async () => new Set(); (session as any).buildWorkspaceDescriptorMap = async () => @@ -1159,6 +1264,7 @@ describe("workspace aggregation", () => { filter: undefined, isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), }; sessionAny.reconcileActiveWorkspaceRecords = async () => new Set(); sessionAny.buildWorkspaceDescriptorMap = async () => @@ -1342,9 +1448,7 @@ describe("workspace aggregation", () => { }); expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]); - const response = emitted.find( - (message) => message.type === "open_in_editor_response", - ) as any; + const response = emitted.find((message) => message.type === "open_in_editor_response") as any; expect(response?.payload.error).toBeNull(); }); @@ -1527,6 +1631,102 @@ describe("workspace aggregation", () => { } }); + test("fetch_workspaces_request reconciles remote URL changes for existing workspaces", async () => { + const session = createSessionForWorkspaceTests().session as any; + const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>(); + const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>(); + + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-fetch-"))); + const mainWorkspaceId = path.join(tempDir, "inkwell"); + const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a"); + const oldProjectId = "remote:github.com/old-owner/inkwell"; + const newProjectId = "remote:github.com/new-owner/inkwell"; + + execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); + + projects.set( + oldProjectId, + createPersistedProjectRecord({ + projectId: oldProjectId, + rootPath: mainWorkspaceId, + kind: "git", + displayName: "old-owner/inkwell", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + + for (const [workspaceId, displayName] of [ + [mainWorkspaceId, "main"], + [worktreeWorkspaceId, "feature-a"], + ] as const) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: oldProjectId, + cwd: workspaceId, + kind: workspaceId === mainWorkspaceId ? "local_checkout" : "worktree", + displayName, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + } + + session.listAgentPayloads = async () => []; + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.upsert = async ( + record: ReturnType<typeof createPersistedProjectRecord>, + ) => { + projects.set(record.projectId, record); + }; + session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { + const existing = projects.get(projectId); + if (!existing) return; + projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.upsert = async ( + record: ReturnType<typeof createPersistedWorkspaceRecord>, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: newProjectId, + projectName: "new-owner/inkwell", + checkout: { + cwd, + isGit: true, + currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", + remoteUrl: "https://github.com/new-owner/inkwell.git", + worktreeRoot: cwd, + isPaseoOwnedWorktree: cwd !== mainWorkspaceId, + mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, + }, + }); + + try { + await session.reconcileWorkspaceRecord(mainWorkspaceId); + await session.reconcileWorkspaceRecord(worktreeWorkspaceId); + + const result = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-fetch-reconcile", + }); + + expect(result.entries.map((entry: any) => entry.projectId)).toEqual([ + newProjectId, + newProjectId, + ]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + test("open_project_request treats non-git directories as directory projects", async () => { const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests(); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-dir-"))); @@ -1837,14 +2037,234 @@ describe("backward compatibility", () => { sessionAny.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor); sessionAny.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor); - const descriptors = await sessionAny.listWorkspaceDescriptorsSnapshot(); + const descriptors = Array.from( + ( + await sessionAny.buildWorkspaceDescriptorMap({ + includeGitData: false, + }) + ).values(), + ); expect(sessionAny.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project); expect(sessionAny.describeWorkspaceRecordWithGitData).not.toHaveBeenCalled(); expect(descriptors).toEqual([baselineDescriptor]); }); - test("subscribed fetch_workspaces emits git enrichment updates after the baseline snapshot", async () => { + test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "runtime-branch", + isDirty: true, + aheadBehind: { ahead: 3, behind: 1 }, + aheadOfOrigin: 3, + behindOfOrigin: 1, + }, + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/456", + title: "Ship runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "runtime-branch", + isMerged: false, + }, + refreshedAt: "2026-04-12T00:05:00.000Z", + }, + }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); + workspaceGitService.getSnapshot = vi.fn(async () => { + throw new Error("fetch_workspaces should not trigger per-workspace refreshes"); + }); + workspaceGitService.subscribe = vi.fn(async () => ({ + initial: runtimeSnapshot, + unsubscribe: () => {}, + })); + + const { session } = createSessionForWorkspaceTests({ + workspaceGitService, + }); + const sessionAny = session as any; + const project = createPersistedProjectRecord({ + projectId: "/tmp/repo", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + sessionAny.emit = (message: any) => emitted.push(message); + sessionAny.listAgentPayloads = async () => []; + sessionAny.projectRegistry.list = async () => [project]; + sessionAny.workspaceRegistry.list = async () => [workspace]; + sessionAny.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: true, + currentBranch: runtimeSnapshot.git.currentBranch, + remoteUrl: runtimeSnapshot.git.remoteUrl, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await sessionAny.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-fetch-workspaces-runtime", + }); + + const response = emitted.find((message) => message.type === "fetch_workspaces_response") as + | { type: "fetch_workspaces_response"; payload: any } + | undefined; + + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(response?.payload.entries).toEqual([ + expect.objectContaining({ + id: "/tmp/repo", + gitRuntime: { + currentBranch: "runtime-branch", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: true, + aheadBehind: { ahead: 3, behind: 1 }, + aheadOfOrigin: 3, + behindOfOrigin: 1, + }, + githubRuntime: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/456", + title: "Ship runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "runtime-branch", + isMerged: false, + }, + error: null, + refreshedAt: "2026-04-12T00:05:00.000Z", + }, + }), + ]); + }); + + test("workspace_update includes updated runtime fields", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "feature/runtime-payloads", + isDirty: true, + }, + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/789", + title: "Updated runtime payloads", + state: "merged", + baseRefName: "main", + headRefName: "feature/runtime-payloads", + isMerged: true, + }, + refreshedAt: "2026-04-12T00:10:00.000Z", + }, + }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); + workspaceGitService.getSnapshot = vi.fn(async () => { + throw new Error("workspace updates should use passive workspace git snapshots"); + }); + + const { session } = createSessionForWorkspaceTests({ + workspaceGitService, + }); + const sessionAny = session as any; + const project = createPersistedProjectRecord({ + projectId: "/tmp/repo", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + sessionAny.emit = (message: any) => emitted.push(message); + sessionAny.workspaceUpdatesSubscription = { + subscriptionId: "sub-runtime", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + sessionAny.reconcileActiveWorkspaceRecords = async () => new Set(); + sessionAny.listAgentPayloads = async () => []; + sessionAny.projectRegistry.list = async () => [project]; + sessionAny.workspaceRegistry.list = async () => [workspace]; + sessionAny.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: true, + currentBranch: runtimeSnapshot.git.currentBranch, + remoteUrl: runtimeSnapshot.git.remoteUrl, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await sessionAny.emitWorkspaceUpdateForCwd("/tmp/repo", { + skipReconcile: true, + }); + + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(emitted).toContainEqual({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: expect.objectContaining({ + id: "/tmp/repo", + gitRuntime: expect.objectContaining({ + currentBranch: "feature/runtime-payloads", + isDirty: true, + }), + githubRuntime: expect.objectContaining({ + featuresEnabled: true, + pullRequest: expect.objectContaining({ + title: "Updated runtime payloads", + isMerged: true, + }), + refreshedAt: "2026-04-12T00:10:00.000Z", + }), + }), + }, + }); + }); + + test("subscribed fetch_workspaces includes git enrichment in the initial snapshot", async () => { const emitted: Array<{ type: string; payload: any }> = []; const { session } = createSessionForWorkspaceTests(); const sessionAny = session as any; @@ -1940,31 +2360,25 @@ describe("backward compatibility", () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - const response = emitted.find( - (message) => message.type === "fetch_workspaces_response", - ) as { type: "fetch_workspaces_response"; payload: any } | undefined; + const response = emitted.find((message) => message.type === "fetch_workspaces_response") as + | { type: "fetch_workspaces_response"; payload: any } + | undefined; expect( - response?.payload.entries.map((entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [ - entry.id, - entry.diffStat, - ]), + response?.payload.entries.map( + (entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [ + entry.id, + entry.diffStat, + ], + ), ).toEqual([ [directoryDescriptor.id, directoryDescriptor.diffStat], - [baselineGitDescriptor.id, baselineGitDescriptor.diffStat], + [enrichedGitDescriptor.id, enrichedGitDescriptor.diffStat], ]); const workspaceUpdates = emitted.filter( (message) => message.type === "workspace_update", ) as Array<{ type: "workspace_update"; payload: any }>; - expect(workspaceUpdates).toEqual([ - { - type: "workspace_update", - payload: { - kind: "upsert", - workspace: enrichedGitDescriptor, - }, - }, - ]); + expect(workspaceUpdates).toEqual([]); expect(sessionAny.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith( gitWorkspace, gitProject, diff --git a/packages/server/src/server/speech/providers/local/runtime.ts b/packages/server/src/server/speech/providers/local/runtime.ts index 0fa7d2c3b..0abfe2630 100644 --- a/packages/server/src/server/speech/providers/local/runtime.ts +++ b/packages/server/src/server/speech/providers/local/runtime.ts @@ -22,7 +22,10 @@ import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-para import { SherpaRealtimeTranscriptionSession } from "./sherpa/sherpa-realtime-session.js"; import { SherpaOnnxSTT } from "./sherpa/sherpa-stt.js"; import { SherpaOnnxTTS } from "./sherpa/sherpa-tts.js"; -import { ensureSileroVadModel, SherpaSileroTurnDetectionProvider } from "./sherpa/silero-vad-provider.js"; +import { + ensureSileroVadModel, + SherpaSileroTurnDetectionProvider, +} from "./sherpa/silero-vad-provider.js"; type LocalSttEngine = | { kind: "offline"; engine: SherpaOfflineRecognizerEngine } @@ -244,7 +247,10 @@ export async function initializeLocalSpeechServices(params: { logger.warn({ err }, "Failed to provision Silero VAD model, falling back to bundled"); } } - turnDetectionService = new SherpaSileroTurnDetectionProvider({ modelPath: vadModelPath }, logger); + turnDetectionService = new SherpaSileroTurnDetectionProvider( + { modelPath: vadModelPath }, + logger, + ); } if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") { diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.test.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.test.ts index 582239206..26e722fc6 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-parakeet-stt.test.ts @@ -22,7 +22,10 @@ class TestSherpaOnnxParakeetStt extends SherpaOnnxParakeetSTT { super({ engine: { sampleRate: 16000 } as any }, pino({ level: "silent" })); } - override async transcribeAudio(audioBuffer: Buffer, format: string): Promise<TranscriptionResult> { + override async transcribeAudio( + audioBuffer: Buffer, + format: string, + ): Promise<TranscriptionResult> { this.calls.push({ audio: Buffer.from(audioBuffer), format }); const deferred = createDeferred<TranscriptionResult>(); this.pending.push(deferred); diff --git a/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts b/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts index 87fe92b1f..3129a6f68 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts @@ -162,10 +162,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio } try { - this.logger.debug( - { phase: this.phase.state }, - "[VAD] Flushing remaining audio", - ); + this.logger.debug({ phase: this.phase.state }, "[VAD] Flushing remaining audio"); this.vad.flush(); this.stepStateMachine(); if (this.phase.state === "speaking" || this.phase.state === "ending") { @@ -205,10 +202,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio switch (this.phase.state) { case "idle": { if (detected) { - this.logger.debug( - { now }, - "[VAD] idle → confirming (detection started)", - ); + this.logger.debug({ now }, "[VAD] idle → confirming (detection started)"); this.phase = { state: "confirming", startedAt: now }; } break; @@ -238,10 +232,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio case "speaking": { if (!detected) { - this.logger.debug( - { now }, - "[VAD] speaking → ending (silence started)", - ); + this.logger.debug({ now }, "[VAD] speaking → ending (silence started)"); this.phase = { state: "ending", startedAt: now }; } break; diff --git a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts index 08d9ad0df..15bd4e223 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts @@ -202,7 +202,7 @@ describe("speech models (download E2E)", () => { provider: "codex", cwd: voiceCwd, modeId: "full-access", - model: "gpt-5.1-codex-mini", + model: "gpt-5.4-mini", thinkingOptionId: "low", }, }); diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 97a995973..e79fe6b47 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -243,7 +243,10 @@ class FakeAgentSession implements AgentSession { private async resolveSlashCommandInput( prompt: AgentPromptInput, ): Promise<{ commandName: string; args?: string } | null> { - if ((this.providerName !== "codex" && this.providerName !== "opencode") || typeof prompt !== "string") { + if ( + (this.providerName !== "codex" && this.providerName !== "opencode") || + typeof prompt !== "string" + ) { return null; } const parsed = this.parseSlashCommandInput(prompt); @@ -914,8 +917,8 @@ class FakeAgentClient implements AgentClient { return [ { provider: this.provider, - id: "gpt-5.1-codex-mini", - label: "gpt-5.1-codex-mini", + id: "gpt-5.4-mini", + label: "gpt-5.4-mini", isDefault: true, }, ]; diff --git a/packages/server/src/server/voice-local-agent.e2e.test.ts b/packages/server/src/server/voice-local-agent.e2e.test.ts index bfd09acef..c776a2580 100644 --- a/packages/server/src/server/voice-local-agent.e2e.test.ts +++ b/packages/server/src/server/voice-local-agent.e2e.test.ts @@ -54,7 +54,7 @@ function waitForSignal<T>( }, voiceLlmProvider: "codex", voiceLlmProviderExplicit: true, - voiceLlmModel: "gpt-5.1-codex-mini", + voiceLlmModel: "gpt-5.4-mini", }); }, 120000); diff --git a/packages/server/src/server/voice-mcp-bridge-command.test.ts b/packages/server/src/server/voice-mcp-bridge-command.test.ts deleted file mode 100644 index 9866e8cf9..000000000 --- a/packages/server/src/server/voice-mcp-bridge-command.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { describe, expect, test } from "vitest"; - -import { - resolveVoiceMcpBridgeFromRuntime, - resolveVoiceMcpBridgeScriptPath, -} from "./voice-mcp-bridge-command.js"; - -describe("resolveVoiceMcpBridgeFromRuntime", () => { - const bootstrapModuleUrl = new URL("./bootstrap.ts", import.meta.url).toString(); - - test("resolves default JS bridge script with node execPath", () => { - const result = resolveVoiceMcpBridgeFromRuntime({ - bootstrapModuleUrl, - execPath: "/usr/local/bin/node", - }); - - const expectedScriptPath = fileURLToPath( - new URL("../../scripts/mcp-stdio-socket-bridge-cli.mjs", bootstrapModuleUrl), - ); - - expect(result.source).toBe("default-js-script"); - expect(result.resolved.command).toBe("/usr/local/bin/node"); - expect(result.resolved.baseArgs).toEqual([expectedScriptPath]); - }); - - test("uses explicit script override when provided", () => { - const explicitScriptPath = fileURLToPath( - new URL("../../scripts/mcp-stdio-socket-bridge-cli.mjs", bootstrapModuleUrl), - ); - - const result = resolveVoiceMcpBridgeFromRuntime({ - bootstrapModuleUrl, - execPath: "/usr/local/bin/node", - explicitScriptPath, - }); - - expect(result.source).toBe("explicit-js-script"); - expect(result.resolved.command).toBe("/usr/local/bin/node"); - expect(result.resolved.baseArgs).toEqual([explicitScriptPath]); - }); - - test("throws when explicit script path is missing", () => { - expect(() => - resolveVoiceMcpBridgeScriptPath({ - bootstrapModuleUrl, - explicitScriptPath: "/tmp/does-not-exist-voice-bridge-script.mjs", - }), - ).toThrow("MCP stdio-socket bridge script not found"); - }); -}); diff --git a/packages/server/src/server/voice-mcp-bridge-command.ts b/packages/server/src/server/voice-mcp-bridge-command.ts deleted file mode 100644 index a71c5e0c0..000000000 --- a/packages/server/src/server/voice-mcp-bridge-command.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -export type VoiceMcpBridgeCommand = { command: string; baseArgs: string[] }; - -const DEFAULT_BRIDGE_SCRIPT_RELATIVE_URL = "../../scripts/mcp-stdio-socket-bridge-cli.mjs"; - -export function resolveVoiceMcpBridgeScriptPath(params: { - bootstrapModuleUrl: string; - explicitScriptPath?: string | null; -}): string { - const explicitScriptPath = params.explicitScriptPath?.trim(); - if (explicitScriptPath) { - if (!existsSync(explicitScriptPath)) { - throw new Error( - `MCP stdio-socket bridge script not found at configured path: ${explicitScriptPath}`, - ); - } - return explicitScriptPath; - } - - const scriptPath = fileURLToPath( - new URL(DEFAULT_BRIDGE_SCRIPT_RELATIVE_URL, params.bootstrapModuleUrl), - ); - if (!existsSync(scriptPath)) { - throw new Error(`MCP stdio-socket bridge script not found: ${scriptPath}`); - } - return scriptPath; -} - -export function resolveVoiceMcpBridgeFromRuntime(params: { - bootstrapModuleUrl: string; - execPath: string; - explicitScriptPath?: string | null; -}): { - resolved: VoiceMcpBridgeCommand; - source: string; -} { - const scriptPath = resolveVoiceMcpBridgeScriptPath({ - bootstrapModuleUrl: params.bootstrapModuleUrl, - explicitScriptPath: params.explicitScriptPath, - }); - return { - source: params.explicitScriptPath?.trim() ? "explicit-js-script" : "default-js-script", - resolved: { - command: params.execPath, - baseArgs: [scriptPath], - }, - }; -} diff --git a/packages/server/src/server/voice-mcp-bridge.test.ts b/packages/server/src/server/voice-mcp-bridge.test.ts deleted file mode 100644 index b54f96743..000000000 --- a/packages/server/src/server/voice-mcp-bridge.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { mkdtemp, rm } from "node:fs/promises"; -import { describe, expect, test } from "vitest"; -import { experimental_createMCPClient } from "ai"; -import { z } from "zod"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import pino from "pino"; - -import { createVoiceMcpSocketBridgeManager } from "./voice-mcp-bridge.js"; -import { resolveVoiceMcpBridgeScriptPath } from "./voice-mcp-bridge-command.js"; - -describe("voice MCP bridge", () => { - test("proxies stdio MCP bytes through per-agent unix socket bridge", async () => { - const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-voice-mcp-bridge-")); - const callerAgentId = "voice-agent-bridge-test"; - - const bridgeManager = createVoiceMcpSocketBridgeManager({ - runtimeDir: tmpRoot, - logger: pino({ level: "silent" }), - createAgentMcpServerForCaller: async (callerId) => { - const server = new McpServer({ - name: "bridge-test-server", - version: "1.0.0", - }); - - server.registerTool( - "echo_caller", - { - value: z.string().optional(), - }, - async (args) => { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - callerAgentId: callerId, - value: args.value ?? null, - }), - }, - ], - structuredContent: { - callerAgentId: callerId, - value: args.value ?? null, - }, - }; - }, - ); - - return server; - }, - }); - - const socketPath = await bridgeManager.ensureBridgeForCaller(callerAgentId); - - const transport = new StdioClientTransport({ - command: process.execPath, - args: [ - resolveVoiceMcpBridgeScriptPath({ - bootstrapModuleUrl: import.meta.url, - }), - "--socket", - socketPath, - ], - }); - - const client = await experimental_createMCPClient({ transport }); - - try { - const result = await client.callTool({ - name: "echo_caller", - args: { value: "ok" }, - }); - - const payload = - (result as { structuredContent?: { callerAgentId?: string; value?: string | null } }) - .structuredContent ?? null; - - expect(payload?.callerAgentId).toBe(callerAgentId); - } finally { - await client.close(); - await bridgeManager.stop(); - await rm(tmpRoot, { recursive: true, force: true }); - } - }, 30_000); -}); diff --git a/packages/server/src/server/voice-mcp-bridge.ts b/packages/server/src/server/voice-mcp-bridge.ts deleted file mode 100644 index cd7b958fd..000000000 --- a/packages/server/src/server/voice-mcp-bridge.ts +++ /dev/null @@ -1,145 +0,0 @@ -import net from "node:net"; -import path from "node:path"; -import { mkdir, rm } from "node:fs/promises"; -import type { Logger } from "pino"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; - -type BridgeServer = { - connect: (transport: StdioServerTransport) => Promise<void>; - close?: () => Promise<void>; -}; - -type BridgeEntry = { - socketPath: string; - server: net.Server; - sockets: Set<net.Socket>; -}; - -export type VoiceMcpSocketBridgeManager = { - ensureBridgeForCaller: (callerAgentId: string) => Promise<string>; - removeBridgeForCaller: (callerAgentId: string) => Promise<void>; - stop: () => Promise<void>; -}; - -function toSocketName(callerAgentId: string): string { - return `voice-mcp-${callerAgentId}.sock`; -} - -export function createVoiceMcpSocketBridgeManager(params: { - runtimeDir: string; - logger: Logger; - createAgentMcpServerForCaller: (callerAgentId: string) => Promise<BridgeServer>; -}): VoiceMcpSocketBridgeManager { - const logger = params.logger.child({ module: "voice-mcp-bridge" }); - const entries = new Map<string, BridgeEntry>(); - const pendingCreates = new Map<string, Promise<string>>(); - - const ensureBridgeForCaller = async (callerAgentId: string): Promise<string> => { - const existing = entries.get(callerAgentId); - if (existing) { - return existing.socketPath; - } - - const pending = pendingCreates.get(callerAgentId); - if (pending) { - return pending; - } - - const createPromise = (async () => { - const socketPath = path.join(params.runtimeDir, toSocketName(callerAgentId)); - const sockets = new Set<net.Socket>(); - const server = net.createServer((socket) => { - sockets.add(socket); - const connectionLogger = logger.child({ callerAgentId, component: "connection" }); - - let mcpServer: BridgeServer | null = null; - let transport: StdioServerTransport | null = null; - - const cleanup = async () => { - sockets.delete(socket); - await Promise.all([ - transport?.close().catch(() => undefined), - mcpServer?.close?.().catch(() => undefined), - ]); - }; - - socket.on("error", (error) => { - connectionLogger.error({ err: error }, "Voice MCP bridge socket error"); - }); - socket.on("close", () => { - void cleanup(); - }); - - void (async () => { - try { - mcpServer = await params.createAgentMcpServerForCaller(callerAgentId); - transport = new StdioServerTransport(socket, socket); - await mcpServer.connect(transport); - } catch (error) { - connectionLogger.error( - { err: error, callerAgentId }, - "Failed to initialize stream-level MCP bridge connection", - ); - socket.destroy(); - } - })(); - }); - - await mkdir(params.runtimeDir, { recursive: true }); - await rm(socketPath, { force: true }).catch(() => undefined); - await new Promise<void>((resolve, reject) => { - server.once("error", reject); - server.listen(socketPath, () => { - server.off("error", reject); - resolve(); - }); - }); - - entries.set(callerAgentId, { socketPath, server, sockets }); - logger.info({ callerAgentId, socketPath }, "Voice MCP per-agent socket bridge listening"); - return socketPath; - })(); - - pendingCreates.set(callerAgentId, createPromise); - try { - return await createPromise; - } finally { - pendingCreates.delete(callerAgentId); - } - }; - - const removeBridgeForCaller = async (callerAgentId: string): Promise<void> => { - const entry = entries.get(callerAgentId); - if (!entry) { - return; - } - entries.delete(callerAgentId); - - for (const socket of entry.sockets) { - socket.destroy(); - } - await new Promise<void>((resolve, reject) => { - entry.server.close((error) => { - if (error) reject(error); - else resolve(); - }); - }); - await rm(entry.socketPath, { force: true }).catch(() => undefined); - logger.info({ callerAgentId, socketPath: entry.socketPath }, "Voice MCP socket bridge removed"); - }; - - const stop = async (): Promise<void> => { - const activeCallerIds = Array.from(entries.keys()); - for (const callerAgentId of activeCallerIds) { - await removeBridgeForCaller(callerAgentId).catch((error) => { - logger.warn({ err: error, callerAgentId }, "Failed to stop voice MCP socket bridge"); - }); - } - }; - - return { - ensureBridgeForCaller, - removeBridgeForCaller, - stop, - }; -} diff --git a/packages/server/src/server/voice-roundtrip.e2e.test.ts b/packages/server/src/server/voice-roundtrip.e2e.test.ts index f4e56a5fe..c44d1a870 100644 --- a/packages/server/src/server/voice-roundtrip.e2e.test.ts +++ b/packages/server/src/server/voice-roundtrip.e2e.test.ts @@ -32,7 +32,7 @@ function getVoiceRoundtripConfig(provider: VoiceRoundtripProvider): { case "codex": return { provider: "codex", - model: "gpt-5.1-codex-mini", + model: "gpt-5.4-mini", modeId: "full-access", thinkingOptionId: "low", }; diff --git a/packages/server/src/server/voice-types.ts b/packages/server/src/server/voice-types.ts index b5e75fa58..14c78ceb6 100644 --- a/packages/server/src/server/voice-types.ts +++ b/packages/server/src/server/voice-types.ts @@ -10,9 +10,3 @@ export type VoiceCallerContext = { allowCustomCwd?: boolean; enableVoiceTools?: boolean; }; - -export type VoiceMcpStdioConfig = { - command: string; - baseArgs: string[]; - env?: Record<string, string>; -}; diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 1c233d5d3..63b796793 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -66,6 +66,9 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) { getLastAssistantMessage: vi.fn(async () => null), ...agentManagerOverrides, }; + const daemonConfigStore = { + onChange: vi.fn(() => () => {}), + }; const server = new VoiceAssistantWebSocketServer( {} as any, @@ -75,13 +78,13 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) { {} as any, {} as any, "/tmp/paseo-test", - async () => ({}) as any, + daemonConfigStore as any, + null, { allowedOrigins: new Set() }, undefined, undefined, undefined, undefined, - undefined, "1.2.3-test", undefined, undefined, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index fe1b7c53b..a6c70e40a 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -147,6 +147,9 @@ function createLogger() { function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | null }) { const speechReadiness = options?.speechReadiness ?? null; + const daemonConfigStore = { + onChange: vi.fn(() => () => {}), + }; return new VoiceAssistantWebSocketServer( {} as any, createLogger() as any, @@ -165,7 +168,8 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu {} as any, {} as any, "/tmp/paseo-test", - async () => ({}) as any, + daemonConfigStore as any, + null, { allowedOrigins: new Set() }, speechReadiness ? { @@ -176,7 +180,6 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu undefined, undefined, undefined, - undefined, TEST_DAEMON_VERSION, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 73d06b9df..a17736934 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,6 +1,5 @@ import { WebSocketServer } from "ws"; import type { Server as HTTPServer } from "http"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { join } from "path"; import { hostname as getHostname } from "node:os"; import type { AgentManager } from "./agent/agent-manager.js"; @@ -13,7 +12,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js"; import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js"; -import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; +import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js"; import { type ServerInfoStatusPayload, type WSHelloMessage, @@ -23,10 +22,7 @@ import { type WSOutboundMessage, wrapSessionMessage, } from "./messages.js"; -import { - asUint8Array, - decodeTerminalStreamFrame, -} from "../shared/terminal-stream-protocol.js"; +import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js"; import type { AllowedHostsConfig } from "./allowed-hosts.js"; import { isHostAllowed } from "./allowed-hosts.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; @@ -34,13 +30,14 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { buildProviderRegistry } from "./agent/provider-registry.js"; +import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; import type { ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptRouteStore } from "./script-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; -import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; +import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import { computeShouldNotifyClient, computeShouldSendPush, @@ -51,7 +48,6 @@ import { findLatestPermissionRequest, } from "../shared/agent-attention-notification.js"; -export type AgentMcpTransportFactory = () => Promise<Transport>; export type ExternalSocketMetadata = { transport: "relay"; externalSessionKey?: string; @@ -242,12 +238,13 @@ export class VoiceAssistantWebSocketServer { private readonly loopService: LoopService; private readonly scheduleService: ScheduleService; private readonly checkoutDiffManager: CheckoutDiffManager; - private readonly backgroundGitFetchManager: BackgroundGitFetchManager; + private readonly workspaceGitService: WorkspaceGitServiceImpl; private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; + private readonly daemonConfigStore: DaemonConfigStore; private readonly pushTokenStore: PushTokenStore; private readonly pushService: PushService; - private readonly createAgentMcpTransport: AgentMcpTransportFactory; + private readonly mcpBaseUrl: string | null; private readonly speech: SpeechService | null; private readonly terminalManager: TerminalManager | null; private readonly scriptRouteStore: ScriptRouteStore | null; @@ -260,11 +257,6 @@ export class VoiceAssistantWebSocketServer { private readonly dictation: { finalTimeoutMs?: number; } | null; - private readonly voice: { - voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; - ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>; - removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>; - } | null; private readonly voiceSpeakHandlers = new Map<string, VoiceSpeakHandler>(); private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>(); private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; @@ -297,6 +289,7 @@ export class VoiceAssistantWebSocketServer { private readonly requestLatencies = new Map<string, number[]>(); private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null; private unsubscribeSpeechReadiness: (() => void) | null = null; + private unsubscribeDaemonConfigChange: (() => void) | null = null; constructor( server: HTTPServer, @@ -306,15 +299,11 @@ export class VoiceAssistantWebSocketServer { agentStorage: AgentSnapshotStore, downloadTokenStore: DownloadTokenStore, paseoHome: string, - createAgentMcpTransport: AgentMcpTransportFactory, + daemonConfigStore: DaemonConfigStore, + mcpBaseUrl: string | null, wsConfig: WebSocketServerConfig, speech?: SpeechService | null, terminalManager?: TerminalManager | null, - voice?: { - voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; - ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>; - removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>; - }, dictation?: { finalTimeoutMs?: number; }, @@ -364,15 +353,16 @@ export class VoiceAssistantWebSocketServer { throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager."); } this.checkoutDiffManager = checkoutDiffManager; - this.backgroundGitFetchManager = new BackgroundGitFetchManager({ + this.workspaceGitService = new WorkspaceGitServiceImpl({ logger: this.logger, + paseoHome, }); this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; - this.createAgentMcpTransport = createAgentMcpTransport; + this.daemonConfigStore = daemonConfigStore; + this.mcpBaseUrl = mcpBaseUrl; this.speech = speech ?? null; this.terminalManager = terminalManager ?? null; - this.voice = voice ?? null; this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" }); @@ -392,9 +382,13 @@ export class VoiceAssistantWebSocketServer { this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, }); - this.unsubscribeSpeechReadiness = this.speech?.onReadinessChange((snapshot) => { - this.publishSpeechReadiness(snapshot); - }) ?? null; + this.unsubscribeSpeechReadiness = + this.speech?.onReadinessChange((snapshot) => { + this.publishSpeechReadiness(snapshot); + }) ?? null; + this.unsubscribeDaemonConfigChange = this.daemonConfigStore.onChange((config) => { + this.broadcastDaemonConfigChanged(config); + }); const pushLogger = this.logger.child({ module: "push" }); this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json")); @@ -497,6 +491,8 @@ export class VoiceAssistantWebSocketServer { public async close(): Promise<void> { this.unsubscribeSpeechReadiness?.(); this.unsubscribeSpeechReadiness = null; + this.unsubscribeDaemonConfigChange?.(); + this.unsubscribeDaemonConfigChange = null; if (this.runtimeMetricsInterval) { clearInterval(this.runtimeMetricsInterval); this.runtimeMetricsInterval = null; @@ -554,7 +550,7 @@ export class VoiceAssistantWebSocketServer { await Promise.all(cleanupPromises); this.providerSnapshotManager.destroy(); - this.backgroundGitFetchManager.dispose(); + this.workspaceGitService.dispose(); this.checkoutDiffManager.dispose(); this.pendingConnections.clear(); this.sessions.clear(); @@ -569,10 +565,7 @@ export class VoiceAssistantWebSocketServer { } } - private sendBinaryToClient( - ws: WebSocketLike, - frame: Uint8Array, - ): void { + private sendBinaryToClient(ws: WebSocketLike, frame: Uint8Array): void { if (ws.readyState !== 1) { return; } @@ -585,10 +578,7 @@ export class VoiceAssistantWebSocketServer { } } - private sendBinaryToConnection( - connection: SessionConnection, - frame: Uint8Array, - ): void { + private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void { for (const ws of connection.sockets) { this.sendBinaryToClient(ws, frame); } @@ -691,8 +681,9 @@ export class VoiceAssistantWebSocketServer { loopService: this.loopService, scheduleService: this.scheduleService, checkoutDiffManager: this.checkoutDiffManager, - backgroundGitFetchManager: this.backgroundGitFetchManager, - createAgentMcpTransport: this.createAgentMcpTransport, + workspaceGitService: this.workspaceGitService, + daemonConfigStore: this.daemonConfigStore, + mcpBaseUrl: this.mcpBaseUrl, stt: () => this.speech?.resolveStt() ?? null, tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, @@ -704,7 +695,6 @@ export class VoiceAssistantWebSocketServer { getDaemonTcpHost: this.getDaemonTcpHost ?? undefined, resolveScriptHealth: this.resolveScriptHealth ?? undefined, voice: { - ...(this.voice ?? {}), turnDetection: () => this.speech?.resolveTurnDetection() ?? null, }, voiceBridge: { @@ -720,8 +710,6 @@ export class VoiceAssistantWebSocketServer { unregisterVoiceCallerContext: (agentId) => { this.voiceCallerContexts.delete(agentId); }, - ensureVoiceMcpSocketForAgent: this.voice?.ensureVoiceMcpSocketForAgent, - removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent, }, dictation: this.dictation || this.speech @@ -866,10 +854,24 @@ export class VoiceAssistantWebSocketServer { }; } + private createDaemonConfigChangedMessage(config: MutableDaemonConfig): WSOutboundMessage { + return wrapSessionMessage({ + type: "status", + payload: { + status: "daemon_config_changed", + config, + }, + }); + } + private broadcastCapabilitiesUpdate(): void { this.broadcast(this.createServerInfoMessage()); } + private broadcastDaemonConfigChanged(config: MutableDaemonConfig): void { + this.broadcast(this.createDaemonConfigChangedMessage(config)); + } + private bindSocketHandlers(ws: WebSocketLike): void { ws.on("message", (data) => { void this.handleRawMessage(ws, data); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts new file mode 100644 index 000000000..97baf930c --- /dev/null +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -0,0 +1,435 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "./workspace-git-service.js"; + +function createLogger() { + const logger = { + child: () => logger, + debug: vi.fn(), + warn: vi.fn(), + }; + return logger; +} + +function createSnapshot( + cwd: string, + overrides?: { + git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>; + github?: Partial<WorkspaceGitRuntimeSnapshot["github"]>; + }, +): WorkspaceGitRuntimeSnapshot { + const base: WorkspaceGitRuntimeSnapshot = { + cwd, + git: { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + diffStat: { additions: 1, deletions: 0 }, + }, + github: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "Update feature", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + }, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }; + + return { + cwd, + git: { + ...base.git, + ...overrides?.git, + }, + github: { + ...base.github, + ...overrides?.github, + pullRequest: + overrides?.github && "pullRequest" in overrides.github + ? (overrides.github.pullRequest ?? null) + : base.github.pullRequest, + error: + overrides?.github && "error" in overrides.github + ? (overrides.github.error ?? null) + : base.github.error, + }, + }; +} + +function createCheckoutStatus( + cwd: string, + overrides?: Partial<CheckoutStatusGit>, +): CheckoutStatusGit { + return { + isGit: true, + repoRoot: cwd, + currentBranch: "main", + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + ...overrides, + }; +} + +function createPullRequestStatusResult( + overrides?: Partial<PullRequestStatusResult>, +): PullRequestStatusResult { + return { + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Update feature", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + }, + githubFeaturesEnabled: true, + ...overrides, + }; +} + +function createWatcher() { + return { + close: vi.fn(), + on: vi.fn().mockReturnThis(), + }; +} + +async function flushPromises(): Promise<void> { + await Promise.resolve(); + await Promise.resolve(); +} + +function createService(options?: { + getCheckoutStatus?: ReturnType<typeof vi.fn>; + getCheckoutShortstat?: ReturnType<typeof vi.fn>; + getPullRequestStatus?: ReturnType<typeof vi.fn>; + resolveGhPath?: ReturnType<typeof vi.fn>; + resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>; + hasOriginRemote?: ReturnType<typeof vi.fn>; + runGitFetch?: ReturnType<typeof vi.fn>; + watch?: ReturnType<typeof vi.fn>; + now?: () => Date; +}) { + return new WorkspaceGitServiceImpl({ + logger: createLogger() as any, + paseoHome: "/tmp/paseo-test", + deps: { + watch: options?.watch ?? ((() => createWatcher()) as unknown as any), + getCheckoutStatus: + options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), + getCheckoutShortstat: + options?.getCheckoutShortstat ?? + vi.fn(async () => ({ + additions: 1, + deletions: 0, + })), + getPullRequestStatus: + options?.getPullRequestStatus ?? vi.fn(async () => createPullRequestStatusResult()), + resolveGhPath: options?.resolveGhPath ?? vi.fn(async () => "/usr/bin/gh"), + resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"), + hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false), + runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}), + now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")), + }, + }); +} + +describe("WorkspaceGitServiceImpl", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("subscribe returns an initial workspace runtime snapshot", async () => { + const service = createService(); + + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + expect(subscription.initial).toEqual(createSnapshot("/tmp/repo")); + expect(listener).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("getSnapshot populates github pull request state in the runtime snapshot", async () => { + const getPullRequestStatus = vi.fn(async () => + createPullRequestStatusResult({ + status: { + url: "https://github.com/acme/repo/pull/999", + title: "Ship runtime centralization", + state: "open", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: false, + }, + }), + ); + + const service = createService({ + getPullRequestStatus, + now: () => new Date("2026-04-12T02:03:04.000Z"), + }); + + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( + createSnapshot("/tmp/repo", { + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/999", + title: "Ship runtime centralization", + state: "open", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: false, + }, + refreshedAt: "2026-04-12T02:03:04.000Z", + }, + }), + ); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => { + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + + const service = createService({ + getPullRequestStatus, + resolveAbsoluteGitDir, + }); + + const [first, second] = await Promise.all([ + service.subscribe({ cwd: "/tmp/repo" }, vi.fn()), + service.subscribe({ cwd: "/tmp/repo" }, vi.fn()), + ]); + + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); + expect((service as any).workspaceTargets.size).toBe(1); + + first.unsubscribe(); + second.unsubscribe(); + service.dispose(); + }); + + test("equivalent cwd strings share one workspace target across service entry points", async () => { + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + + const service = createService({ + getPullRequestStatus, + resolveAbsoluteGitDir, + }); + + const subscription = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn()); + + expect(subscription.initial).toEqual(createSnapshot("/tmp/repo")); + expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); + + await service.refresh("/tmp/repo"); + await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo")); + + expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); + expect((service as any).workspaceTargets.size).toBe(1); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("repo-level fetch intervals are shared for workspaces in the same repo", async () => { + const runGitFetch = vi.fn(async () => {}); + const hasOriginRemote = vi.fn(async () => true); + + const service = createService({ + resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), + hasOriginRemote, + runGitFetch, + }); + + const first = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); + const second = await service.subscribe({ cwd: "/tmp/repo/packages/server" }, vi.fn()); + await flushPromises(); + + expect(hasOriginRemote).toHaveBeenCalledTimes(1); + expect(runGitFetch).toHaveBeenCalledTimes(1); + expect((service as any).repoTargets.size).toBe(1); + + await vi.advanceTimersByTimeAsync(180_000); + await flushPromises(); + + expect(runGitFetch).toHaveBeenCalledTimes(2); + + first.unsubscribe(); + second.unsubscribe(); + service.dispose(); + }); + + test("explicit refresh recomputes github state and notifies listeners", async () => { + const getPullRequestStatus = vi + .fn<() => Promise<PullRequestStatusResult>>() + .mockResolvedValueOnce( + createPullRequestStatusResult({ + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Before refresh", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + }, + }), + ) + .mockResolvedValueOnce( + createPullRequestStatusResult({ + status: { + url: "https://github.com/acme/repo/pull/123", + title: "After refresh", + state: "merged", + baseRefName: "main", + headRefName: "feature", + isMerged: true, + }, + }), + ); + + const nowValues = [new Date("2026-04-12T00:00:00.000Z"), new Date("2026-04-12T00:05:00.000Z")]; + const service = createService({ + getPullRequestStatus, + now: () => nowValues.shift() ?? new Date("2026-04-12T00:05:00.000Z"), + }); + + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + expect(subscription.initial.github.pullRequest?.title).toBe("Before refresh"); + + service.refresh("/tmp/repo"); + await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise; + await flushPromises(); + + expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + createSnapshot("/tmp/repo", { + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "After refresh", + state: "merged", + baseRefName: "main", + headRefName: "feature", + isMerged: true, + }, + refreshedAt: "2026-04-12T00:05:00.000Z", + }, + }), + ); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("unchanged runtime snapshots do not emit duplicate updates", async () => { + const getCheckoutStatus = vi + .fn<() => Promise<CheckoutStatusGit>>() + .mockResolvedValueOnce(createCheckoutStatus("/tmp/repo")) + .mockResolvedValueOnce( + createCheckoutStatus("/tmp/repo", { + currentBranch: "feature/runtime-payloads", + aheadBehind: { ahead: 2, behind: 0 }, + aheadOfOrigin: 2, + }), + ) + .mockResolvedValueOnce( + createCheckoutStatus("/tmp/repo", { + currentBranch: "feature/runtime-payloads", + aheadBehind: { ahead: 2, behind: 0 }, + aheadOfOrigin: 2, + }), + ); + const getPullRequestStatus = vi.fn<() => Promise<PullRequestStatusResult>>().mockResolvedValue( + createPullRequestStatusResult({ + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "feature/runtime-payloads", + isMerged: false, + }, + }), + ); + + const service = createService({ + getCheckoutStatus, + getPullRequestStatus, + now: () => new Date("2026-04-12T00:00:00.000Z"), + }); + + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + expect(subscription.initial.git.currentBranch).toBe("main"); + + service.refresh("/tmp/repo"); + await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise; + await flushPromises(); + + service.refresh("/tmp/repo"); + await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise; + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + createSnapshot("/tmp/repo", { + git: { + currentBranch: "feature/runtime-payloads", + aheadBehind: { ahead: 2, behind: 0 }, + aheadOfOrigin: 2, + }, + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "Runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "feature/runtime-payloads", + isMerged: false, + }, + }, + }), + ); + + subscription.unsubscribe(); + service.dispose(); + }); +}); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts new file mode 100644 index 000000000..07f29fc88 --- /dev/null +++ b/packages/server/src/server/workspace-git-service.ts @@ -0,0 +1,597 @@ +import { execFile } from "node:child_process"; +import { watch, type FSWatcher } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import type pino from "pino"; +import type { CheckoutContext } from "../utils/checkout-git.js"; +import { + getCheckoutShortstat, + getCheckoutStatus, + getPullRequestStatus, + hasOriginRemote, + resolveGhPath, + resolveAbsoluteGitDir, +} from "../utils/checkout-git.js"; +import { normalizeWorkspaceId } from "./workspace-registry-model.js"; + +const execFileAsync = promisify(execFile); + +const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; +const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; + +export type WorkspaceGitRuntimeSnapshot = { + cwd: string; + git: { + isGit: boolean; + repoRoot: string | null; + mainRepoRoot: string | null; + currentBranch: string | null; + remoteUrl: string | null; + isPaseoOwnedWorktree: boolean; + isDirty: boolean | null; + aheadBehind: { ahead: number; behind: number } | null; + aheadOfOrigin: number | null; + behindOfOrigin: number | null; + diffStat: { additions: number; deletions: number } | null; + }; + github: { + featuresEnabled: boolean; + pullRequest: { + url: string; + title: string; + state: string; + baseRefName: string; + headRefName: string; + isMerged: boolean; + } | null; + error: { message: string } | null; + refreshedAt: string | null; + }; +}; + +export interface WorkspaceGitService { + subscribe( + params: { cwd: string }, + listener: WorkspaceGitListener, + ): Promise<{ + initial: WorkspaceGitRuntimeSnapshot; + unsubscribe: () => void; + }>; + + peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; + getSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot>; + refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise<void>; + dispose(): void; +} + +export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; + +interface WorkspaceGitServiceDependencies { + watch: typeof watch; + getCheckoutStatus: typeof getCheckoutStatus; + getCheckoutShortstat: typeof getCheckoutShortstat; + getPullRequestStatus: typeof getPullRequestStatus; + resolveGhPath: typeof resolveGhPath; + resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>; + hasOriginRemote: (cwd: string) => Promise<boolean>; + runGitFetch: (cwd: string) => Promise<void>; + now: () => Date; +} + +interface WorkspaceGitServiceOptions { + logger: pino.Logger; + paseoHome: string; + deps?: Partial<WorkspaceGitServiceDependencies>; +} + +interface WorkspaceGitTarget { + cwd: string; + listeners: Set<WorkspaceGitListener>; + watchers: FSWatcher[]; + debounceTimer: NodeJS.Timeout | null; + refreshPromise: Promise<void> | null; + refreshQueued: boolean; + latestSnapshot: WorkspaceGitRuntimeSnapshot | null; + latestFingerprint: string | null; + repoGitRoot: string | null; +} + +interface RepoGitTarget { + repoGitRoot: string; + cwd: string; + workspaceKeys: Set<string>; + intervalId: NodeJS.Timeout | null; + fetchInFlight: boolean; +} + +export class WorkspaceGitServiceImpl implements WorkspaceGitService { + private readonly logger: pino.Logger; + private readonly paseoHome: string; + private readonly deps: WorkspaceGitServiceDependencies; + private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>(); + private readonly repoTargets = new Map<string, RepoGitTarget>(); + private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>(); + + constructor(options: WorkspaceGitServiceOptions) { + this.logger = options.logger.child({ module: "workspace-git-service" }); + this.paseoHome = options.paseoHome; + this.deps = { + watch, + getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus, + getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat, + getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus, + resolveGhPath: options.deps?.resolveGhPath ?? resolveGhPath, + resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir, + hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote, + runGitFetch: options.deps?.runGitFetch ?? runGitFetch, + now: options.deps?.now ?? (() => new Date()), + }; + } + + async subscribe( + params: { cwd: string }, + listener: WorkspaceGitListener, + ): Promise<{ + initial: WorkspaceGitRuntimeSnapshot; + unsubscribe: () => void; + }> { + const cwd = normalizeWorkspaceId(params.cwd); + const target = await this.ensureWorkspaceTarget(cwd); + target.listeners.add(listener); + + return { + initial: target.latestSnapshot ?? (await this.getSnapshot(cwd)), + unsubscribe: () => { + this.removeWorkspaceListener(cwd, listener); + }, + }; + } + + async getSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot> { + cwd = normalizeWorkspaceId(cwd); + const target = this.workspaceTargets.get(cwd); + if (target?.latestSnapshot) { + return target.latestSnapshot; + } + return this.refreshSnapshot(cwd); + } + + peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { + cwd = normalizeWorkspaceId(cwd); + return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; + } + + async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> { + cwd = normalizeWorkspaceId(cwd); + const target = this.workspaceTargets.get(cwd); + if (target) { + await this.refreshWorkspaceTarget(target); + return; + } + + await this.ensureWorkspaceTarget(cwd); + } + + dispose(): void { + for (const target of this.workspaceTargets.values()) { + this.closeWorkspaceTarget(target); + } + this.workspaceTargets.clear(); + + for (const target of this.repoTargets.values()) { + this.closeRepoTarget(target); + } + this.repoTargets.clear(); + this.workspaceTargetSetups.clear(); + } + + private async ensureWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> { + const existingTarget = this.workspaceTargets.get(cwd); + if (existingTarget) { + return existingTarget; + } + + const existingSetup = this.workspaceTargetSetups.get(cwd); + if (existingSetup) { + return existingSetup; + } + + const setup = this.createWorkspaceTarget(cwd).finally(() => { + this.workspaceTargetSetups.delete(cwd); + }); + this.workspaceTargetSetups.set(cwd, setup); + return setup; + } + + private async createWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> { + const target: WorkspaceGitTarget = { + cwd, + listeners: new Set(), + watchers: [], + debounceTimer: null, + refreshPromise: null, + refreshQueued: false, + latestSnapshot: null, + latestFingerprint: null, + repoGitRoot: null, + }; + + const initial = await this.refreshSnapshot(cwd); + this.rememberSnapshot(target, initial); + this.workspaceTargets.set(cwd, target); + + const gitDir = await this.deps.resolveAbsoluteGitDir(cwd); + if (!gitDir) { + return target; + } + + const repoGitRoot = await this.resolveWorkspaceGitRefsRoot(gitDir); + target.repoGitRoot = repoGitRoot; + this.startWorkspaceWatchers(target, gitDir, repoGitRoot); + await this.ensureRepoTarget(target); + return target; + } + + private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise<string> { + try { + const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim(); + if (commonDir.length > 0) { + return resolve(gitDir, commonDir); + } + } catch { + return gitDir; + } + + return gitDir; + } + + private startWorkspaceWatchers( + target: WorkspaceGitTarget, + gitDir: string, + repoGitRoot: string, + ): void { + for (const watchPath of new Set([join(gitDir, "HEAD"), join(repoGitRoot, "refs", "heads")])) { + let watcher: FSWatcher | null = null; + try { + watcher = this.deps.watch(watchPath, { recursive: false }, () => { + this.scheduleWorkspaceRefresh(target); + }); + } catch (error) { + this.logger.warn( + { err: error, cwd: target.cwd, watchPath }, + "Failed to start workspace git watcher", + ); + } + + if (!watcher) { + continue; + } + + watcher.on("error", (error) => { + this.logger.warn({ err: error, cwd: target.cwd, watchPath }, "Workspace git watcher error"); + }); + target.watchers.push(watcher); + } + } + + private async ensureRepoTarget(workspaceTarget: WorkspaceGitTarget): Promise<void> { + const repoGitRoot = workspaceTarget.repoGitRoot; + if (!repoGitRoot) { + return; + } + + const existingTarget = this.repoTargets.get(repoGitRoot); + if (existingTarget) { + existingTarget.workspaceKeys.add(workspaceTarget.cwd); + return; + } + + const hasOrigin = await this.deps.hasOriginRemote(workspaceTarget.cwd); + if (!hasOrigin) { + return; + } + + const targetAfterProbe = this.repoTargets.get(repoGitRoot); + if (targetAfterProbe) { + targetAfterProbe.workspaceKeys.add(workspaceTarget.cwd); + return; + } + + const repoTarget: RepoGitTarget = { + repoGitRoot, + cwd: workspaceTarget.cwd, + workspaceKeys: new Set([workspaceTarget.cwd]), + intervalId: setInterval(() => { + void this.runRepoFetch(repoTarget); + }, BACKGROUND_GIT_FETCH_INTERVAL_MS), + fetchInFlight: false, + }; + this.repoTargets.set(repoGitRoot, repoTarget); + void this.runRepoFetch(repoTarget); + } + + private scheduleWorkspaceRefresh(target: WorkspaceGitTarget): void { + if (target.debounceTimer) { + clearTimeout(target.debounceTimer); + } + + target.debounceTimer = setTimeout(() => { + target.debounceTimer = null; + void this.refreshWorkspaceTarget(target); + }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); + } + + private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise<void> { + if (target.refreshPromise) { + target.refreshQueued = true; + return; + } + + target.refreshPromise = (async () => { + do { + target.refreshQueued = false; + try { + const snapshot = await this.refreshSnapshot(target.cwd); + this.rememberSnapshot(target, snapshot, { notify: true }); + } catch (error) { + this.logger.warn( + { err: error, cwd: target.cwd }, + "Failed to refresh workspace git snapshot", + ); + } + } while (target.refreshQueued); + })(); + + try { + await target.refreshPromise; + } finally { + target.refreshPromise = null; + } + } + + private async refreshSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot> { + return loadWorkspaceGitRuntimeSnapshot( + cwd, + { paseoHome: this.paseoHome }, + this.deps.now(), + this.deps, + ); + } + + private rememberSnapshot( + target: WorkspaceGitTarget, + snapshot: WorkspaceGitRuntimeSnapshot, + options?: { notify?: boolean }, + ): void { + target.latestSnapshot = snapshot; + const fingerprint = JSON.stringify(snapshot); + if (target.latestFingerprint === fingerprint) { + return; + } + target.latestFingerprint = fingerprint; + if (!options?.notify) { + return; + } + for (const listener of target.listeners) { + listener(snapshot); + } + } + + private async runRepoFetch(target: RepoGitTarget): Promise<void> { + if (target.fetchInFlight) { + return; + } + + target.fetchInFlight = true; + this.logger.debug( + { repoGitRoot: target.repoGitRoot, cwd: target.cwd }, + "Running background git fetch", + ); + + try { + await this.deps.runGitFetch(target.cwd); + } catch (error) { + this.logger.warn( + { err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd }, + "Background git fetch failed", + ); + } finally { + target.fetchInFlight = false; + await Promise.all( + Array.from(target.workspaceKeys, async (workspaceKey) => { + const workspaceTarget = this.workspaceTargets.get(workspaceKey); + if (!workspaceTarget) { + return; + } + await this.refreshWorkspaceTarget(workspaceTarget); + }), + ); + } + } + + private removeWorkspaceListener(cwd: string, listener: WorkspaceGitListener): void { + const target = this.workspaceTargets.get(cwd); + if (!target) { + return; + } + + target.listeners.delete(listener); + if (target.listeners.size > 0) { + return; + } + + this.removeWorkspaceTarget(target); + } + + private removeWorkspaceTarget(target: WorkspaceGitTarget): void { + if (target.repoGitRoot) { + const repoTarget = this.repoTargets.get(target.repoGitRoot); + repoTarget?.workspaceKeys.delete(target.cwd); + if (repoTarget && repoTarget.workspaceKeys.size === 0) { + this.closeRepoTarget(repoTarget); + this.repoTargets.delete(target.repoGitRoot); + } + } + + this.closeWorkspaceTarget(target); + this.workspaceTargets.delete(target.cwd); + } + + private closeWorkspaceTarget(target: WorkspaceGitTarget): void { + if (target.debounceTimer) { + clearTimeout(target.debounceTimer); + target.debounceTimer = null; + } + + for (const watcher of target.watchers) { + watcher.close(); + } + target.watchers = []; + target.listeners.clear(); + } + + private closeRepoTarget(target: RepoGitTarget): void { + if (target.intervalId) { + clearInterval(target.intervalId); + target.intervalId = null; + } + target.workspaceKeys.clear(); + } +} + +async function loadWorkspaceGitRuntimeSnapshot( + cwd: string, + context: CheckoutContext, + now: Date, + deps: Pick< + WorkspaceGitServiceDependencies, + "getCheckoutStatus" | "getCheckoutShortstat" | "getPullRequestStatus" | "resolveGhPath" + >, +): Promise<WorkspaceGitRuntimeSnapshot> { + const checkoutStatus = await deps.getCheckoutStatus(cwd, context); + if (!checkoutStatus.isGit) { + return buildNotGitSnapshot(cwd); + } + + const [diffStat, github] = await Promise.all([ + deps.getCheckoutShortstat(cwd, context), + loadGitHubSnapshot({ + cwd, + remoteUrl: checkoutStatus.remoteUrl, + now, + deps, + }), + ]); + + return { + cwd, + git: { + isGit: true, + repoRoot: checkoutStatus.repoRoot, + mainRepoRoot: checkoutStatus.isPaseoOwnedWorktree ? checkoutStatus.mainRepoRoot : null, + currentBranch: checkoutStatus.currentBranch, + remoteUrl: checkoutStatus.remoteUrl, + isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, + isDirty: checkoutStatus.isDirty, + aheadBehind: checkoutStatus.aheadBehind, + aheadOfOrigin: checkoutStatus.aheadOfOrigin, + behindOfOrigin: checkoutStatus.behindOfOrigin, + diffStat, + }, + github, + }; +} + +async function loadGitHubSnapshot(options: { + cwd: string; + remoteUrl: string | null; + now: Date; + deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "resolveGhPath">; +}): Promise<WorkspaceGitRuntimeSnapshot["github"]> { + if (!hasGitHubRemoteUrl(options.remoteUrl)) { + return { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }; + } + + try { + await options.deps.resolveGhPath(); + } catch { + return { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }; + } + + try { + const result = await options.deps.getPullRequestStatus(options.cwd); + return { + featuresEnabled: true, + pullRequest: result.status, + error: null, + refreshedAt: options.now.toISOString(), + }; + } catch (error) { + return { + featuresEnabled: true, + pullRequest: null, + error: { + message: error instanceof Error ? error.message : String(error), + }, + refreshedAt: options.now.toISOString(), + }; + } +} + +function hasGitHubRemoteUrl(remoteUrl: string | null): boolean { + if (!remoteUrl) { + return false; + } + + return ( + remoteUrl.includes("github.com/") || + remoteUrl.startsWith("git@github.com:") || + remoteUrl.startsWith("ssh://git@github.com/") + ); +} + +function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { + return { + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }; +} + +async function runGitFetch(cwd: string): Promise<void> { + await execFileAsync("git", ["fetch", "origin", "--prune"], { + cwd, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + }, + }); +} diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 025b9628e..d56daef28 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -30,10 +30,7 @@ import { getWorktreeSetupProgressResults, } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import { - getCheckoutStatusLite, - resolveRepositoryDefaultBranch, -} from "../utils/checkout-git.js"; +import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { computeWorktreePath, @@ -320,10 +317,7 @@ export function assertSafeGitRef(ref: string, label: string): void { } } -export async function resolveGitCreateBaseBranch( - cwd: string, - paseoHome?: string, -): Promise<string> { +export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise<string> { const checkout = await getCheckoutStatusLite(cwd, { paseoHome }); if (!checkout.isGit) { throw new Error("Cannot create a worktree outside a git repository"); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 539f8ec35..1a3fcfaf1 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -47,6 +47,29 @@ import { LoopLogsResponseSchema, LoopStopResponseSchema, } from "../server/loop/rpc-schemas.js"; +// --------------------------------------------------------------------------- +// Mutable daemon config schemas (shared between server store and client) +// --------------------------------------------------------------------------- + +export const MutableDaemonConfigSchema = z + .object({ + mcp: z + .object({ + injectIntoAgents: z.boolean(), + }) + .passthrough(), + }) + .passthrough(); + +export const MutableDaemonConfigPatchSchema = z + .object({ + mcp: MutableDaemonConfigSchema.shape.mcp.partial().optional(), + }) + .partial() + .passthrough(); + +export type MutableDaemonConfig = z.infer<typeof MutableDaemonConfigSchema>; +export type MutableDaemonConfigPatch = z.infer<typeof MutableDaemonConfigPatchSchema>; import type { LiteralUnion } from "./literal-union.js"; import type { AgentCapabilityFlags, @@ -762,6 +785,17 @@ export const WaitForFinishRequestSchema = z.object({ timeoutMs: z.number().int().positive().optional(), }); +export const GetDaemonConfigRequestMessageSchema = z.object({ + type: z.literal("get_daemon_config_request"), + requestId: z.string(), +}); + +export const SetDaemonConfigRequestMessageSchema = z.object({ + type: z.literal("set_daemon_config_request"), + requestId: z.string(), + config: MutableDaemonConfigPatchSchema, +}); + // ============================================================================ // Dictation Streaming (lossless, resumable) // ============================================================================ @@ -1472,6 +1506,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, WaitForFinishRequestSchema, + GetDaemonConfigRequestMessageSchema, + SetDaemonConfigRequestMessageSchema, DictationStreamStartMessageSchema, DictationStreamChunkMessageSchema, DictationStreamFinishMessageSchema, @@ -1810,6 +1846,13 @@ export const ShutdownRequestedStatusPayloadSchema = z.object({ requestId: z.string(), }); +export const DaemonConfigChangedStatusPayloadSchema = z + .object({ + status: z.literal("daemon_config_changed"), + config: MutableDaemonConfigSchema, + }) + .passthrough(); + export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ AgentCreatedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema, @@ -1817,6 +1860,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ AgentRefreshedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema, RestartRequestedStatusPayloadSchema, + DaemonConfigChangedStatusPayloadSchema, ]); export type KnownStatusPayload = z.infer<typeof KnownStatusPayloadSchema>; @@ -1903,6 +1947,50 @@ export const WorkspaceScriptPayloadSchema = z.object({ exitCode: z.number().nullable().optional().default(null), }); +const WorkspaceGitRuntimePayloadSchema = z + .object({ + currentBranch: z.string().nullable().optional(), + remoteUrl: z.string().nullable().optional(), + isPaseoOwnedWorktree: z.boolean().optional(), + isDirty: z.boolean().nullable().optional(), + aheadBehind: z + .object({ + ahead: z.number(), + behind: z.number(), + }) + .nullable() + .optional(), + aheadOfOrigin: z.number().nullable().optional(), + behindOfOrigin: z.number().nullable().optional(), + }) + .optional() + .nullable(); + +const WorkspaceGitHubRuntimePayloadSchema = z + .object({ + featuresEnabled: z.boolean().optional(), + pullRequest: z + .object({ + url: z.string(), + title: z.string(), + state: z.string(), + baseRefName: z.string(), + headRefName: z.string(), + isMerged: z.boolean(), + }) + .nullable() + .optional(), + error: z + .object({ + message: z.string(), + }) + .nullable() + .optional(), + refreshedAt: z.string().nullable().optional(), + }) + .optional() + .nullable(); + export const WorkspaceDescriptorPayloadSchema = z.object({ id: z.union([z.string(), z.number()]).transform(String), projectId: z.union([z.string(), z.number()]).transform(String), @@ -1923,6 +2011,8 @@ export const WorkspaceDescriptorPayloadSchema = z.object({ .nullable() .optional(), scripts: z.array(WorkspaceScriptPayloadSchema).default([]), + gitRuntime: WorkspaceGitRuntimePayloadSchema, + githubRuntime: WorkspaceGitHubRuntimePayloadSchema, }); export const AgentUpdateMessageSchema = z.object({ @@ -2199,6 +2289,26 @@ export const WaitForFinishResponseMessageSchema = z.object({ }), }); +export const GetDaemonConfigResponseMessageSchema = z.object({ + type: z.literal("get_daemon_config_response"), + payload: z + .object({ + requestId: z.string(), + config: MutableDaemonConfigSchema, + }) + .passthrough(), +}); + +export const SetDaemonConfigResponseMessageSchema = z.object({ + type: z.literal("set_daemon_config_response"), + payload: z + .object({ + requestId: z.string(), + config: MutableDaemonConfigSchema, + }) + .passthrough(), +}); + export const AgentPermissionRequestMessageSchema = z.object({ type: z.literal("agent_permission_request"), payload: z.object({ @@ -2856,6 +2966,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentTimelineResponseMessageSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, + GetDaemonConfigResponseMessageSchema, + SetDaemonConfigResponseMessageSchema, SetAgentModeResponseMessageSchema, SetAgentModelResponseMessageSchema, SetAgentThinkingResponseMessageSchema, @@ -3047,9 +3159,7 @@ export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>; export type ListProviderModelsRequestMessage = z.infer< typeof ListProviderModelsRequestMessageSchema >; -export type ListProviderModesRequestMessage = z.infer< - typeof ListProviderModesRequestMessageSchema ->; +export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>; export type ListProviderFeaturesRequestMessage = z.infer< typeof ListProviderFeaturesRequestMessageSchema >; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 6cedf4659..05c863065 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { describe, expect, test } from "vitest"; import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; @@ -284,6 +285,162 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("workspace_setup_status_response"); }); + test("parses fetch_workspaces_response with optional runtime fields", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "fetch_workspaces_response", + payload: { + requestId: "req-workspaces", + entries: [ + { + id: "/tmp/repo", + projectId: "remote:github.com/acme/repo", + projectDisplayName: "acme/repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: { + additions: 3, + deletions: 1, + }, + gitRuntime: { + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: true, + aheadBehind: { + ahead: 2, + behind: 1, + }, + aheadOfOrigin: 2, + behindOfOrigin: 1, + }, + githubRuntime: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "Runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: false, + }, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }, + ], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, + }, + }, + }); + + expect(parsed.type).toBe("fetch_workspaces_response"); + expect(parsed.payload.entries[0]?.gitRuntime).toMatchObject({ + currentBranch: "main", + isDirty: true, + aheadOfOrigin: 2, + }); + expect(parsed.payload.entries[0]?.githubRuntime?.pullRequest?.title).toBe("Runtime payloads"); + }); + + test("older workspace parsers ignore additive runtime fields", () => { + const message = { + type: "fetch_workspaces_response", + payload: { + requestId: "req-workspaces", + entries: [ + { + id: "/tmp/repo", + projectId: "remote:github.com/acme/repo", + projectDisplayName: "acme/repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: null, + gitRuntime: { + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + aheadBehind: { + ahead: 0, + behind: 0, + }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + }, + githubRuntime: { + featuresEnabled: true, + pullRequest: null, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }, + ], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, + }, + }, + }; + + const legacyWorkspaceSchema = z.object({ + id: z.string(), + projectId: z.string(), + projectDisplayName: z.string(), + projectRootPath: z.string(), + projectKind: z.enum(["git", "non_git"]), + workspaceKind: z.enum(["local_checkout", "worktree", "directory"]), + name: z.string(), + status: z.enum(["needs_input", "failed", "running", "attention", "done"]), + activityAt: z.string().nullable(), + diffStat: z + .object({ + additions: z.number(), + deletions: z.number(), + }) + .nullable() + .optional(), + }); + const legacyMessageSchema = z.object({ + type: z.literal("fetch_workspaces_response"), + payload: z.object({ + requestId: z.string(), + entries: z.array(legacyWorkspaceSchema), + pageInfo: z.object({ + nextCursor: z.string().nullable(), + prevCursor: z.string().nullable(), + hasMore: z.boolean(), + }), + }), + }); + + const parsed = legacyMessageSchema.parse(message); + + expect(parsed.payload.entries[0]).toEqual({ + id: "/tmp/repo", + projectId: "remote:github.com/acme/repo", + projectDisplayName: "acme/repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: null, + }); + }); + test("parses legacy fetch_agents_response checkout payloads without worktreeRoot", () => { const result = SessionOutboundMessageSchema.safeParse({ type: "fetch_agents_response", diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts index 8d36e962a..bece79125 100644 --- a/packages/server/src/shared/tool-call-display.test.ts +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -142,6 +142,46 @@ describe("shared tool-call display mapping", () => { }); }); + it("humanizes Paseo MCP tool names (Claude Code format)", () => { + const display = buildToolCallDisplayModel({ + name: "mcp__paseo__create_agent", + status: "running", + error: null, + detail: { type: "unknown", input: null, output: null }, + }); + expect(display.displayName).toBe("Create Agent"); + }); + + it("humanizes Paseo MCP tool names (Codex format)", () => { + const display = buildToolCallDisplayModel({ + name: "paseo.create_agent", + status: "running", + error: null, + detail: { type: "unknown", input: null, output: null }, + }); + expect(display.displayName).toBe("Create Agent"); + }); + + it("humanizes list_agents Paseo tool", () => { + const display = buildToolCallDisplayModel({ + name: "mcp__paseo__list_agents", + status: "running", + error: null, + detail: { type: "unknown", input: null, output: null }, + }); + expect(display.displayName).toBe("List Agents"); + }); + + it("does not override speak tool display name", () => { + const display = buildToolCallDisplayModel({ + name: "speak", + status: "running", + error: null, + detail: { type: "unknown", input: null, output: null }, + }); + expect(display.displayName).toBe("Speak"); + }); + it("labels plan detail rows as Plan", () => { const display = buildToolCallDisplayModel({ name: "plan", diff --git a/packages/server/src/shared/tool-call-display.ts b/packages/server/src/shared/tool-call-display.ts index 899c54d0b..a407d34b4 100644 --- a/packages/server/src/shared/tool-call-display.ts +++ b/packages/server/src/shared/tool-call-display.ts @@ -1,4 +1,5 @@ import type { ToolCallTimelineItem } from "../server/agent/agent-sdk-types.js"; +import { getPaseoToolLeafName, isPaseoToolName } from "../server/agent/tool-name-normalization.js"; import { stripCwdPrefix } from "./path-utils.js"; export type ToolCallDisplayInput = Pick< @@ -32,6 +33,12 @@ function humanizeToolName(name: string): string { if (!trimmed) { return name; } + if (isPaseoToolName(trimmed)) { + const leaf = getPaseoToolLeafName(trimmed); + if (leaf) { + return humanizeToolName(leaf); + } + } if (/[:./]/.test(trimmed) || trimmed.includes("__")) { return trimmed; } diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 9e8c6d4f9..e75b4b08f 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -142,10 +142,7 @@ export function ensureNodePtySpawnHelperExecutableForCurrentPlatform( } export function resolveDefaultTerminalShell( - options: { - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; - } = {}, + options: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv } = {}, ): string { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; @@ -455,7 +452,10 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] { } function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string { - const text = cells.map((cell) => cell.char).join("").trimEnd(); + const text = cells + .map((cell) => cell.char) + .join("") + .trimEnd(); return options.stripAnsi ? stripAnsi(text) : text; } diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 55a6b1fc0..136748676 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -617,7 +617,9 @@ const x = 1; writeFileSync(join(repoDir, "conflict.txt"), "local\n"); execSync("git add conflict.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", { + cwd: repoDir, + }); const otherClone = join(tempDir, "other-clone"); execSync(`git clone ${remoteDir} ${otherClone}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index f77212421..e2781e0b9 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -191,7 +191,6 @@ type CheckoutFileChange = { isUntracked?: boolean; }; - function normalizeBranchSuggestionName(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { @@ -678,7 +677,7 @@ async function requireGitRepo(cwd: string): Promise<void> { } } -async function getCurrentBranch(cwd: string): Promise<string | null> { +export async function getCurrentBranch(cwd: string): Promise<string | null> { const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd, env: READ_ONLY_GIT_ENV, @@ -700,7 +699,7 @@ async function getWorktreeRoot(cwd: string): Promise<string | null> { } } -async function getMainRepoRoot(cwd: string): Promise<string> { +export async function getMainRepoRoot(cwd: string): Promise<string> { const { stdout: commonDirOut } = await execAsync( "git rev-parse --path-format=absolute --git-common-dir", { cwd, env: READ_ONLY_GIT_ENV }, @@ -717,9 +716,7 @@ async function getMainRepoRoot(cwd: string): Promise<string> { env: READ_ONLY_GIT_ENV, }); const worktrees = parseWorktreeList(worktreeOut); - const nonBareNonPaseo = worktrees.filter( - (wt) => !wt.isBare && !isPaseoWorktreePath(wt.path), - ); + const nonBareNonPaseo = worktrees.filter((wt) => !wt.isBare && !isPaseoWorktreePath(wt.path)); const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized)); const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main"); return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized; @@ -844,7 +841,7 @@ async function isWorkingTreeDirty(cwd: string): Promise<boolean> { return stdout.trim().length > 0; } -async function getOriginRemoteUrl(cwd: string): Promise<string | null> { +export async function getOriginRemoteUrl(cwd: string): Promise<string | null> { try { const { stdout } = await execAsync("git config --get remote.origin.url", { cwd, @@ -857,12 +854,12 @@ async function getOriginRemoteUrl(cwd: string): Promise<string | null> { } } -async function hasOriginRemote(cwd: string): Promise<boolean> { +export async function hasOriginRemote(cwd: string): Promise<boolean> { const url = await getOriginRemoteUrl(cwd); return url !== null; } -async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> { +export async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> { try { const { stdout } = await execAsync("git rev-parse --absolute-git-dir", { cwd, @@ -1042,7 +1039,16 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num const count = Number.parseInt(stdout.trim(), 10); return Number.isNaN(count) ? null : count; } catch { - return null; + try { + const { stdout } = await execAsync(`git rev-list --count ${currentBranch}`, { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const count = Number.parseInt(stdout.trim(), 10); + return Number.isNaN(count) ? null : count; + } catch { + return null; + } } } @@ -1630,11 +1636,7 @@ export async function getCheckoutDiff( if (diffBytes >= TOTAL_DIFF_MAX_BYTES) { break; } - const { text, truncated, stat } = await getUntrackedDiffText( - cwd, - change, - ignoreWhitespace, - ); + const { text, truncated, stat } = await getUntrackedDiffText(cwd, change, ignoreWhitespace); if (!compare.includeStructured) { if (stat?.isBinary) { @@ -2015,7 +2017,7 @@ type CheckRunNode = z.infer<typeof CheckRunNodeSchema>; type StatusContextNode = z.infer<typeof StatusContextNodeSchema>; -async function resolveGhPath(): Promise<string> { +export async function resolveGhPath(): Promise<string> { if (cachedGhPath === undefined) { cachedGhPath = await findExecutable("gh"); } @@ -2238,7 +2240,6 @@ async function resolveGitHubRepo(cwd: string): Promise<string | null> { return null; } - export async function createPullRequest( cwd: string, options: CreatePullRequestOptions, @@ -2340,9 +2341,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta return { status: null, githubFeaturesEnabled: true }; } const mergedAt = - typeof pr.mergedAt === "string" && pr.mergedAt.trim().length > 0 - ? pr.mergedAt - : null; + typeof pr.mergedAt === "string" && pr.mergedAt.trim().length > 0 ? pr.mergedAt : null; const state = mergedAt !== null ? "merged" diff --git a/packages/server/src/utils/executable.test.ts b/packages/server/src/utils/executable.test.ts index db8d7c0cb..31bbfb2bf 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -1,10 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { - findExecutableSync, - quoteWindowsArgument, - quoteWindowsCommand, -} from "./executable.js"; +import { findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js"; type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutableSync>[1]>; @@ -46,20 +42,18 @@ describe("findExecutableSync", () => { "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", ); - expect(findExecutableSync("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex"); + expect(findExecutableSync("codex", findExecutableDependencies)).toBe( + "C:\\nvm4w\\nodejs\\codex", + ); }); test("on Unix, uses the last line from which output", () => { - findExecutableDependencies.execFileSync.mockReturnValue( - "/usr/local/bin/codex\n", - ); + findExecutableDependencies.execFileSync.mockReturnValue("/usr/local/bin/codex\n"); expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex"); - expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith( - "which", - ["codex"], - { encoding: "utf8" }, - ); + expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith("which", ["codex"], { + encoding: "utf8", + }); }); test("warns and returns null when the final which line is not an absolute path", () => { diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index ad9b400b3..25a91acab 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -66,10 +66,12 @@ export function findExecutableSync( if (deps.platform() === "win32") { try { - const out = deps.execFileSync("where.exe", [trimmed], { - encoding: "utf8", - windowsHide: true, - }).trim(); + const out = deps + .execFileSync("where.exe", [trimmed], { + encoding: "utf8", + windowsHide: true, + }) + .trim(); return ( out .split(/\r?\n/) @@ -160,4 +162,3 @@ export function quoteWindowsArgument(argument: string): string { if (argument.startsWith('"') && argument.endsWith('"')) return argument; return `"${argument}"`; } - diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index 21898b8cb..32c1d911d 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,8 +1,4 @@ -import { - spawn, - type ChildProcess, - type SpawnOptions, -} from "node:child_process"; +import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; /** * Platform-aware spawn that centralizes Windows shell and quoting concerns. diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index f54462c9d..fdd7d503b 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -13,9 +13,7 @@ import { runWorktreeSetupCommands, slugify, } from "./worktree"; -import { - getPaseoWorktreeMetadataPath, -} from "./worktree-metadata.js"; +import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { execSync } from "child_process"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; import { dirname, join } from "path"; @@ -161,6 +159,7 @@ describe("createWorktree", () => { execSync(`git clone ${remoteDir} ${remoteCloneDir}`); execSync("git config user.email 'test@test.com'", { cwd: remoteCloneDir }); execSync("git config user.name 'Test'", { cwd: remoteCloneDir }); + execSync("git checkout -B main origin/main", { cwd: remoteCloneDir }); writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n"); execSync("git add file.txt", { cwd: remoteCloneDir }); execSync("git -c commit.gpgsign=false commit -m 'advance origin main'", { diff --git a/packages/website/package.json b/packages/website/package.json index 7b7582fba..be6e565fd 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.52", + "version": "0.1.54", "private": true, "type": "module", "scripts": { diff --git a/packages/website/src/components/command-dialog.tsx b/packages/website/src/components/command-dialog.tsx index 245b71d12..da73184d9 100644 --- a/packages/website/src/components/command-dialog.tsx +++ b/packages/website/src/components/command-dialog.tsx @@ -59,14 +59,10 @@ export function CommandDialog({ > <div className="space-y-2"> <p className="text-base font-medium text-white">{title}</p> - {description && ( - <p className="text-sm text-muted-foreground">{description}</p> - )} + {description && <p className="text-sm text-muted-foreground">{description}</p>} </div> <CodeBlock>{command}</CodeBlock> - {footnote && ( - <p className="text-xs text-white/30">{footnote}</p> - )} + {footnote && <p className="text-xs text-white/30">{footnote}</p>} </motion.div> </> )} diff --git a/packages/website/src/components/hero-mockup.tsx b/packages/website/src/components/hero-mockup.tsx index 613f7f856..9746143e2 100644 --- a/packages/website/src/components/hero-mockup.tsx +++ b/packages/website/src/components/hero-mockup.tsx @@ -58,7 +58,12 @@ const CHAT: ChatItem[] = [ { type: "text", text: "I'll break this down into planning and implementation.", bold: true }, { type: "tool", label: "Run plan-technical", summary: "codex · plan-technical", status: "done" }, { type: "tool", label: "Run plan-design", summary: "claude · plan-design", status: "done" }, - { type: "tool", label: "Wait for agents", summary: "plan-technical plan-design", status: "done" }, + { + type: "tool", + label: "Wait for agents", + summary: "plan-technical plan-design", + status: "done", + }, { type: "text", text: "Got the plans. Spinning up Codex for implementation.", bold: true }, { type: "tool", label: "Run implement", summary: "codex · 12 files changed", status: "done" }, { type: "text", text: "Implementation done. Requesting review from Claude." }, @@ -88,8 +93,19 @@ const SIDEBAR_PROJECTS: SidebarProject[] = [ initial: "A", name: "acme/returns-app", workspaces: [ - { name: "main", kind: "checkout", status: "syncing", selected: true, diffStat: { additions: 247, deletions: 15 } }, - { name: "feat/dashboard", kind: "worktree", status: "done", pr: { number: 142, state: "open" } }, + { + name: "main", + kind: "checkout", + status: "syncing", + selected: true, + diffStat: { additions: 247, deletions: 15 }, + }, + { + name: "feat/dashboard", + kind: "worktree", + status: "done", + pr: { number: 142, state: "open" }, + }, ], }, { @@ -97,7 +113,13 @@ const SIDEBAR_PROJECTS: SidebarProject[] = [ name: "acme/payments", workspaces: [ { name: "main", kind: "checkout", status: "idle" }, - { name: "fix/stripe-webhook", kind: "worktree", status: "done", diffStat: { additions: 38, deletions: 4 }, pr: { number: 89, state: "merged" } }, + { + name: "fix/stripe-webhook", + kind: "worktree", + status: "done", + diffStat: { additions: 38, deletions: 4 }, + pr: { number: 89, state: "merged" }, + }, ], }, { @@ -105,15 +127,18 @@ const SIDEBAR_PROJECTS: SidebarProject[] = [ name: "acme/infra", workspaces: [ { name: "main", kind: "checkout", status: "idle" }, - { name: "feat/k8s-autoscale", kind: "worktree", status: "syncing", diffStat: { additions: 91, deletions: 3 } }, + { + name: "feat/k8s-autoscale", + kind: "worktree", + status: "syncing", + diffStat: { additions: 91, deletions: 3 }, + }, ], }, { initial: "D", name: "acme/design-system", - workspaces: [ - { name: "main", kind: "checkout", status: "idle" }, - ], + workspaces: [{ name: "main", kind: "checkout", status: "idle" }], }, ]; @@ -143,20 +168,145 @@ type DiffLine = { }; const DIFF_LINES: DiffLine[] = [ - { type: "add", ln: "1", tokens: [{ text: "import", cls: "text-syn-keyword" }, { text: " { ", cls: "text-syn-punctuation" }, { text: "useState", cls: "text-syn-variable" }, { text: " } ", cls: "text-syn-punctuation" }, { text: "from", cls: "text-syn-keyword" }, { text: ' "react"', cls: "text-syn-string" }] }, - { type: "add", ln: "2", tokens: [{ text: "import", cls: "text-syn-keyword" }, { text: " { ", cls: "text-syn-punctuation" }, { text: "ReturnTable", cls: "text-syn-variable" }, { text: " } ", cls: "text-syn-punctuation" }, { text: "from", cls: "text-syn-keyword" }, { text: ' "./components"', cls: "text-syn-string" }] }, + { + type: "add", + ln: "1", + tokens: [ + { text: "import", cls: "text-syn-keyword" }, + { text: " { ", cls: "text-syn-punctuation" }, + { text: "useState", cls: "text-syn-variable" }, + { text: " } ", cls: "text-syn-punctuation" }, + { text: "from", cls: "text-syn-keyword" }, + { text: ' "react"', cls: "text-syn-string" }, + ], + }, + { + type: "add", + ln: "2", + tokens: [ + { text: "import", cls: "text-syn-keyword" }, + { text: " { ", cls: "text-syn-punctuation" }, + { text: "ReturnTable", cls: "text-syn-variable" }, + { text: " } ", cls: "text-syn-punctuation" }, + { text: "from", cls: "text-syn-keyword" }, + { text: ' "./components"', cls: "text-syn-string" }, + ], + }, { type: "add", ln: "3", tokens: [] }, - { type: "add", ln: "4", tokens: [{ text: "export", cls: "text-syn-keyword" }, { text: " function", cls: "text-syn-keyword" }, { text: " Dashboard", cls: "text-syn-function" }, { text: "() {", cls: "text-syn-punctuation" }] }, - { type: "add", ln: "5", tokens: [{ text: " const", cls: "text-syn-keyword" }, { text: " [returns, setReturns]", cls: "text-syn-variable" }, { text: " = ", cls: "text-syn-operator" }, { text: "useState", cls: "text-syn-function" }, { text: "([])", cls: "text-syn-punctuation" }] }, - { type: "add", ln: "6", tokens: [{ text: " const", cls: "text-syn-keyword" }, { text: " [filter, setFilter]", cls: "text-syn-variable" }, { text: " = ", cls: "text-syn-operator" }, { text: "useState", cls: "text-syn-function" }, { text: '("all")', cls: "text-syn-string" }] }, + { + type: "add", + ln: "4", + tokens: [ + { text: "export", cls: "text-syn-keyword" }, + { text: " function", cls: "text-syn-keyword" }, + { text: " Dashboard", cls: "text-syn-function" }, + { text: "() {", cls: "text-syn-punctuation" }, + ], + }, + { + type: "add", + ln: "5", + tokens: [ + { text: " const", cls: "text-syn-keyword" }, + { text: " [returns, setReturns]", cls: "text-syn-variable" }, + { text: " = ", cls: "text-syn-operator" }, + { text: "useState", cls: "text-syn-function" }, + { text: "([])", cls: "text-syn-punctuation" }, + ], + }, + { + type: "add", + ln: "6", + tokens: [ + { text: " const", cls: "text-syn-keyword" }, + { text: " [filter, setFilter]", cls: "text-syn-variable" }, + { text: " = ", cls: "text-syn-operator" }, + { text: "useState", cls: "text-syn-function" }, + { text: '("all")', cls: "text-syn-string" }, + ], + }, { type: "add", ln: "7", tokens: [] }, - { type: "add", ln: "8", tokens: [{ text: " ", cls: "text-syn-punctuation" }, { text: "return", cls: "text-syn-keyword" }, { text: " (", cls: "text-syn-punctuation" }] }, - { type: "add", ln: "9", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "main", cls: "text-syn-tag" }, { text: " className", cls: "text-syn-property" }, { text: '="', cls: "text-syn-punctuation" }, { text: "min-h-screen p-8", cls: "text-syn-string" }, { text: '">', cls: "text-syn-punctuation" }] }, - { type: "add", ln: "10", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "h1", cls: "text-syn-tag" }, { text: ">Customer Returns</", cls: "text-syn-variable" }, { text: "h1", cls: "text-syn-tag" }, { text: ">", cls: "text-syn-punctuation" }] }, - { type: "add", ln: "11", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "FilterBar", cls: "text-syn-tag" }, { text: " value", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "filter", cls: "text-syn-variable" }, { text: "}", cls: "text-syn-punctuation" }, { text: " onChange", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "setFilter", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] }, - { type: "add", ln: "12", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "ReturnTable", cls: "text-syn-tag" }, { text: " data", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "returns", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] }, - { type: "add", ln: "13", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "StatusChart", cls: "text-syn-tag" }, { text: " data", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "returns", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] }, - { type: "add", ln: "14", tokens: [{ text: " </", cls: "text-syn-punctuation" }, { text: "main", cls: "text-syn-tag" }, { text: ">", cls: "text-syn-punctuation" }] }, + { + type: "add", + ln: "8", + tokens: [ + { text: " ", cls: "text-syn-punctuation" }, + { text: "return", cls: "text-syn-keyword" }, + { text: " (", cls: "text-syn-punctuation" }, + ], + }, + { + type: "add", + ln: "9", + tokens: [ + { text: " <", cls: "text-syn-punctuation" }, + { text: "main", cls: "text-syn-tag" }, + { text: " className", cls: "text-syn-property" }, + { text: '="', cls: "text-syn-punctuation" }, + { text: "min-h-screen p-8", cls: "text-syn-string" }, + { text: '">', cls: "text-syn-punctuation" }, + ], + }, + { + type: "add", + ln: "10", + tokens: [ + { text: " <", cls: "text-syn-punctuation" }, + { text: "h1", cls: "text-syn-tag" }, + { text: ">Customer Returns</", cls: "text-syn-variable" }, + { text: "h1", cls: "text-syn-tag" }, + { text: ">", cls: "text-syn-punctuation" }, + ], + }, + { + type: "add", + ln: "11", + tokens: [ + { text: " <", cls: "text-syn-punctuation" }, + { text: "FilterBar", cls: "text-syn-tag" }, + { text: " value", cls: "text-syn-property" }, + { text: "={", cls: "text-syn-punctuation" }, + { text: "filter", cls: "text-syn-variable" }, + { text: "}", cls: "text-syn-punctuation" }, + { text: " onChange", cls: "text-syn-property" }, + { text: "={", cls: "text-syn-punctuation" }, + { text: "setFilter", cls: "text-syn-variable" }, + { text: "} />", cls: "text-syn-punctuation" }, + ], + }, + { + type: "add", + ln: "12", + tokens: [ + { text: " <", cls: "text-syn-punctuation" }, + { text: "ReturnTable", cls: "text-syn-tag" }, + { text: " data", cls: "text-syn-property" }, + { text: "={", cls: "text-syn-punctuation" }, + { text: "returns", cls: "text-syn-variable" }, + { text: "} />", cls: "text-syn-punctuation" }, + ], + }, + { + type: "add", + ln: "13", + tokens: [ + { text: " <", cls: "text-syn-punctuation" }, + { text: "StatusChart", cls: "text-syn-tag" }, + { text: " data", cls: "text-syn-property" }, + { text: "={", cls: "text-syn-punctuation" }, + { text: "returns", cls: "text-syn-variable" }, + { text: "} />", cls: "text-syn-punctuation" }, + ], + }, + { + type: "add", + ln: "14", + tokens: [ + { text: " </", cls: "text-syn-punctuation" }, + { text: "main", cls: "text-syn-tag" }, + { text: ">", cls: "text-syn-punctuation" }, + ], + }, { type: "add", ln: "15", tokens: [{ text: " )", cls: "text-syn-punctuation" }] }, { type: "add", ln: "16", tokens: [{ text: "}", cls: "text-syn-punctuation" }] }, ]; @@ -207,15 +357,27 @@ function TrafficLights() { ); } -function ProviderIcon({ provider, muted = false }: { provider: "claude" | "codex" | "terminal"; muted?: boolean }) { +function ProviderIcon({ + provider, + muted = false, +}: { + provider: "claude" | "codex" | "terminal"; + muted?: boolean; +}) { const cls = muted ? "text-mock-fg-muted" : "text-mock-fg"; if (provider === "terminal") return <SquareTerminal size={13} className={cls} />; - return provider === "claude" - ? <ClaudeIcon size={13} className={cls} /> - : <CodexIcon size={13} className={cls} />; + return provider === "claude" ? ( + <ClaudeIcon size={13} className={cls} /> + ) : ( + <CodexIcon size={13} className={cls} /> + ); } -function TabBarAction({ icon: Icon }: { icon: React.ComponentType<{ size?: number; className?: string; strokeWidth?: number }> }) { +function TabBarAction({ + icon: Icon, +}: { + icon: React.ComponentType<{ size?: number; className?: string; strokeWidth?: number }>; +}) { return ( <div className="w-4 h-5 flex items-center justify-center flex-shrink-0"> <Icon size={12} strokeWidth={1.5} className="text-mock-fg-muted" /> @@ -227,16 +389,23 @@ function PaneTabBar({ tabs, focused = false }: { tabs: TabDef[]; focused?: boole return ( <div className="flex items-stretch h-7 bg-mock-surface0 border-b border-mock-border flex-shrink-0"> {tabs.map((tab) => ( - <div key={tab.name} className="flex items-center gap-1.5 px-2 border-r border-mock-border relative min-w-0"> + <div + key={tab.name} + className="flex items-center gap-1.5 px-2 border-r border-mock-border relative min-w-0" + > {tab.active && ( - <div className={`absolute top-0 left-0 right-0 h-0.5 ${focused ? "bg-mock-accent" : "bg-mock-border-accent"}`} /> + <div + className={`absolute top-0 left-0 right-0 h-0.5 ${focused ? "bg-mock-accent" : "bg-mock-border-accent"}`} + /> )} <div className="relative flex-shrink-0"> <ProviderIcon provider={tab.provider} muted={!tab.active} /> </div> - <span className={`text-[11px] truncate ${tab.active ? "text-mock-fg" : "text-mock-fg-muted"}`}> + <span + className={`text-[11px] truncate ${tab.active ? "text-mock-fg" : "text-mock-fg-muted"}`} + > {tab.name} </span> @@ -253,7 +422,15 @@ function PaneTabBar({ tabs, focused = false }: { tabs: TabDef[]; focused?: boole ); } -function Composer({ provider, model, focused = false }: { provider: "claude" | "codex"; model: string; focused?: boolean }) { +function Composer({ + provider, + model, + focused = false, +}: { + provider: "claude" | "codex"; + model: string; + focused?: boolean; +}) { const Icon = provider === "claude" ? ClaudeIcon : CodexIcon; return ( <div className="px-3 pb-3 flex-shrink-0"> @@ -417,7 +594,9 @@ function ExplorerSidebar() { <span className="text-[10px] text-mock-green-400 px-2 py-[2px] rounded-md flex-shrink-0" style={{ backgroundColor: "rgba(46, 160, 67, 0.2)" }} - >New</span> + > + New + </span> </div> <div className="flex items-center gap-1 flex-shrink-0 ml-1"> <span className="text-[11px] text-mock-green-400">+16</span> @@ -430,17 +609,29 @@ function ExplorerSidebar() { {DIFF_LINES.map((line, i) => { const isAdd = line.type === "add"; const isRemove = line.type === "remove"; - const lineBg = isAdd ? "bg-mock-diff-add" : isRemove ? "bg-mock-diff-remove" : "bg-mock-surface1"; - const lineNumCls = isAdd ? "text-mock-green-400" : isRemove ? "text-mock-red" : "text-mock-fg-muted"; + const lineBg = isAdd + ? "bg-mock-diff-add" + : isRemove + ? "bg-mock-diff-remove" + : "bg-mock-surface1"; + const lineNumCls = isAdd + ? "text-mock-green-400" + : isRemove + ? "text-mock-red" + : "text-mock-fg-muted"; return ( <div key={i} className={`flex items-stretch ${lineBg}`}> <div className="w-8 border-r border-mock-border flex-shrink-0 flex items-center justify-end"> - <code className={`text-[10px] font-mono ${lineNumCls} select-none pr-2 py-[1px]`}>{line.ln ?? ""}</code> + <code className={`text-[10px] font-mono ${lineNumCls} select-none pr-2 py-[1px]`}> + {line.ln ?? ""} + </code> </div> <code className="text-[10px] font-mono text-mock-fg pl-3 pr-3 py-[1px] whitespace-pre flex-1 min-w-0"> {line.tokens?.map((tok, j) => ( - <span key={j} className={tok.cls}>{tok.text}</span> + <span key={j} className={tok.cls}> + {tok.text} + </span> ))} </code> </div> @@ -456,7 +647,10 @@ function ExplorerSidebar() { { name: "returns.ts", dir: "src/api", added: 12, removed: 5 }, { name: "index.tsx", dir: "src/pages", added: 6, removed: 2 }, ].map((file) => ( - <div key={file.name} className="flex items-center justify-between pl-2 pr-2 py-1.5 border-b border-mock-border"> + <div + key={file.name} + className="flex items-center justify-between pl-2 pr-2 py-1.5 border-b border-mock-border" + > <div className="flex items-center gap-1 flex-1 min-w-0 overflow-hidden"> <ChevronRight size={10} className="text-mock-fg-muted flex-shrink-0" /> <span className="text-[11px] text-mock-fg flex-shrink-0">{file.name}</span> @@ -465,7 +659,9 @@ function ExplorerSidebar() { <span className="text-[10px] text-mock-green-400 px-2 py-[2px] rounded-md flex-shrink-0" style={{ backgroundColor: "rgba(46, 160, 67, 0.2)" }} - >New</span> + > + New + </span> )} </div> <div className="flex items-center gap-1 flex-shrink-0 ml-1"> @@ -494,7 +690,16 @@ function SyncedLoader({ size = 11 }: { size?: number }) { const gridH = dotSize * 3 + gap * 2; return ( - <div style={{ width: size, height: size, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}> + <div + style={{ + width: size, + height: size, + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }} + > <div style={{ position: "relative", width: gridW, height: gridH }}> {Array.from({ length: DOT_COUNT }).map((_, dotIndex) => { const col = dotIndex % 2; @@ -560,7 +765,9 @@ function Sidebar() { {/* Project row — icon aligns with traffic lights / sessions */} <div className="flex items-center gap-2 min-h-[32px] py-1.5 pl-3 pr-2"> <div className="w-4 h-4 rounded-sm border border-mock-border flex items-center justify-center flex-shrink-0"> - <span className="text-[9px] text-mock-fg-muted leading-none">{project.initial}</span> + <span className="text-[9px] text-mock-fg-muted leading-none"> + {project.initial} + </span> </div> <span className="text-[13px] text-mock-fg font-normal truncate flex-1 min-w-0 leading-5"> {project.name} @@ -569,17 +776,21 @@ function Sidebar() { {/* Workspace rows — indented one level */} {project.workspaces.map((workspace) => ( - <div key={workspace.name} className={`mb-1 mx-1.5 rounded-lg ${workspace.selected ? "bg-mock-surface1" : ""}`}> + <div + key={workspace.name} + className={`mb-1 mx-1.5 rounded-lg ${workspace.selected ? "bg-mock-surface1" : ""}`} + > <div className="flex items-center gap-2 min-h-[28px] py-1 pl-[22px] pr-1"> <div className="relative w-[14px] h-4 flex-shrink-0 flex items-center justify-center"> {workspace.status === "syncing" ? ( <SyncedLoader size={11} /> ) : ( <> - {workspace.kind === "worktree" - ? <FolderGit2 size={14} className="text-mock-fg-muted" /> - : <Monitor size={14} className="text-mock-fg-muted" /> - } + {workspace.kind === "worktree" ? ( + <FolderGit2 size={14} className="text-mock-fg-muted" /> + ) : ( + <Monitor size={14} className="text-mock-fg-muted" /> + )} {workspace.status !== "idle" && ( <div className="absolute bottom-0 right-0"> <WorkspaceStatusDot status={workspace.status} /> @@ -593,8 +804,12 @@ function Sidebar() { </span> {workspace.diffStat && ( <div className="flex items-center gap-1 flex-shrink-0"> - <span className="text-[10px] text-mock-green-400 font-normal leading-none">+{workspace.diffStat.additions}</span> - <span className="text-[10px] text-mock-red font-normal leading-none">-{workspace.diffStat.deletions}</span> + <span className="text-[10px] text-mock-green-400 font-normal leading-none"> + +{workspace.diffStat.additions} + </span> + <span className="text-[10px] text-mock-red font-normal leading-none"> + -{workspace.diffStat.deletions} + </span> </div> )} </div> @@ -602,7 +817,12 @@ function Sidebar() { <div className="flex items-center gap-1 pl-[42px] pr-2 pb-0.5"> <GitPullRequest size={11} className="text-mock-fg-muted" /> <span className="text-[10px] text-mock-fg-muted leading-none truncate"> - #{workspace.pr.number} · {workspace.pr.state === "open" ? "Open" : workspace.pr.state === "merged" ? "Merged" : "Closed"} + #{workspace.pr.number} ·{" "} + {workspace.pr.state === "open" + ? "Open" + : workspace.pr.state === "merged" + ? "Merged" + : "Closed"} </span> </div> )} @@ -650,81 +870,94 @@ function DesktopMockup() { }, []); return ( - <div ref={containerRef} className="w-full overflow-hidden" style={{ height: `${1200 * (9 / 16) * scale}px` }}> + <div + ref={containerRef} + className="w-full overflow-hidden" + style={{ height: `${1200 * (9 / 16) * scale}px` }} + > <div className="mx-auto rounded-xl overflow-hidden border border-mock-border bg-mock-surface0 shadow-[6px_6px_0_rgba(0,0,0,0.4)] origin-top-left" style={{ width: 1200, transform: `scale(${scale})` }} > {/* Top-level: left sidebar | center column | explorer sidebar — all full height */} <div className="flex aspect-video"> - {/* Left sidebar — full height */} - <motion.div {...fade(D.sidebar)} className="contents"> - <Sidebar /> - </motion.div> - - {/* Center column: title bar + split panes */} - <div className="flex flex-col flex-1 min-w-0 min-h-0"> - {/* Title bar — belongs to center column only */} - <motion.div {...fade(D.titleBar)} className="flex items-center h-10 px-2 bg-mock-surface0 border-b border-mock-border flex-shrink-0"> - <div className="flex items-center gap-2 flex-1 min-w-0"> - <div className="px-2 py-1 rounded-lg flex items-center justify-center flex-shrink-0"> - <PanelLeft size={14} className="text-mock-fg-muted" /> - </div> - <div className="flex items-center gap-2 min-w-0 flex-1"> - <span className="text-[13px] font-light text-mock-fg truncate flex-shrink-0">main</span> - <span className="text-[13px] text-mock-fg-muted truncate flex-shrink min-w-0">acme/returns-app</span> - <div className="px-2 py-1 rounded-lg flex items-center justify-center flex-shrink-0"> - <Ellipsis size={14} className="text-mock-fg-muted" /> - </div> - </div> - </div> - - <div className="flex items-center gap-2 ml-auto flex-shrink-0"> - <div className="flex items-stretch rounded-md border border-mock-border-accent overflow-hidden"> - <div className="flex items-center gap-2 px-3 py-1"> - <GitCommitHorizontal size={12} className="text-mock-fg-muted flex-shrink-0" /> - <span className="text-[11px] text-mock-fg font-normal">Commit</span> - </div> - <div className="flex items-center justify-center w-7 border-l border-mock-border-accent"> - <ChevronDown size={12} className="text-mock-fg-muted" /> - </div> - </div> - <div className="flex items-center gap-2 px-3 py-1 rounded-md"> - <SourceControlIcon size={14} className="text-mock-fg-muted" /> - <span className="text-[11px] font-normal text-mock-green-400">+247</span> - <span className="text-[11px] font-normal text-mock-red">-15</span> - </div> - </div> + {/* Left sidebar — full height */} + <motion.div {...fade(D.sidebar)} className="contents"> + <Sidebar /> </motion.div> - {/* Split panes */} - <div className="flex flex-1 min-h-0"> - {/* Left pane: all agent tabs + chat */} - <div className="flex flex-col flex-1 min-w-0 min-h-0"> - <motion.div {...fade(D.tabs)}> - <PaneTabBar tabs={TABS} focused /> - </motion.div> - <motion.div {...fade(D.chat)} className="flex-1 flex min-h-0"> - <ChatArea /> + {/* Center column: title bar + split panes */} + <div className="flex flex-col flex-1 min-w-0 min-h-0"> + {/* Title bar — belongs to center column only */} + <motion.div + {...fade(D.titleBar)} + className="flex items-center h-10 px-2 bg-mock-surface0 border-b border-mock-border flex-shrink-0" + > + <div className="flex items-center gap-2 flex-1 min-w-0"> + <div className="px-2 py-1 rounded-lg flex items-center justify-center flex-shrink-0"> + <PanelLeft size={14} className="text-mock-fg-muted" /> + </div> + <div className="flex items-center gap-2 min-w-0 flex-1"> + <span className="text-[13px] font-light text-mock-fg truncate flex-shrink-0"> + main + </span> + <span className="text-[13px] text-mock-fg-muted truncate flex-shrink min-w-0"> + acme/returns-app + </span> + <div className="px-2 py-1 rounded-lg flex items-center justify-center flex-shrink-0"> + <Ellipsis size={14} className="text-mock-fg-muted" /> + </div> + </div> + </div> + + <div className="flex items-center gap-2 ml-auto flex-shrink-0"> + <div className="flex items-stretch rounded-md border border-mock-border-accent overflow-hidden"> + <div className="flex items-center gap-2 px-3 py-1"> + <GitCommitHorizontal size={12} className="text-mock-fg-muted flex-shrink-0" /> + <span className="text-[11px] text-mock-fg font-normal">Commit</span> + </div> + <div className="flex items-center justify-center w-7 border-l border-mock-border-accent"> + <ChevronDown size={12} className="text-mock-fg-muted" /> + </div> + </div> + <div className="flex items-center gap-2 px-3 py-1 rounded-md"> + <SourceControlIcon size={14} className="text-mock-fg-muted" /> + <span className="text-[11px] font-normal text-mock-green-400">+247</span> + <span className="text-[11px] font-normal text-mock-red">-15</span> + </div> + </div> + </motion.div> + + {/* Split panes */} + <div className="flex flex-1 min-h-0"> + {/* Left pane: all agent tabs + chat */} + <div className="flex flex-col flex-1 min-w-0 min-h-0"> + <motion.div {...fade(D.tabs)}> + <PaneTabBar tabs={TABS} focused /> + </motion.div> + <motion.div {...fade(D.chat)} className="flex-1 flex min-h-0"> + <ChatArea /> + </motion.div> + </div> + + {/* Resize handle */} + <div className="w-px bg-mock-border flex-shrink-0" /> + + {/* Right pane: terminal only */} + <motion.div {...fade(D.chat)} className="flex flex-col flex-1 min-w-0 min-h-0"> + <PaneTabBar + tabs={[{ name: "npm run dev", provider: "terminal", done: false, active: true }]} + /> + <TerminalPane /> </motion.div> </div> - - {/* Resize handle */} - <div className="w-px bg-mock-border flex-shrink-0" /> - - {/* Right pane: terminal only */} - <motion.div {...fade(D.chat)} className="flex flex-col flex-1 min-w-0 min-h-0"> - <PaneTabBar tabs={[{ name: "npm run dev", provider: "terminal", done: false, active: true }]} /> - <TerminalPane /> - </motion.div> </div> - </div> - {/* Explorer sidebar — full height, pushes center column */} - <motion.div {...fade(D.diffPanel)} className="contents"> - <ExplorerSidebar /> - </motion.div> - </div> + {/* Explorer sidebar — full height, pushes center column */} + <motion.div {...fade(D.diffPanel)} className="contents"> + <ExplorerSidebar /> + </motion.div> + </div> </div> </div> ); diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 727926fd1..074e2e9db 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -1,5 +1,12 @@ import * as React from "react"; -import { motion, AnimatePresence, useInView, useScroll, useTransform, useMotionValueEvent } from "framer-motion"; +import { + motion, + AnimatePresence, + useInView, + useScroll, + useTransform, + useMotionValueEvent, +} from "framer-motion"; import { CursorFieldProvider } from "~/components/butterfly"; import { CommandDialog } from "~/components/command-dialog"; import { @@ -30,7 +37,6 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { <CursorFieldProvider> {/* Hero section with background image */} <div className="relative bg-cover bg-center bg-no-repeat"> - <div className="relative p-6 pb-10 md:px-32 md:pt-20 md:pb-12 max-w-7xl mx-auto"> <Nav /> <Hero title={title} subtitle={subtitle} /> @@ -48,7 +54,6 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { <HeroMockup /> </div> </motion.div> - </div> {/* Phone showcase */} @@ -335,17 +340,88 @@ function MultiProviderSection() { function SelfHostedDiagram() { const clients = [ - { name: "Desktop", icon: <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" /></svg> }, - { name: "Web", icon: <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg> }, - { name: "Mobile", icon: <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" /><path d="M12 18h.01" /></svg> }, - { name: "CLI", icon: <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></svg> }, + { + name: "Desktop", + icon: ( + <svg + width="28" + height="28" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + > + <rect x="2" y="3" width="20" height="14" rx="2" /> + <path d="M8 21h8M12 17v4" /> + </svg> + ), + }, + { + name: "Web", + icon: ( + <svg + width="28" + height="28" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="10" /> + <path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /> + </svg> + ), + }, + { + name: "Mobile", + icon: ( + <svg + width="28" + height="28" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + > + <rect x="5" y="2" width="14" height="20" rx="2" /> + <path d="M12 18h.01" /> + </svg> + ), + }, + { + name: "CLI", + icon: ( + <svg + width="28" + height="28" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + > + <polyline points="4 17 10 11 4 5" /> + <line x1="12" y1="19" x2="20" y2="19" /> + </svg> + ), + }, ]; const hosts = ["MacBook Pro", "Hetzner VM", "Dev server"]; const containerRef = React.useRef<HTMLDivElement>(null); const clientRefs = React.useRef<(HTMLDivElement | null)[]>([]); const hostRefs = React.useRef<(HTMLDivElement | null)[]>([]); const centerRef = React.useRef<HTMLDivElement>(null); - const [paths, setPaths] = React.useState<{ left: string[]; right: string[] }>({ left: [], right: [] }); + const [paths, setPaths] = React.useState<{ left: string[]; right: string[] }>({ + left: [], + right: [], + }); React.useEffect(() => { function computePaths() { @@ -387,93 +463,141 @@ function SelfHostedDiagram() { return ( <> - {/* Mobile: vertical stack */} - <div className="md:hidden flex flex-col items-center gap-4 py-4"> - <div className="space-y-2 w-full"> - {clients.map((c) => ( - <div key={c.name} className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4"> - <span className="text-white/80">{c.icon}</span> - <span className="font-medium">{c.name}</span> - </div> - ))} - </div> - <div className="w-px h-6 border-l border-dashed border-white/25" /> - <div className="rounded-xl border border-white/10 bg-white/[0.03] px-6 py-5 text-center space-y-1"> - <p className="text-xs font-medium text-white/50">E2E Encrypted Relay</p> - <p className="text-[10px] text-white/25">or</p> - <p className="text-xs font-medium text-white/50">Direct Connection</p> - </div> - <div className="w-px h-6 border-l border-dashed border-white/25" /> - <div className="space-y-2 w-full"> - {hosts.map((h) => ( - <div key={h} className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4"> - <span className="text-white/80"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> - <rect x="2" y="2" width="20" height="8" rx="2" /> - <rect x="2" y="14" width="20" height="8" rx="2" /> - <circle cx="6" cy="6" r="1" /> - <circle cx="6" cy="18" r="1" /> - </svg></span> - <span className="font-medium">{h}</span> - </div> - ))} - </div> - </div> - - {/* Desktop: horizontal with bezier curves */} - <div ref={containerRef} className="relative hidden md:flex items-center py-4 gap-0"> - {/* SVG curves */} - <svg className="absolute inset-0 w-full h-full pointer-events-none" style={{ overflow: "visible" }}> - {[...paths.left, ...paths.right].map((d, i) => ( - d && <path key={i} d={d} fill="none" stroke="rgba(255,255,255,0.25)" strokeWidth="1" strokeDasharray="4 4" /> - ))} - </svg> - - {/* Clients */} - <div className="space-y-3 flex-shrink-0 relative z-10"> - {clients.map((c, i) => ( - <div - key={c.name} - ref={(el) => { clientRefs.current[i] = el; }} - className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4 backdrop-blur-sm" - > - <span className="text-white/80">{c.icon}</span> - <span className="font-medium">{c.name}</span> - </div> - ))} + {/* Mobile: vertical stack */} + <div className="md:hidden flex flex-col items-center gap-4 py-4"> + <div className="space-y-2 w-full"> + {clients.map((c) => ( + <div + key={c.name} + className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4" + > + <span className="text-white/80">{c.icon}</span> + <span className="font-medium">{c.name}</span> + </div> + ))} + </div> + <div className="w-px h-6 border-l border-dashed border-white/25" /> + <div className="rounded-xl border border-white/10 bg-white/[0.03] px-6 py-5 text-center space-y-1"> + <p className="text-xs font-medium text-white/50">E2E Encrypted Relay</p> + <p className="text-[10px] text-white/25">or</p> + <p className="text-xs font-medium text-white/50">Direct Connection</p> + </div> + <div className="w-px h-6 border-l border-dashed border-white/25" /> + <div className="space-y-2 w-full"> + {hosts.map((h) => ( + <div + key={h} + className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4" + > + <span className="text-white/80"> + <svg + width="28" + height="28" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + > + <rect x="2" y="2" width="20" height="8" rx="2" /> + <rect x="2" y="14" width="20" height="8" rx="2" /> + <circle cx="6" cy="6" r="1" /> + <circle cx="6" cy="18" r="1" /> + </svg> + </span> + <span className="font-medium">{h}</span> + </div> + ))} + </div> </div> - {/* Spacer */} - <div className="flex-1" /> + {/* Desktop: horizontal with bezier curves */} + <div ref={containerRef} className="relative hidden md:flex items-center py-4 gap-0"> + {/* SVG curves */} + <svg + className="absolute inset-0 w-full h-full pointer-events-none" + style={{ overflow: "visible" }} + > + {[...paths.left, ...paths.right].map( + (d, i) => + d && ( + <path + key={i} + d={d} + fill="none" + stroke="rgba(255,255,255,0.25)" + strokeWidth="1" + strokeDasharray="4 4" + /> + ), + )} + </svg> - {/* Center label */} - <div ref={centerRef} className="flex-shrink-0 rounded-xl border border-white/10 bg-white/[0.03] px-8 py-6 text-center space-y-1.5 relative z-10 backdrop-blur-sm"> - <p className="text-sm font-medium text-white/50">E2E Encrypted Relay</p> - <p className="text-xs text-white/25">or</p> - <p className="text-sm font-medium text-white/50">Direct Connection</p> + {/* Clients */} + <div className="space-y-3 flex-shrink-0 relative z-10"> + {clients.map((c, i) => ( + <div + key={c.name} + ref={(el) => { + clientRefs.current[i] = el; + }} + className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4 backdrop-blur-sm" + > + <span className="text-white/80">{c.icon}</span> + <span className="font-medium">{c.name}</span> + </div> + ))} + </div> + + {/* Spacer */} + <div className="flex-1" /> + + {/* Center label */} + <div + ref={centerRef} + className="flex-shrink-0 rounded-xl border border-white/10 bg-white/[0.03] px-8 py-6 text-center space-y-1.5 relative z-10 backdrop-blur-sm" + > + <p className="text-sm font-medium text-white/50">E2E Encrypted Relay</p> + <p className="text-xs text-white/25">or</p> + <p className="text-sm font-medium text-white/50">Direct Connection</p> + </div> + + {/* Spacer */} + <div className="flex-1" /> + + {/* Hosts */} + <div className="space-y-3 flex-shrink-0 relative z-10"> + {hosts.map((h, i) => ( + <div + key={h} + ref={(el) => { + hostRefs.current[i] = el; + }} + className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4 backdrop-blur-sm" + > + <span className="text-white/80"> + <svg + width="28" + height="28" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + > + <rect x="2" y="2" width="20" height="8" rx="2" /> + <rect x="2" y="14" width="20" height="8" rx="2" /> + <circle cx="6" cy="6" r="1" /> + <circle cx="6" cy="18" r="1" /> + </svg> + </span> + <span className="font-medium">{h}</span> + </div> + ))} + </div> </div> - - {/* Spacer */} - <div className="flex-1" /> - - {/* Hosts */} - <div className="space-y-3 flex-shrink-0 relative z-10"> - {hosts.map((h, i) => ( - <div - key={h} - ref={(el) => { hostRefs.current[i] = el; }} - className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4 backdrop-blur-sm" - > - <span className="text-white/80"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> - <rect x="2" y="2" width="20" height="8" rx="2" /> - <rect x="2" y="14" width="20" height="8" rx="2" /> - <circle cx="6" cy="6" r="1" /> - <circle cx="6" cy="18" r="1" /> - </svg></span> - <span className="font-medium">{h}</span> - </div> - ))} - </div> - </div> </> ); } @@ -489,7 +613,6 @@ function SelfHostedSection() { ); } - function ServiceProxySection() { const workspaces = [ { name: "fix-auth", url: "fix-auth.my-app.localhost" }, @@ -611,8 +734,12 @@ function VoiceWaveform() { ); } -const USER_WORDS = "Refactor the auth middleware to use the new session store, then run the test suite".split(" "); -const RESPONSE_WORDS = "I'll update the auth middleware to use SessionStore instead of the legacy cookie-based approach. Let me refactor the middleware and update the tests.".split(" "); +const USER_WORDS = + "Refactor the auth middleware to use the new session store, then run the test suite".split(" "); +const RESPONSE_WORDS = + "I'll update the auth middleware to use SessionStore instead of the legacy cookie-based approach. Let me refactor the middleware and update the tests.".split( + " ", + ); const DICTATION_LAG = 2; const RESPONSE_LAG = 3; const WORD_APPEAR_MS = 150; @@ -620,7 +747,13 @@ const RESPONSE_WORD_MS = 60; const PHASE_GAP_MS = 800; const LOOP_PAUSE_MS = 3000; -type VoicePhase = "dictation" | "dictation-flush" | "pause" | "response" | "response-flush" | "done"; +type VoicePhase = + | "dictation" + | "dictation-flush" + | "pause" + | "response" + | "response-flush" + | "done"; function useVoiceConversation() { const [phase, setPhase] = React.useState<VoicePhase>("dictation"); @@ -641,11 +774,16 @@ function useVoiceConversation() { const t = setTimeout(() => setWordIndex((w) => w + 1), WORD_APPEAR_MS); return () => clearTimeout(t); } - const t = setTimeout(() => { setPhase("pause"); }, PHASE_GAP_MS); + const t = setTimeout(() => { + setPhase("pause"); + }, PHASE_GAP_MS); return () => clearTimeout(t); } if (phase === "pause") { - const t = setTimeout(() => { setPhase("response"); setWordIndex(0); }, PHASE_GAP_MS); + const t = setTimeout(() => { + setPhase("response"); + setWordIndex(0); + }, PHASE_GAP_MS); return () => clearTimeout(t); } if (phase === "response") { @@ -662,11 +800,16 @@ function useVoiceConversation() { const t = setTimeout(() => setWordIndex((w) => w + 1), RESPONSE_WORD_MS); return () => clearTimeout(t); } - const t = setTimeout(() => { setPhase("done"); }, LOOP_PAUSE_MS); + const t = setTimeout(() => { + setPhase("done"); + }, LOOP_PAUSE_MS); return () => clearTimeout(t); } if (phase === "done") { - const t = setTimeout(() => { setPhase("dictation"); setWordIndex(0); }, 0); + const t = setTimeout(() => { + setPhase("dictation"); + setWordIndex(0); + }, 0); return () => clearTimeout(t); } }, [phase, wordIndex]); @@ -697,7 +840,15 @@ function useVoiceConversation() { return { dictationWordIndex, responseWordIndex, showResponse }; } -function StreamingWords({ words, wordIndex, confirmLag = 2 }: { words: string[]; wordIndex: number; confirmLag?: number }) { +function StreamingWords({ + words, + wordIndex, + confirmLag = 2, +}: { + words: string[]; + wordIndex: number; + confirmLag?: number; +}) { return ( <div className="relative"> {/* Invisible full text to reserve height at any viewport width */} @@ -813,10 +964,7 @@ function GetStarted() { <ServerInstallButton /> </div> <div className="pt-3"> - <a - href="/download" - className="text-xs text-white/40 hover:text-white/70 transition-colors" - > + <a href="/download" className="text-xs text-white/40 hover:text-white/70 transition-colors"> All download options </a> </div> @@ -866,8 +1014,8 @@ function ServerInstallButton() { command="npm install -g @getpaseo/cli && paseo" footnote={ <> - Requires Node.js 18+. Run <span className="font-mono text-white/40">paseo</span> to - start the daemon. + Requires Node.js 18+. Run <span className="font-mono text-white/40">paseo</span> to start + the daemon. </> } /> @@ -1014,7 +1162,6 @@ function Step({ number, children }: { number: number; children: React.ReactNode ); } - const bashKeywords = new Set([ "while", "do", @@ -1304,11 +1451,22 @@ function PhoneShowcase() { transition={{ duration: 0.5 }} className="flex flex-col items-center gap-1.5 px-6" > - <svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24" className="text-white/20"> + <svg + width="24" + height="24" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + viewBox="0 0 24 24" + className="text-white/20" + > <path d="M12 5v14M5 12l7 7 7-7" /> </svg> <p className="text-lg text-white/80 text-center"> - When you want to step away from your desk,<br className="md:hidden" /> you can. + When you want to step away from your desk, + <br className="md:hidden" /> you can. </p> <p className="text-sm text-white/50 text-center"> The native mobile app has full feature parity with desktop. @@ -1316,7 +1474,10 @@ function PhoneShowcase() { </motion.div> {/* Phone trio — side phones are absolute, start behind center, slide outward with perspective rotation */} - <div className="relative flex items-center justify-center overflow-x-clip w-full" style={{ minHeight: 480, perspective: 1200 }}> + <div + className="relative flex items-center justify-center overflow-x-clip w-full" + style={{ minHeight: 480, perspective: 1200 }} + > {/* Left phone — rotated to face inward */} <motion.div style={{ opacity: sideOpacity, x: leftX, rotateY: -15, scale: 0.97 }} @@ -1441,9 +1602,11 @@ function FAQ() { </FAQItem> <FAQItem question="Do I need the desktop app?"> No. You can run the daemon headless with{" "} - <code className="font-mono text-muted-foreground">npm install -g @getpaseo/cli && paseo</code> and - use the CLI, web app, or mobile app to connect. The desktop app just bundles the daemon - with a UI. + <code className="font-mono text-muted-foreground"> + npm install -g @getpaseo/cli && paseo + </code>{" "} + and use the CLI, web app, or mobile app to connect. The desktop app just bundles the + daemon with a UI. </FAQItem> <FAQItem question="How does voice work?"> Voice runs locally on your device by default. You talk, the app transcribes and sends it @@ -1502,7 +1665,9 @@ function SponsorCTA() { > <div className="text-sm text-muted-foreground leading-relaxed space-y-3"> <p> - I built Paseo because I wanted better tools for coding agents on my own setup. It's an independent open source project, built around freedom of choice and real workflows. If you like what I'm building, consider becoming a supporter. + I built Paseo because I wanted better tools for coding agents on my own setup. It's an + independent open source project, built around freedom of choice and real workflows. If you + like what I'm building, consider becoming a supporter. </p> <p>- Mo</p> </div> diff --git a/packages/website/src/components/mockup/icons.tsx b/packages/website/src/components/mockup/icons.tsx index 43d82090f..7d210548d 100644 --- a/packages/website/src/components/mockup/icons.tsx +++ b/packages/website/src/components/mockup/icons.tsx @@ -2,7 +2,14 @@ export function ClaudeIcon({ size = 13, className }: { size?: number; className?: string }) { return ( - <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}> + <svg + width={size} + height={size} + viewBox="0 0 24 24" + fill="currentColor" + fillRule="evenodd" + className={className} + > <path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" /> </svg> ); @@ -10,7 +17,14 @@ export function ClaudeIcon({ size = 13, className }: { size?: number; className? export function CodexIcon({ size = 13, className }: { size?: number; className?: string }) { return ( - <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}> + <svg + width={size} + height={size} + viewBox="0 0 24 24" + fill="currentColor" + fillRule="evenodd" + className={className} + > <path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z" /> </svg> ); @@ -18,7 +32,17 @@ export function CodexIcon({ size = 13, className }: { size?: number; className?: export function SourceControlIcon({ size = 14, className }: { size?: number; className?: string }) { return ( - <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}> + <svg + width={size} + height={size} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + className={className} + > <rect x={3} y={3} width={18} height={18} rx={2} /> <line x1={9} y1={9.5} x2={15} y2={9.5} /> <line x1={12} y1={6.5} x2={12} y2={12.5} /> diff --git a/packages/website/src/components/site-header.tsx b/packages/website/src/components/site-header.tsx index 210b21cbd..2b765be16 100644 --- a/packages/website/src/components/site-header.tsx +++ b/packages/website/src/components/site-header.tsx @@ -1,6 +1,8 @@ import "~/styles.css"; +import { useStars } from "~/routes/__root"; export function SiteHeader() { + const { stars } = useStars(); return ( <header className="flex flex-col items-center gap-4 md:flex-row md:justify-between"> <a href="/" className="flex items-center gap-3"> @@ -55,7 +57,7 @@ export function SiteHeader() { target="_blank" rel="noopener noreferrer" aria-label="GitHub" - className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center" + className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1.5" > <svg xmlns="http://www.w3.org/2000/svg" @@ -66,6 +68,7 @@ export function SiteHeader() { > <path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" /> </svg> + {stars && <span className="text-sm">{stars}</span>} </a> </div> </header> diff --git a/packages/website/src/release.ts b/packages/website/src/release.ts index ac925cd5c..113eb0d5a 100644 --- a/packages/website/src/release.ts +++ b/packages/website/src/release.ts @@ -28,8 +28,7 @@ function versionFromTag(tag: string): string { return tag.replace(/^v/, ""); } -const GITHUB_RELEASES_URL = - "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; +const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; async function fetchLatestReadyRelease(): Promise<string> { const fallback = websitePackage.version.replace(/-.*$/, ""); @@ -52,18 +51,14 @@ async function fetchLatestReadyRelease(): Promise<string> { if (!res.ok) return fallback; const releases = (await res.json()) as GitHubRelease[]; - const ready = releases.find( - (r) => !r.prerelease && !r.draft && hasRequiredAssets(r), - ); + const ready = releases.find((r) => !r.prerelease && !r.draft && hasRequiredAssets(r)); return ready ? versionFromTag(ready.tag_name) : fallback; } catch { return fallback; } } -export const getLatestRelease = createServerFn({ method: "GET" }).handler( - async () => { - const version = await fetchLatestReadyRelease(); - return { version }; - }, -); +export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => { + const version = await fetchLatestReadyRelease(); + return { version }; +}); diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index 037ca9c97..aa618795d 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -8,440 +8,440 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as PrivacyRouteImport } from './routes/privacy' -import { Route as OpencodeRouteImport } from './routes/opencode' -import { Route as DownloadRouteImport } from './routes/download' -import { Route as DocsRouteImport } from './routes/docs' -import { Route as CodexRouteImport } from './routes/codex' -import { Route as ClaudeCodeRouteImport } from './routes/claude-code' -import { Route as ChangelogRouteImport } from './routes/changelog' -import { Route as BlogRouteImport } from './routes/blog' -import { Route as IndexRouteImport } from './routes/index' -import { Route as DocsIndexRouteImport } from './routes/docs/index' -import { Route as BlogIndexRouteImport } from './routes/blog/index' -import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees' -import { Route as DocsVoiceRouteImport } from './routes/docs/voice' -import { Route as DocsUpdatesRouteImport } from './routes/docs/updates' -import { Route as DocsSkillsRouteImport } from './routes/docs/skills' -import { Route as DocsSecurityRouteImport } from './routes/docs/security' -import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration' -import { Route as DocsCliRouteImport } from './routes/docs/cli' -import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices' -import { Route as BlogSplatRouteImport } from './routes/blog/$' +import { Route as rootRouteImport } from "./routes/__root"; +import { Route as PrivacyRouteImport } from "./routes/privacy"; +import { Route as OpencodeRouteImport } from "./routes/opencode"; +import { Route as DownloadRouteImport } from "./routes/download"; +import { Route as DocsRouteImport } from "./routes/docs"; +import { Route as CodexRouteImport } from "./routes/codex"; +import { Route as ClaudeCodeRouteImport } from "./routes/claude-code"; +import { Route as ChangelogRouteImport } from "./routes/changelog"; +import { Route as BlogRouteImport } from "./routes/blog"; +import { Route as IndexRouteImport } from "./routes/index"; +import { Route as DocsIndexRouteImport } from "./routes/docs/index"; +import { Route as BlogIndexRouteImport } from "./routes/blog/index"; +import { Route as DocsWorktreesRouteImport } from "./routes/docs/worktrees"; +import { Route as DocsVoiceRouteImport } from "./routes/docs/voice"; +import { Route as DocsUpdatesRouteImport } from "./routes/docs/updates"; +import { Route as DocsSkillsRouteImport } from "./routes/docs/skills"; +import { Route as DocsSecurityRouteImport } from "./routes/docs/security"; +import { Route as DocsConfigurationRouteImport } from "./routes/docs/configuration"; +import { Route as DocsCliRouteImport } from "./routes/docs/cli"; +import { Route as DocsBestPracticesRouteImport } from "./routes/docs/best-practices"; +import { Route as BlogSplatRouteImport } from "./routes/blog/$"; const PrivacyRoute = PrivacyRouteImport.update({ - id: '/privacy', - path: '/privacy', + id: "/privacy", + path: "/privacy", getParentRoute: () => rootRouteImport, -} as any) +} as any); const OpencodeRoute = OpencodeRouteImport.update({ - id: '/opencode', - path: '/opencode', + id: "/opencode", + path: "/opencode", getParentRoute: () => rootRouteImport, -} as any) +} as any); const DownloadRoute = DownloadRouteImport.update({ - id: '/download', - path: '/download', + id: "/download", + path: "/download", getParentRoute: () => rootRouteImport, -} as any) +} as any); const DocsRoute = DocsRouteImport.update({ - id: '/docs', - path: '/docs', + id: "/docs", + path: "/docs", getParentRoute: () => rootRouteImport, -} as any) +} as any); const CodexRoute = CodexRouteImport.update({ - id: '/codex', - path: '/codex', + id: "/codex", + path: "/codex", getParentRoute: () => rootRouteImport, -} as any) +} as any); const ClaudeCodeRoute = ClaudeCodeRouteImport.update({ - id: '/claude-code', - path: '/claude-code', + id: "/claude-code", + path: "/claude-code", getParentRoute: () => rootRouteImport, -} as any) +} as any); const ChangelogRoute = ChangelogRouteImport.update({ - id: '/changelog', - path: '/changelog', + id: "/changelog", + path: "/changelog", getParentRoute: () => rootRouteImport, -} as any) +} as any); const BlogRoute = BlogRouteImport.update({ - id: '/blog', - path: '/blog', + id: "/blog", + path: "/blog", getParentRoute: () => rootRouteImport, -} as any) +} as any); const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', + id: "/", + path: "/", getParentRoute: () => rootRouteImport, -} as any) +} as any); const DocsIndexRoute = DocsIndexRouteImport.update({ - id: '/', - path: '/', + id: "/", + path: "/", getParentRoute: () => DocsRoute, -} as any) +} as any); const BlogIndexRoute = BlogIndexRouteImport.update({ - id: '/', - path: '/', + id: "/", + path: "/", getParentRoute: () => BlogRoute, -} as any) +} as any); const DocsWorktreesRoute = DocsWorktreesRouteImport.update({ - id: '/worktrees', - path: '/worktrees', + id: "/worktrees", + path: "/worktrees", getParentRoute: () => DocsRoute, -} as any) +} as any); const DocsVoiceRoute = DocsVoiceRouteImport.update({ - id: '/voice', - path: '/voice', + id: "/voice", + path: "/voice", getParentRoute: () => DocsRoute, -} as any) +} as any); const DocsUpdatesRoute = DocsUpdatesRouteImport.update({ - id: '/updates', - path: '/updates', + id: "/updates", + path: "/updates", getParentRoute: () => DocsRoute, -} as any) +} as any); const DocsSkillsRoute = DocsSkillsRouteImport.update({ - id: '/skills', - path: '/skills', + id: "/skills", + path: "/skills", getParentRoute: () => DocsRoute, -} as any) +} as any); const DocsSecurityRoute = DocsSecurityRouteImport.update({ - id: '/security', - path: '/security', + id: "/security", + path: "/security", getParentRoute: () => DocsRoute, -} as any) +} as any); const DocsConfigurationRoute = DocsConfigurationRouteImport.update({ - id: '/configuration', - path: '/configuration', + id: "/configuration", + path: "/configuration", getParentRoute: () => DocsRoute, -} as any) +} as any); const DocsCliRoute = DocsCliRouteImport.update({ - id: '/cli', - path: '/cli', + id: "/cli", + path: "/cli", getParentRoute: () => DocsRoute, -} as any) +} as any); const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({ - id: '/best-practices', - path: '/best-practices', + id: "/best-practices", + path: "/best-practices", getParentRoute: () => DocsRoute, -} as any) +} as any); const BlogSplatRoute = BlogSplatRouteImport.update({ - id: '/$', - path: '/$', + id: "/$", + path: "/$", getParentRoute: () => BlogRoute, -} as any) +} as any); export interface FileRoutesByFullPath { - '/': typeof IndexRoute - '/blog': typeof BlogRouteWithChildren - '/changelog': typeof ChangelogRoute - '/claude-code': typeof ClaudeCodeRoute - '/codex': typeof CodexRoute - '/docs': typeof DocsRouteWithChildren - '/download': typeof DownloadRoute - '/opencode': typeof OpencodeRoute - '/privacy': typeof PrivacyRoute - '/blog/$': typeof BlogSplatRoute - '/docs/best-practices': typeof DocsBestPracticesRoute - '/docs/cli': typeof DocsCliRoute - '/docs/configuration': typeof DocsConfigurationRoute - '/docs/security': typeof DocsSecurityRoute - '/docs/skills': typeof DocsSkillsRoute - '/docs/updates': typeof DocsUpdatesRoute - '/docs/voice': typeof DocsVoiceRoute - '/docs/worktrees': typeof DocsWorktreesRoute - '/blog/': typeof BlogIndexRoute - '/docs/': typeof DocsIndexRoute + "/": typeof IndexRoute; + "/blog": typeof BlogRouteWithChildren; + "/changelog": typeof ChangelogRoute; + "/claude-code": typeof ClaudeCodeRoute; + "/codex": typeof CodexRoute; + "/docs": typeof DocsRouteWithChildren; + "/download": typeof DownloadRoute; + "/opencode": typeof OpencodeRoute; + "/privacy": typeof PrivacyRoute; + "/blog/$": typeof BlogSplatRoute; + "/docs/best-practices": typeof DocsBestPracticesRoute; + "/docs/cli": typeof DocsCliRoute; + "/docs/configuration": typeof DocsConfigurationRoute; + "/docs/security": typeof DocsSecurityRoute; + "/docs/skills": typeof DocsSkillsRoute; + "/docs/updates": typeof DocsUpdatesRoute; + "/docs/voice": typeof DocsVoiceRoute; + "/docs/worktrees": typeof DocsWorktreesRoute; + "/blog/": typeof BlogIndexRoute; + "/docs/": typeof DocsIndexRoute; } export interface FileRoutesByTo { - '/': typeof IndexRoute - '/changelog': typeof ChangelogRoute - '/claude-code': typeof ClaudeCodeRoute - '/codex': typeof CodexRoute - '/download': typeof DownloadRoute - '/opencode': typeof OpencodeRoute - '/privacy': typeof PrivacyRoute - '/blog/$': typeof BlogSplatRoute - '/docs/best-practices': typeof DocsBestPracticesRoute - '/docs/cli': typeof DocsCliRoute - '/docs/configuration': typeof DocsConfigurationRoute - '/docs/security': typeof DocsSecurityRoute - '/docs/skills': typeof DocsSkillsRoute - '/docs/updates': typeof DocsUpdatesRoute - '/docs/voice': typeof DocsVoiceRoute - '/docs/worktrees': typeof DocsWorktreesRoute - '/blog': typeof BlogIndexRoute - '/docs': typeof DocsIndexRoute + "/": typeof IndexRoute; + "/changelog": typeof ChangelogRoute; + "/claude-code": typeof ClaudeCodeRoute; + "/codex": typeof CodexRoute; + "/download": typeof DownloadRoute; + "/opencode": typeof OpencodeRoute; + "/privacy": typeof PrivacyRoute; + "/blog/$": typeof BlogSplatRoute; + "/docs/best-practices": typeof DocsBestPracticesRoute; + "/docs/cli": typeof DocsCliRoute; + "/docs/configuration": typeof DocsConfigurationRoute; + "/docs/security": typeof DocsSecurityRoute; + "/docs/skills": typeof DocsSkillsRoute; + "/docs/updates": typeof DocsUpdatesRoute; + "/docs/voice": typeof DocsVoiceRoute; + "/docs/worktrees": typeof DocsWorktreesRoute; + "/blog": typeof BlogIndexRoute; + "/docs": typeof DocsIndexRoute; } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/': typeof IndexRoute - '/blog': typeof BlogRouteWithChildren - '/changelog': typeof ChangelogRoute - '/claude-code': typeof ClaudeCodeRoute - '/codex': typeof CodexRoute - '/docs': typeof DocsRouteWithChildren - '/download': typeof DownloadRoute - '/opencode': typeof OpencodeRoute - '/privacy': typeof PrivacyRoute - '/blog/$': typeof BlogSplatRoute - '/docs/best-practices': typeof DocsBestPracticesRoute - '/docs/cli': typeof DocsCliRoute - '/docs/configuration': typeof DocsConfigurationRoute - '/docs/security': typeof DocsSecurityRoute - '/docs/skills': typeof DocsSkillsRoute - '/docs/updates': typeof DocsUpdatesRoute - '/docs/voice': typeof DocsVoiceRoute - '/docs/worktrees': typeof DocsWorktreesRoute - '/blog/': typeof BlogIndexRoute - '/docs/': typeof DocsIndexRoute + __root__: typeof rootRouteImport; + "/": typeof IndexRoute; + "/blog": typeof BlogRouteWithChildren; + "/changelog": typeof ChangelogRoute; + "/claude-code": typeof ClaudeCodeRoute; + "/codex": typeof CodexRoute; + "/docs": typeof DocsRouteWithChildren; + "/download": typeof DownloadRoute; + "/opencode": typeof OpencodeRoute; + "/privacy": typeof PrivacyRoute; + "/blog/$": typeof BlogSplatRoute; + "/docs/best-practices": typeof DocsBestPracticesRoute; + "/docs/cli": typeof DocsCliRoute; + "/docs/configuration": typeof DocsConfigurationRoute; + "/docs/security": typeof DocsSecurityRoute; + "/docs/skills": typeof DocsSkillsRoute; + "/docs/updates": typeof DocsUpdatesRoute; + "/docs/voice": typeof DocsVoiceRoute; + "/docs/worktrees": typeof DocsWorktreesRoute; + "/blog/": typeof BlogIndexRoute; + "/docs/": typeof DocsIndexRoute; } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath + fileRoutesByFullPath: FileRoutesByFullPath; fullPaths: - | '/' - | '/blog' - | '/changelog' - | '/claude-code' - | '/codex' - | '/docs' - | '/download' - | '/opencode' - | '/privacy' - | '/blog/$' - | '/docs/best-practices' - | '/docs/cli' - | '/docs/configuration' - | '/docs/security' - | '/docs/skills' - | '/docs/updates' - | '/docs/voice' - | '/docs/worktrees' - | '/blog/' - | '/docs/' - fileRoutesByTo: FileRoutesByTo + | "/" + | "/blog" + | "/changelog" + | "/claude-code" + | "/codex" + | "/docs" + | "/download" + | "/opencode" + | "/privacy" + | "/blog/$" + | "/docs/best-practices" + | "/docs/cli" + | "/docs/configuration" + | "/docs/security" + | "/docs/skills" + | "/docs/updates" + | "/docs/voice" + | "/docs/worktrees" + | "/blog/" + | "/docs/"; + fileRoutesByTo: FileRoutesByTo; to: - | '/' - | '/changelog' - | '/claude-code' - | '/codex' - | '/download' - | '/opencode' - | '/privacy' - | '/blog/$' - | '/docs/best-practices' - | '/docs/cli' - | '/docs/configuration' - | '/docs/security' - | '/docs/skills' - | '/docs/updates' - | '/docs/voice' - | '/docs/worktrees' - | '/blog' - | '/docs' + | "/" + | "/changelog" + | "/claude-code" + | "/codex" + | "/download" + | "/opencode" + | "/privacy" + | "/blog/$" + | "/docs/best-practices" + | "/docs/cli" + | "/docs/configuration" + | "/docs/security" + | "/docs/skills" + | "/docs/updates" + | "/docs/voice" + | "/docs/worktrees" + | "/blog" + | "/docs"; id: - | '__root__' - | '/' - | '/blog' - | '/changelog' - | '/claude-code' - | '/codex' - | '/docs' - | '/download' - | '/opencode' - | '/privacy' - | '/blog/$' - | '/docs/best-practices' - | '/docs/cli' - | '/docs/configuration' - | '/docs/security' - | '/docs/skills' - | '/docs/updates' - | '/docs/voice' - | '/docs/worktrees' - | '/blog/' - | '/docs/' - fileRoutesById: FileRoutesById + | "__root__" + | "/" + | "/blog" + | "/changelog" + | "/claude-code" + | "/codex" + | "/docs" + | "/download" + | "/opencode" + | "/privacy" + | "/blog/$" + | "/docs/best-practices" + | "/docs/cli" + | "/docs/configuration" + | "/docs/security" + | "/docs/skills" + | "/docs/updates" + | "/docs/voice" + | "/docs/worktrees" + | "/blog/" + | "/docs/"; + fileRoutesById: FileRoutesById; } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute - BlogRoute: typeof BlogRouteWithChildren - ChangelogRoute: typeof ChangelogRoute - ClaudeCodeRoute: typeof ClaudeCodeRoute - CodexRoute: typeof CodexRoute - DocsRoute: typeof DocsRouteWithChildren - DownloadRoute: typeof DownloadRoute - OpencodeRoute: typeof OpencodeRoute - PrivacyRoute: typeof PrivacyRoute + IndexRoute: typeof IndexRoute; + BlogRoute: typeof BlogRouteWithChildren; + ChangelogRoute: typeof ChangelogRoute; + ClaudeCodeRoute: typeof ClaudeCodeRoute; + CodexRoute: typeof CodexRoute; + DocsRoute: typeof DocsRouteWithChildren; + DownloadRoute: typeof DownloadRoute; + OpencodeRoute: typeof OpencodeRoute; + PrivacyRoute: typeof PrivacyRoute; } -declare module '@tanstack/react-router' { +declare module "@tanstack/react-router" { interface FileRoutesByPath { - '/privacy': { - id: '/privacy' - path: '/privacy' - fullPath: '/privacy' - preLoaderRoute: typeof PrivacyRouteImport - parentRoute: typeof rootRouteImport - } - '/opencode': { - id: '/opencode' - path: '/opencode' - fullPath: '/opencode' - preLoaderRoute: typeof OpencodeRouteImport - parentRoute: typeof rootRouteImport - } - '/download': { - id: '/download' - path: '/download' - fullPath: '/download' - preLoaderRoute: typeof DownloadRouteImport - parentRoute: typeof rootRouteImport - } - '/docs': { - id: '/docs' - path: '/docs' - fullPath: '/docs' - preLoaderRoute: typeof DocsRouteImport - parentRoute: typeof rootRouteImport - } - '/codex': { - id: '/codex' - path: '/codex' - fullPath: '/codex' - preLoaderRoute: typeof CodexRouteImport - parentRoute: typeof rootRouteImport - } - '/claude-code': { - id: '/claude-code' - path: '/claude-code' - fullPath: '/claude-code' - preLoaderRoute: typeof ClaudeCodeRouteImport - parentRoute: typeof rootRouteImport - } - '/changelog': { - id: '/changelog' - path: '/changelog' - fullPath: '/changelog' - preLoaderRoute: typeof ChangelogRouteImport - parentRoute: typeof rootRouteImport - } - '/blog': { - id: '/blog' - path: '/blog' - fullPath: '/blog' - preLoaderRoute: typeof BlogRouteImport - parentRoute: typeof rootRouteImport - } - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } - '/docs/': { - id: '/docs/' - path: '/' - fullPath: '/docs/' - preLoaderRoute: typeof DocsIndexRouteImport - parentRoute: typeof DocsRoute - } - '/blog/': { - id: '/blog/' - path: '/' - fullPath: '/blog/' - preLoaderRoute: typeof BlogIndexRouteImport - parentRoute: typeof BlogRoute - } - '/docs/worktrees': { - id: '/docs/worktrees' - path: '/worktrees' - fullPath: '/docs/worktrees' - preLoaderRoute: typeof DocsWorktreesRouteImport - parentRoute: typeof DocsRoute - } - '/docs/voice': { - id: '/docs/voice' - path: '/voice' - fullPath: '/docs/voice' - preLoaderRoute: typeof DocsVoiceRouteImport - parentRoute: typeof DocsRoute - } - '/docs/updates': { - id: '/docs/updates' - path: '/updates' - fullPath: '/docs/updates' - preLoaderRoute: typeof DocsUpdatesRouteImport - parentRoute: typeof DocsRoute - } - '/docs/skills': { - id: '/docs/skills' - path: '/skills' - fullPath: '/docs/skills' - preLoaderRoute: typeof DocsSkillsRouteImport - parentRoute: typeof DocsRoute - } - '/docs/security': { - id: '/docs/security' - path: '/security' - fullPath: '/docs/security' - preLoaderRoute: typeof DocsSecurityRouteImport - parentRoute: typeof DocsRoute - } - '/docs/configuration': { - id: '/docs/configuration' - path: '/configuration' - fullPath: '/docs/configuration' - preLoaderRoute: typeof DocsConfigurationRouteImport - parentRoute: typeof DocsRoute - } - '/docs/cli': { - id: '/docs/cli' - path: '/cli' - fullPath: '/docs/cli' - preLoaderRoute: typeof DocsCliRouteImport - parentRoute: typeof DocsRoute - } - '/docs/best-practices': { - id: '/docs/best-practices' - path: '/best-practices' - fullPath: '/docs/best-practices' - preLoaderRoute: typeof DocsBestPracticesRouteImport - parentRoute: typeof DocsRoute - } - '/blog/$': { - id: '/blog/$' - path: '/$' - fullPath: '/blog/$' - preLoaderRoute: typeof BlogSplatRouteImport - parentRoute: typeof BlogRoute - } + "/privacy": { + id: "/privacy"; + path: "/privacy"; + fullPath: "/privacy"; + preLoaderRoute: typeof PrivacyRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/opencode": { + id: "/opencode"; + path: "/opencode"; + fullPath: "/opencode"; + preLoaderRoute: typeof OpencodeRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/download": { + id: "/download"; + path: "/download"; + fullPath: "/download"; + preLoaderRoute: typeof DownloadRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/docs": { + id: "/docs"; + path: "/docs"; + fullPath: "/docs"; + preLoaderRoute: typeof DocsRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/codex": { + id: "/codex"; + path: "/codex"; + fullPath: "/codex"; + preLoaderRoute: typeof CodexRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/claude-code": { + id: "/claude-code"; + path: "/claude-code"; + fullPath: "/claude-code"; + preLoaderRoute: typeof ClaudeCodeRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/changelog": { + id: "/changelog"; + path: "/changelog"; + fullPath: "/changelog"; + preLoaderRoute: typeof ChangelogRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/blog": { + id: "/blog"; + path: "/blog"; + fullPath: "/blog"; + preLoaderRoute: typeof BlogRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/": { + id: "/"; + path: "/"; + fullPath: "/"; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/docs/": { + id: "/docs/"; + path: "/"; + fullPath: "/docs/"; + preLoaderRoute: typeof DocsIndexRouteImport; + parentRoute: typeof DocsRoute; + }; + "/blog/": { + id: "/blog/"; + path: "/"; + fullPath: "/blog/"; + preLoaderRoute: typeof BlogIndexRouteImport; + parentRoute: typeof BlogRoute; + }; + "/docs/worktrees": { + id: "/docs/worktrees"; + path: "/worktrees"; + fullPath: "/docs/worktrees"; + preLoaderRoute: typeof DocsWorktreesRouteImport; + parentRoute: typeof DocsRoute; + }; + "/docs/voice": { + id: "/docs/voice"; + path: "/voice"; + fullPath: "/docs/voice"; + preLoaderRoute: typeof DocsVoiceRouteImport; + parentRoute: typeof DocsRoute; + }; + "/docs/updates": { + id: "/docs/updates"; + path: "/updates"; + fullPath: "/docs/updates"; + preLoaderRoute: typeof DocsUpdatesRouteImport; + parentRoute: typeof DocsRoute; + }; + "/docs/skills": { + id: "/docs/skills"; + path: "/skills"; + fullPath: "/docs/skills"; + preLoaderRoute: typeof DocsSkillsRouteImport; + parentRoute: typeof DocsRoute; + }; + "/docs/security": { + id: "/docs/security"; + path: "/security"; + fullPath: "/docs/security"; + preLoaderRoute: typeof DocsSecurityRouteImport; + parentRoute: typeof DocsRoute; + }; + "/docs/configuration": { + id: "/docs/configuration"; + path: "/configuration"; + fullPath: "/docs/configuration"; + preLoaderRoute: typeof DocsConfigurationRouteImport; + parentRoute: typeof DocsRoute; + }; + "/docs/cli": { + id: "/docs/cli"; + path: "/cli"; + fullPath: "/docs/cli"; + preLoaderRoute: typeof DocsCliRouteImport; + parentRoute: typeof DocsRoute; + }; + "/docs/best-practices": { + id: "/docs/best-practices"; + path: "/best-practices"; + fullPath: "/docs/best-practices"; + preLoaderRoute: typeof DocsBestPracticesRouteImport; + parentRoute: typeof DocsRoute; + }; + "/blog/$": { + id: "/blog/$"; + path: "/$"; + fullPath: "/blog/$"; + preLoaderRoute: typeof BlogSplatRouteImport; + parentRoute: typeof BlogRoute; + }; } } interface BlogRouteChildren { - BlogSplatRoute: typeof BlogSplatRoute - BlogIndexRoute: typeof BlogIndexRoute + BlogSplatRoute: typeof BlogSplatRoute; + BlogIndexRoute: typeof BlogIndexRoute; } const BlogRouteChildren: BlogRouteChildren = { BlogSplatRoute: BlogSplatRoute, BlogIndexRoute: BlogIndexRoute, -} +}; -const BlogRouteWithChildren = BlogRoute._addFileChildren(BlogRouteChildren) +const BlogRouteWithChildren = BlogRoute._addFileChildren(BlogRouteChildren); interface DocsRouteChildren { - DocsBestPracticesRoute: typeof DocsBestPracticesRoute - DocsCliRoute: typeof DocsCliRoute - DocsConfigurationRoute: typeof DocsConfigurationRoute - DocsSecurityRoute: typeof DocsSecurityRoute - DocsSkillsRoute: typeof DocsSkillsRoute - DocsUpdatesRoute: typeof DocsUpdatesRoute - DocsVoiceRoute: typeof DocsVoiceRoute - DocsWorktreesRoute: typeof DocsWorktreesRoute - DocsIndexRoute: typeof DocsIndexRoute + DocsBestPracticesRoute: typeof DocsBestPracticesRoute; + DocsCliRoute: typeof DocsCliRoute; + DocsConfigurationRoute: typeof DocsConfigurationRoute; + DocsSecurityRoute: typeof DocsSecurityRoute; + DocsSkillsRoute: typeof DocsSkillsRoute; + DocsUpdatesRoute: typeof DocsUpdatesRoute; + DocsVoiceRoute: typeof DocsVoiceRoute; + DocsWorktreesRoute: typeof DocsWorktreesRoute; + DocsIndexRoute: typeof DocsIndexRoute; } const DocsRouteChildren: DocsRouteChildren = { @@ -454,9 +454,9 @@ const DocsRouteChildren: DocsRouteChildren = { DocsVoiceRoute: DocsVoiceRoute, DocsWorktreesRoute: DocsWorktreesRoute, DocsIndexRoute: DocsIndexRoute, -} +}; -const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren) +const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren); const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, @@ -468,16 +468,16 @@ const rootRouteChildren: RootRouteChildren = { DownloadRoute: DownloadRoute, OpencodeRoute: OpencodeRoute, PrivacyRoute: PrivacyRoute, -} +}; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes<FileRouteTypes>() + ._addFileTypes<FileRouteTypes>(); -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { +import type { getRouter } from "./router.tsx"; +import type { createStart } from "@tanstack/react-start"; +declare module "@tanstack/react-start" { interface Register { - ssr: true - router: Awaited<ReturnType<typeof getRouter>> + ssr: true; + router: Awaited<ReturnType<typeof getRouter>>; } } diff --git a/packages/website/src/routes/__root.tsx b/packages/website/src/routes/__root.tsx index 234ed8842..61f44db78 100644 --- a/packages/website/src/routes/__root.tsx +++ b/packages/website/src/routes/__root.tsx @@ -2,20 +2,31 @@ import type { ReactNode } from "react"; import { createContext, useContext } from "react"; import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; import { getLatestRelease } from "~/release"; +import { getStarCount } from "~/stars"; interface ReleaseContext { version: string; } +interface StarsContext { + stars: string; +} + const ReleaseCtx = createContext<ReleaseContext>({ version: "" }); +const StarsCtx = createContext<StarsContext>({ stars: "" }); export function useRelease(): ReleaseContext { return useContext(ReleaseCtx); } +export function useStars(): StarsContext { + return useContext(StarsCtx); +} + export const Route = createRootRoute({ loader: async () => { - return getLatestRelease(); + const [release, stars] = await Promise.all([getLatestRelease(), getStarCount()]); + return { ...release, ...stars }; }, head: () => ({ meta: [ @@ -38,12 +49,14 @@ export const Route = createRootRoute({ }); function RootComponent() { - const release = Route.useLoaderData(); + const data = Route.useLoaderData(); return ( - <ReleaseCtx value={release}> - <RootDocument> - <Outlet /> - </RootDocument> + <ReleaseCtx value={data}> + <StarsCtx value={data}> + <RootDocument> + <Outlet /> + </RootDocument> + </StarsCtx> </ReleaseCtx> ); } diff --git a/packages/website/src/routes/docs/index.tsx b/packages/website/src/routes/docs/index.tsx index 6a98d075c..ceacc4a99 100644 --- a/packages/website/src/routes/docs/index.tsx +++ b/packages/website/src/routes/docs/index.tsx @@ -17,8 +17,8 @@ function GettingStarted() { <div> <h1 className="text-3xl font-medium font-title mb-4">Getting Started</h1> <p className="text-white/60 leading-relaxed"> - Paseo has three main pieces: the daemon is the local server that manages your agents, - the app is the client you use from mobile, web, or desktop, and the CLI is the terminal + Paseo has three main pieces: the daemon is the local server that manages your agents, the + app is the client you use from mobile, web, or desktop, and the CLI is the terminal interface that can also launch the daemon. </p> </div> diff --git a/packages/website/src/routes/docs/skills.tsx b/packages/website/src/routes/docs/skills.tsx index 94d4f7d85..a18bef073 100644 --- a/packages/website/src/routes/docs/skills.tsx +++ b/packages/website/src/routes/docs/skills.tsx @@ -26,9 +26,9 @@ function Skills() { <h1 className="text-3xl font-medium font-title mb-4">Orchestration Skills</h1> <p className="text-white/60 leading-relaxed"> Paseo ships orchestration skills that teach coding agents (Claude Code, Codex) how to use - the Paseo CLI to spawn, coordinate, and manage other agents. Skills are slash commands your - agent can invoke — they provide the prompts, context, and workflows so agents know how to - orchestrate without you writing boilerplate. Install them from the desktop app's + the Paseo CLI to spawn, coordinate, and manage other agents. Skills are slash commands + your agent can invoke — they provide the prompts, context, and workflows so agents know + how to orchestrate without you writing boilerplate. Install them from the desktop app's Integrations settings or via the CLI. </p> </div> @@ -42,9 +42,10 @@ function Skills() { <strong>Desktop app:</strong> Settings → Integrations → Install </li> <li> - <strong>Manual:</strong> <code className="font-mono">npx skills add getpaseo/paseo</code>{" "} - — this installs to <code className="font-mono">~/.agents/skills/</code> and sets up - symlinks for each agent. + <strong>Manual:</strong>{" "} + <code className="font-mono">npx skills add getpaseo/paseo</code> — this installs to{" "} + <code className="font-mono">~/.agents/skills/</code> and sets up symlinks for each + agent. </li> </ul> </section> @@ -90,8 +91,8 @@ function Skills() { </h2> <p className="text-white/60 leading-relaxed"> Runs an agent in a loop with automatic verification until an exit condition is met. Worker - runs, verifier checks, repeat until done or max iterations. Supports different providers for - worker vs verifier (e.g., Codex implements, Claude verifies). + runs, verifier checks, repeat until done or max iterations. Supports different providers + for worker vs verifier (e.g., Codex implements, Claude verifies). </p> <p className="text-white/60 leading-relaxed"> Stop conditions: <code className="font-mono">--max-iterations</code>,{" "} @@ -110,8 +111,8 @@ function Skills() { </h2> <p className="text-white/60 leading-relaxed"> Builds and manages a team of agents coordinating through a shared chat room. You describe - the work, it sets up roles, launches agents, and coordinates through chat. Uses a heartbeat - schedule to check progress. + the work, it sets up roles, launches agents, and coordinates through chat. Uses a + heartbeat schedule to check progress. </p> <p className="text-white/60 leading-relaxed"> Cross-provider: typically Codex for implementation, Claude for review. @@ -127,8 +128,9 @@ function Skills() { <code className="font-mono">/paseo-chat</code> — Chat Rooms </h2> <p className="text-white/60 leading-relaxed"> - Use persistent chat rooms for asynchronous agent coordination. Create rooms, post messages, - read history, wait for replies. Supports @mentions for specific agents or @everyone. + Use persistent chat rooms for asynchronous agent coordination. Create rooms, post + messages, read history, wait for replies. Supports @mentions for specific agents or + @everyone. </p> <p className="text-white/60 leading-relaxed"> Typically used by the orchestrator skill, but can be used directly. @@ -145,9 +147,9 @@ function Skills() { <code className="font-mono">/paseo-committee</code> — Committee Planning </h2> <p className="text-white/60 leading-relaxed"> - Forms a committee of two high-reasoning agents (Claude Opus + GPT 5.4) to analyze a problem - before implementing. Both agents reason in parallel, then plans are merged. Useful when - stuck, looping, or facing a hard architectural decision. + Forms a committee of two high-reasoning agents (Claude Opus + GPT 5.4) to analyze a + problem before implementing. Both agents reason in parallel, then plans are merged. Useful + when stuck, looping, or facing a hard architectural decision. </p> <p className="text-white/60 leading-relaxed"> Agents are prevented from editing code — they only produce a plan. diff --git a/packages/website/src/routes/docs/worktrees.tsx b/packages/website/src/routes/docs/worktrees.tsx index 17e959d95..39745339d 100644 --- a/packages/website/src/routes/docs/worktrees.tsx +++ b/packages/website/src/routes/docs/worktrees.tsx @@ -172,9 +172,9 @@ function Worktrees() { checkout to the worktree. </p> <p className="text-white/60 leading-relaxed"> - <code className="font-mono">$PASEO_WORKTREE_PORT</code> is available when the worktree - was bootstrapped with a port. That makes it useful for both starting services in setup - and stopping them again in teardown. + <code className="font-mono">$PASEO_WORKTREE_PORT</code> is available when the worktree was + bootstrapped with a port. That makes it useful for both starting services in setup and + stopping them again in teardown. </p> </section> diff --git a/packages/website/src/routes/download.tsx b/packages/website/src/routes/download.tsx index e28acbed2..d148b0f14 100644 --- a/packages/website/src/routes/download.tsx +++ b/packages/website/src/routes/download.tsx @@ -90,9 +90,7 @@ function Download() { </header> <h1 className="text-3xl md:text-4xl font-semibold tracking-tight mb-2">Download</h1> - <p className="text-muted-foreground mb-10"> - v{version} - </p> + <p className="text-muted-foreground mb-10">v{version}</p> {/* Desktop */} <section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6"> @@ -130,10 +128,7 @@ function Download() { <span className="font-medium">Windows</span> </div> <div className="flex flex-wrap gap-2"> - <DownloadPill - href={urls.windowsExe} - label="Download" - /> + <DownloadPill href={urls.windowsExe} label="Download" /> </div> </div> @@ -144,10 +139,7 @@ function Download() { <span className="font-medium">Linux</span> </div> <div className="flex flex-wrap gap-2"> - <DownloadPill - href={urls.linuxAppImage} - label="AppImage" - /> + <DownloadPill href={urls.linuxAppImage} label="AppImage" /> <DownloadPill href={urls.linuxDeb} label="DEB" /> <DownloadPill href={urls.linuxRpm} label="RPM" /> </div> @@ -170,15 +162,8 @@ function Download() { <span className="font-medium">Android</span> </div> <div className="flex flex-wrap gap-2"> - <DownloadPill - href={playStoreUrl} - label="Play Store" - external - /> - <DownloadPill - href={urls.androidApk} - label="APK" - /> + <DownloadPill href={playStoreUrl} label="Play Store" external /> + <DownloadPill href={urls.androidApk} label="APK" /> </div> </div> @@ -189,11 +174,7 @@ function Download() { <span className="font-medium">iOS</span> </div> <div className="flex flex-wrap gap-2"> - <DownloadPill - href={appStoreUrl} - label="App Store" - external - /> + <DownloadPill href={appStoreUrl} label="App Store" external /> </div> </div> </div> @@ -213,11 +194,7 @@ function Download() { <span className="font-medium">Web App</span> </div> <div className="flex flex-wrap gap-2"> - <DownloadPill - href={webAppUrl} - label="Open" - external - /> + <DownloadPill href={webAppUrl} label="Open" external /> </div> </div> diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 9d62a8b8c..636da8f3e 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -15,7 +15,13 @@ export const Route = createFileRoute("/")({ function Home() { return ( <LandingPage - title={<>Orchestrate coding agents<br />from your desk and your phone</>} + title={ + <> + Orchestrate coding agents + <br /> + from your desk and your phone + </> + } subtitle="Run any coding agent from your phone, desktop, or terminal. Self-hosted, multi-provider, open source." /> ); diff --git a/packages/website/src/stars.ts b/packages/website/src/stars.ts new file mode 100644 index 000000000..cf5775e07 --- /dev/null +++ b/packages/website/src/stars.ts @@ -0,0 +1,40 @@ +import { createServerFn } from "@tanstack/react-start"; + +interface GitHubRepo { + stargazers_count: number; +} + +function formatStars(count: number): string { + if (count < 1000) return String(count); + const k = count / 1000; + return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; +} + +const GITHUB_REPO_URL = "https://api.github.com/repos/getpaseo/paseo"; + +async function fetchStarCount(): Promise<string> { + try { + const res = await fetch(GITHUB_REPO_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "paseo-website", + }, + cf: { + cacheEverything: true, + cacheTtl: 60, + cacheKey: "github-repo-stars", + }, + } as RequestInit); + if (!res.ok) return ""; + + const repo = (await res.json()) as GitHubRepo; + return formatStars(repo.stargazers_count); + } catch { + return ""; + } +} + +export const getStarCount = createServerFn({ method: "GET" }).handler(async () => { + const stars = await fetchStarCount(); + return { stars }; +}); diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index c54937692..75eae3571 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -1,8 +1,12 @@ @import "tailwindcss"; @keyframes voice-bar { - 0% { transform: scaleY(1); } - 100% { transform: scaleY(0.3); } + 0% { + transform: scaleY(1); + } + 100% { + transform: scaleY(0.3); + } } /* Title font - centralized for easy changes */ @@ -189,26 +193,26 @@ @theme { --color-background: #101615; --color-foreground: #fafafa; - --color-muted: #252B2A; - --color-muted-foreground: #A8ADAC; - --color-card: #171D1C; - --color-border: #252B2A; + --color-muted: #252b2a; + --color-muted-foreground: #a8adac; + --color-card: #171d1c; + --color-border: #252b2a; --color-primary: #3b82f6; --color-primary-foreground: #ffffff; - --color-secondary: #252B2A; + --color-secondary: #252b2a; --color-secondary-foreground: #fafafa; /* Mockup palette (from app unistyles theme) */ - --color-mock-surface0: #181B1A; - --color-mock-surface1: #1E2120; - --color-mock-surface2: #272A29; + --color-mock-surface0: #181b1a; + --color-mock-surface1: #1e2120; + --color-mock-surface2: #272a29; --color-mock-surface3: #434645; --color-mock-sidebar: #141716; --color-mock-fg: #fafafa; - --color-mock-fg-muted: #A1A5A4; - --color-mock-border: #272A29; + --color-mock-fg-muted: #a1a5a4; + --color-mock-border: #272a29; --color-mock-border-accent: #313433; - --color-mock-accent: #20744A; + --color-mock-accent: #20744a; --color-mock-green: #22c55e; --color-mock-green-400: #4ade80; --color-mock-red: #ef4444; @@ -243,12 +247,30 @@ up to 4 dots are visible at decreasing opacity (head=1, trail=0.72/0.46/0.22). steps(1,end) keeps each opacity level solid for its full 1/6-cycle slot. */ @keyframes synced-snake-dot { - 0% { opacity: 1; animation-timing-function: steps(1, end); } - 16.667% { opacity: 0.72; animation-timing-function: steps(1, end); } - 33.333% { opacity: 0.46; animation-timing-function: steps(1, end); } - 50% { opacity: 0.22; animation-timing-function: steps(1, end); } - 66.667% { opacity: 0; animation-timing-function: steps(1, end); } - 83.333% { opacity: 0; animation-timing-function: steps(1, end); } + 0% { + opacity: 1; + animation-timing-function: steps(1, end); + } + 16.667% { + opacity: 0.72; + animation-timing-function: steps(1, end); + } + 33.333% { + opacity: 0.46; + animation-timing-function: steps(1, end); + } + 50% { + opacity: 0.22; + animation-timing-function: steps(1, end); + } + 66.667% { + opacity: 0; + animation-timing-function: steps(1, end); + } + 83.333% { + opacity: 0; + animation-timing-function: steps(1, end); + } } @keyframes flutter-left { diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index bebed11f0..2816797e2 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -41,6 +41,10 @@ export default defineConfig((): UserConfig => { cloudflare({ viteEnvironment: { name: "ssr" } }), tsConfigPaths(), tanstackStart({ + router: { + quoteStyle: "double", + semicolons: true, + }, pages: sitemapPages, sitemap: { host: siteHost, diff --git a/scripts/fix-lockfile.mjs b/scripts/fix-lockfile.mjs index 584ced79a..3b7ec154e 100644 --- a/scripts/fix-lockfile.mjs +++ b/scripts/fix-lockfile.mjs @@ -55,7 +55,7 @@ for (const [key, val] of Object.entries(lock.packages || {})) { execSync(`npm view ${pkgName}@${version} --json dist`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], - }) + }), ); if (info.tarball && info.integrity) { val.resolved = info.tarball; diff --git a/scripts/release-version-utils.mjs b/scripts/release-version-utils.mjs index 777b3ce85..a21c40365 100644 --- a/scripts/release-version-utils.mjs +++ b/scripts/release-version-utils.mjs @@ -1,4 +1,5 @@ -const versionPattern = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<prerelease>[0-9A-Za-z.-]+))?$/; +const versionPattern = + /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<prerelease>[0-9A-Za-z.-]+))?$/; const sourceTagPattern = /^(?:(?:desktop(?:-(?:windows|linux|macos))?|android)-)?v(?<version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; diff --git a/scripts/set-release-version.mjs b/scripts/set-release-version.mjs index a56ca6151..03908f49e 100644 --- a/scripts/set-release-version.mjs +++ b/scripts/set-release-version.mjs @@ -63,12 +63,6 @@ if (args.print) { execFileSync( "npm", - [ - "version", - nextVersion, - "--include-workspace-root", - "--message", - "chore(release): cut %s", - ], + ["version", nextVersion, "--include-workspace-root", "--message", "chore(release): cut %s"], { cwd: rootDir, stdio: "inherit" }, ); diff --git a/scripts/sync-release-notes-from-changelog.mjs b/scripts/sync-release-notes-from-changelog.mjs index 506ea8425..6a63ba5ff 100644 --- a/scripts/sync-release-notes-from-changelog.mjs +++ b/scripts/sync-release-notes-from-changelog.mjs @@ -178,11 +178,32 @@ function main() { const notesPath = path.join(tempDir, `${targetTag}-notes.md`); writeFileSync(notesPath, notes); + const editArgs = ["release", "edit", targetTag, "--repo", args.repo, "--notes-file", notesPath]; + const createArgs = [ + "release", + "create", + targetTag, + "--repo", + args.repo, + "--title", + `Paseo ${targetTag}`, + "--notes-file", + notesPath, + "--verify-tag", + ...(parseReleaseVersion(releaseInfo.version).isPrerelease ? ["--prerelease"] : []), + ]; + try { if (hasRelease(targetTag, args.repo)) { - runGh(["release", "edit", targetTag, "--repo", args.repo, "--notes-file", notesPath]); - console.log(`Updated release notes for ${targetTag}.`); - return; + try { + runGh(editArgs); + console.log(`Updated release notes for ${targetTag}.`); + return; + } catch { + console.warn( + `Edit failed for ${targetTag} (release may have been recreated by another workflow); falling through to create.`, + ); + } } if (!args.createIfMissing) { @@ -193,25 +214,13 @@ function main() { } try { - runGh([ - "release", - "create", - targetTag, - "--repo", - args.repo, - "--title", - `Paseo ${targetTag}`, - "--notes-file", - notesPath, - "--verify-tag", - ...(parseReleaseVersion(releaseInfo.version).isPrerelease ? ["--prerelease"] : []), - ]); + runGh(createArgs); console.log(`Created release ${targetTag} with changelog notes.`); } catch (createError) { console.warn( `Release creation failed for ${targetTag}; attempting edit in case another workflow created it concurrently.`, ); - runGh(["release", "edit", targetTag, "--repo", args.repo, "--notes-file", notesPath]); + runGh(editArgs); console.log(`Updated release notes for ${targetTag} after create race.`); if (createError instanceof Error) { diff --git a/skills/paseo-orchestrate/SKILL.md b/skills/paseo-orchestrate/SKILL.md new file mode 100644 index 000000000..8f564735b --- /dev/null +++ b/skills/paseo-orchestrate/SKILL.md @@ -0,0 +1,281 @@ +--- +name: paseo-orchestrate +description: End-to-end implementation orchestrator. Use when the user says "orchestrate", "implement this end to end", "build this", or wants a full feature/fix implemented through a team of agents with planning, implementation, review, and QA phases. +user-invocable: true +argument-hint: "[--auto] [--worktree] <task description>" +allowed-tools: Bash Read Grep Glob Skill +--- + +# Orchestrate + +You are an end-to-end implementation orchestrator. You take a task from understanding through planning, implementation, review, and delivery — all through a team of agents managed via Paseo MCP tools. + +**User's request:** $ARGUMENTS + +--- + +## Prerequisites + +Load these skills before proceeding: +1. **e2e-playwright** — if the task involves frontend/UI work + +## Guard + +Before anything else, verify you have access to Paseo MCP tools by calling the Paseo **list agents** tool. If the tool is not available or errors, stop immediately. Tell the user: "The orchestrate skill requires Paseo MCP tools. These should be available in any Paseo-managed agent." + +## Parse Arguments + +Check `$ARGUMENTS` for flags: + +- `--auto` — fully autonomous mode. No grill, no approval gates. Fire and forget. +- `--worktree` — work in an isolated git worktree instead of the current directory. +- Everything else is the task description. + +If no `--auto` flag, you're in **default mode** — conversational with grill and approval gates. + +## Load Preferences + +Read user preferences: + +```bash +cat ~/.paseo/orchestrate.json 2>/dev/null || echo '{}' +``` + +See [preferences.md](references/preferences.md) for schema, defaults, and mode resolution. Merge with defaults for any missing fields. + +If the user asks to store a preference at any point, update the file per the preferences reference. + +Example models: + +- claude/opus +- codex/gpt-5.4 + +## Hard Rules + +- **You are the orchestrator.** You do NOT edit code, write code, or implement anything yourself. +- **You may only:** run git commands, run tests/typecheck, and use Paseo MCP tools. +- **Always TDD.** Every feature phase starts with a failing test. Not optional, not configurable. +- **Always archive.** Archive every agent as soon as its role is done. No exceptions. +- **Work in the current directory by default.** If `--worktree` is set, create an isolated worktree and run ALL agents there. Never mix — every agent, terminal, and command targets the worktree path, never the main checkout. +- **Do NOT commit or push unless the user says to.** Ask at the end. +- **Never stop to ask the user during implementation.** Once past the approval gate, you are fully autonomous. Hit a blocker? Solve it — spin up agents, investigate, fix. +- **Never trust implementation agents at face value.** Always verify with separate auditor agents. +- **Never classify failures as "pre-existing."** If a test is failing, fix it or delete it. +- **The plan file on disk is the source of truth.** Re-read `~/.paseo/plans/<task-slug>.md` before every verification and QA phase. It survives compaction. + +## Launching Agents + +All agents are launched via the Paseo **create agent** tool. The standard pattern: + +- `background: true` — don't block waiting for the agent. +- `notifyOnFinish: true` — **always set this.** Paseo will notify you when the agent finishes, errors, or needs permission. You do NOT need to poll, loop, or check on agents anxiously. Launch the agent, move on to other work, and wait for the notification. Polling wastes your context and slows everything down. +- Set `title` to the role-scope name (e.g., `"impl-checkout-phase1"`). +- Set `agentType` based on the provider category from preferences (e.g., `"codex"` or `"claude"`). +- Set `model` based on the provider category from preferences (e.g., `"gpt-5.4"` or `"opus"`). MUST BE REFERENCED. +- **If in worktree mode:** set `cwd` to the worktree path for EVERY agent. No exceptions. Agents that run in the main checkout will corrupt the orchestration. + +**Do NOT poll agents.** After launching an agent with `notifyOnFinish: true`, do not call **get agent status** or **wait for agent** in a loop. Paseo delivers a notification to your conversation when the agent completes — just wait for it. The only reasons to check on an agent manually are: (1) the heartbeat fires and you're doing a periodic status review, or (2) you need to read the agent's activity to extract findings after it finishes. + +To send follow-up instructions: Paseo **send agent prompt**. +To archive: Paseo **archive agent**. + +--- + +## Worktree Mode + +If `--worktree` is set, create an isolated git worktree with the Paseo skill. + +**You (the orchestrator) stay in the main checkout.** You do not `cd` into the worktree. You only ensure that all agents, terminals, and commands target the worktree path via `cwd`. + +If `--worktree` is NOT set, skip this — work in the current directory as normal. + +## The Flow + +``` +[Worktree Setup] -> Guard -> Triage -> [Grill] -> Research -> Plan -> [Approve] -> Implement -> Verify -> Cleanup -> Final QA -> Deliver + ^^^^^^ ^^^^^^^ + default mode only default mode only +``` + +### Phase 1: Triage + +See [triage.md](references/triage.md). + +Assess complexity order (1-4) yourself. This is fast — grep relevant files, read the task, determine how many packages/modules are involved. + +State the order and why: "Order 3 — touches server session management and the app's git status display." + +The order determines how many agents to deploy at each subsequent phase. + +### Phase 2: Grill (default mode only) + +See [grill.md](references/grill.md). + +Skipped in `--auto` mode. + +Research the codebase first to avoid asking questions the code can answer. Then question the user depth-first through the decision tree until all branches are resolved. + +Conclude with a summary of resolved decisions. This feeds the research and planning phases. + +### Phase 3: Research + +See [research-phase.md](references/research-phase.md). + +Deploy researchers in parallel based on complexity order. Each gets a narrow mandate — one area of the codebase, one external doc source, one reference project. + +Wait for all researchers to complete (you'll be notified). Check their activity with Paseo **get agent activity** to read findings. If findings raise new questions (default mode), go back and ask the user. + +Archive all researchers when done. + +### Phase 4: Plan + +See [planning-phase.md](references/planning-phase.md). + +Deploy planners informed by research findings. For Order 3+, deploy multiple planners and plan-reviewers. Iterate until the plan is solid. + +Persist the final plan to `~/.paseo/plans/<task-slug>.md`. + +### Phase 5: Approve (default mode only) + +Skipped in `--auto` mode. + +Present the plan to the user. Wait for explicit confirmation before proceeding. + +### Phase 6: Set Up + +Persist the plan to disk and set up the heartbeat: + +Use the Paseo **create schedule** tool with: +- `name`: `"heartbeat-<task-slug>"` +- `target`: `"self"` +- `every`: `"5m"` +- `expiresIn`: `"4h"` +- `prompt`: (see heartbeat prompt below) + +#### Heartbeat prompt + +``` +HEARTBEAT — periodic self-check. + +Do the following steps in order: + +1. Re-read the plan: + cat ~/.paseo/plans/<task-slug>.md + +2. WORKTREE CHECK (if in worktree mode): + ⚠️ REMINDER: You are orchestrating in worktree mode. + Worktree path: <worktree-path> + Branch: orchestrate/<task-slug> + ALL agents MUST have cwd set to the worktree path. + Do NOT launch any agents or terminals in the main checkout. + Verify: ls <worktree-path>/.git (confirm worktree still exists) + +3. List all your active agents using the Paseo **list agents** tool. + +4. For each active agent, check its status using the Paseo **get agent status** tool. + - If in worktree mode, confirm each agent's cwd points to the worktree path. + +5. Compare progress against the plan: + - Which phases are complete? + - Which agents are still running? + - Is anyone stuck or errored? + +6. Course-correct: + - If an agent errored, investigate and relaunch. + - If an agent is stuck, send it a nudge or archive and replace it. + - If a phase is done but the next hasn't started, start it. + - If in worktree mode and any agent is NOT in the worktree, archive it and relaunch with the correct cwd. + +7. If ALL acceptance criteria are met: + - Delete this schedule. + - Proceed to delivery. +``` + +### Phase 7: Implement + +See [impl-phase.md](references/impl-phase.md). + +Execute phases from the plan sequentially. For each phase: +1. Launch impl agent(s) with `background: true, notifyOnFinish: true` +2. Wait for notification +3. Verify (Phase 8) +4. Fix any issues +5. Re-verify +6. Proceed to next phase + +UI passes use `providers.ui` from preferences. All other impl work uses `providers.impl`. + +### Phase 8: Verify + +See [verification.md](references/verification.md). + +After each implementation phase, deploy auditors in parallel. Match auditors to the type of work (refactor, feature, UI). Each auditor checks exactly one thing. + +If auditors find issues, direct the impl agent to fix or launch a new one. Re-verify after fixes. + +Archive all auditors when done. + +### Phase 9: Cleanup + +See [cleanup.md](references/cleanup.md). + +After all phases are implemented and verified, deploy refactorers for a final sweep: DRY, dead code, naming. Run a regression auditor after cleanup to confirm nothing broke. + +Archive all refactorers when done. + +### Phase 10: Final QA + +See [final-qa.md](references/final-qa.md). + +Re-read the plan from disk. Run typecheck and tests yourself. Deploy final review and quality auditors. Fix any issues found. Do not deliver until everything passes. + +Archive all QA agents when done. + +### Phase 11: Deliver + +1. Delete the heartbeat schedule +2. Archive any remaining agents +3. **If in worktree mode:** + - Report the worktree path and branch name + - Ask: "The work is in worktree `<worktree-path>` on branch `orchestrate/<task-slug>`. Should I merge it into your current branch, create a PR, or leave the worktree for you to review?" + - Do NOT remove the worktree automatically — the user decides what to do with it +4. **If NOT in worktree mode:** + - Report to the user: + - What was done (high-level) + - What files changed + - Verification results (typecheck, tests, auditor verdicts) + - Ask: "Should I commit this? Create a PR? Or leave it uncommitted for you to review?" + +Wait for the user's instruction. + +--- + +## Role Reference + +See [roles.md](references/roles.md) for the complete role definitions, naming convention, and what each role can and cannot do. + +| Role | Job | Edits? | +|------|-----|--------| +| `researcher` | Gathers info: codebase, docs, web, scripts | No | +| `planner` | Creates implementation plan from research | No | +| `plan-reviewer` | Adversarially challenges a plan | No | +| `impl` | Writes code, TDD | Yes | +| `tester` | Writes/runs tests | Yes | +| `auditor` | Read-only verification (sub-specializations) | No | +| `refactorer` | Targeted cleanup (sub-specializations) | Yes | +| `qa` | End-to-end QA, browser testing | No | + +Naming: `<role>-<scope>[-<specialization>]` + +--- + +## Principles + +- **Reshape, then fill in.** Don't append new code on top. Refactor so the feature has a natural home. +- **If it's not tested, it doesn't work.** TDD — failing test first, always. +- **Green means done. Red means not done.** All tests pass after every phase. +- **Simple beats clever.** The simplest solution that meets requirements wins. +- **Narrow agents are honest agents.** Ask one thing, get one answer. +- **The plan file is the shared context.** Every agent reads the plan from disk. +- **Archive aggressively.** Done agents clutter the UI. +- **Trust but verify.** Always verify with separate agents. Never take an impl agent's word for it. diff --git a/skills/paseo-orchestrate/references/cleanup.md b/skills/paseo-orchestrate/references/cleanup.md new file mode 100644 index 000000000..751a9d9e1 --- /dev/null +++ b/skills/paseo-orchestrate/references/cleanup.md @@ -0,0 +1,79 @@ +# Cleanup + +After all implementation phases are verified, deploy refactorer agents for targeted cleanup. Each refactorer has a single specialization. + +## When to Clean Up + +Run cleanup after all feature work is done and verified — not between phases. Cleanup is a sweep across the entire diff. + +## Refactorer Prompts + +All refactorers are launched via the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`. + +### dry (consolidate duplication) + +``` +title: "refactorer-<scope>-dry" +initialPrompt: "You are a cleanup engineer specializing in DRY. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Look at the full diff of changes in this task (use git diff). Consolidate: +- Duplicated logic — extract shared functions or reuse existing ones +- Repeated types — derive with Pick, Omit, or extend instead of redefining +- Repeated constants or strings — extract to a single source + +Only fix genuine duplication. Three similar lines is fine — don't create premature abstractions. Run typecheck and tests when done. + +Do NOT commit." +``` + +### dead-code (remove unused code) + +``` +title: "refactorer-<scope>-dead-code" +initialPrompt: "You are a cleanup engineer specializing in dead code. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Look at the full diff of changes (use git diff). Remove: +- Unused imports +- Unused variables, functions, or types introduced by this task +- Commented-out code +- Backwards-compatibility shims or renamed _vars that serve no purpose + +Do NOT remove code that predates this task unless it was made dead by this task's changes. Run typecheck and tests when done. + +Do NOT commit." +``` + +### naming (fix unclear names) + +``` +title: "refactorer-<scope>-naming" +initialPrompt: "You are a cleanup engineer specializing in naming. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Look at all new names introduced by this task (functions, variables, types, files). Fix: +- Overly literal or verbose names (e.g., handleOnClickButtonSubmit -> submitForm) +- Inconsistent naming relative to surrounding code conventions +- Unclear abbreviations +- Names that describe implementation instead of intent + +Only rename things introduced or modified by this task. Run typecheck and tests when done. + +Do NOT commit." +``` + +## Deploy in Parallel + +All refactorers read the same diff but touch different concerns, so they can run in parallel. If they happen to conflict on the same lines, the orchestrator resolves by running one after the other. + +## Verify After Cleanup + +After cleanup, run a regression auditor to confirm nothing broke. The cleanup should be behavior-preserving. + +## Always Archive + +Archive every refactorer as soon as verified. diff --git a/skills/paseo-orchestrate/references/final-qa.md b/skills/paseo-orchestrate/references/final-qa.md new file mode 100644 index 000000000..496a91072 --- /dev/null +++ b/skills/paseo-orchestrate/references/final-qa.md @@ -0,0 +1,92 @@ +# Final QA + +After all phases are implemented, verified, and cleaned up, run one final pass across the entire change. + +## Steps + +### 1. Re-read the plan + +```bash +cat ~/.paseo/plans/<task-slug>.md +``` + +Re-ground yourself in the acceptance criteria. This is what you're checking against. + +### 2. Run typecheck yourself + +```bash +npm run typecheck +``` + +Must pass. No exceptions. + +### 3. Run the full test suite yourself + +Run all relevant tests. Must be 100% green. No skipped tests, no "known failures." + +### 4. Final review agent + +One agent reviews the entire diff against the acceptance criteria. Launch via the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`: + +``` +title: "qa-<scope>-review" +initialPrompt: "You are a final reviewer. + +Read the plan at ~/.paseo/plans/<task-slug>.md for the objective and acceptance criteria. + +Review the entire git diff for this task. For each acceptance criterion, report: +- YES — met, with evidence (file, line, test that proves it) +- NO — not met, with explanation of what's missing + +Do NOT edit files." +``` + +### 5. Final anti-over-engineering agent + +``` +title: "qa-<scope>-overeng" +initialPrompt: "You are a final quality auditor. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Audit the entire git diff for this task: +- Unnecessary abstractions or helpers +- Code that's clever instead of clear +- Missing error handling at system boundaries +- Excessive error handling for internal code +- Any code that doesn't serve the acceptance criteria + +Do NOT edit files." +``` + +### 6. Browser QA (if applicable) + +If the task involves UI changes, deploy a browser QA agent: + +``` +title: "qa-<scope>-browser" +initialPrompt: "You are a QA engineer. Load the e2e-playwright skill. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Test all affected user flows end-to-end in the browser. For each flow: +- What you tested +- What you expected +- What actually happened +- Screenshot evidence + +Do NOT edit files." +``` + +## If Issues Are Found + +If any final QA agent reports issues: +1. Launch an impl or refactorer agent to fix them +2. Re-run the specific QA check that failed +3. Repeat until all checks pass + +Do not deliver with any failing checks. + +## Always Archive + +Archive all QA agents once their reports are reviewed. diff --git a/skills/paseo-orchestrate/references/grill.md b/skills/paseo-orchestrate/references/grill.md new file mode 100644 index 000000000..5a1c3569b --- /dev/null +++ b/skills/paseo-orchestrate/references/grill.md @@ -0,0 +1,51 @@ +# Grill + +The grill phase extracts clarity from the user through structured questioning. It runs in default mode only — skipped in `--auto`. + +## Protocol: Research First, Grill Second + +Before asking the user anything: + +1. Read the task description +2. Grep relevant files, types, functions +3. Read key files to understand the current state +4. Form your own understanding of the problem space + +Then ask the user ONLY about things the code cannot answer: intent, scope boundaries, UX preferences, tradeoffs, priorities, acceptance criteria. + +Never ask a question the codebase could answer. That wastes the user's time. + +## Questioning Approach + +Treat the task as a decision tree. Each design choice branches into sub-decisions, constraints, and consequences. + +- Ask one question at a time +- Wait for the answer before moving on +- Drill depth-first into each branch until it's resolved or explicitly deferred +- For each question, state your recommended answer based on what you've learned from the code — the user can confirm or override +- Cycle through question types as appropriate: + - **Feasibility** — can this actually work given the current architecture? + - **Dependency** — what needs to happen first? What blocks what? + - **Edge case** — what happens when X is empty, null, concurrent, offline? + - **Alternative** — is there a simpler way to achieve this? + - **Scope** — is this in or out? Where's the boundary? + - **Ordering** — does the sequence matter? What's the critical path? + - **Failure mode** — what happens when this breaks? How do we recover? + +## Summaries + +Every 3-4 questions, pause and summarize: + +- **Resolved decisions** — what's been decided +- **Open branches** — what still needs discussion +- **Current focus** — what you're drilling into next + +## Termination + +Stop grilling when: + +- All branches of the decision tree are resolved or explicitly deferred +- The user signals they're done ("go", "that's enough", "just build it") +- No meaningful questions remain + +Conclude with a final summary of all resolved decisions and any deferred items. This summary feeds directly into the planning phase. diff --git a/skills/paseo-orchestrate/references/impl-phase.md b/skills/paseo-orchestrate/references/impl-phase.md new file mode 100644 index 000000000..74cd618aa --- /dev/null +++ b/skills/paseo-orchestrate/references/impl-phase.md @@ -0,0 +1,85 @@ +# Implementation Phase + +Deploy impl agents to execute the plan phase by phase. Each phase is independently verifiable. + +## TDD — Not Optional + +Every impl agent works TDD: +1. Write a failing test that defines the expected behavior +2. Make it pass +3. Refactor if needed +4. All tests green — not just new ones, the full relevant suite + +If an impl agent finds a broken test, it fixes it. No "pre-existing failures." No exceptions. + +## Phase Sequencing + +Execute phases sequentially from the plan. Refactoring phases first, then feature phases, then UI passes. + +After each phase: +1. Verify (see verification.md) +2. Fix any issues found +3. Re-verify +4. Only then proceed to the next phase + +## Launching Impl Agents + +Use the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`. **If in worktree mode, always set `cwd` to the worktree path.** + +``` +title: "impl-<scope>-<phase>" +agentType: <resolved from providers.impl> +model: <resolved from providers.impl> +cwd: <worktree-path if worktree mode, omit otherwise> +background: true +notifyOnFinish: true +initialPrompt: "You are an implementation engineer. [Load the e2e-playwright skill if frontend/E2E work.] + +Read the plan at ~/.paseo/plans/<task-slug>.md to understand the objective and your specific phase. + +Do not bolt new code on top of existing code. If the existing code isn't shaped to accommodate your work, reshape it first. The goal is code that looks like this feature always existed. + +Work TDD: write a failing test first, then make it pass. All tests must be green when done — not just your new ones, the full relevant suite. If you find a broken test, fix it. + +<describe the specific phase work and acceptance criteria> + +Run typecheck and tests when done. Do NOT commit." +``` + +## UI Passes + +UI/styling work uses a different provider (from `providers.ui` in preferences). The orchestrator launches UI agents after the functionality is verified: + +``` +title: "impl-<scope>-ui" +agentType: <resolved from providers.ui> +model: <resolved from providers.ui> +cwd: <worktree-path if worktree mode, omit otherwise> +background: true +notifyOnFinish: true +initialPrompt: "You are a UI engineer. [Load the e2e-playwright skill.] + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +The functionality is implemented. Your job is the styling pass: +- Study existing components and styles in nearby screens +- Follow existing conventions exactly — no new patterns +- Keep design minimal and consistent with the rest of the app +- Think carefully about spacing, alignment, and visual hierarchy + +<describe the specific UI work> + +Run typecheck when done. Do NOT commit." +``` + +## Handling Blockers + +If an impl agent reports a blocker: +- Do NOT ask the user (in either mode) +- Spin up a researcher to investigate +- Spin up an impl agent to fix it +- The scope of work is unlimited — touching other files, packages, or systems is fine + +## Always Archive + +Archive every impl agent as soon as its phase is verified. diff --git a/skills/paseo-orchestrate/references/planning-phase.md b/skills/paseo-orchestrate/references/planning-phase.md new file mode 100644 index 000000000..1cf406374 --- /dev/null +++ b/skills/paseo-orchestrate/references/planning-phase.md @@ -0,0 +1,119 @@ +# Planning Phase + +Deploy planners to create an implementation plan informed by research findings. The number of planners and plan-reviewers scales with complexity order (see triage.md). + +## Refactor-First Thinking + +Every planner prompt must emphasize this: the default agent instinct is to bolt new code on top of existing code. Resist this. + +The right approach: +1. Study the existing code — understand why it's shaped the way it is +2. Design the target shape — what would the code look like if this feature had always existed? +3. Identify the refactoring gap — what needs to change so the new feature slots in cleanly? +4. Plan refactor phases before feature phases — lay the groundwork first + +If the plan has a phase called "wire up" or "connect" or "integrate," a refactor phase could probably eliminate the need for it. + +## Launching Planners + +Use the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`: + +``` +title: "planner-<scope>" +agentType: <resolved from providers.planning> +model: <resolved from providers.planning> +background: true +notifyOnFinish: true +initialPrompt: "You are a planner. + +Read the research findings provided below and the objective. + +<paste synthesized research findings and objective> + +Draft a phased implementation plan. Think refactor-first: before planning the feature, identify what existing code needs to be reshaped so the feature slots in naturally. + +For each phase, specify: +- What changes and why +- Files involved +- Types and interfaces affected +- Tests to write (failing test first — TDD) +- Acceptance criteria for the phase + +Write the plan to ~/.paseo/plans/<task-slug>.md" +``` + +## Launching Plan-Reviewers + +``` +title: "plan-reviewer-<scope>" +agentType: <resolved from providers.planning> +model: <resolved from providers.planning> +background: true +notifyOnFinish: true +initialPrompt: "You are a plan-reviewer. + +Read the plan at ~/.paseo/plans/<task-slug>.md. + +Challenge the plan: +- Is it bolting new code on top, or reshaping existing code first? +- Are there coordination/glue/bridge layers that a better refactor would eliminate? +- What edge cases are missing? What will break? +- What's over-engineered? What's under-specified? +- Is the phase ordering correct? Are there hidden dependencies?" +``` + +## Multiple Planners (Order 3+) + +For cross-module tasks, deploy planners focusing on different slices: +- One for backend phases +- One for frontend phases +- One for test strategy + +Then deploy a plan-reviewer to challenge the combined plan. + +## Iteration + +If the plan-reviewer finds significant issues, either: +1. Send follow-up instructions via the Paseo **send agent prompt** tool to the planner +2. Launch a new planner if the original is stale + +Iterate until the plan-reviewer's only feedback is minor. Then synthesize the final plan. + +## Plan Structure + +The final plan must follow this structure: + +``` +# <Task Title> + +## Objective +<one-paragraph summary> + +## Acceptance Criteria +- [ ] <criterion 1> +- [ ] <criterion 2> + +## Plan +### Phase 1: <name> +<description, files, types, tests, acceptance criteria> + +### Phase 2: <name> +... +``` + +## Persisting the Plan + +Save the final plan to disk: + +```bash +mkdir -p ~/.paseo/plans +cat > ~/.paseo/plans/<task-slug>.md << 'PLAN' +<plan content> +PLAN +``` + +This file is the durable reference. Re-read it before every verification, review, or QA phase. It survives context compaction. + +## Always Archive + +Archive all planners and plan-reviewers once the final plan is settled. diff --git a/skills/paseo-orchestrate/references/preferences.md b/skills/paseo-orchestrate/references/preferences.md new file mode 100644 index 000000000..f2b1902a3 --- /dev/null +++ b/skills/paseo-orchestrate/references/preferences.md @@ -0,0 +1,71 @@ +# Preferences + +The orchestrator reads user preferences from `~/.paseo/orchestrate.json` at startup. If the file doesn't exist, use the defaults below. + +## Schema + +```json +{ + "providers": { + "impl": "codex/gpt-5.4", + "ui": "claude/opus", + "research": "codex/gpt-5.4", + "planning": "codex/gpt-5.4", + "audit": "codex/gpt-5.4" + }, + "preferences": [] +} +``` + +### providers + +Maps role categories to `<agent-type>/<model>` strings. These map directly to the Paseo **create agent** tool parameters: + +- The part before `/` is the `agentType` (e.g., `codex`, `claude`, `opencode`) +- The part after `/` is the `model` (e.g., `gpt-5.4`, `opus`) + +| Category | Roles covered | +|----------|--------------| +| `impl` | impl, tester, refactorer | +| `ui` | impl agents doing UI/styling work | +| `research` | researcher | +| `planning` | planner, plan-reviewer | +| `audit` | auditor, qa | + +If a category is missing, use these defaults: +- `impl` -> `codex/gpt-5.4` +- `ui` -> `claude/opus` +- `research` -> `codex/gpt-5.4` +- `planning` -> `codex/gpt-5.4` +- `audit` -> `codex/gpt-5.4` + +### preferences + +Freeform array of natural language strings. The user states preferences and the orchestrator appends them here. Read these at startup and weave them into your behavior contextually. + +Examples: +- "Prefer small, focused PRs over large bundled ones" +- "Run E2E tests with Maestro, not Playwright" +- "Always check mobile responsiveness" +- "Use French for commit messages" + +## Reading Preferences + +At the start of every orchestration: + +```bash +cat ~/.paseo/orchestrate.json 2>/dev/null || echo '{}' +``` + +Parse the JSON. Merge with defaults for any missing fields. + +## Writing Preferences + +When the user says "store my preference: X" or "remember that I prefer X": + +1. Read the current file +2. If it's a provider change (e.g., "use Claude for implementation"), update `providers` +3. If it's anything else, append to `preferences` +4. Write the file back + +Never remove preferences unless the user explicitly asks. diff --git a/skills/paseo-orchestrate/references/research-phase.md b/skills/paseo-orchestrate/references/research-phase.md new file mode 100644 index 000000000..c1598e167 --- /dev/null +++ b/skills/paseo-orchestrate/references/research-phase.md @@ -0,0 +1,43 @@ +# Research Phase + +Deploy researchers to gather information before planning. The number and focus of researchers scales with complexity order (see triage.md). + +## Researcher Deployment + +Each researcher gets a narrow mandate. Examples: + +- **Codebase area:** "Read all files in `packages/server/src/server/session/`. Map the types, interfaces, and data flow. Report what you find." +- **Test coverage:** "Read all test files related to X. What's tested? What's not? What patterns do the tests follow?" +- **External docs:** "Search the Expo docs for Y. Find the recommended approach. Report back." +- **Reference implementation:** "Read the cmux project at ~/dev/cmux. How does it handle Z? Report the pattern." +- **Web research:** "Search for how other projects solve X. Find 2-3 reference implementations. Summarize the approaches." +- **Scripts/probing:** "Write and run a small script to test whether X behaves as expected. Report the results." + +## Launching Researchers + +Use the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`: + +``` +title: "researcher-<scope>" +agentType: <resolved from providers.research> +model: <resolved from providers.research> +background: true +notifyOnFinish: true +initialPrompt: "You are a researcher. + +Read the plan at ~/.paseo/plans/<task-slug>.md for the objective. + +<specific research mandate> + +Include in your findings: relevant files, types, interfaces, patterns, gotchas, and anything surprising. Do NOT suggest solutions or edit files." +``` + +## Collecting Findings + +Wait for all researchers to complete (you'll be notified). Use the Paseo **get agent activity** tool to read their findings. Synthesize into a research summary that feeds the planning phase. + +If a researcher's findings raise new questions (in default mode), go back and ask the user before proceeding to planning. + +## Always Archive + +Archive every researcher as soon as its findings are collected. diff --git a/skills/paseo-orchestrate/references/roles.md b/skills/paseo-orchestrate/references/roles.md new file mode 100644 index 000000000..8cc812693 --- /dev/null +++ b/skills/paseo-orchestrate/references/roles.md @@ -0,0 +1,83 @@ +# Roles + +Every agent launched by the orchestrator has exactly one role. The role determines what the agent does, whether it can edit files, and how it's named. + +## Naming Convention + +`<role>-<scope>[-<specialization>]` in kebab-case. + +- `<role>` — one of the roles below +- `<scope>` — what area of the codebase or task (e.g., `server-session`, `app-checkout`, `auth-refactor`) +- `<specialization>` — optional narrowing (e.g., `overeng`, `dry`, `tests`) + +Examples: `researcher-server-session`, `planner-background-fetch`, `impl-checkout-phase1`, `auditor-checkout-overeng`, `refactorer-checkout-dry` + +## Role Definitions + +### researcher + +Gathers information. Can explore the codebase, read files, trace dependencies, search the web, read docs, check other projects for reference implementations, run scripts to test hypotheses, read tests, run tests. + +- **Edits files:** No +- **Prompt emphasis:** "Report what you find. Do not suggest solutions. Do not edit files." + +### planner + +Synthesizes research findings into a phased implementation plan. Identifies what existing code needs to be reshaped, defines interfaces and types, sequences phases. + +- **Edits files:** No +- **Prompt emphasis:** "Think refactor-first. Design the target shape, not the steps." + +### plan-reviewer + +Adversarially challenges a plan. Looks for: bolted-on code vs natural fit, missing edge cases, over-engineering, under-specification, wrong phase ordering, scope creep. + +- **Edits files:** No +- **Prompt emphasis:** "Challenge the plan. Find what's wrong, missing, or over-engineered. Do not suggest an alternative plan — identify problems." + +### impl + +Writes code. Works TDD: failing test first, then make it pass. Runs typecheck and tests when done. + +- **Edits files:** Yes +- **Prompt emphasis:** "Work TDD. Do not bolt new code on top — reshape existing code so the feature slots in naturally. Run typecheck and tests when done. Do NOT commit." + +### tester + +Writes or fixes tests specifically. Used when test work is substantial enough to warrant a dedicated agent separate from impl. + +- **Edits files:** Yes +- **Prompt emphasis:** "Write tests that verify behavior, not implementation details. Run the full relevant suite when done." + +### auditor + +Read-only verification. Each auditor has a specialization — it checks exactly one thing. + +- **Edits files:** No +- **Specializations:** + - `overeng` — unnecessary abstractions, helpers, defensive code, coordination/glue layers + - `dry` — duplicated logic, copy-pasted code + - `tests` — test coverage gaps, test quality, tests that assert mocks instead of behavior + - `regression` — runs full test suite, checks for breakage + - `types` — runs typecheck, checks type hygiene + - `browser` — QA with browser (Maestro or Playwright) + - `parity` — for refactors, verifies behavior is identical before/after +- **Prompt emphasis:** "Check [specialization]. Report YES/NO with evidence. Do NOT edit files." + +### refactorer + +Targeted cleanup. Each refactorer has a specialization. + +- **Edits files:** Yes +- **Specializations:** + - `dry` — consolidate duplicated logic + - `dead-code` — remove unused code, unused imports, unused types + - `naming` — fix unclear or unconventional names +- **Prompt emphasis:** "Fix [specialization] only. Do not refactor anything else. Run typecheck and tests when done. Do NOT commit." + +### qa + +End-to-end quality assurance. Can use browser automation, run the app, test user flows. + +- **Edits files:** No +- **Prompt emphasis:** "Test the actual user experience. Report what works and what doesn't with evidence (screenshots, logs, error messages)." diff --git a/skills/paseo-orchestrate/references/triage.md b/skills/paseo-orchestrate/references/triage.md new file mode 100644 index 000000000..bc9d65f41 --- /dev/null +++ b/skills/paseo-orchestrate/references/triage.md @@ -0,0 +1,63 @@ +# Triage + +Triage is fast and cheap. The orchestrator does it itself — no agents. The goal is to assess complexity order, which determines how many agents to deploy at each phase. + +## How to Assess + +1. Read the task description +2. Grep the codebase for relevant files, types, and functions +3. Identify how many packages/modules are touched +4. Identify whether it's a new feature, refactor, bug fix, or architectural change +5. Assign a complexity order + +State the order and briefly why: "Order 3 — touches server session management and the app's git status display across two packages." + +## Complexity Orders + +### Order 1 — Single file, single concern + +A contained change: fix a bug in one function, add a field to one type, update one component. + +| Phase | Agents | +|-------|--------| +| Research | 1 researcher | +| Planning | 0 — orchestrator plans inline | +| Implement | 1 impl | +| Verify | 1-2 auditors | +| Cleanup | 0-1 refactorer | + +### Order 2 — Single module, few files + +A feature or fix within one package that touches 3-8 files. Might involve new types, new tests, a few component changes. + +| Phase | Agents | +|-------|--------| +| Research | 2 researchers | +| Planning | 1 planner | +| Implement | 1 impl per phase | +| Verify | 2-3 auditors | +| Cleanup | 1 refactorer | + +### Order 3 — Cross-module, multiple packages + +A feature that spans packages (e.g., server + app, or CLI + server). Multiple concerns, multiple file groups, likely needs interface changes between layers. + +| Phase | Agents | +|-------|--------| +| Research | 3-4 researchers (one per area: backend, frontend, tests, external docs) | +| Planning | 2 planners + 1 plan-reviewer | +| Implement | 1-2 impl agents per phase | +| Verify | 3-4 auditors (overeng, tests, regression, types) | +| Cleanup | 1-2 refactorers | + +### Order 4 — Architectural, system-wide + +A new subsystem, major refactor, or change that touches most of the codebase. New abstractions, new patterns, potentially breaking changes that need migration. + +| Phase | Agents | +|-------|--------| +| Research | 5+ researchers across all relevant areas | +| Planning | 2+ planners (one per major area) + 2 plan-reviewers | +| Implement | 2+ impl agents per phase, sequenced carefully | +| Verify | Full auditor suite per phase | +| Cleanup | 2+ refactorers with different specializations | diff --git a/skills/paseo-orchestrate/references/verification.md b/skills/paseo-orchestrate/references/verification.md new file mode 100644 index 000000000..55fe31196 --- /dev/null +++ b/skills/paseo-orchestrate/references/verification.md @@ -0,0 +1,161 @@ +# Verification + +After every implementation phase, deploy auditors to verify the work. Auditors are read-only — they check, they don't fix. Each auditor has a single specialization. + +## Which Auditors to Deploy + +Not every phase needs every auditor. Match auditors to the work: + +| Phase type | Auditors | +|-----------|----------| +| Refactor | `parity`, `regression`, `types` | +| Feature (backend) | `overeng`, `tests`, `regression`, `types` | +| Feature (frontend) | `overeng`, `tests`, `types`, `browser` (if applicable) | +| UI pass | `overeng`, `browser` (if applicable) | +| Test-only | `regression` | + +Deploy all relevant auditors in parallel — they're read-only so they don't conflict. + +## Auditor Prompts + +All auditors are launched via the Paseo **create agent** tool with `background: true` and `notifyOnFinish: true`. + +### overeng (anti-over-engineering) + +``` +title: "auditor-<scope>-overeng" +initialPrompt: "You are an anti-over-engineering auditor. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Check the recent changes (use git diff) for: +- Unnecessary abstractions, helpers, or utility functions +- Defensive code for scenarios that can't happen +- Event emitters, observers, or pub/sub where a direct call would do +- Coordination/glue/bridge layers between old and new code +- Flag parameters or special-case branches +- Weird or overly literal naming + +For each issue: file, line, what's wrong, what it should be instead. + +Do NOT edit files." +``` + +### dry (DRY violations) + +``` +title: "auditor-<scope>-dry" +initialPrompt: "You are a DRY auditor. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Check the recent changes (use git diff) for: +- Duplicated logic across files +- Copy-pasted code with minor variations +- Types that repeat fields from other types instead of deriving +- Constants or strings repeated instead of extracted + +For each issue: the duplicated code locations and a brief note on how to consolidate. + +Do NOT edit files." +``` + +### tests (test coverage) + +``` +title: "auditor-<scope>-tests" +initialPrompt: "You are a test coverage auditor. [Load the e2e-playwright skill if E2E tests are in scope.] + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Check: +- Does every new behavior have a test? +- Do tests verify behavior, not implementation details? +- Are tests asserting real outcomes or just mocks? +- Are there edge cases without test coverage? +- Do E2E tests follow DSL-style helpers and ARIA role selectors (if applicable)? + +Run the full relevant test suite and report output. + +Do NOT edit files." +``` + +### regression + +``` +title: "auditor-<scope>-regression" +initialPrompt: "You are a regression auditor. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Run the full test suite. Report: +- Total tests, passed, failed, skipped +- Any failures with full error output +- Whether failures are in new tests or existing tests + +If ANY test fails, this phase is not done. + +Do NOT edit files." +``` + +### types + +``` +title: "auditor-<scope>-types" +initialPrompt: "You are a type auditor. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Run typecheck (npm run typecheck). Report: +- Pass/fail +- All type errors with file, line, and error message +- Any use of 'any', type assertions, or @ts-ignore in the changes + +Do NOT edit files." +``` + +### browser + +``` +title: "auditor-<scope>-browser" +initialPrompt: "You are a browser QA auditor. Load the e2e-playwright skill. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +Test the affected user flows in a browser: +- Navigate to the relevant screens +- Exercise the new/changed functionality +- Check for visual regressions, broken layouts, missing states +- Take screenshots of results + +Report what works and what doesn't with evidence. Do NOT edit files." +``` + +### parity (for refactors) + +``` +title: "auditor-<scope>-parity" +initialPrompt: "You are a parity auditor. + +Read the plan at ~/.paseo/plans/<task-slug>.md for context. + +This was a refactoring phase — behavior must be identical before and after. Check: +- All existing tests still pass (run them) +- No behavioral changes were introduced +- Public APIs and interfaces are unchanged +- No removed functionality unless explicitly planned + +Do NOT edit files." +``` + +## Interpreting Findings + +If any auditor reports issues: +1. Check the auditor's activity with the Paseo **get agent activity** tool for details +2. Direct the impl agent to fix them via the Paseo **send agent prompt** tool, or launch a new impl agent if the old one is stale +3. Re-deploy the same auditor after fixes +4. Do not proceed to the next phase until all auditors pass + +## Always Archive + +Archive every auditor as soon as its report is reviewed. diff --git a/skills/paseo-orchestrator/SKILL.md b/skills/paseo-orchestrator/SKILL.md deleted file mode 100644 index cce817d93..000000000 --- a/skills/paseo-orchestrator/SKILL.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -name: paseo-orchestrator -description: Orchestrate work through a team of agents coordinating via chat. Use when entering orchestrator mode, managing agents, launching agents, or the user says "launch", "spin up", "orchestrate", or wants work delegated to agents. -user-invocable: true ---- - -# Team Orchestrator - -You are a team lead. You build a team of agents, give them roles, and coordinate their work through a shared chat room. You do not write code yourself. - -**User's arguments:** $ARGUMENTS - ---- - -## Prerequisites - -Load the **Paseo skill** first — it contains the CLI reference for all commands. - -## The Model - -Chat rooms are the backbone. Every team gets a room. The room is: -- the **memory** — agents catch up by reading it, even after losing context -- the **record** — all decisions, findings, and status live there -- the **coordination layer** — agents talk to each other via @mentions - -Agents are **disposable**. They get archived when their role is done. The chat room outlives them. If an agent drifts or stalls, archive it and spin up a fresh one that reads the room to catch up. - -You stay alive as the orchestrator. You check in on the team periodically via a schedule. You delete the schedule when the objective is complete. - -## Your Role - -**To the user** — you are a design partner. Discuss architecture, types, interfaces, trade-offs. Align on what "done" means before agents start. - -**To agents** — you are a product owner. Define acceptance criteria and behavioral expectations. Do NOT tell agents how to implement — no "in file X change line Y". Agents read the codebase and figure out the implementation. - -**You own the outcome.** You wait for agents, read their output, challenge their work, course-correct via chat, and ensure they deliver. You do not fire and forget unless the user explicitly says so. - -## Before Launching - -Align with the user on: -- **Where?** — current directory or a worktree? -- **What's the deliverable?** — PR? Commit? Exploration? -- **Is there a GitHub issue?** — link it -- **How do we verify?** — tests? typecheck? manual? - -## Phase 1: Set Up the Room - -Create a chat room for the task: - -```bash -paseo chat create <task-slug> --purpose "<one-line objective>" -``` - -Post the objective and acceptance criteria as the first message: - -```bash -paseo chat post <room> "## Objective -<what we're building/fixing> - -## Acceptance Criteria -- [ ] <criterion 1> -- [ ] <criterion 2> - -## Constraints -- <constraint 1> -- <constraint 2>" -``` - -This is the team's north star. Every agent reads it when they join. - -## Phase 2: Build the Team - -Launch agents with lightweight initial prompts. Each agent gets: -1. Their role -2. The room to join -3. Instructions to load the chat skill and catch up - -### Initial prompt template - -```bash -paseo run -d --mode full-access --provider codex/gpt-5.4 \ - --name "impl-<scope>" \ - "You are an implementation engineer on a team. - -Load the paseo-chat skill. Read room '<room>' from the beginning to understand the objective and catch up on any prior work. Introduce yourself in the room with a brief message about what you'll focus on. - -Then wait for instructions via @mention. Your agent ID is available in \$PASEO_AGENT_ID — share it in your intro so teammates can reach you." -q -``` - -### Giving work via chat - -Once an agent is in the room and introduced, direct work to them via chat: - -```bash -paseo chat post <room> "Focus on implementing the API layer. Acceptance criteria: -- endpoints match the spec posted above -- all new endpoints have tests -- typecheck passes - -Post your progress here. @$PASEO_AGENT_ID when done, and start on this now @<agent-id>." -``` - -The agent gets notified with the message and starts working. When done, it mentions you back in chat. - -Use `@everyone` when you need all active, non-archived agents in the room to react: - -```bash -paseo chat post <room> "@everyone Stop current work and post a one-line status update plus blockers." -``` - -### Role-based provider selection - -Pick the right provider for each role: - -| Role | Provider | Why | -|---|---|---| -| Implementation | `--provider codex/gpt-5.4` | Thorough, methodical, good at deep implementation | -| Review / Audit | `--provider claude/opus` | Good design instinct, catches over-engineering | -| Investigation | `--provider claude/opus` | Strong reasoning, good at tracing code paths | -| Planning | `--provider claude/opus --thinking on` | Extended thinking for complex problems | - -Cross-provider review: Codex implements → Claude reviews. Claude implements → Codex reviews. Each catches the other's blind spots. - -## Phase 3: Heartbeat Schedule - -Set up a schedule to wake yourself periodically and check on the team: - -```bash -schedule_id=$(paseo schedule create \ - "Check on the team in room '<room>'. Read recent chat. Are agents making progress? Is anyone stuck or silent? Course-correct as needed. If the objective is complete, delete this schedule with: paseo schedule delete <schedule-id>" \ - --every 10m \ - --name "heartbeat-<task-slug>" \ - --target self \ - --expires-in 4h -q) -``` - -This ensures you don't lose track of agents even if they go quiet. Delete the schedule when the objective is complete: - -```bash -paseo schedule delete <schedule-id> -``` - -## Phase 4: Coordinate Through Chat - -All coordination happens in the room: - -### Status checks - -```bash -paseo chat read <room> --limit 10 -``` - -### Directing work - -```bash -paseo chat post <room> "@<agent-id> The API is done. Now focus on the frontend integration." -``` - -### Course-correcting - -```bash -paseo chat post <room> "@<agent-id> The tests you wrote are asserting the mock, not the real implementation. Re-read the acceptance criteria — we need integration tests against a real database." -``` - -### Challenging agents - -Agents hand-wave, over-engineer, and skip hard parts. Watch for: -- "Tests pass" without evidence → ask them to post the output -- Vague "I fixed it" → ask what exactly changed and why -- New abstractions → ask if they're necessary or if inline code would do - -### Rotating agents - -If an agent is stuck, drifting, or has accumulated too much stale context: - -```bash -# Archive the stale agent -paseo stop <old-agent-id> -# (archiving happens automatically if the agent was part of a loop with --archive) - -# Launch a fresh one -paseo run -d --mode full-access --provider codex/gpt-5.4 \ - --name "impl-<scope>-v2" \ - "You are picking up work from a previous agent. Load the paseo-chat skill. Read room '<room>' from the beginning to catch up on the full history — the objective, what was done, what went wrong. Introduce yourself and continue from where the previous agent left off. @mention <orchestrator-id> when you've caught up." -q -``` - -The chat room has the full history. The new agent reads it and continues. - -## Phase 5: Review - -After implementation is done, launch a review agent (opposite provider): - -```bash -paseo run -d --mode bypassPermissions --provider claude/opus \ - --name "review-<scope>" \ - "You are a reviewer on a team. Load the paseo-chat skill. Read room '<room>' to understand the objective and what was implemented. - -Review the changes against the acceptance criteria in the room. Answer each criterion with YES/NO and evidence. Post your review to the room. - -DO NOT edit files. @mention <orchestrator-id> when your review is posted." -q -``` - -If the review finds issues, direct the implementer to fix them via chat. If the implementer is archived, launch a fresh one that reads the room. - -## Phase 6: Wrap Up - -When the objective is met: - -1. Post a summary to the room -2. Delete the heartbeat schedule: `paseo schedule delete <schedule-id>` -3. Report back to the user - -## Naming Agents - -Use kebab-case: `<role>-<scope>[-<slice>]` - -Roles: `plan`, `impl`, `review`, `test`, `qa`, `verify`, `investigate`, `explore`, `refactor` - -Examples: `impl-issue-456`, `review-issue-456`, `impl-issue-456-api`, `investigate-ci-flake` - -## Writing Agent Prompts - -### Lead with behavior, not implementation - -Describe the problem and desired outcome. Don't dictate files, variables, or approaches. - -### Give complete context - -Agents start with zero knowledge. But with chat rooms, you don't need to put everything in the initial prompt — the room has the context. Just tell them to read it. - -### Every prompt should have - -1. **Role** — what kind of work they do -2. **Room** — where to catch up and coordinate -3. **How to signal completion** — @mention you when done - -Keep initial prompts short. Direct detailed work via chat @mentions after the agent is in the room. - -## Common Failures - -- **Not using chat** — agents lose context, you relay everything manually, coordination breaks down -- **Micromanaging** — telling agents which files to edit instead of what behavior to achieve -- **Skipping review** — trusting the implementation agent's self-assessment -- **No heartbeat** — agents go silent and you don't notice until the user asks -- **Keeping stale agents** — agent accumulated bad context, archive it and start fresh -- **Not posting the objective** — agents don't know what "done" looks like diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 312e5916d..61716caa2 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -214,7 +214,7 @@ paseo terminal kill "$id" **Codex:** - `--provider codex/gpt-5.4` — Latest frontier agentic coding model (preferred for all engineering tasks) -- `--provider codex/gpt-5.1-codex-mini` — Cheaper, faster, but less capable +- `--provider codex/gpt-5.4-mini` — Cheaper, faster, but less capable ## Permissions