diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 40933afc8..f7dd2745f 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -339,6 +339,8 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout. +Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below. + ### Adding a generic ACP provider Set `extends: "acp"` and provide a `command`: diff --git a/docs/data-model.md b/docs/data-model.md index b92acfa94..c11d7a65f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -139,6 +139,7 @@ Single file, validated with `PersistedConfigSchema`. listen: "127.0.0.1:6767", hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load mcp: { enabled: boolean, injectIntoAgents: boolean }, + appendSystemPrompt: string, // appended to supported provider system/developer prompts cors: { allowedOrigins: string[] }, relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, auth: { password: string } // bcrypt hash, optional diff --git a/docs/product.md b/docs/product.md index ba1c531cf..57fa1808b 100644 --- a/docs/product.md +++ b/docs/product.md @@ -71,7 +71,7 @@ Anyone who builds software: - Desktop (Electron), mobile (iOS/Android), web, CLI - Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi -- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers +- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers - Voice mode: dictate prompts or talk through problems hands-free - MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees) - Scheduled agents (cron-style triggers) via app, CLI, and MCP diff --git a/docs/providers.md b/docs/providers.md index 6e3c39150..d491d082b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -14,9 +14,15 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch. -Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. +Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. -Pi direct embeds Pi's SDK through the `@earendil-works/pi-*` packages. Keep those dependencies in sync with the Pi package-manager behavior expected by current user installs: Pi 0.75+ loads user-scoped npm packages from `~/.pi/agent/npm/`, while older `@mariozechner/pi-coding-agent` releases looked in npm's global package root and can miss or load stale extensions. +Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages. + +Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions. + +Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config. + +Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`. Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. diff --git a/package-lock.json b/package-lock.json index d165c1f36..b8cc87493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -211,432 +211,6 @@ "win32" ] }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.91.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", - "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1049.0.tgz", - "integrity": "sha512-YM8b2baoRY8ul47b4amQW2VlUthLmM8DnqdlGO20LJmmmRpjnT91SaQJai3OMehA6uE0Gig88VyDCT1vEACSww==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-node": "^3.972.43", - "@aws-sdk/eventstream-handler-node": "^3.972.16", - "@aws-sdk/middleware-eventstream": "^3.972.12", - "@aws-sdk/middleware-websocket": "^3.972.20", - "@aws-sdk/token-providers": "3.1049.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.974.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.12.tgz", - "integrity": "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.38.tgz", - "integrity": "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.40", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.40.tgz", - "integrity": "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.42.tgz", - "integrity": "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-login": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.42.tgz", - "integrity": "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.43.tgz", - "integrity": "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-ini": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.38.tgz", - "integrity": "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.42.tgz", - "integrity": "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/token-providers": "3.1049.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.42.tgz", - "integrity": "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", - "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", - "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.20.tgz", - "integrity": "sha512-LM6P0i+Lu6pi25oNw2nqxjRxiEOtLgPB7xIvHfa+FxHTRLg8wcgqu3qg2COl4QaT7Es2yCxYdeRLVYazKAwL8g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.10.tgz", - "integrity": "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1049.0.tgz", - "integrity": "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@babel/cli": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.6.tgz", @@ -3357,279 +2931,6 @@ "react": ">=16.8.0" } }, - "node_modules/@earendil-works/pi-agent-core": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.3.tgz", - "integrity": "sha512-azg09GSrckQa3ffbH09YEZC7DyHgmNSX+vmWEoEhQvp4icbzqbqLfIeMayMNEK/aGusm1SghZC4bPlDdagDALg==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.75.3", - "ignore": "^7.0.5", - "typebox": "^1.1.24", - "yaml": "^2.8.2" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-agent-core/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.3.tgz", - "integrity": "sha512-UKccS+ADlkSVJ49a00346jUfXmUi6zzzB+pPWotsyA6SxhKr2ejjkGQksGyR1DyNVrsEP/WWlsOSTUUwVlzNaA==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "^0.91.1", - "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "^2.2.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "openai": "6.26.0", - "partial-json": "^0.1.7", - "typebox": "^1.1.24" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.75.3.tgz", - "integrity": "sha512-LIi5/CdUBfcLp3BAtpLx1BfnHDLmDOQVdzYfS1H9fjjCw2dcPr9voSI5ncrhvZdgyFSnfHck4BCbNcfZk+TEHQ==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.3", - "@earendil-works/pi-ai": "^0.75.3", - "@earendil-works/pi-tui": "^0.75.3", - "@silvia-odwyer/photon-node": "^0.3.4", - "chalk": "^5.5.0", - "cross-spawn": "^7.0.6", - "diff": "^8.0.2", - "glob": "^13.0.1", - "highlight.js": "^10.7.3", - "hosted-git-info": "^9.0.2", - "ignore": "^7.0.5", - "jiti": "^2.7.0", - "minimatch": "^10.2.3", - "proper-lockfile": "^4.1.2", - "typebox": "^1.1.24", - "undici": "^8.3.0", - "yaml": "^2.8.2" - }, - "bin": { - "pi": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.6" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-tui": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.3.tgz", - "integrity": "sha512-UbhtCsae+b3Y8/ZxtBPhiOrkD66gOHvJbfvLZwhBBsNtQuvUkZY5t9MQwmb8QcDYkFRnXHaq3FcEy1hjRSfj6w==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12" - }, - "engines": { - "node": ">=22.19.0" - }, - "optionalDependencies": { - "koffi": "^2.9.0" - } - }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -7142,30 +6443,6 @@ "resolved": "packages/website", "link": true }, - "node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@gorhom/bottom-sheet": { "version": "5.2.14", "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz", @@ -8580,196 +7857,6 @@ "node": ">=18" } }, - "node_modules/@mariozechner/clipboard": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", - "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.6", - "@mariozechner/clipboard-darwin-universal": "0.3.6", - "@mariozechner/clipboard-darwin-x64": "0.3.6", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-musl": "0.3.6", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" - } - }, - "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", - "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", - "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", - "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", - "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", - "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", - "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", - "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", - "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", - "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", - "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", - "license": "Apache-2.0", - "dependencies": { - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -8795,18 +7882,6 @@ "license": "MIT", "optional": true }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -10277,69 +9352,6 @@ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", "license": "MIT" }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -11272,7 +10284,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", @@ -11285,7 +10298,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", @@ -11298,7 +10312,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", @@ -11311,7 +10326,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", @@ -11324,7 +10340,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", @@ -11337,7 +10354,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", @@ -11350,7 +10368,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", @@ -11363,7 +10382,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", @@ -11376,7 +10396,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", @@ -11389,7 +10410,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", @@ -11402,7 +10424,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", @@ -11415,7 +10438,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", @@ -11428,7 +10452,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", @@ -11441,7 +10466,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", @@ -11454,7 +10480,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", @@ -11467,7 +10494,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", @@ -11480,7 +10508,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", @@ -11493,7 +10522,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", @@ -11506,7 +10536,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", @@ -11519,7 +10550,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", @@ -11532,7 +10564,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", @@ -11545,7 +10578,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", @@ -11558,7 +10592,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", @@ -11571,7 +10606,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", @@ -11584,7 +10620,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -11747,12 +10784,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0" - }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -11789,126 +10820,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@speed-highlight/core": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", @@ -13593,12 +12504,6 @@ "@types/node": "*" } }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -16204,15 +15109,6 @@ "node": ">=0.6" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -16305,12 +15201,6 @@ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT" }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -16446,6 +15336,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/buffer-from": { @@ -17784,15 +16675,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -18821,6 +17703,7 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -19420,6 +18303,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -22934,43 +21818,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -23057,38 +21904,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/fetch-blob/node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/fetch-retry": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-4.1.1.tgz", @@ -23544,18 +22359,6 @@ "node": ">= 12.20" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -23717,74 +22520,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gaxios/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -23812,18 +22547,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -24135,53 +22858,6 @@ "integrity": "sha512-YSwLaGMOgSBx9roJlNLL12c+FRiw7VECphinc6mGucphc/ZxTHgdEz6gmJqH6NOzYEd/yr64hwjom5pZ+tJVpg==", "dev": true }, - "node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-auth-library/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/google-auth-library/node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -24600,15 +23276,6 @@ "hermes-estree": "0.29.1" } }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -26706,15 +25373,6 @@ "node": ">=6" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -26992,17 +25650,6 @@ "typescript": ">=5.0.4 <7" } }, - "node_modules/koffi": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", - "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, "node_modules/lan-network": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz", @@ -27606,12 +26253,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -27830,18 +26471,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -30859,28 +29488,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-retry/node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -31046,12 +29653,6 @@ "node": ">= 0.8" } }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, "node_modules/password-prompt": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/password-prompt/-/password-prompt-1.1.3.tgz", @@ -31102,21 +29703,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -31723,6 +30309,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -31734,6 +30321,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/property-information": { @@ -31746,30 +30334,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/protobufjs": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", - "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -33395,6 +31959,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -35051,18 +33616,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", @@ -36113,12 +34666,6 @@ "node": ">= 0.6" } }, - "node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT" - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -37657,21 +36204,6 @@ "node": ">=12" } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/xml-reader": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz", @@ -38268,9 +36800,6 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.2.133", - "@earendil-works/pi-agent-core": "^0.75.3", - "@earendil-works/pi-ai": "^0.75.3", - "@earendil-works/pi-coding-agent": "^0.75.3", "@getpaseo/highlight": "0.1.78", "@getpaseo/relay": "0.1.78", "@isaacs/ttlcache": "^2.1.4", diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts index fba5b504e..cd0055412 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -171,14 +171,24 @@ describe("resolveStartupRedirectRoute", () => { expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" }); }); - it("redirects to the host root when the persisted workspace targets a different server", () => { + it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => { const route = resolveStartupRedirectRoute({ ...baseInput, anyOnlineHostServerId: "server-2", workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, }); - expect(route).toBe("/h/server-2"); + expect(route).toBeNull(); + }); + + it("resolves the persisted workspace when another host is first online", () => { + const selection = resolveStartupWorkspaceSelection({ + ...baseInput, + anyOnlineHostServerId: "server-2", + workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, + }); + + expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" }); }); it("redirects to the host root when no persisted workspace exists", () => { diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts index 45c821f6e..0af3f1d91 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -79,11 +79,7 @@ export function resolveStartupWorkspaceSelection( if (!input.isWorkspaceSelectionLoaded) { return null; } - if ( - !input.anyOnlineHostServerId || - !input.workspaceSelection || - input.workspaceSelection.serverId !== input.anyOnlineHostServerId - ) { + if (!input.workspaceSelection) { return null; } return input.workspaceSelection; diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx index 1b200928d..cd6540363 100644 --- a/packages/app/src/app/index.test.tsx +++ b/packages/app/src/app/index.test.tsx @@ -18,6 +18,7 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => { storeReady: false, } as HostRuntimeBootstrapState, anyOnlineHostServerId: null as string | null, + isWorkspaceSelectionLoaded: true, workspaceSelection: null as ActiveWorkspaceSelection | null, }; @@ -51,7 +52,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({ vi.mock("@/stores/navigation-active-workspace-store", () => ({ navigateToWorkspace: navigateToWorkspaceMock, - useActiveWorkspaceSelection: () => state.workspaceSelection, + useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded, + useLastWorkspaceSelection: () => state.workspaceSelection, })); describe("Index route startup navigation", () => { @@ -68,6 +70,7 @@ describe("Index route startup navigation", () => { storeReady: false, }; state.anyOnlineHostServerId = null; + state.isWorkspaceSelectionLoaded = true; state.workspaceSelection = null; navigateToWorkspaceMock.mockReset(); redirectMock.mockReset(); @@ -98,6 +101,17 @@ describe("Index route startup navigation", () => { expect(redirectMock).not.toHaveBeenCalled(); }); + it("shows the startup splash while the persisted workspace selection has not loaded", async () => { + state.anyOnlineHostServerId = "server-1"; + state.isWorkspaceSelectionLoaded = false; + + await renderIndex(); + + expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); + expect(navigateToWorkspaceMock).not.toHaveBeenCalled(); + expect(redirectMock).not.toHaveBeenCalled(); + }); + it("restores the persisted workspace when the online host matches its server id", async () => { state.anyOnlineHostServerId = "server-1"; state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; @@ -111,13 +125,16 @@ describe("Index route startup navigation", () => { expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); }); - it("navigates to the host root when the persisted workspace targets a different server", async () => { + it("restores the persisted workspace even when the first online host is different", async () => { state.anyOnlineHostServerId = "server-2"; state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; await renderIndex(); - expect(redirectMock).toHaveBeenCalledWith("/h/server-2"); + expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", { + currentPathname: "/", + }); + expect(redirectMock).not.toHaveBeenCalled(); }); it("navigates to the host root when no persisted workspace exists", async () => { diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 08ecb638c..d2fbbd53d 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -8,7 +8,8 @@ import { } from "@/app/host-runtime-bootstrap"; import { navigateToWorkspace, - useActiveWorkspaceSelection, + useIsLastWorkspaceSelectionHydrated, + useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; @@ -18,20 +19,21 @@ export default function Index() { const pathname = usePathname(); const bootstrapState = useHostRuntimeBootstrapState(); const anyOnlineHostServerId = useEarliestOnlineHostServerId(); - const workspaceSelection = useActiveWorkspaceSelection(); + const workspaceSelection = useLastWorkspaceSelection(); + const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated(); const redirectRoute = resolveStartupRedirectRoute({ pathname, anyOnlineHostServerId, workspaceSelection, - isWorkspaceSelectionLoaded: true, + isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); const startupWorkspaceSelection = resolveStartupWorkspaceSelection({ pathname, anyOnlineHostServerId, workspaceSelection, - isWorkspaceSelectionLoaded: true, + isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index cb4672b38..9dcc01202 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -33,9 +33,9 @@ import { import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { - buildModelSelectorProviders, - type ModelSelectorProvider, -} from "@/components/combined-model-selector.utils"; + buildProviderSelectorProviders, + type ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; @@ -97,7 +97,7 @@ interface ControlledAgentStatusBarProps { disabled?: boolean; isModelLoading?: boolean; providerDefinitions: AgentProviderDefinition[]; - modelSelectorProviders?: ModelSelectorProvider[]; + modelSelectorProviders?: ProviderSelectorProvider[]; favoriteKeys?: Set; onToggleFavoriteModel?: (provider: string, modelId: string) => void; features?: AgentFeature[]; @@ -117,7 +117,7 @@ export interface DraftAgentStatusBarProps { selectedModel: string; onSelectModel: (modelId: string) => void; isModelLoading: boolean; - modelSelectorProviders: ModelSelectorProvider[]; + modelSelectorProviders: ProviderSelectorProvider[]; isAllModelsLoading: boolean; onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void; thinkingOptions: NonNullable; @@ -237,7 +237,7 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[ function buildFallbackModelSelectorProviders( provider: string, modelOptions: StatusOption[] | undefined, -): ModelSelectorProvider[] { +): ProviderSelectorProvider[] { if (!modelOptions || modelOptions.length === 0) { return []; } @@ -245,13 +245,16 @@ function buildFallbackModelSelectorProviders( { id: provider, label: provider, - rows: modelOptions.map((option) => ({ - favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }), - provider, - providerLabel: provider, - modelId: option.id, - modelLabel: option.label, - })), + modelSelection: { + kind: "models", + rows: modelOptions.map((option) => ({ + favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }), + provider, + providerLabel: provider, + modelId: option.id, + modelLabel: option.label, + })), + }, }, ]; } @@ -794,7 +797,7 @@ interface DesktopStatusBarContentProps { canSelectMode: boolean; canSelectModel: boolean; canSelectThinking: boolean; - modelSelectorProviders: ModelSelectorProvider[]; + modelSelectorProviders: ProviderSelectorProvider[]; modelDisabled: boolean; comboboxProviderOptions: ComboboxOption[]; comboboxModeOptions: ComboboxOption[]; @@ -1058,7 +1061,7 @@ interface SheetStatusBarContentProps { canSelectMode: boolean; canSelectModel: boolean; canSelectThinking: boolean; - modelSelectorProviders: ModelSelectorProvider[]; + modelSelectorProviders: ProviderSelectorProvider[]; modelDisabled: boolean; comboboxModeOptions: ComboboxOption[]; comboboxThinkingOptions: ComboboxOption[]; @@ -1662,7 +1665,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({ [agent?.provider, models], ); const agentModelSelectorProviders = useMemo( - () => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels), + () => + buildProviderSelectorProviders({ + providerDefinitions: agentProviderDefinitions, + modelsByProvider: agentProviderModels, + }), [agentProviderDefinitions, agentProviderModels], ); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 23ff82cd1..920a1f6d6 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -47,9 +47,13 @@ import { getProviderIcon } from "@/components/provider-icons"; import { buildSelectedTriggerLabel, filterAndRankModelRows, - type ModelSelectorProvider, - type SelectorModelRow, -} from "./combined-model-selector.utils"; + getAllProviderModelRows, + getProviderDefaultLabel, + getProviderModelRows, + resolveSelectedModelLabel, + type ProviderSelectionModelRow, + type ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; // TODO: this should be configured per provider in the provider manifest const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]); @@ -59,7 +63,7 @@ type SelectorView = | { kind: "provider"; providerId: string; providerLabel: string }; interface CombinedModelSelectorProps { - providers: ModelSelectorProvider[]; + providers: ProviderSelectorProvider[]; selectedProvider: string; selectedModel: string; onSelect: (provider: AgentProvider, modelId: string) => void; @@ -79,7 +83,7 @@ interface CombinedModelSelectorProps { interface SelectorContentProps { view: SelectorView; - providers: ModelSelectorProvider[]; + providers: ProviderSelectorProvider[]; selectedProvider: string; selectedModel: string; searchQuery: string; @@ -89,23 +93,16 @@ interface SelectorContentProps { onDrillDown: (providerId: string, providerLabel: string) => void; } -function resolveDefaultModelLabel(rows: SelectorModelRow[] | undefined): string { - if (!rows || rows.length === 0) { - return "Select model"; - } - return (rows.find((row) => row.isDefault) ?? rows[0])?.modelLabel ?? "Select model"; -} - function normalizeSearchQuery(value: string): string { return value.trim().toLowerCase(); } function sortFavoritesFirst( - rows: SelectorModelRow[], + rows: ProviderSelectionModelRow[], favoriteKeys: Set, -): SelectorModelRow[] { - const favorites: SelectorModelRow[] = []; - const rest: SelectorModelRow[] = []; +): ProviderSelectionModelRow[] { + const favorites: ProviderSelectionModelRow[] = []; + const rest: ProviderSelectionModelRow[] = []; for (const row of rows) { if (favoriteKeys.has(row.favoriteKey)) { favorites.push(row); @@ -124,7 +121,7 @@ function ModelRow({ onPress, onToggleFavorite, }: { - row: SelectorModelRow; + row: ProviderSelectionModelRow; isSelected: boolean; isFavorite: boolean; elevated?: boolean; @@ -200,7 +197,7 @@ function ModelRow({ } interface SelectableModelRowProps { - row: SelectorModelRow; + row: ProviderSelectionModelRow; isSelected: boolean; isFavorite: boolean; elevated?: boolean; @@ -239,7 +236,7 @@ function FavoritesSection({ onSelect, onToggleFavorite, }: { - favoriteRows: SelectorModelRow[]; + favoriteRows: ProviderSelectionModelRow[]; selectedProvider: string; selectedModel: string; favoriteKeys: Set; @@ -274,7 +271,7 @@ interface GroupProviderButtonProps { providerId: string; providerLabel: string; rowCount: number; - hasNoModels: boolean; + defaultLabel: string | null; onDrillDown: (providerId: string, providerLabel: string) => void; onSelectDefault: (providerId: string) => void; } @@ -283,28 +280,28 @@ function GroupProviderButton({ providerId, providerLabel, rowCount, - hasNoModels, + defaultLabel, onDrillDown, onSelectDefault, }: GroupProviderButtonProps) { const { theme } = useUnistyles(); const ProvIcon = getProviderIcon(providerId); const handlePress = useCallback(() => { - if (hasNoModels) { + if (defaultLabel) { onSelectDefault(providerId); return; } onDrillDown(providerId, providerLabel); - }, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]); + }, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]); return ( {providerLabel} - {hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`} + {defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`} - {hasNoModels ? null : ( + {defaultLabel ? null : ( )} @@ -317,22 +314,23 @@ function GroupedProviderRows({ onDrillDown, onSelectDefault, }: { - providers: ModelSelectorProvider[]; + providers: ProviderSelectorProvider[]; onDrillDown: (providerId: string, providerLabel: string) => void; onSelectDefault: (providerId: string) => void; }) { return ( {providers.map((provider, index) => { - const hasNoModels = provider.rows.length === 0; + const rows = getProviderModelRows(provider); + const defaultLabel = getProviderDefaultLabel(provider); return ( {index > 0 ? : null} @@ -381,7 +379,7 @@ function ProviderModelRows({ onToggleFavorite, normalizedQuery, }: { - rows: SelectorModelRow[]; + rows: ProviderSelectionModelRow[]; selectedProvider: string; selectedModel: string; favoriteKeys: Set; @@ -396,7 +394,7 @@ function ProviderModelRows({ [favoriteKeys, normalizedQuery, rows], ); const renderItem = useCallback( - ({ item }: { item: SelectorModelRow }) => ( + ({ item }: { item: ProviderSelectionModelRow }) => ( row.favoriteKey, []); + const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []); if (useVirtualizedList) { return ( @@ -455,15 +453,12 @@ function SelectorContent({ const visibleRows = useMemo( () => selectedViewProvider - ? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery) + ? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery) : [], [normalizedQuery, selectedViewProvider], ); const favoriteRows = useMemo( - () => - providers.flatMap((provider) => - provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)), - ), + () => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)), [favoriteKeys, providers], ); const handleSelectDefaultProvider = useCallback( @@ -485,7 +480,7 @@ function SelectorContent({ return emptyState; } - if (selectedViewProvider.rows.length === 0 && !normalizedQuery) { + if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) { return ( { - if (!selectedModel) { - if (!hasSelectedProvider) { - return "Select model"; - } - const provider = providers.find((entry) => entry.id === selectedProvider); - if (provider?.rows.length === 0) { - return "Default"; - } - return isLoading ? "Loading..." : "Select model"; - } - const provider = providers.find((entry) => entry.id === selectedProvider); - if (!provider) { - return isLoading ? "Loading..." : "Select model"; - } - const model = provider.rows.find((entry) => entry.modelId === selectedModel); - return model?.modelLabel ?? resolveDefaultModelLabel(provider.rows); - }, [hasSelectedProvider, isLoading, providers, selectedModel, selectedProvider]); + return resolveSelectedModelLabel({ + providers, + selectedProvider, + selectedModel, + isLoading, + }); + }, [isLoading, providers, selectedModel, selectedProvider]); const desktopFixedHeight = useMemo(() => { if (view.kind !== "provider") { return undefined; } - const modelCount = - providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0; + const provider = providers.find((entry) => entry.id === view.providerId); + const modelCount = provider ? getProviderModelRows(provider).length : 0; return Math.min(80 + modelCount * 40, 400); }, [providers, view]); diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts deleted file mode 100644 index b39324019..000000000 --- a/packages/app/src/components/combined-model-selector.utils.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { - AgentModelDefinition, - ProviderSnapshotEntry, -} from "@server/server/agent/agent-sdk-types"; -import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; -import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; -import { compareMatchScores, scoreTextFields } from "@/utils/score-match"; - -export type SelectorModelRow = FavoriteModelRow & { isDefault?: boolean }; - -export interface ModelSelectorProvider { - id: string; - label: string; - rows: SelectorModelRow[]; -} - -export function buildSelectedTriggerLabel(modelLabel: string): string { - return modelLabel; -} - -export function buildModelSelectorProviders( - providerDefinitions: AgentProviderDefinition[], - allProviderModels: Map, -): ModelSelectorProvider[] { - return providerDefinitions.map((definition) => ({ - id: definition.id, - label: definition.label, - rows: (allProviderModels.get(definition.id) ?? []).map((model) => ({ - favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }), - provider: definition.id, - providerLabel: definition.label, - modelId: model.id, - modelLabel: model.label, - description: model.description, - isDefault: model.isDefault, - })), - })); -} - -export function buildSelectableModelSelectorProviders( - entries: ProviderSnapshotEntry[] | undefined, -): ModelSelectorProvider[] { - return (entries ?? []) - .filter((entry) => entry.enabled && entry.status === "ready") - .map((entry) => ({ - id: entry.provider, - label: entry.label ?? entry.provider, - rows: (entry.models ?? []).map((model) => ({ - favoriteKey: buildFavoriteModelKey({ provider: entry.provider, modelId: model.id }), - provider: entry.provider, - providerLabel: entry.label ?? entry.provider, - modelId: model.id, - modelLabel: model.label, - description: model.description, - isDefault: model.isDefault, - })), - })); -} - -export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean { - return scoreModelRow(row, normalizedQuery) !== null; -} - -function getModelRowSearchFields(row: SelectorModelRow): string[] { - return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]; -} - -export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) { - return scoreTextFields(normalizedQuery, getModelRowSearchFields(row)); -} - -export function filterAndRankModelRows( - rows: SelectorModelRow[], - normalizedQuery: string, -): SelectorModelRow[] { - if (!normalizedQuery) return rows; - const scored = rows - .map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) })) - .filter((entry): entry is { row: SelectorModelRow; score: NonNullable } => - Boolean(entry.score), - ); - - scored.sort((a, b) => { - const cmp = compareMatchScores(a.score, b.score); - if (cmp !== 0) return cmp; - return a.row.modelLabel.localeCompare(b.row.modelLabel); - }); - - return scored.map((entry) => entry.row); -} diff --git a/packages/app/src/components/settings-textarea.tsx b/packages/app/src/components/settings-textarea.tsx new file mode 100644 index 000000000..1e19d35cf --- /dev/null +++ b/packages/app/src/components/settings-textarea.tsx @@ -0,0 +1,58 @@ +import type { StyleProp, TextStyle } from "react-native"; +import { useMemo } from "react"; +import { TextInput, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { settingsStyles } from "@/styles/settings"; + +interface SettingsTextAreaProps { + accessibilityLabel: string; + value: string; + onChangeText: (text: string) => void; + placeholder?: string; + testID?: string; + style?: StyleProp; +} + +export function SettingsTextArea({ + accessibilityLabel, + value, + onChangeText, + placeholder, + testID, + style, +}: SettingsTextAreaProps) { + const { theme } = useUnistyles(); + const inputStyle = useMemo(() => [styles.input, style], [style]); + + return ( + + ); +} + +export function SettingsTextAreaCard(props: SettingsTextAreaProps) { + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + input: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + minHeight: 96, + textAlignVertical: "top", + }, +})); diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index fb579a53c..c99a34d7b 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -9,9 +9,9 @@ import type { import { useHosts } from "@/runtime/host-runtime"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { - buildSelectableModelSelectorProviders, - type ModelSelectorProvider, -} from "@/components/combined-model-selector.utils"; + buildSelectableProviderSelectorProviders, + type ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useFormPreferences, @@ -32,9 +32,9 @@ import { SELECTABLE_PROVIDER_STATUSES, type FormInitialValues, type FormState, -} from "./resolve-agent-form"; +} from "@/provider-selection/resolve-agent-form"; -export type { FormInitialValues } from "./resolve-agent-form"; +export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; export interface UseAgentFormStateOptions { initialServerId?: string | null; @@ -67,7 +67,7 @@ export interface UseAgentFormStateResult { modeOptions: AgentMode[]; availableModels: AgentModelDefinition[]; allProviderModels: Map; - modelSelectorProviders: ModelSelectorProvider[]; + modelSelectorProviders: ProviderSelectorProvider[]; isAllModelsLoading: boolean; availableThinkingOptions: NonNullable; isModelLoading: boolean; @@ -243,7 +243,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [snapshotEntries], ); const snapshotModelSelectorProviders = useMemo( - () => buildSelectableModelSelectorProviders(snapshotEntries), + () => buildSelectableProviderSelectorProviders(snapshotEntries), [snapshotEntries], ); const snapshotSelectedEntry = useMemo( diff --git a/packages/app/src/hooks/use-agent-input-draft-core.ts b/packages/app/src/hooks/use-agent-input-draft-core.ts index 91c16bbab..45d2b37fb 100644 --- a/packages/app/src/hooks/use-agent-input-draft-core.ts +++ b/packages/app/src/hooks/use-agent-input-draft-core.ts @@ -1,8 +1,6 @@ import type { UserComposerAttachment } from "@/attachments/types"; import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar"; -import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state"; -import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; export interface DraftKeyContext { selectedServerId: string | null; @@ -20,56 +18,6 @@ export function resolveDraftKey(input: { return input.draftKey; } -export function resolveEffectiveComposerModelId(input: { - selectedModel: string; - availableModels: AgentModelDefinition[]; -}): string { - return input.selectedModel.trim(); -} - -export function resolveEffectiveComposerThinkingOptionId(input: { - selectedThinkingOptionId: string; - availableModels: AgentModelDefinition[]; - effectiveModelId: string; -}): string { - const selectedThinkingOptionId = input.selectedThinkingOptionId.trim(); - if (selectedThinkingOptionId) { - return selectedThinkingOptionId; - } - - const selectedModelDefinition = - input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null; - return selectedModelDefinition?.defaultThinkingOptionId ?? ""; -} - -export function buildDraftComposerCommandConfig(input: { - provider: AgentProvider | null; - cwd: string; - modeOptions: DraftAgentStatusBarProps["modeOptions"]; - selectedMode: string; - effectiveModelId: string; - effectiveThinkingOptionId: string; - featureValues?: Record; -}): DraftCommandConfig | undefined { - const cwd = input.cwd.trim(); - if (!input.provider || !cwd) { - return undefined; - } - - return { - provider: input.provider, - cwd, - ...(input.modeOptions.length > 0 && input.selectedMode !== "" - ? { modeId: input.selectedMode } - : {}), - ...(input.effectiveModelId ? { model: input.effectiveModelId } : {}), - ...(input.effectiveThinkingOptionId - ? { thinkingOptionId: input.effectiveThinkingOptionId } - : {}), - ...(input.featureValues ? { featureValues: input.featureValues } : {}), - }; -} - export function buildDraftStatusControls(input: { formState: UseAgentFormStateResult; features?: DraftAgentStatusBarProps["features"]; diff --git a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx index 07cc2aed9..90c30756f 100644 --- a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx +++ b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx @@ -85,16 +85,19 @@ vi.mock("./use-agent-form-state", () => ({ { id: "codex", label: "Codex", - rows: [ - { - favoriteKey: "codex:gpt-5.4", - provider: "codex", - providerLabel: "Codex", - modelId: "gpt-5.4", - modelLabel: "gpt-5.4", - isDefault: true, - }, - ], + modelSelection: { + kind: "models", + rows: [ + { + favoriteKey: "codex:gpt-5.4", + provider: "codex", + providerLabel: "Codex", + modelId: "gpt-5.4", + modelLabel: "gpt-5.4", + isDefault: true, + }, + ], + }, }, ], isAllModelsLoading: false, diff --git a/packages/app/src/hooks/use-agent-input-draft.test.ts b/packages/app/src/hooks/use-agent-input-draft.test.ts index ee9cec7d7..f34c5ff2a 100644 --- a/packages/app/src/hooks/use-agent-input-draft.test.ts +++ b/packages/app/src/hooks/use-agent-input-draft.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; +import { resolveDraftKey } from "./use-agent-input-draft-core"; import { - buildDraftComposerCommandConfig, - resolveDraftKey, + buildDraftCommandConfig, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, -} from "./use-agent-input-draft-core"; +} from "@/provider-selection/provider-selection"; describe("resolveDraftKey", () => { it("returns a string draft key unchanged", () => { @@ -30,8 +30,12 @@ describe("resolveEffectiveComposerModelId", () => { it("returns the selected model trimmed", () => { expect( resolveEffectiveComposerModelId({ - selectedModel: " gpt-5.4-mini ", + provider: "codex", + modelId: " gpt-5.4-mini ", + modeId: "", + thinkingOptionId: "", availableModels: [], + modeOptions: [], }), ).toBe("gpt-5.4-mini"); }); @@ -39,8 +43,12 @@ describe("resolveEffectiveComposerModelId", () => { it("returns empty string when no model selected", () => { expect( resolveEffectiveComposerModelId({ - selectedModel: "", + provider: "codex", + modelId: "", + modeId: "", + thinkingOptionId: "", availableModels: [], + modeOptions: [], }), ).toBe(""); }); @@ -63,21 +71,33 @@ describe("resolveEffectiveComposerThinkingOptionId", () => { it("prefers the selected thinking option when present", () => { expect( - resolveEffectiveComposerThinkingOptionId({ - selectedThinkingOptionId: "medium", - availableModels: models, - effectiveModelId: "gpt-5.4", - }), + resolveEffectiveComposerThinkingOptionId( + { + provider: "codex", + modelId: "gpt-5.4", + modeId: "", + thinkingOptionId: "medium", + availableModels: models, + modeOptions: [], + }, + "gpt-5.4", + ), ).toBe("medium"); }); it("falls back to the model default thinking option", () => { expect( - resolveEffectiveComposerThinkingOptionId({ - selectedThinkingOptionId: "", - availableModels: models, - effectiveModelId: "gpt-5.4", - }), + resolveEffectiveComposerThinkingOptionId( + { + provider: "codex", + modelId: "gpt-5.4", + modeId: "", + thinkingOptionId: "", + availableModels: models, + modeOptions: [], + }, + "gpt-5.4", + ), ).toBe("high"); }); }); @@ -85,11 +105,16 @@ describe("resolveEffectiveComposerThinkingOptionId", () => { describe("buildDraftComposerCommandConfig", () => { it("returns undefined when cwd is empty", () => { expect( - buildDraftComposerCommandConfig({ - provider: "codex", + buildDraftCommandConfig({ + selection: { + provider: "codex", + modelId: "gpt-5.4", + modeId: "", + thinkingOptionId: "", + availableModels: [], + modeOptions: [], + }, cwd: " ", - modeOptions: [], - selectedMode: "", effectiveModelId: "gpt-5.4", effectiveThinkingOptionId: "high", }), @@ -98,11 +123,16 @@ describe("buildDraftComposerCommandConfig", () => { it("builds the draft command config from derived composer state", () => { expect( - buildDraftComposerCommandConfig({ - provider: "codex", + buildDraftCommandConfig({ + selection: { + provider: "codex", + modelId: "gpt-5.4", + modeId: "auto", + thinkingOptionId: "high", + availableModels: [], + modeOptions: [{ id: "auto", label: "Auto" }], + }, cwd: "/repo", - modeOptions: [{ id: "auto", label: "Auto" }], - selectedMode: "auto", effectiveModelId: "gpt-5.4", effectiveThinkingOptionId: "high", }), diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 8e63f38ac..f8c468ae2 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -10,14 +10,17 @@ import { import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { areAttachmentsEqual, - buildDraftComposerCommandConfig, buildDraftStatusControls, hasDraftContent, resolveDraftKey, - resolveEffectiveComposerModelId, - resolveEffectiveComposerThinkingOptionId, type DraftKeyInput, } from "@/hooks/use-agent-input-draft-core"; +import { + buildDraftCommandConfig, + resolveEffectiveComposerModelId, + resolveEffectiveComposerThinkingOptionId, + type ProviderSelectionState, +} from "@/provider-selection/provider-selection"; import { useDraftStore } from "@/stores/draft-store"; type AttachmentUpdater = @@ -199,23 +202,33 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr formState.setWorkingDir(lockedWorkingDir); }, [composerOptions, formState, lockedWorkingDir]); + const providerSelection = useMemo( + () => ({ + provider: formState.selectedProvider, + modelId: formState.selectedModel, + modeId: formState.selectedMode, + thinkingOptionId: formState.selectedThinkingOptionId, + availableModels: formState.availableModels, + modeOptions: formState.modeOptions, + }), + [ + formState.availableModels, + formState.modeOptions, + formState.selectedMode, + formState.selectedModel, + formState.selectedProvider, + formState.selectedThinkingOptionId, + ], + ); + const effectiveModelId = useMemo( - () => - resolveEffectiveComposerModelId({ - selectedModel: formState.selectedModel, - availableModels: formState.availableModels, - }), - [formState.availableModels, formState.selectedModel], + () => resolveEffectiveComposerModelId(providerSelection), + [providerSelection], ); const effectiveThinkingOptionId = useMemo( - () => - resolveEffectiveComposerThinkingOptionId({ - selectedThinkingOptionId: formState.selectedThinkingOptionId, - availableModels: formState.availableModels, - effectiveModelId, - }), - [effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId], + () => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId), + [effectiveModelId, providerSelection], ); const workingDir = lockedWorkingDir || formState.workingDir; @@ -236,11 +249,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const commandDraftConfig = useMemo( () => composerOptions - ? buildDraftComposerCommandConfig({ - provider: formState.selectedProvider, + ? buildDraftCommandConfig({ + selection: providerSelection, cwd: workingDir, - modeOptions: formState.modeOptions, - selectedMode: formState.selectedMode, effectiveModelId, effectiveThinkingOptionId, featureValues: draftFeatureValues, @@ -251,10 +262,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr effectiveModelId, effectiveThinkingOptionId, draftFeatureValues, + providerSelection, workingDir, - formState.modeOptions, - formState.selectedMode, - formState.selectedProvider, ], ); @@ -303,6 +312,7 @@ export const __private__ = { resolveDraftKey, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, - buildDraftComposerCommandConfig, + buildDraftCommandConfig, + buildDraftComposerCommandConfig: buildDraftCommandConfig, buildDraftStatusControls, }; diff --git a/packages/app/src/components/combined-model-selector.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts similarity index 53% rename from packages/app/src/components/combined-model-selector.test.ts rename to packages/app/src/provider-selection/provider-selection.test.ts index 5c892159d..8d79f7d3b 100644 --- a/packages/app/src/components/combined-model-selector.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -5,12 +5,14 @@ import type { } from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { - buildModelSelectorProviders, - buildSelectableModelSelectorProviders, + buildProviderSelectorProviders, + buildSelectableProviderSelectorProviders, buildSelectedTriggerLabel, filterAndRankModelRows, - matchesSearch, -} from "./combined-model-selector.utils"; + matchesModelSearch, + resolveSelectedModelLabel, + resolveSubmissionReadiness, +} from "./provider-selection"; describe("combined model selector data", () => { const codexModel: AgentModelDefinition = { @@ -37,7 +39,7 @@ describe("combined model selector data", () => { it("builds selector providers from ready enabled snapshot entries", () => { expect( - buildSelectableModelSelectorProviders([ + buildSelectableProviderSelectorProviders([ snapshotEntry({ provider: "codex", label: "Codex", @@ -48,24 +50,27 @@ describe("combined model selector data", () => { { id: "codex", label: "Codex", - rows: [ - { - favoriteKey: "codex:gpt-5.4", - provider: "codex", - providerLabel: "Codex", - modelId: "gpt-5.4", - modelLabel: "GPT-5.4", - description: undefined, - isDefault: undefined, - }, - ], + modelSelection: { + kind: "models", + rows: [ + { + favoriteKey: "codex:gpt-5.4", + provider: "codex", + providerLabel: "Codex", + modelId: "gpt-5.4", + modelLabel: "GPT-5.4", + description: undefined, + isDefault: undefined, + }, + ], + }, }, ]); }); - it("keeps ready enabled providers with no models as model-less providers", () => { + it("represents ready enabled providers without explicit models as provider-default selection", () => { expect( - buildSelectableModelSelectorProviders([ + buildSelectableProviderSelectorProviders([ snapshotEntry({ provider: "deepseek-tui", label: "DeepSeek TUI", @@ -76,14 +81,14 @@ describe("combined model selector data", () => { { id: "deepseek-tui", label: "DeepSeek TUI", - rows: [], + modelSelection: { kind: "providerDefault", label: "Default" }, }, ]); }); it("excludes disabled providers from selector data", () => { expect( - buildSelectableModelSelectorProviders([ + buildSelectableProviderSelectorProviders([ snapshotEntry({ provider: "deepseek-tui", label: "DeepSeek TUI", @@ -96,7 +101,7 @@ describe("combined model selector data", () => { it("excludes providers that are not ready", () => { expect( - buildSelectableModelSelectorProviders([ + buildSelectableProviderSelectorProviders([ snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }), snapshotEntry({ provider: "error-provider", status: "error", models: [] }), snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }), @@ -116,19 +121,25 @@ describe("combined model selector data", () => { ]; expect( - buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])), + buildProviderSelectorProviders({ + providerDefinitions, + modelsByProvider: new Map([["codex", [codexModel]]]), + }), ).toEqual([ { id: "codex", label: "Codex", - rows: [ - expect.objectContaining({ - provider: "codex", - providerLabel: "Codex", - modelId: "gpt-5.4", - modelLabel: "GPT-5.4", - }), - ], + modelSelection: { + kind: "models", + rows: [ + expect.objectContaining({ + provider: "codex", + providerLabel: "Codex", + modelId: "gpt-5.4", + modelLabel: "GPT-5.4", + }), + ], + }, }, ]); }); @@ -143,10 +154,10 @@ describe("combined model selector data", () => { description: "OpenCode Zen - kimi", }; - expect(matchesSearch(row, "kimi zen")).toBe(true); - expect(matchesSearch(row, "zen kimi")).toBe(true); - expect(matchesSearch(row, "k2.5 zen")).toBe(true); - expect(matchesSearch(row, "kimi gemini")).toBe(false); + expect(matchesModelSearch(row, "kimi zen")).toBe(true); + expect(matchesModelSearch(row, "zen kimi")).toBe(true); + expect(matchesModelSearch(row, "k2.5 zen")).toBe(true); + expect(matchesModelSearch(row, "kimi gemini")).toBe(false); }); it("ranks model search results by fuzzy match quality", () => { @@ -180,4 +191,75 @@ describe("combined model selector data", () => { it("keeps the selected trigger label model-only", () => { expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4"); }); + + it("resolves selected labels from explicit provider model-selection state", () => { + const providers = buildSelectableProviderSelectorProviders([ + snapshotEntry({ + provider: "codex", + label: "Codex", + models: [codexModel], + }), + snapshotEntry({ + provider: "deepseek-tui", + label: "DeepSeek TUI", + models: [], + }), + ]); + + expect( + resolveSelectedModelLabel({ + providers, + selectedProvider: "codex", + selectedModel: "gpt-5.4", + isLoading: false, + }), + ).toBe("GPT-5.4"); + expect( + resolveSelectedModelLabel({ + providers, + selectedProvider: "deepseek-tui", + selectedModel: "", + isLoading: false, + }), + ).toBe("Default"); + }); + + it("returns observable submission readiness reasons", () => { + expect( + resolveSubmissionReadiness({ + text: "hello", + allowsEmptyAutoSubmit: false, + providerCount: 1, + selection: { + provider: "codex", + modelId: "", + availableModels: [codexModel], + isModelLoading: false, + }, + autoSubmitConfig: null, + workspaceDirectory: "/repo", + hasClient: true, + }), + ).toEqual({ + ok: false, + reason: "No model is available for the selected provider", + }); + + expect( + resolveSubmissionReadiness({ + text: "hello", + allowsEmptyAutoSubmit: false, + providerCount: 1, + selection: { + provider: "deepseek-tui", + modelId: "", + availableModels: [], + isModelLoading: false, + }, + autoSubmitConfig: null, + workspaceDirectory: "/repo", + hasClient: true, + }), + ).toEqual({ ok: true }); + }); }); diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts new file mode 100644 index 000000000..871694045 --- /dev/null +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -0,0 +1,284 @@ +import type { + AgentMode, + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "@server/server/agent/agent-sdk-types"; +import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; +import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; +import { compareMatchScores, scoreTextFields } from "@/utils/score-match"; + +export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean }; + +export type ProviderModelSelection = + | { kind: "models"; rows: ProviderSelectionModelRow[] } + | { kind: "providerDefault"; label: string } + | { kind: "loading" } + | { kind: "unavailable"; reason: string }; + +export interface ProviderSelectorProvider { + id: string; + label: string; + modelSelection: ProviderModelSelection; +} + +export interface ProviderSelectionState { + provider: AgentProvider | null; + modelId: string; + modeId: string; + thinkingOptionId: string; + availableModels: AgentModelDefinition[]; + modeOptions: AgentMode[]; +} + +export interface ProviderSelectionReadiness { + ok: boolean; + reason?: string; +} + +function buildModelRows( + provider: string, + providerLabel: string, + models: AgentModelDefinition[], +): ProviderSelectionModelRow[] { + return models.map((model) => ({ + favoriteKey: buildFavoriteModelKey({ provider, modelId: model.id }), + provider, + providerLabel, + modelId: model.id, + modelLabel: model.label, + description: model.description, + isDefault: model.isDefault, + })); +} + +function buildModelSelection( + provider: string, + providerLabel: string, + models: AgentModelDefinition[] | null, +): ProviderModelSelection { + if (models === null) { + return { kind: "loading" }; + } + if (models.length === 0) { + return { kind: "providerDefault", label: "Default" }; + } + return { kind: "models", rows: buildModelRows(provider, providerLabel, models) }; +} + +function isSelectableProvider(entry: ProviderSnapshotEntry): boolean { + return entry.enabled && entry.status === "ready"; +} + +export function buildProviderSelectorProviders(input: { + providerDefinitions: AgentProviderDefinition[]; + modelsByProvider: Map; +}): ProviderSelectorProvider[] { + return input.providerDefinitions.map((definition) => ({ + id: definition.id, + label: definition.label, + modelSelection: buildModelSelection( + definition.id, + definition.label, + input.modelsByProvider.has(definition.id) + ? (input.modelsByProvider.get(definition.id) ?? []) + : null, + ), + })); +} + +export function buildSelectableProviderSelectorProviders( + entries: ProviderSnapshotEntry[] | undefined, +): ProviderSelectorProvider[] { + return (entries ?? []).filter(isSelectableProvider).map((entry) => { + const label = entry.label ?? entry.provider; + return { + id: entry.provider, + label, + modelSelection: buildModelSelection(entry.provider, label, entry.models ?? []), + }; + }); +} + +export function getProviderModelRows( + provider: ProviderSelectorProvider, +): ProviderSelectionModelRow[] { + return provider.modelSelection.kind === "models" ? provider.modelSelection.rows : []; +} + +export function getAllProviderModelRows( + providers: ProviderSelectorProvider[], +): ProviderSelectionModelRow[] { + return providers.flatMap(getProviderModelRows); +} + +export function getProviderDefaultLabel(provider: ProviderSelectorProvider): string | null { + return provider.modelSelection.kind === "providerDefault" ? provider.modelSelection.label : null; +} + +export function resolveSelectedModelLabel(input: { + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + isLoading: boolean; +}): string { + const selectedProvider = input.selectedProvider.trim(); + if (!selectedProvider) { + return "Select model"; + } + + const provider = input.providers.find((entry) => entry.id === selectedProvider); + if (!input.selectedModel) { + if (provider?.modelSelection.kind === "providerDefault") { + return provider.modelSelection.label; + } + if (provider?.modelSelection.kind === "loading") { + return "Loading..."; + } + return input.isLoading ? "Loading..." : "Select model"; + } + + if (!provider) { + return input.isLoading ? "Loading..." : "Select model"; + } + if (provider.modelSelection.kind !== "models") { + return provider.modelSelection.kind === "providerDefault" + ? provider.modelSelection.label + : "Select model"; + } + + const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel); + const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault); + return ( + model?.modelLabel ?? + defaultModel?.modelLabel ?? + provider.modelSelection.rows[0]?.modelLabel ?? + "Select model" + ); +} + +export function buildSelectedTriggerLabel(modelLabel: string): string { + return modelLabel; +} + +export function matchesModelSearch( + row: ProviderSelectionModelRow, + normalizedQuery: string, +): boolean { + return scoreModelRow(row, normalizedQuery) !== null; +} + +function getModelRowSearchFields(row: ProviderSelectionModelRow): string[] { + return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]; +} + +export function scoreModelRow(row: ProviderSelectionModelRow, normalizedQuery: string) { + return scoreTextFields(normalizedQuery, getModelRowSearchFields(row)); +} + +export function filterAndRankModelRows( + rows: ProviderSelectionModelRow[], + normalizedQuery: string, +): ProviderSelectionModelRow[] { + if (!normalizedQuery) return rows; + const scored = rows + .map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) })) + .filter( + ( + entry, + ): entry is { row: ProviderSelectionModelRow; score: NonNullable } => + Boolean(entry.score), + ); + + scored.sort((a, b) => { + const cmp = compareMatchScores(a.score, b.score); + if (cmp !== 0) return cmp; + return a.row.modelLabel.localeCompare(b.row.modelLabel); + }); + + return scored.map((entry) => entry.row); +} + +export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string { + return selection.modelId.trim(); +} + +export function resolveEffectiveComposerThinkingOptionId( + selection: ProviderSelectionState, + effectiveModelId: string, +): string { + const selectedThinkingOptionId = selection.thinkingOptionId.trim(); + if (selectedThinkingOptionId) { + return selectedThinkingOptionId; + } + + const selectedModelDefinition = + selection.availableModels.find((model) => model.id === effectiveModelId) ?? null; + return selectedModelDefinition?.defaultThinkingOptionId ?? ""; +} + +export function buildDraftCommandConfig(input: { + selection: ProviderSelectionState; + cwd: string; + effectiveModelId: string; + effectiveThinkingOptionId: string; + featureValues?: Record; +}): DraftCommandConfig | undefined { + const cwd = input.cwd.trim(); + if (!input.selection.provider || !cwd) { + return undefined; + } + + return { + provider: input.selection.provider, + cwd, + ...(input.selection.modeOptions.length > 0 && input.selection.modeId !== "" + ? { modeId: input.selection.modeId } + : {}), + ...(input.effectiveModelId ? { model: input.effectiveModelId } : {}), + ...(input.effectiveThinkingOptionId + ? { thinkingOptionId: input.effectiveThinkingOptionId } + : {}), + ...(input.featureValues ? { featureValues: input.featureValues } : {}), + }; +} + +export function resolveSubmissionReadiness(input: { + text: string; + allowsEmptyAutoSubmit: boolean; + providerCount: number; + selection: { + provider: AgentProvider | string | null; + modelId: string; + availableModels: readonly unknown[]; + isModelLoading: boolean; + }; + autoSubmitConfig: { provider: string; model: string | null } | null; + workspaceDirectory: string | null; + hasClient: boolean; +}): ProviderSelectionReadiness { + if (!input.allowsEmptyAutoSubmit && !input.text.trim()) { + return { ok: false, reason: "Initial prompt is required" }; + } + if (input.providerCount === 0) { + return { ok: false, reason: "No available providers on the selected host" }; + } + if (!(input.autoSubmitConfig?.provider ?? input.selection.provider)) { + return { ok: false, reason: "Select a model" }; + } + if (input.selection.isModelLoading) { + return { ok: false, reason: "Model defaults are still loading" }; + } + const hasSelectedModel = Boolean(input.autoSubmitConfig?.model ?? input.selection.modelId); + if (!hasSelectedModel && input.selection.availableModels.length > 0) { + return { ok: false, reason: "No model is available for the selected provider" }; + } + if (!input.workspaceDirectory) { + return { ok: false, reason: "Workspace directory not found" }; + } + if (!input.hasClient) { + return { ok: false, reason: "Host is not connected" }; + } + return { ok: true }; +} diff --git a/packages/app/src/hooks/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts similarity index 100% rename from packages/app/src/hooks/resolve-agent-form.test.ts rename to packages/app/src/provider-selection/resolve-agent-form.test.ts diff --git a/packages/app/src/hooks/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts similarity index 99% rename from packages/app/src/hooks/resolve-agent-form.ts rename to packages/app/src/provider-selection/resolve-agent-form.ts index a54f9ae8c..3763370e2 100644 --- a/packages/app/src/hooks/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -8,7 +8,7 @@ import { mergeProviderPreferences, type FormPreferences, type ProviderPreferences, -} from "./use-form-preferences"; +} from "@/hooks/use-form-preferences"; export interface FormInitialValues { serverId?: string | null; diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 26068a03f..f1dd8e3e9 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -23,6 +23,7 @@ import { ExternalLink } from "@/components/ui/external-link"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; @@ -621,18 +622,13 @@ function ProjectConfigForm({ testID="worktree-group" > - - - + - - - + @@ -1008,18 +999,13 @@ function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPr ); return ( - - - + ); } @@ -1344,14 +1330,6 @@ const styles = StyleSheet.create((theme) => ({ errorBlock: { marginTop: theme.spacing[2], }, - lifecycleInput: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - minHeight: 96, - textAlignVertical: "top", - }, emptyScripts: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index f7894079d..6cd2d2c3e 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -4,6 +4,7 @@ import { Alert, Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; @@ -379,9 +380,9 @@ function ConnectionRow({ function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) { return ( <> - - + + {isLocalDaemon ? ( @@ -544,9 +545,9 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) { - Inject Paseo tools + Enable Paseo tools - Automatically inject Paseo MCP tools into new agents + Agents will be able to manage worktrees, agents and schedules (() => ({ title: "Append system prompt" }), []); + + useEffect(() => { + setDraft(persistedPrompt); + }, [persistedPrompt]); + + const hasChanges = draft !== persistedPrompt; + + const handleOpen = useCallback(() => { + setDraft(persistedPrompt); + setIsEditing(true); + }, [persistedPrompt]); + + const handleClose = useCallback(() => { + if (isSaving) return; + setDraft(persistedPrompt); + setIsEditing(false); + }, [isSaving, persistedPrompt]); + + const handleSave = useCallback(() => { + setIsSaving(true); + void patchConfig({ appendSystemPrompt: draft }) + .then(() => { + setIsEditing(false); + return; + }) + .catch((error) => { + console.error("[HostPage] Failed to save append system prompt", error); + }) + .finally(() => setIsSaving(false)); + }, [draft, patchConfig]); + + const handleReset = useCallback(() => { + setDraft(persistedPrompt); + }, [persistedPrompt]); + + if (!isConnected) return null; + + return ( + <> + + + + System prompt + Added a system prompt to all agents + + + + + + {isEditing ? ( + + + + + + + + ) : null} + + ); +} + function PairDeviceRow() { const { theme } = useUnistyles(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -631,6 +737,8 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: return ( + + @@ -755,6 +863,11 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], marginTop: theme.spacing[4], }, + appendPromptActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, emptyCard: { padding: theme.spacing[4], alignItems: "center", diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 61095e834..560df9f70 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -202,7 +202,12 @@ const disabledCodexEntry: ProviderSnapshotEntry = { }; function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig { - return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false }; + return { + mcp: { injectIntoAgents: false }, + providers, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", + }; } function descendants(el: HTMLElement): HTMLElement[] { diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts b/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts index c9d0d55e2..7e31b03a9 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts @@ -1,3 +1,5 @@ +import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection"; + export interface WorkspaceDraftAutoSubmitConfig { provider: string; model: string | null; @@ -25,27 +27,19 @@ export function validateDraftSubmission(input: { workspaceDirectory, hasClient, } = input; - if (!allowsEmptyAutoSubmit && !text.trim()) { - return "Initial prompt is required"; - } - if (composerState.providerDefinitions.length === 0) { - return "No available providers on the selected host"; - } - if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) { - return "Select a model"; - } - if (composerState.isModelLoading) { - return "Model defaults are still loading"; - } - const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId); - if (!hasSelectedModel && composerState.availableModels.length > 0) { - return "No model is available for the selected provider"; - } - if (!workspaceDirectory) { - return "Workspace directory not found"; - } - if (!hasClient) { - return "Host is not connected"; - } - return null; + const readiness = resolveSubmissionReadiness({ + text, + allowsEmptyAutoSubmit, + providerCount: composerState.providerDefinitions.length, + selection: { + provider: composerState.selectedProvider, + modelId: composerState.effectiveModelId ?? "", + availableModels: composerState.availableModels, + isModelLoading: composerState.isModelLoading, + }, + autoSubmitConfig, + workspaceDirectory, + hasClient, + }); + return readiness.ok ? null : (readiness.reason ?? null); } diff --git a/packages/app/src/stores/last-workspace-selection.test.ts b/packages/app/src/stores/last-workspace-selection.test.ts new file mode 100644 index 000000000..06f4dceec --- /dev/null +++ b/packages/app/src/stores/last-workspace-selection.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + createLastWorkspaceSelectionStore, + type ActiveWorkspaceSelection, + type LastWorkspaceSelectionStorage, +} from "./last-workspace-selection"; + +class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage { + private finishRead: (value: string | null) => void = () => {}; + private readonly pendingRead = new Promise((resolve) => { + this.finishRead = resolve; + }); + private saved: string | null = null; + + read(): Promise { + return this.pendingRead; + } + + async write(value: string): Promise { + this.saved = value; + } + + finishHydrationWith(selection: ActiveWorkspaceSelection | null) { + this.finishRead(selection ? JSON.stringify(selection) : null); + } + + getSavedSelection(): ActiveWorkspaceSelection | null { + return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null; + } +} + +describe("last workspace selection", () => { + it("hydrates the saved workspace selection", async () => { + const storage = new DelayedWorkspaceSelectionStorage(); + const store = createLastWorkspaceSelectionStore(storage); + const hydration = store.hydrate(); + + storage.finishHydrationWith({ serverId: "server-saved", workspaceId: "workspace-saved" }); + await hydration; + + expect(store.getSelection()).toEqual({ + serverId: "server-saved", + workspaceId: "workspace-saved", + }); + expect(store.isHydrated()).toBe(true); + }); + + it("keeps a newer workspace selection when storage hydration finishes late", async () => { + const storage = new DelayedWorkspaceSelectionStorage(); + const store = createLastWorkspaceSelectionStore(storage); + const hydration = store.hydrate(); + + store.remember({ serverId: "server-new", workspaceId: "workspace-new" }); + storage.finishHydrationWith({ serverId: "server-old", workspaceId: "workspace-old" }); + await hydration; + + expect(store.getSelection()).toEqual({ + serverId: "server-new", + workspaceId: "workspace-new", + }); + expect(storage.getSavedSelection()).toEqual({ + serverId: "server-new", + workspaceId: "workspace-new", + }); + }); +}); diff --git a/packages/app/src/stores/last-workspace-selection.ts b/packages/app/src/stores/last-workspace-selection.ts new file mode 100644 index 000000000..a9fe19b9e --- /dev/null +++ b/packages/app/src/stores/last-workspace-selection.ts @@ -0,0 +1,102 @@ +export interface ActiveWorkspaceSelection { + serverId: string; + workspaceId: string; +} + +export interface LastWorkspaceSelectionStorage { + read(): Promise; + write(value: string): Promise; +} + +function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return null; + } + const record = input as Record; + const serverId = typeof record.serverId === "string" ? record.serverId.trim() : ""; + const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : ""; + if (!serverId || !workspaceId) { + return null; + } + return { serverId, workspaceId }; +} + +function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null { + if (!stored) { + return null; + } + try { + return normalizeWorkspaceSelection(JSON.parse(stored)); + } catch { + return null; + } +} + +export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectionStorage) { + let selection: ActiveWorkspaceSelection | null = null; + let hydrated = false; + let hydrationPromise: Promise | null = null; + let revision = 0; + const listeners = new Set<() => void>(); + + function notifyListeners() { + for (const listener of listeners) { + listener(); + } + } + + function remember(next: ActiveWorkspaceSelection) { + const normalized = normalizeWorkspaceSelection(next); + if (!normalized) { + return; + } + if ( + selection?.serverId === normalized.serverId && + selection.workspaceId === normalized.workspaceId + ) { + return; + } + selection = normalized; + revision += 1; + notifyListeners(); + void storage.write(JSON.stringify(normalized)).catch(() => {}); + } + + function hydrate(): Promise { + if (hydrationPromise) { + return hydrationPromise; + } + const hydrationRevision = revision; + hydrationPromise = storage + .read() + .then((stored) => { + if (revision === hydrationRevision) { + selection = parseStoredWorkspaceSelection(stored); + } + return undefined; + }) + .catch(() => { + if (revision === hydrationRevision) { + selection = null; + } + }) + .finally(() => { + hydrated = true; + notifyListeners(); + }); + return hydrationPromise; + } + + return { + getSelection: () => selection, + hydrate, + isHydrated: () => hydrated, + remember, + subscribe: (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/app/src/stores/navigation-active-workspace-store.test.ts b/packages/app/src/stores/navigation-active-workspace-store.test.ts index c5f4b287f..3fbb93f7b 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.test.ts @@ -13,6 +13,10 @@ const pathnameState = vi.hoisted(() => ({ const localParamsState = vi.hoisted(() => ({ value: {} as { serverId?: string | string[]; workspaceId?: string | string[] }, })); +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(async () => null as string | null), + setItem: vi.fn(async () => undefined), +})); vi.mock("expo-router", () => ({ router: routerMock, @@ -20,6 +24,10 @@ vi.mock("expo-router", () => ({ usePathname: () => pathnameState.value, })); +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: asyncStorageMock, +})); + import { navigateToLastWorkspace, navigateToWorkspace, @@ -29,6 +37,7 @@ import { describe("workspace navigation", () => { beforeEach(() => { routerMock.dismissTo.mockReset(); + asyncStorageMock.setItem.mockClear(); pathnameState.value = "/"; localParamsState.value = {}; }); @@ -41,6 +50,10 @@ describe("workspace navigation", () => { navigateToWorkspace("server-1", "workspace-a"); expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); + expect(asyncStorageMock.setItem).toHaveBeenCalledWith( + "paseo:last-workspace-route-selection", + JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }), + ); }); it("reads the active workspace from the current route", () => { diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index 930f13e14..698e1a148 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -1,4 +1,11 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; import { router, useLocalSearchParams, usePathname, type Href } from "expo-router"; +import { useEffect, useSyncExternalStore } from "react"; +import { + createLastWorkspaceSelectionStore, + type ActiveWorkspaceSelection, + type LastWorkspaceSelectionStorage, +} from "@/stores/last-workspace-selection"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { pickAttentionAgent } from "@/utils/agent-attention"; @@ -12,16 +19,34 @@ import { resolveWorkspaceMapKeyByIdentity, } from "@/utils/workspace-execution"; -export interface ActiveWorkspaceSelection { - serverId: string; - workspaceId: string; -} +export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; interface NavigateToWorkspaceOptions { currentPathname?: string | null; } -let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null; +const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection"; + +const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = { + read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), + write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value), +}; + +const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( + lastWorkspaceSelectionStorage, +); + +export function hydrateLastWorkspaceSelection(): Promise { + return lastWorkspaceSelectionStore.hydrate(); +} + +export function getLastWorkspaceSelection(): ActiveWorkspaceSelection | null { + return lastWorkspaceSelectionStore.getSelection(); +} + +export function getIsLastWorkspaceSelectionHydrated(): boolean { + return lastWorkspaceSelectionStore.isHydrated(); +} function getParamValue(value: string | string[] | undefined): string { if (typeof value === "string") { @@ -74,16 +99,17 @@ export function navigateToWorkspace( }); } - lastWorkspaceSelection = { serverId, workspaceId }; + lastWorkspaceSelectionStore.remember({ serverId, workspaceId }); const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href; router.dismissTo(route); } export function navigateToLastWorkspace(): boolean { - if (!lastWorkspaceSelection) { + const selection = lastWorkspaceSelectionStore.getSelection(); + if (!selection) { return false; } - navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId); + navigateToWorkspace(selection.serverId, selection.workspaceId); return true; } @@ -95,8 +121,31 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { const selection = parseHostWorkspaceRouteFromPathname(usePathname()) ?? parseWorkspaceSelectionFromRouteParams(params); - if (selection) { - lastWorkspaceSelection = selection; - } + const serverId = selection?.serverId ?? null; + const workspaceId = selection?.workspaceId ?? null; + useEffect(() => { + if (!serverId || !workspaceId) { + return; + } + lastWorkspaceSelectionStore.remember({ serverId, workspaceId }); + }, [serverId, workspaceId]); return selection; } + +export function useLastWorkspaceSelection(): ActiveWorkspaceSelection | null { + return useSyncExternalStore( + lastWorkspaceSelectionStore.subscribe, + getLastWorkspaceSelection, + getLastWorkspaceSelection, + ); +} + +export function useIsLastWorkspaceSelectionHydrated(): boolean { + return useSyncExternalStore( + lastWorkspaceSelectionStore.subscribe, + getIsLastWorkspaceSelectionHydrated, + getIsLastWorkspaceSelectionHydrated, + ); +} + +void hydrateLastWorkspaceSelection(); diff --git a/packages/cli/src/commands/agent/import.test.ts b/packages/cli/src/commands/agent/import.test.ts index 28fef48d5..48a678de4 100644 --- a/packages/cli/src/commands/agent/import.test.ts +++ b/packages/cli/src/commands/agent/import.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it } from "vitest"; -import { resolveImportCwd } from "./import.js"; +import { describe, expect, it, vi } from "vitest"; +import { resolveImportCwd, runImportCommand } from "./import.js"; + +const importAgent = vi.fn(); +const close = vi.fn(); + +vi.mock("../../utils/client.js", () => ({ + connectToDaemon: vi.fn(async () => ({ + importAgent, + close, + })), + getDaemonHost: vi.fn(() => "ws://127.0.0.1:6767"), +})); describe("resolveImportCwd", () => { it("uses the invoking process cwd when --cwd is omitted", () => { @@ -19,4 +30,30 @@ describe("resolveImportCwd", () => { }), ); }); + + it("accepts pi as an import provider", async () => { + importAgent.mockResolvedValueOnce({ + id: "agent-1", + status: "idle", + provider: "pi", + cwd: "/tmp/project", + title: "Imported Pi session", + }); + + const result = await runImportCommand( + "pi-session-1", + { + provider: "pi", + cwd: "/tmp/project", + }, + {} as never, + ); + + expect(importAgent).toHaveBeenCalledWith({ + provider: "pi", + sessionId: "pi-session-1", + cwd: "/tmp/project", + }); + expect(result.data.provider).toBe("pi"); + }); }); diff --git a/packages/cli/src/commands/agent/import.ts b/packages/cli/src/commands/agent/import.ts index 9bd7d1507..32f99623a 100644 --- a/packages/cli/src/commands/agent/import.ts +++ b/packages/cli/src/commands/agent/import.ts @@ -5,13 +5,15 @@ import type { CommandError, CommandOptions, SingleResult } from "../../output/in import { agentRunSchema, type AgentRunResult } from "./run.js"; import type { AgentSnapshotPayload } from "@getpaseo/server"; -const IMPORT_PROVIDERS = new Set(["claude", "codex", "opencode", "acp"]); +const IMPORT_PROVIDER_LIST = ["claude", "codex", "opencode", "pi", "acp"] as const; +const IMPORT_PROVIDERS = new Set(IMPORT_PROVIDER_LIST); +const IMPORT_PROVIDER_HELP = IMPORT_PROVIDER_LIST.join(", "); export function addImportOptions(cmd: Command): Command { return cmd .description("Import an existing provider session as a Paseo agent") .argument("", "Provider session/thread ID to import") - .requiredOption("--provider ", "Agent provider: claude, codex, opencode, or acp") + .requiredOption("--provider ", `Agent provider: ${IMPORT_PROVIDER_HELP}`) .option("--cwd ", "Working directory for providers that require it") .option( "--label ", @@ -54,7 +56,7 @@ function parseImportProvider(provider: string | undefined): string { throw { code: "INVALID_PROVIDER", message: `Unsupported provider: ${normalizedProvider}`, - details: "Supported providers: claude, codex, opencode, acp", + details: `Supported providers: ${IMPORT_PROVIDER_HELP}`, } satisfies CommandError; } diff --git a/packages/server/package.json b/packages/server/package.json index d3832ba9a..09137073f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -63,9 +63,6 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.2.133", - "@earendil-works/pi-agent-core": "^0.75.3", - "@earendil-works/pi-ai": "^0.75.3", - "@earendil-works/pi-coding-agent": "^0.75.3", "@getpaseo/highlight": "0.1.78", "@getpaseo/relay": "0.1.78", "@isaacs/ttlcache": "^2.1.4", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2c5a2aae8..b22cbffee 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -100,12 +100,15 @@ function expectArchivedAgentRecord( class TestAgentClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; + readonly createdConfigs: AgentSessionConfig[] = []; + readonly resumeOverrides: Array | undefined> = []; async isAvailable(): Promise { return true; } async createSession(config: AgentSessionConfig): Promise { + this.createdConfigs.push(config); return new TestAgentSession(config); } @@ -135,9 +138,11 @@ class TestAgentClient implements AgentClient { config?: Partial, _launchContext?: AgentLaunchContext, ): Promise { + this.resumeOverrides.push(config); return new TestAgentSession({ provider: "codex", cwd: config?.cwd ?? process.cwd(), + daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt, }); } } @@ -391,6 +396,62 @@ test("normalizeConfig strips legacy 'default' model id", async () => { expect(snapshot.config.modeId).toBe("auto"); }); +test("createAgent injects daemon append system prompt at runtime only", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const client = new TestAgentClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + appendSystemPrompt: " Daemon instructions. ", + idFactory: () => "00000000-0000-4000-8000-000000000103", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + systemPrompt: "Agent instructions.", + }); + const record = await storage.get(snapshot.id); + + expect(client.createdConfigs[0]?.systemPrompt).toBe("Agent instructions."); + expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions."); + expect(snapshot.config.daemonAppendSystemPrompt).toBe("Daemon instructions."); + expect(record?.config?.systemPrompt).toBe("Agent instructions."); + expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt"); +}); + +test("daemon append system prompt is injected into Pi configs", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const client = new TestAgentClient(); + const manager = new AgentManager({ + clients: { + pi: client as unknown as AgentClient, + }, + providerDefinitions: { + pi: { enabled: true }, + }, + registry: storage, + logger, + appendSystemPrompt: "Daemon instructions.", + idFactory: () => "00000000-0000-4000-8000-000000000104", + }); + + await manager.createAgent({ + provider: "pi", + cwd: workdir, + systemPrompt: "Agent instructions.", + }); + + expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions."); +}); + test("setAgentMode persists the selected mode across session reload", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 570ecbbab..3c3a95d79 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -173,6 +173,7 @@ export interface AgentManagerOptions { durableTimelineStore?: AgentTimelineStore; terminalManager?: TerminalManager | null; mcpBaseUrl?: string; + appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; logger: Logger; @@ -438,6 +439,7 @@ export class AgentManager { private readonly backgroundTasks = new Set>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; + private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private logger: Logger; private readonly rescueTimeouts: Required; @@ -448,6 +450,7 @@ export class AgentManager { this.durableTimelineStore = options?.durableTimelineStore; this.onAgentAttention = options?.onAgentAttention; this.mcpBaseUrl = options?.mcpBaseUrl ?? null; + this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.rescueTimeouts = { reloadSessionCloseMs: @@ -502,6 +505,10 @@ export class AgentManager { this.mcpBaseUrl = url; } + setAppendSystemPrompt(prompt: string | null | undefined): void { + this.appendSystemPrompt = prompt ?? ""; + } + public getMetricsSnapshot(): AgentMetricsSnapshot { const byLifecycle: Record = {}; let withActiveForegroundTurn = 0; @@ -816,7 +823,9 @@ export class AgentManager { }, }; this.requireEnabledProvider(injectedConfig.provider); - const normalizedConfig = await this.normalizeConfig(injectedConfig); + const normalizedConfig = this.applyDaemonAppendSystemPrompt( + await this.normalizeConfig(injectedConfig), + ); const launchContext = this.buildLaunchContext(resolvedAgentId); const client = await this.requireAvailableClient({ provider: normalizedConfig.provider, @@ -860,7 +869,9 @@ export class AgentManager { ...overrides, provider: handle.provider, } as AgentSessionConfig; - const normalizedConfig = await this.normalizeConfig(mergedConfig); + const normalizedConfig = this.applyDaemonAppendSystemPrompt( + await this.normalizeConfig(mergedConfig), + ); const resumeOverrides: Partial = { ...overrides }; let hasResumeOverrides = overrides !== undefined; @@ -874,6 +885,11 @@ export class AgentManager { hasResumeOverrides = true; } + if (metadata.daemonAppendSystemPrompt !== normalizedConfig.daemonAppendSystemPrompt) { + resumeOverrides.daemonAppendSystemPrompt = normalizedConfig.daemonAppendSystemPrompt; + hasResumeOverrides = true; + } + const launchContext = this.buildLaunchContext(resolvedAgentId); const client = this.requireClient(handle.provider); const available = await client.isAvailable(); @@ -919,7 +935,9 @@ export class AgentManager { ...overrides, provider, } as AgentSessionConfig; - const normalizedConfig = await this.normalizeConfig(refreshConfig); + const normalizedConfig = this.applyDaemonAppendSystemPrompt( + await this.normalizeConfig(refreshConfig), + ); const launchContext = this.buildLaunchContext(agentId); const session = handle @@ -1055,6 +1073,7 @@ export class AgentManager { } const { archivedAt } = await this.markRecordArchived(stored); + agent.updatedAt = new Date(archivedAt); await this.closeAgent(agentId); await this.cascadeArchiveChildren(agentId); @@ -3437,6 +3456,19 @@ export class AgentManager { return normalized; } + private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig { + const daemonAppendSystemPrompt = this.appendSystemPrompt.trim(); + const next = { ...config }; + delete next.daemonAppendSystemPrompt; + + return daemonAppendSystemPrompt + ? { + ...next, + daemonAppendSystemPrompt, + } + : next; + } + private buildLaunchContext(agentId: string): AgentLaunchContext { return { agentId, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 768a1a059..cdeee51bf 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -473,6 +473,11 @@ export interface AgentSessionConfig { * Mapped by each provider to its native instruction field. */ systemPrompt?: string; + /** + * Daemon-level instructions appended at runtime. This is deliberately not + * persisted into agent config so daemon setting changes apply cleanly. + */ + daemonAppendSystemPrompt?: string; modeId?: string; model?: string; thinkingOptionId?: string; diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index f46f26f95..91584ac61 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -1,4 +1,7 @@ import { beforeEach, expect, test, vi } from "vitest"; +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js"; @@ -10,6 +13,8 @@ import { normalizeImportAgentRequest, } from "./import-sessions.js"; +const directorySymlinkType = process.platform === "win32" ? "junction" : "dir"; + const TEST_CAPABILITIES = { supportsStreaming: true, supportsSessionPersistence: true, @@ -275,6 +280,39 @@ test("listImportableProviderSessions filters out metadata generation sessions", expect(result.filteredAlreadyImportedCount).toBe(0); }); +test("listImportableProviderSessions keeps realpath-equivalent cwd matches", async () => { + const root = mkdtempSync(path.join(tmpdir(), "paseo-import-cwd-")); + const realCwd = path.join(root, "real-project"); + const linkedCwd = path.join(root, "linked-project"); + mkdirSync(realCwd, { recursive: true }); + symlinkSync(realCwd, linkedCwd, directorySymlinkType); + const persistedCwd = realpathSync(linkedCwd); + + const result = await listImportableProviderSessions({ + request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }), + agentManager: { + listAgents: () => [], + listImportablePersistedAgents: async () => [ + makeDescriptor({ + provider: "pi", + sessionId: "pi-session", + nativeHandle: "pi-handle", + cwd: persistedCwd, + title: "Pi session", + lastActivityAt: "2026-04-30T12:00:00.000Z", + firstPrompt: "remember this", + }), + ], + } satisfies Pick, + agentStorage: { + list: async () => [], + } satisfies Pick, + providerRegistry: { pi: { label: "Pi" } }, + }); + + expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["pi-handle"]); +}); + test("listImportableProviderSessions rejects invalid since values", async () => { await expect( listImportableProviderSessions({ diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 9741b1418..746d42215 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -20,7 +20,7 @@ import type { RecentProviderSessionDescriptorPayload, } from "../../shared/messages.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; -import { createPathEquivalenceMatcher } from "../../utils/path.js"; +import { createRealpathAwarePathMatcher } from "../../utils/path.js"; type ImportAgentRequestMessage = z.infer; @@ -112,7 +112,7 @@ export async function listImportableProviderSessions( }); let filteredAlreadyImportedCount = 0; const candidates: PersistedAgentDescriptor[] = []; - const matchesRequestCwd = request.cwd ? createPathEquivalenceMatcher(request.cwd) : null; + const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null; for (const descriptor of descriptors) { if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) { continue; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index c1f59cc8f..1c1e18d1c 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1987,6 +1987,7 @@ describe("provider listing MCP tool", () => { }); const tool = registeredTool(server, "list_providers"); const response = await tool.handler({}); + const modelVisibleText = String(response.content[0]?.text); expect(response.structuredContent).toEqual({ providers: [ @@ -2008,6 +2009,9 @@ describe("provider listing MCP tool", () => { }, ], }); + expect(modelVisibleText).toContain("providers_count=2"); + expect(modelVisibleText).toContain("providers_ids=claude,zai"); + expect(modelVisibleText).toContain('"providers"'); }); it("returns disabled providers with metadata without checking availability", async () => { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 577e9f495..4727c654d 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -3,7 +3,11 @@ import { z } from "zod"; import { ensureValidJson } from "../json-utils.js"; 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 { + CallToolResult, + ServerNotification, + ServerRequest, +} from "@modelcontextprotocol/sdk/types.js"; import type { AgentProvider } from "./agent-sdk-types.js"; import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; @@ -154,6 +158,54 @@ function mapModeAcrossProviders( return sourceMode; } +function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult { + if (result.structuredContent === undefined || result.content.length > 0) { + return result; + } + + return { + ...result, + content: [ + { + type: "text", + text: formatStructuredContentForModel(result.structuredContent), + }, + ], + }; +} + +function formatStructuredContentForModel(structuredContent: unknown): string { + if ( + !structuredContent || + typeof structuredContent !== "object" || + Array.isArray(structuredContent) + ) { + return JSON.stringify(structuredContent, null, 2); + } + + const record = structuredContent as Record; + const summary: string[] = []; + for (const [key, value] of Object.entries(record)) { + if (!Array.isArray(value)) { + continue; + } + summary.push(`${key}_count=${value.length}`); + const ids = value + .map((item) => + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record).id + : null, + ) + .filter((id): id is string => typeof id === "string" && id.length > 0); + if (ids.length === value.length && ids.length > 0) { + summary.push(`${key}_ids=${ids.join(",")}`); + } + } + + const json = JSON.stringify(structuredContent, null, 2); + return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json; +} + type McpToolContext = RequestHandlerExtra; function parseTimestamp(value: string | null | undefined): number { @@ -454,6 +506,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom name: "agent-mcp", version: "2.0.0", }); + const registerRawTool = server.registerTool.bind(server); + const registerTool: McpServer["registerTool"] = (name, config, handler) => + registerRawTool(name, config, (async (args: never, extra: never) => + addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler); const resolveCallerAgent = () => { if (!callerAgentId) { @@ -750,7 +806,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }; if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { - server.registerTool( + registerTool( "speak", { title: "Speak", @@ -954,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }; }; - server.registerTool( + registerTool( "create_agent", { title: "Create agent", @@ -1099,7 +1155,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "wait_for_agent", { title: "Wait for agent", @@ -1176,7 +1232,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "send_agent_prompt", { title: "Send agent prompt", @@ -1277,7 +1333,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "get_agent_status", { title: "Get agent status", @@ -1327,7 +1383,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "list_agents", { title: "List agents", @@ -1386,7 +1442,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "cancel_agent", { title: "Cancel agent run", @@ -1410,7 +1466,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "archive_agent", { title: "Archive agent", @@ -1433,7 +1489,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "kill_agent", { title: "Kill agent", @@ -1455,7 +1511,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "update_agent", { title: "Update agent", @@ -1513,7 +1569,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "list_terminals", { title: "List terminals", @@ -1561,7 +1617,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "create_terminal", { title: "Create terminal", @@ -1596,7 +1652,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "kill_terminal", { title: "Kill terminal", @@ -1627,7 +1683,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "capture_terminal", { title: "Capture terminal", @@ -1671,7 +1727,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "send_terminal_keys", { title: "Send terminal keys", @@ -1707,7 +1763,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "create_schedule", { title: "Create schedule", @@ -1788,7 +1844,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "list_schedules", { title: "List schedules", @@ -1813,7 +1869,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "inspect_schedule", { title: "Inspect schedule", @@ -1836,7 +1892,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "pause_schedule", { title: "Pause schedule", @@ -1861,7 +1917,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "resume_schedule", { title: "Resume schedule", @@ -1886,7 +1942,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "delete_schedule", { title: "Delete schedule", @@ -1911,7 +1967,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "update_schedule", { title: "Update schedule", @@ -1973,7 +2029,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "schedule_logs", { title: "Schedule logs", @@ -1998,7 +2054,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "list_providers", { title: "List providers", @@ -2021,7 +2077,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "list_models", { title: "List models", @@ -2058,7 +2114,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "inspect_provider", { title: "Inspect provider", @@ -2122,7 +2178,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "list_worktrees", { title: "List worktrees", @@ -2153,7 +2209,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "create_worktree", { title: "Create worktree", @@ -2218,7 +2274,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "archive_worktree", { title: "Archive worktree", @@ -2310,7 +2366,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "get_agent_activity", { title: "Get agent activity", @@ -2366,7 +2422,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "set_agent_mode", { title: "Set agent session mode", @@ -2390,7 +2446,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "list_pending_permissions", { title: "List pending permissions", @@ -2424,7 +2480,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); - server.registerTool( + registerTool( "respond_to_permission", { title: "Respond to permission", diff --git a/packages/server/src/server/agent/orchestrator-instructions.ts b/packages/server/src/server/agent/orchestrator-instructions.ts deleted file mode 100644 index 659beb0a3..000000000 --- a/packages/server/src/server/agent/orchestrator-instructions.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Returns orchestrator mode instructions to append to the system prompt. - * These instructions are from CLAUDE.md and guide agents on how to work - * effectively in this repository. - */ -export function getOrchestratorModeInstructions(): string { - return ` - -Activation: -- Only activate if the user explicitly says "go into orchestrator mode" (or similar). -- Otherwise, do work directly yourself; do not spawn agents. - -Core rules: -- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself. -- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion"). -- Set cwd to the repository root and choose the most permissive mode available. -- If an agent control tool call fails, list agents before launching another; it may just be a wait timeout. - -Context management: -- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area). -- Start a new agent when switching to a different area/module, or when an agent has run long and its context feels stale. -- Prefer sending follow-up prompts to an existing agent to avoid reloading context. -- Use multiple agents when roles diverge (e.g., one for refactor, one for external validation), but default to reuse when context overlaps. - -Conversation with agents: -- Engage actively: ask pointed questions, probe risks, and request clarifications before accepting proposals. -- Encourage agents to validate assumptions, consider edge cases, and describe how they will test/verify. - -Agent selection guidance: -- Codex: methodical and slower; great for deep debugging, tracing code paths, refactoring, complex features, and design discussions. -- Claude: fast; strong at tool use, agentic control, and managing other agents; may jump to conclusions—ask it to verify. - -Clarifying ambiguous requests: -- Research first to understand the current state. -- Ask clarifying questions about what the user wants. -- Present options with trade-offs. -- Get explicit confirmation; never assume. - -Investigation vs Implementation: -- Investigate only unless explicitly asked to implement. -- Report findings clearly. -- After investigation, ask for direction before implementing. - -Tool usage discipline: -- Do not ask users to run commands—run them yourself. -- Do not repeat the user’s instructions verbatim—summarize them in your own words. -- Be explicit about results—tell the user what happened after every command. - -`; -} diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index c6f507b3e..9d8acc8ae 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -193,8 +193,8 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({ }, })); -vi.mock("./providers/pi-direct-agent.js", () => ({ - PiDirectAgentClient: class PiDirectAgentClient { +vi.mock("./providers/pi/agent.js", () => ({ + PiRpcAgentClient: class PiRpcAgentClient { readonly capabilities = { supportsStreaming: true, supportsSessionPersistence: true, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 1af17a12d..6cc26ca85 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -29,7 +29,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; import { OpenCodeServerManager } from "./providers/opencode/server-manager.js"; -import { PiDirectAgentClient } from "./providers/pi-direct-agent.js"; +import { PiRpcAgentClient } from "./providers/pi/agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { AGENT_PROVIDER_DEFINITIONS, @@ -121,7 +121,7 @@ const PROVIDER_CLIENT_FACTORIES: Record = { }), opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings), pi: (logger, runtimeSettings) => - new PiDirectAgentClient({ + new PiRpcAgentClient({ logger, runtimeSettings, }), 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 b8f889c56..21b417172 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -36,7 +36,7 @@ import { transformCopilotSessionResponse, writeCopilotProviderMode, } from "./copilot-acp-agent.js"; -import { transformPiModels } from "./pi-direct-agent.js"; +import { transformPiModels } from "./pi/agent.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { asInternals } from "../../test-utils/class-mocks.js"; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 8d88d7929..27bcabf6d 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -81,7 +81,7 @@ import { import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js"; import { withTimeout } from "../../../../utils/promise-timeout.js"; import { execCommand } from "../../../../utils/spawn.js"; -import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js"; +import { composeSystemPromptParts } from "../../system-prompt.js"; const fsPromises = promises; const CLAUDE_SETTING_SOURCES: NonNullable = [ @@ -2314,9 +2314,9 @@ class ClaudeAgentSession implements AgentSession { } private buildAppendedSystemPrompt(): string { - return [getOrchestratorModeInstructions(), this.config.systemPrompt?.trim()] - .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) - .join("\n\n"); + return ( + composeSystemPromptParts(this.config.systemPrompt, this.config.daemonAppendSystemPrompt) ?? "" + ); } private buildSdkEnv(extraClaudeOptions: Partial | undefined): NodeJS.ProcessEnv { @@ -2365,7 +2365,7 @@ class ClaudeAgentSession implements AgentSession { canUseTool: this.handlePermissionRequest, pathToClaudeCodeExecutable: claudeBinary, // Use Claude Code preset system prompt and load CLAUDE.md files - // Append provider-agnostic system prompt and orchestrator instructions for agents. + // Append provider-agnostic system prompts for agents. systemPrompt: { type: "preset", preset: "claude_code", 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 ffdb13c81..8bf5364d6 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 @@ -41,6 +41,7 @@ import os from "node:os"; import path from "node:path"; import { z } from "zod"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { composeSystemPromptParts } from "../system-prompt.js"; import { curateAgentActivity } from "../activity-curator.js"; import { mapCodexRolloutToolCall, @@ -3026,12 +3027,11 @@ class CodexAppServerAgentSession implements AgentSession { const settings: Record = {}; if (match.model) settings.model = match.model; if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort; - const developerInstructions = [ - match.developer_instructions?.trim(), - this.config.systemPrompt?.trim(), - ] - .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) - .join("\n\n"); + const developerInstructions = composeSystemPromptParts( + match.developer_instructions, + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); if (developerInstructions) settings.developer_instructions = developerInstructions; if (this.config.model) settings.model = this.config.model; const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); @@ -3161,8 +3161,12 @@ class CodexAppServerAgentSession implements AgentSession { return; } const params: Record = { threadId: this.currentThreadId }; - if (this.config.systemPrompt?.trim()) { - params.developerInstructions = this.config.systemPrompt.trim(); + const developerInstructions = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); + if (developerInstructions) { + params.developerInstructions = developerInstructions; } const codexConfig = this.buildCodexInnerConfig(); if (codexConfig) { @@ -3257,6 +3261,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy: string; sandboxPolicyType: string; hasOutputSchema: boolean; + hasDeveloperInstructions: boolean; hasCodexConfig: boolean; }> { const input = await this.buildUserInput(prompt); @@ -3299,8 +3304,12 @@ class CodexAppServerAgentSession implements AgentSession { if (options?.outputSchema) { params.outputSchema = normalizeCodexOutputSchema(options.outputSchema); } - if (this.config.systemPrompt?.trim()) { - params.developerInstructions = this.config.systemPrompt.trim(); + const developerInstructions = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); + if (developerInstructions) { + params.developerInstructions = developerInstructions; } const codexConfig = this.buildCodexInnerConfig(); if (codexConfig) { @@ -3313,6 +3322,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy, sandboxPolicyType, hasOutputSchema: Boolean(options?.outputSchema), + hasDeveloperInstructions: Boolean(developerInstructions), hasCodexConfig: Boolean(codexConfig), }; } @@ -3323,6 +3333,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy, sandboxPolicyType, hasOutputSchema, + hasDeveloperInstructions, hasCodexConfig, }: { turnId: string; @@ -3330,6 +3341,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy: string; sandboxPolicyType: string; hasOutputSchema: boolean; + hasDeveloperInstructions: boolean; hasCodexConfig: boolean; }): void { this.logger.info( @@ -3345,7 +3357,7 @@ class CodexAppServerAgentSession implements AgentSession { sandboxPolicyType, hasCollaborationMode: Boolean(this.resolvedCollaborationMode), hasOutputSchema, - hasDeveloperInstructions: Boolean(this.config.systemPrompt?.trim()), + hasDeveloperInstructions, hasCodexConfig, }, "Starting Codex app-server turn", @@ -3407,6 +3419,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy: turnStart.approvalPolicy, sandboxPolicyType: turnStart.sandboxPolicyType, hasOutputSchema: turnStart.hasOutputSchema, + hasDeveloperInstructions: turnStart.hasDeveloperInstructions, hasCodexConfig: turnStart.hasCodexConfig, }); await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS); @@ -3952,14 +3965,16 @@ class CodexAppServerAgentSession implements AgentSession { const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; const sandbox = this.config.sandboxMode ?? preset.sandbox; const innerConfig = this.buildCodexInnerConfig(); + const developerInstructions = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); const params: Record = { model, cwd: this.config.cwd ?? null, approvalPolicy, sandbox, - ...(this.config.systemPrompt?.trim() - ? { developerInstructions: this.config.systemPrompt.trim() } - : {}), + ...(developerInstructions ? { developerInstructions } : {}), ...(innerConfig ? { config: innerConfig } : {}), ...(this.ephemeral ? { ephemeral: true } : {}), }; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d7b491399..9e28b4b73 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -60,6 +60,7 @@ import { } from "./diagnostic-utils.js"; import { runProviderTurn } from "./provider-runner.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { composeSystemPromptParts } from "../system-prompt.js"; import { createSdkOpenCodeClient, type OpenCodeRuntime, @@ -2589,6 +2590,10 @@ class OpenCodeAgentSession implements AgentSession { partTypes: parts.map((p) => p.type), }); try { + const systemPrompt = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); const promptResponse = await this.client.session.promptAsync({ sessionID: this.sessionId, directory: this.config.cwd, @@ -2601,7 +2606,7 @@ class OpenCodeAgentSession implements AgentSession { }, } : {}), - ...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}), + ...(systemPrompt ? { system: systemPrompt } : {}), ...(model ? { model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts deleted file mode 100644 index 92dc2b982..000000000 --- a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { describe, expect, test, vi } from "vitest"; -import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; -import pino from "pino"; - -import type { AgentStreamEvent } from "../agent-sdk-types.js"; -import { - PiDirectAgentClient, - PiDirectAgentSession, - type PiDirectSessionRuntimeAdapter, - type PiDirectSessionAdapter, -} from "./pi-direct-agent.js"; - -function createPiAssistantErrorMessage(errorMessage: string): AssistantMessage { - return { - role: "assistant", - content: [], - api: "openai-responses", - provider: "github-copilot", - model: "gpt-5.4", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - stopReason: "error", - errorMessage, - timestamp: Date.now(), - }; -} - -function createPiSession( - prompt: () => Promise, - options: { - compact?: () => Promise; - messages?: PiDirectSessionAdapter["messages"]; - errorMessage?: string | null; - } = {}, -): PiDirectSessionAdapter { - return { - sessionId: "pi-session-1", - thinkingLevel: "medium", - model: undefined, - messages: options.messages ?? [], - extensionRunner: undefined, - promptTemplates: [], - resourceLoader: { - getSkills: () => ({ skills: [] }), - }, - agent: { - state: { - systemPrompt: "", - errorMessage: options.errorMessage ?? null, - }, - }, - sessionManager: { - getSessionFile: () => "/tmp/pi-session.json", - getCwd: () => "/tmp/paseo-pi-test", - }, - subscribe: vi.fn(), - prompt, - compact: options.compact ?? vi.fn(async () => undefined), - abort: vi.fn(), - dispose: vi.fn(), - getSessionStats: vi.fn(() => ({})), - setModel: vi.fn(), - setThinkingLevel: vi.fn(), - }; -} - -function createPiRuntime( - session: PiDirectSessionAdapter, - dispose: () => Promise = vi.fn(async () => undefined), -): PiDirectSessionRuntimeAdapter { - return { - session, - dispose, - }; -} - -function createPiModel(provider: string, id: string): Model { - return { - provider, - id, - name: id, - api: "openai-completions", - baseUrl: "https://example.invalid/v1", - reasoning: true, - input: ["text"], - cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 8192, - } as Model; -} - -describe("PiDirectAgentSession", () => { - test("treats SDK request abort rejections as turn cancellations", async () => { - const session = new PiDirectAgentSession( - createPiRuntime(createPiSession(() => Promise.reject(new Error("Request was aborted.")))), - { find: vi.fn(), getAll: vi.fn(() => []) }, - { - provider: "pi", - cwd: "/tmp/paseo-pi-test", - }, - ); - const events: AgentStreamEvent[] = []; - session.subscribe((event) => events.push(event)); - - const { turnId } = await session.startTurn("hello"); - await Promise.resolve(); - - expect(events).toEqual([ - { - type: "turn_canceled", - provider: "pi", - turnId, - reason: "Request was aborted.", - }, - ]); - }); - - test("compacts stale Copilot 413 sessions before prompting again", async () => { - const callOrder: string[] = []; - const sdkSession = createPiSession( - vi.fn(async () => { - callOrder.push("prompt"); - }), - { - messages: [createPiAssistantErrorMessage("413 failed to parse request")], - errorMessage: "413 failed to parse request", - compact: vi.fn(async () => { - callOrder.push("compact"); - }), - }, - ); - const session = new PiDirectAgentSession( - createPiRuntime(sdkSession), - { find: vi.fn(), getAll: vi.fn(() => []) }, - { - provider: "pi", - cwd: "/tmp/paseo-pi-test", - }, - ); - - await session.startTurn("continue"); - - expect(sdkSession.compact).toHaveBeenCalledTimes(1); - expect(sdkSession.prompt).toHaveBeenCalledTimes(1); - expect(callOrder).toEqual(["compact", "prompt"]); - }); - - test("does not compact other Pi errors before prompting", async () => { - const sdkSession = createPiSession( - vi.fn(async () => undefined), - { - messages: [createPiAssistantErrorMessage("413 unrelated provider error")], - compact: vi.fn(async () => { - throw new Error("should not compact"); - }), - }, - ); - const session = new PiDirectAgentSession( - createPiRuntime(sdkSession), - { find: vi.fn(), getAll: vi.fn(() => []) }, - { - provider: "pi", - cwd: "/tmp/paseo-pi-test", - }, - ); - - await session.startTurn("continue"); - - expect(sdkSession.compact).not.toHaveBeenCalled(); - expect(sdkSession.prompt).toHaveBeenCalledTimes(1); - }); - - test("setModel creates a minimal model for new ids under a known provider", async () => { - const sdkSession = createPiSession(async () => undefined); - const session = new PiDirectAgentSession( - createPiRuntime(sdkSession), - { - find: vi.fn(() => undefined), - getAll: vi.fn(() => [createPiModel("openrouter", "known-model")]), - }, - { - provider: "pi", - cwd: "/tmp/paseo-pi-test", - }, - ); - - await session.setModel("openrouter/blabal"); - - expect(sdkSession.setModel).toHaveBeenCalledWith({ - id: "blabal", - name: "blabal", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://example.invalid/v1", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 16384, - compat: undefined, - }); - }); -}); - -describe("PiDirectAgentClient", () => { - test("lists only Pi models with configured auth", async () => { - const client = new PiDirectAgentClient({ - logger: pino({ level: "silent" }), - }); - const registry = { - find: vi.fn(), - getAll: vi.fn(() => [createPiModel("amazon-bedrock", "claude-sonnet-4")]), - getAvailable: vi.fn(() => [createPiModel("anthropic", "claude-opus-4-5")]), - }; - (client as unknown as { modelRegistry: typeof registry }).modelRegistry = registry; - - const models = await client.listModels({ cwd: "/tmp/paseo-pi-test", force: false }); - - expect(registry.getAvailable).toHaveBeenCalledTimes(1); - expect(registry.getAll).not.toHaveBeenCalled(); - expect(models.map((model) => model.id)).toEqual(["anthropic/claude-opus-4-5"]); - }); - - test("loads project extensions before listing available models", async () => { - const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-")); - const previousAgentDir = process.env.PI_CODING_AGENT_DIR; - - try { - const agentDir = join(testRoot, "agent"); - const cwd = join(testRoot, "project"); - const extensionDir = join(cwd, ".pi", "extensions"); - process.env.PI_CODING_AGENT_DIR = agentDir; - - await mkdir(extensionDir, { recursive: true }); - await writeFile( - join(extensionDir, "dummy-provider.ts"), - ` -export default function(pi) { - pi.registerProvider("paseo-dummy", { - baseUrl: "https://example.invalid/v1", - apiKey: "paseo-test-key", - api: "openai-responses", - models: [ - { - id: "extension-model", - name: "Extension Model", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 4096 - } - ] - }); -} -`, - "utf-8", - ); - - const client = new PiDirectAgentClient({ - logger: pino({ level: "silent" }), - }); - - const models = await client.listModels({ cwd, force: false }); - - expect(models.map((model) => model.id)).toContain("paseo-dummy/extension-model"); - } finally { - if (previousAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousAgentDir; - } - await rm(testRoot, { recursive: true, force: true }); - } - }); - - test("creates sessions with project extension models and exposes extension commands", async () => { - const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-session-")); - const previousAgentDir = process.env.PI_CODING_AGENT_DIR; - - try { - const agentDir = join(testRoot, "agent"); - const cwd = join(testRoot, "project"); - const extensionDir = join(cwd, ".pi", "extensions"); - const shutdownMarker = join(testRoot, "shutdown.txt"); - process.env.PI_CODING_AGENT_DIR = agentDir; - - await mkdir(extensionDir, { recursive: true }); - await writeFile( - join(extensionDir, "dummy-command.ts"), - ` -import { writeFileSync } from "node:fs"; - -export default function(pi) { - pi.registerProvider("paseo-dummy", { - baseUrl: "https://example.invalid/v1", - apiKey: "paseo-test-key", - api: "openai-responses", - models: [ - { - id: "extension-model", - name: "Extension Model", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 4096 - } - ] - }); - - pi.registerCommand("dummy-command", { - description: "Dummy extension command", - handler: async () => {} - }); - - pi.on("session_shutdown", async () => { - writeFileSync(${JSON.stringify(shutdownMarker)}, "closed"); - }); -} -`, - "utf-8", - ); - - const client = new PiDirectAgentClient({ - logger: pino({ level: "silent" }), - }); - const session = await client.createSession({ - provider: "pi", - cwd, - model: "paseo-dummy/extension-model", - }); - let closed = false; - - try { - await expect(session.listCommands()).resolves.toContainEqual({ - name: "dummy-command", - description: "Dummy extension command", - argumentHint: "", - }); - await session.close(); - closed = true; - await expect(readFile(shutdownMarker, "utf-8")).resolves.toBe("closed"); - } finally { - if (!closed) { - await session.close(); - } - } - } finally { - if (previousAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousAgentDir; - } - await rm(testRoot, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.ts b/packages/server/src/server/agent/providers/pi-direct-agent.ts deleted file mode 100644 index edaee537d..000000000 --- a/packages/server/src/server/agent/providers/pi-direct-agent.ts +++ /dev/null @@ -1,1565 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import type { Logger } from "pino"; -import { - AuthStorage, - ModelRegistry, - SessionManager, - createAgentSessionFromServices, - createAgentSessionRuntime, - createAgentSessionServices, - getAgentDir, - type AgentSession as PiAgentSession, - type AgentSessionEvent, - type AgentSessionServices, - type BashToolInput, - type CreateAgentSessionRuntimeFactory, - type EditToolInput, - type FindToolInput, - type GrepToolInput, - type LsToolInput, - type ReadToolInput, - type ResourceLoader, - type ResolvedCommand, - type Skill, - type WriteToolInput, -} from "@earendil-works/pi-coding-agent"; -import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; -import { z } from "zod"; - -import { - getAgentStreamEventTurnId, - type AgentCapabilityFlags, - type AgentClient, - type AgentLaunchContext, - type AgentMetadata, - type AgentMode, - type AgentModelDefinition, - type AgentPermissionRequest, - type AgentPermissionResponse, - type AgentPersistenceHandle, - type AgentPromptInput, - type AgentRunOptions, - type AgentRunResult, - type AgentRuntimeInfo, - type AgentSession, - type AgentSessionConfig, - type AgentSlashCommand, - type AgentStreamEvent, - type AgentTimelineItem, - type AgentUsage, - type ListModesOptions, - type ListModelsOptions, - type ToolCallDetail, -} from "../agent-sdk-types.js"; -import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; -import { - formatDiagnosticStatus, - formatProviderDiagnostic, - formatProviderDiagnosticError, - resolveBinaryVersion, - toDiagnosticErrorMessage, -} from "./diagnostic-utils.js"; -import { applyPiSessionRecoveryPolicy } from "./pi-session-recovery-policy.js"; - -const PI_PROVIDER = "pi"; -const DEFAULT_PI_THINKING_LEVEL: ThinkingLevel = "medium"; -const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi"; - -const PI_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: false, - supportsReasoningStream: true, - supportsToolInvocations: true, -}; - -interface PiDirectAgentClientOptions { - logger: Logger; - runtimeSettings?: ProviderRuntimeSettings; -} - -interface PiPromptPayload { - text: string; - images?: ImageContent[]; -} - -interface ToolCallOutputSummary { - output?: string; - exitCode?: number | null; -} - -interface PiModelReference { - provider?: string; - id: string; -} - -interface PiPersistenceMetadata { - cwd?: string; -} - -interface StartTurnResult { - turnId: string; -} - -interface PiDirectSessionResult { - runtime: PiDirectSessionRuntimeAdapter; - modelRegistry: ModelRegistry; -} - -export type PiDirectSessionAdapter = Pick< - PiAgentSession, - | "abort" - | "agent" - | "compact" - | "dispose" - | "extensionRunner" - | "getSessionStats" - | "messages" - | "model" - | "prompt" - | "promptTemplates" - | "resourceLoader" - | "sessionId" - | "sessionManager" - | "setModel" - | "setThinkingLevel" - | "subscribe" - | "thinkingLevel" ->; - -export interface PiDirectSessionRuntimeAdapter { - readonly session: PiDirectSessionAdapter; - dispose(): Promise; -} - -type PiDirectModelRegistry = Pick; - -interface PiToolResultObject { - output?: string; - stdout?: string; - text?: string; - content?: PiToolResultContent[]; - exitCode?: number; - code?: number; - details?: PiToolResultDetails; -} - -interface PiToolResultDetails { - diff?: string; -} - -interface PiToolResultTextContent { - type: "text"; - text: string; -} - -interface PiToolResultUnknownContent { - type: string; -} - -type PiToolResultContent = PiToolResultTextContent | PiToolResultUnknownContent; -type PiToolResult = string | PiToolResultObject | null; - -interface PiBashToolCall { - kind: "bash"; - toolName: "bash"; - args: BashToolInput; -} - -interface PiReadToolCall { - kind: "read"; - toolName: "read"; - args: ReadToolInput; -} - -interface PiEditToolCall { - kind: "edit"; - toolName: "edit"; - args: EditToolInput; -} - -interface PiWriteToolCall { - kind: "write"; - toolName: "write"; - args: WriteToolInput; -} - -interface PiFindToolCall { - kind: "find"; - toolName: "find"; - args: FindToolInput; -} - -interface PiGrepToolCall { - kind: "grep"; - toolName: "grep"; - args: GrepToolInput; -} - -interface PiLsToolCall { - kind: "ls"; - toolName: "ls"; - args: LsToolInput; -} - -interface PiUnknownToolCall { - kind: "unknown"; - toolName: string; - args: unknown; -} - -type PiTrackedToolCall = - | PiBashToolCall - | PiReadToolCall - | PiEditToolCall - | PiWriteToolCall - | PiFindToolCall - | PiGrepToolCall - | PiLsToolCall - | PiUnknownToolCall; - -const PI_THINKING_OPTIONS: ReadonlyArray<{ - id: ThinkingLevel; - label: string; - description: string; - isDefault?: boolean; -}> = [ - { id: "off", label: "Off", description: "No extra reasoning" }, - { id: "minimal", label: "Minimal", description: "Light reasoning" }, - { id: "low", label: "Low", description: "Faster reasoning" }, - { id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, - { id: "high", label: "High", description: "Deeper reasoning" }, - { id: "xhigh", label: "XHigh", description: "Maximum reasoning" }, -] as const; - -const PiPromptTextBlockSchema = z.object({ - type: z.literal("text"), - text: z.string(), -}); - -const PiToolResultTextContentSchema = z.object({ - type: z.literal("text"), - text: z.string(), -}); - -const PiToolResultUnknownContentSchema = z - .object({ - type: z.string(), - }) - .passthrough(); - -const PiToolResultContentSchema = z.union([ - PiToolResultTextContentSchema, - PiToolResultUnknownContentSchema, -]); - -const PiToolResultDetailsSchema = z - .object({ - diff: z.string().optional(), - }) - .passthrough(); - -const PiToolResultObjectSchema = z - .object({ - output: z.string().optional(), - stdout: z.string().optional(), - text: z.string().optional(), - content: z.array(PiToolResultContentSchema).optional(), - exitCode: z.number().optional(), - code: z.number().optional(), - details: PiToolResultDetailsSchema.optional(), - }) - .passthrough(); - -const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]); - -const PiPersistenceMetadataSchema = z - .object({ - cwd: z.string().optional(), - }) - .passthrough(); - -const BashToolInputSchema: z.ZodType = z.object({ - command: z.string(), - timeout: z.number().optional(), -}); - -const ReadToolInputSchema: z.ZodType = z.object({ - path: z.string(), - offset: z.number().optional(), - limit: z.number().optional(), -}); - -const EditToolInputSchema: z.ZodType = z.object({ - path: z.string(), - edits: z.array( - z.object({ - oldText: z.string(), - newText: z.string(), - }), - ), -}); - -const LegacyEditToolInputSchema = z.object({ - path: z.string(), - old_string: z.string().optional(), - oldString: z.string().optional(), - new_string: z.string().optional(), - newString: z.string().optional(), -}); - -const WriteToolInputSchema: z.ZodType = z.object({ - path: z.string(), - content: z.string(), -}); - -const FindToolInputSchema: z.ZodType = z.object({ - pattern: z.string(), - path: z.string().optional(), - limit: z.number().optional(), -}); - -const GrepToolInputSchema: z.ZodType = z.object({ - pattern: z.string(), - path: z.string().optional(), - glob: z.string().optional(), - ignoreCase: z.boolean().optional(), - literal: z.boolean().optional(), - context: z.number().optional(), - limit: z.number().optional(), -}); - -const LsToolInputSchema: z.ZodType = z.object({ - path: z.string().optional(), - limit: z.number().optional(), -}); - -function normalizePiModelLabel(label: string): string { - return label.trim().replace(/[_\s]+/g, " "); -} - -export function transformPiModels(models: AgentModelDefinition[]): AgentModelDefinition[] { - return models.map((model) => { - if (!model.label.includes("/")) { - return model; - } - - const segments = model.label.split("/").filter((segment) => segment.length > 0); - const rawLabel = segments.at(-1); - if (!rawLabel) { - return model; - } - - return { - ...model, - label: normalizePiModelLabel(rawLabel), - description: model.description ?? model.label, - }; - }); -} - -function isPiThinkingLevel(value: string | null | undefined): value is ThinkingLevel { - return ( - value === "off" || - value === "minimal" || - value === "low" || - value === "medium" || - value === "high" || - value === "xhigh" - ); -} - -function normalizePiThinkingOption(value: string | null | undefined): ThinkingLevel | null { - if (!value) { - return null; - } - return isPiThinkingLevel(value) ? value : null; -} - -function toAgentUsage( - stats: ReturnType, -): AgentUsage | undefined { - const inputTokens = stats.tokens.input; - const cachedInputTokens = stats.tokens.cacheRead; - const outputTokens = stats.tokens.output; - const totalCostUsd = stats.cost; - - if (inputTokens === 0 && cachedInputTokens === 0 && outputTokens === 0 && totalCostUsd === 0) { - return undefined; - } - - return { - inputTokens, - cachedInputTokens, - outputTokens, - totalCostUsd, - }; -} - -function convertPromptInput(prompt: AgentPromptInput): PiPromptPayload { - if (typeof prompt === "string") { - return { text: prompt }; - } - - const textParts: string[] = []; - const images: ImageContent[] = []; - - for (const block of prompt) { - if (block.type === "text") { - textParts.push(block.text); - continue; - } - - if (block.type === "image") { - images.push({ - type: "image", - data: block.data, - mimeType: block.mimeType, - }); - continue; - } - - textParts.push(renderPromptAttachmentAsText(block)); - } - - const payload: PiPromptPayload = { - text: textParts.join("\n\n"), - }; - if (images.length > 0) { - payload.images = images; - } - return payload; -} - -function parseToolResult(rawResult: unknown): PiToolResult { - const parsed = PiToolResultSchema.safeParse(rawResult); - if (parsed.success) { - return parsed.data; - } - return null; -} - -function extractTextFromToolResult(result: PiToolResult): string | undefined { - if (typeof result === "string") { - return result; - } - if (!result) { - return undefined; - } - - const directText = result.output ?? result.stdout ?? result.text; - if (directText) { - return directText; - } - if (!result.content) { - return undefined; - } - - const textParts: string[] = []; - for (const block of result.content) { - if (block.type === "text" && "text" in block) { - textParts.push(block.text); - } - } - - if (textParts.length === 0) { - return undefined; - } - return textParts.join("\n"); -} - -function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary { - if (typeof result === "string") { - return { output: result }; - } - if (!result) { - return {}; - } - - const summary: ToolCallOutputSummary = { - output: extractTextFromToolResult(result), - }; - if (typeof result.exitCode === "number") { - summary.exitCode = result.exitCode; - return summary; - } - if (typeof result.code === "number") { - summary.exitCode = result.code; - return summary; - } - summary.exitCode = null; - return summary; -} - -function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null { - const parsed = LegacyEditToolInputSchema.safeParse(rawArgs); - if (!parsed.success) { - return null; - } - - const oldText = parsed.data.old_string ?? parsed.data.oldString; - const newText = parsed.data.new_string ?? parsed.data.newString; - if (!oldText || newText === undefined) { - return null; - } - - return { - path: parsed.data.path, - edits: [{ oldText, newText }], - }; -} - -function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall { - const parsed = EditToolInputSchema.safeParse(rawArgs); - if (parsed.success) { - return { kind: "edit", toolName: "edit", args: parsed.data }; - } - const legacyArgs = normalizeLegacyEditArgs(rawArgs); - if (legacyArgs) { - return { kind: "edit", toolName: "edit", args: legacyArgs }; - } - return { kind: "unknown", toolName: "edit", args: rawArgs ?? null }; -} - -type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls"; -const SIMPLE_TOOL_SCHEMAS: { - [K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } }; -} = { - bash: BashToolInputSchema, - read: ReadToolInputSchema, - write: WriteToolInputSchema, - find: FindToolInputSchema, - grep: GrepToolInputSchema, - ls: LsToolInputSchema, -}; - -function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall { - if (toolName === "edit") { - return parseEditToolArgs(rawArgs); - } - const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind]; - if (schema) { - const parsed = schema.safeParse(rawArgs); - if (parsed.success) { - return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall; - } - } - return { kind: "unknown", toolName, args: rawArgs ?? null }; -} - -function mapFindToolDetail(args: FindToolInput, result: PiToolResult): ToolCallDetail { - return { - type: "search", - query: args.pattern, - toolName: "search", - content: typeof result === "string" ? result : undefined, - }; -} - -function mapGrepToolDetail(args: GrepToolInput, result: PiToolResult): ToolCallDetail { - return { - type: "search", - query: args.pattern, - toolName: "grep", - content: typeof result === "string" ? result : undefined, - }; -} - -function mapLsToolDetail(args: LsToolInput, result: PiToolResult): ToolCallDetail { - const query = args.path ?? "ls"; - return { - type: "search", - query, - content: typeof result === "string" ? result : undefined, - }; -} - -function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail { - const parsedResult = result ?? null; - - switch (toolCall.kind) { - case "bash": { - const summary = resolveToolCallOutput(parsedResult); - return { - type: "shell", - command: toolCall.args.command, - output: summary.output, - exitCode: summary.exitCode, - }; - } - case "read": - return { - type: "read", - filePath: toolCall.args.path, - content: extractTextFromToolResult(parsedResult), - offset: toolCall.args.offset, - limit: toolCall.args.limit, - }; - case "edit": { - const firstEdit = toolCall.args.edits[0]; - const unifiedDiff = - parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined; - - return { - type: "edit", - filePath: toolCall.args.path, - oldString: firstEdit?.oldText, - newString: firstEdit?.newText, - unifiedDiff, - }; - } - case "write": - return { - type: "write", - filePath: toolCall.args.path, - content: toolCall.args.content, - }; - case "find": - return mapFindToolDetail(toolCall.args, parsedResult); - case "grep": - return mapGrepToolDetail(toolCall.args, parsedResult); - case "ls": - return mapLsToolDetail(toolCall.args, parsedResult); - default: - return { - type: "unknown", - input: toolCall.args, - output: parsedResult, - }; - } -} - -function parseModelReference(modelId: string | null): PiModelReference | null { - if (!modelId) { - return null; - } - if (modelId.includes("/")) { - const [provider, ...rest] = modelId.split("/"); - const id = rest.join("/"); - if (provider && id) { - return { provider, id }; - } - } - if (modelId.includes(":")) { - const [provider, ...rest] = modelId.split(":"); - const id = rest.join(":"); - if (provider && id) { - return { provider, id }; - } - } - return { id: modelId }; -} - -function mapResolvedCommand(command: ResolvedCommand): AgentSlashCommand { - return { - name: command.invocationName, - description: command.description ?? "Extension command", - argumentHint: "", - }; -} - -function mapSkillCommand(skill: Skill): AgentSlashCommand { - return { - name: `skill:${skill.name}`, - description: skill.description || "Skill", - argumentHint: "", - }; -} - -function buildSlashCommands(session: PiDirectSessionAdapter): AgentSlashCommand[] { - const commands: AgentSlashCommand[] = []; - const extensionCommands = session.extensionRunner?.getRegisteredCommands() ?? []; - - for (const command of extensionCommands) { - commands.push(mapResolvedCommand(command)); - } - - for (const template of session.promptTemplates) { - commands.push({ - name: template.name, - description: template.description ?? "Prompt template", - argumentHint: "", - }); - } - - const resourceLoader: ResourceLoader = session.resourceLoader; - const skills = resourceLoader.getSkills().skills; - for (const skill of skills) { - commands.push(mapSkillCommand(skill)); - } - - return commands; -} - -function applySystemPrompt(session: PiAgentSession, systemPrompt: string | undefined): void { - const trimmed = systemPrompt?.trim(); - if (!trimmed) { - return; - } - - // Pi does not expose a public setter for composing an additional system prompt, - // so this escape hatch is isolated to one typed boundary. - const sessionObject = session as object; - const baseSystemPrompt = Reflect.get(sessionObject, "_baseSystemPrompt"); - const currentBase = - typeof baseSystemPrompt === "string" ? baseSystemPrompt : session.agent.state.systemPrompt; - const combinedPrompt = currentBase ? `${currentBase}\n\n${trimmed}` : trimmed; - Reflect.set(sessionObject, "_baseSystemPrompt", combinedPrompt); - session.agent.state.systemPrompt = combinedPrompt; -} - -function isTextContentBlock(block: unknown): block is TextContent { - return PiPromptTextBlockSchema.safeParse(block).success; -} - -function getUserMessageText(content: string | (TextContent | ImageContent)[]): string { - if (typeof content === "string") { - return content; - } - - const textParts: string[] = []; - for (const block of content) { - if (isTextContentBlock(block)) { - textParts.push(block.text); - } - } - return textParts.join("\n\n"); -} - -function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersistenceMetadata { - const parsed = PiPersistenceMetadataSchema.safeParse(metadata); - if (parsed.success) { - return parsed.data; - } - return {}; -} - -function isPiRequestAbortError(error: unknown): boolean { - if (error instanceof Error && error.name === "AbortError") { - return true; - } - - return /\brequest was aborted\b/i.test(toDiagnosticErrorMessage(error)); -} - -function resolveThinkingOptionId( - cachedThinkingOptionId: string | null, - sessionThinkingLevel: ThinkingLevel, -): ThinkingLevel | null { - const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel; - return normalizePiThinkingOption(currentThinking); -} - -function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) { - const mappedOption = { - id: option.id, - label: option.label, - description: option.description, - }; - if (option.isDefault) { - return { - ...mappedOption, - isDefault: true, - }; - } - return mappedOption; -} - -function findModelInRegistry( - registry: PiDirectModelRegistry, - parsedReference: PiModelReference, -): Model | undefined { - if (parsedReference.provider) { - return ( - registry.find(parsedReference.provider, parsedReference.id) ?? - createProviderModelFallback(registry, { - provider: parsedReference.provider, - id: parsedReference.id, - }) - ); - } - - return registry.getAll().find((entry) => { - if (entry.id === parsedReference.id) { - return true; - } - return `${entry.provider}/${entry.id}` === parsedReference.id; - }); -} - -function createProviderModelFallback( - registry: PiDirectModelRegistry, - parsedReference: { provider: string; id: string }, -): Model | undefined { - const providerDefault = registry - .getAll() - .find((model) => model.provider === parsedReference.provider); - if (!providerDefault) { - return undefined; - } - - return { - id: parsedReference.id, - name: parsedReference.id, - api: providerDefault.api, - provider: parsedReference.provider, - baseUrl: providerDefault.baseUrl, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 16384, - compat: providerDefault.compat, - }; -} - -export class PiDirectAgentSession implements AgentSession { - readonly provider = PI_PROVIDER; - readonly capabilities = PI_CAPABILITIES; - - private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); - private readonly activeToolCalls = new Map(); - private activeTurnId: string | null = null; - private lastKnownThinkingOptionId: string | null; - private latestUsage: AgentUsage | undefined; - - constructor( - private readonly runtime: PiDirectSessionRuntimeAdapter, - private readonly modelRegistry: PiDirectModelRegistry, - private readonly config: AgentSessionConfig, - ) { - const session = this.session; - this.lastKnownThinkingOptionId = - normalizePiThinkingOption(config.thinkingOptionId) ?? session.thinkingLevel ?? null; - - session.subscribe((event) => { - this.handleSessionEvent(event); - }); - } - - private get session(): PiDirectSessionAdapter { - return this.runtime.session; - } - - get id(): string | null { - return this.session.sessionId; - } - - private emit(event: AgentStreamEvent): void { - for (const subscriber of this.subscribers) { - subscriber(event); - } - } - - private currentTurnIdForEvent(): string | undefined { - return this.activeTurnId ?? undefined; - } - - private emitToolCallEvent( - toolCallId: string, - toolCall: PiTrackedToolCall, - status: "running" | "completed" | "failed", - result: PiToolResult, - error: unknown, - ): void { - const turnId = this.currentTurnIdForEvent(); - const detail = mapToolDetail(toolCall, result); - const baseItem = { - type: "tool_call" as const, - callId: toolCallId, - name: toolCall.toolName, - detail, - }; - const item = - status === "failed" ? { ...baseItem, status, error } : { ...baseItem, status, error: null }; - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item, - }); - } - - private handleMessageUpdate( - event: Extract, - turnId: string | undefined, - ): void { - if (event.message.role !== "assistant") { - return; - } - if (event.assistantMessageEvent.type === "text_delta") { - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "assistant_message", - text: event.assistantMessageEvent.delta ?? "", - }, - }); - return; - } - if (event.assistantMessageEvent.type === "thinking_delta") { - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "reasoning", - text: event.assistantMessageEvent.delta ?? "", - }, - }); - } - } - - private handleSessionEvent(event: AgentSessionEvent): void { - const turnId = this.currentTurnIdForEvent(); - - switch (event.type) { - case "agent_start": - this.emit({ - type: "thread_started", - provider: PI_PROVIDER, - sessionId: this.session.sessionId, - }); - return; - case "turn_start": - this.emit({ - type: "turn_started", - provider: PI_PROVIDER, - turnId, - }); - return; - case "message_update": - this.handleMessageUpdate(event, turnId); - return; - case "tool_execution_start": { - const toolCall = parseToolArgs(event.toolName, event.args); - this.activeToolCalls.set(event.toolCallId, toolCall); - this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null); - return; - } - case "tool_execution_update": { - const toolCall = this.activeToolCalls.get(event.toolCallId); - if (!toolCall) { - return; - } - - const partialResult = parseToolResult(event.partialResult); - this.emitToolCallEvent(event.toolCallId, toolCall, "running", partialResult, null); - return; - } - case "tool_execution_end": { - const toolCall = - this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); - this.activeToolCalls.delete(event.toolCallId); - - const result = parseToolResult(event.result); - const error = event.isError ? event.result : null; - const status = event.isError ? "failed" : "completed"; - this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); - return; - } - case "turn_end": - return; - case "compaction_start": - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "compaction", - status: "loading", - trigger: event.reason === "manual" ? "manual" : "auto", - }, - }); - return; - case "compaction_end": - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "compaction", - status: "completed", - }, - }); - return; - case "agent_end": { - this.latestUsage = toAgentUsage(this.session.getSessionStats()); - const currentTurnId = turnId; - this.activeTurnId = null; - if (this.session.agent.state.errorMessage) { - this.emit({ - type: "turn_failed", - provider: PI_PROVIDER, - turnId: currentTurnId, - error: this.session.agent.state.errorMessage, - }); - return; - } - this.emit({ - type: "turn_completed", - provider: PI_PROVIDER, - turnId: currentTurnId, - usage: this.latestUsage, - }); - return; - } - default: - return; - } - } - - async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { - const timeline: AgentTimelineItem[] = []; - let finalText = ""; - let usage: AgentUsage | undefined; - let turnId: string | null = null; - const bufferedEvents: AgentStreamEvent[] = []; - let settled = false; - let resolveCompletion!: () => void; - let rejectCompletion!: (error: Error) => void; - - function processEvent(event: AgentStreamEvent): void { - if (settled) { - return; - } - - const eventTurnId = getAgentStreamEventTurnId(event); - if (turnId && eventTurnId && eventTurnId !== turnId) { - return; - } - if (event.type === "timeline") { - timeline.push(event.item); - if (event.item.type === "assistant_message") { - finalText += event.item.text; - } - return; - } - if (event.type === "turn_completed") { - usage = event.usage; - settled = true; - resolveCompletion(); - return; - } - if (event.type === "turn_failed") { - settled = true; - rejectCompletion(new Error(event.error)); - } - } - - const completion = new Promise((resolve, reject) => { - resolveCompletion = resolve; - rejectCompletion = reject; - }); - const unsubscribe = this.subscribe((event) => { - if (!turnId) { - bufferedEvents.push(event); - return; - } - processEvent(event); - }); - - try { - const result = await this.startTurn(prompt, options); - turnId = result.turnId; - for (const event of bufferedEvents) { - processEvent(event); - } - if (!settled) { - await completion; - } - } finally { - unsubscribe(); - } - - return { - sessionId: this.session.sessionId, - finalText, - usage, - timeline, - }; - } - - async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise { - if (this.activeTurnId) { - throw new Error("A Pi turn is already active"); - } - - const payload = convertPromptInput(prompt); - const turnId = randomUUID(); - this.activeTurnId = turnId; - - try { - await applyPiSessionRecoveryPolicy(this.session); - } catch (error) { - this.activeTurnId = null; - this.emit({ - type: "turn_failed", - provider: PI_PROVIDER, - turnId, - error: toDiagnosticErrorMessage(error), - }); - return { turnId }; - } - - void this.session - .prompt(payload.text, payload.images ? { images: payload.images } : undefined) - .catch((error) => { - const failedTurnId = this.activeTurnId ?? turnId; - this.activeTurnId = null; - if (isPiRequestAbortError(error)) { - this.emit({ - type: "turn_canceled", - provider: PI_PROVIDER, - turnId: failedTurnId, - reason: toDiagnosticErrorMessage(error), - }); - return; - } - this.emit({ - type: "turn_failed", - provider: PI_PROVIDER, - turnId: failedTurnId, - error: toDiagnosticErrorMessage(error), - }); - }); - - return { turnId }; - } - - subscribe(callback: (event: AgentStreamEvent) => void): () => void { - this.subscribers.add(callback); - return () => { - this.subscribers.delete(callback); - }; - } - - async *streamHistory(): AsyncGenerator { - const pendingToolCalls = new Map(); - let userIndex = 0; - - for (const message of this.session.messages) { - if (message.role === "user") { - const text = getUserMessageText(message.content); - if (text) { - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { - type: "user_message", - text, - messageId: `pi-user-${userIndex}`, - }, - }; - } - userIndex += 1; - continue; - } - - if (message.role === "assistant") { - for (const content of message.content) { - if (content.type === "text" && content.text) { - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { type: "assistant_message", text: content.text }, - }; - continue; - } - - if (content.type === "thinking" && content.thinking) { - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { type: "reasoning", text: content.thinking }, - }; - continue; - } - - if (content.type === "toolCall") { - const tracked = parseToolArgs(content.name, content.arguments); - pendingToolCalls.set(content.id, tracked); - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { - type: "tool_call", - callId: content.id, - name: tracked.toolName, - status: "running", - detail: mapToolDetail(tracked, null), - error: null, - }, - }; - } - } - continue; - } - - if (message.role === "toolResult") { - const tracked = - pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null); - pendingToolCalls.delete(message.toolCallId); - const result = parseToolResult({ content: message.content }); - const detail = mapToolDetail(tracked, result); - - if (message.isError) { - const errorText = extractTextFromToolResult(result) ?? "Tool call failed"; - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { - type: "tool_call", - callId: message.toolCallId, - name: tracked.toolName, - status: "failed", - detail, - error: errorText, - }, - }; - } else { - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { - type: "tool_call", - callId: message.toolCallId, - name: tracked.toolName, - status: "completed", - detail, - error: null, - }, - }; - } - continue; - } - - if (message.role === "bashExecution") { - const callId = `pi-bash-${message.timestamp}`; - const exitCode = message.exitCode ?? null; - const detail: ToolCallDetail = { - type: "shell", - command: message.command, - output: message.output, - exitCode, - }; - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { - type: "tool_call", - callId, - name: "bash", - status: message.cancelled ? "canceled" : "completed", - detail, - error: null, - }, - }; - } - } - } - - async getRuntimeInfo(): Promise { - const thinkingOptionId = resolveThinkingOptionId( - this.lastKnownThinkingOptionId, - this.session.thinkingLevel, - ); - - return { - provider: PI_PROVIDER, - sessionId: this.session.sessionId, - model: this.session.model ? `${this.session.model.provider}/${this.session.model.id}` : null, - thinkingOptionId, - modeId: null, - }; - } - - async getAvailableModes(): Promise { - return []; - } - - async getCurrentMode(): Promise { - return null; - } - - async setMode(modeId: string): Promise { - void modeId; - throw new Error("Pi does not expose selectable modes"); - } - - getPendingPermissions(): AgentPermissionRequest[] { - return []; - } - - async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { - void requestId; - void response; - } - - describePersistence(): AgentPersistenceHandle | null { - return { - provider: PI_PROVIDER, - sessionId: this.session.sessionId, - nativeHandle: this.session.sessionManager.getSessionFile(), - metadata: { - cwd: this.session.sessionManager.getCwd(), - }, - }; - } - - async interrupt(): Promise { - await this.session.abort(); - } - - async close(): Promise { - await this.runtime.dispose(); - } - - async listCommands(): Promise { - return buildSlashCommands(this.session); - } - - async setModel(modelId: string | null): Promise { - const parsedReference = parseModelReference(modelId); - if (!parsedReference) { - return; - } - - const model = findModelInRegistry(this.modelRegistry, parsedReference); - if (!model) { - throw new Error(`Unknown Pi model: ${modelId}`); - } - - await this.session.setModel(model); - this.config.model = `${model.provider}/${model.id}`; - } - - async setThinkingOption(thinkingOptionId: string | null): Promise { - const thinkingLevel = normalizePiThinkingOption(thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL; - this.session.setThinkingLevel(thinkingLevel); - this.lastKnownThinkingOptionId = thinkingLevel; - this.config.thinkingOptionId = thinkingLevel; - } -} - -export class PiDirectAgentClient implements AgentClient { - readonly provider = PI_PROVIDER; - readonly capabilities = PI_CAPABILITIES; - - private readonly logger: Logger; - private readonly runtimeSettings?: ProviderRuntimeSettings; - private modelRegistry: ModelRegistry | null = null; - - constructor(options: PiDirectAgentClientOptions) { - this.logger = options.logger; - this.runtimeSettings = options.runtimeSettings; - } - - private async getSessionServices(cwd: string, agentDir?: string): Promise { - return createAgentSessionServices({ - cwd, - ...(agentDir ? { agentDir } : {}), - ...(this.modelRegistry ? { modelRegistry: this.modelRegistry } : {}), - }); - } - - private resolveConfiguredModel( - registry: PiDirectModelRegistry, - modelId: string | null | undefined, - ): Model | undefined { - const parsedReference = parseModelReference(modelId ?? null); - if (!parsedReference) { - return undefined; - } - - return findModelInRegistry(registry, parsedReference); - } - - private async createSdkRuntime( - config: AgentSessionConfig, - sessionManager: SessionManager, - options: { defaultThinkingLevel?: ThinkingLevel } = {}, - ): Promise { - const createRuntime: CreateAgentSessionRuntimeFactory = async ({ - cwd, - agentDir, - sessionManager: runtimeSessionManager, - sessionStartEvent, - }) => { - const thinkingLevel = - normalizePiThinkingOption(config.thinkingOptionId) ?? options.defaultThinkingLevel; - const services = await this.getSessionServices(cwd, agentDir); - const model = this.resolveConfiguredModel(services.modelRegistry, config.model); - - return { - ...(await createAgentSessionFromServices({ - services, - sessionManager: runtimeSessionManager, - sessionStartEvent, - ...(thinkingLevel ? { thinkingLevel } : {}), - ...(model ? { model } : {}), - })), - services, - diagnostics: services.diagnostics, - }; - }; - - const runtime = await createAgentSessionRuntime(createRuntime, { - cwd: sessionManager.getCwd(), - agentDir: getAgentDir(), - sessionManager, - }); - await runtime.session.bindExtensions({}); - applySystemPrompt(runtime.session, config.systemPrompt); - return { runtime, modelRegistry: runtime.services.modelRegistry }; - } - - async createSession( - config: AgentSessionConfig, - _launchContext?: AgentLaunchContext, - ): Promise { - const { runtime, modelRegistry } = await this.createSdkRuntime( - config, - SessionManager.create(config.cwd), - { - defaultThinkingLevel: DEFAULT_PI_THINKING_LEVEL, - }, - ); - return new PiDirectAgentSession(runtime, modelRegistry, config); - } - - async resumeSession( - handle: AgentPersistenceHandle, - overrides?: Partial, - _launchContext?: AgentLaunchContext, - ): Promise { - const sessionFile = handle.nativeHandle; - if (!sessionFile) { - throw new Error("Pi resume requires a native session file handle"); - } - - const initialManager = SessionManager.open(sessionFile); - const persistenceMetadata = parsePersistenceMetadata(handle.metadata); - const cwd = overrides?.cwd ?? persistenceMetadata.cwd ?? initialManager.getCwd(); - const resumedManager = SessionManager.open(sessionFile, undefined, cwd); - const mergedConfig: AgentSessionConfig = { - provider: PI_PROVIDER, - cwd, - model: overrides?.model, - thinkingOptionId: overrides?.thinkingOptionId, - systemPrompt: overrides?.systemPrompt, - featureValues: overrides?.featureValues, - title: overrides?.title, - approvalPolicy: overrides?.approvalPolicy, - sandboxMode: overrides?.sandboxMode, - networkAccess: overrides?.networkAccess, - webSearch: overrides?.webSearch, - extra: overrides?.extra, - mcpServers: overrides?.mcpServers, - internal: overrides?.internal, - modeId: overrides?.modeId, - }; - - const { runtime, modelRegistry } = await this.createSdkRuntime(mergedConfig, resumedManager); - return new PiDirectAgentSession(runtime, modelRegistry, mergedConfig); - } - - async listModels(options: ListModelsOptions): Promise { - const services = await this.getSessionServices(options.cwd); - const models = services.modelRegistry.getAvailable().map((model) => ({ - provider: PI_PROVIDER, - id: `${model.provider}/${model.id}`, - label: `${model.provider}/${model.name}`, - description: `${model.provider}/${model.id}`, - metadata: { - provider: model.provider, - modelId: model.id, - } satisfies AgentMetadata, - thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined, - defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined, - })); - - return transformPiModels(models); - } - - async listModes(_options: ListModesOptions): Promise { - return []; - } - - async isAvailable(): Promise { - const command = this.runtimeSettings?.command; - if (command?.mode === "replace" && command.argv[0]) { - if (!existsSync(command.argv[0])) { - return false; - } - } else if (!(await isCommandAvailable(PI_BINARY_COMMAND))) { - return false; - } - - const registry = ModelRegistry.create(AuthStorage.create()); - return registry.getAvailable().length > 0; - } - - async getDiagnostic(): Promise<{ diagnostic: string }> { - try { - const available = await this.isAvailable(); - const binaryOverride = this.runtimeSettings?.command; - const binary = - binaryOverride?.mode === "replace" && binaryOverride.argv[0] - ? binaryOverride.argv[0] - : await findExecutable(PI_BINARY_COMMAND); - const version = binary ? await resolveBinaryVersion(binary) : "unknown"; - const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); - let modelsValue = "Not checked"; - let status = formatDiagnosticStatus(available); - const registry = ModelRegistry.create(AuthStorage.create()); - const configuredProviders = Array.from( - new Set(registry.getAvailable().map((model) => model.provider)), - ).sort(); - - if (available) { - try { - const models = await this.listModels({ cwd: homedir(), force: false }); - modelsValue = String(models.length); - } catch (error) { - modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; - status = formatDiagnosticStatus(available, { - source: "model fetch", - cause: error, - }); - } - } - - return { - diagnostic: formatProviderDiagnostic("Pi", [ - { label: "Binary", value: binary ?? "not found" }, - { label: "Version", value: version }, - { - label: "Configured providers", - value: configuredProviders.length > 0 ? configuredProviders.join(", ") : "none", - }, - { - label: "Auth config (~/.pi/agent/auth.json)", - value: existsSync(authConfigPath) ? "found" : "not found", - }, - { label: "Models", value: modelsValue }, - { label: "Status", value: status }, - ]), - }; - } catch (error) { - this.logger.debug({ err: error }, "Pi diagnostic lookup failed"); - return { - diagnostic: formatProviderDiagnosticError("Pi", error), - }; - } - } -} diff --git a/packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts b/packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts deleted file mode 100644 index 302b7f075..000000000 --- a/packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; - -import { - applyPiSessionRecoveryPolicy, - type PiSessionRecoveryPolicySession, -} from "./pi-session-recovery-policy.js"; - -function createRecoverySession( - options: { - messages?: PiSessionRecoveryPolicySession["messages"]; - errorMessage?: string | null; - compact?: () => Promise; - } = {}, -): PiSessionRecoveryPolicySession { - return { - messages: options.messages ?? [], - agent: { - state: { - errorMessage: options.errorMessage ?? null, - }, - }, - compact: options.compact ?? vi.fn(async () => undefined), - }; -} - -describe("applyPiSessionRecoveryPolicy", () => { - test("compacts sessions that ended on Pi Copilot short 413 overflow", async () => { - const compact = vi.fn(async () => undefined); - const session = createRecoverySession({ - messages: [ - { - role: "assistant", - stopReason: "error", - errorMessage: "413 failed to parse request", - }, - ], - compact, - }); - - const result = await applyPiSessionRecoveryPolicy(session); - - expect(result).toEqual({ applied: true, policyId: "piCopilot413" }); - expect(compact).toHaveBeenCalledTimes(1); - }); - - test("uses Pi agent error state when message history has no matching assistant error", async () => { - const compact = vi.fn(async () => undefined); - const session = createRecoverySession({ - messages: [{ role: "user" }], - errorMessage: " 413 failed to parse request ", - compact, - }); - - const result = await applyPiSessionRecoveryPolicy(session); - - expect(result).toEqual({ applied: true, policyId: "piCopilot413" }); - expect(compact).toHaveBeenCalledTimes(1); - }); - - test("does not compact unrelated Pi errors", async () => { - const compact = vi.fn(async () => undefined); - const session = createRecoverySession({ - messages: [ - { - role: "assistant", - stopReason: "error", - errorMessage: "413 unrelated provider error", - }, - ], - compact, - }); - - const result = await applyPiSessionRecoveryPolicy(session); - - expect(result).toEqual({ applied: false }); - expect(compact).not.toHaveBeenCalled(); - }); - - test("treats already-compacted sessions as recovered", async () => { - const session = createRecoverySession({ - messages: [ - { - role: "assistant", - stopReason: "error", - errorMessage: "413 failed to parse request", - }, - ], - compact: vi.fn(async () => { - throw new Error("Already compacted"); - }), - }); - - await expect(applyPiSessionRecoveryPolicy(session)).resolves.toEqual({ - applied: true, - policyId: "piCopilot413", - }); - }); - - test("surfaces unexpected compaction failures", async () => { - const session = createRecoverySession({ - messages: [ - { - role: "assistant", - stopReason: "error", - errorMessage: "413 failed to parse request", - }, - ], - compact: vi.fn(async () => { - throw new Error("disk exploded"); - }), - }); - - await expect(applyPiSessionRecoveryPolicy(session)).rejects.toThrow("disk exploded"); - }); -}); diff --git a/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts b/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts deleted file mode 100644 index 140eb6b09..000000000 --- a/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { toDiagnosticErrorMessage } from "./diagnostic-utils.js"; - -export interface PiSessionRecoveryPolicySession { - readonly messages: readonly PiRecoveryMessage[]; - readonly agent: { - readonly state: { - readonly errorMessage?: string | null; - }; - }; - compact(): Promise; -} - -interface PiRecoveryMessage { - readonly role: string; - readonly stopReason?: string; - readonly errorMessage?: string | null; -} - -type PiSessionRecoveryPolicyId = "piCopilot413"; - -interface PiSessionRecoveryPolicy { - readonly id: PiSessionRecoveryPolicyId; - shouldRecover(session: PiSessionRecoveryPolicySession): boolean; - recover(session: PiSessionRecoveryPolicySession): Promise; -} - -export interface PiSessionRecoveryResult { - readonly applied: boolean; - readonly policyId?: PiSessionRecoveryPolicyId; -} - -// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal -// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow. -const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i; - -const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [ - { - id: "piCopilot413", - shouldRecover: shouldCompactForPiCopilot413, - recover: compactPiSession, - }, -]; - -export async function applyPiSessionRecoveryPolicy( - session: PiSessionRecoveryPolicySession, -): Promise { - const policy = PI_SESSION_RECOVERY_POLICIES.find((entry) => entry.shouldRecover(session)); - if (!policy) { - return { applied: false }; - } - - await policy.recover(session); - return { applied: true, policyId: policy.id }; -} - -function shouldCompactForPiCopilot413(session: PiSessionRecoveryPolicySession): boolean { - return ( - isPiCopilotShort413Overflow(getLatestAssistantErrorMessage(session)) || - isPiCopilotShort413Overflow(session.agent.state.errorMessage) - ); -} - -function isPiCopilotShort413Overflow(errorMessage: string | null | undefined): boolean { - const normalized = errorMessage?.trim(); - return normalized ? PI_COPILOT_SHORT_413_OVERFLOW_PATTERN.test(normalized) : false; -} - -function getLatestAssistantErrorMessage(session: PiSessionRecoveryPolicySession): string | null { - for (let index = session.messages.length - 1; index >= 0; index -= 1) { - const message = session.messages[index]; - if (message.role !== "assistant") { - continue; - } - return message.stopReason === "error" ? (message.errorMessage?.trim() ?? null) : null; - } - return null; -} - -async function compactPiSession(session: PiSessionRecoveryPolicySession): Promise { - try { - await session.compact(); - } catch (error) { - if (!isHarmlessPiCompactionError(error)) { - throw error; - } - } -} - -function isHarmlessPiCompactionError(error: unknown): boolean { - return /already compacted|nothing to compact/i.test(toDiagnosticErrorMessage(error)); -} diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts new file mode 100644 index 000000000..30a2d6d55 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -0,0 +1,489 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { describe, expect, test } from "vitest"; + +import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; +import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js"; +import { FakePi } from "./test-utils/fake-pi.js"; + +function createClient(pi = new FakePi()): PiRpcAgentClient { + return new PiRpcAgentClient({ + logger: pino({ level: "silent" }), + runtime: pi, + }); +} + +function createClientWithPiAgentDir(agentDir: string): PiRpcAgentClient { + return new PiRpcAgentClient({ + logger: pino({ level: "silent" }), + runtime: new FakePi(), + runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } }, + }); +} + +function createConfig(overrides: Partial = {}): AgentSessionConfig { + return { + provider: "pi", + cwd: "/tmp/paseo-pi-rpc-test", + ...overrides, + }; +} + +async function createSession(pi = new FakePi()): Promise<{ + pi: FakePi; + session: PiRpcAgentSession; + events: SessionEvents; +}> { + const client = createClient(pi); + const session = (await client.createSession(createConfig())) as PiRpcAgentSession; + const events = new SessionEvents(session); + return { pi, session, events }; +} + +class SessionEvents { + private readonly events: AgentStreamEvent[] = []; + private readonly waiters: Array<{ + predicate: (event: AgentStreamEvent) => boolean; + resolve: (event: AgentStreamEvent) => void; + }> = []; + + constructor(session: PiRpcAgentSession) { + session.subscribe((event) => { + this.events.push(event); + for (let index = 0; index < this.waiters.length; index += 1) { + const waiter = this.waiters[index]; + if (waiter.predicate(event)) { + this.waiters.splice(index, 1); + index -= 1; + waiter.resolve(event); + } + } + }); + } + + timelineItems() { + return this.events + .filter( + (event): event is Extract => + event.type === "timeline", + ) + .map((event) => event.item); + } + + nextTurnCompletion(): Promise> { + return this.nextEvent( + (event): event is Extract => + event.type === "turn_completed", + ); + } + + nextTurnFailure(): Promise> { + return this.nextEvent( + (event): event is Extract => + event.type === "turn_failed", + ); + } + + private nextEvent( + predicate: (event: AgentStreamEvent) => event is T, + ): Promise { + const existing = this.events.find(predicate); + if (existing) { + return Promise.resolve(existing); + } + return new Promise((resolve) => { + this.waiters.push({ + predicate, + resolve: (event) => resolve(event as T), + }); + }); + } +} + +describe("PiRpcAgentSession", () => { + test("streams assistant text, reasoning, and tool calls from Pi events", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + await session.startTurn("hello"); + fakeSession.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "hello" }, + }); + fakeSession.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "thinking_delta", delta: "thinking" }, + }); + fakeSession.emit({ + type: "tool_execution_start", + toolCallId: "tool-1", + toolName: "bash", + args: { command: "echo hi" }, + }); + fakeSession.emit({ + type: "tool_execution_end", + toolCallId: "tool-1", + toolName: "bash", + result: { output: "hi\n", exitCode: 0 }, + isError: false, + }); + fakeSession.finishTurn(); + + await events.nextTurnCompletion(); + + expect(events.timelineItems()).toEqual([ + { type: "assistant_message", text: "hello" }, + { type: "reasoning", text: "thinking" }, + { + type: "tool_call", + callId: "tool-1", + name: "bash", + status: "running", + detail: { type: "shell", command: "echo hi" }, + error: null, + }, + { + type: "tool_call", + callId: "tool-1", + name: "bash", + status: "completed", + detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 }, + error: null, + }, + ]); + }); + + test("resumes by launching Pi with the persisted session file and cwd metadata", async () => { + const pi = new FakePi(); + const client = createClient(pi); + + await client.resumeSession( + { + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session.jsonl", + metadata: { + cwd: "/workspace/project", + model: "openrouter/model-a", + thinkingOptionId: "high", + }, + }, + {}, + ); + + expect(pi.recordedLaunches).toEqual([ + expect.objectContaining({ + cwd: "/workspace/project", + session: "/tmp/native-pi-session.jsonl", + argv: [ + "pi", + "--mode", + "rpc", + "--model", + "openrouter/model-a", + "--thinking", + "high", + "--session", + "/tmp/native-pi-session.jsonl", + ], + }), + ]); + }); + + test("creates Pi sessions with agent and daemon system prompts appended", async () => { + const pi = new FakePi(); + const client = createClient(pi); + + await client.createSession( + createConfig({ + systemPrompt: "Agent prompt", + daemonAppendSystemPrompt: "Daemon prompt", + }), + ); + + expect(pi.recordedLaunches[0]).toEqual( + expect.objectContaining({ + cwd: "/tmp/paseo-pi-rpc-test", + systemPrompt: "Agent prompt\n\nDaemon prompt", + argv: [ + "pi", + "--mode", + "rpc", + "--thinking", + "medium", + "--append-system-prompt", + "Agent prompt\n\nDaemon prompt", + ], + }), + ); + }); + + test("resumes Pi sessions with daemon system prompts appended", async () => { + const pi = new FakePi(); + const client = createClient(pi); + + await client.resumeSession( + { + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session.jsonl", + metadata: { + cwd: "/workspace/project", + model: "openrouter/model-a", + thinkingOptionId: "high", + systemPrompt: "Agent prompt", + }, + }, + { + daemonAppendSystemPrompt: "Daemon prompt", + }, + ); + + expect(pi.recordedLaunches).toEqual([ + expect.objectContaining({ + cwd: "/workspace/project", + session: "/tmp/native-pi-session.jsonl", + systemPrompt: "Agent prompt\n\nDaemon prompt", + argv: [ + "pi", + "--mode", + "rpc", + "--model", + "openrouter/model-a", + "--thinking", + "high", + "--session", + "/tmp/native-pi-session.jsonl", + "--append-system-prompt", + "Agent prompt\n\nDaemon prompt", + ], + }), + ]); + }); + + test("updates model and thinking through Pi runtime commands", async () => { + const { pi, session } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.setModelResult = { provider: "openrouter", id: "model-a", name: "Model A" }; + + await session.setModel("openrouter/model-a"); + await session.setThinkingOption("high"); + + expect(fakeSession.setModelRequests).toEqual([{ provider: "openrouter", modelId: "model-a" }]); + expect(fakeSession.setThinkingLevelRequests).toEqual(["high"]); + }); + + test("fails the active turn when the Pi process exits mid-turn", async () => { + const { pi, session, events } = await createSession(); + + await session.startTurn("hello"); + pi.latestSession().emit({ type: "process_exit", error: "Pi exited" }); + + await expect(events.nextTurnFailure()).resolves.toMatchObject({ + error: "Pi exited", + }); + }); +}); + +describe("PiRpcAgentClient", () => { + test("lists models from a short-lived Pi session in the requested cwd", async () => { + const pi = new FakePi(); + const client = createClient(pi); + const modelsPromise = client.listModels({ cwd: "/workspace/with-extension", force: false }); + pi.latestSession().models = [ + { + provider: "openrouter", + id: "google/gemini-2.5-flash-lite", + name: "google/gemini-2.5-flash-lite", + reasoning: true, + }, + ]; + + await expect(modelsPromise).resolves.toMatchObject([ + { + provider: "pi", + id: "openrouter/google/gemini-2.5-flash-lite", + label: "gemini-2.5-flash-lite", + defaultThinkingOptionId: "medium", + }, + ]); + expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" }); + }); + + test("maps extension, prompt, and skill commands to Paseo slash commands", async () => { + const { pi, session } = await createSession(); + pi.latestSession().commands = [ + { name: "review", description: "Review changes", source: "extension" }, + { name: "fix-tests", description: "Fix tests", source: "prompt" }, + { name: "skill:docs", description: "Read docs", source: "skill" }, + ]; + + await expect(session.listCommands()).resolves.toEqual([ + { name: "review", description: "Review changes", argumentHint: "" }, + { name: "fix-tests", description: "Fix tests", argumentHint: "" }, + { name: "skill:docs", description: "Read docs", argumentHint: "" }, + ]); + }); + + test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => { + const pi = new FakePi(); + pi.queueCommands([ + { + name: "mcp", + description: "Show MCP server status", + source: "extension", + sourceInfo: { source: "npm:pi-mcp-adapter" }, + }, + ]); + const client = createClient(pi); + + const session = await client.createSession( + createConfig({ + mcpServers: { + paseo: { + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + }, + localSecret: { + type: "stdio", + command: "node", + args: ["secret-server.js"], + env: { SECRET_NUMBER: "314159" }, + }, + }, + }), + ); + + expect(pi.recordedLaunches).toHaveLength(2); + expect(pi.recordedLaunches[0]).toMatchObject({ + cwd: "/tmp/paseo-pi-rpc-test", + argv: ["pi", "--mode", "rpc"], + }); + const actualLaunch = pi.recordedLaunches[1]; + expect(actualLaunch.argv).toEqual([ + "pi", + "--mode", + "rpc", + "--thinking", + "medium", + "--mcp-config", + actualLaunch.mcpConfigPath, + ]); + expect(session.capabilities.supportsMcpServers).toBe(true); + + const configPath = actualLaunch.mcpConfigPath; + expect(configPath).toEqual(expect.any(String)); + const injectedConfig = JSON.parse(readFileSync(configPath!, "utf8")) as { + mcpServers: Record; + }; + expect(injectedConfig).toEqual({ + mcpServers: { + paseo: { + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + auth: false, + oauth: false, + }, + localSecret: { + command: "node", + args: ["secret-server.js"], + env: { SECRET_NUMBER: "314159" }, + }, + }, + }); + + await session.close(); + expect(existsSync(configPath!)).toBe(false); + }); + + test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => { + const pi = new FakePi(); + pi.queueCommands([]); + const client = createClient(pi); + + const session = await client.createSession( + createConfig({ + mcpServers: { + paseo: { + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + }, + }, + }), + ); + + expect(pi.recordedLaunches).toHaveLength(2); + expect(pi.recordedLaunches[1]?.argv).toEqual(["pi", "--mode", "rpc", "--thinking", "medium"]); + expect(pi.recordedLaunches[1]?.mcpConfigPath).toBeUndefined(); + expect(session.capabilities.supportsMcpServers).toBe(false); + }); + + test("lists persisted Pi sessions from the configured Pi agent directory", async () => { + const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-client-")); + const cwd = path.join(root, "workspace"); + const agentDir = path.join(root, "agent"); + const sessionsDir = path.join(agentDir, "sessions", "--workspace--"); + mkdirSync(sessionsDir, { recursive: true }); + const sessionFile = path.join(sessionsDir, "20260101_session.jsonl"); + writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: "session", + version: 3, + id: "pi-session", + timestamp: "2026-01-01T00:00:00.000Z", + cwd, + }), + JSON.stringify({ + type: "message", + id: "entry-1", + parentId: null, + timestamp: "2026-01-01T00:00:01.000Z", + message: { role: "user", content: "remember this" }, + }), + ].join("\n") + "\n", + "utf8", + ); + const client = createClientWithPiAgentDir(agentDir); + + await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([ + { + provider: "pi", + sessionId: "pi-session", + cwd, + persistence: { + provider: "pi", + sessionId: "pi-session", + nativeHandle: sessionFile, + metadata: { provider: "pi", cwd }, + }, + timeline: [{ type: "user_message", text: "remember this" }], + }, + ]); + }); +}); + +describe("transformPiModels", () => { + test("normalizes labels that include the upstream provider prefix", () => { + expect( + transformPiModels([ + { + provider: "pi", + id: "openrouter/google/gemini-2.5-flash-lite", + label: "openrouter/google/gemini_2.5 flash lite", + }, + ]), + ).toEqual([ + { + provider: "pi", + id: "openrouter/google/gemini-2.5-flash-lite", + label: "gemini 2.5 flash lite", + description: "openrouter/google/gemini_2.5 flash lite", + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts new file mode 100644 index 000000000..3c4607c99 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -0,0 +1,1074 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; + +import { + type AgentCapabilityFlags, + type AgentClient, + type AgentLaunchContext, + type AgentMetadata, + type AgentMode, + type AgentModelDefinition, + type McpServerConfig, + type AgentPermissionRequest, + type AgentPermissionResponse, + type AgentPersistenceHandle, + type AgentPromptInput, + type AgentRunOptions, + type AgentRunResult, + type AgentRuntimeInfo, + type AgentSession, + type AgentSessionConfig, + type AgentSlashCommand, + type AgentStreamEvent, + type AgentUsage, + type ListPersistedAgentsOptions, + type ListModesOptions, + type ListModelsOptions, + type PersistedAgentDescriptor, +} from "../../agent-sdk-types.js"; +import { runProviderTurn } from "../provider-runner.js"; +import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; +import { composeSystemPromptParts } from "../../system-prompt.js"; +import { findExecutable } from "../../../../utils/executable.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "../diagnostic-utils.js"; +import { streamPiHistory } from "./history-mapper.js"; +import { PiCliRuntime } from "./cli-runtime.js"; +import { listPiPersistedAgents } from "./session-descriptor.js"; +import type { PiRuntime, PiRuntimeSession } from "./runtime.js"; +import type { + PiAgentSessionEvent, + PiAgentMessage, + PiImageContent, + PiModel, + PiRpcSlashCommand, + PiRuntimeEvent, + PiSessionStats, + PiSessionState, + PiThinkingLevel, +} from "./rpc-types.js"; +import { + mapToolDetail, + parseToolArgs, + parseToolResult, + resolveToolCallName, + type PiToolResult, + type PiTrackedToolCall, +} from "./tool-call-mapper.js"; + +const PI_PROVIDER = "pi"; +const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium"; +const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi"; + +const PI_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: false, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const PI_THINKING_OPTIONS: ReadonlyArray<{ + id: PiThinkingLevel; + label: string; + description: string; + isDefault?: boolean; +}> = [ + { id: "off", label: "Off", description: "No extra reasoning" }, + { id: "minimal", label: "Minimal", description: "Light reasoning" }, + { id: "low", label: "Low", description: "Faster reasoning" }, + { id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, + { id: "high", label: "High", description: "Deeper reasoning" }, + { id: "xhigh", label: "XHigh", description: "Maximum reasoning" }, +] as const; + +interface PiRpcAgentClientOptions { + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; + runtime?: PiRuntime; +} + +interface PiPromptPayload { + text: string; + images?: PiImageContent[]; +} + +interface PiModelReference { + provider?: string; + id: string; +} + +interface PiPersistenceMetadata { + cwd?: string; + model?: string; + thinkingOptionId?: string; + systemPrompt?: string; +} + +interface StartTurnResult { + turnId: string; +} + +interface PiRpcAgentSessionOptions { + runtimeSession: PiRuntimeSession; + config: AgentSessionConfig; + initialState: PiSessionState; + capabilities: AgentCapabilityFlags; + cleanup?: () => void; +} + +interface PiResumeConfig { + cwd: string; + model?: string; + thinkingOptionId?: string; + config: AgentSessionConfig; +} + +interface PiMcpServerConfig { + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Record; + auth?: false; + oauth?: false; +} + +interface PiMcpConfigFile { + path: string; + cleanup: () => void; +} + +function normalizePiModelLabel(label: string): string { + return label.trim().replace(/[_\s]+/g, " "); +} + +export function transformPiModels(models: AgentModelDefinition[]): AgentModelDefinition[] { + return models.map((model) => { + if (!model.label.includes("/")) { + return model; + } + + const segments = model.label.split("/").filter((segment) => segment.length > 0); + const rawLabel = segments.at(-1); + if (!rawLabel) { + return model; + } + + return { + ...model, + label: normalizePiModelLabel(rawLabel), + description: model.description ?? model.label, + }; + }); +} + +function isPiThinkingLevel(value: string | null | undefined): value is PiThinkingLevel { + return ( + value === "off" || + value === "minimal" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ); +} + +function normalizePiThinkingOption(value: string | null | undefined): PiThinkingLevel | null { + if (!value) { + return null; + } + return isPiThinkingLevel(value) ? value : null; +} + +function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) { + const mappedOption = { + id: option.id, + label: option.label, + description: option.description, + }; + if (option.isDefault) { + return { + ...mappedOption, + isDefault: true, + }; + } + return mappedOption; +} + +function toAgentUsage(stats: PiSessionStats): AgentUsage | undefined { + const inputTokens = stats.tokens?.input ?? 0; + const cachedInputTokens = stats.tokens?.cacheRead ?? 0; + const outputTokens = stats.tokens?.output ?? 0; + const totalCostUsd = stats.cost ?? 0; + const contextWindowMaxTokens = stats.contextUsage?.contextWindow ?? undefined; + const contextWindowUsedTokens = stats.contextUsage?.tokens ?? undefined; + + if ( + inputTokens === 0 && + cachedInputTokens === 0 && + outputTokens === 0 && + totalCostUsd === 0 && + contextWindowMaxTokens === undefined && + contextWindowUsedTokens === undefined + ) { + return undefined; + } + + return { + inputTokens, + cachedInputTokens, + outputTokens, + totalCostUsd, + ...(typeof contextWindowMaxTokens === "number" ? { contextWindowMaxTokens } : {}), + ...(typeof contextWindowUsedTokens === "number" ? { contextWindowUsedTokens } : {}), + }; +} + +function convertPromptInput(prompt: AgentPromptInput): PiPromptPayload { + if (typeof prompt === "string") { + return { text: prompt }; + } + + const textParts: string[] = []; + const images: PiImageContent[] = []; + + for (const block of prompt) { + if (block.type === "text") { + textParts.push(block.text); + continue; + } + + if (block.type === "image") { + images.push({ + type: "image", + data: block.data, + mimeType: block.mimeType, + }); + continue; + } + + textParts.push(renderPromptAttachmentAsText(block)); + } + + const payload: PiPromptPayload = { + text: textParts.join("\n\n"), + }; + if (images.length > 0) { + payload.images = images; + } + return payload; +} + +function parseModelReference(modelId: string | null): PiModelReference | null { + if (!modelId) { + return null; + } + if (modelId.includes("/")) { + const [provider, ...rest] = modelId.split("/"); + const id = rest.join("/"); + if (provider && id) { + return { provider, id }; + } + } + if (modelId.includes(":")) { + const [provider, ...rest] = modelId.split(":"); + const id = rest.join(":"); + if (provider && id) { + return { provider, id }; + } + } + return { id: modelId }; +} + +function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersistenceMetadata { + if (!metadata) { + return {}; + } + return { + ...(typeof metadata.cwd === "string" ? { cwd: metadata.cwd } : {}), + ...(typeof metadata.model === "string" ? { model: metadata.model } : {}), + ...(typeof metadata.thinkingOptionId === "string" + ? { thinkingOptionId: metadata.thinkingOptionId } + : {}), + ...(typeof metadata.systemPrompt === "string" ? { systemPrompt: metadata.systemPrompt } : {}), + }; +} + +function buildResumeConfig( + metadata: PiPersistenceMetadata, + overrides: Partial | undefined, +): PiResumeConfig { + const overrideConfig = overrides ?? {}; + const cwd = overrideConfig.cwd ?? metadata.cwd ?? process.cwd(); + const model = overrideConfig.model ?? metadata.model; + const thinkingOptionId = overrideConfig.thinkingOptionId ?? metadata.thinkingOptionId; + return { + cwd, + model, + thinkingOptionId, + config: { + ...overrideConfig, + provider: PI_PROVIDER, + cwd, + model, + thinkingOptionId, + systemPrompt: overrideConfig.systemPrompt ?? metadata.systemPrompt, + }, + }; +} + +function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig { + if (config.type === "stdio") { + return { + command: config.command, + ...(config.args ? { args: config.args } : {}), + ...(config.env ? { env: config.env } : {}), + }; + } + + return { + url: config.url, + ...(config.headers ? { headers: config.headers } : {}), + auth: false, + oauth: false, + }; +} + +function createPiMcpConfigFile(servers: Record): PiMcpConfigFile { + const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-")); + const filePath = join(dir, "mcp.json"); + const mcpServers: Record = {}; + for (const [name, serverConfig] of Object.entries(servers)) { + mcpServers[name] = toPiMcpConfig(serverConfig); + } + writeFileSync(filePath, `${JSON.stringify({ mcpServers }, null, 2)}\n`, "utf8"); + return { + path: filePath, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean { + if (command.source !== "extension" || !/^mcp(?::\d+)?$/.test(command.name)) { + return false; + } + if (!command.sourceInfo) { + return true; + } + return JSON.stringify(command.sourceInfo).includes("pi-mcp-adapter"); +} + +function withPiMcpCapability(supportsMcpServers: boolean): AgentCapabilityFlags { + return { + ...PI_CAPABILITIES, + supportsMcpServers, + }; +} + +function isPiRequestAbortError(error: unknown): boolean { + if (error instanceof Error && error.name === "AbortError") { + return true; + } + + return /\brequest was aborted\b|\babort(ed)?\b/i.test(toDiagnosticErrorMessage(error)); +} + +function resolveThinkingOptionId( + cachedThinkingOptionId: string | null, + sessionThinkingLevel: PiThinkingLevel, +): PiThinkingLevel | null { + const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel; + return normalizePiThinkingOption(currentThinking); +} + +function modelToId(model: PiModel | null | undefined): string | null { + return model?.provider && model.id ? `${model.provider}/${model.id}` : null; +} + +function latestPiErrorMessage(messages: PiAgentMessage[]): string | null { + const latestAssistant = messages.findLast((message) => message.role === "assistant"); + return latestAssistant && "errorMessage" in latestAssistant + ? (latestAssistant.errorMessage ?? null) + : null; +} + +function mapPiModel(model: PiModel): AgentModelDefinition { + return { + provider: PI_PROVIDER, + id: `${model.provider}/${model.id}`, + label: `${model.provider}/${model.name ?? model.id}`, + description: `${model.provider}/${model.id}`, + metadata: { + provider: model.provider, + modelId: model.id, + }, + thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined, + defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined, + }; +} + +function createRuntime(logger: Logger, runtimeSettings?: ProviderRuntimeSettings): PiRuntime { + return new PiCliRuntime({ logger, runtimeSettings }); +} + +export class PiRpcAgentSession implements AgentSession { + readonly provider = PI_PROVIDER; + readonly capabilities: AgentCapabilityFlags; + + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private readonly activeToolCalls = new Map(); + private activeTurnId: string | null = null; + private lastKnownThinkingOptionId: string | null; + private state: PiSessionState; + private closed = false; + + constructor(options: PiRpcAgentSessionOptions) { + this.runtimeSession = options.runtimeSession; + this.config = options.config; + this.state = options.initialState; + this.capabilities = options.capabilities; + this.cleanup = options.cleanup; + this.lastKnownThinkingOptionId = + normalizePiThinkingOption(options.config.thinkingOptionId) ?? + this.state.thinkingLevel ?? + null; + + this.runtimeSession.onEvent((event) => { + this.handleRuntimeEvent(event); + }); + } + + private readonly runtimeSession: PiRuntimeSession; + private readonly config: AgentSessionConfig; + private readonly cleanup?: () => void; + + get id(): string | null { + return this.state.sessionId; + } + + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + return runProviderTurn({ + prompt, + runOptions: options, + startTurn: (p, o) => this.startTurn(p, o), + subscribe: (callback) => this.subscribe(callback), + getSessionId: () => this.state.sessionId, + reduceFinalText: ({ current, item }) => + item.type === "assistant_message" ? `${current}${item.text}` : current, + }); + } + + async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise { + if (this.activeTurnId) { + throw new Error("A Pi turn is already active"); + } + + const payload = convertPromptInput(prompt); + const turnId = randomUUID(); + this.activeTurnId = turnId; + + void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => { + const failedTurnId = this.activeTurnId ?? turnId; + this.activeTurnId = null; + if (isPiRequestAbortError(error)) { + this.emit({ + type: "turn_canceled", + provider: PI_PROVIDER, + turnId: failedTurnId, + reason: toDiagnosticErrorMessage(error), + }); + return; + } + this.emit({ + type: "turn_failed", + provider: PI_PROVIDER, + turnId: failedTurnId, + error: toDiagnosticErrorMessage(error), + }); + }); + + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + async *streamHistory(): AsyncGenerator { + yield* streamPiHistory(PI_PROVIDER, await this.runtimeSession.getMessages()); + } + + async getRuntimeInfo(): Promise { + await this.refreshState(); + return { + provider: PI_PROVIDER, + sessionId: this.state.sessionId, + model: modelToId(this.state.model), + thinkingOptionId: resolveThinkingOptionId( + this.lastKnownThinkingOptionId, + this.state.thinkingLevel, + ), + modeId: null, + }; + } + + async getAvailableModes(): Promise { + return []; + } + + async getCurrentMode(): Promise { + return null; + } + + async setMode(modeId: string): Promise { + void modeId; + throw new Error("Pi does not expose selectable modes"); + } + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + void requestId; + void response; + } + + describePersistence(): AgentPersistenceHandle | null { + return { + provider: PI_PROVIDER, + sessionId: this.state.sessionId, + nativeHandle: this.state.sessionFile, + metadata: { + cwd: this.config.cwd, + ...(this.config.model ? { model: this.config.model } : {}), + ...(this.config.thinkingOptionId ? { thinkingOptionId: this.config.thinkingOptionId } : {}), + }, + }; + } + + async interrupt(): Promise { + await this.runtimeSession.abort(); + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + try { + await this.runtimeSession.close(); + } finally { + this.cleanup?.(); + } + } + + async listCommands(): Promise { + const commands = await this.runtimeSession.getCommands(); + return commands.map((command) => ({ + name: command.name, + description: command.description ?? command.source, + argumentHint: "", + })); + } + + async setModel(modelId: string | null): Promise { + const parsedReference = parseModelReference(modelId); + if (!parsedReference) { + return; + } + if (!parsedReference.provider) { + throw new Error(`Pi model id must include a provider: ${modelId}`); + } + + const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id); + this.state = { + ...this.state, + model, + }; + this.config.model = `${model.provider}/${model.id}`; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + const thinkingLevel = normalizePiThinkingOption(thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL; + await this.runtimeSession.setThinkingLevel(thinkingLevel); + this.lastKnownThinkingOptionId = thinkingLevel; + this.config.thinkingOptionId = thinkingLevel; + this.state = { + ...this.state, + thinkingLevel, + }; + } + + private emit(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } + + private currentTurnIdForEvent(): string | undefined { + return this.activeTurnId ?? undefined; + } + + private handleRuntimeEvent(event: PiRuntimeEvent): void { + if (event.type === "extension_ui_request") { + this.runtimeSession.cancelExtensionUiRequest(event.id); + return; + } + if (event.type === "process_exit") { + this.handleProcessExit(event.error); + return; + } + this.handleSessionEvent(event); + } + + private handleProcessExit(error: string): void { + if (!this.activeTurnId) { + return; + } + const turnId = this.activeTurnId; + this.activeTurnId = null; + this.emit({ + type: "turn_failed", + provider: PI_PROVIDER, + turnId, + error, + }); + } + + private handleSessionEvent(event: PiAgentSessionEvent): void { + const turnId = this.currentTurnIdForEvent(); + + switch (event.type) { + case "agent_start": + this.emit({ + type: "thread_started", + provider: PI_PROVIDER, + sessionId: this.state.sessionId, + }); + return; + case "turn_start": + this.emit({ + type: "turn_started", + provider: PI_PROVIDER, + turnId, + }); + return; + case "message_update": + this.handleMessageUpdate(event, turnId); + return; + case "tool_execution_start": { + const toolCall = parseToolArgs(event.toolName, event.args); + this.activeToolCalls.set(event.toolCallId, toolCall); + this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null); + return; + } + case "tool_execution_update": { + const toolCall = this.activeToolCalls.get(event.toolCallId); + if (!toolCall) { + return; + } + + const partialResult = parseToolResult(event.partialResult); + this.emitToolCallEvent(event.toolCallId, toolCall, "running", partialResult, null); + return; + } + case "tool_execution_end": { + const toolCall = + this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); + this.activeToolCalls.delete(event.toolCallId); + + const result = parseToolResult(event.result); + const error = event.isError ? event.result : null; + const status = event.isError ? "failed" : "completed"; + this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); + return; + } + case "compaction_start": + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "compaction", + status: "loading", + trigger: event.reason === "manual" ? "manual" : "auto", + }, + }); + return; + case "compaction_end": + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "compaction", + status: "completed", + }, + }); + return; + case "agent_end": + this.completeTurn(turnId, event.messages ?? []); + return; + default: + return; + } + } + + private handleMessageUpdate( + event: Extract, + turnId: string | undefined, + ): void { + if (event.message.role !== "assistant") { + return; + } + if (event.assistantMessageEvent.type === "text_delta") { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "assistant_message", + text: event.assistantMessageEvent.delta ?? "", + }, + }); + return; + } + if (event.assistantMessageEvent.type === "thinking_delta") { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "reasoning", + text: event.assistantMessageEvent.delta ?? "", + }, + }); + } + } + + private emitToolCallEvent( + toolCallId: string, + toolCall: PiTrackedToolCall, + status: "running" | "completed" | "failed", + result: PiToolResult, + error: unknown, + ): void { + const turnId = this.currentTurnIdForEvent(); + const detail = mapToolDetail(toolCall, result); + const baseItem = { + type: "tool_call" as const, + callId: toolCallId, + name: resolveToolCallName(toolCall, result), + detail, + }; + const item = + status === "failed" ? { ...baseItem, status, error } : { ...baseItem, status, error: null }; + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item, + }); + } + + private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void { + this.activeTurnId = null; + const errorMessage = latestPiErrorMessage(messages); + if (typeof errorMessage === "string" && errorMessage.length > 0) { + this.emit({ + type: "turn_failed", + provider: PI_PROVIDER, + turnId, + error: errorMessage, + }); + return; + } + this.emit({ + type: "turn_completed", + provider: PI_PROVIDER, + turnId, + }); + void this.refreshAfterTurn(turnId); + } + + private async refreshState(): Promise { + this.state = await this.runtimeSession.getState(); + } + + private async refreshAfterTurn(turnId: string | undefined): Promise { + await this.refreshState().catch(() => undefined); + const usage = await this.runtimeSession + .getSessionStats() + .then(toAgentUsage) + .catch(() => undefined); + if (usage) { + this.emit({ + type: "usage_updated", + provider: PI_PROVIDER, + turnId, + usage, + }); + } + } +} + +export class PiRpcAgentClient implements AgentClient { + readonly provider = PI_PROVIDER; + readonly capabilities = PI_CAPABILITIES; + + private readonly logger: Logger; + private readonly runtimeSettings?: ProviderRuntimeSettings; + private readonly runtime: PiRuntime; + + constructor(options: PiRpcAgentClientOptions) { + this.logger = options.logger; + this.runtimeSettings = options.runtimeSettings; + this.runtime = options.runtime ?? createRuntime(options.logger, options.runtimeSettings); + } + + async createSession( + config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { + const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers); + let runtimeSession: PiRuntimeSession; + try { + runtimeSession = await this.runtime.startSession({ + cwd: config.cwd, + model: config.model, + thinkingOptionId: + normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL, + systemPrompt: composeSystemPromptParts( + config.systemPrompt, + config.daemonAppendSystemPrompt, + ), + mcpConfigPath: mcpConfig?.path, + }); + } catch (error) { + mcpConfig?.cleanup(); + throw error; + } + try { + return new PiRpcAgentSession({ + runtimeSession, + config, + initialState: await runtimeSession.getState(), + capabilities: withPiMcpCapability(mcpConfig !== null), + cleanup: mcpConfig?.cleanup, + }); + } catch (error) { + await runtimeSession.close().catch(() => undefined); + mcpConfig?.cleanup(); + throw error; + } + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + _launchContext?: AgentLaunchContext, + ): Promise { + const sessionFile = handle.nativeHandle; + if (!sessionFile) { + throw new Error("Pi resume requires a native session file handle"); + } + + const persistenceMetadata = parsePersistenceMetadata(handle.metadata); + const resumeConfig = buildResumeConfig(persistenceMetadata, overrides); + + const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers); + let runtimeSession: PiRuntimeSession; + try { + runtimeSession = await this.runtime.startSession({ + cwd: resumeConfig.cwd, + session: sessionFile, + model: resumeConfig.model, + thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined, + systemPrompt: composeSystemPromptParts( + resumeConfig.config.systemPrompt, + resumeConfig.config.daemonAppendSystemPrompt, + ), + mcpConfigPath: mcpConfig?.path, + }); + } catch (error) { + mcpConfig?.cleanup(); + throw error; + } + try { + return new PiRpcAgentSession({ + runtimeSession, + config: resumeConfig.config, + initialState: await runtimeSession.getState(), + capabilities: withPiMcpCapability(mcpConfig !== null), + cleanup: mcpConfig?.cleanup, + }); + } catch (error) { + await runtimeSession.close().catch(() => undefined); + mcpConfig?.cleanup(); + throw error; + } + } + + async listModels(options: ListModelsOptions): Promise { + const runtimeSession = await this.runtime.startSession({ cwd: options.cwd }); + try { + return transformPiModels((await runtimeSession.getAvailableModels()).map(mapPiModel)); + } finally { + await runtimeSession.close(); + } + } + + async listModes(_options: ListModesOptions): Promise { + return []; + } + + async listPersistedAgents( + options?: ListPersistedAgentsOptions, + ): Promise { + return await listPiPersistedAgents({ + ...options, + runtimeSettings: this.runtimeSettings, + }); + } + + async isAvailable(): Promise { + const binary = await this.resolvePiBinary(); + if (!binary) { + return false; + } + const runtimeSession = await this.runtime.startSession({ cwd: homedir() }).catch(() => null); + if (!runtimeSession) { + return false; + } + try { + return (await runtimeSession.getAvailableModels()).length > 0; + } catch { + return false; + } finally { + await runtimeSession.close().catch(() => undefined); + } + } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const binary = await this.resolvePiBinary(); + const version = binary ? await resolveBinaryVersion(binary) : "unknown"; + const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); + let modelsValue = "Not checked"; + let configuredProvidersValue = "none"; + let mcpToolsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (binary) { + const runtimeSession = await this.runtime + .startSession({ cwd: homedir() }) + .catch((error) => { + status = formatDiagnosticStatus(false, { + source: "startup", + cause: error, + }); + return null; + }); + if (runtimeSession) { + try { + const models = await runtimeSession.getAvailableModels(); + modelsValue = String(models.length); + const configuredProviders = Array.from( + new Set(models.map((model) => model.provider)), + ).sort(); + configuredProvidersValue = + configuredProviders.length > 0 ? configuredProviders.join(", ") : "none"; + const commands = await runtimeSession.getCommands(); + mcpToolsValue = commands.some(isPiMcpAdapterCommand) + ? "yes (pi-mcp-adapter loaded)" + : "no (install pi-mcp-adapter)"; + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + mcpToolsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } finally { + await runtimeSession.close().catch(() => undefined); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("Pi", [ + { label: "Binary", value: binary ?? "not found" }, + { label: "Version", value: version }, + { label: "Configured providers", value: configuredProvidersValue }, + { + label: "Auth config (~/.pi/agent/auth.json)", + value: existsSync(authConfigPath) ? "found" : "not found", + }, + { label: "Models", value: modelsValue }, + { label: "Paseo MCP tools", value: mcpToolsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + this.logger.debug({ err: error }, "Pi diagnostic lookup failed"); + return { + diagnostic: formatProviderDiagnosticError("Pi", error), + }; + } + } + + private async prepareMcpConfig( + cwd: string, + servers: Record | undefined, + ): Promise { + if (!servers || Object.keys(servers).length === 0) { + return null; + } + if (!(await this.detectMcpAdapter(cwd))) { + return null; + } + return createPiMcpConfigFile(servers); + } + + private async detectMcpAdapter(cwd: string): Promise { + const runtimeSession = await this.runtime.startSession({ cwd }).catch((error) => { + this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed to start"); + return null; + }); + if (!runtimeSession) { + return false; + } + try { + return (await runtimeSession.getCommands()).some(isPiMcpAdapterCommand); + } catch (error) { + this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed"); + return false; + } finally { + await runtimeSession.close().catch(() => undefined); + } + } + + private async resolvePiBinary(): Promise { + const command = this.runtimeSettings?.command; + if (command?.mode === "replace" && command.argv[0]) { + return await findExecutable(command.argv[0]); + } + return await findExecutable(PI_BINARY_COMMAND); + } +} diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts new file mode 100644 index 000000000..bf10ee30d --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts @@ -0,0 +1,188 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import pino from "pino"; +import { describe, expect, test } from "vitest"; + +import { PiCliRuntime } from "./cli-runtime.js"; +import type { PiRuntimeLaunch } from "./runtime.js"; + +type PiChild = ChildProcessWithoutNullStreams & { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; + killedSignals: Array; +}; + +function createPiChild(): PiChild { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + killedSignals: [], + }) as PiChild; + child.kill = ((signal?: NodeJS.Signals | number) => { + child.killedSignals.push(signal); + queueMicrotask(() => child.emit("exit", null, signal ?? null)); + return true; + }) as ChildProcessWithoutNullStreams["kill"]; + return child; +} + +function createRuntime(child: PiChild, launches: PiRuntimeLaunch[] = []): PiCliRuntime { + return new PiCliRuntime({ + logger: pino({ level: "silent" }), + command: ["pi"], + spawnProcess: (launch) => { + launches.push(launch); + return child; + }, + }); +} + +function replyToCommands( + child: PiChild, + handler: (command: Record) => unknown, +): void { + let buffer = ""; + child.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + for (;;) { + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) break; + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + const command = JSON.parse(line) as Record; + const result = handler(command); + child.stdout.write( + `${JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data: result, + })}\n`, + ); + } + }); +} + +describe("PiCliRuntime", () => { + test("starts pi in rpc mode and resolves command responses", async () => { + const child = createPiChild(); + replyToCommands(child, (command) => + command.type === "get_state" + ? { + sessionId: "pi-session-1", + thinkingLevel: "medium", + isStreaming: false, + isCompacting: false, + messageCount: 0, + pendingMessageCount: 0, + } + : {}, + ); + const launches: PiRuntimeLaunch[] = []; + const runtime = createRuntime(child, launches); + + const session = await runtime.startSession({ cwd: "/workspace/project" }); + + await expect(session.getState()).resolves.toMatchObject({ + sessionId: "pi-session-1", + thinkingLevel: "medium", + }); + expect(launches).toEqual([ + expect.objectContaining({ + cwd: "/workspace/project", + argv: ["pi", "--mode", "rpc"], + }), + ]); + }); + + test("passes an MCP config path to Pi", async () => { + const child = createPiChild(); + replyToCommands(child, () => ({})); + const launches: PiRuntimeLaunch[] = []; + const runtime = createRuntime(child, launches); + + await runtime.startSession({ + cwd: "/workspace/project", + mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json", + }); + + expect(launches).toEqual([ + expect.objectContaining({ + cwd: "/workspace/project", + mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json", + argv: ["pi", "--mode", "rpc", "--mcp-config", "/tmp/paseo-pi-mcp/mcp.json"], + }), + ]); + }); + + test("passes an appended system prompt to Pi", async () => { + const child = createPiChild(); + replyToCommands(child, () => ({})); + const launches: PiRuntimeLaunch[] = []; + const runtime = createRuntime(child, launches); + + await runtime.startSession({ + cwd: "/workspace/project", + systemPrompt: " Use the daemon prompt. ", + }); + + expect(launches).toEqual([ + expect.objectContaining({ + cwd: "/workspace/project", + systemPrompt: "Use the daemon prompt.", + argv: ["pi", "--mode", "rpc", "--append-system-prompt", "Use the daemon prompt."], + }), + ]); + }); + + test("delivers events separately from command responses", async () => { + const child = createPiChild(); + replyToCommands(child, () => ({ models: [] })); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const events: unknown[] = []; + session.onEvent((event) => events.push(event)); + + child.stdout.write(`${JSON.stringify({ type: "turn_start" })}\n`); + await session.getAvailableModels(); + + expect(events).toEqual([{ type: "turn_start" }]); + }); + + test("keeps unicode line separators inside one JSONL record", async () => { + const child = createPiChild(); + replyToCommands(child, () => ({})); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const events: unknown[] = []; + session.onEvent((event) => events.push(event)); + + child.stdout.write(`${JSON.stringify({ type: "message", text: "a\u2028b\u2029c" })}\n`); + + expect(events).toEqual([{ type: "message", text: "a\u2028b\u2029c" }]); + }); + + test("rejects pending commands when the Pi process exits", async () => { + const child = createPiChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + const state = session.getState(); + child.stderr.write("boom"); + child.emit("exit", 1, null); + + await expect(state).rejects.toThrow("boom"); + }); + + test("disposes the Pi process", async () => { + const child = createPiChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + await session.close(); + + expect(child.killedSignals).toContain("SIGTERM"); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts new file mode 100644 index 000000000..27ecbd803 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -0,0 +1,282 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { Logger } from "pino"; + +import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { + buildPiLaunch, + type PiRuntime, + type PiRuntimeLaunch, + type PiRuntimeSession, + type PiStartSessionInput, +} from "./runtime.js"; +import type { + PiAgentMessage, + PiModel, + PiRpcCommand, + PiRpcResponse, + PiRpcSlashCommand, + PiRuntimeEvent, + PiSessionState, + PiSessionStats, +} from "./rpc-types.js"; + +const DEFAULT_PI_COMMAND: [string, ...string[]] = [ + process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi", +]; +const DEFAULT_TIMEOUT_MS = 30_000; +const STDERR_BUFFER_LIMIT = 8192; +const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000; +const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +export interface PiCliRuntimeOptions { + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; + command?: [string, ...string[]]; + spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams; +} + +export class PiCliRuntime implements PiRuntime { + private readonly command: [string, ...string[]]; + private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams; + + constructor(private readonly options: PiCliRuntimeOptions) { + this.command = options.command ?? DEFAULT_PI_COMMAND; + this.spawnProcess = + options.spawnProcess ?? + ((launch) => { + const [command, ...args] = launch.argv; + return spawn(command, args, { + cwd: launch.cwd, + env: launch.env ? { ...process.env, ...launch.env } : process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + }); + } + + async startSession(input: PiStartSessionInput): Promise { + const launch = buildPiLaunch({ + command: this.command, + runtimeSettings: this.options.runtimeSettings, + session: input, + }); + return new PiCliRuntimeSession(launch, this.spawnProcess(launch), this.options.logger); + } +} + +class PiCliRuntimeSession implements PiRuntimeSession { + private readonly pending = new Map(); + private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>(); + private stderrBuffer = ""; + private nextRequestId = 1; + private disposed = false; + private stdoutBuffer = ""; + + constructor( + _launch: PiRuntimeLaunch, + private readonly child: ChildProcessWithoutNullStreams, + private readonly logger: Logger, + ) { + child.stdout.on("data", (chunk) => { + this.handleStdoutChunk(chunk.toString()); + }); + child.stderr.on("data", (chunk) => { + this.stderrBuffer += chunk.toString(); + if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) { + this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT); + } + }); + child.on("error", (error) => { + this.failAll(error instanceof Error ? error : new Error(String(error))); + }); + child.on("exit", (code, signal) => { + const error = new Error( + `Pi RPC process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(), + ); + this.emit({ type: "process_exit", error: error.message }); + this.failAll(error); + }); + } + + onEvent(callback: (event: PiRuntimeEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + async prompt( + message: string, + images?: Array<{ type: "image"; data: string; mimeType: string }>, + ): Promise { + await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) }); + } + + async abort(): Promise { + await this.request({ type: "abort" }); + } + + async getState(): Promise { + return (await this.request({ type: "get_state" })) as PiSessionState; + } + + async getMessages(): Promise { + const data = (await this.request({ type: "get_messages" })) as { messages?: PiAgentMessage[] }; + return data.messages ?? []; + } + + async getAvailableModels(): Promise { + const data = (await this.request({ type: "get_available_models" })) as { models?: PiModel[] }; + return data.models ?? []; + } + + async setModel(provider: string, modelId: string): Promise { + return (await this.request({ type: "set_model", provider, modelId })) as PiModel; + } + + async setThinkingLevel(level: string): Promise { + await this.request({ type: "set_thinking_level", level: level as never }); + } + + async getSessionStats(): Promise { + return (await this.request({ type: "get_session_stats" })) as PiSessionStats; + } + + async getCommands(): Promise { + const data = (await this.request({ type: "get_commands" })) as { + commands?: PiRpcSlashCommand[]; + }; + return data.commands ?? []; + } + + cancelExtensionUiRequest(id: string): void { + this.writeJsonLine({ type: "extension_ui_response", id, cancelled: true }); + } + + async close(): Promise { + if (this.disposed) return; + this.disposed = true; + try { + this.child.stdin.end(); + } catch { + // ignore + } + const result = await terminateWithTreeKill(this.child, { + gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS, + forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS, + onForceSignal: () => { + this.logger.warn( + { timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS }, + "Pi RPC process did not exit after SIGTERM; sending SIGKILL", + ); + }, + }); + if (result === "kill-timeout") { + this.logger.warn( + { timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS }, + "Pi RPC process did not report exit after SIGKILL", + ); + } + } + + private request(command: PiRpcCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + if (this.disposed) { + return Promise.reject(new Error("Pi RPC session is closed")); + } + const id = `req_${this.nextRequestId}`; + this.nextRequestId += 1; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject( + new Error(`Pi RPC request timed out for ${command.type}\n${this.stderrBuffer}`.trim()), + ); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + this.writeJsonLine({ ...command, id }); + }); + } + + private writeJsonLine(value: unknown): void { + if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) { + return; + } + this.child.stdin.write(`${JSON.stringify(value)}\n`); + } + + private handleStdoutChunk(chunk: string): void { + this.stdoutBuffer += chunk; + for (;;) { + const newlineIndex = this.stdoutBuffer.indexOf("\n"); + if (newlineIndex === -1) { + break; + } + const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, ""); + this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1); + if (line.trim()) { + this.handleLine(line); + } + } + } + + private handleLine(line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + this.logger.warn({ error, line }, "Ignoring non-JSON Pi RPC stdout line"); + return; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return; + } + const message = parsed as Record; + if (message.type === "response") { + this.handleResponse(message as unknown as PiRpcResponse); + return; + } + this.emit(message as PiRuntimeEvent); + } + + private handleResponse(response: PiRpcResponse): void { + const id = response.id; + if (!id) { + return; + } + const pending = this.pending.get(id); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pending.delete(id); + if (!response.success) { + pending.reject(new Error(response.error ?? `Pi RPC ${response.command} failed`)); + return; + } + pending.resolve(response.data); + } + + private emit(event: PiRuntimeEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } + + private failAll(error: Error): void { + if (this.disposed) { + return; + } + this.disposed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } +} diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts new file mode 100644 index 000000000..1851204b7 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "vitest"; + +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { PiAgentMessage } from "./rpc-types.js"; +import { streamPiHistory } from "./history-mapper.js"; + +async function collectHistory(messages: PiAgentMessage[]): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of streamPiHistory("pi", messages)) { + events.push(event); + } + return events; +} + +describe("Pi history mapper", () => { + test("replays user, assistant, reasoning, and completed tool calls", async () => { + await expect( + collectHistory([ + { + role: "user", + content: [ + { type: "text", text: "read this" }, + { type: "image", data: "base64", mimeType: "image/png" }, + { type: "text", text: "then answer" }, + ], + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "checking file" }, + { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "note.txt" } }, + { type: "text", text: "done" }, + ], + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + }, + ]), + ).resolves.toEqual([ + { + type: "timeline", + provider: "pi", + item: { + type: "user_message", + text: "read this\n\nthen answer", + messageId: "pi-user-0", + }, + }, + { + type: "timeline", + provider: "pi", + item: { type: "reasoning", text: "checking file" }, + }, + { + type: "timeline", + provider: "pi", + item: { + type: "tool_call", + callId: "tool-1", + name: "read", + status: "running", + detail: { + type: "read", + filePath: "note.txt", + content: undefined, + offset: undefined, + limit: undefined, + }, + error: null, + }, + }, + { + type: "timeline", + provider: "pi", + item: { type: "assistant_message", text: "done" }, + }, + { + type: "timeline", + provider: "pi", + item: { + type: "tool_call", + callId: "tool-1", + name: "read", + status: "completed", + detail: { + type: "read", + filePath: "note.txt", + content: "file contents", + offset: undefined, + limit: undefined, + }, + error: null, + }, + }, + ]); + }); + + test("replays bash execution records as completed shell calls", async () => { + await expect( + collectHistory([ + { + role: "bashExecution", + command: "echo hi", + output: "hi\n", + exitCode: 0, + timestamp: 123, + }, + ]), + ).resolves.toEqual([ + { + type: "timeline", + provider: "pi", + item: { + type: "tool_call", + callId: "pi-bash-123", + name: "bash", + status: "completed", + detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 }, + error: null, + }, + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.ts b/packages/server/src/server/agent/providers/pi/history-mapper.ts new file mode 100644 index 000000000..f8440f901 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -0,0 +1,169 @@ +import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js"; +import type { PiAgentMessage, PiImageContent, PiTextContent } from "./rpc-types.js"; +import { + extractTextFromToolResult, + mapToolDetail, + parseToolArgs, + parseToolResult, + type PiTrackedToolCall, +} from "./tool-call-mapper.js"; + +function isTextContentBlock(block: unknown): block is PiTextContent { + return ( + typeof block === "object" && + block !== null && + !Array.isArray(block) && + Reflect.get(block, "type") === "text" && + typeof Reflect.get(block, "text") === "string" + ); +} + +export function getUserMessageText(content: string | (PiTextContent | PiImageContent)[]): string { + if (typeof content === "string") { + return content; + } + + const textParts: string[] = []; + for (const block of content) { + if (isTextContentBlock(block)) { + textParts.push(block.text); + } + } + return textParts.join("\n\n"); +} + +export async function* streamPiHistory( + provider: string, + messages: PiAgentMessage[], +): AsyncGenerator { + const pendingToolCalls = new Map(); + let userIndex = 0; + + for (const message of messages) { + if (message.role === "user") { + const text = getUserMessageText(message.content); + if (text) { + yield { + type: "timeline", + provider, + item: { + type: "user_message", + text, + messageId: `pi-user-${userIndex}`, + }, + }; + } + userIndex += 1; + continue; + } + + if (message.role === "assistant") { + for (const content of message.content) { + if (content.type === "text" && content.text) { + yield { + type: "timeline", + provider, + item: { type: "assistant_message", text: content.text }, + }; + continue; + } + + if (content.type === "thinking" && content.thinking) { + yield { + type: "timeline", + provider, + item: { type: "reasoning", text: content.thinking }, + }; + continue; + } + + if (content.type === "toolCall") { + const tracked = parseToolArgs(content.name, content.arguments); + pendingToolCalls.set(content.id, tracked); + yield { + type: "timeline", + provider, + item: { + type: "tool_call", + callId: content.id, + name: tracked.toolName, + status: "running", + detail: mapToolDetail(tracked, null), + error: null, + }, + }; + } + } + continue; + } + + if (message.role === "toolResult") { + const tracked = + pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null); + pendingToolCalls.delete(message.toolCallId); + const result = parseToolResult({ content: message.content }); + const detail = mapToolDetail(tracked, result); + yield { + type: "timeline", + provider, + item: toToolResultTimelineItem({ + callId: message.toolCallId, + name: tracked.toolName, + isError: Boolean(message.isError), + detail, + errorText: extractTextFromToolResult(result) ?? "Tool call failed", + }), + }; + continue; + } + + if (message.role === "bashExecution") { + const callId = `pi-bash-${message.timestamp}`; + const detail: ToolCallDetail = { + type: "shell", + command: message.command, + output: message.output, + exitCode: message.exitCode ?? null, + }; + yield { + type: "timeline", + provider, + item: { + type: "tool_call", + callId, + name: "bash", + status: message.cancelled ? "canceled" : "completed", + detail, + error: null, + }, + }; + } + } +} + +function toToolResultTimelineItem(input: { + callId: string; + name: string; + isError: boolean; + detail: ToolCallDetail; + errorText: string; +}): AgentTimelineItem { + if (input.isError) { + return { + type: "tool_call", + callId: input.callId, + name: input.name, + status: "failed", + detail: input.detail, + error: input.errorText, + }; + } + return { + type: "tool_call", + callId: input.callId, + name: input.name, + status: "completed", + detail: input.detail, + error: null, + }; +} diff --git a/packages/server/src/server/agent/providers/pi/rpc-types.ts b/packages/server/src/server/agent/providers/pi/rpc-types.ts new file mode 100644 index 000000000..93efad32b --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/rpc-types.ts @@ -0,0 +1,172 @@ +export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +export interface PiImageContent { + type: "image"; + data: string; + mimeType: string; +} + +export interface PiTextContent { + type: "text"; + text: string; +} + +export interface PiThinkingContent { + type: "thinking"; + thinking: string; +} + +export interface PiToolCallContent { + type: "toolCall"; + id: string; + name: string; + arguments: unknown; +} + +export type PiAssistantContent = PiTextContent | PiThinkingContent | PiToolCallContent; + +export type PiAgentMessage = + | { + role: "user"; + content: string | Array; + } + | { + role: "assistant"; + content: PiAssistantContent[]; + errorMessage?: string | null; + stopReason?: string; + } + | { + role: "toolResult"; + toolCallId: string; + toolName: string; + content: unknown; + isError?: boolean; + } + | { + role: "bashExecution"; + command: string; + output?: string; + exitCode?: number | null; + cancelled?: boolean; + timestamp: number; + }; + +export interface PiModel { + provider: string; + id: string; + name?: string; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; + api?: string; + baseUrl?: string; + input?: string[]; + cost?: Record; + compat?: unknown; +} + +export interface PiSessionState { + model?: PiModel | null; + thinkingLevel: PiThinkingLevel; + isStreaming: boolean; + isCompacting: boolean; + sessionFile?: string; + sessionId: string; + sessionName?: string; + messageCount: number; + pendingMessageCount: number; +} + +export interface PiSessionStats { + tokens?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + }; + cost?: number; + contextUsage?: { + tokens?: number | null; + contextWindow?: number | null; + percent?: number | null; + }; +} + +export interface PiRpcSlashCommand { + name: string; + description?: string; + source: "extension" | "prompt" | "skill"; + sourceInfo?: Record; +} + +export type PiRpcCommand = + | { id?: string; type: "prompt"; message: string; images?: PiImageContent[] } + | { id?: string; type: "abort" } + | { id?: string; type: "get_state" } + | { id?: string; type: "get_messages" } + | { id?: string; type: "get_available_models" } + | { id?: string; type: "set_model"; provider: string; modelId: string } + | { id?: string; type: "set_thinking_level"; level: PiThinkingLevel } + | { id?: string; type: "get_session_stats" } + | { id?: string; type: "get_commands" }; + +export interface PiRpcResponse { + id?: string; + type: "response"; + command: string; + success: boolean; + data?: unknown; + error?: string; +} + +export type PiAssistantMessageEvent = + | { type: "text_delta"; delta?: string } + | { type: "thinking_delta"; delta?: string } + | { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" }; + +export type PiAgentSessionEvent = + | { type: "agent_start" } + | { type: "turn_start" } + | { + type: "message_update"; + message: PiAgentMessage; + assistantMessageEvent: PiAssistantMessageEvent; + } + | { + type: "tool_execution_start"; + toolCallId: string; + toolName: string; + args: unknown; + } + | { + type: "tool_execution_update"; + toolCallId: string; + toolName: string; + args?: unknown; + partialResult: unknown; + } + | { + type: "tool_execution_end"; + toolCallId: string; + toolName: string; + result: unknown; + isError?: boolean; + } + | { type: "compaction_start"; reason?: "manual" | "threshold" | "overflow" | string } + | { type: "compaction_end"; reason?: string; errorMessage?: string; aborted?: boolean } + | { type: "agent_end"; messages?: PiAgentMessage[] }; + +export type PiRuntimeEvent = + | PiAgentSessionEvent + | { + type: "extension_ui_request"; + id: string; + method: string; + [key: string]: unknown; + } + | { + type: "process_exit"; + error: string; + }; diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts new file mode 100644 index 000000000..0e7a50b5e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -0,0 +1,106 @@ +import type { + PiAgentMessage, + PiModel, + PiRpcSlashCommand, + PiRuntimeEvent, + PiSessionState, + PiSessionStats, +} from "./rpc-types.js"; +import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; + +export interface PiRuntimeLaunch { + cwd: string; + argv: string[]; + env?: Record; + model?: string; + thinkingOptionId?: string; + session?: string; + systemPrompt?: string; + mcpConfigPath?: string; +} + +export interface PiStartSessionInput { + cwd: string; + model?: string; + thinkingOptionId?: string; + session?: string; + systemPrompt?: string; + mcpConfigPath?: string; +} + +export interface PiRuntimeSession { + onEvent(callback: (event: PiRuntimeEvent) => void): () => void; + prompt( + message: string, + images?: Array<{ type: "image"; data: string; mimeType: string }>, + ): Promise; + abort(): Promise; + getState(): Promise; + getMessages(): Promise; + getAvailableModels(): Promise; + setModel(provider: string, modelId: string): Promise; + setThinkingLevel(level: string): Promise; + getSessionStats(): Promise; + getCommands(): Promise; + cancelExtensionUiRequest(id: string): void; + close(): Promise; +} + +export interface PiRuntime { + startSession(input: PiStartSessionInput): Promise; +} + +export function buildPiLaunch(input: { + command: [string, ...string[]]; + runtimeSettings?: ProviderRuntimeSettings; + session: PiStartSessionInput; +}): PiRuntimeLaunch { + const command = + input.runtimeSettings?.command?.mode === "replace" && input.runtimeSettings.command.argv[0] + ? input.runtimeSettings.command.argv + : input.command; + const argv = [...command]; + + if (!hasModeRpc(argv)) { + argv.push("--mode", "rpc"); + } + if (input.session.model) { + argv.push("--model", input.session.model); + } + if (input.session.thinkingOptionId) { + argv.push("--thinking", input.session.thinkingOptionId); + } + if (input.session.session) { + argv.push("--session", input.session.session); + } + const systemPrompt = input.session.systemPrompt?.trim(); + if (systemPrompt) { + argv.push("--append-system-prompt", systemPrompt); + } + if (input.session.mcpConfigPath) { + argv.push("--mcp-config", input.session.mcpConfigPath); + } + + return { + cwd: input.session.cwd, + argv, + env: input.runtimeSettings?.env, + model: input.session.model, + thinkingOptionId: input.session.thinkingOptionId, + session: input.session.session, + systemPrompt, + mcpConfigPath: input.session.mcpConfigPath, + }; +} + +function hasModeRpc(argv: string[]): boolean { + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === "--mode" && argv[i + 1] === "rpc") { + return true; + } + if (argv[i] === "--mode=rpc") { + return true; + } + } + return false; +} diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts new file mode 100644 index 000000000..9665fe8be --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts @@ -0,0 +1,297 @@ +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { listPiPersistedAgents } from "./session-descriptor.js"; + +const directorySymlinkType = process.platform === "win32" ? "junction" : "dir"; + +function tempRoot(): string { + return mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-")); +} + +function writeJsonl(filePath: string, entries: unknown[]): string { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); + return filePath; +} + +function piSession(overrides: { + id: string; + cwd: string; + timestamp?: string; + entries?: unknown[]; +}): unknown[] { + return [ + { + type: "session", + version: 3, + id: overrides.id, + timestamp: overrides.timestamp ?? "2026-01-01T00:00:00.000Z", + cwd: overrides.cwd, + }, + ...(overrides.entries ?? []), + ]; +} + +function message(input: { + role: "user" | "assistant"; + content: unknown; + timestamp: string; +}): unknown { + return { + type: "message", + id: `entry-${input.timestamp}`, + parentId: null, + timestamp: input.timestamp, + message: { + role: input.role, + content: input.content, + }, + }; +} + +function sessionInfo(name: string, timestamp: string): unknown { + return { + type: "session_info", + id: `info-${timestamp}`, + parentId: null, + timestamp, + name, + }; +} + +describe("listPiPersistedAgents", () => { + test("lists Pi sessions from the configured agent dir", async () => { + const root = tempRoot(); + const cwd = path.join(root, "project"); + const sessionsDir = path.join(root, "agent", "sessions", "--project--"); + const sessionFile = writeJsonl( + path.join(sessionsDir, "20260101_session-a.jsonl"), + piSession({ + id: "session-a", + cwd, + entries: [ + message({ + role: "user", + content: "first prompt", + timestamp: "2026-01-01T00:00:01.000Z", + }), + message({ + role: "assistant", + content: [{ type: "text", text: "answer" }], + timestamp: "2026-01-01T00:00:02.000Z", + }), + message({ + role: "user", + content: [{ type: "text", text: "last prompt" }], + timestamp: "2026-01-01T00:00:03.000Z", + }), + sessionInfo("Named Pi Session", "2026-01-01T00:00:10.000Z"), + ], + }), + ); + + const descriptors = await listPiPersistedAgents({ + cwd, + env: { PI_CODING_AGENT_DIR: path.join(root, "agent") }, + homeDir: root, + }); + + expect(descriptors).toEqual([ + { + provider: "pi", + sessionId: "session-a", + cwd, + title: "Named Pi Session", + lastActivityAt: new Date("2026-01-01T00:00:03.000Z"), + persistence: { + provider: "pi", + sessionId: "session-a", + nativeHandle: sessionFile, + metadata: { + provider: "pi", + cwd, + }, + }, + timeline: [ + { type: "user_message", text: "first prompt" }, + { type: "user_message", text: "last prompt" }, + ], + }, + ]); + }); + + test("uses the runtime session-dir override before Pi settings", async () => { + const root = tempRoot(); + const cwd = path.join(root, "project"); + const agentDir = path.join(root, "agent"); + const runtimeSessionsDir = path.join(root, "runtime-sessions"); + const settingsSessionsDir = path.join(root, "settings-sessions"); + mkdirSync(agentDir, { recursive: true }); + writeFileSync( + path.join(agentDir, "settings.json"), + JSON.stringify({ sessionDir: settingsSessionsDir }), + "utf8", + ); + writeJsonl( + path.join(settingsSessionsDir, "ignored.jsonl"), + piSession({ id: "settings-session", cwd }), + ); + writeJsonl( + path.join(runtimeSessionsDir, "selected.jsonl"), + piSession({ + id: "runtime-session", + cwd, + entries: [ + message({ + role: "user", + content: "from runtime dir", + timestamp: "2026-01-01T00:00:01.000Z", + }), + ], + }), + ); + + const descriptors = await listPiPersistedAgents({ + cwd, + runtimeSettings: { + env: { + PI_CODING_AGENT_DIR: agentDir, + PI_CODING_AGENT_SESSION_DIR: runtimeSessionsDir, + }, + }, + env: {}, + homeDir: root, + }); + + expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["runtime-session"]); + }); + + test("resolves project settings sessionDir relative to the requested cwd", async () => { + const root = tempRoot(); + const cwd = path.join(root, "project"); + const projectSettingsDir = path.join(cwd, ".pi"); + const sessionsDir = path.join(cwd, "relative-sessions"); + mkdirSync(projectSettingsDir, { recursive: true }); + writeFileSync( + path.join(projectSettingsDir, "settings.json"), + JSON.stringify({ sessionDir: "relative-sessions" }), + "utf8", + ); + writeJsonl( + path.join(sessionsDir, "relative.jsonl"), + piSession({ + id: "relative-session", + cwd, + entries: [ + message({ + role: "user", + content: "relative settings", + timestamp: "2026-01-01T00:00:01.000Z", + }), + ], + }), + ); + + const descriptors = await listPiPersistedAgents({ + cwd, + env: { PI_CODING_AGENT_DIR: path.join(root, "agent") }, + homeDir: root, + }); + + expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["relative-session"]); + }); + + test("matches sessions stored with the real cwd when the requested cwd is symlinked", async () => { + const root = tempRoot(); + const realCwd = path.join(root, "real-project"); + const linkedCwd = path.join(root, "linked-project"); + mkdirSync(realCwd, { recursive: true }); + symlinkSync(realCwd, linkedCwd, directorySymlinkType); + const persistedCwd = realpathSync(linkedCwd); + const sessionsDir = path.join(root, "agent", "sessions"); + writeJsonl( + path.join(sessionsDir, "symlinked.jsonl"), + piSession({ + id: "symlinked-session", + cwd: persistedCwd, + entries: [ + message({ + role: "user", + content: "through symlink", + timestamp: "2026-01-01T00:00:01.000Z", + }), + ], + }), + ); + + const descriptors = await listPiPersistedAgents({ + cwd: linkedCwd, + env: { PI_CODING_AGENT_DIR: path.join(root, "agent") }, + homeDir: root, + }); + + expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["symlinked-session"]); + }); + + test("filters by cwd, sorts by activity, and ignores invalid session files", async () => { + const root = tempRoot(); + const cwd = path.join(root, "project"); + const otherCwd = path.join(root, "other"); + const sessionsDir = path.join(root, "agent", "sessions"); + writeJsonl( + path.join(sessionsDir, "older.jsonl"), + piSession({ + id: "older", + cwd, + entries: [ + message({ + role: "user", + content: "older", + timestamp: "2026-01-01T00:00:01.000Z", + }), + ], + }), + ); + writeJsonl( + path.join(sessionsDir, "newer.jsonl"), + piSession({ + id: "newer", + cwd, + entries: [ + message({ + role: "user", + content: "newer", + timestamp: "2026-01-01T00:00:02.000Z", + }), + ], + }), + ); + writeJsonl( + path.join(sessionsDir, "outside.jsonl"), + piSession({ + id: "outside", + cwd: otherCwd, + entries: [ + message({ + role: "user", + content: "outside", + timestamp: "2026-01-01T00:00:03.000Z", + }), + ], + }), + ); + writeFileSync(path.join(sessionsDir, "broken.jsonl"), '{"type":"nope"}\n', "utf8"); + + const descriptors = await listPiPersistedAgents({ + cwd, + limit: 1, + env: { PI_CODING_AGENT_DIR: path.join(root, "agent") }, + homeDir: root, + }); + + expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["newer"]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.ts new file mode 100644 index 000000000..b1e6d9918 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.ts @@ -0,0 +1,372 @@ +import { open, readdir, readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +import type { + AgentPersistenceHandle, + AgentTimelineItem, + ListPersistedAgentsOptions, + PersistedAgentDescriptor, +} from "../../agent-sdk-types.js"; +import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { createRealpathAwarePathMatcher } from "../../../../utils/path.js"; + +const PI_PROVIDER = "pi"; +const PI_CONFIG_DIR_NAME = ".pi"; +const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"; +const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR"; +const HEAD_BYTES = 64 * 1024; +const TAIL_BYTES = 256 * 1024; +const FULL_SCAN_LINE_LIMIT = 2_000; + +interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions { + runtimeSettings?: ProviderRuntimeSettings; + env?: NodeJS.ProcessEnv; + homeDir?: string; +} + +interface PiSessionHeader { + sessionId: string; + cwd: string; + createdAt: Date | null; +} + +interface PiSessionTail { + title: string | null; + lastActivityAt: Date | null; + lastUserMessage: string | null; +} + +export async function listPiPersistedAgents( + options: PiSessionDescriptorOptions = {}, +): Promise { + const sessionsDir = await resolvePiSessionsDir(options); + const files = await walkJsonlFiles(sessionsDir); + const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null; + const limit = options.limit ?? 20; + const descriptors: PersistedAgentDescriptor[] = []; + + for (const file of files) { + const descriptor = await readPiSessionDescriptor(file); + if (!descriptor) continue; + if (matchesCwd && !matchesCwd(descriptor.cwd)) continue; + descriptors.push(descriptor); + } + + return descriptors + .sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()) + .slice(0, limit); +} + +async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? homedir(); + const baseDir = options.cwd ?? process.cwd(); + const agentDir = resolvePiAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir }); + + const envSessionDir = + options.runtimeSettings?.env?.[PI_SESSION_DIR_ENV] ?? env[PI_SESSION_DIR_ENV]; + if (envSessionDir?.trim()) { + return resolveConfigPath(envSessionDir, { baseDir, homeDir }); + } + + const settingsSessionDir = await readConfiguredSessionDir({ + agentDir, + cwd: options.cwd, + }); + if (settingsSessionDir?.trim()) { + return resolveConfigPath(settingsSessionDir, { baseDir, homeDir }); + } + + return path.join(agentDir, "sessions"); +} + +function resolvePiAgentDir(input: { + runtimeSettings?: ProviderRuntimeSettings; + env: NodeJS.ProcessEnv; + homeDir: string; +}): string { + const configured = input.runtimeSettings?.env?.[PI_AGENT_DIR_ENV] ?? input.env[PI_AGENT_DIR_ENV]; + if (configured?.trim()) { + return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir }); + } + return path.join(input.homeDir, PI_CONFIG_DIR_NAME, "agent"); +} + +async function readConfiguredSessionDir(input: { + agentDir: string; + cwd: string | undefined; +}): Promise { + const values = await Promise.all([ + readSessionDirFromSettings(path.join(input.agentDir, "settings.json")), + input.cwd + ? readSessionDirFromSettings(path.join(input.cwd, PI_CONFIG_DIR_NAME, "settings.json")) + : null, + ]); + return values[1] ?? values[0] ?? null; +} + +async function readSessionDirFromSettings(settingsPath: string): Promise { + try { + const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const sessionDir = Reflect.get(parsed, "sessionDir"); + return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null; + } catch { + return null; + } +} + +function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string { + if (value === "~") { + return options.homeDir; + } + if (value.startsWith("~/")) { + return path.join(options.homeDir, value.slice(2)); + } + return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value); +} + +async function walkJsonlFiles(root: string): Promise { + let entries: import("node:fs").Dirent[]; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return []; + } + + const files = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + return await walkJsonlFiles(entryPath); + } + return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : []; + }), + ); + return files.flat(); +} + +async function readPiSessionDescriptor(filePath: string): Promise { + const firstLine = await readFirstLine(filePath); + if (!firstLine) return null; + const header = parseSessionHeader(firstLine); + if (!header) return null; + + const tail = await readTail(filePath).catch(() => ""); + const tailInfo = parseSessionTail(tail); + const headInfo = await scanSessionHead(filePath); + const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage; + const lastActivityAt = + tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0); + const timeline = buildPreviewTimeline({ + firstUserMessage: headInfo.firstUserMessage, + lastUserMessage: tailInfo.lastUserMessage, + }); + + const persistence: AgentPersistenceHandle = { + provider: PI_PROVIDER, + sessionId: header.sessionId, + nativeHandle: filePath, + metadata: { + provider: PI_PROVIDER, + cwd: header.cwd, + }, + }; + + return { + provider: PI_PROVIDER, + sessionId: header.sessionId, + cwd: header.cwd, + title, + lastActivityAt, + persistence, + timeline, + }; +} + +async function readFirstLine(filePath: string): Promise { + const handle = await open(filePath, "r").catch(() => null); + if (!handle) return null; + try { + const buffer = Buffer.alloc(HEAD_BYTES); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead <= 0) return null; + const chunk = buffer.subarray(0, bytesRead).toString("utf8"); + const newlineIndex = chunk.indexOf("\n"); + return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim(); + } finally { + await handle.close().catch(() => undefined); + } +} + +async function readTail(filePath: string): Promise { + const fileStats = await stat(filePath); + const start = Math.max(0, fileStats.size - TAIL_BYTES); + const length = fileStats.size - start; + const handle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); + return buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + await handle.close().catch(() => undefined); + } +} + +async function readFileMtime(filePath: string): Promise { + try { + return (await stat(filePath)).mtime; + } catch { + return null; + } +} + +function parseSessionHeader(firstLine: string): PiSessionHeader | null { + const entry = parseJsonRecord(firstLine); + if (!entry || entry.type !== "session") return null; + const sessionId = typeof entry.id === "string" ? entry.id : null; + const cwd = typeof entry.cwd === "string" ? entry.cwd : null; + if (!sessionId || !cwd) return null; + const createdAt = parseDate(entry.timestamp); + return { sessionId, cwd, createdAt }; +} + +function parseSessionTail(tail: string): PiSessionTail { + const lines = tail.split(/\r?\n/); + let title: string | null = null; + let lastActivityAt: Date | null = null; + let fallbackTimestamp: Date | null = null; + let lastUserMessage: string | null = null; + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const entry = parseJsonRecord(lines[index].trim()); + if (!entry) continue; + + if (!title && entry.type === "session_info") { + title = readNonEmptyString(entry.name); + } + + const entryTimestamp = parseDate(entry.timestamp); + if (!fallbackTimestamp && entryTimestamp) { + fallbackTimestamp = entryTimestamp; + } + + if (entry.type !== "message") { + continue; + } + + if (!lastActivityAt && entryTimestamp) { + lastActivityAt = entryTimestamp; + } + + if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") { + lastUserMessage = extractMessageText(entry.message.content); + } + + if (title && lastActivityAt && lastUserMessage) { + break; + } + } + + return { title, lastActivityAt: lastActivityAt ?? fallbackTimestamp, lastUserMessage }; +} + +async function scanSessionHead(filePath: string): Promise<{ + title: string | null; + firstUserMessage: string | null; +}> { + let content: string; + try { + content = await readFile(filePath, "utf8"); + } catch { + return { title: null, firstUserMessage: null }; + } + + let title: string | null = null; + let firstUserMessage: string | null = null; + let lineCount = 0; + + for (const rawLine of content.split(/\r?\n/)) { + lineCount += 1; + const entry = parseJsonRecord(rawLine.trim()); + if (!entry) continue; + + if (entry.type === "session_info") { + title = readNonEmptyString(entry.name) ?? title; + } + + if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) { + if (entry.message.role === "user") { + firstUserMessage = extractMessageText(entry.message.content); + } + } + + if (title && firstUserMessage) { + break; + } + if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) { + break; + } + } + + return { title, firstUserMessage }; +} + +function buildPreviewTimeline(input: { + firstUserMessage: string | null; + lastUserMessage: string | null; +}): AgentTimelineItem[] { + const items: AgentTimelineItem[] = []; + if (input.firstUserMessage) { + items.push({ type: "user_message", text: input.firstUserMessage }); + } + if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) { + items.push({ type: "user_message", text: input.lastUserMessage }); + } + return items; +} + +function parseJsonRecord(line: string): Record | null { + if (!line) return null; + try { + const parsed = JSON.parse(line) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function parseDate(value: unknown): Date | null { + if (typeof value !== "string" && typeof value !== "number") { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function extractMessageText(content: unknown): string | null { + if (typeof content === "string") { + return content.trim() || null; + } + if (!Array.isArray(content)) { + return null; + } + const text = content + .flatMap((part) => + isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [], + ) + .join("\n\n") + .trim(); + return text || null; +} diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts new file mode 100644 index 000000000..9542bc602 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -0,0 +1,149 @@ +import type { + PiRuntime, + PiRuntimeLaunch, + PiRuntimeSession, + PiStartSessionInput, +} from "../runtime.js"; +import type { + PiAgentMessage, + PiModel, + PiRpcSlashCommand, + PiRuntimeEvent, + PiSessionState, + PiSessionStats, +} from "../rpc-types.js"; +import { buildPiLaunch } from "../runtime.js"; + +export class FakePi implements PiRuntime { + readonly recordedLaunches: PiRuntimeLaunch[] = []; + private readonly sessions: FakePiSession[] = []; + private readonly command: [string, ...string[]]; + private readonly queuedCommands: PiRpcSlashCommand[][] = []; + + constructor(command: [string, ...string[]] = ["pi"]) { + this.command = command; + } + + async startSession(input: PiStartSessionInput): Promise { + const launch = buildPiLaunch({ + command: this.command, + session: input, + }); + this.recordedLaunches.push(launch); + const session = new FakePiSession(launch); + session.commands = this.queuedCommands.shift() ?? []; + this.sessions.push(session); + return session; + } + + queueCommands(commands: PiRpcSlashCommand[]): void { + this.queuedCommands.push(commands); + } + + latestSession(): FakePiSession { + const session = this.sessions.at(-1); + if (!session) { + throw new Error("FakePi has no sessions"); + } + return session; + } +} + +export class FakePiSession implements PiRuntimeSession { + readonly prompts: Array<{ message: string; imageCount: number }> = []; + readonly setModelRequests: Array<{ provider: string; modelId: string }> = []; + readonly setThinkingLevelRequests: string[] = []; + abortRequested = false; + readonly canceledExtensionUiRequests: string[] = []; + setModelResult: PiModel | null = null; + models: PiModel[] = []; + messages: PiAgentMessage[] = []; + stats: PiSessionStats = { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + }; + commands: PiRpcSlashCommand[] = []; + state: PiSessionState; + + private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>(); + + constructor(launch: PiRuntimeLaunch) { + this.state = { + model: null, + thinkingLevel: "medium", + isStreaming: false, + isCompacting: false, + sessionFile: launch.session ?? "/tmp/pi-session.jsonl", + sessionId: "pi-session-1", + messageCount: 0, + pendingMessageCount: 0, + }; + } + + onEvent(callback: (event: PiRuntimeEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + async prompt( + message: string, + images?: Array<{ type: "image"; data: string; mimeType: string }>, + ): Promise { + this.prompts.push({ message, imageCount: images?.length ?? 0 }); + } + + async abort(): Promise { + this.abortRequested = true; + } + + async getState(): Promise { + return this.state; + } + + async getMessages(): Promise { + return this.messages; + } + + async getAvailableModels(): Promise { + return this.models; + } + + async setModel(provider: string, modelId: string): Promise { + this.setModelRequests.push({ provider, modelId }); + if (!this.setModelResult) { + throw new Error("FakePi setModel requires setModelResult to be scripted"); + } + return this.setModelResult; + } + + async setThinkingLevel(level: string): Promise { + this.setThinkingLevelRequests.push(level); + } + + async getSessionStats(): Promise { + return this.stats; + } + + async getCommands(): Promise { + return this.commands; + } + + cancelExtensionUiRequest(id: string): void { + this.canceledExtensionUiRequests.push(id); + } + + async close(): Promise {} + + emit(event: PiRuntimeEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } + + finishTurn(message: PiAgentMessage = { role: "assistant", content: [] }): void { + this.messages = [...this.messages, message]; + this.emit({ type: "agent_end", messages: this.messages }); + } +} diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts new file mode 100644 index 000000000..45ccbcb8a --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; + +import { + mapToolDetail, + parseToolArgs, + parseToolResult, + resolveToolCallName, +} from "./tool-call-mapper.js"; + +describe("Pi tool call mapper", () => { + test("maps bash args and result to shell detail", () => { + const toolCall = parseToolArgs("bash", { command: "echo hello" }); + const result = parseToolResult({ output: "hello\n", exitCode: 0 }); + + expect(mapToolDetail(toolCall, result)).toEqual({ + type: "shell", + command: "echo hello", + output: "hello\n", + exitCode: 0, + }); + }); + + test("maps legacy edit args to edit detail with diff", () => { + const toolCall = parseToolArgs("edit", { + path: "app.ts", + old_string: "before", + new_string: "after", + }); + const result = parseToolResult({ details: { diff: "-before\n+after" } }); + + expect(mapToolDetail(toolCall, result)).toEqual({ + type: "edit", + filePath: "app.ts", + oldString: "before", + newString: "after", + unifiedDiff: "-before\n+after", + }); + }); + + test("preserves unknown tool input and parsed output", () => { + const toolCall = parseToolArgs("custom_tool", { value: 42 }); + const result = parseToolResult({ text: "custom result" }); + + expect(mapToolDetail(toolCall, result)).toEqual({ + type: "unknown", + input: { value: 42 }, + output: { text: "custom result" }, + }); + }); + + test("normalizes Pi MCP proxy calls from requested tool args while running", () => { + const toolCall = parseToolArgs("mcp", { + tool: "paseo_list_models", + args: '{"provider":"pi"}', + }); + + expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models"); + }); + + test("normalizes Pi MCP proxy calls from result details when completed", () => { + const toolCall = parseToolArgs("mcp", { + tool: "paseo_list_models", + args: '{"provider":"pi"}', + }); + const result = parseToolResult({ + content: [{ type: "text", text: "(empty result)" }], + details: { + mode: "call", + server: "paseo", + tool: "list_models", + }, + }); + + expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models"); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts new file mode 100644 index 000000000..abdea5624 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts @@ -0,0 +1,469 @@ +import { z } from "zod"; + +import type { ToolCallDetail } from "../../agent-sdk-types.js"; + +interface BashToolInput { + command: string; + timeout?: number; +} + +interface ReadToolInput { + path: string; + offset?: number; + limit?: number; +} + +interface EditToolInput { + path: string; + edits: Array<{ + oldText: string; + newText: string; + }>; +} + +interface WriteToolInput { + path: string; + content: string; +} + +interface FindToolInput { + pattern: string; + path?: string; + limit?: number; +} + +interface GrepToolInput { + pattern: string; + path?: string; + glob?: string; + ignoreCase?: boolean; + literal?: boolean; + context?: number; + limit?: number; +} + +interface LsToolInput { + path?: string; + limit?: number; +} + +interface PiToolResultObject { + output?: string; + stdout?: string; + text?: string; + content?: PiToolResultContent[]; + exitCode?: number; + code?: number; + details?: PiToolResultDetails; +} + +interface PiToolResultDetails { + diff?: string; + mode?: string; + server?: string; + tool?: string; + mcpResult?: unknown; +} + +interface PiToolResultTextContent { + type: "text"; + text: string; +} + +interface PiToolResultUnknownContent { + type: string; +} + +type PiToolResultContent = PiToolResultTextContent | PiToolResultUnknownContent; +export type PiToolResult = string | PiToolResultObject | null; + +interface PiBashToolCall { + kind: "bash"; + toolName: "bash"; + args: BashToolInput; +} + +interface PiReadToolCall { + kind: "read"; + toolName: "read"; + args: ReadToolInput; +} + +interface PiEditToolCall { + kind: "edit"; + toolName: "edit"; + args: EditToolInput; +} + +interface PiWriteToolCall { + kind: "write"; + toolName: "write"; + args: WriteToolInput; +} + +interface PiFindToolCall { + kind: "find"; + toolName: "find"; + args: FindToolInput; +} + +interface PiGrepToolCall { + kind: "grep"; + toolName: "grep"; + args: GrepToolInput; +} + +interface PiLsToolCall { + kind: "ls"; + toolName: "ls"; + args: LsToolInput; +} + +interface PiUnknownToolCall { + kind: "unknown"; + toolName: string; + args: unknown; +} + +export type PiTrackedToolCall = + | PiBashToolCall + | PiReadToolCall + | PiEditToolCall + | PiWriteToolCall + | PiFindToolCall + | PiGrepToolCall + | PiLsToolCall + | PiUnknownToolCall; + +interface ToolCallOutputSummary { + output?: string; + exitCode?: number | null; +} + +const PiToolResultTextContentSchema = z.object({ + type: z.literal("text"), + text: z.string(), +}); + +const PiToolResultUnknownContentSchema = z + .object({ + type: z.string(), + }) + .passthrough(); + +const PiToolResultContentSchema = z.union([ + PiToolResultTextContentSchema, + PiToolResultUnknownContentSchema, +]); + +const PiToolResultDetailsSchema = z + .object({ + diff: z.string().optional(), + }) + .passthrough(); + +const PiToolResultObjectSchema = z + .object({ + output: z.string().optional(), + stdout: z.string().optional(), + text: z.string().optional(), + content: z.array(PiToolResultContentSchema).optional(), + exitCode: z.number().optional(), + code: z.number().optional(), + details: PiToolResultDetailsSchema.optional(), + }) + .passthrough(); + +const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +const BashToolInputSchema: z.ZodType = z.object({ + command: z.string(), + timeout: z.number().optional(), +}); + +const ReadToolInputSchema: z.ZodType = z.object({ + path: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), +}); + +const EditToolInputSchema: z.ZodType = z.object({ + path: z.string(), + edits: z.array( + z.object({ + oldText: z.string(), + newText: z.string(), + }), + ), +}); + +const LegacyEditToolInputSchema = z.object({ + path: z.string(), + old_string: z.string().optional(), + oldString: z.string().optional(), + new_string: z.string().optional(), + newString: z.string().optional(), +}); + +const WriteToolInputSchema: z.ZodType = z.object({ + path: z.string(), + content: z.string(), +}); + +const FindToolInputSchema: z.ZodType = z.object({ + pattern: z.string(), + path: z.string().optional(), + limit: z.number().optional(), +}); + +const GrepToolInputSchema: z.ZodType = z.object({ + pattern: z.string(), + path: z.string().optional(), + glob: z.string().optional(), + ignoreCase: z.boolean().optional(), + literal: z.boolean().optional(), + context: z.number().optional(), + limit: z.number().optional(), +}); + +const LsToolInputSchema: z.ZodType = z.object({ + path: z.string().optional(), + limit: z.number().optional(), +}); + +export function parseToolResult(rawResult: unknown): PiToolResult { + const parsed = PiToolResultSchema.safeParse(rawResult); + if (parsed.success) { + return parsed.data; + } + return null; +} + +export function extractTextFromToolResult(result: PiToolResult): string | undefined { + if (typeof result === "string") { + return result; + } + if (!result) { + return undefined; + } + + const directText = result.output ?? result.stdout ?? result.text; + if (directText) { + return directText; + } + if (!result.content) { + return undefined; + } + + const textParts: string[] = []; + for (const block of result.content) { + if (block.type === "text" && "text" in block) { + textParts.push(block.text); + } + } + + return textParts.length > 0 ? textParts.join("\n") : undefined; +} + +export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall { + if (toolName === "edit") { + return parseEditToolArgs(rawArgs); + } + const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind]; + if (schema) { + const parsed = schema.safeParse(rawArgs); + if (parsed.success) { + return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall; + } + } + return { kind: "unknown", toolName, args: rawArgs ?? null }; +} + +function stripMcpProxyPrefix(toolName: string, serverName: string): string { + const prefix = `${serverName}_`; + return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName; +} + +export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string { + if (toolCall.toolName !== "mcp") { + return toolCall.toolName; + } + + if (result && typeof result !== "string") { + const serverName = readNonEmptyString(result.details?.server); + const toolName = readNonEmptyString(result.details?.tool); + if (serverName && toolName) { + return `${serverName}.${toolName}`; + } + } + + if (isRecord(toolCall.args)) { + const requestedTool = readNonEmptyString(toolCall.args.tool); + const requestedServer = readNonEmptyString(toolCall.args.server); + if (requestedTool && requestedServer) { + return `${requestedServer}.${stripMcpProxyPrefix(requestedTool, requestedServer)}`; + } + if (requestedTool) { + const [serverName, ...toolParts] = requestedTool.split("_"); + if (serverName && toolParts.length > 0) { + return `${serverName}.${toolParts.join("_")}`; + } + } + } + + return toolCall.toolName; +} + +export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail { + const parsedResult = result ?? null; + + switch (toolCall.kind) { + case "bash": { + const summary = resolveToolCallOutput(parsedResult); + return { + type: "shell", + command: toolCall.args.command, + output: summary.output, + exitCode: summary.exitCode, + }; + } + case "read": + return { + type: "read", + filePath: toolCall.args.path, + content: extractTextFromToolResult(parsedResult), + offset: toolCall.args.offset, + limit: toolCall.args.limit, + }; + case "edit": { + const firstEdit = toolCall.args.edits[0]; + const unifiedDiff = + parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined; + + return { + type: "edit", + filePath: toolCall.args.path, + oldString: firstEdit?.oldText, + newString: firstEdit?.newText, + unifiedDiff, + }; + } + case "write": + return { + type: "write", + filePath: toolCall.args.path, + content: toolCall.args.content, + }; + case "find": + return mapFindToolDetail(toolCall.args, parsedResult); + case "grep": + return mapGrepToolDetail(toolCall.args, parsedResult); + case "ls": + return mapLsToolDetail(toolCall.args, parsedResult); + default: + return { + type: "unknown", + input: toolCall.args, + output: parsedResult, + }; + } +} + +function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary { + if (typeof result === "string") { + return { output: result }; + } + if (!result) { + return {}; + } + + const summary: ToolCallOutputSummary = { + output: extractTextFromToolResult(result), + }; + if (typeof result.exitCode === "number") { + summary.exitCode = result.exitCode; + return summary; + } + if (typeof result.code === "number") { + summary.exitCode = result.code; + return summary; + } + summary.exitCode = null; + return summary; +} + +function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null { + const parsed = LegacyEditToolInputSchema.safeParse(rawArgs); + if (!parsed.success) { + return null; + } + + const oldText = parsed.data.old_string ?? parsed.data.oldString; + const newText = parsed.data.new_string ?? parsed.data.newString; + if (!oldText || newText === undefined) { + return null; + } + + return { + path: parsed.data.path, + edits: [{ oldText, newText }], + }; +} + +function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall { + const parsed = EditToolInputSchema.safeParse(rawArgs); + if (parsed.success) { + return { kind: "edit", toolName: "edit", args: parsed.data }; + } + const legacyArgs = normalizeLegacyEditArgs(rawArgs); + if (legacyArgs) { + return { kind: "edit", toolName: "edit", args: legacyArgs }; + } + return { kind: "unknown", toolName: "edit", args: rawArgs ?? null }; +} + +type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls"; +const SIMPLE_TOOL_SCHEMAS: { + [K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } }; +} = { + bash: BashToolInputSchema, + read: ReadToolInputSchema, + write: WriteToolInputSchema, + find: FindToolInputSchema, + grep: GrepToolInputSchema, + ls: LsToolInputSchema, +}; + +function mapFindToolDetail(args: FindToolInput, result: PiToolResult): ToolCallDetail { + return { + type: "search", + query: args.pattern, + toolName: "search", + content: typeof result === "string" ? result : undefined, + }; +} + +function mapGrepToolDetail(args: GrepToolInput, result: PiToolResult): ToolCallDetail { + return { + type: "search", + query: args.pattern, + toolName: "grep", + content: typeof result === "string" ? result : undefined, + }; +} + +function mapLsToolDetail(args: LsToolInput, result: PiToolResult): ToolCallDetail { + return { + type: "search", + query: args.path ?? "ls", + content: typeof result === "string" ? result : undefined, + }; +} diff --git a/packages/server/src/server/agent/system-prompt.ts b/packages/server/src/server/agent/system-prompt.ts new file mode 100644 index 000000000..2aafc3759 --- /dev/null +++ b/packages/server/src/server/agent/system-prompt.ts @@ -0,0 +1,10 @@ +export function composeSystemPromptParts( + ...parts: Array +): string | undefined { + const prompt = parts + .map((part) => part?.trim()) + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n\n"); + + return prompt.length > 0 ? prompt : undefined; +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4f509e40a..95e0d4d00 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -230,6 +230,7 @@ export interface PaseoDaemonConfig { mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; autoArchiveAfterMerge?: boolean; + appendSystemPrompt?: string; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -290,6 +291,7 @@ export async function createPaseoDaemon( ]), ), autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, + appendSystemPrompt: config.appendSystemPrompt ?? "", }, logger, ); @@ -516,6 +518,7 @@ export async function createPaseoDaemon( }, providerDefinitions: providerRegistry, registry: agentStorage, + appendSystemPrompt: config.appendSystemPrompt, logger, }); @@ -862,6 +865,9 @@ export async function createPaseoDaemon( daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => { agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null); }); + daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => { + agentManager.setAppendSystemPrompt(typeof value === "string" ? value : ""); + }); const relayEnabled = config.relayEnabled ?? true; const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443"; const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint; diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index f5b2fe09a..3a6f2ed28 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -256,6 +256,10 @@ function resolveAuthConfig( : undefined; } +function resolveAppendSystemPrompt(persisted: ReturnType): string { + return persisted.daemon?.appendSystemPrompt ?? ""; +} + function resolveStaticLoadConfigSettings( env: NodeJS.ProcessEnv, cli: CliConfigOverrides | undefined, @@ -266,6 +270,7 @@ function resolveStaticLoadConfigSettings( mcpInjectIntoAgents: cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false, autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false, + appendSystemPrompt: resolveAppendSystemPrompt(persisted), hostnames: mergeHostnames([ persisted.daemon?.hostnames, parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), @@ -286,8 +291,14 @@ export function loadConfig( const persisted = loadPersistedConfig(paseoHome); const listen = resolveListenAddress(env, options?.cli, persisted); - const { mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, hostnames, appBaseUrl } = - resolveStaticLoadConfigSettings(env, options?.cli, persisted); + const { + mcpEnabled, + mcpInjectIntoAgents, + autoArchiveAfterMerge, + appendSystemPrompt, + hostnames, + appBaseUrl, + } = resolveStaticLoadConfigSettings(env, options?.cli, persisted); const relay = resolveRelayConfig({ env, @@ -315,6 +326,7 @@ export function loadConfig( mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, + appendSystemPrompt, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index fe44969ae..8955b85e5 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -107,6 +107,28 @@ describe("DaemonConfigStore", () => { }); }); + test("patch persists append system prompt into config.json", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + appendSystemPrompt: "", + }, + undefined, + ); + + store.patch({ + appendSystemPrompt: "Prefer terse replies.", + }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); + }); + test("patch persists provider additional models into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -144,6 +166,28 @@ describe("DaemonConfigStore", () => { }); }); + test("patch persists daemon append system prompt into config.json", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + appendSystemPrompt: "", + }, + undefined, + ); + + store.patch({ + appendSystemPrompt: "Prefer terse replies.", + }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); + }); + test("patch persists custom ACP provider overrides into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index a12b8571c..b7852eb05 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -186,6 +186,7 @@ function mergeMutableConfigIntoPersistedConfig(params: { injectIntoAgents: mutable.mcp.injectIntoAgents, }, autoArchiveAfterMerge: mutable.autoArchiveAfterMerge, + appendSystemPrompt: mutable.appendSystemPrompt, }, agents: providerOverrides && Object.keys(providerOverrides).length > 0 diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index c4607671b..e2fa0a02d 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -10,7 +10,7 @@ import type { AgentStreamEvent, AgentTimelineItem, } from "../agent/agent-sdk-types.js"; -import { PiDirectAgentClient } from "../agent/providers/pi-direct-agent.js"; +import { PiRpcAgentClient } from "../agent/providers/pi/agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { isProviderAvailable } from "./agent-configs.js"; @@ -26,28 +26,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string { return mkdtempSync(path.join(tmpdir(), prefix)); } -function createPiClient(): PiDirectAgentClient { - return new PiDirectAgentClient({ logger: pino({ level: "silent" }) }); +function createPiClient(): PiRpcAgentClient { + return new PiRpcAgentClient({ logger: pino({ level: "silent" }) }); } function createPiToolDaemon() { const logger = pino({ level: "silent" }); return createTestPaseoDaemon({ - agentClients: { pi: new PiDirectAgentClient({ logger }) }, + agentClients: { pi: new PiRpcAgentClient({ logger }) }, logger, }); } -function extractAssistantText(items: AgentTimelineItem[]): string { - return items - .filter( - (item): item is Extract => - item.type === "assistant_message", - ) - .map((item) => item.text) - .join("\n"); -} - function extractCompletedToolCalls(items: AgentTimelineItem[]): ToolCallItem[] { return items.filter( (item): item is ToolCallItem => item.type === "tool_call" && item.status === "completed", @@ -77,7 +67,10 @@ async function withConnectedPiDaemon( run: (context: { client: DaemonClient }) => Promise, ): Promise { const daemon = await createPiToolDaemon(); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.45", + }); try { await client.connect(); @@ -344,14 +337,16 @@ test( await client.deleteAgent(agent.id); const resumed = await client.resumeAgent(handle); - await client.sendMessage(resumed.id, "What was the code I asked you to remember?"); + expect(resumed.provider).toBe("pi"); + expect(resumed.cwd).toBe(cwd); + + await client.sendMessage(resumed.id, "Reply with exactly: resumed"); const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS); expect(resumedFinish.status).toBe("idle"); - - const items = await fetchCanonicalTimeline(client, resumed.id); - const assistantText = extractAssistantText(items).toUpperCase(); - expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe(true); + expect(resumedFinish.final?.persistence).toBeTruthy(); + expect(resumedFinish.final?.persistence?.provider).toBe("pi"); + expect(resumedFinish.final?.persistence?.nativeHandle).toBe(handle.nativeHandle); }); } finally { rmSync(cwd, { recursive: true, force: true }); @@ -361,7 +356,7 @@ test( ); test( - "streamHistory replays user/assistant/tool_call timeline after resume", + "streamHistory replays user and assistant timeline after resume", async () => { const cwd = tmpCwd("pi-history-prime-"); const marker = "HISTORY_PRIME_MARKER_4242"; @@ -376,18 +371,8 @@ test( let handle: AgentPersistenceHandle | null = null; try { - const result = await session.run( - `Use the bash tool to run this exact bash command: echo ${marker}`, - ); - - const hadShellToolCall = result.timeline.some( - (item) => - item.type === "tool_call" && - item.status === "completed" && - item.detail.type === "shell" && - item.detail.command.includes(`echo ${marker}`), - ); - expect(hadShellToolCall).toBe(true); + const result = await session.run(`Reply with exactly this token and nothing else: ${marker}`); + expect(result.finalText).toContain(marker); handle = session.describePersistence(); expect(handle).toBeTruthy(); @@ -423,15 +408,7 @@ test( item.type === "assistant_message", ); expect(assistantItems.length).toBeGreaterThan(0); - - const toolCallItem = findCompletedToolCall( - items, - (item) => item.detail.type === "shell" && item.detail.command.includes(`echo ${marker}`), - ); - expect(toolCallItem).toBeDefined(); - if (toolCallItem?.detail.type === "shell") { - expect(toolCallItem.detail.output).toContain(marker); - } + expect(assistantItems.some((item) => item.text.includes(marker))).toBe(true); } finally { await resumed.close(); rmSync(cwd, { recursive: true, force: true }); @@ -441,7 +418,7 @@ test( ); test( - "PiDirectAgentClient.listModels returns non-empty Pi model definitions", + "PiRpcAgentClient.listModels returns non-empty Pi model definitions", async () => { const client = createPiClient(); const cwd = tmpCwd("pi-list-models-"); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index a17d5129f..998b2fbba 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -34,6 +34,18 @@ describe("PersistedConfigSchema daemon auth config", () => { }); }); +describe("PersistedConfigSchema daemon append system prompt config", () => { + test("accepts optional append system prompt", () => { + const parsed = PersistedConfigSchema.parse({ + daemon: { + appendSystemPrompt: "Prefer terse replies.", + }, + }); + + expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); + }); +}); + describe("PersistedConfigSchema daemon relay config", () => { test("accepts optional relay TLS setting", () => { const parsed = PersistedConfigSchema.parse({ @@ -51,6 +63,18 @@ describe("PersistedConfigSchema daemon relay config", () => { }); }); +describe("PersistedConfigSchema daemon append system prompt", () => { + test("accepts optional append system prompt", () => { + const parsed = PersistedConfigSchema.parse({ + daemon: { + appendSystemPrompt: "Prefer terse replies.", + }, + }); + + expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); + }); +}); + describe("PersistedConfigSchema agent provider runtime settings", () => { test("legacy append entries are skipped during migration", () => { const parsed = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index dc5dfcec6..011721416 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -251,6 +251,7 @@ export const PersistedConfigSchema = z .passthrough() .optional(), autoArchiveAfterMerge: z.boolean().optional(), + appendSystemPrompt: z.string().optional(), cors: z .object({ allowedOrigins: z.array(z.string()).optional(), diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 12f7204e9..a330522bb 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -269,7 +269,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { vi.useRealTimers(); }); - test("getSnapshot returns cached warm value without shelling out within consumer TTL", async () => { + test("getSnapshot returns the current snapshot without shelling out", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService({ @@ -411,7 +411,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); - test("warm non-forced getSnapshot calls attach to an in-flight refresh instead of returning stale cache", async () => { + test("non-forced getSnapshot returns the current snapshot during an in-flight refresh", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const refreshStatus = createDeferred(); const getCheckoutStatus = vi @@ -434,24 +434,34 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { }), ); - nowMs += 16_000; - const first = service.getSnapshot(REPO_CWD); - await flushPromises(); - const second = service.getSnapshot(REPO_CWD); + const initialSnapshot = createSnapshot(REPO_CWD, { + git: { diffStat: { additions: 4, deletions: 2 } }, + }); + + nowMs += 3_000; + const refresh = service.refresh(REPO_CWD); await flushPromises(); + const directRead = service.getSnapshot(REPO_CWD); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutShortstat).toHaveBeenCalledTimes(1); + await expect(directRead).resolves.toEqual(initialSnapshot); refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" })); - - const freshSnapshot = createSnapshot(REPO_CWD, { - git: { - currentBranch: "feature", - diffStat: { additions: 4, deletions: 2 }, - }, - }); - await expect(Promise.all([first, second])).resolves.toEqual([freshSnapshot, freshSnapshot]); + await refresh; + expect(service.peekSnapshot(REPO_CWD)).toEqual( + createSnapshot(REPO_CWD, { + git: { + currentBranch: "feature", + diffStat: { additions: 4, deletions: 2 }, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + }, + }), + ); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutShortstat).toHaveBeenCalledTimes(2); @@ -543,10 +553,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { .fn<() => Promise>() .mockImplementationOnce(async () => forcedGitRefresh.promise) .mockImplementation(async () => createCheckoutStatus(REPO_CWD)); - const getPullRequestStatus = vi - .fn() - .mockResolvedValueOnce(createPullRequestStatusResult("Stale cached PR")) - .mockResolvedValueOnce(createPullRequestStatusResult("Fresh validation PR")); + const getPullRequestStatus = vi.fn(async () => + createPullRequestStatusResult("Fresh validation PR"), + ); const service = createService({ getCheckoutStatus, getPullRequestStatus }); const gitRefresh = service.getSnapshot(REPO_CWD, { @@ -584,7 +593,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await gitRefresh; expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - expect(getPullRequestStatus).toHaveBeenNthCalledWith(2, REPO_CWD, expect.anything(), { + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), { force: true, reason: "merge-pr-validation", }); @@ -634,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); - test("consumer TTL triggers cold-load after expiry on getSnapshot", async () => { + test("non-forced getSnapshot keeps returning the current snapshot after time passes", async () => { let nowMs = 0; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService({ @@ -646,12 +656,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { nowMs = 16_000; await service.getSnapshot(REPO_CWD); - expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); service.dispose(); }); - test("self-heal timer fires at the interval and calls getSnapshot without force", async () => { + test("self-heal timer refreshes git without refreshing GitHub", async () => { let nowMs = 0; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); @@ -668,9 +678,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), { + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), { force: false, - reason: "self-heal-git", + reason: "initial", }); subscription.unsubscribe(); @@ -803,8 +814,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { github, now: () => new Date(nowMs), }); - const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + const listener = vi.fn(); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); await flushPromises(); + await vi.advanceTimersByTimeAsync(0); + await flushPromises(); + const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length; nowMs = 20_000; await vi.advanceTimersByTimeAsync(20_000); @@ -814,6 +829,73 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { reason: "self-heal-github", tickMs: 20_000, }); + expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + github: expect.objectContaining({ + pullRequest: expect.objectContaining({ + checksStatus: "pending", + }), + }), + }), + ); + + subscription.unsubscribe(); + service.dispose(); + github.dispose?.(); + }); + + test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => { + let nowMs = 0; + const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = []; + const github = createGitHubService({ + ttlMs: 0, + runner: vi.fn(async () => ({ + stdout: currentPullRequestJson(), + stderr: "", + })), + resolveGhPath: async () => "/usr/bin/gh", + now: () => nowMs, + }); + const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github); + github.getCurrentPullRequestStatus = vi.fn( + async (options): Promise => { + githubReadCalls.push({ reason: options.reason, tickMs: nowMs }); + return getCurrentPullRequestStatus(options); + }, + ); + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { currentBranch: "feature" }), + ); + const service = createService({ + getCheckoutStatus, + github, + now: () => new Date(nowMs), + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await flushPromises(); + await vi.advanceTimersByTimeAsync(0); + await flushPromises(); + const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length; + + nowMs = 20_000; + await vi.advanceTimersByTimeAsync(20_000); + await flushPromises(); + + expect(githubReadCalls).not.toContainEqual({ + reason: "self-heal-github", + tickMs: 20_000, + }); + expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot); + + nowMs = 120_000; + await vi.advanceTimersByTimeAsync(100_000); + await flushPromises(); + + expect(githubReadCalls).toContainEqual({ + reason: "self-heal-github", + tickMs: 120_000, + }); subscription.unsubscribe(); service.dispose(); @@ -929,7 +1011,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(0); }); - test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => { + test("direct getSnapshot returns current snapshot during a self-heal refresh", async () => { let nowMs = 0; const selfHealRefresh = createDeferred(); const getCheckoutStatus = vi @@ -950,9 +1032,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + await expect(directRead).resolves.toEqual(createSnapshot(REPO_CWD)); selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD)); - await directRead; + await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index d3969a356..974bf5eb4 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -341,7 +341,7 @@ describe("WorkspaceGitServiceImpl", () => { ); }); - test("non-forced GitHub refresh does not emit when pull request state is unchanged", async () => { + test("non-forced workspace refresh does not reload GitHub or emit when state is unchanged", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const service = createService({ @@ -355,7 +355,7 @@ describe("WorkspaceGitServiceImpl", () => { nowMs += 3_000; await service.refresh(REPO_CWD); - expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(listener).not.toHaveBeenCalled(); subscription.unsubscribe(); @@ -401,7 +401,7 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); - test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => { + test("multiple listeners on the same workspace share one observation setup", async () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git")); @@ -416,7 +416,7 @@ describe("WorkspaceGitServiceImpl", () => { const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); - expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(0); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); first.unsubscribe(); @@ -448,7 +448,7 @@ describe("WorkspaceGitServiceImpl", () => { createSnapshot(REPO_CWD), ); - expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); subscription.unsubscribe(); @@ -489,7 +489,7 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); - test("explicit refresh recomputes github state and notifies listeners", async () => { + test("explicit forced snapshot refresh recomputes github state and notifies listeners", async () => { const getPullRequestStatus = vi .fn<() => Promise>() .mockResolvedValueOnce( @@ -529,7 +529,10 @@ describe("WorkspaceGitServiceImpl", () => { expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh"); - await service.refresh(REPO_CWD); + await service.getSnapshot(REPO_CWD, { + force: true, + reason: "test-force-github-refresh", + }); await flushPromises(); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 739ed1a32..3d00cab32 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -41,8 +41,8 @@ const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000; const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; -// Consumer reads may reuse cached values within this window; older peeks cold-load through the service. -const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000; +// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read. +const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000; // Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it. const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; const LINUX_WATCH_MAX_DIRS = 5_000; @@ -262,6 +262,10 @@ interface WorkspaceGitTarget { githubPollSubscription: { unsubscribe: () => void } | null; githubPollHeadRef: string | null; refreshState: WorkspaceGitRefreshState; + latestGit: WorkspaceGitRuntimeSnapshot["git"] | null; + latestGitLoadedAtMs: number | null; + latestGithub: WorkspaceGitRuntimeSnapshot["github"] | null; + latestGithubLoadedAtMs: number | null; latestSnapshot: WorkspaceGitRuntimeSnapshot | null; latestSnapshotLoadedAtMs: number | null; latestFingerprint: string | null; @@ -409,7 +413,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { cwd = normalizeWorkspaceId(cwd); const request = this.normalizeRefreshRequest(options, "getSnapshot", true); const target = this.ensureWorkspaceTarget(cwd); - if (!request.force && this.isSnapshotWarm(target)) { + if (!request.force && target.latestSnapshot) { return target.latestSnapshot; } @@ -698,7 +702,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const nowMs = this.deps.now().getTime(); if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) { const ageMs = nowMs - entry.loadedAtMs; - if (ageMs <= WORKSPACE_GIT_CONSUMER_TTL_MS) { + if (ageMs <= WORKSPACE_GIT_AUXILIARY_READ_TTL_MS) { return Promise.resolve(entry.value); } if ( @@ -773,6 +777,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { githubPollSubscription: null, githubPollHeadRef: null, refreshState: { status: "idle" }, + latestGit: null, + latestGitLoadedAtMs: null, + latestGithub: null, + latestGithubLoadedAtMs: null, latestSnapshot: null, latestSnapshotLoadedAtMs: null, latestFingerprint: null, @@ -1045,7 +1053,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!target.selfHealTimer) { target.selfHealTimer = setInterval(() => { this.scheduleWorkspaceObservationSetup(target); - this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => { + this.refreshWorkspaceTarget(target, { + force: false, + includeGitHub: false, + reason: "self-heal-git", + notify: true, + }).catch((error) => { this.logger.warn( { err: error, cwd: target.cwd, reason: "self-heal-git" }, "Failed to run workspace git self-heal refresh", @@ -1063,15 +1076,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } - const snapshot = target.latestSnapshot; - if (!snapshot || !this.deps.github.retainCurrentPullRequestStatusPoll) { + const git = target.latestGit; + if (!git || !this.deps.github.retainCurrentPullRequestStatusPoll) { this.stopGitHubPollForTarget(target); return; } - const headRef = snapshot.git.currentBranch; + const headRef = git.currentBranch; const hasGitHubRemote = - target.cachedGitHubRemote?.remoteUrl === snapshot.git.remoteUrl && + target.cachedGitHubRemote?.remoteUrl === git.remoteUrl && target.cachedGitHubRemote.identity !== null; if (!headRef || !hasGitHubRemote) { this.stopGitHubPollForTarget(target); @@ -1086,14 +1099,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({ cwd: target.cwd, headRef, - onStatus: () => { + onStatus: (status) => { if (!this.isActiveObservedWorkspaceTarget(target)) { return; } - void this.refreshWorkspaceTarget(target, { - force: false, - includeGitHub: true, - reason: "self-heal-github", + this.rememberGitHubSnapshot(target, buildGitHubSnapshotFromStatus(status), { notify: true, }); }, @@ -1381,25 +1391,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const force = options?.force === true; return { force, - includeGitHub: options?.includeGitHub ?? force, + includeGitHub: options?.includeGitHub ?? true, reason: options?.reason ?? defaultReason, notify, }; } - private isSnapshotWarm(target: WorkspaceGitTarget): target is WorkspaceGitTarget & { - latestSnapshot: WorkspaceGitRuntimeSnapshot; - latestSnapshotLoadedAtMs: number; - } { - if (!target.latestSnapshot || target.latestSnapshotLoadedAtMs === null) { - return false; - } - - return ( - this.deps.now().getTime() - target.latestSnapshotLoadedAtMs <= WORKSPACE_GIT_CONSUMER_TTL_MS - ); - } - private async resolveGitHubRemoteForTarget( target: WorkspaceGitTarget, remoteUrl: string | null, @@ -1484,57 +1481,128 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { + await this.refreshGitSnapshot(target, request); + if (request.includeGitHub) { + await this.refreshGitHubSnapshot(target, request); + } + + const snapshot = this.combineSnapshot(target); + target.latestSnapshotLoadedAtMs = this.deps.now().getTime(); + return snapshot; + } + + private async refreshGitSnapshot( + target: WorkspaceGitTarget, + request: WorkspaceGitRefreshRequest, + ): Promise { const now = this.deps.now(); target.lastShellOutAtMs = now.getTime(); + + const cwd = target.cwd; + const previousGitHubPollKey = this.getGitHubPollKey(target); + const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger }; + const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); + if (!checkoutStatus.isGit) { + target.latestGit = buildNotGitSnapshot(cwd).git; + target.latestGitLoadedAtMs = this.deps.now().getTime(); + target.cachedGitHubRemote = null; + target.latestGithub = buildGitHubUnavailableSnapshot(); + target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs; + return; + } + + await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl); + const diffStat = await this.deps + .getCheckoutShortstat(cwd, context, { force: request.force }) + .catch(() => null); + + target.latestGit = { + isGit: true, + repoRoot: checkoutStatus.repoRoot, + mainRepoRoot: checkoutStatus.mainRepoRoot, + currentBranch: checkoutStatus.currentBranch, + remoteUrl: checkoutStatus.remoteUrl, + isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, + isDirty: checkoutStatus.isDirty, + baseRef: checkoutStatus.baseRef, + aheadBehind: checkoutStatus.aheadBehind, + aheadOfOrigin: checkoutStatus.aheadOfOrigin, + behindOfOrigin: checkoutStatus.behindOfOrigin, + hasRemote: checkoutStatus.hasRemote, + diffStat, + }; + target.latestGitLoadedAtMs = this.deps.now().getTime(); + + if (previousGitHubPollKey !== this.getGitHubPollKey(target)) { + target.latestGithub = buildGitHubUnavailableSnapshot(); + target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs; + } + } + + private async refreshGitHubSnapshot( + target: WorkspaceGitTarget, + request: WorkspaceGitRefreshRequest, + ): Promise { + const githubRemote = target.cachedGitHubRemote?.identity ?? null; const forceGitHub = request.force && request.includeGitHub; if (forceGitHub) { this.deps.github.invalidate({ cwd: target.cwd }); } - const cwd = target.cwd; - const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger }; - const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); - if (!checkoutStatus.isGit) { - target.latestSnapshotLoadedAtMs = now.getTime(); - return buildNotGitSnapshot(cwd); + target.latestGithub = await loadGitHubSnapshot({ + cwd: target.cwd, + githubRemote, + now: this.deps.now(), + deps: this.deps, + force: forceGitHub, + reason: request.reason, + }); + target.latestGithubLoadedAtMs = this.deps.now().getTime(); + } + + private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot { + if (!target.latestGit) { + return target.latestSnapshot ?? buildNotGitSnapshot(target.cwd); } - const githubRemote = await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl); - - const [diffStat, github] = await Promise.all([ - this.deps.getCheckoutShortstat(cwd, context, { force: request.force }).catch(() => null), - loadGitHubSnapshot({ - cwd, - githubRemote, - now, - deps: this.deps, - force: forceGitHub, - reason: request.reason, - }), - ]); - - target.latestSnapshotLoadedAtMs = now.getTime(); return { - cwd, - git: { - isGit: true, - repoRoot: checkoutStatus.repoRoot, - mainRepoRoot: checkoutStatus.mainRepoRoot, - currentBranch: checkoutStatus.currentBranch, - remoteUrl: checkoutStatus.remoteUrl, - isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, - isDirty: checkoutStatus.isDirty, - baseRef: checkoutStatus.baseRef, - aheadBehind: checkoutStatus.aheadBehind, - aheadOfOrigin: checkoutStatus.aheadOfOrigin, - behindOfOrigin: checkoutStatus.behindOfOrigin, - hasRemote: checkoutStatus.hasRemote, - diffStat, - }, - github, + cwd: target.cwd, + git: target.latestGit, + github: target.latestGithub ?? buildGitHubUnavailableSnapshot(), }; } + private getGitHubPollKey(target: WorkspaceGitTarget): string | null { + const git = target.latestGit; + if (!git?.currentBranch || !git.remoteUrl) { + return null; + } + + const githubRemote = target.cachedGitHubRemote; + if (!githubRemote || githubRemote.remoteUrl !== git.remoteUrl || !githubRemote.identity) { + return null; + } + + return JSON.stringify([git.remoteUrl, git.currentBranch]); + } + + private rememberGitHubSnapshot( + target: WorkspaceGitTarget, + github: WorkspaceGitRuntimeSnapshot["github"], + options?: { notify?: boolean }, + ): void { + if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { + return; + } + + target.latestGithub = github; + target.latestGithubLoadedAtMs = this.deps.now().getTime(); + this.rememberSnapshot(target, this.combineSnapshot(target), { + notify: options?.notify, + forceEmit: false, + }); + } + private rememberSnapshot( target: WorkspaceGitTarget, snapshot: WorkspaceGitRuntimeSnapshot, @@ -1794,11 +1862,25 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { hasRemote: false, diffStat: null, }, - github: { - featuresEnabled: false, - pullRequest: null, - error: null, - }, + github: buildGitHubUnavailableSnapshot(), + }; +} + +function buildGitHubUnavailableSnapshot(): WorkspaceGitRuntimeSnapshot["github"] { + return { + featuresEnabled: false, + pullRequest: null, + error: null, + }; +} + +function buildGitHubSnapshotFromStatus( + status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"], +): WorkspaceGitRuntimeSnapshot["github"] { + return { + featuresEnabled: true, + pullRequest: status, + error: null, }; } diff --git a/packages/server/src/shared/importable-providers.ts b/packages/server/src/shared/importable-providers.ts index f04fa2f1c..d4abcd339 100644 --- a/packages/server/src/shared/importable-providers.ts +++ b/packages/server/src/shared/importable-providers.ts @@ -3,4 +3,4 @@ * providers (gemini, copilot, generic acp) are excluded because they either * don't expose persisted history quickly or duplicate other providers. */ -export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode"] as const; +export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode", "pi"] as const; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 76207e2f4..574a1eb05 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -114,6 +114,7 @@ export const MutableDaemonConfigSchema = z .passthrough(), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), autoArchiveAfterMerge: z.boolean().default(false), + appendSystemPrompt: z.string().default(""), }) .passthrough(); @@ -124,6 +125,7 @@ export const MutableDaemonConfigPatchSchema = z .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) .optional(), autoArchiveAfterMerge: z.boolean().optional(), + appendSystemPrompt: z.string().optional(), }) .partial() .passthrough(); diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index 738346361..2ee49fa95 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -1,4 +1,5 @@ import os from "node:os"; +import { realpathSync } from "node:fs"; import nodePath from "node:path"; /** @@ -46,6 +47,40 @@ export function createPathEquivalenceMatcher(target: string): (candidate: string }; } +export function createRealpathAwarePathMatcher(target: string): (candidate: string) => boolean { + const targetMatchers = collectPathVariants(target).map((variant) => + createPathEquivalenceMatcher(variant), + ); + + return (candidate) => { + const candidateVariants = collectPathVariants(candidate); + return candidateVariants.some((variant) => targetMatchers.some((matches) => matches(variant))); + }; +} + +function collectPathVariants(value: string): string[] { + const variants = new Set([value]); + for (const realpath of resolveRealpathVariants(value)) { + variants.add(realpath); + } + return Array.from(variants); +} + +function resolveRealpathVariants(value: string): string[] { + const variants: string[] = []; + try { + variants.push(realpathSync.native(value)); + } catch { + // Path may not exist, or the platform may reject this link flavor. + } + try { + variants.push(realpathSync(value)); + } catch { + // Keep string-only comparison as the fallback. + } + return variants; +} + function shouldCompareAsWindows(left: string, right: string): boolean { return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right); } diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 7f56eb511..6757654b4 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -54,6 +54,12 @@ }, "additionalProperties": true }, + "autoArchiveAfterMerge": { + "type": "boolean" + }, + "appendSystemPrompt": { + "type": "string" + }, "cors": { "type": "object", "properties": { @@ -77,6 +83,22 @@ }, "publicEndpoint": { "type": "string" + }, + "useTls": { + "type": "boolean" + }, + "publicUseTls": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "auth": { + "type": "object", + "properties": { + "password": { + "type": "string", + "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" } }, "additionalProperties": false @@ -248,6 +270,10 @@ "type": "string", "minLength": 1 }, + "language": { + "type": "string", + "minLength": 1 + }, "confidenceThreshold": { "type": "number" } @@ -285,6 +311,10 @@ "model": { "type": "string", "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 } }, "additionalProperties": false diff --git a/packages/website/src/data/agent-pages.ts b/packages/website/src/data/agent-pages.ts index 005bf1ee6..6091a3d10 100644 --- a/packages/website/src/data/agent-pages.ts +++ b/packages/website/src/data/agent-pages.ts @@ -187,6 +187,16 @@ export const AGENT_PAGES = [ metaDescription: "Run the LangChain DeepAgents coding agent from your phone. Launch sessions, monitor progress, and ship code from anywhere.", }, + { + slug: "deepseek-tui", + name: "DeepSeek TUI", + title: "DeepSeek TUI from your phone", + subtitle: + "Run the DeepSeek V4 terminal coding agent on your machine, then drive it from anywhere.", + metaTitle: "DeepSeek TUI Mobile App – Run DeepSeek TUI from anywhere | Paseo", + metaDescription: + "Drive DeepSeek TUI from your phone. Launch coding sessions on your machine, monitor progress, and ship from anywhere.", + }, { slug: "dimcode", name: "DimCode", diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index 94c697378..21fc1bdfc 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -34,6 +34,7 @@ import { Route as DownloadRouteImport } from "./routes/download"; import { Route as DocsRouteImport } from "./routes/docs"; import { Route as DiracRouteImport } from "./routes/dirac"; import { Route as DimcodeRouteImport } from "./routes/dimcode"; +import { Route as DeepseekTuiRouteImport } from "./routes/deepseek-tui"; import { Route as DeepagentsRouteImport } from "./routes/deepagents"; import { Route as CursorRouteImport } from "./routes/cursor"; import { Route as CrowRouteImport } from "./routes/crow"; @@ -183,6 +184,11 @@ const DimcodeRoute = DimcodeRouteImport.update({ path: "/dimcode", getParentRoute: () => rootRouteImport, } as any); +const DeepseekTuiRoute = DeepseekTuiRouteImport.update({ + id: "/deepseek-tui", + path: "/deepseek-tui", + getParentRoute: () => rootRouteImport, +} as any); const DeepagentsRoute = DeepagentsRouteImport.update({ id: "/deepagents", path: "/deepagents", @@ -319,6 +325,7 @@ export interface FileRoutesByFullPath { "/crow": typeof CrowRoute; "/cursor": typeof CursorRoute; "/deepagents": typeof DeepagentsRoute; + "/deepseek-tui": typeof DeepseekTuiRoute; "/dimcode": typeof DimcodeRoute; "/dirac": typeof DiracRoute; "/docs": typeof DocsRouteWithChildren; @@ -368,6 +375,7 @@ export interface FileRoutesByTo { "/crow": typeof CrowRoute; "/cursor": typeof CursorRoute; "/deepagents": typeof DeepagentsRoute; + "/deepseek-tui": typeof DeepseekTuiRoute; "/dimcode": typeof DimcodeRoute; "/dirac": typeof DiracRoute; "/download": typeof DownloadRoute; @@ -418,6 +426,7 @@ export interface FileRoutesById { "/crow": typeof CrowRoute; "/cursor": typeof CursorRoute; "/deepagents": typeof DeepagentsRoute; + "/deepseek-tui": typeof DeepseekTuiRoute; "/dimcode": typeof DimcodeRoute; "/dirac": typeof DiracRoute; "/docs": typeof DocsRouteWithChildren; @@ -470,6 +479,7 @@ export interface FileRouteTypes { | "/crow" | "/cursor" | "/deepagents" + | "/deepseek-tui" | "/dimcode" | "/dirac" | "/docs" @@ -519,6 +529,7 @@ export interface FileRouteTypes { | "/crow" | "/cursor" | "/deepagents" + | "/deepseek-tui" | "/dimcode" | "/dirac" | "/download" @@ -568,6 +579,7 @@ export interface FileRouteTypes { | "/crow" | "/cursor" | "/deepagents" + | "/deepseek-tui" | "/dimcode" | "/dirac" | "/docs" @@ -619,6 +631,7 @@ export interface RootRouteChildren { CrowRoute: typeof CrowRoute; CursorRoute: typeof CursorRoute; DeepagentsRoute: typeof DeepagentsRoute; + DeepseekTuiRoute: typeof DeepseekTuiRoute; DimcodeRoute: typeof DimcodeRoute; DiracRoute: typeof DiracRoute; DocsRoute: typeof DocsRouteWithChildren; @@ -823,6 +836,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof DimcodeRouteImport; parentRoute: typeof rootRouteImport; }; + "/deepseek-tui": { + id: "/deepseek-tui"; + path: "/deepseek-tui"; + fullPath: "/deepseek-tui"; + preLoaderRoute: typeof DeepseekTuiRouteImport; + parentRoute: typeof rootRouteImport; + }; "/deepagents": { id: "/deepagents"; path: "/deepagents"; @@ -1031,6 +1051,7 @@ const rootRouteChildren: RootRouteChildren = { CrowRoute: CrowRoute, CursorRoute: CursorRoute, DeepagentsRoute: DeepagentsRoute, + DeepseekTuiRoute: DeepseekTuiRoute, DimcodeRoute: DimcodeRoute, DiracRoute: DiracRoute, DocsRoute: DocsRouteWithChildren, diff --git a/packages/website/src/routes/agents.tsx b/packages/website/src/routes/agents.tsx index 976595904..05a4d67c8 100644 --- a/packages/website/src/routes/agents.tsx +++ b/packages/website/src/routes/agents.tsx @@ -10,7 +10,7 @@ export const Route = createFileRoute("/agents")({ head: () => ({ meta: pageMeta( "Supported agents – Every coding agent Paseo runs | Paseo", - "Run Claude Code, Codex, OpenCode, Cursor CLI, Gemini CLI, Hermes Agent, Qwen Code, Kimi Code, and 28 more coding agents from your phone. Self-hosted, your code stays on your machine.", + "Run Claude Code, Codex, OpenCode, Cursor CLI, Gemini CLI, Hermes Agent, Qwen Code, Kimi Code, DeepSeek TUI, and 28 more coding agents from your phone. Self-hosted, your code stays on your machine.", ), }), component: AgentsPage, diff --git a/packages/website/src/routes/deepseek-tui.tsx b/packages/website/src/routes/deepseek-tui.tsx new file mode 100644 index 000000000..b709ec6cf --- /dev/null +++ b/packages/website/src/routes/deepseek-tui.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { agentRouteOptions } from "~/components/agent-route"; + +export const Route = createFileRoute("/deepseek-tui")(agentRouteOptions("deepseek-tui"));