From 4cf985c25450d88b21b7767e01ee13c1dc08aae7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 23:56:07 +0800 Subject: [PATCH] Run Pi agents through the installed Pi CLI (#1097) * Run Pi through its RPC interface Replace the embedded Pi runtime packages with a process-backed runtime that starts pi --mode rpc, keeps the runtime port thin, and leaves provider behavior in the Paseo session and mapper modules. Add focused adapter and mapper tests plus real Pi E2E coverage for the installed binary path. * Keep Pi provider code in one module * Add Pi session imports * Enable Paseo MCP tools in Pi agents Route injected MCP servers through Pi's MCP adapter and make structured MCP results visible to model-only clients. Normalize Pi MCP proxy calls so Paseo timelines show the underlying tool names. * Fix Pi import symlink tests on Windows Create directory links explicitly in symlink cwd fixtures so Windows server tests exercise the intended path-equivalence behavior. * Use native realpath for import cwd matching Resolve import cwd matchers with native realpath so Windows symlink and junction sessions compare against the same path shape used by persisted provider data. * Fallback realpath for Windows import matching Use plain realpath when native realpath cannot resolve a cwd so symlink-equivalent persisted sessions still match on Windows runners. * Add daemon system prompt support for Pi * Fix Windows realpath import matching * Remove Pi RPC migration plan doc --- docs/providers.md | 10 +- package-lock.json | 1583 +---------------- .../cli/src/commands/agent/import.test.ts | 41 +- packages/cli/src/commands/agent/import.ts | 8 +- packages/server/package.json | 3 - .../src/server/agent/agent-manager.test.ts | 4 +- .../server/src/server/agent/agent-manager.ts | 26 +- .../src/server/agent/import-sessions.test.ts | 38 + .../src/server/agent/import-sessions.ts | 4 +- .../src/server/agent/mcp-server.test.ts | 4 + .../server/src/server/agent/mcp-server.ts | 124 +- .../server/agent/provider-registry.test.ts | 4 +- .../src/server/agent/provider-registry.ts | 4 +- .../server/agent/providers/acp-agent.test.ts | 2 +- .../agent/providers/pi-direct-agent.test.ts | 373 ---- .../server/agent/providers/pi-direct-agent.ts | 1565 ---------------- .../pi-session-recovery-policy.test.ts | 115 -- .../providers/pi-session-recovery-policy.ts | 91 - .../server/agent/providers/pi/agent.test.ts | 489 +++++ .../src/server/agent/providers/pi/agent.ts | 1074 +++++++++++ .../agent/providers/pi/cli-runtime.test.ts | 188 ++ .../server/agent/providers/pi/cli-runtime.ts | 282 +++ .../agent/providers/pi/history-mapper.test.ts | 127 ++ .../agent/providers/pi/history-mapper.ts | 169 ++ .../server/agent/providers/pi/rpc-types.ts | 172 ++ .../src/server/agent/providers/pi/runtime.ts | 106 ++ .../providers/pi/session-descriptor.test.ts | 297 ++++ .../agent/providers/pi/session-descriptor.ts | 372 ++++ .../agent/providers/pi/test-utils/fake-pi.ts | 149 ++ .../providers/pi/tool-call-mapper.test.ts | 76 + .../agent/providers/pi/tool-call-mapper.ts | 469 +++++ .../src/server/daemon-config-store.test.ts | 22 + .../src/server/daemon-e2e/pi.real.e2e.test.ts | 63 +- .../src/server/persisted-config.test.ts | 12 + .../server/src/shared/importable-providers.ts | 2 +- packages/server/src/utils/path.ts | 35 + 36 files changed, 4319 insertions(+), 3784 deletions(-) delete mode 100644 packages/server/src/server/agent/providers/pi-direct-agent.test.ts delete mode 100644 packages/server/src/server/agent/providers/pi-direct-agent.ts delete mode 100644 packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts delete mode 100644 packages/server/src/server/agent/providers/pi-session-recovery-policy.ts create mode 100644 packages/server/src/server/agent/providers/pi/agent.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/agent.ts create mode 100644 packages/server/src/server/agent/providers/pi/cli-runtime.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/cli-runtime.ts create mode 100644 packages/server/src/server/agent/providers/pi/history-mapper.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/history-mapper.ts create mode 100644 packages/server/src/server/agent/providers/pi/rpc-types.ts create mode 100644 packages/server/src/server/agent/providers/pi/runtime.ts create mode 100644 packages/server/src/server/agent/providers/pi/session-descriptor.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/session-descriptor.ts create mode 100644 packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts create mode 100644 packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/tool-call-mapper.ts 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/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 384b81c0a..b22cbffee 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -425,7 +425,7 @@ test("createAgent injects daemon append system prompt at runtime only", async () expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt"); }); -test("daemon append system prompt is not injected into Pi", async () => { +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); @@ -449,7 +449,7 @@ test("daemon append system prompt is not injected into Pi", async () => { systemPrompt: "Agent instructions.", }); - expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBeUndefined(); + expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions."); }); test("setAgentMode persists the selected mode across session reload", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d13ac0e72..3c3a95d79 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1073,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); @@ -3456,23 +3457,16 @@ export class AgentManager { } private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig { - if (config.provider === "pi") { - const next = { ...config }; - delete next.daemonAppendSystemPrompt; - return next; - } + const daemonAppendSystemPrompt = this.appendSystemPrompt.trim(); + const next = { ...config }; + delete next.daemonAppendSystemPrompt; - const trimmed = this.appendSystemPrompt.trim(); - if (!trimmed) { - const next = { ...config }; - delete next.daemonAppendSystemPrompt; - return next; - } - - return { - ...config, - daemonAppendSystemPrompt: trimmed, - }; + return daemonAppendSystemPrompt + ? { + ...next, + daemonAppendSystemPrompt, + } + : next; } private buildLaunchContext(agentId: string): AgentLaunchContext { 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/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/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/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 2ac5f65e3..8955b85e5 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -166,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-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 23756ceb2..998b2fbba 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -63,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/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/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); }