From 145d09497e45c839e143be30be81e5b9367d35e8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 29 May 2026 15:37:35 +0700 Subject: [PATCH 01/16] Add Paseo Agent provider --- CLAUDE.md | 2 +- docs/paseo-agent.md | 186 + package-lock.json | 3451 +++++++++++++++-- .../src/components/provider-icon-name.test.ts | 1 + packages/app/src/components/provider-icons.ts | 2 + packages/cli/src/cli.ts | 4 + packages/cli/src/commands/login/index.test.ts | 119 + packages/cli/src/commands/login/index.ts | 122 + packages/cli/src/utils/open-browser.ts | 30 + packages/protocol/src/provider-config.ts | 10 +- packages/protocol/src/provider-icon-names.ts | 1 + packages/protocol/src/provider-manifest.ts | 7 + packages/server/package.json | 3 + .../src/server/agent/provider-registry.ts | 18 +- .../agent/provider-snapshot-manager.test.ts | 3 +- .../server/agent/provider-snapshot-manager.ts | 5 + .../agent/providers/paseo-agent/agent.test.ts | 94 + .../agent/providers/paseo-agent/agent.ts | 560 +++ .../providers/paseo-agent/config.test.ts | 318 ++ .../agent/providers/paseo-agent/config.ts | 407 ++ .../paseo-agent/event-mapping.test.ts | 78 + .../providers/paseo-agent/event-mapping.ts | 349 ++ .../providers/paseo-agent/mcp-bridge.test.ts | 169 + .../agent/providers/paseo-agent/mcp-bridge.ts | 245 ++ .../providers/paseo-agent/mcp-schema.test.ts | 77 + .../agent/providers/paseo-agent/mcp-schema.ts | 161 + .../paseo-agent/oauth-credentials.test.ts | 35 + .../paseo-agent/oauth-credentials.ts | 71 + .../providers/paseo-agent/oauth-store.test.ts | 129 + .../providers/paseo-agent/oauth-store.ts | 124 + .../providers/paseo-agent/pi-services.test.ts | 205 + .../providers/paseo-agent/pi-services.ts | 195 + packages/server/src/server/bootstrap.ts | 3 + packages/server/src/server/config.ts | 1 + packages/server/src/server/exports.ts | 8 + .../src/server/persisted-config.test.ts | 24 + .../server/src/server/persisted-config.ts | 2 + .../public/schemas/paseo.config.v1.json | 98 + 38 files changed, 7065 insertions(+), 252 deletions(-) create mode 100644 docs/paseo-agent.md create mode 100644 packages/cli/src/commands/login/index.test.ts create mode 100644 packages/cli/src/commands/login/index.ts create mode 100644 packages/cli/src/utils/open-browser.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/agent.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/agent.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/config.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/config.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/event-mapping.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/event-mapping.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/mcp-schema.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/mcp-schema.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/pi-services.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8b3de2fff9..c8609684a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,8 +35,8 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash | | [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | -| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | +| [docs/paseo-agent.md](docs/paseo-agent.md) | Paseo Agent provider (id `paseo`): in-process Pi harness, `agents.paseo` typed inference providers, auth, OAuth limitation | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md new file mode 100644 index 0000000000..5c55688dbc --- /dev/null +++ b/docs/paseo-agent.md @@ -0,0 +1,186 @@ +# Paseo Agent provider + +Paseo Agent is a built-in provider that runs Pi's coding-agent harness **in process** (no `pi` CLI, no `~/.pi` discovery). It is configured entirely by Paseo-owned config under `agents.paseo` in `$PASEO_HOME/config.json`. The model backends are "inference providers": one or more typed entries, each pointing at an API with its own key and models. + +The provider id is **`paseo`** (the display name is "Paseo Agent"). Use it like any other provider, e.g. `paseo run --provider paseo --model / ...`. + +This is a prototype. There is no UI yet — config-file only. + +> Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing +> provider/config code, run `npm run build:server` (or run a source/dev daemon) before +> smoking, otherwise a stale `dist` may reject the `agents.paseo` config. Always pass +> `--host ` to CLI smoke commands so they hit your isolated daemon, not the real +> daemon on `:6767`. + +## MCP tools + +Paseo Agent bridges `AgentSessionConfig.mcpServers` into Pi custom tools, so the +daemon-injected `paseo` MCP server (and any other configured MCP server) is available to +the model. On session start the provider connects to each server, lists its tools, and +registers them as Pi tools named `__`; tool input schemas (JSON +Schema) are converted to TypeBox, calls are proxied to the MCP server, and results map +back to the model. Connections are torn down on session close. Servers that fail to +connect or list are logged and skipped rather than failing the session. + +Transports: HTTP (streamable) is the primary path (the injected `paseo` server is HTTP); +SSE and stdio transports are also wired via the MCP SDK. No extra config is needed — MCP +servers come from Paseo's normal injection/config, not from `agents.paseo`. + +## Config shape + +```jsonc +{ + "agents": { + "paseo": { + // Optional. "/". Used when an agent is + // started without an explicit model. + "defaultModel": "openrouter-main/anthropic/claude-3.7-sonnet", + + // Inference providers, keyed by instance name. Names are free-form; you may + // run several entries of the same type against different APIs/keys/models. + "providers": { + "openrouter-main": { + "type": "openrouter", + "options": { + // apiKey may be omitted to fall back to the type's env var + // (here OPENROUTER_API_KEY). It may also be a literal, an env + // reference like "$OPENROUTER_API_KEY" / "${OPENROUTER_API_KEY}", + // or a "!command" that prints the key. + "models": [ + { "id": "anthropic/claude-3.7-sonnet", "label": "Claude 3.7", "reasoning": true }, + { "id": "openai/gpt-4o", "label": "GPT-4o" }, + ], + }, + }, + }, + }, + }, +} +``` + +## Supported types + +Each type supplies sensible defaults so you usually only provide an API key (or its +env var) and model ids. + +| type | wire `api` | default base URL | default key env var | +| ------------------- | --------------------------- | --------------------------------- | --------------------- | +| `openrouter` | `openai-completions` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | +| `openai` | `openai-responses` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | +| `anthropic` | `anthropic-messages` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | +| `opencode` | `openai-completions` | `https://opencode.ai/zen/v1` | `OPENCODE_API_KEY` | +| `openai-compatible` | `openai-completions` | _(required: `options.baseUrl`)_ | _(none — set apiKey)_ | +| `openai-codex` | `openai-codex-responses` | `https://chatgpt.com/backend-api` | _(OAuth — see below)_ | +| `custom` | _(required: `options.api`)_ | _(required: `options.baseUrl`)_ | _(none — set apiKey)_ | + +Per-entry overrides live in `options`: `baseUrl`, `api`, `apiKey`, `headers`, +`authHeader` (send `Authorization: Bearer `), and `models[]`. Each model may +override `api` (e.g. an `anthropic-messages` model behind an otherwise +`openai-completions` provider), plus `label`, `reasoning`, `contextWindow`, `maxTokens`. + +### OpenCode Zen / Go (OpenAI-compatible) + +OpenCode Zen models speak either `openai-completions` or `anthropic-messages`. Use the +`opencode` type for Zen, or `openai-compatible` with an explicit base URL for Go, and +override the per-model `api` where a model is Anthropic-family: + +```jsonc +{ + "type": "opencode", + "options": { + "models": [ + { "id": "big-pickle" }, + { "id": "claude-sonnet", "api": "anthropic-messages" } + ] + } +} +// OpenCode Go: +{ + "type": "openai-compatible", + "options": { + "baseUrl": "https://opencode.ai/zen/go/v1", + "apiKey": "$OPENCODE_API_KEY", + "models": [{ "id": "glm-5" }] + } +} +``` + +Pi attaches its own `x-opencode-*` attribution headers automatically when the base URL +is on `opencode.ai`, so you do not set those yourself. + +### `custom` escape hatch + +When a backend needs a wire protocol the named types don't cover, use `custom` and set +`options.api` to a Pi wire protocol (e.g. `google-generative-ai`, `mistral-conversations`, +`openai-codex-responses`) plus `options.baseUrl`. This is a thin pass-through, not a place +to embed raw Pi internals. + +## Authentication + +- **API key / env var / command** — works for every type. Omit `apiKey` to use the + type's default env var; or set a literal, a `$ENV` reference, or a `!command`. +- A provider only counts as "available" (and its models listable for use) when its + resolved key is actually present — a literal value, a set env var, or a command. + +### ChatGPT / OpenAI subscription (OAuth) — `openai-codex` + +The `openai-codex` type uses a ChatGPT/OpenAI **subscription** via OAuth instead of an +API key, against `https://chatgpt.com/backend-api` with the `openai-codex-responses` wire +API. Paseo **owns** the auth: `paseo login chatgpt` runs Pi's browser PKCE/callback +OAuth flow by default, stores the credential in a Paseo-controlled file, and lets Pi +refresh/rotate it there. Paseo does **not** read ChatGPT/Codex/OpenCode/Pi or any other +tool's auth files. + +Config — just declare the provider and its models (no credential field): + +```jsonc +{ + "agents": { + "paseo": { + "providers": { + "chatgpt": { + "type": "openai-codex", + "options": { "models": [{ "id": "gpt-5.3-codex", "reasoning": true }] }, + }, + }, + }, + }, +} +``` + +> Use a model id that ChatGPT-account Codex supports — e.g. `gpt-5.3-codex` (live-verified), +> or another Pi codex id like `gpt-5.2`, `gpt-5.4`, `gpt-5.4-mini`. The non-subscription id +> `gpt-5-codex` is **not** accepted on a ChatGPT account (the backend returns a 400 +> "model is not supported when using Codex with a ChatGPT account"). + +Then log in once (the credential is stored under the `chatgpt` provider instance): + +```bash +paseo login chatgpt +# Opens your browser to approve (OAuth PKCE + local callback on 127.0.0.1:1455). +# If the browser can't open, the URL is printed to copy; you can also paste the code. +``` + +Headless machines (no browser) can use the device-code fallback: + +```bash +paseo login chatgpt --device-code # prints a URL + code to enter on another device +``` + +Both store the credential at `$PASEO_HOME/paseo-agent/auth.json` (mode `0600`, created by +Pi's AuthStorage; pass `--home` to target a specific Paseo home). On each session the +provider loads that credential, and Pi refreshes expired access tokens — persisting any +rotated refresh token **back into Paseo's own file**. A codex provider counts as +"available" once a credential is stored. Token values are never logged or printed. + +> **Rotation is handled within Paseo.** Because Paseo owns the store and Pi writes +> refreshed tokens back to it, rotation does not break Paseo. Run `paseo login chatgpt` +> again any time to re-authorize (e.g. after a long idle period or an explicit logout). + +**Advanced / manual override (not the normal path).** If you already hold your own refresh +token you may set `options.refreshToken` to a literal, `"$ENV"`/`"${ENV}"`, or `"!command"`. +It is seeded into the Paseo store at session start. This is for power users/automation; the +normal path is `paseo login chatgpt`. Paseo still never reads another tool's auth files. + +Other OAuth providers (Anthropic Pro/Max, Copilot) remain unwired; for those you can pass a +pre-obtained bearer token via `apiKey`/env where accepted (e.g. `ANTHROPIC_OAUTH_TOKEN`). diff --git a/package-lock.json b/package-lock.json index e28e0925c3..7cee03e4a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -258,6 +258,455 @@ } } }, + "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.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.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.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", + "integrity": "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.30", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.48.tgz", + "integrity": "sha512-h6FEC95fbexUd6zxm4PdgS82bTcI2PRtUb2ZwMipb/Xr8bPwtf0G8rBo2jp7NA24Mbx2JA8/WingiYpA9RCCyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.50.tgz", + "integrity": "sha512-lJO3OLpjvz5m/RSBQmsG/CEUGsvCy5ruxKwPQaOCqxqCMuyYT2BZwQUTDZVVwqQ9LrZKuK24JSa6r31hL/tvkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", + "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.55.tgz", + "integrity": "sha512-TBoF4buBGYhXjdZAryayY2TrkQj2B2KfE/msG4V53XCt+w0EhEwM2JRjx8p2grJ2C6gtH5++SAwEvGMRdi0yyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-login": "^3.972.54", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.54.tgz", + "integrity": "sha512-hBWI3wZTdTGiuMfmPts6AWbAjFfRniOQnqx68tc2cQvRKWawFbN9wkLOVPWM1FAOyowZU73mC6Fi+rHSHNyLFw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.57.tgz", + "integrity": "sha512-u6dClpzNdWf1HGWz4wwhdXi1wiOofCLniM9S4BQQGlLAN9TW7VB+ld5V533GdKrYMaFeBGFqKnj0JCYvynLqwQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-ini": "^3.972.55", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.48.tgz", + "integrity": "sha512-w6VZwojPt12WnEkAUy6Nu4K6sWCbBmR7QX390b0nE6vRvkXbrYr9Lq9VySGkfjiMjpUA87op+J4EgvRmtWIDoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.54.tgz", + "integrity": "sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/token-providers": "3.1071.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1071.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1071.0.tgz", + "integrity": "sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.54.tgz", + "integrity": "sha512-0Iv5QttS6wcATlodYKgvQj6B9Db51rx7NU9fqu0PoLeS4BIgdYMc/QK4smwLwpm5RFrs02V/eLyEFp3FklvlNQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.22.tgz", + "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.18.tgz", + "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.30.tgz", + "integrity": "sha512-kH6N4f/Fzi9r/dYap8EQ+Zk4NOz8pl4AtWKhzAoG2C1/4YkIHok9APp/e+75woreWQq264n+LkrJsJVZ0Q+M1Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.22.tgz", + "integrity": "sha512-4IwtcYSxEIVw5hcp8ogq0CMbFNZFw7jJUetpfFUhFFeqsa1K8j2Ihg2hnxLyOp3stMZnXda6VzOmPi1AFZQXcg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", + "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@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.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.30.tgz", + "integrity": "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "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", @@ -2499,333 +2948,2220 @@ "sisteransi": "^1.0.5" } }, - "node_modules/@clack/prompts": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.1.0.tgz", - "integrity": "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==", + "node_modules/@clack/prompts": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.1.0.tgz", + "integrity": "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.1.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vite-plugin": { + "version": "1.36.3", + "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.36.3.tgz", + "integrity": "sha512-n3SZhEZQxk4B2xHaCwgXfc7oLkx/4leTxL254P09DK2Qoj1VVOqVELo62CsUYq5dZS+okMdeWqNa13xEOKMs4g==", + "license": "MIT", + "dependencies": { + "@cloudflare/unenv-preset": "2.16.1", + "miniflare": "4.20260507.1", + "unenv": "2.0.0-rc.24", + "wrangler": "4.90.0", + "ws": "8.18.0" + }, + "peerDependencies": { + "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", + "wrangler": "^4.90.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260507.1.tgz", + "integrity": "sha512-S85aMwcaPJUjKWDiG6iMMnioKWtPLACa6m0j/EhHR1GYfVpnxb974cBc6d25L+sf7jHWHJI2u5hGp0UTJ7MtXQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260507.1.tgz", + "integrity": "sha512-GMEBu8Zp9Q97HLnf7bWJN4KjWpN5MxpeqdvHjBGWNl8UYprJI0k+Jkp89+Wh5S8vIon+HoVbDfOzPa7VwgL6Eg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260507.1.tgz", + "integrity": "sha512-QlrKEBdgA3uVc0Ok0Q3+0/CW0CTjgj5ySir1i1YY5FXVv0X6GpwtnB5umjunjF2MFprss+L+iFGZzxcSvMC1nA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260507.1.tgz", + "integrity": "sha512-eGbbupEtK2nh9V9Dhcx3vv3GTKeXqSVNgAEYVCCN0NGS9tl9HbMoHRX/4JL181FKXROMigWBCQVL//qPhsAzBQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260507.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260507.1.tgz", + "integrity": "sha512-dmClJ/E0BAcuDetQIZFqbeAXejWrG5pysGRMQ6T83Y0IW/7IAamY2zFEkAJ10I5xwZsdHuYsZtzlOxpEXpJs7A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260509.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260509.1.tgz", + "integrity": "sha512-jFlTTD+0MK/01TdL5sHIsQ8RqzfmvBsGl4hSp87INv2+JIs/JF6EL9J8enuCz6z3fNdfOKISNbGCIrzZRXVrcw==", + "license": "MIT OR Apache-2.0" + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@develar/schema-utils/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@develar/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/@develar/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.77.0.tgz", + "integrity": "sha512-l/mYLJPjpgiPDmcfnFIGdOBqICkWaf8IawCdAbae5guBrPXg+Z0o84l9OuHyRJPOb8RfedeGg8DtSnq8t7grOg==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.77.0", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "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.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", + "integrity": "sha512-H21BrQDPf3ydaeBmS5maNDHxUGFMiKBF/n3WnE+OTWloIZSayeL+/NVEgG3aKQw8fZL6HAMYAGpUIVJgFuKtnw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/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/@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-coding-agent": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.77.0.tgz", + "integrity": "sha512-huS+k+dhQRR9PlTK7crLfeSRUw3a96V6JYfP0ZH3Zkko/m10gsYk8dKQmwScSy5Dll516pXorz19BURfD6S2qQ==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.77.0", + "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-tui": "^0.77.0", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.77.0.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.77.0", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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-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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@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/buffer-equal-constant-time": { + "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==", + "license": "BSD-3-Clause" + }, + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@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/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/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/@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/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-coding-agent/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-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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@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/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/@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": { - "@clack/core": "1.1.0", - "sisteransi": "^1.0.5" + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", - "license": "MIT OR Apache-2.0", + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=22.0.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" } }, - "node_modules/@cloudflare/vite-plugin": { - "version": "1.36.3", - "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.36.3.tgz", - "integrity": "sha512-n3SZhEZQxk4B2xHaCwgXfc7oLkx/4leTxL254P09DK2Qoj1VVOqVELo62CsUYq5dZS+okMdeWqNa13xEOKMs4g==", + "node_modules/@earendil-works/pi-coding-agent/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": { - "@cloudflare/unenv-preset": "2.16.1", - "miniflare": "4.20260507.1", - "unenv": "2.0.0-rc.24", - "wrangler": "4.90.0", - "ws": "8.18.0" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, - "peerDependencies": { - "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", - "wrangler": "^4.90.0" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, - "node_modules/@cloudflare/vite-plugin/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" + "node_modules/@earendil-works/pi-coding-agent/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": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { - "bufferutil": { + "ws": { "optional": true }, - "utf-8-validate": { + "zod": { "optional": true } } }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260507.1.tgz", - "integrity": "sha512-S85aMwcaPJUjKWDiG6iMMnioKWtPLACa6m0j/EhHR1GYfVpnxb974cBc6d25L+sf7jHWHJI2u5hGp0UTJ7MtXQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260507.1.tgz", - "integrity": "sha512-GMEBu8Zp9Q97HLnf7bWJN4KjWpN5MxpeqdvHjBGWNl8UYprJI0k+Jkp89+Wh5S8vIon+HoVbDfOzPa7VwgL6Eg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@earendil-works/pi-coding-agent/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": ">=16" + "node": ">=8" } }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260507.1.tgz", - "integrity": "sha512-QlrKEBdgA3uVc0Ok0Q3+0/CW0CTjgj5ySir1i1YY5FXVv0X6GpwtnB5umjunjF2MFprss+L+iFGZzxcSvMC1nA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/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/@cloudflare/workerd-linux-arm64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260507.1.tgz", - "integrity": "sha512-eGbbupEtK2nh9V9Dhcx3vv3GTKeXqSVNgAEYVCCN0NGS9tl9HbMoHRX/4JL181FKXROMigWBCQVL//qPhsAzBQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } + "node_modules/@earendil-works/pi-coding-agent/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/@cloudflare/workerd-windows-64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260507.1.tgz", - "integrity": "sha512-dmClJ/E0BAcuDetQIZFqbeAXejWrG5pysGRMQ6T83Y0IW/7IAamY2zFEkAJ10I5xwZsdHuYsZtzlOxpEXpJs7A==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" + "node_modules/@earendil-works/pi-coding-agent/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": ">=16" + "node": ">=14.0.0" } }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20260509.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260509.1.tgz", - "integrity": "sha512-jFlTTD+0MK/01TdL5sHIsQ8RqzfmvBsGl4hSp87INv2+JIs/JF6EL9J8enuCz6z3fNdfOKISNbGCIrzZRXVrcw==", - "license": "MIT OR Apache-2.0" - }, - "node_modules/@codemirror/language": { - "version": "6.12.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", - "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.5.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0", - "style-mod": "^4.0.0" + "engines": { + "node": ">=8" } }, - "node_modules/@codemirror/legacy-modes": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", - "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", - "license": "MIT", + "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": { - "@codemirror/language": "^6.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@codemirror/state": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", - "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", "license": "MIT", "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" } }, - "node_modules/@codemirror/view": { - "version": "6.43.0", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", - "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.6.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" + "engines": { + "node": ">= 4" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "license": "MIT", + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "@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.0.0" }, "engines": { - "node": ">=12" + "node": ">=12.0.0" } }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "engines": { + "node": ">= 4" } }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", - "dev": true, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">=8" } }, - "node_modules/@develar/schema-utils/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=8" } }, - "node_modules/@develar/schema-utils/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/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/@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", - "peerDependencies": { - "ajv": "^6.9.1" + "engines": { + "node": ">=22.19.0" } }, - "node_modules/@develar/schema-utils/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, - "node_modules/@dnd-kit/accessibility": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", - "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", "dependencies": { - "tslib": "^2.0.0" + "isexe": "^2.0.0" }, - "peerDependencies": { - "react": ">=16.8.0" + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/@dnd-kit/core": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", - "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", - "dependencies": { - "@dnd-kit/accessibility": "^3.1.1", - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" + "engines": { + "node": ">=10.0.0" }, "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, - "node_modules/@dnd-kit/sortable": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", - "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "node_modules/@earendil-works/pi-coding-agent/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", - "dependencies": { - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" }, - "peerDependencies": { - "@dnd-kit/core": "^6.3.0", - "react": ">=16.8.0" + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", - "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", "peerDependencies": { - "react": ">=16.8.0" + "zod": "^3.25.28 || ^4" } }, "node_modules/@egjs/hammerjs": { @@ -6348,6 +8684,30 @@ "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", @@ -7818,6 +10178,17 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "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/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -8229,6 +10600,18 @@ "license": "MIT", "optional": true }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "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", @@ -9699,6 +12082,63 @@ "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.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "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/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", @@ -11733,6 +14173,126 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/core": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.1.tgz", + "integrity": "sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.1.tgz", + "integrity": "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.1.tgz", + "integrity": "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "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.5.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.1.tgz", + "integrity": "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "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", @@ -12694,6 +15254,12 @@ "@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/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -14147,6 +16713,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/app-builder-bin": { "version": "5.0.0-alpha.12", "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", @@ -15173,6 +17751,15 @@ "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", @@ -15269,6 +17856,12 @@ "license": "MIT", "optional": true }, + "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", @@ -15404,7 +17997,6 @@ "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": { @@ -16661,6 +19253,15 @@ "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", @@ -17682,7 +20283,6 @@ "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" @@ -21221,6 +23821,43 @@ ], "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", @@ -21307,6 +23944,29 @@ } } }, + "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-retry": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-4.1.1.tgz", @@ -21613,6 +24273,18 @@ "node": ">=18.3.0" } }, + "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", @@ -21774,6 +24446,74 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "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", @@ -22111,6 +24851,53 @@ "integrity": "sha512-YSwLaGMOgSBx9roJlNLL12c+FRiw7VECphinc6mGucphc/ZxTHgdEz6gmJqH6NOzYEd/yr64hwjom5pZ+tJVpg==", "dev": true }, + "node_modules/google-auth-library": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", + "integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", + "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", @@ -24384,6 +27171,15 @@ "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", @@ -25266,6 +28062,12 @@ "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", @@ -27491,6 +30293,26 @@ "semver": "^7.3.5" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -28087,6 +30909,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/opentype.js": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.8.0.tgz", @@ -28385,6 +31228,28 @@ "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", @@ -28525,6 +31390,12 @@ "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", @@ -28575,6 +31446,21 @@ "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", @@ -29187,6 +32073,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "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.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.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", @@ -32468,6 +35377,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", @@ -33447,6 +36371,12 @@ "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", @@ -34542,6 +37472,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -34963,6 +37902,21 @@ "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", @@ -35051,9 +38005,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -37436,6 +40390,9 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.181", "@anthropic-ai/sdk": "^0.104.2", + "@earendil-works/pi-agent-core": "0.77.0", + "@earendil-works/pi-ai": "0.77.0", + "@earendil-works/pi-coding-agent": "0.77.0", "@getpaseo/client": "0.1.97", "@getpaseo/highlight": "0.1.97", "@getpaseo/protocol": "0.1.97", diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index 80727b59c7..1c97bb2a4b 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -12,6 +12,7 @@ describe("resolveProviderIconName", () => { expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" }); expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" }); expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" }); + expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" }); }); it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 443abb50e1..862e04b920 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -6,6 +6,7 @@ import { CodexIcon } from "@/components/icons/codex-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { OmpIcon } from "@/components/icons/omp-icon"; +import { PaseoLogo } from "@/components/icons/paseo-logo"; import { PiIcon } from "@/components/icons/pi-icon"; import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog"; import { resolveProviderIconName } from "@/components/provider-icon-name"; @@ -24,6 +25,7 @@ const BUILTIN_PROVIDER_ICONS: Record = { kiro: PackagePlus, omp: OmpIcon as unknown as ProviderIconComponent, opencode: OpenCodeIcon as unknown as ProviderIconComponent, + paseo: PaseoLogo as unknown as ProviderIconComponent, pi: PiIcon as unknown as ProviderIconComponent, }; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f89004dd51..32f868e03d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,6 +2,7 @@ import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; import { createChatCommand } from "./commands/chat/index.js"; +import { createLoginCommand } from "./commands/login/index.js"; import { createLoopCommand } from "./commands/loop/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; @@ -161,6 +162,9 @@ export function createCli(): Command { // Permission commands program.addCommand(createPermitCommand()); + // Auth / login commands + program.addCommand(createLoginCommand()); + // Provider commands program.addCommand(createProviderCommand()); diff --git a/packages/cli/src/commands/login/index.test.ts b/packages/cli/src/commands/login/index.test.ts new file mode 100644 index 0000000000..cc01063a15 --- /dev/null +++ b/packages/cli/src/commands/login/index.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { createCli } from "../../cli.js"; +import { createLoginCommand } from "./index.js"; + +interface RecordedLogin { + providerInstance: string; + envHome: string | undefined; + mode: "browser" | "device"; +} + +describe("paseo login command", () => { + it("is registered on the top-level CLI", () => { + const program = createCli(); + const login = program.commands.find((command) => command.name() === "login"); + expect(login).toBeDefined(); + }); + + it("exposes a `chatgpt` subcommand with browser default + headless flag", () => { + const login = createLoginCommand(); + const chatgpt = login.commands.find((command) => command.name() === "chatgpt"); + expect(chatgpt).toBeDefined(); + + const flags = chatgpt?.options.map((option) => option.long); + // Default flow is browser; device-code is an opt-in fallback. + expect(flags).toContain("--device-code"); + expect(flags).toContain("--home"); + // It must not require a copy/paste device flow by default. + expect(chatgpt?.description().toLowerCase()).toContain("chatgpt"); + }); + + it("runs browser login by default and opens the Pi auth URL", async () => { + const recorded: RecordedLogin[] = []; + const openedUrls: string[] = []; + const output: string[] = []; + + const login = createLoginCommand({ + write: (message) => output.push(message), + writeError: (message) => output.push(message), + openBrowser: (url) => { + openedUrls.push(url); + return true; + }, + promptForCode: async () => { + throw new Error("manual code prompt should not be used for successful browser login"); + }, + loginBrowser: async (options) => { + recorded.push({ + providerInstance: options.providerInstance, + envHome: options.env?.PASEO_HOME, + mode: "browser", + }); + options.onAuthUrl("https://auth.openai.com/oauth/authorize?client_id=paseo"); + options.onProgress?.("callback complete"); + return { path: "/tmp/paseo-home/paseo-agent/auth.json" }; + }, + loginDeviceCode: async () => { + throw new Error("device-code login should not be used by default"); + }, + }); + + await login.parseAsync(["node", "login", "chatgpt", "--home", "/tmp/paseo-home"]); + + expect(recorded).toEqual([ + { providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "browser" }, + ]); + expect(openedUrls).toEqual(["https://auth.openai.com/oauth/authorize?client_id=paseo"]); + expect(output.join("\n")).toContain("browser flow"); + expect(output.join("\n")).toContain("/tmp/paseo-home/paseo-agent/auth.json"); + }); + + it("uses device-code login only when explicitly requested", async () => { + const recorded: RecordedLogin[] = []; + const output: string[] = []; + + const login = createLoginCommand({ + write: (message) => output.push(message), + writeError: (message) => output.push(message), + openBrowser: () => { + throw new Error("browser opener should not run for --device-code"); + }, + promptForCode: async () => { + throw new Error("manual browser prompt should not run for --device-code"); + }, + loginBrowser: async () => { + throw new Error("browser login should not run for --device-code"); + }, + loginDeviceCode: async (options) => { + recorded.push({ + providerInstance: options.providerInstance, + envHome: options.env?.PASEO_HOME, + mode: "device", + }); + options.onDeviceCode({ + userCode: "ABCD-EFGH", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }); + return { path: "/tmp/paseo-home/paseo-agent/auth.json" }; + }, + }); + + await login.parseAsync([ + "node", + "login", + "chatgpt", + "--device-code", + "--home", + "/tmp/paseo-home", + ]); + + expect(recorded).toEqual([ + { providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "device" }, + ]); + expect(output.join("\n")).toContain("headless device-code flow"); + expect(output.join("\n")).toContain("ABCD-EFGH"); + }); +}); diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts new file mode 100644 index 0000000000..0a7fc6eef6 --- /dev/null +++ b/packages/cli/src/commands/login/index.ts @@ -0,0 +1,122 @@ +import { createInterface } from "node:readline/promises"; +import { Command } from "commander"; +import { + loginAndStoreCodex, + loginAndStoreCodexBrowser, + type CodexDeviceCodeInfo, +} from "@getpaseo/server"; + +import { openBrowserUrl } from "../../utils/open-browser.js"; + +// First-class auth UX: `paseo login chatgpt`. +// Default flow is browser OAuth (PKCE + local callback on 127.0.0.1:1455) via Pi's +// helper; `--device-code` is a headless fallback. Credentials are stored in the +// Paseo-owned store ($PASEO_HOME/paseo-agent/auth.json). No foreign auth files are read. + +const PROVIDER_INSTANCE = "chatgpt"; + +interface LoginChatgptOptions { + deviceCode?: boolean; + home?: string; +} + +interface LoginResult { + path: string; +} + +interface LoginCommandDependencies { + loginDeviceCode: typeof loginAndStoreCodex; + loginBrowser: typeof loginAndStoreCodexBrowser; + openBrowser: (url: string) => boolean; + promptForCode: (message: string) => Promise; + write: (message: string) => void; + writeError: (message: string) => void; +} + +const defaultDependencies: LoginCommandDependencies = { + loginDeviceCode: loginAndStoreCodex, + loginBrowser: loginAndStoreCodexBrowser, + openBrowser: openBrowserUrl, + promptForCode, + write: (message) => console.log(message), + writeError: (message) => console.error(message), +}; + +function resolveEnv(home: string | undefined): NodeJS.ProcessEnv { + return home ? { ...process.env, PASEO_HOME: home } : process.env; +} + +async function promptForCode(message: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return (await rl.question(`${message} `)).trim(); + } finally { + rl.close(); + } +} + +function printDeviceCode(write: (message: string) => void, info: CodexDeviceCodeInfo): void { + write("To authorize Paseo:"); + write(` 1. Open: ${info.verificationUri}`); + write(` 2. Enter code: ${info.userCode}`); + write(` (expires in ~${Math.round(info.expiresInSeconds / 60)} min — waiting...)\n`); +} + +async function runChatgptLogin( + options: LoginChatgptOptions, + dependencies: LoginCommandDependencies, +): Promise { + const env = resolveEnv(options.home); + const { write } = dependencies; + + if (options.deviceCode) { + write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n"); + const { path } = await dependencies.loginDeviceCode({ + providerInstance: PROVIDER_INSTANCE, + env, + onDeviceCode: (info) => printDeviceCode(write, info), + }); + write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`); + return { path }; + } + + write("Paseo login — ChatGPT/Codex subscription (browser flow)\n"); + const { path } = await dependencies.loginBrowser({ + providerInstance: PROVIDER_INSTANCE, + env, + onAuthUrl: (url) => { + const opened = dependencies.openBrowser(url); + write( + opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:", + ); + write(` ${url}\n`); + write("Waiting for you to approve in the browser…"); + write("(If the browser didn't open, copy the URL above. You can also paste the code here.)"); + }, + onProgress: (message) => write(message), + promptForCode: dependencies.promptForCode, + }); + write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`); + return { path }; +} + +export function createLoginCommand(dependencies: Partial = {}): Command { + const deps = { ...defaultDependencies, ...dependencies }; + const login = new Command("login").description("Authenticate Paseo providers"); + + login + .command("chatgpt") + .description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider") + .option("--device-code", "Use the headless device-code flow instead of the browser flow") + .option("--home ", "Paseo home directory (default: ~/.paseo or $PASEO_HOME)") + .action(async (options: LoginChatgptOptions) => { + try { + await runChatgptLogin(options, deps); + } catch (error) { + deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } + }); + + return login; +} diff --git a/packages/cli/src/utils/open-browser.ts b/packages/cli/src/utils/open-browser.ts new file mode 100644 index 0000000000..b0f2432a6b --- /dev/null +++ b/packages/cli/src/utils/open-browser.ts @@ -0,0 +1,30 @@ +import { spawn } from "node:child_process"; + +/** + * Best-effort cross-platform browser opener for CLI OAuth flows. Returns true if the + * opener process was spawned, false otherwise. Callers must always print the URL too, + * so a failed/headless open still lets the user copy it. + */ +function browserOpenCommand(url: string): { command: string; args: string[] } { + switch (process.platform) { + case "darwin": + return { command: "open", args: [url] }; + case "win32": + return { command: "cmd", args: ["/c", "start", "", url] }; + default: + return { command: "xdg-open", args: [url] }; + } +} + +export function openBrowserUrl(url: string): boolean { + const { command, args } = browserOpenCommand(url); + + try { + const child = spawn(command, args, { stdio: "ignore", detached: true }); + child.on("error", () => {}); + child.unref(); + return true; + } catch { + return false; + } +} diff --git a/packages/protocol/src/provider-config.ts b/packages/protocol/src/provider-config.ts index 2ab879c31f..989cc1af69 100644 --- a/packages/protocol/src/provider-config.ts +++ b/packages/protocol/src/provider-config.ts @@ -57,7 +57,15 @@ export const ProviderOverrideSchema = z.object({ order: z.number().optional(), }); -const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const; +const BUILTIN_PROVIDER_IDS = [ + "claude", + "codex", + "copilot", + "opencode", + "pi", + "omp", + "paseo", +] as const; const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/; export const ProviderOverridesSchema = z diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index 1e08198500..5a27715e09 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -5,6 +5,7 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [ "kiro", "omp", "opencode", + "paseo", "pi", ]; diff --git a/packages/protocol/src/provider-manifest.ts b/packages/protocol/src/provider-manifest.ts index 312e704559..50e6a4d48f 100644 --- a/packages/protocol/src/provider-manifest.ts +++ b/packages/protocol/src/provider-manifest.ts @@ -220,6 +220,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModeId: null, modes: [], }, + { + id: "paseo", + label: "Paseo Agent", + description: "Paseo's in-process agent harness with configurable inference providers", + defaultModeId: null, + modes: [], + }, ]; export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ diff --git a/packages/server/package.json b/packages/server/package.json index 9099c8ed2b..4035c7f4b3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -65,6 +65,9 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.181", "@anthropic-ai/sdk": "^0.104.2", + "@earendil-works/pi-agent-core": "0.77.0", + "@earendil-works/pi-ai": "0.77.0", + "@earendil-works/pi-coding-agent": "0.77.0", "@getpaseo/client": "0.1.97", "@getpaseo/highlight": "0.1.97", "@getpaseo/protocol": "0.1.97", diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 85f3ae5bc9..c7a6f933c0 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -34,6 +34,8 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; import { PiRpcAgentClient } from "./providers/pi/agent.js"; +import { PaseoAgentClient } from "./providers/paseo-agent/agent.js"; +import type { PaseoAgentConfig } from "./providers/paseo-agent/config.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { MockSlowProviderClient } from "./providers/mock-slow-provider.js"; import { @@ -72,11 +74,16 @@ export interface BuildProviderRegistryOptions { providerOverrides?: Record; workspaceGitService?: Pick; isDev?: boolean; + /** + * Opaque Paseo Agent config blob. The registry only forwards it to the + * paseo-agent client factory; it never reads the nested inference providers. + */ + paseoAgentConfig?: PaseoAgentConfig; } interface ProviderClientFactoryOptions extends Pick< BuildProviderRegistryOptions, - "workspaceGitService" + "workspaceGitService" | "paseoAgentConfig" > { providerParams?: unknown; customProvider?: { @@ -149,6 +156,11 @@ const PROVIDER_CLIENT_FACTORIES: Record = { sessionDir: "~/.omp/agent/sessions", }, }), + paseo: (logger, _runtimeSettings, options) => + new PaseoAgentClient({ + logger, + config: options?.paseoAgentConfig ?? {}, + }), mock: (logger) => new MockLoadTestAgentClient(logger), "mock-slow": () => new MockSlowProviderClient(), }; @@ -528,7 +540,7 @@ function createResolvedProviderClient( function buildResolvedBuiltinProviders( providerOverrides: Record, runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, - options: Pick, + options: Pick, isDev: boolean, ): Map { const resolvedProviders = new Map(); @@ -558,6 +570,7 @@ function buildResolvedBuiltinProviders( factory(logger, mergedRuntimeSettings, { workspaceGitService: options.workspaceGitService, providerParams: override?.params, + paseoAgentConfig: options.paseoAgentConfig, }), }); } @@ -675,6 +688,7 @@ export function buildProviderRegistry( runtimeSettings, { workspaceGitService: options?.workspaceGitService, + paseoAgentConfig: options?.paseoAgentConfig, }, options?.isDev === true, ); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 4ee09301cb..954d4f0fe3 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -340,12 +340,13 @@ describe("ProviderSnapshotManager public surface", () => { copilot: { enabled: false }, opencode: { enabled: false }, pi: { enabled: false }, + paseo: { enabled: false }, }, }); try { const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true }); const providers = entries.map((entry) => entry.provider).sort(); - expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "pi"]); + expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "paseo", "pi"]); for (const entry of entries) { expect(entry.enabled).toBe(false); expect(entry.status).toBe("unavailable"); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index b273f38d99..a3273e65b1 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -25,6 +25,7 @@ import { shutdownAgentClients, type ProviderDefinition, } from "./provider-registry.js"; +import type { PaseoAgentConfig } from "./providers/paseo-agent/config.js"; import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js"; import type { MutableDaemonConfig } from "../daemon-config-store.js"; @@ -59,6 +60,7 @@ export interface ProviderSnapshotManagerOptions { isDev?: boolean; extraClients?: Partial>; refreshTimeoutMs?: number; + paseoAgentConfig?: PaseoAgentConfig; } interface ProviderSnapshotRefreshOptions { @@ -132,6 +134,7 @@ export class ProviderSnapshotManager { private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private readonly baseProviderOverrides: Record | undefined; + private readonly paseoAgentConfig: PaseoAgentConfig | undefined; private providerRegistry: Record; private providerClients: Record; @@ -143,6 +146,7 @@ export class ProviderSnapshotManager { this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; + this.paseoAgentConfig = options.paseoAgentConfig; this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs); this.providerRegistry = this.buildRegistry(); this.providerClients = { ...this.extraClients } as Record; @@ -371,6 +375,7 @@ export class ProviderSnapshotManager { providerOverrides: this.providerOverrides, workspaceGitService: this.workspaceGitService, isDev: this.isDev, + paseoAgentConfig: this.paseoAgentConfig, }); for (const [provider, client] of Object.entries(this.extraClients) as Array< diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts new file mode 100644 index 0000000000..55358deb1f --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentSessionConfig } from "../../agent-sdk-types.js"; +import { PaseoAgentClient } from "./agent.js"; +import { PaseoAgentConfigSchema, type PaseoAgentConfig } from "./config.js"; + +function makeConfig(): PaseoAgentConfig { + return PaseoAgentConfigSchema.parse({ + defaultModel: "openrouter-main/test-model", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "test-model", label: "Test Model" }], + }, + }, + }, + }); +} + +function sessionConfig(overrides?: Partial): AgentSessionConfig { + return { provider: "paseo", cwd: process.cwd(), ...overrides }; +} + +describe("PaseoAgentClient", () => { + it("is available only when config has a usable inference provider", async () => { + const withConfig = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); + expect(await withConfig.isAvailable()).toBe(true); + + const empty = new PaseoAgentClient({ + logger: createTestLogger(), + config: PaseoAgentConfigSchema.parse({}), + }); + expect(await empty.isAvailable()).toBe(false); + }); + + it("lists only configured models, never Pi disk/default models", async () => { + const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); + const models = await client.listModels({ cwd: process.cwd(), force: false }); + expect(models.map((m) => m.id)).toEqual(["openrouter-main/test-model"]); + expect(models[0]?.isDefault).toBe(true); + }); + + it("throws when creating a session with no configured providers", async () => { + const client = new PaseoAgentClient({ + logger: createTestLogger(), + config: PaseoAgentConfigSchema.parse({}), + }); + await expect(client.createSession(sessionConfig())).rejects.toThrow(/no configured/i); + }); + + it("creates an in-process session bound to the configured model", async () => { + const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); + const session = await client.createSession(sessionConfig()); + try { + expect(session.provider).toBe("paseo"); + const info = await session.getRuntimeInfo(); + expect(info.model).toBe("openrouter-main/test-model"); + // In-memory prototype: no durable persistence handle. + expect(session.describePersistence()).toBeNull(); + } finally { + await session.close(); + } + }); + + it("honors an explicitly requested model over the default", async () => { + const config = PaseoAgentConfigSchema.parse({ + defaultModel: "openrouter-main/a", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "a" }, { id: "b" }], + }, + }, + }, + }); + const client = new PaseoAgentClient({ logger: createTestLogger(), config }); + const session = await client.createSession(sessionConfig({ model: "openrouter-main/b" })); + try { + const info = await session.getRuntimeInfo(); + expect(info.model).toBe("openrouter-main/b"); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.ts new file mode 100644 index 0000000000..0a39ed779f --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -0,0 +1,560 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; + +import { + getAgentStreamEventTurnId, + type AgentCapabilityFlags, + type AgentClient, + type AgentLaunchContext, + 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 ListModelsOptions, +} from "../../agent-sdk-types.js"; +import { + PASEO_AGENT_PROVIDER, + type PaseoAgentConfig, + listPaseoAgentModels, + paseoAgentHasUsableModel, + paseoAgentInferenceProviders, + parsePaseoAgentModelId, + resolvePaseoAgentModel, +} from "./config.js"; +import { + convertPromptInput, + getUserMessageText, + mapToolDetail, + parseToolArgs, + parseToolResult, + toAgentUsage, + type PiTrackedToolCall, +} from "./event-mapping.js"; +import { createMcpToolBridge, type McpToolBridge } from "./mcp-bridge.js"; +import { createPaseoAgentAuthStorage, hasStoredOAuthCredential } from "./oauth-store.js"; +import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js"; + +const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; + +const PASEO_AGENT_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + // Phase 4 uses in-memory sessions; resume/persistence is out of scope. + supportsSessionPersistence: false, + supportsDynamicModes: false, + // MCP servers from AgentSessionConfig.mcpServers are bridged to Pi custom tools. + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const THINKING_LEVELS: ReadonlySet = new Set([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", +]); + +function normalizeThinkingLevel(value: string | null | undefined): ThinkingLevel | null { + return value && THINKING_LEVELS.has(value as ThinkingLevel) ? (value as ThinkingLevel) : null; +} + +function resolveIsolatedAgentDir(): string { + // Paseo-owned, never ~/.pi. Inert because all Pi services are in-memory, but we + // still keep it off the project tree and outside Pi's default global config. + const base = process.env.PASEO_HOME ?? join(tmpdir(), "paseo-agent"); + return join(base, "pi-harness"); +} + +interface PaseoAgentClientOptions { + logger: Logger; + config: PaseoAgentConfig; +} + +export class PaseoAgentSession implements AgentSession { + readonly provider = PASEO_AGENT_PROVIDER; + readonly capabilities = PASEO_AGENT_CAPABILITIES; + + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private readonly activeToolCalls = new Map(); + private activeTurnId: string | null = null; + private lastThinkingOptionId: string | null; + + constructor( + private readonly handle: PaseoAgentSessionHandle, + private readonly config: AgentSessionConfig, + private readonly mcpBridge: McpToolBridge, + ) { + this.lastThinkingOptionId = + normalizeThinkingLevel(config.thinkingOptionId) ?? this.piSession.thinkingLevel ?? null; + this.piSession.subscribe((event) => this.handleSessionEvent(event)); + } + + private get piSession() { + return this.handle.session; + } + + get id(): string | null { + return this.piSession.sessionId; + } + + private emit(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } + + private emitToolCall( + toolCallId: string, + toolCall: PiTrackedToolCall, + status: "running" | "completed" | "failed", + result: ReturnType, + error: unknown, + ): void { + const turnId = this.activeTurnId ?? undefined; + const baseItem = { + type: "tool_call" as const, + callId: toolCallId, + name: toolCall.toolName, + detail: mapToolDetail(toolCall, result), + }; + const item = + status === "failed" ? { ...baseItem, status, error } : { ...baseItem, status, error: null }; + this.emit({ type: "timeline", provider: PASEO_AGENT_PROVIDER, turnId, item }); + } + + private handleSessionEvent(event: AgentSessionEvent): void { + const turnId = this.activeTurnId ?? undefined; + switch (event.type) { + case "agent_start": + this.emit({ + type: "thread_started", + provider: PASEO_AGENT_PROVIDER, + sessionId: this.piSession.sessionId, + }); + return; + case "turn_start": + this.emit({ type: "turn_started", provider: PASEO_AGENT_PROVIDER, turnId }); + return; + case "message_update": { + if (event.message.role !== "assistant") { + return; + } + if (event.assistantMessageEvent.type === "text_delta") { + this.emit({ + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + turnId, + item: { type: "assistant_message", text: event.assistantMessageEvent.delta ?? "" }, + }); + return; + } + if (event.assistantMessageEvent.type === "thinking_delta") { + this.emit({ + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + turnId, + item: { type: "reasoning", text: event.assistantMessageEvent.delta ?? "" }, + }); + } + return; + } + case "tool_execution_start": { + const toolCall = parseToolArgs(event.toolName, event.args); + this.activeToolCalls.set(event.toolCallId, toolCall); + this.emitToolCall(event.toolCallId, toolCall, "running", null, null); + return; + } + case "tool_execution_update": { + const toolCall = this.activeToolCalls.get(event.toolCallId); + if (!toolCall) { + return; + } + this.emitToolCall( + event.toolCallId, + toolCall, + "running", + parseToolResult(event.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 status = event.isError ? "failed" : "completed"; + this.emitToolCall( + event.toolCallId, + toolCall, + status, + result, + event.isError ? event.result : null, + ); + return; + } + case "agent_end": { + const usage = toAgentUsage(this.piSession.getSessionStats()); + const currentTurnId = turnId; + this.activeTurnId = null; + const errorMessage = this.piSession.agent.state.errorMessage; + if (errorMessage) { + this.emit({ + type: "turn_failed", + provider: PASEO_AGENT_PROVIDER, + turnId: currentTurnId, + error: errorMessage, + }); + return; + } + this.emit({ + type: "turn_completed", + provider: PASEO_AGENT_PROVIDER, + turnId: currentTurnId, + usage, + }); + 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.piSession.sessionId, finalText, usage, timeline }; + } + + async startTurn( + prompt: AgentPromptInput, + _options?: AgentRunOptions, + ): Promise<{ turnId: string }> { + if (this.activeTurnId) { + throw new Error("A Paseo Agent turn is already active"); + } + const payload = convertPromptInput(prompt); + const turnId = randomUUID(); + this.activeTurnId = turnId; + + void this.piSession + .prompt(payload.text, payload.images ? { images: payload.images } : undefined) + .catch((error: unknown) => { + const failedTurnId = this.activeTurnId ?? turnId; + this.activeTurnId = null; + this.emit({ + type: "turn_failed", + provider: PASEO_AGENT_PROVIDER, + turnId: failedTurnId, + error: error instanceof Error ? error.message : String(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.piSession.messages) { + if (message.role === "user") { + const text = getUserMessageText(message.content); + if (text) { + yield { + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + item: { type: "user_message", text, messageId: `paseo-agent-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: PASEO_AGENT_PROVIDER, + item: { type: "assistant_message", text: content.text }, + }; + continue; + } + if (content.type === "thinking" && content.thinking) { + yield { + type: "timeline", + provider: PASEO_AGENT_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: PASEO_AGENT_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 detail = mapToolDetail(tracked, parseToolResult({ content: message.content })); + const base = { + type: "tool_call" as const, + callId: message.toolCallId, + name: tracked.toolName, + detail, + }; + yield { + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + item: message.isError + ? { ...base, status: "failed", error: "Tool call failed" } + : { ...base, status: "completed", error: null }, + }; + } + } + } + + async getRuntimeInfo(): Promise { + const model = this.piSession.model; + return { + provider: PASEO_AGENT_PROVIDER, + sessionId: this.piSession.sessionId, + model: model ? `${model.provider}/${model.id}` : null, + thinkingOptionId: + normalizeThinkingLevel(this.lastThinkingOptionId) ?? this.piSession.thinkingLevel ?? null, + modeId: null, + }; + } + + async getAvailableModes(): Promise { + return []; + } + + async getCurrentMode(): Promise { + return null; + } + + async setMode(_modeId: string): Promise { + throw new Error("Paseo Agent does not expose selectable modes"); + } + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission( + _requestId: string, + _response: AgentPermissionResponse, + ): Promise {} + + describePersistence(): AgentPersistenceHandle | null { + return null; + } + + async interrupt(): Promise { + await this.piSession.abort(); + } + + async close(): Promise { + this.piSession.dispose(); + await this.mcpBridge.close(); + } + + async listCommands(): Promise { + return []; + } + + async setModel(modelId: string | null): Promise { + if (!modelId) { + return; + } + const reference = parsePaseoAgentModelId(modelId); + if (!reference) { + throw new Error(`Invalid Paseo Agent model: ${modelId}`); + } + const model = this.handle.modelRegistry.find(reference.provider, reference.id); + if (!model) { + throw new Error(`Unknown Paseo Agent model: ${modelId}`); + } + await this.piSession.setModel(model); + this.config.model = modelId; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + const level = normalizeThinkingLevel(thinkingOptionId) ?? DEFAULT_THINKING_LEVEL; + this.piSession.setThinkingLevel(level); + this.lastThinkingOptionId = level; + this.config.thinkingOptionId = level; + } +} + +export class PaseoAgentClient implements AgentClient { + readonly provider = PASEO_AGENT_PROVIDER; + readonly capabilities = PASEO_AGENT_CAPABILITIES; + + private readonly logger: Logger; + private readonly config: PaseoAgentConfig; + + constructor(options: PaseoAgentClientOptions) { + this.logger = options.logger; + this.config = options.config; + } + + async createSession( + config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { + const inferenceProviders = paseoAgentInferenceProviders(this.config); + if (inferenceProviders.length === 0) { + throw new Error( + "Paseo Agent has no configured inference providers. Add agents.paseo.providers to your Paseo config.", + ); + } + + const model = resolvePaseoAgentModel(this.config, config.model, inferenceProviders); + const thinkingLevel = normalizeThinkingLevel(config.thinkingOptionId) ?? undefined; + this.logger.debug( + { provider: PASEO_AGENT_PROVIDER, model: model ? `${model.provider}/${model.id}` : null }, + "Creating Paseo Agent session", + ); + + // OAuth providers (ChatGPT/Codex) use a Paseo-owned, file-backed AuthStorage so Pi + // reads the stored credential and persists refreshed tokens (rotation) back to it. + const usesOAuth = inferenceProviders.some((provider) => provider.oauth); + const authStorage = usesOAuth ? createPaseoAgentAuthStorage() : undefined; + + // Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools. + const mcpBridge = await createMcpToolBridge({ + mcpServers: config.mcpServers, + logger: this.logger, + }); + + try { + const handle = await createPaseoAgentSession({ + cwd: config.cwd, + agentDir: resolveIsolatedAgentDir(), + inferenceProviders, + ...(model ? { model } : {}), + ...(thinkingLevel ? { thinkingLevel } : {}), + ...(authStorage ? { authStorage } : {}), + ...(mcpBridge.tools.length > 0 ? { customTools: mcpBridge.tools } : {}), + }); + return new PaseoAgentSession(handle, config, mcpBridge); + } catch (error) { + await mcpBridge.close(); + throw error; + } + } + + async resumeSession(): Promise { + throw new Error("Paseo Agent does not support session resume in this prototype"); + } + + async listModels(_options: ListModelsOptions): Promise { + return listPaseoAgentModels(this.config); + } + + async isAvailable(): Promise { + return paseoAgentHasUsableModel(this.config, process.env, (providerInstance) => + hasStoredOAuthCredential(providerInstance), + ); + } +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts new file mode 100644 index 0000000000..6b580900a6 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; + +import { + PaseoAgentConfigSchema, + encodePaseoAgentModelId, + listPaseoAgentModels, + paseoAgentHasUsableModel, + paseoAgentInferenceProviders, + parsePaseoAgentModelId, + resolvePaseoAgentModel, + type PaseoAgentConfig, +} from "./config.js"; + +function configWith(overrides?: Partial): PaseoAgentConfig { + return PaseoAgentConfigSchema.parse({ + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-test", + models: [ + { id: "anthropic/claude", label: "Claude", reasoning: true }, + { id: "openai/gpt", reasoning: false }, + ], + }, + }, + }, + ...overrides, + }); +} + +describe("PaseoAgentConfigSchema", () => { + it("rejects unknown keys (strict)", () => { + expect(() => PaseoAgentConfigSchema.parse({ providers: {}, unexpected: true })).toThrow(); + }); + + it("rejects an unknown inference provider type", () => { + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { p: { type: "mystery", options: { models: [{ id: "m" }] } } }, + }), + ).toThrow(); + }); + + it("requires at least one model per inference provider", () => { + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { p: { type: "openrouter", options: { models: [] } } }, + }), + ).toThrow(); + }); + + it("requires baseUrl for openai-compatible and api for custom", () => { + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { p: { type: "openai-compatible", options: { models: [{ id: "m" }] } } }, + }), + ).toThrow(/baseUrl/); + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { + p: { type: "custom", options: { baseUrl: "https://x.test", models: [{ id: "m" }] } }, + }, + }), + ).toThrow(/api/); + }); + + it("accepts an empty config", () => { + expect(PaseoAgentConfigSchema.parse({})).toEqual({}); + }); + + it("accepts multiple entries of the same type with distinct names", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + "openai-a": { type: "openai", options: { apiKey: "sk-a", models: [{ id: "gpt-a" }] } }, + "openai-b": { + type: "openai", + options: { baseUrl: "https://proxy.test/v1", apiKey: "sk-b", models: [{ id: "gpt-b" }] }, + }, + }, + }); + expect(Object.keys(config.providers ?? {})).toEqual(["openai-a", "openai-b"]); + }); +}); + +describe("model id encoding", () => { + it("round-trips provider + model id", () => { + const id = encodePaseoAgentModelId("openrouter-main", "anthropic/claude"); + expect(parsePaseoAgentModelId(id)).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); + }); + + it("returns null for an unprefixed id", () => { + expect(parsePaseoAgentModelId("noslash")).toBeNull(); + }); +}); + +describe("listPaseoAgentModels", () => { + it("exposes every configured model with provider-prefixed ids", () => { + const models = listPaseoAgentModels(configWith()); + expect(models.map((m) => m.id)).toEqual([ + "openrouter-main/anthropic/claude", + "openrouter-main/openai/gpt", + ]); + expect(models.every((m) => m.provider === "paseo")).toBe(true); + }); + + it("marks the configured default model", () => { + const models = listPaseoAgentModels(configWith({ defaultModel: "openrouter-main/openai/gpt" })); + const defaults = models.filter((m) => m.isDefault).map((m) => m.id); + expect(defaults).toEqual(["openrouter-main/openai/gpt"]); + }); +}); + +describe("paseoAgentInferenceProviders (per-type defaults)", () => { + it("applies openrouter defaults (base url, api, model fields)", () => { + const [provider] = paseoAgentInferenceProviders(configWith()); + expect(provider.name).toBe("openrouter-main"); + expect(provider.config.baseUrl).toBe("https://openrouter.ai/api/v1"); + expect(provider.config.apiKey).toBe("sk-test"); + expect(provider.config.models?.[0]).toMatchObject({ + id: "anthropic/claude", + name: "Claude", + api: "openai-completions", + reasoning: true, + contextWindow: 128_000, + maxTokens: 16_384, + }); + }); + + it("falls back to the type's env var when no apiKey is given", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + anthropic: { type: "anthropic", options: { models: [{ id: "claude-x" }] } }, + }, + }); + const [provider] = paseoAgentInferenceProviders(config); + expect(provider.config.baseUrl).toBe("https://api.anthropic.com"); + expect(provider.config.apiKey).toBe("$ANTHROPIC_API_KEY"); + expect(provider.config.models?.[0]?.api).toBe("anthropic-messages"); + }); + + it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + zen: { + type: "openai-compatible", + options: { + baseUrl: "https://opencode.ai/zen/v1", + apiKey: "$OPENCODE_API_KEY", + models: [{ id: "big-pickle" }, { id: "claude-sonnet", api: "anthropic-messages" }], + }, + }, + }, + }); + const [provider] = paseoAgentInferenceProviders(config); + expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/v1"); + expect(provider.config.models?.[0]?.api).toBe("openai-completions"); + expect(provider.config.models?.[1]?.api).toBe("anthropic-messages"); + }); + + it("passes through the custom escape hatch (explicit api + authHeader)", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + vertex: { + type: "custom", + options: { + baseUrl: "https://my-gateway.test/v1", + api: "google-generative-ai", + apiKey: "sk-custom", + authHeader: true, + headers: { "x-extra": "1" }, + models: [{ id: "gemini" }], + }, + }, + }, + }); + const [provider] = paseoAgentInferenceProviders(config); + expect(provider.config.api).toBe("google-generative-ai"); + expect(provider.config.authHeader).toBe(true); + expect(provider.config.headers).toEqual({ "x-extra": "1" }); + expect(provider.config.models?.[0]?.api).toBe("google-generative-ai"); + }); +}); + +describe("paseoAgentHasUsableModel (env-aware auth)", () => { + it("is true for a literal api key", () => { + expect(paseoAgentHasUsableModel(configWith(), {})).toBe(true); + }); + + it("is false when no providers are configured", () => { + expect(paseoAgentHasUsableModel(PaseoAgentConfigSchema.parse({}), {})).toBe(false); + }); + + it("is false for an openai-compatible provider without any key", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + local: { + type: "openai-compatible", + options: { baseUrl: "https://local.test/v1", models: [{ id: "m" }] }, + }, + }, + }); + expect(paseoAgentHasUsableModel(config, {})).toBe(false); + }); + + it("follows the env var for an env-backed key", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { openrouter: { type: "openrouter", options: { models: [{ id: "m" }] } } }, + }); + expect(paseoAgentHasUsableModel(config, {})).toBe(false); + expect(paseoAgentHasUsableModel(config, { OPENROUTER_API_KEY: "sk-env" })).toBe(true); + }); +}); + +describe("resolvePaseoAgentModel", () => { + it("prefers the explicit request, then default, then first configured", () => { + const config = configWith({ defaultModel: "openrouter-main/openai/gpt" }); + expect(resolvePaseoAgentModel(config, "openrouter-main/anthropic/claude")).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); + expect(resolvePaseoAgentModel(config, null)).toEqual({ + provider: "openrouter-main", + id: "openai/gpt", + }); + expect(resolvePaseoAgentModel(configWith(), null)).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); + }); + + it("returns undefined when no providers are configured", () => { + expect(resolvePaseoAgentModel(PaseoAgentConfigSchema.parse({}), null)).toBeUndefined(); + }); + + it("ignores an implicit default whose provider is not registered", () => { + const config = configWith({ defaultModel: "ghost/model" }); + expect(resolvePaseoAgentModel(config, null)).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); + }); + + it("honors an explicit request even if its provider is not registered", () => { + expect(resolvePaseoAgentModel(configWith(), "ghost/model")).toEqual({ + provider: "ghost", + id: "model", + }); + }); +}); + +describe("openai-codex (ChatGPT subscription) provider", () => { + function codexConfig(options: Record): PaseoAgentConfig { + return PaseoAgentConfigSchema.parse({ + providers: { + chatgpt: { + type: "openai-codex", + options: { models: [{ id: "gpt-5.3-codex" }], ...options }, + }, + }, + }); + } + + it("accepts a codex provider with no credential field (login provides it)", () => { + const config = codexConfig({}); + expect(config.providers?.chatgpt?.type).toBe("openai-codex"); + }); + + it("rejects an unknown option like a foreign credentials file", () => { + expect(() => + PaseoAgentConfigSchema.parse({ + providers: { + chatgpt: { + type: "openai-codex", + options: { credentialsFile: "/Users/me/.codex/auth.json", models: [{ id: "x" }] }, + }, + }, + }), + ).toThrow(); + }); + + it("maps to a codex inference provider with an oauth marker and no api key", () => { + const [provider] = paseoAgentInferenceProviders(codexConfig({}), {}); + expect(provider.name).toBe("chatgpt"); + expect(provider.oauth).toEqual({ kind: "openai-codex" }); + expect(provider.config.apiKey).toBeUndefined(); + expect(provider.config.api).toBe("openai-codex-responses"); + expect(provider.config.baseUrl).toBe("https://chatgpt.com/backend-api"); + expect(provider.config.models?.[0]?.api).toBe("openai-codex-responses"); + }); + + it("carries an advanced self-supplied refresh token resolved from an env var", () => { + const config = codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" }); + const [provider] = paseoAgentInferenceProviders(config, { CODEX_REFRESH_TOKEN: "rt-env" }); + expect(provider.oauth).toEqual({ kind: "openai-codex", refreshToken: "rt-env" }); + }); + + it("availability uses the OAuth store predicate, or an advanced refresh token", () => { + // No stored credential and no advanced token → not available. + expect(paseoAgentHasUsableModel(codexConfig({}), {})).toBe(false); + // Stored credential (predicate true) → available. + expect(paseoAgentHasUsableModel(codexConfig({}), {}, () => true)).toBe(true); + // Advanced env-backed token → available without the store. + expect( + paseoAgentHasUsableModel(codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" }), { + CODEX_REFRESH_TOKEN: "rt-env", + }), + ).toBe(true); + }); + + it("lists codex models regardless of auth state", () => { + const models = listPaseoAgentModels(codexConfig({})); + expect(models.map((m) => m.id)).toEqual(["chatgpt/gpt-5.3-codex"]); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts new file mode 100644 index 0000000000..540f41dd36 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -0,0 +1,407 @@ +import { z } from "zod"; + +import type { AgentModelDefinition } from "../../agent-sdk-types.js"; +import { + isRefreshTokenExpressionConfigured, + resolveRefreshTokenExpression, +} from "./oauth-credentials.js"; +import type { PaseoAgentInferenceProvider, PaseoAgentModelReference } from "./pi-services.js"; + +export const PASEO_AGENT_PROVIDER = "paseo"; + +// Dedicated Paseo-owned config for the Paseo Agent provider. This is the single +// schema for `agents.paseo`; inference-provider fields are intentionally NOT +// merged into the shared strict ProviderOverrideSchema, and only this provider +// (via the helpers below) consumes it. This module imports no Pi runtime code so +// it stays cheap to load from persisted-config parsing. +// +// Inference providers are typed by `type`. Each known type carries sensible +// defaults (base URL, Pi wire `api`, and the env var its API key is read from), +// so a user only needs to give an `apiKey` (or the env var) and one or more model +// ids. `openai-compatible` covers any OpenAI Chat Completions endpoint (incl. +// OpenCode Zen/Go behind a custom base URL); `custom` is a thin escape hatch for +// directly choosing Pi's wire `api`. +// +// AUTH: API-key and env-var auth work for every type. ChatGPT/OpenAI subscription +// OAuth is supported via the `openai-codex` type. The product path is `paseo login +// chatgpt`, which runs Pi's browser PKCE/callback login by default and stores the +// credential in a Paseo-controlled file (see oauth-store.ts); the session loads it and +// Pi refreshes/persists rotation there. `options.refreshToken` is an advanced, manual +// escape hatch for users supplying their OWN token (literal/`$ENV`/`!cmd`) — it is not +// the normal path. Paseo never reads another tool's auth files. Other OAuth providers +// (e.g. Anthropic Pro/Max) remain unwired. + +const PROVIDER_TYPES = [ + "openrouter", + "openai", + "anthropic", + "opencode", + "openai-compatible", + "openai-codex", + "custom", +] as const; + +export type PaseoAgentProviderType = (typeof PROVIDER_TYPES)[number]; + +interface ProviderTypeDefault { + /** Pi wire protocol. `undefined` for `custom`, where the user must pick one. */ + api?: string; + /** Default base URL. `undefined` means the user must supply `options.baseUrl`. */ + baseUrl?: string; + /** Env var the API key is read from when `options.apiKey` is omitted. */ + envVar?: string; +} + +// Defaults mirror Pi's built-in provider definitions (packages/ai models). Pi adds +// its own attribution headers for openrouter/opencode based on the base URL, so we +// deliberately do not inject provider headers here. +const PROVIDER_TYPE_DEFAULTS: Record = { + openrouter: { + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + envVar: "OPENROUTER_API_KEY", + }, + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + envVar: "OPENAI_API_KEY", + }, + anthropic: { + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + envVar: "ANTHROPIC_API_KEY", + }, + opencode: { + // OpenCode Zen. Some Zen models speak anthropic-messages; override per model + // with `api` when needed. Go models live behind a custom base URL via + // `openai-compatible` (or set `options.baseUrl` to .../zen/go/v1 here). + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/v1", + envVar: "OPENCODE_API_KEY", + }, + "openai-compatible": { + api: "openai-completions", + baseUrl: undefined, + envVar: undefined, + }, + "openai-codex": { + // ChatGPT/OpenAI subscription via OAuth. Auth is a refresh token, not an API + // key, so there is no default env var here. + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + envVar: undefined, + }, + custom: { + api: undefined, + baseUrl: undefined, + envVar: undefined, + }, +}; + +const PaseoAgentModelSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1).optional(), + // Override the provider's wire api for this model (e.g. an anthropic-messages + // model served by an otherwise openai-completions provider like OpenCode Zen). + api: z.string().min(1).optional(), + reasoning: z.boolean().optional(), + contextWindow: z.number().int().positive().optional(), + maxTokens: z.number().int().positive().optional(), + }) + .strict(); + +const PaseoAgentProviderOptionsSchema = z + .object({ + // API key. Literal value, an env-var reference like `$OPENROUTER_API_KEY` / + // `${OPENROUTER_API_KEY}`, or a `!command` (resolved by Pi at request time). + // When omitted, known types fall back to their default env var. + apiKey: z.string().min(1).optional(), + baseUrl: z.string().url().optional(), + // Override the wire api. Required for `custom`; optional elsewhere. + api: z.string().min(1).optional(), + headers: z.record(z.string()).optional(), + // Advanced: send `Authorization: Bearer ` as a header. Only needed for + // endpoints whose wire api doesn't already attach the key. + authHeader: z.boolean().optional(), + // Advanced/manual ONLY: a self-supplied OAuth refresh token for `openai-codex` + // (literal, `$ENV`/`${ENV}`, or `!command`). The normal path is the Paseo-owned + // login (`paseo login chatgpt`), which stores the credential for you. + refreshToken: z.string().min(1).optional(), + models: z.array(PaseoAgentModelSchema).min(1), + }) + .strict(); + +const PaseoAgentInferenceProviderSchema = z + .object({ + type: z.enum(PROVIDER_TYPES), + options: PaseoAgentProviderOptionsSchema, + }) + .strict() + .superRefine((entry, ctx) => { + const defaults = PROVIDER_TYPE_DEFAULTS[entry.type]; + if (!defaults.baseUrl && !entry.options.baseUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["options", "baseUrl"], + message: `Inference provider type "${entry.type}" requires options.baseUrl.`, + }); + } + if (!defaults.api && !entry.options.api) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["options", "api"], + message: `Inference provider type "${entry.type}" requires options.api.`, + }); + } + // `openai-codex` needs no credential field here: auth comes from the Paseo-owned + // store populated by `paseo login chatgpt`. `options.refreshToken` is an + // optional advanced override. + }); + +export const PaseoAgentConfigSchema = z + .object({ + // Optional default model as "/". + defaultModel: z.string().min(1).optional(), + // Inference providers keyed by instance name. Multiple entries may share a + // type while pointing at different APIs/base URLs/models. + providers: z.record(PaseoAgentInferenceProviderSchema).optional(), + }) + .strict(); + +export type PaseoAgentConfig = z.infer; +type PaseoAgentInferenceProviderEntry = z.infer; + +const DEFAULT_CONTEXT_WINDOW = 128_000; +const DEFAULT_MAX_TOKENS = 16_384; + +interface ResolvedProviderSettings { + baseUrl?: string; + api?: string; + apiKey?: string; + headers?: Record; + authHeader?: boolean; +} + +function entries(config: PaseoAgentConfig): [string, PaseoAgentInferenceProviderEntry][] { + return Object.entries(config.providers ?? {}); +} + +/** Apply per-type defaults to a raw inference provider entry. */ +function resolveProviderSettings( + entry: PaseoAgentInferenceProviderEntry, +): ResolvedProviderSettings { + const defaults = PROVIDER_TYPE_DEFAULTS[entry.type]; + const apiKey = entry.options.apiKey ?? (defaults.envVar ? `$${defaults.envVar}` : undefined); + return { + baseUrl: entry.options.baseUrl ?? defaults.baseUrl, + api: entry.options.api ?? defaults.api, + apiKey, + headers: entry.options.headers, + authHeader: entry.options.authHeader, + }; +} + +const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; + +/** + * Whether a resolved API-key value is actually configured. Mirrors Pi's config-value + * semantics without importing Pi: literals and `!command` values count as present; + * `$ENV` / `${ENV}` references count only when every referenced var is set. + */ +function isAuthConfigured(value: string | undefined, env: NodeJS.ProcessEnv): boolean { + if (!value) { + return false; + } + if (value.startsWith("!")) { + return true; + } + const referencedVars = Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]); + if (referencedVars.length === 0) { + return true; + } + return referencedVars.every((name) => Boolean(env[name])); +} + +/** Encode the Paseo-facing model id from an inference provider + Pi model id. */ +export function encodePaseoAgentModelId(providerName: string, modelId: string): string { + return `${providerName}/${modelId}`; +} + +/** Parse a Paseo-facing model id back into its inference provider + Pi model id. */ +export function parsePaseoAgentModelId(modelId: string): PaseoAgentModelReference | null { + const slash = modelId.indexOf("/"); + if (slash <= 0 || slash === modelId.length - 1) { + return null; + } + return { provider: modelId.slice(0, slash), id: modelId.slice(slash + 1) }; +} + +function toPiModels(entry: PaseoAgentInferenceProviderEntry, settings: ResolvedProviderSettings) { + return entry.options.models.map((model) => { + const api = model.api ?? settings.api; + return { + id: model.id, + name: model.label ?? model.id, + ...(api ? { api } : {}), + reasoning: model.reasoning ?? false, + input: ["text"] as ("text" | "image")[], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW, + maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS, + }; + }); +} + +/** + * Map Paseo-owned config into the in-memory inference providers the Pi seam expects. + * `openai-codex` entries carry an oauth marker instead of an API key. Their credential + * normally lives in the Paseo-owned store (populated by `paseo login chatgpt`); an + * advanced `options.refreshToken` may supply the user's own token instead. + */ +export function paseoAgentInferenceProviders( + config: PaseoAgentConfig, + env: NodeJS.ProcessEnv = process.env, +): PaseoAgentInferenceProvider[] { + return entries(config).flatMap(([name, entry]): PaseoAgentInferenceProvider[] => { + const settings = resolveProviderSettings(entry); + const models = toPiModels(entry, settings); + + if (entry.type === "openai-codex") { + const refreshToken = entry.options.refreshToken + ? resolveRefreshTokenExpression(entry.options.refreshToken, env) + : undefined; + return [ + { + name, + config: { + ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + ...(settings.api ? { api: settings.api } : {}), + models, + }, + oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) }, + }, + ]; + } + + return [ + { + name, + config: { + ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), + ...(settings.api ? { api: settings.api } : {}), + ...(settings.headers ? { headers: settings.headers } : {}), + ...(settings.authHeader ? { authHeader: settings.authHeader } : {}), + models, + }, + }, + ]; + }); +} + +/** Enumerate configured models as Paseo model definitions (no Pi disk/auth reads). */ +export function listPaseoAgentModels(config: PaseoAgentConfig): AgentModelDefinition[] { + const models: AgentModelDefinition[] = []; + for (const [name, entry] of entries(config)) { + for (const model of entry.options.models) { + const id = encodePaseoAgentModelId(name, model.id); + models.push({ + provider: PASEO_AGENT_PROVIDER, + id, + label: model.label ?? model.id, + description: `${name} · ${model.id}`, + isDefault: config.defaultModel === id, + }); + } + } + return models; +} + +/** + * An inference provider is usable when it has at least one model and auth is configured. + * For API-key types that means a resolvable key (literal, set env var, or command). For + * `openai-codex`, auth comes from the Paseo-owned store (checked via `isOAuthAuthed`, + * keyed by provider instance name) or, as an advanced override, a resolvable + * `options.refreshToken`. + */ +export function paseoAgentHasUsableModel( + config: PaseoAgentConfig, + env: NodeJS.ProcessEnv = process.env, + isOAuthAuthed: (providerInstance: string) => boolean = () => false, +): boolean { + return entries(config).some(([name, entry]) => { + if (entry.options.models.length === 0) { + return false; + } + if (entry.type === "openai-codex") { + if ( + entry.options.refreshToken && + isRefreshTokenExpressionConfigured(entry.options.refreshToken, env) + ) { + return true; + } + return isOAuthAuthed(name); + } + return isAuthConfigured(resolveProviderSettings(entry).apiKey, env); + }); +} + +/** + * Resolve which Pi model to launch: the explicit request is honored as-is; implicit + * default selection only chooses models from the providers actually registered with Pi. + */ +export function resolvePaseoAgentModel( + config: PaseoAgentConfig, + requestedModelId: string | null | undefined, + registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config), +): PaseoAgentModelReference | undefined { + if (requestedModelId) { + return parsePaseoAgentModelId(requestedModelId) ?? undefined; + } + + for (const candidate of [config.defaultModel, firstModelId(config)]) { + if (!candidate) { + continue; + } + const parsed = parsePaseoAgentModelId(candidate); + if (parsed && hasRegisteredModel(registeredProviders, parsed)) { + return parsed; + } + } + + return firstRegisteredModel(registeredProviders); +} + +function firstModelId(config: PaseoAgentConfig): string | undefined { + for (const [name, entry] of entries(config)) { + const first = entry.options.models[0]; + if (first) { + return encodePaseoAgentModelId(name, first.id); + } + } + return undefined; +} + +function hasRegisteredModel( + providers: PaseoAgentInferenceProvider[], + model: PaseoAgentModelReference, +): boolean { + return providers.some( + (provider) => + provider.name === model.provider && + provider.config.models?.some((registered) => registered.id === model.id), + ); +} + +function firstRegisteredModel( + providers: PaseoAgentInferenceProvider[], +): PaseoAgentModelReference | undefined { + for (const provider of providers) { + const first = provider.config.models?.[0]; + if (first) { + return { provider: provider.name, id: first.id }; + } + } + return undefined; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/event-mapping.test.ts b/packages/server/src/server/agent/providers/paseo-agent/event-mapping.test.ts new file mode 100644 index 0000000000..eac786db97 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/event-mapping.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + convertPromptInput, + mapToolDetail, + parseToolArgs, + parseToolResult, +} from "./event-mapping.js"; + +describe("parseToolArgs", () => { + it("classifies known built-in tools", () => { + expect(parseToolArgs("bash", { command: "ls" }).kind).toBe("bash"); + expect(parseToolArgs("read", { path: "/tmp/a" }).kind).toBe("read"); + expect(parseToolArgs("grep", { pattern: "foo" }).kind).toBe("grep"); + }); + + it("falls back to unknown for unrecognized tools or bad args", () => { + expect(parseToolArgs("mcp__paseo__do", { anything: 1 }).kind).toBe("unknown"); + expect(parseToolArgs("bash", { notACommand: true }).kind).toBe("unknown"); + }); + + it("accepts legacy edit args (old_string/new_string)", () => { + const call = parseToolArgs("edit", { + path: "/tmp/a", + old_string: "x", + new_string: "y", + }); + expect(call.kind).toBe("edit"); + if (call.kind === "edit") { + expect(call.args.edits[0]).toEqual({ oldText: "x", newText: "y" }); + } + }); +}); + +describe("mapToolDetail", () => { + it("maps a bash call with exit code and output", () => { + const call = parseToolArgs("bash", { command: "echo hi" }); + const detail = mapToolDetail(call, parseToolResult({ output: "hi", exitCode: 0 })); + expect(detail).toMatchObject({ type: "shell", command: "echo hi", output: "hi", exitCode: 0 }); + }); + + it("maps an edit call to a diff detail", () => { + const call = parseToolArgs("edit", { + path: "/tmp/a", + edits: [{ oldText: "a", newText: "b" }], + }); + const detail = mapToolDetail(call, parseToolResult({ details: { diff: "--- diff ---" } })); + expect(detail).toMatchObject({ + type: "edit", + filePath: "/tmp/a", + oldString: "a", + newString: "b", + unifiedDiff: "--- diff ---", + }); + }); + + it("maps unknown tools to a passthrough detail", () => { + const call = parseToolArgs("mystery", { foo: 1 }); + const detail = mapToolDetail(call, parseToolResult("done")); + expect(detail.type).toBe("unknown"); + }); +}); + +describe("convertPromptInput", () => { + it("passes a plain string through", () => { + expect(convertPromptInput("hello")).toEqual({ text: "hello" }); + }); + + it("joins text blocks and collects images", () => { + const payload = convertPromptInput([ + { type: "text", text: "one" }, + { type: "image", data: "base64", mimeType: "image/png" }, + { type: "text", text: "two" }, + ]); + expect(payload.text).toBe("one\n\ntwo"); + expect(payload.images).toEqual([{ type: "image", data: "base64", mimeType: "image/png" }]); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/event-mapping.ts b/packages/server/src/server/agent/providers/paseo-agent/event-mapping.ts new file mode 100644 index 0000000000..5e308f7646 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/event-mapping.ts @@ -0,0 +1,349 @@ +import type { + BashToolInput, + EditToolInput, + FindToolInput, + GrepToolInput, + LsToolInput, + ReadToolInput, + SessionStats, + WriteToolInput, +} from "@earendil-works/pi-coding-agent"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import { z } from "zod"; + +import type { AgentPromptInput, AgentUsage, ToolCallDetail } from "../../agent-sdk-types.js"; +import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; + +// Pure event/tool/model mapping ported from the old direct Pi provider. These +// translate Pi's harness shapes into Paseo's provider-agnostic timeline types. +// Kept free of any session/runtime state so they can be unit-tested directly. + +export interface PiPromptPayload { + text: string; + images?: ImageContent[]; +} + +interface ToolCallOutputSummary { + output?: string; + exitCode?: number | 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; + +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 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: z.object({ diff: z.string().optional() }).passthrough().optional(), + }) + .passthrough(); +const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]); + +type PiToolResult = z.infer; + +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(), +}); + +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, +}; + +export function parseToolResult(rawResult: unknown): PiToolResult { + const parsed = PiToolResultSchema.safeParse(rawResult); + return parsed.success ? parsed.data : null; +} + +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 }; +} + +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 }; +} + +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 as string); + } + } + return textParts.length > 0 ? textParts.join("\n") : undefined; +} + +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") { + return { ...summary, exitCode: result.exitCode }; + } + if (typeof result.code === "number") { + return { ...summary, exitCode: result.code }; + } + return { ...summary, exitCode: null }; +} + +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 { + type: "search", + query: toolCall.args.pattern, + toolName: "search", + content: typeof parsedResult === "string" ? parsedResult : undefined, + }; + case "grep": + return { + type: "search", + query: toolCall.args.pattern, + toolName: "grep", + content: typeof parsedResult === "string" ? parsedResult : undefined, + }; + case "ls": + return { + type: "search", + query: toolCall.args.path ?? "ls", + content: typeof parsedResult === "string" ? parsedResult : undefined, + }; + default: + return { type: "unknown", input: toolCall.args, output: parsedResult }; + } +} + +export 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; +} + +export function toAgentUsage(stats: SessionStats): 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 }; +} + +const PiTextContentSchema = z.object({ type: z.literal("text"), text: z.string() }); + +export function getUserMessageText(content: string | (TextContent | ImageContent)[]): string { + if (typeof content === "string") { + return content; + } + const textParts: string[] = []; + for (const block of content) { + if (PiTextContentSchema.safeParse(block).success) { + textParts.push((block as TextContent).text); + } + } + return textParts.join("\n\n"); +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.test.ts b/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.test.ts new file mode 100644 index 0000000000..e66baad632 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { McpServerConfig } from "../../agent-sdk-types.js"; +import { + type McpCallToolResult, + type McpConnection, + type McpConnectionFactory, + type McpToolInfo, + createMcpToolBridge, + mapMcpToolContent, +} from "./mcp-bridge.js"; + +interface FakeConnectionSpec { + tools?: McpToolInfo[]; + listToolsError?: Error; + callResult?: McpCallToolResult; +} + +function fakeConnection(spec: FakeConnectionSpec) { + const calls: { toolName: string; args: Record }[] = []; + const closed = { value: false }; + const connection: McpConnection = { + async listTools() { + if (spec.listToolsError) { + throw spec.listToolsError; + } + return spec.tools ?? []; + }, + async callTool(toolName, args) { + calls.push({ toolName, args }); + return spec.callResult ?? { content: [{ type: "text", text: "ok" }] }; + }, + async close() { + closed.value = true; + }, + }; + return { connection, calls, closed }; +} + +const HTTP_SERVER: McpServerConfig = { type: "http", url: "https://example.test/mcp" }; + +describe("mapMcpToolContent", () => { + it("maps text and image blocks, notes other kinds", () => { + const content = mapMcpToolContent({ + content: [ + { type: "text", text: "hello" }, + { type: "image", data: "b64", mimeType: "image/png" }, + { type: "resource_link", uri: "file:///x" }, + { type: "audio", mimeType: "audio/wav" }, + ], + }); + expect(content).toEqual([ + { type: "text", text: "hello" }, + { type: "image", data: "b64", mimeType: "image/png" }, + { type: "text", text: "[resource file:///x]" }, + { type: "text", text: "[audio audio/wav]" }, + ]); + }); +}); + +describe("createMcpToolBridge", () => { + it("lists tools from each server as namespaced Pi tools", async () => { + const { connection } = fakeConnection({ + tools: [ + { name: "do_thing", description: "does a thing", inputSchema: { type: "object" } }, + { name: "other", inputSchema: { type: "object" } }, + ], + }); + const connect: McpConnectionFactory = async () => connection; + + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect, + }); + + expect(bridge.tools.map((t) => t.name)).toEqual(["paseo__do_thing", "paseo__other"]); + expect(bridge.tools[0]?.description).toBe("does a thing"); + await bridge.close(); + }); + + it("proxies execute to callTool and maps the result", async () => { + const { connection, calls } = fakeConnection({ + tools: [{ name: "echo", inputSchema: { type: "object" } }], + callResult: { content: [{ type: "text", text: "pong" }] }, + }); + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect: async () => connection, + }); + + const tool = bridge.tools[0]; + const result = await tool.execute("call-1", { msg: "ping" }, undefined, undefined, {} as never); + + expect(calls).toEqual([{ toolName: "echo", args: { msg: "ping" } }]); + expect(result.content).toEqual([{ type: "text", text: "pong" }]); + await bridge.close(); + }); + + it("throws from execute when the MCP result is an error", async () => { + const { connection } = fakeConnection({ + tools: [{ name: "boom", inputSchema: { type: "object" } }], + callResult: { isError: true, content: [{ type: "text", text: "kaboom" }] }, + }); + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect: async () => connection, + }); + + await expect( + bridge.tools[0].execute("call-1", {}, undefined, undefined, {} as never), + ).rejects.toThrow(/kaboom/); + await bridge.close(); + }); + + it("skips a server whose listTools fails but still closes it on teardown", async () => { + const { connection, closed } = fakeConnection({ listToolsError: new Error("nope") }); + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect: async () => connection, + }); + + expect(bridge.tools).toHaveLength(0); + await bridge.close(); + expect(closed.value).toBe(true); + }); + + it("skips a server that fails to connect without throwing", async () => { + const connect = vi.fn(async () => { + throw new Error("connect failed"); + }); + const bridge = await createMcpToolBridge({ + mcpServers: { paseo: HTTP_SERVER }, + logger: createTestLogger(), + connect, + }); + + expect(bridge.tools).toHaveLength(0); + expect(connect).toHaveBeenCalledTimes(1); + await bridge.close(); + }); + + it("closes every connection on teardown", async () => { + const a = fakeConnection({ tools: [{ name: "t", inputSchema: { type: "object" } }] }); + const b = fakeConnection({ tools: [{ name: "u", inputSchema: { type: "object" } }] }); + const connect: McpConnectionFactory = async (serverName) => + serverName === "a" ? a.connection : b.connection; + + const bridge = await createMcpToolBridge({ + mcpServers: { a: HTTP_SERVER, b: HTTP_SERVER }, + logger: createTestLogger(), + connect, + }); + await bridge.close(); + + expect(a.closed.value).toBe(true); + expect(b.closed.value).toBe(true); + }); + + it("produces no tools when there are no MCP servers", async () => { + const bridge = await createMcpToolBridge({ logger: createTestLogger() }); + expect(bridge.tools).toHaveLength(0); + await bridge.close(); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.ts b/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.ts new file mode 100644 index 0000000000..f1f0716b9b --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/mcp-bridge.ts @@ -0,0 +1,245 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { Logger } from "pino"; + +import type { AgentToolResultLike, ToolDefinition } from "./pi-services.js"; +import type { McpServerConfig } from "../../agent-sdk-types.js"; +import { mcpInputSchemaToTypeBox } from "./mcp-schema.js"; + +// Provider-owned bridge that turns `AgentSessionConfig.mcpServers` into Pi +// `customTools`. It owns the MCP client lifecycle: connect on creation, expose tools, +// proxy execution, and tear down on session close. Network/transport construction is +// behind an injectable connection factory so the bridge is testable with a fake. + +const MCP_CLIENT_NAME = "paseo-agent"; +const MCP_CLIENT_VERSION = "0.1.0"; + +export interface McpToolInfo { + name: string; + description?: string; + inputSchema?: unknown; +} + +interface McpContentBlock { + type: string; + text?: string; + data?: string; + mimeType?: string; + uri?: string; + resource?: { text?: string; uri?: string; mimeType?: string; blob?: string }; +} + +export interface McpCallToolResult { + content?: McpContentBlock[]; + isError?: boolean; + structuredContent?: unknown; +} + +/** A live connection to one MCP server. The default impl wraps the MCP SDK Client. */ +export interface McpConnection { + listTools(): Promise; + callTool(toolName: string, args: Record): Promise; + close(): Promise; +} + +export type McpConnectionFactory = ( + serverName: string, + config: McpServerConfig, +) => Promise; + +export interface McpToolBridge { + /** Pi custom tools for every successfully-listed MCP tool. */ + tools: ToolDefinition[]; + /** Close every MCP connection. Idempotent and best-effort. */ + close(): Promise; +} + +function buildTransport(config: McpServerConfig): Transport { + switch (config.type) { + case "http": + return new StreamableHTTPClientTransport( + new URL(config.url), + config.headers ? { requestInit: { headers: config.headers } } : undefined, + ); + case "sse": + return new SSEClientTransport( + new URL(config.url), + config.headers ? { requestInit: { headers: config.headers } } : undefined, + ); + case "stdio": + return new StdioClientTransport({ + command: config.command, + ...(config.args ? { args: config.args } : {}), + ...(config.env ? { env: config.env } : {}), + }); + } +} + +/** Default connection factory: a real MCP SDK client over the configured transport. */ +async function connectWithSdk( + _serverName: string, + config: McpServerConfig, +): Promise { + const client = new Client( + { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + { capabilities: {} }, + ); + await client.connect(buildTransport(config)); + return { + async listTools() { + const result = await client.listTools(); + return result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })); + }, + async callTool(toolName, args) { + return (await client.callTool({ name: toolName, arguments: args })) as McpCallToolResult; + }, + async close() { + await client.close(); + }, + }; +} + +function mcpResultText(result: McpCallToolResult): string { + const parts: string[] = []; + for (const block of result.content ?? []) { + if (block.type === "text" && typeof block.text === "string") { + parts.push(block.text); + } else if (block.type === "resource" && typeof block.resource?.text === "string") { + parts.push(block.resource.text); + } + } + return parts.join("\n"); +} + +/** + * Map an MCP `CallToolResult` content array into Pi tool-result content. Text and image + * blocks map directly; other block kinds become a short text note so nothing is silently + * dropped. Pure and exported for testing. + */ +export function mapMcpToolContent(result: McpCallToolResult): (TextContent | ImageContent)[] { + const content: (TextContent | ImageContent)[] = []; + for (const block of result.content ?? []) { + switch (block.type) { + case "text": + content.push({ type: "text", text: block.text ?? "" }); + break; + case "image": + if (block.data && block.mimeType) { + content.push({ type: "image", data: block.data, mimeType: block.mimeType }); + } + break; + case "audio": + content.push({ + type: "text", + text: `[audio${block.mimeType ? ` ${block.mimeType}` : ""}]`, + }); + break; + case "resource": + if (typeof block.resource?.text === "string") { + content.push({ type: "text", text: block.resource.text }); + } else if (block.resource?.uri) { + content.push({ type: "text", text: `[resource ${block.resource.uri}]` }); + } + break; + case "resource_link": + if (block.uri) { + content.push({ type: "text", text: `[resource ${block.uri}]` }); + } + break; + default: + content.push({ type: "text", text: JSON.stringify(block) }); + } + } + return content; +} + +function buildToolDefinition( + serverName: string, + info: McpToolInfo, + connection: McpConnection, +): ToolDefinition { + const toolName = `${serverName}__${info.name}`; + const description = info.description ?? info.name; + const definition: ToolDefinition = { + name: toolName, + label: info.name, + description, + promptSnippet: description, + parameters: mcpInputSchemaToTypeBox(info.inputSchema), + async execute(_toolCallId, params) { + const args = (params ?? {}) as Record; + const result = await connection.callTool(info.name, args); + if (result.isError) { + // Throwing makes Pi mark the tool call failed and surface the message. + throw new Error(mcpResultText(result) || `MCP tool "${info.name}" reported an error`); + } + const toolResult: AgentToolResultLike = { + content: mapMcpToolContent(result), + details: result.structuredContent ?? null, + }; + return toolResult; + }, + }; + return definition; +} + +/** + * Connect to every configured MCP server, list its tools, and produce Pi custom tools. + * Servers that fail to connect or list are logged and skipped rather than failing the + * whole session. Call `close()` on session teardown. + */ +export async function createMcpToolBridge(options: { + mcpServers?: Record; + logger: Logger; + connect?: McpConnectionFactory; +}): Promise { + const connect = options.connect ?? connectWithSdk; + const connections: McpConnection[] = []; + const tools: ToolDefinition[] = []; + + for (const [serverName, serverConfig] of Object.entries(options.mcpServers ?? {})) { + let connection: McpConnection; + try { + connection = await connect(serverName, serverConfig); + } catch (error) { + options.logger.warn({ err: error, mcpServer: serverName }, "Paseo Agent: MCP connect failed"); + continue; + } + connections.push(connection); + + try { + const toolInfos = await connection.listTools(); + for (const info of toolInfos) { + tools.push(buildToolDefinition(serverName, info, connection)); + } + } catch (error) { + options.logger.warn( + { err: error, mcpServer: serverName }, + "Paseo Agent: MCP listTools failed", + ); + } + } + + return { + tools, + async close() { + await Promise.all( + connections.map((connection) => + connection + .close() + .catch((error) => + options.logger.warn({ err: error }, "Paseo Agent: MCP connection close failed"), + ), + ), + ); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.test.ts b/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.test.ts new file mode 100644 index 0000000000..d918080b84 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.test.ts @@ -0,0 +1,77 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; + +import { mcpInputSchemaToTypeBox } from "./mcp-schema.js"; + +describe("mcpInputSchemaToTypeBox", () => { + it("converts an object schema with required and optional properties", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + properties: { a: { type: "string" }, b: { type: "number" } }, + required: ["a"], + }); + expect(Value.Check(schema, { a: "x" })).toBe(true); + expect(Value.Check(schema, { a: "x", b: 1 })).toBe(true); + expect(Value.Check(schema, { b: 1 })).toBe(false); // missing required "a" + expect(Value.Check(schema, { a: 1 })).toBe(false); // wrong type for "a" + }); + + it("converts enums to a closed set", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + properties: { mode: { type: "string", enum: ["read", "write"] } }, + required: ["mode"], + }); + expect(Value.Check(schema, { mode: "read" })).toBe(true); + expect(Value.Check(schema, { mode: "delete" })).toBe(false); + }); + + it("converts arrays with typed items", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + properties: { tags: { type: "array", items: { type: "number" } } }, + required: ["tags"], + }); + expect(Value.Check(schema, { tags: [1, 2] })).toBe(true); + expect(Value.Check(schema, { tags: ["x"] })).toBe(false); + }); + + it("converts nested objects", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + properties: { + filter: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + required: ["filter"], + }); + expect(Value.Check(schema, { filter: { name: "a" } })).toBe(true); + expect(Value.Check(schema, { filter: {} })).toBe(false); + }); + + it("accepts any object when the schema is missing or empty", () => { + expect(Value.Check(mcpInputSchemaToTypeBox(undefined), { anything: 1 })).toBe(true); + expect(Value.Check(mcpInputSchemaToTypeBox({}), { anything: 1 })).toBe(true); + }); + + it("treats a property-only schema (no declared type) as an object", () => { + const schema = mcpInputSchemaToTypeBox({ + properties: { q: { type: "string" } }, + required: ["q"], + }); + expect(Value.Check(schema, { q: "hi" })).toBe(true); + expect(Value.Check(schema, {})).toBe(false); + }); + + it("preserves descriptions", () => { + const schema = mcpInputSchemaToTypeBox({ + type: "object", + description: "the tool input", + properties: {}, + }) as Record; + expect(schema.description).toBe("the tool input"); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.ts b/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.ts new file mode 100644 index 0000000000..61d78104b9 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/mcp-schema.ts @@ -0,0 +1,161 @@ +import { Type, type TSchema } from "@earendil-works/pi-ai"; + +// JSON-Schema → TypeBox conversion for MCP tool input schemas. MCP advertises tool +// parameters as JSON Schema; Pi tool definitions expect a TypeBox `TSchema`. This +// covers the shapes common MCP servers emit (objects, primitives, arrays, enums, +// unions, required/optional, additionalProperties) and falls back to a permissive +// `Type.Unknown()` for anything it doesn't recognise, so unusual schemas degrade to +// "accept anything" rather than throwing. + +type JsonSchemaRecord = Record; + +// Index signature lets these annotations satisfy TypeBox's option types directly. +interface SchemaAnnotations { + [key: PropertyKey]: unknown; + description?: string; + default?: unknown; + title?: string; +} + +function isRecord(value: unknown): value is JsonSchemaRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function annotationsOf(schema: JsonSchemaRecord): SchemaAnnotations { + const annotations: SchemaAnnotations = {}; + if (typeof schema.description === "string") { + annotations.description = schema.description; + } + if (typeof schema.title === "string") { + annotations.title = schema.title; + } + if ("default" in schema) { + annotations.default = schema.default; + } + return annotations; +} + +function literalOf(value: unknown): TSchema { + if (value === null) { + return Type.Null(); + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return Type.Literal(value); + } + // Non-literal enum members (objects/arrays) can't be TypeBox literals. + return Type.Unknown(); +} + +function unionOf(members: TSchema[], annotations: SchemaAnnotations): TSchema { + if (members.length === 0) { + return Type.Unknown(annotations); + } + if (members.length === 1) { + return members[0]; + } + return Type.Union(members, annotations); +} + +function convertByType( + type: string, + schema: JsonSchemaRecord, + annotations: SchemaAnnotations, +): TSchema { + switch (type) { + case "string": + return Type.String(annotations); + case "number": + return Type.Number(annotations); + case "integer": + return Type.Integer(annotations); + case "boolean": + return Type.Boolean(annotations); + case "null": + return Type.Null(annotations); + case "array": { + const items = isRecord(schema.items) ? convertSchema(schema.items) : Type.Unknown(); + return Type.Array(items, annotations); + } + case "object": + return convertObject(schema, annotations); + default: + return Type.Unknown(annotations); + } +} + +function convertObject(schema: JsonSchemaRecord, annotations: SchemaAnnotations): TSchema { + const properties = isRecord(schema.properties) ? schema.properties : {}; + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter((name): name is string => typeof name === "string") + : [], + ); + + const fields: Record = {}; + for (const [key, propSchema] of Object.entries(properties)) { + const converted = isRecord(propSchema) ? convertSchema(propSchema) : Type.Unknown(); + fields[key] = required.has(key) ? converted : Type.Optional(converted); + } + + const objectOptions: Record = { ...annotations }; + const additional = schema.additionalProperties; + if (additional === false || additional === true) { + objectOptions.additionalProperties = additional; + } else if (isRecord(additional)) { + objectOptions.additionalProperties = convertSchema(additional); + } + + return Type.Object(fields, objectOptions); +} + +function convertSchema(schema: JsonSchemaRecord): TSchema { + const annotations = annotationsOf(schema); + + if (Array.isArray(schema.enum)) { + return unionOf(schema.enum.map(literalOf), annotations); + } + + const composite = schema.anyOf ?? schema.oneOf; + if (Array.isArray(composite)) { + const members = composite.filter(isRecord).map(convertSchema); + return unionOf(members, annotations); + } + + if (Array.isArray(schema.type)) { + const members = schema.type + .filter((t): t is string => typeof t === "string") + .map((t) => convertByType(t, schema, annotations)); + return unionOf(members, annotations); + } + + if (typeof schema.type === "string") { + return convertByType(schema.type, schema, annotations); + } + + // No declared type: treat as an object when properties are present, else accept anything. + if (isRecord(schema.properties)) { + return convertObject(schema, annotations); + } + + return Type.Unknown(annotations); +} + +/** + * Convert an MCP tool `inputSchema` (JSON Schema) into a TypeBox schema for a Pi tool + * definition. Always returns an object schema at the top level so tool parameters are + * a well-formed object, even when the server omits or malforms the schema. + */ +export function mcpInputSchemaToTypeBox(inputSchema: unknown): TSchema { + if (!isRecord(inputSchema)) { + return Type.Object({}, { additionalProperties: true }); + } + const { type } = inputSchema; + // MCP tool parameters are objects. Convert object schemas faithfully; for a bare + // schema with neither a declared object type nor properties, accept any object. + if (type === "object" || isRecord(inputSchema.properties)) { + return convertObject(inputSchema, annotationsOf(inputSchema)); + } + // No declared type, or a non-object top-level type (unusual for tool params): + // accept any object so the tool stays callable. + return Type.Object({}, { additionalProperties: true }); +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts new file mode 100644 index 0000000000..d8e65b5e6b --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + isRefreshTokenExpressionConfigured, + resolveRefreshTokenExpression, +} from "./oauth-credentials.js"; + +describe("resolveRefreshTokenExpression", () => { + it("returns a literal value", () => { + expect(resolveRefreshTokenExpression("rt-literal", {})).toBe("rt-literal"); + }); + + it("resolves an env reference and returns undefined when unset", () => { + expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).toBe("rt-env"); + expect(resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" })).toBe("rt-env"); + expect(resolveRefreshTokenExpression("$CODEX_RT", {})).toBeUndefined(); + }); + + it("runs a !command and returns its trimmed output", () => { + expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).toBe("rt-cmd"); + expect(resolveRefreshTokenExpression("!exit 1", {})).toBeUndefined(); + }); +}); + +describe("isRefreshTokenExpressionConfigured", () => { + it("is true for a literal and a set env var, false for an unset env var", () => { + expect(isRefreshTokenExpressionConfigured("rt", {})).toBe(true); + expect(isRefreshTokenExpressionConfigured("$RT", { RT: "x" })).toBe(true); + expect(isRefreshTokenExpressionConfigured("$RT", {})).toBe(false); + }); + + it("assumes a !command is runnable without executing it", () => { + expect(isRefreshTokenExpressionConfigured("!exit 1", {})).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts new file mode 100644 index 0000000000..e4d310389b --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts @@ -0,0 +1,71 @@ +import { execSync } from "node:child_process"; + +// Resolution of a *self-supplied* OAuth refresh token expression — a literal, an env +// reference (`$VAR` / `${VAR}`), or a `!command` that prints the token. This is an +// advanced/manual escape hatch for users who already hold their own ChatGPT/Codex +// refresh token; the product path is `paseo login chatgpt`, which performs browser +// OAuth by default and writes a Paseo-owned credential store (see oauth-store.ts). +// +// This module deliberately does NOT read any other tool's auth files (Codex CLI, +// OpenCode, Pi, etc.) and imports no Pi runtime code. Token values are never logged. + +const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; +const ENV_REFERENCE_DETECT = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/; + +/** Substitute `$VAR` / `${VAR}` references. Returns undefined if any var is unset. */ +function substituteEnv(value: string, env: NodeJS.ProcessEnv): string | undefined { + let missing = false; + const result = value.replace(ENV_REFERENCE_PATTERN, (_match, name: string) => { + const resolved = env[name]; + if (resolved === undefined) { + missing = true; + return ""; + } + return resolved; + }); + return missing ? undefined : result; +} + +/** + * Resolve a refresh-token expression to its literal value (may run a `!command`). + * Returns undefined when it can't be resolved. + */ +export function resolveRefreshTokenExpression( + value: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + if (value.startsWith("!")) { + const command = value.slice(1).trim(); + if (!command) { + return undefined; + } + try { + const output = execSync(command, { encoding: "utf8", env }).trim(); + return output.length > 0 ? output : undefined; + } catch { + return undefined; + } + } + if (ENV_REFERENCE_DETECT.test(value)) { + const substituted = substituteEnv(value, env); + return substituted && substituted.length > 0 ? substituted : undefined; + } + return value.length > 0 ? value : undefined; +} + +/** + * Cheap check: could this refresh-token expression yield a value without running a + * command? `!command` is assumed runnable; env refs require their vars to be set. + */ +export function isRefreshTokenExpressionConfigured( + value: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (value.startsWith("!")) { + return true; + } + if (ENV_REFERENCE_DETECT.test(value)) { + return substituteEnv(value, env) !== undefined; + } + return value.length > 0; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts new file mode 100644 index 0000000000..c3d0e6a270 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts @@ -0,0 +1,129 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + hasStoredOAuthCredential, + loginAndStoreCodex, + loginAndStoreCodexBrowser, + paseoAgentAuthStoragePath, +} from "./oauth-store.js"; + +describe("oauth-store", () => { + let home: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "paseo-oauth-store-")); + env = { PASEO_HOME: home }; + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("derives the store path from PASEO_HOME", () => { + expect(paseoAgentAuthStoragePath(env)).toBe(join(home, "paseo-agent", "auth.json")); + }); + + it("reports no stored credential before login", () => { + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false); + }); + + it("runs the Pi login helper and persists a Paseo-owned credential", async () => { + const loginCalls: string[] = []; + const deviceCodes: unknown[] = []; + const login = async (opts: { onDeviceCode: (info: unknown) => void }) => { + loginCalls.push("called"); + opts.onDeviceCode({ + userCode: "ABCD-EFGH", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }); + return { refresh: "rt-from-login", access: "ac", expires: 123, accountId: "acct" }; + }; + + const { path } = await loginAndStoreCodex({ + providerInstance: "chatgpt", + env, + onDeviceCode: (info) => deviceCodes.push(info), + login, + }); + + expect(loginCalls).toEqual(["called"]); + expect(deviceCodes).toEqual([expect.objectContaining({ userCode: "ABCD-EFGH" })]); + expect(path).toBe(join(home, "paseo-agent", "auth.json")); + + // Credential persisted to the Paseo-owned store (not any foreign file). + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); + const stored = JSON.parse(readFileSync(path, "utf8")); + expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-from-login" }); + + // Stored private (0600). + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("keys the credential by provider instance name", async () => { + const login = async () => ({ refresh: "rt", access: "", expires: 0 }); + await loginAndStoreCodex({ + providerInstance: "work-chatgpt", + env, + onDeviceCode: () => {}, + login, + }); + expect(hasStoredOAuthCredential("work-chatgpt", env)).toBe(true); + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false); + }); + + it("browser login surfaces the auth URL and persists a Paseo-owned credential", async () => { + const authUrls: Array<[string, string | undefined]> = []; + const loginCalls: string[] = []; + // Fake Pi browser-login helper: emits an auth URL, returns credentials. + const login = async (opts: { onAuth: (info: { url: string }) => void }) => { + loginCalls.push("called"); + opts.onAuth({ url: "https://auth.openai.com/oauth/authorize?x=1" }); + return { refresh: "rt-browser", access: "ac", expires: 456, accountId: "acct" }; + }; + + const { path } = await loginAndStoreCodexBrowser({ + providerInstance: "chatgpt", + env, + onAuthUrl: (url, instructions) => authUrls.push([url, instructions]), + login, + }); + + expect(loginCalls).toEqual(["called"]); + expect(authUrls).toEqual([["https://auth.openai.com/oauth/authorize?x=1", undefined]]); + expect(path).toBe(join(home, "paseo-agent", "auth.json")); + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); + const stored = JSON.parse(readFileSync(path, "utf8")); + expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-browser" }); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("browser login falls back to manual code entry only when the callback can't complete", async () => { + const prompts: string[] = []; + const promptForCode = async (message: string) => { + prompts.push(message); + return "pasted-code"; + }; + // Fake helper that cannot complete via callback and invokes onPrompt. + const login = async (opts: { onPrompt: (p: { message: string }) => Promise }) => { + const code = await opts.onPrompt({ message: "Paste the code:" }); + expect(code).toBe("pasted-code"); + return { refresh: "rt-manual", access: "", expires: 0 }; + }; + + await loginAndStoreCodexBrowser({ + providerInstance: "chatgpt", + env, + onAuthUrl: () => {}, + promptForCode, + login, + }); + + expect(prompts).toEqual(["Paste the code:"]); + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts new file mode 100644 index 0000000000..596aa354c6 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts @@ -0,0 +1,124 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { AuthStorage } from "@earendil-works/pi-coding-agent"; +import type { OAuthCredentials } from "@earendil-works/pi-ai"; +import { loginOpenAICodex, loginOpenAICodexDeviceCode } from "@earendil-works/pi-ai/oauth"; + +// Paseo-owned OAuth credential store for the Paseo Agent provider. Credentials live +// in a Paseo-controlled file (NOT ~/.pi, ~/.codex, OpenCode, or any other tool's +// store) and are managed through Pi's own AuthStorage, so Pi refreshes tokens and +// persists rotation back into Paseo's file. The login flows reuse Pi's OAuth helpers +// (browser PKCE/callback by default, device-code as a headless fallback) — Paseo does +// not reimplement the OAuth protocol. + +export interface CodexDeviceCodeInfo { + userCode: string; + verificationUri: string; + intervalSeconds: number; + expiresInSeconds: number; +} + +type DeviceCodeLogin = (options: { + onDeviceCode: (info: CodexDeviceCodeInfo) => void; + signal?: AbortSignal; +}) => Promise; + +type BrowserLogin = (options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: { message: string }) => Promise; + onProgress?: (message: string) => void; +}) => Promise; + +/** Path to the Paseo-owned auth store. Uses PASEO_HOME; falls back to ~/.paseo. */ +export function paseoAgentAuthStoragePath(env: NodeJS.ProcessEnv = process.env): string { + const base = env.PASEO_HOME ?? join(homedir(), ".paseo"); + return join(base, "paseo-agent", "auth.json"); +} + +/** + * Pi AuthStorage backed by the Paseo-owned file. Pi creates the parent dir (0700) and + * the file (0600) and re-chmods on every write, so refreshed tokens stay private. + */ +export function createPaseoAgentAuthStorage(env: NodeJS.ProcessEnv = process.env): AuthStorage { + return AuthStorage.create(paseoAgentAuthStoragePath(env)); +} + +/** + * Read-only check (no file creation) for whether a Paseo-owned OAuth credential exists + * for a provider instance. Used for availability without constructing AuthStorage. + */ +export function hasStoredOAuthCredential( + providerInstance: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const path = paseoAgentAuthStoragePath(env); + if (!existsSync(path)) { + return false; + } + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (typeof parsed !== "object" || parsed === null) { + return false; + } + const entry = (parsed as Record)[providerInstance]; + return ( + typeof entry === "object" && entry !== null && (entry as { type?: unknown }).type === "oauth" + ); + } catch { + return false; + } +} + +/** + * Run Pi's ChatGPT/Codex device-code OAuth login and persist the resulting credential + * into the Paseo-owned store under `providerInstance`. The `login` dependency defaults + * to Pi's helper and is injectable for tests (no network). Never reads foreign files. + */ +export async function loginAndStoreCodex(options: { + providerInstance: string; + onDeviceCode: (info: CodexDeviceCodeInfo) => void; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + login?: DeviceCodeLogin; +}): Promise<{ path: string }> { + const login = options.login ?? (loginOpenAICodexDeviceCode as DeviceCodeLogin); + const credentials = await login({ onDeviceCode: options.onDeviceCode, signal: options.signal }); + const path = paseoAgentAuthStoragePath(options.env); + const authStorage = AuthStorage.create(path); + authStorage.set(options.providerInstance, { type: "oauth", ...credentials }); + return { path }; +} + +/** + * Run Pi's ChatGPT/Codex **browser** OAuth login (PKCE + local callback on + * 127.0.0.1:1455) and persist the resulting credential into the Paseo-owned store. + * This is the default, first-class login UX. `onAuthUrl` receives the authorization + * URL (the caller opens it / prints it); `promptForCode` is a fallback used only if + * the browser callback can't complete (manual code paste). The `login` dependency + * defaults to Pi's helper and is injectable for tests. Never reads foreign files. + */ +export async function loginAndStoreCodexBrowser(options: { + providerInstance: string; + onAuthUrl: (url: string, instructions?: string) => void; + promptForCode?: (message: string) => Promise; + onProgress?: (message: string) => void; + env?: NodeJS.ProcessEnv; + login?: BrowserLogin; +}): Promise<{ path: string }> { + const login = options.login ?? (loginOpenAICodex as BrowserLogin); + const credentials = await login({ + onAuth: (info) => options.onAuthUrl(info.url, info.instructions), + onProgress: options.onProgress, + onPrompt: async (prompt) => { + if (!options.promptForCode) { + throw new Error("Browser login did not complete and no manual code entry was available."); + } + return options.promptForCode(prompt.message); + }, + }); + const path = paseoAgentAuthStoragePath(options.env); + const authStorage = AuthStorage.create(path); + authStorage.set(options.providerInstance, { type: "oauth", ...credentials }); + return { path }; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts new file mode 100644 index 0000000000..4b396f8a5b --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts @@ -0,0 +1,205 @@ +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AuthStorage } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + type CreatePaseoAgentSessionOptions, + type PaseoAgentInferenceProvider, + createPaseoAgentSession, +} from "./pi-services.js"; + +function codexInferenceProvider(): PaseoAgentInferenceProvider { + return { + name: "chatgpt", + config: { + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + models: [ + { + id: "gpt-5.3-codex", + name: "gpt-5.3-codex", + api: "openai-codex-responses", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + }, + }; +} + +const FAKE_PROVIDER = "paseo-test-openrouter"; +const FAKE_MODEL_ID = "test-model"; + +function fakeInferenceProvider(): PaseoAgentInferenceProvider { + return { + name: FAKE_PROVIDER, + config: { + baseUrl: "https://example.invalid/v1", + apiKey: "sk-in-memory-only", + api: "openai-completions", + models: [ + { + id: FAKE_MODEL_ID, + name: "Paseo Test Model", + api: "openai-completions", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + }, + }; +} + +describe("createPaseoAgentSession (no-discovery spike)", () => { + let cwd: string; + let agentDir: string; + let fakeHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "paseo-agent-cwd-")); + agentDir = join(mkdtempSync(join(tmpdir(), "paseo-agent-dir-")), "agent"); + fakeHome = mkdtempSync(join(tmpdir(), "paseo-agent-home-")); + // Redirect HOME so any accidental ~/.pi discovery would land in fakeHome and be detectable. + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + for (const dir of [cwd, fakeHome, agentDir]) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function baseOptions(): CreatePaseoAgentSessionOptions { + return { + cwd, + agentDir, + inferenceProviders: [fakeInferenceProvider()], + model: { provider: FAKE_PROVIDER, id: FAKE_MODEL_ID }, + }; + } + + it("creates a session from an in-memory inference provider and selects its model", async () => { + const { session, modelRegistry } = await createPaseoAgentSession(baseOptions()); + + expect(session).toBeDefined(); + expect(session.model?.provider).toBe(FAKE_PROVIDER); + expect(session.model?.id).toBe(FAKE_MODEL_ID); + // The in-memory model is the only one reachable with configured auth. + const available = modelRegistry.getAvailable(); + expect(available.some((m) => m.provider === FAKE_PROVIDER && m.id === FAKE_MODEL_ID)).toBe( + true, + ); + }); + + it("performs no Pi resource discovery", async () => { + const { resourceLoader } = await createPaseoAgentSession(baseOptions()); + + expect(resourceLoader.getSkills().skills).toHaveLength(0); + expect(resourceLoader.getExtensions().extensions).toHaveLength(0); + expect(resourceLoader.getPrompts().prompts).toHaveLength(0); + }); + + it("uses an in-memory session manager with no on-disk session file", async () => { + const { sessionManager } = await createPaseoAgentSession(baseOptions()); + + expect(sessionManager.getSessionFile()).toBeUndefined(); + }); + + it("touches no ~/.pi config and writes nothing to the isolated agentDir", async () => { + await createPaseoAgentSession(baseOptions()); + + // No discovery against the redirected home directory: if Pi resolved its + // default agentDir (~/.pi/agent) it would create or read it under fakeHome. + expect(existsSync(join(fakeHome, ".pi"))).toBe(false); + // Nothing persisted to the Paseo-owned isolated agentDir. + const agentDirContents = existsSync(agentDir) ? readdirSync(agentDir) : []; + expect(agentDirContents).toHaveLength(0); + // No session/auth/model files leaked into the cwd either. + expect(existsSync(join(cwd, ".pi"))).toBe(false); + }); + + it("rejects a model that no inference provider registered", async () => { + await expect( + createPaseoAgentSession({ + ...baseOptions(), + inferenceProviders: [], + }), + ).rejects.toThrow(/not registered/); + }); + + it("activates supplied custom tools alongside the built-in tools", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + customTools: [ + { + name: "paseo__demo", + label: "demo", + description: "demo tool", + parameters: { type: "object" } as never, + async execute() { + return { content: [{ type: "text", text: "ok" }], details: null }; + }, + }, + ], + }); + + const active = session.getActiveToolNames(); + expect(active).toContain("paseo__demo"); + // Built-in tools remain active too. + expect(active).toContain("bash"); + }); + + it("registers a codex provider and seeds the advanced refresh-token override", async () => { + const codex = codexInferenceProvider(); + const { session, modelRegistry } = await createPaseoAgentSession({ + cwd, + agentDir, + model: { provider: "chatgpt", id: "gpt-5.3-codex" }, + inferenceProviders: [ + { ...codex, oauth: { kind: "openai-codex", refreshToken: "rt-test-only" } }, + ], + }); + + expect(session.model?.provider).toBe("chatgpt"); + expect(modelRegistry.find("chatgpt", "gpt-5.3-codex")?.api).toBe("openai-codex-responses"); + const available = modelRegistry.getAvailable(); + expect(available.some((m) => m.provider === "chatgpt" && m.id === "gpt-5.3-codex")).toBe(true); + }); + + it("loads a codex credential from a Paseo-owned AuthStorage (product path)", async () => { + // Simulate the result of `paseo login chatgpt`: a credential already in the store. + const authPath = join(mkdtempSync(join(tmpdir(), "paseo-agent-auth-")), "auth.json"); + const authStorage = AuthStorage.create(authPath); + authStorage.set("chatgpt", { type: "oauth", access: "", refresh: "rt-stored", expires: 0 }); + + const { modelRegistry } = await createPaseoAgentSession({ + cwd, + agentDir, + authStorage, + model: { provider: "chatgpt", id: "gpt-5.3-codex" }, + // No oauth.refreshToken marker — the credential comes from the Paseo store. + inferenceProviders: [{ ...codexInferenceProvider(), oauth: { kind: "openai-codex" } }], + }); + + const available = modelRegistry.getAvailable(); + expect(available.some((m) => m.provider === "chatgpt" && m.id === "gpt-5.3-codex")).toBe(true); + rmSync(authPath, { force: true }); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts new file mode 100644 index 0000000000..a48d8f8379 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts @@ -0,0 +1,195 @@ +import { + AuthStorage, + type AgentSession as PiAgentSession, + DefaultResourceLoader, + ModelRegistry, + type ResourceLoader, + SessionManager, + SettingsManager, + type ToolDefinition, + createAgentSession, +} from "@earendil-works/pi-coding-agent"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import { openaiCodexOAuthProvider } from "@earendil-works/pi-ai/oauth"; + +// Re-export the Pi tool contract so the MCP bridge can build custom tools without +// importing the Pi SDK type names itself. +export type { ToolDefinition }; + +/** Shape a Pi custom tool's `execute` must return (subset of Pi's AgentToolResult). */ +export interface AgentToolResultLike { + content: (TextContent | ImageContent)[]; + details: unknown; + terminate?: boolean; +} + +// The single seam between Paseo and Pi's in-process harness. Every `@earendil-works/*` +// import and all no-discovery service construction lives here so the rest of the +// Paseo Agent provider never touches Pi's disk-backed config, auth, or sessions. + +// ProviderConfigInput is not re-exported from the package index, so derive it from +// the public `registerProvider` signature. +export type PiProviderConfig = Parameters[1]; +type PiAuthData = Parameters[0]; +type PiSettings = Parameters[0]; + +/** OAuth wiring for an inference provider (currently only ChatGPT/Codex). */ +export interface PaseoAgentOAuth { + kind: "openai-codex"; + /** + * Advanced/manual override: an already-resolved refresh token to seed into the auth + * store. Omitted on the product path, where the credential already lives in the + * Paseo-owned store (populated by login). + */ + refreshToken?: string; +} + +export interface PaseoAgentInferenceProvider { + /** Instance name, e.g. "openrouter-main". Used as the Pi provider key. */ + name: string; + /** Typed Pi provider config: baseUrl, apiKey, models, api, etc. */ + config: PiProviderConfig; + /** When present, register an OAuth provider and seed an in-memory credential. */ + oauth?: PaseoAgentOAuth; +} + +export interface PaseoAgentModelReference { + provider: string; + id: string; +} + +export interface CreatePaseoAgentSessionOptions { + /** Working directory for the agent. */ + cwd: string; + /** + * Isolated, Paseo-owned global config directory. Never `~/.pi`. Used only to + * satisfy Pi's path math; all services below are in-memory so nothing is read + * from or written to it during creation. + */ + agentDir: string; + /** Inference providers (model backends) registered entirely in memory. */ + inferenceProviders: PaseoAgentInferenceProvider[]; + /** Explicit model selection. When omitted, Pi falls back to its own resolution. */ + model?: PaseoAgentModelReference; + thinkingLevel?: ThinkingLevel; + /** In-memory credential seed, if any provider auth is keyed by AuthStorage. */ + auth?: PiAuthData; + /** + * Pi AuthStorage to use. Defaults to a fresh in-memory store. The Paseo Agent + * provider passes a file-backed, Paseo-owned store for OAuth providers so Pi can + * refresh tokens and persist rotation. Any oauth markers carrying a refresh token + * are still seeded into whichever store is used. + */ + authStorage?: AuthStorage; + /** In-memory settings overrides. Empty by default. */ + settings?: PiSettings; + /** Paseo-bridged tools (e.g. MCP) to register alongside built-in tools. */ + customTools?: ToolDefinition[]; +} + +export interface PaseoAgentSessionHandle { + session: PiAgentSession; + modelRegistry: ModelRegistry; + resourceLoader: ResourceLoader; + sessionManager: SessionManager; +} + +/** + * Build a fully Paseo-controlled Pi `ResourceLoader` that performs no discovery. + * + * Discovery only happens inside `reload()`; the constructor initialises valid empty + * state. We never call `reload()`, and the `no*` flags ensure that even an accidental + * reload would not scan `~/.pi`, the project, or the cwd. + */ +function createNoDiscoveryResourceLoader(options: { + cwd: string; + agentDir: string; + settingsManager: SettingsManager; +}): ResourceLoader { + return new DefaultResourceLoader({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager: options.settingsManager, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + }); +} + +/** + * Create a Pi agent session through the high-level `createAgentSession` API with + * every service supplied in-memory and no Pi config discovery. + */ +export async function createPaseoAgentSession( + options: CreatePaseoAgentSessionOptions, +): Promise { + // Use the caller's Paseo-owned store when provided (so Pi refreshes + persists token + // rotation there), else a fresh in-memory store. + const authStorage = options.authStorage ?? AuthStorage.inMemory({ ...options.auth }); + + // Seed any oauth marker that carries a refresh token (the advanced/manual override). + // The product path leaves this empty — the credential is already in the Paseo store. + // Empty `access` + `expires: 0` forces a refresh on the first request. + for (const provider of options.inferenceProviders) { + if (provider.oauth?.kind === "openai-codex" && provider.oauth.refreshToken) { + authStorage.set(provider.name, { + type: "oauth", + access: "", + refresh: provider.oauth.refreshToken, + expires: 0, + }); + } + } + + const modelRegistry = ModelRegistry.inMemory(authStorage); + + for (const provider of options.inferenceProviders) { + const config = + provider.oauth?.kind === "openai-codex" + ? { ...provider.config, oauth: openaiCodexOAuthProvider } + : provider.config; + modelRegistry.registerProvider(provider.name, config); + } + + const settingsManager = SettingsManager.inMemory(options.settings ?? {}); + const sessionManager = SessionManager.inMemory(options.cwd); + const resourceLoader = createNoDiscoveryResourceLoader({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + }); + + const model = options.model + ? modelRegistry.find(options.model.provider, options.model.id) + : undefined; + if (options.model && !model) { + throw new Error( + `Paseo Agent: model ${options.model.provider}/${options.model.id} is not registered by any inference provider`, + ); + } + + const { session } = await createAgentSession({ + cwd: options.cwd, + agentDir: options.agentDir, + authStorage, + modelRegistry, + settingsManager, + sessionManager, + resourceLoader, + ...(model ? { model } : {}), + ...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}), + ...(options.customTools ? { customTools: options.customTools } : {}), + }); + + // Custom (MCP) tools are registered but not active by default — only the built-in + // tool set is. Activate them so the model can actually call them. + if (options.customTools && options.customTools.length > 0) { + const customToolNames = options.customTools.map((tool) => tool.name); + session.setActiveToolsByName([...session.getActiveToolNames(), ...customToolNames]); + } + + return { session, modelRegistry, resourceLoader, sessionManager }; +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ed92d23d4a..8697c0bb62 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -134,6 +134,7 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; +import type { PaseoAgentConfig } from "./agent/providers/paseo-agent/config.js"; import type { PersistedConfig } from "./persisted-config.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js"; @@ -349,6 +350,7 @@ export interface PaseoDaemonConfig { }>; }; providerOverrides?: Record; + paseoAgentConfig?: PaseoAgentConfig; log?: PersistedConfig["log"]; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; pushNotificationSender?: PushNotificationSender; @@ -641,6 +643,7 @@ export async function createPaseoDaemon( logger: providerSnapshotLogger, runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, + paseoAgentConfig: config.paseoAgentConfig, workspaceGitService, isDev: config.isDev === true, extraClients: config.agentClients, diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index ee4af864d0..98a2e1dae9 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -410,6 +410,7 @@ export function loadConfig( agentProviderSettings: extractAgentProviderSettings(providerOverrides), metadataGeneration: persisted.agents?.metadataGeneration, providerOverrides, + paseoAgentConfig: persisted.agents?.paseo, log: resolveLogConfigFromEnv(env, persisted), }; } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 0961e08b11..221a4b7da2 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -48,6 +48,14 @@ export { type SherpaLoaderEnvResolution, } from "./speech/providers/local/sherpa/sherpa-runtime-env.js"; +// Paseo Agent (ChatGPT/Codex) OAuth login + Paseo-owned credential store +export { + loginAndStoreCodexBrowser, + loginAndStoreCodex, + paseoAgentAuthStoragePath, + type CodexDeviceCodeInfo, +} from "./agent/providers/paseo-agent/oauth-store.js"; + // Provider binary resolution export { type ProviderOverride, diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index fbbff85820..84588df10d 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -165,6 +165,30 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { ], }); }); + + test("accepts the dedicated agents.paseo inference provider config", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + paseo: { + defaultModel: "openrouter-main/anthropic/claude", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "anthropic/claude", label: "Claude" }], + }, + }, + }, + }, + }, + }); + + expect(parsed.agents?.paseo?.defaultModel).toBe("openrouter-main/anthropic/claude"); + expect(parsed.agents?.paseo?.providers?.["openrouter-main"]?.type).toBe("openrouter"); + }); }); describe("provider overrides (new format)", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 65d8c8e8b9..1f9e826219 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -8,6 +8,7 @@ import { ProviderOverridesSchema, } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; +import { PaseoAgentConfigSchema } from "./agent/providers/paseo-agent/config.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; import { TerminalProfileSchema } from "@getpaseo/protocol/messages"; @@ -275,6 +276,7 @@ export const PersistedConfigSchema = z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), metadataGeneration: AgentMetadataGenerationSchema.optional(), + paseo: PaseoAgentConfigSchema.optional(), }) .strict() .optional(), diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index cd2e750af0..e896c63630 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -274,6 +274,104 @@ }, "additionalProperties": false } + }, + "paseo": { + "type": "object", + "properties": { + "defaultModel": { + "type": "string", + "minLength": 1 + }, + "providers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "openrouter", + "openai", + "anthropic", + "opencode", + "openai-compatible", + "openai-codex", + "custom" + ] + }, + "options": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "format": "uri" + }, + "api": { + "type": "string", + "minLength": 1 + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "authHeader": { + "type": "boolean" + }, + "refreshToken": { + "type": "string", + "minLength": 1 + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "api": { + "type": "string", + "minLength": 1 + }, + "reasoning": { + "type": "boolean" + }, + "contextWindow": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "maxTokens": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["id"], + "additionalProperties": false + }, + "minItems": 1 + } + }, + "required": ["models"], + "additionalProperties": false + } + }, + "required": ["type", "options"], + "additionalProperties": false + } + } + }, + "additionalProperties": false } }, "additionalProperties": false From ae087b1906c8df6379a01f26a6f791b93fc4c970 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 30 May 2026 00:07:24 +0700 Subject: [PATCH 02/16] Add remote Paseo Agent configuration --- docs/paseo-agent.md | 30 +- packages/app/e2e/helpers/paseo-agent.ts | 109 +++++ .../e2e/paseo-agent-provider-config.spec.ts | 52 +++ .../paseo-agent-settings-sheet.test.tsx | 258 +++++++++++ .../components/paseo-agent-settings-sheet.tsx | 413 ++++++++++++++++++ .../src/components/provider-settings-host.tsx | 7 + .../src/hooks/use-paseo-agent-providers.ts | 94 ++++ packages/cli/src/commands/login/index.test.ts | 175 +++++++- packages/cli/src/commands/login/index.ts | 128 ++++-- packages/cli/src/commands/provider/index.ts | 12 +- .../src/commands/provider/openrouter.test.ts | 119 +++++ .../cli/src/commands/provider/openrouter.ts | 182 ++++++++ packages/client/src/daemon-client.ts | 68 ++- .../src/messages.paseo-agent-config.test.ts | 80 ++++ packages/protocol/src/messages.ts | 194 ++++++++ .../agent/provider-snapshot-manager.test.ts | 40 ++ .../server/agent/provider-snapshot-manager.ts | 29 +- .../paseo-agent/config-service.test.ts | 179 ++++++++ .../providers/paseo-agent/config-service.ts | 268 ++++++++++++ .../providers/paseo-agent/oauth-store.ts | 46 +- .../src/server/daemon-config-store.test.ts | 90 +++- .../server/src/server/daemon-config-store.ts | 26 +- packages/server/src/server/exports.ts | 2 + packages/server/src/server/session.ts | 188 ++++++++ .../src/server/test-utils/session-stubs.ts | 7 + .../server/src/server/websocket-server.ts | 58 ++- 26 files changed, 2778 insertions(+), 76 deletions(-) create mode 100644 packages/app/e2e/helpers/paseo-agent.ts create mode 100644 packages/app/e2e/paseo-agent-provider-config.spec.ts create mode 100644 packages/app/src/components/paseo-agent-settings-sheet.test.tsx create mode 100644 packages/app/src/components/paseo-agent-settings-sheet.tsx create mode 100644 packages/app/src/hooks/use-paseo-agent-providers.ts create mode 100644 packages/cli/src/commands/provider/openrouter.test.ts create mode 100644 packages/cli/src/commands/provider/openrouter.ts create mode 100644 packages/protocol/src/messages.paseo-agent-config.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/config-service.ts diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 5c55688dbc..2957452c74 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -4,7 +4,8 @@ Paseo Agent is a built-in provider that runs Pi's coding-agent harness **in proc The provider id is **`paseo`** (the display name is "Paseo Agent"). Use it like any other provider, e.g. `paseo run --provider paseo --model / ...`. -This is a prototype. There is no UI yet — config-file only. +This is a prototype. There is no app UI yet. OpenRouter and ChatGPT setup have CLI +paths; other provider setup is still config-file based. > Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing > provider/config code, run `npm run build:server` (or run a source/dev daemon) before @@ -184,3 +185,30 @@ normal path is `paseo login chatgpt`. Paseo still never reads another tool's aut Other OAuth providers (Anthropic Pro/Max, Copilot) remain unwired; for those you can pass a pre-obtained bearer token via `apiKey`/env where accepted (e.g. `ANTHROPIC_OAUTH_TOKEN`). + +## CLI setup + +Configure an OpenRouter provider through the selected daemon: + +```bash +export OPENROUTER_API_KEY=... +paseo provider add openrouter openrouter-main \ + --model anthropic/claude-3.7-sonnet \ + --host localhost:7777 +``` + +For shell-history-safe key entry, pipe the key instead: + +```bash +printf '%s\n' "$OPENROUTER_API_KEY" | + paseo provider add openrouter openrouter-main \ + --api-key-stdin \ + --model anthropic/claude-3.7-sonnet \ + --host localhost:7777 +``` + +`paseo login chatgpt --host ` runs browser OAuth on the CLI machine, then sends +the returned credential to the selected daemon. The credential is stored in that daemon's +`$PASEO_HOME/paseo-agent/auth.json`; token values are not printed. `--device-code` is +currently local-only and is rejected when combined with `--host` until a daemon-run +device-code RPC exists. diff --git a/packages/app/e2e/helpers/paseo-agent.ts b/packages/app/e2e/helpers/paseo-agent.ts new file mode 100644 index 0000000000..3a2aea032c --- /dev/null +++ b/packages/app/e2e/helpers/paseo-agent.ts @@ -0,0 +1,109 @@ +import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { gotoAppShell, openSettings } from "./app"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { getServerId } from "./server-id"; +import { openSettingsHost } from "./settings"; + +type PaseoAgentDaemonClient = Pick< + InternalDaemonClient, + | "close" + | "connect" + | "removePaseoAgentProvider" + | "setPaseoAgentProvider" + | "storePaseoAgentChatGptCredential" +>; + +interface OpenRouterProviderInput { + name: string; + apiKey: string; + models: string[]; +} + +interface ExpectedProvider { + name: string; + providerType: "openai-codex" | "openrouter"; + auth: "API key configured" | "ChatGPT login stored"; + modelCount: number; +} + +async function connectPaseoAgentClient(): Promise { + return connectDaemonClient({ clientIdPrefix: "paseo-agent-e2e" }); +} + +export async function openPaseoAgentSettings(page: Page): Promise { + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await page.getByRole("button", { name: "Paseo Agent provider details", exact: true }).click(); + const sheet = page.getByTestId("paseo-agent-settings-sheet"); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible(); +} + +export async function addOpenRouterProvider( + page: Page, + provider: OpenRouterProviderInput, +): Promise { + await page.getByRole("button", { name: "Add OpenRouter", exact: true }).click(); + await expect(page.getByText("Add OpenRouter provider", { exact: true })).toBeVisible(); + + await page.getByLabel("Provider name").fill(provider.name); + await page.getByLabel("OpenRouter API key").fill(provider.apiKey); + await page.getByLabel("OpenRouter models").fill(provider.models.join("\n")); + await page.getByRole("button", { name: "Save provider", exact: true }).click(); + + await expect(page.getByText("Add OpenRouter provider", { exact: true })).toHaveCount(0); + await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0); +} + +export async function expectInferenceProviderListed( + page: Page, + expected: ExpectedProvider, +): Promise { + const modelLabel = expected.modelCount === 1 ? "1 model" : `${expected.modelCount} models`; + await expect( + page.getByRole("listitem", { + name: new RegExp( + `${expected.name}.*${expected.providerType}.*${modelLabel}.*${expected.auth}`, + ), + }), + ).toBeVisible(); +} + +export async function seedChatGptProvider(providerName: string): Promise { + const client = await connectPaseoAgentClient(); + try { + await client.setPaseoAgentProvider({ + name: providerName, + providerType: "openai-codex", + options: { + models: [{ id: "gpt-5.3-codex", reasoning: true }], + }, + }); + await client.storePaseoAgentChatGptCredential({ + providerName, + credential: { + type: "oauth", + access: "fake-access-token", + refresh: "fake-refresh-token", + expires: 4_102_444_800, + futureField: { passthrough: true }, + }, + }); + } finally { + await client.close().catch(() => undefined); + } +} + +export async function cleanupPaseoAgentProviders(providerNames: Iterable): Promise { + const client = await connectPaseoAgentClient(); + try { + for (const name of providerNames) { + await client.removePaseoAgentProvider(name); + } + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/app/e2e/paseo-agent-provider-config.spec.ts b/packages/app/e2e/paseo-agent-provider-config.spec.ts new file mode 100644 index 0000000000..784857eef7 --- /dev/null +++ b/packages/app/e2e/paseo-agent-provider-config.spec.ts @@ -0,0 +1,52 @@ +import { test } from "./fixtures"; +import { + addOpenRouterProvider, + cleanupPaseoAgentProviders, + expectInferenceProviderListed, + openPaseoAgentSettings, + seedChatGptProvider, +} from "./helpers/paseo-agent"; + +const OPENROUTER_PROVIDER = "phase-e-openrouter-ui"; +const CHATGPT_PROVIDER = "phase-e-chatgpt-ui"; + +test.describe("Paseo Agent provider configuration", () => { + const providerNamesToCleanup = new Set(); + + test.afterEach(async () => { + await cleanupPaseoAgentProviders(providerNamesToCleanup); + providerNamesToCleanup.clear(); + }); + + test("adds an OpenRouter inference provider from Settings", async ({ page }) => { + providerNamesToCleanup.add(OPENROUTER_PROVIDER); + + await openPaseoAgentSettings(page); + await addOpenRouterProvider(page, { + name: OPENROUTER_PROVIDER, + apiKey: "sk-or-phase-e-write-only", + models: ["openai/gpt-4o-mini", "anthropic/claude-3.7-sonnet"], + }); + + await expectInferenceProviderListed(page, { + name: OPENROUTER_PROVIDER, + providerType: "openrouter", + modelCount: 2, + auth: "API key configured", + }); + }); + + test("shows a stored ChatGPT login as a read-only inference provider row", async ({ page }) => { + providerNamesToCleanup.add(CHATGPT_PROVIDER); + + await seedChatGptProvider(CHATGPT_PROVIDER); + await openPaseoAgentSettings(page); + + await expectInferenceProviderListed(page, { + name: CHATGPT_PROVIDER, + providerType: "openai-codex", + modelCount: 1, + auth: "ChatGPT login stored", + }); + }); +}); diff --git a/packages/app/src/components/paseo-agent-settings-sheet.test.tsx b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx new file mode 100644 index 0000000000..32093efd34 --- /dev/null +++ b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx @@ -0,0 +1,258 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; + +const { theme, hookState, setProviderMock } = vi.hoisted(() => ({ + theme: { + spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, + fontSize: { xs: 11, sm: 13 }, + fontWeight: { medium: "500" }, + borderRadius: { lg: 8 }, + colors: { + surface1: "#111", + surface2: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + destructive: "#f00", + statusSuccess: "#0f0", + }, + }, + hookState: { + supported: true, + providers: [] as RedactedPaseoAgentProviderConfig[], + isLoading: false, + error: null as string | null, + }, + setProviderMock: vi.fn(async () => null), +})); + +vi.mock("react-native", () => ({ + View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => + React.createElement("div", { "data-testid": testID }, children), + Text: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => + React.createElement("span", { "data-testid": testID }, children), +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, +})); + +vi.mock("lucide-react-native", () => ({ + Plus: () => React.createElement("span", { "data-icon": "Plus" }), +})); + +vi.mock("@/constants/platform", () => ({ isWeb: true })); + +vi.mock("@/components/adaptive-modal-sheet", () => ({ + AdaptiveModalSheet: ({ + children, + footer, + visible, + testID, + }: { + children?: React.ReactNode; + footer?: React.ReactNode; + visible?: boolean; + testID?: string; + }) => (visible ? React.createElement("div", { "data-testid": testID }, children, footer) : null), + AdaptiveTextInput: ({ + onChangeText, + accessibilityLabel, + testID, + }: { + onChangeText?: (value: string) => void; + accessibilityLabel?: string; + testID?: string; + }) => + React.createElement("input", { + "data-testid": testID, + "aria-label": accessibilityLabel, + onChange: (event: React.ChangeEvent) => onChangeText?.(event.target.value), + }), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onPress, + disabled, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + testID?: string; + }) => + React.createElement( + "button", + { + type: "button", + "data-testid": testID, + disabled, + onClick: disabled ? undefined : onPress, + }, + children, + ), +})); + +vi.mock("@/hooks/use-paseo-agent-providers", () => ({ + usePaseoAgentProviders: () => ({ + supported: hookState.supported, + providers: hookState.providers, + defaultModel: null, + isLoading: hookState.isLoading, + error: hookState.error, + refresh: vi.fn(async () => {}), + setProvider: setProviderMock, + }), +})); + +import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet"; + +function openRouterProvider(): RedactedPaseoAgentProviderConfig { + return { + name: "openrouter-main", + providerType: "openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }; +} + +describe("PaseoAgentSettingsSheet", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + hookState.supported = true; + hookState.providers = []; + hookState.isLoading = false; + hookState.error = null; + setProviderMock.mockReset(); + setProviderMock.mockResolvedValue(null); + }); + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + function render(): void { + act(() => { + root?.render(); + }); + } + + function type(testID: string, value: string): void { + const input = container?.querySelector(`[data-testid="${testID}"]`); + if (!input) throw new Error(`No input ${testID}`); + const setValue = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + act(() => { + setValue?.call(input, value); + input.dispatchEvent(new window.Event("input", { bubbles: true })); + }); + } + + function click(testID: string): void { + const el = container?.querySelector(`[data-testid="${testID}"]`); + if (!el) throw new Error(`No element ${testID}`); + act(() => { + el.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); + } + + it("shows the update-host message and hides the add button when unsupported", () => { + hookState.supported = false; + render(); + + expect( + container?.querySelector('[data-testid="paseo-agent-unsupported"]')?.textContent, + ).toContain("Update the host to configure Paseo Agent."); + expect(container?.querySelector('[data-testid="paseo-agent-add-openrouter"]')).toBeNull(); + }); + + it("shows the error message instead of the empty state when the fetch fails", () => { + hookState.error = "Host is not connected"; + render(); + + const text = container?.textContent ?? ""; + expect(text).toContain("Host is not connected"); + expect(text).not.toContain("No inference providers configured yet."); + }); + + it("lists configured providers with type, model count, and auth state", () => { + hookState.providers = [openRouterProvider()]; + render(); + + const text = container?.textContent ?? ""; + expect(text).toContain("openrouter-main"); + expect(text).toContain("openrouter"); + expect(text).toContain("1 model"); + expect(text).toContain("API key configured"); + }); + + it("submits OpenRouter setup with name, api key, and parsed models", async () => { + render(); + + click("paseo-agent-add-openrouter"); + type("paseo-openrouter-name", "my-router"); + type("paseo-openrouter-api-key", "sk-or-secret"); + type("paseo-openrouter-models", "anthropic/claude-3.7-sonnet, openai/gpt-4o"); + + await act(async () => { + click("paseo-openrouter-submit"); + }); + + expect(setProviderMock).toHaveBeenCalledTimes(1); + expect(setProviderMock).toHaveBeenCalledWith({ + name: "my-router", + providerType: "openrouter", + options: { + apiKey: "sk-or-secret", + models: [{ id: "anthropic/claude-3.7-sonnet" }, { id: "openai/gpt-4o" }], + }, + }); + }); + + it("omits api key from the payload when left blank", async () => { + render(); + + click("paseo-agent-add-openrouter"); + type("paseo-openrouter-models", "anthropic/claude-3.7-sonnet"); + + await act(async () => { + click("paseo-openrouter-submit"); + }); + + expect(setProviderMock).toHaveBeenCalledWith({ + name: "openrouter", + providerType: "openrouter", + options: { + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }); + }); +}); diff --git a/packages/app/src/components/paseo-agent-settings-sheet.tsx b/packages/app/src/components/paseo-agent-settings-sheet.tsx new file mode 100644 index 0000000000..9f3009bad7 --- /dev/null +++ b/packages/app/src/components/paseo-agent-settings-sheet.tsx @@ -0,0 +1,413 @@ +import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Plus } from "lucide-react-native"; +import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { isWeb } from "@/constants/platform"; +import { usePaseoAgentProviders } from "@/hooks/use-paseo-agent-providers"; + +interface PaseoAgentSettingsSheetProps { + serverId: string; + visible: boolean; + onClose: () => void; +} + +const MAIN_SNAP_POINTS = ["65%", "92%"]; +const ADD_SNAP_POINTS = ["70%", "92%"]; +const HEADER: SheetHeader = { title: "Paseo Agent" }; +const ADD_HEADER: SheetHeader = { title: "Add OpenRouter provider" }; +const DEFAULT_PROVIDER_NAME = "openrouter"; + +function authLabel(auth: RedactedPaseoAgentProviderConfig["auth"]): string { + if (auth.kind === "oauth") { + return auth.configured ? "ChatGPT login stored" : "Login required"; + } + if (auth.kind === "none") { + return "No auth"; + } + return auth.configured ? "API key configured" : "API key required"; +} + +function parseModelIds(raw: string): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const part of raw.split(/[\n,]/)) { + const id = part.trim(); + if (id.length > 0 && !seen.has(id)) { + seen.add(id); + ids.push(id); + } + } + return ids; +} + +function ProviderRow({ provider }: { provider: RedactedPaseoAgentProviderConfig }) { + const modelCount = provider.models.length; + const modelLabel = modelCount === 1 ? "1 model" : `${modelCount} models`; + const auth = authLabel(provider.auth); + return ( + + + + + {provider.name} + + + {provider.providerType} · {modelLabel} · {auth} + + + + ); +} + +function AddOpenRouterSubSheet({ + serverId, + visible, + onClose, +}: { + serverId: string; + visible: boolean; + onClose: () => void; +}) { + const { setProvider } = usePaseoAgentProviders(serverId); + const [name, setName] = useState(DEFAULT_PROVIDER_NAME); + const [apiKey, setApiKey] = useState(""); + const [models, setModels] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [resetKey, bumpResetKey] = useReducer((key: number) => key + 1, 0); + + useEffect(() => { + if (!visible) { + setName(DEFAULT_PROVIDER_NAME); + setApiKey(""); + setModels(""); + setError(null); + setSaving(false); + bumpResetKey(); + } + }, [visible]); + + const trimmedName = name.trim(); + const modelIds = useMemo(() => parseModelIds(models), [models]); + const canSubmit = trimmedName.length > 0 && modelIds.length > 0 && !saving; + + const handleSubmit = useCallback(() => { + if (!canSubmit) return; + setError(null); + setSaving(true); + const trimmedKey = apiKey.trim(); + void setProvider({ + name: trimmedName, + providerType: "openrouter", + options: { + models: modelIds.map((id) => ({ id })), + ...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : {}), + }, + }) + .then(() => { + setApiKey(""); + onClose(); + return undefined; + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Failed to save provider"); + }) + .finally(() => setSaving(false)); + }, [apiKey, canSubmit, modelIds, onClose, setProvider, trimmedName]); + + return ( + + + Provider name + + + API key + + + Stored on the host and never shown again. Leave blank to use OPENROUTER_API_KEY on the + host. + + + Models + + One model id per line, or comma-separated. + + {error ? ( + + {error} + + ) : null} + + + + + + + + ); +} + +export function PaseoAgentSettingsSheet({ + serverId, + visible, + onClose, +}: PaseoAgentSettingsSheetProps) { + const { supported, providers, isLoading, error } = usePaseoAgentProviders(serverId); + const [addOpen, setAddOpen] = useState(false); + + useEffect(() => { + if (!visible) { + setAddOpen(false); + } + }, [visible]); + + const handleOpenAdd = useCallback(() => setAddOpen(true), []); + const handleCloseAdd = useCallback(() => setAddOpen(false), []); + + const footer = useMemo(() => { + if (!supported) { + return undefined; + } + return ( + + + + ); + }, [supported, handleOpenAdd]); + + let body: React.ReactNode; + if (!supported) { + body = ( + + Update the host to configure Paseo Agent. + + ); + } else if (error) { + body = ( + + {error} + + ); + } else if (isLoading) { + body = ( + + Loading… + + ); + } else if (providers.length === 0) { + body = ( + + No inference providers configured yet. + + ); + } else { + body = ( + + {providers.map((provider) => ( + + ))} + + ); + } + + return ( + <> + + {body} + + {supported ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + list: { + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + overflow: "hidden", + }, + providerRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + providerText: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], + }, + providerName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + providerMeta: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + dotAvailable: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: theme.colors.statusSuccess, + }, + dotMuted: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: theme.colors.foregroundMuted, + }, + stateBox: { + minHeight: 96, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + stateText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, + footerActions: { + flex: 1, + flexDirection: "row", + justifyContent: "flex-end", + }, + formGroup: { + gap: theme.spacing[2], + }, + formLabel: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + marginTop: theme.spacing[2], + }, + formHint: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + formInput: { + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.sm, + }, + modelsInput: { + minHeight: 80, + textAlignVertical: "top", + }, + errorText: { + fontSize: theme.fontSize.xs, + color: theme.colors.destructive, + }, + formActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + marginTop: theme.spacing[3], + }, +})); + +const FORM_INPUT_STYLE = [styles.formInput, isWeb && { outlineStyle: "none" }]; +const MODELS_INPUT_STYLE = [ + styles.formInput, + styles.modelsInput, + isWeb && { outlineStyle: "none" }, +]; diff --git a/packages/app/src/components/provider-settings-host.tsx b/packages/app/src/components/provider-settings-host.tsx index ce8937a93b..3664b30d7a 100644 --- a/packages/app/src/components/provider-settings-host.tsx +++ b/packages/app/src/components/provider-settings-host.tsx @@ -1,7 +1,10 @@ import { useCallback } from "react"; +import { PaseoAgentSettingsSheet } from "@/components/paseo-agent-settings-sheet"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; +const PASEO_AGENT_PROVIDER = "paseo"; + export function ProviderSettingsHost() { const serverId = useProviderSettingsStore((state) => state.serverId); const provider = useProviderSettingsStore((state) => state.provider); @@ -16,6 +19,10 @@ export function ProviderSettingsHost() { return null; } + if (provider === PASEO_AGENT_PROVIDER) { + return ; + } + return ( ; + +interface UsePaseoAgentProvidersResult { + supported: boolean; + providers: RedactedPaseoAgentProviderConfig[]; + defaultModel: string | null; + isLoading: boolean; + error: string | null; + refresh: () => Promise; + setProvider: ( + input: PaseoAgentSetProviderInput, + ) => Promise; +} + +export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult { + const queryClient = useQueryClient(); + const client = useHostRuntimeClient(serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + // COMPAT(paseoAgentConfig): added in v0.1.X, drop the gate when floor >= v0.1.X. + const supported = useSessionStore( + (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true, + ); + const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]); + + const query = useQuery({ + queryKey, + enabled: Boolean(supported && serverId && client && isConnected), + staleTime: 30_000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return client.getPaseoAgentProviders(); + }, + }); + + const refresh = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey }); + }, [queryClient, queryKey]); + + const error = query.data?.error ?? describeQueryError(query.error); + + const setProviderMutation = useMutation({ + mutationFn: async (input: PaseoAgentSetProviderInput) => { + if (!client) { + throw new Error("Host is not connected"); + } + const result = await client.setPaseoAgentProvider(input); + if (!result.success) { + throw new Error(result.error ?? "Failed to save provider"); + } + return result.provider; + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); + const { mutateAsync: setProviderAsync } = setProviderMutation; + + const setProvider = useCallback( + (input: PaseoAgentSetProviderInput) => setProviderAsync(input), + [setProviderAsync], + ); + + return { + supported, + providers: query.data?.providers ?? [], + defaultModel: query.data?.defaultModel ?? null, + isLoading: query.isLoading, + error, + refresh, + setProvider, + }; +} diff --git a/packages/cli/src/commands/login/index.test.ts b/packages/cli/src/commands/login/index.test.ts index cc01063a15..9bb6807c21 100644 --- a/packages/cli/src/commands/login/index.test.ts +++ b/packages/cli/src/commands/login/index.test.ts @@ -4,9 +4,9 @@ import { createCli } from "../../cli.js"; import { createLoginCommand } from "./index.js"; interface RecordedLogin { - providerInstance: string; - envHome: string | undefined; mode: "browser" | "device"; + providerInstance?: string; + envHome?: string | undefined; } describe("paseo login command", () => { @@ -24,13 +24,15 @@ describe("paseo login command", () => { const flags = chatgpt?.options.map((option) => option.long); // Default flow is browser; device-code is an opt-in fallback. expect(flags).toContain("--device-code"); + expect(flags).toContain("--host"); expect(flags).toContain("--home"); // It must not require a copy/paste device flow by default. expect(chatgpt?.description().toLowerCase()).toContain("chatgpt"); }); - it("runs browser login by default and opens the Pi auth URL", async () => { + it("runs browser login by default and stores the credential through the daemon", async () => { const recorded: RecordedLogin[] = []; + const stored: unknown[] = []; const openedUrls: string[] = []; const output: string[] = []; @@ -44,29 +46,57 @@ describe("paseo login command", () => { promptForCode: async () => { throw new Error("manual code prompt should not be used for successful browser login"); }, - loginBrowser: async (options) => { - recorded.push({ - providerInstance: options.providerInstance, - envHome: options.env?.PASEO_HOME, - mode: "browser", - }); + loginBrowserCredential: async (options) => { + recorded.push({ mode: "browser" }); options.onAuthUrl("https://auth.openai.com/oauth/authorize?client_id=paseo"); options.onProgress?.("callback complete"); - return { path: "/tmp/paseo-home/paseo-agent/auth.json" }; + return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 }; + }, + connectDaemon: async (options) => { + expect(options.host).toBe("localhost:7777"); + return { + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: { paseoAgentConfig: true }, + }), + storePaseoAgentChatGptCredential: async (input) => { + stored.push(input); + return { + requestId: "request-1", + success: true, + providerName: input.providerName, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }; + }, + close: async () => {}, + }; }, loginDeviceCode: async () => { throw new Error("device-code login should not be used by default"); }, }); - await login.parseAsync(["node", "login", "chatgpt", "--home", "/tmp/paseo-home"]); + await login.parseAsync(["node", "login", "chatgpt", "--host", "localhost:7777"]); - expect(recorded).toEqual([ - { providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "browser" }, + expect(recorded).toEqual([{ mode: "browser" }]); + expect(stored).toEqual([ + { + providerName: "chatgpt", + credential: { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }, + }, ]); expect(openedUrls).toEqual(["https://auth.openai.com/oauth/authorize?client_id=paseo"]); expect(output.join("\n")).toContain("browser flow"); - expect(output.join("\n")).toContain("/tmp/paseo-home/paseo-agent/auth.json"); + expect(output.join("\n")).toContain("selected daemon (localhost:7777)"); + expect(output.join("\n")).not.toContain("access-token"); + expect(output.join("\n")).not.toContain("refresh-token"); }); it("uses device-code login only when explicitly requested", async () => { @@ -82,9 +112,12 @@ describe("paseo login command", () => { promptForCode: async () => { throw new Error("manual browser prompt should not run for --device-code"); }, - loginBrowser: async () => { + loginBrowserCredential: async () => { throw new Error("browser login should not run for --device-code"); }, + connectDaemon: async () => { + throw new Error("daemon client should not be used for local --device-code"); + }, loginDeviceCode: async (options) => { recorded.push({ providerInstance: options.providerInstance, @@ -116,4 +149,116 @@ describe("paseo login command", () => { expect(output.join("\n")).toContain("headless device-code flow"); expect(output.join("\n")).toContain("ABCD-EFGH"); }); + + it("rejects --device-code with --host instead of writing local auth for a remote host", async () => { + const output: string[] = []; + const login = createLoginCommand({ + write: (message) => output.push(message), + writeError: (message) => output.push(message), + openBrowser: () => { + throw new Error("browser opener should not run"); + }, + promptForCode: async () => { + throw new Error("prompt should not run"); + }, + loginBrowserCredential: async () => { + throw new Error("browser login should not run"); + }, + loginDeviceCode: async () => { + throw new Error("device-code login should not run with --host"); + }, + connectDaemon: async () => { + throw new Error("daemon client should not be used"); + }, + }); + + await login.parseAsync(["node", "login", "chatgpt", "--device-code", "--host", "remote:7777"]); + + expect(output.join("\n")).toContain("--device-code cannot be combined with --host"); + }); + + it("asks for a host update instead of sending credentials to an old daemon", async () => { + const stored: unknown[] = []; + const output: string[] = []; + const login = createLoginCommand({ + write: (message) => output.push(message), + writeError: (message) => output.push(message), + openBrowser: () => { + throw new Error("browser opener should not run without the capability flag"); + }, + promptForCode: async () => { + throw new Error("manual code prompt should not be used"); + }, + loginBrowserCredential: async () => { + throw new Error("browser login should not run without the capability flag"); + }, + connectDaemon: async () => ({ + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: {}, + }), + storePaseoAgentChatGptCredential: async (input) => { + stored.push(input); + throw new Error("store RPC should not be called without the capability flag"); + }, + close: async () => {}, + }), + loginDeviceCode: async () => { + throw new Error("device-code login should not run"); + }, + }); + + await login.parseAsync(["node", "login", "chatgpt", "--host", "remote:7777"]); + + expect(stored).toEqual([]); + expect(output.join("\n")).toContain("Update the host to configure Paseo Agent providers."); + }); + + it("does not echo password-bearing host URIs after remote login", async () => { + const output: string[] = []; + const login = createLoginCommand({ + write: (message) => output.push(message), + writeError: (message) => output.push(message), + openBrowser: () => true, + promptForCode: async () => { + throw new Error("manual code prompt should not be used"); + }, + loginBrowserCredential: async () => ({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }), + connectDaemon: async () => ({ + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: { paseoAgentConfig: true }, + }), + storePaseoAgentChatGptCredential: async (input) => ({ + requestId: "request-1", + success: true, + providerName: input.providerName, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }), + close: async () => {}, + }), + loginDeviceCode: async () => { + throw new Error("device-code login should not run"); + }, + }); + + await login.parseAsync([ + "node", + "login", + "chatgpt", + "--host", + "tcp://remote:7777?ssl=true&password=super-secret", + ]); + + expect(output.join("\n")).toContain("tcp://remote:7777?ssl=true"); + expect(output.join("\n")).not.toContain("super-secret"); + }); }); diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index 0a7fc6eef6..7046d60eae 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -1,23 +1,28 @@ import { createInterface } from "node:readline/promises"; import { Command } from "commander"; import { + loginCodexBrowser, loginAndStoreCodex, - loginAndStoreCodexBrowser, type CodexDeviceCodeInfo, + type StoredCodexOAuthCredential, } from "@getpaseo/server"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { addDaemonHostOption } from "../../utils/command-options.js"; +import { connectToDaemon } from "../../utils/client.js"; import { openBrowserUrl } from "../../utils/open-browser.js"; // First-class auth UX: `paseo login chatgpt`. // Default flow is browser OAuth (PKCE + local callback on 127.0.0.1:1455) via Pi's -// helper; `--device-code` is a headless fallback. Credentials are stored in the -// Paseo-owned store ($PASEO_HOME/paseo-agent/auth.json). No foreign auth files are read. +// helper; credentials are then sent to the selected daemon for storage. `--device-code` +// remains a local-only fallback until a daemon-run device-code RPC exists. const PROVIDER_INSTANCE = "chatgpt"; interface LoginChatgptOptions { deviceCode?: boolean; home?: string; + host?: string; } interface LoginResult { @@ -26,7 +31,12 @@ interface LoginResult { interface LoginCommandDependencies { loginDeviceCode: typeof loginAndStoreCodex; - loginBrowser: typeof loginAndStoreCodexBrowser; + loginBrowserCredential: typeof loginCodexBrowser; + connectDaemon: (options: { + host?: string; + }) => Promise< + Pick + >; openBrowser: (url: string) => boolean; promptForCode: (message: string) => Promise; write: (message: string) => void; @@ -35,7 +45,8 @@ interface LoginCommandDependencies { const defaultDependencies: LoginCommandDependencies = { loginDeviceCode: loginAndStoreCodex, - loginBrowser: loginAndStoreCodexBrowser, + loginBrowserCredential: loginCodexBrowser, + connectDaemon: connectToDaemon, openBrowser: openBrowserUrl, promptForCode, write: (message) => console.log(message), @@ -46,6 +57,29 @@ function resolveEnv(home: string | undefined): NodeJS.ProcessEnv { return home ? { ...process.env, PASEO_HOME: home } : process.env; } +function requirePaseoAgentConfigFeature(client: Pick) { + if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) { + return; + } + throw new Error("Update the host to configure Paseo Agent providers."); +} + +function formatDaemonTarget(host: string | undefined): string { + if (!host) { + return "local daemon"; + } + try { + if (host.startsWith("tcp://")) { + const url = new URL(host); + url.searchParams.delete("password"); + return `selected daemon (${url.toString()})`; + } + } catch { + // Invalid hosts fail during connection; this path only formats the success message. + } + return `selected daemon (${host})`; +} + async function promptForCode(message: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { @@ -69,6 +103,12 @@ async function runChatgptLogin( const env = resolveEnv(options.home); const { write } = dependencies; + if (options.deviceCode && options.host) { + throw new Error( + "--device-code cannot be combined with --host yet. Use browser login for remote hosts.", + ); + } + if (options.deviceCode) { write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n"); const { path } = await dependencies.loginDeviceCode({ @@ -80,43 +120,59 @@ async function runChatgptLogin( return { path }; } - write("Paseo login — ChatGPT/Codex subscription (browser flow)\n"); - const { path } = await dependencies.loginBrowser({ - providerInstance: PROVIDER_INSTANCE, - env, - onAuthUrl: (url) => { - const opened = dependencies.openBrowser(url); - write( - opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:", - ); - write(` ${url}\n`); - write("Waiting for you to approve in the browser…"); - write("(If the browser didn't open, copy the URL above. You can also paste the code here.)"); - }, - onProgress: (message) => write(message), - promptForCode: dependencies.promptForCode, - }); - write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`); - return { path }; + const client = await dependencies.connectDaemon({ host: options.host }); + try { + requirePaseoAgentConfigFeature(client); + write("Paseo login — ChatGPT/Codex subscription (browser flow)\n"); + const credential: StoredCodexOAuthCredential = await dependencies.loginBrowserCredential({ + onAuthUrl: (url) => { + const opened = dependencies.openBrowser(url); + write( + opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:", + ); + write(` ${url}\n`); + write("Waiting for you to approve in the browser…"); + write( + "(If the browser didn't open, copy the URL above. You can also paste the code here.)", + ); + }, + onProgress: (message) => write(message), + promptForCode: dependencies.promptForCode, + }); + const result = await client.storePaseoAgentChatGptCredential({ + providerName: PROVIDER_INSTANCE, + credential, + }); + if (!result.success || result.error) { + throw new Error(result.error ?? "Daemon rejected the ChatGPT credential"); + } + } finally { + await client.close().catch(() => {}); + } + + const target = formatDaemonTarget(options.host); + write(`\n✓ Logged in. Credential stored on ${target} in its Paseo-owned auth store.`); + return { path: target }; } export function createLoginCommand(dependencies: Partial = {}): Command { const deps = { ...defaultDependencies, ...dependencies }; const login = new Command("login").description("Authenticate Paseo providers"); - login - .command("chatgpt") - .description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider") - .option("--device-code", "Use the headless device-code flow instead of the browser flow") - .option("--home ", "Paseo home directory (default: ~/.paseo or $PASEO_HOME)") - .action(async (options: LoginChatgptOptions) => { - try { - await runChatgptLogin(options, deps); - } catch (error) { - deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } - }); + addDaemonHostOption( + login + .command("chatgpt") + .description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider") + .option("--device-code", "Use the headless device-code flow instead of the browser flow") + .option("--home ", "Paseo home directory for local --device-code only"), + ).action(async (options: LoginChatgptOptions) => { + try { + await runChatgptLogin(options, deps); + } catch (error) { + deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } + }); return login; } diff --git a/packages/cli/src/commands/provider/index.ts b/packages/cli/src/commands/provider/index.ts index acd513fa0b..3173dec0b0 100644 --- a/packages/cli/src/commands/provider/index.ts +++ b/packages/cli/src/commands/provider/index.ts @@ -1,10 +1,13 @@ import { Command } from "commander"; import { runLsCommand } from "./ls.js"; import { runModelsCommand } from "./models.js"; +import { addOpenRouterOptions, runAddOpenRouterCommand } from "./openrouter.js"; import { withOutput } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; -export function createProviderCommand(): Command { +export function createProviderCommand( + dependencies: Parameters[3] = {}, +): Command { const provider = new Command("provider").description("Manage agent providers"); addJsonAndDaemonHostOptions( @@ -19,5 +22,12 @@ export function createProviderCommand(): Command { .option("--thinking", "Include thinking option IDs for each model"), ).action(withOutput(runModelsCommand)); + const add = provider.command("add").description("Configure a provider"); + addJsonAndDaemonHostOptions(addOpenRouterOptions(add.command("openrouter"))).action( + withOutput>["data"], [string]>( + (name, options, command) => runAddOpenRouterCommand(name, options, command, dependencies), + ), + ); + return provider; } diff --git a/packages/cli/src/commands/provider/openrouter.test.ts b/packages/cli/src/commands/provider/openrouter.test.ts new file mode 100644 index 0000000000..805c4eb47a --- /dev/null +++ b/packages/cli/src/commands/provider/openrouter.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { render } from "../../output/index.js"; +import { runAddOpenRouterCommand } from "./openrouter.js"; + +describe("provider add openrouter", () => { + it("sends OpenRouter config to the selected daemon and redacts output", async () => { + const calls: unknown[] = []; + const result = await runAddOpenRouterCommand( + "openrouter-main", + { + host: "localhost:7777", + apiKeyStdin: true, + model: ["anthropic/claude-3.7-sonnet", "openai/gpt-4o"], + }, + {} as never, + { + readStdin: async () => "redaction-sentinel\n", + env: {}, + connectDaemon: async (options) => { + expect(options.host).toBe("localhost:7777"); + return { + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: { paseoAgentConfig: true }, + }), + setPaseoAgentProvider: async (input) => { + calls.push(input); + return { + requestId: "request-1", + success: true, + provider: { + name: input.name, + providerType: "openrouter", + models: input.options.models, + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + error: null, + }; + }, + close: async () => {}, + }; + }, + }, + ); + + expect(calls).toEqual([ + { + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "redaction-sentinel", + models: [{ id: "anthropic/claude-3.7-sonnet" }, { id: "openai/gpt-4o" }], + }, + }, + ]); + + const json = render(result, { format: "json" }); + const table = render(result, { format: "table", noColor: true }); + expect(json).not.toContain("redaction-sentinel"); + expect(table).not.toContain("redaction-sentinel"); + expect(table).toContain("openrouter-main"); + expect(table).toContain("anthropic/claude-3.7-sonnet"); + }); + + it("uses OPENROUTER_API_KEY by default and requires explicit models", async () => { + await expect( + runAddOpenRouterCommand("openrouter-main", { model: [] }, {} as never, { + env: { OPENROUTER_API_KEY: "redaction-sentinel" }, + readStdin: async () => { + throw new Error("stdin should not be read"); + }, + connectDaemon: async () => { + throw new Error("daemon should not be called without models"); + }, + }), + ).rejects.toMatchObject({ code: "MISSING_MODELS" }); + }); + + it("asks for a host update instead of sending provider config to an old daemon", async () => { + const calls: unknown[] = []; + + await expect( + runAddOpenRouterCommand( + "openrouter-main", + { + host: "localhost:7777", + apiKeyStdin: true, + model: ["anthropic/claude-3.7-sonnet"], + }, + {} as never, + { + readStdin: async () => "redaction-sentinel\n", + env: {}, + connectDaemon: async () => ({ + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: {}, + }), + setPaseoAgentProvider: async (input) => { + calls.push(input); + throw new Error("set provider RPC should not run without the capability flag"); + }, + close: async () => {}, + }), + }, + ), + ).rejects.toMatchObject({ + code: "HOST_UPDATE_REQUIRED", + message: "Update the host to configure Paseo Agent providers.", + }); + + expect(calls).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/provider/openrouter.ts b/packages/cli/src/commands/provider/openrouter.ts new file mode 100644 index 0000000000..dca369de60 --- /dev/null +++ b/packages/cli/src/commands/provider/openrouter.ts @@ -0,0 +1,182 @@ +import type { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; + +import { connectToDaemon } from "../../utils/client.js"; +import { collectMultiple } from "../../utils/command-options.js"; +import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js"; + +interface OpenRouterAddOptions extends CommandOptions { + apiKey?: string; + apiKeyEnv?: string; + apiKeyStdin?: boolean; + model?: string[]; +} + +interface OpenRouterConfiguredItem { + name: string; + providerType: string; + auth: string; + available: string; + models: string; +} + +interface OpenRouterDependencies { + connectDaemon: (options: { + host?: string; + }) => Promise>; + env: NodeJS.ProcessEnv; + readStdin: () => Promise; +} + +const DEFAULT_API_KEY_ENV = "OPENROUTER_API_KEY"; + +const defaultDependencies: OpenRouterDependencies = { + connectDaemon: connectToDaemon, + env: process.env, + readStdin, +}; + +export const openRouterConfiguredSchema: OutputSchema = { + idField: "name", + columns: [ + { header: "NAME", field: "name", width: 20 }, + { header: "TYPE", field: "providerType", width: 12 }, + { header: "AUTH", field: "auth", width: 16 }, + { header: "AVAILABLE", field: "available", width: 10 }, + { header: "MODELS", field: "models", width: 50 }, + ], +}; + +async function readStdin(): Promise { + process.stdin.setEncoding("utf8"); + let value = ""; + for await (const chunk of process.stdin) { + value += chunk; + } + return value; +} + +function normalizeModels(rawModels: string[] | undefined): string[] { + return (rawModels ?? []) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); +} + +async function resolveApiKey( + options: OpenRouterAddOptions, + dependencies: OpenRouterDependencies, +): Promise { + if (options.apiKey) { + return options.apiKey; + } + + if (options.apiKeyStdin) { + const value = (await dependencies.readStdin()).trim(); + if (value) { + return value; + } + throw { + code: "MISSING_API_KEY", + message: "No OpenRouter API key was read from stdin", + }; + } + + const envName = options.apiKeyEnv ?? DEFAULT_API_KEY_ENV; + const value = dependencies.env[envName]?.trim(); + if (value) { + return value; + } + + throw { + code: "MISSING_API_KEY", + message: `OpenRouter API key not found in $${envName}`, + details: + "Set OPENROUTER_API_KEY, pass --api-key-env , or pipe the key with --api-key-stdin.", + }; +} + +function toConfiguredItem(provider: RedactedPaseoAgentProviderConfig): OpenRouterConfiguredItem { + return { + name: provider.name, + providerType: provider.providerType, + auth: provider.auth.configured ? (provider.auth.source ?? "configured") : "not configured", + available: provider.available ? "yes" : "no", + models: provider.models.map((model) => model.id).join(", "), + }; +} + +function requirePaseoAgentConfigFeature(client: Pick) { + if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) { + return; + } + throw { + code: "HOST_UPDATE_REQUIRED", + message: "Update the host to configure Paseo Agent providers.", + }; +} + +export async function runAddOpenRouterCommand( + name: string, + options: OpenRouterAddOptions, + _command: Command, + dependencies: Partial = {}, +): Promise> { + const deps = { ...defaultDependencies, ...dependencies }; + const models = normalizeModels(options.model); + if (models.length === 0) { + throw { + code: "MISSING_MODELS", + message: "At least one OpenRouter model is required", + details: "Pass --model . Repeat --model to configure more than one.", + }; + } + + const apiKey = await resolveApiKey(options, deps); + const client = await deps.connectDaemon({ host: options.host }); + try { + requirePaseoAgentConfigFeature(client); + const result = await client.setPaseoAgentProvider({ + name, + providerType: "openrouter", + options: { + apiKey, + models: models.map((id) => ({ id })), + }, + }); + if (!result.success || !result.provider) { + throw { + code: "PROVIDER_CONFIG_FAILED", + message: result.error ?? "Daemon rejected the OpenRouter provider config", + }; + } + + return { + type: "single", + data: toConfiguredItem(result.provider), + schema: openRouterConfiguredSchema, + }; + } finally { + await client.close().catch(() => {}); + } +} + +export function addOpenRouterOptions(command: Command): Command { + return command + .description("Configure an OpenRouter inference provider for Paseo Agent") + .argument("", "Provider instance name") + .option( + "--model ", + "OpenRouter model ID to expose (repeatable, comma-separated also accepted)", + collectMultiple, + [], + ) + .option( + "--api-key-env ", + `Environment variable containing the API key`, + DEFAULT_API_KEY_ENV, + ) + .option("--api-key-stdin", "Read the API key from stdin") + .option("--api-key ", "OpenRouter API key (prefer env or stdin to avoid shell history)"); +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 776644d1e5..d55e875709 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -88,7 +88,16 @@ import type { AgentProvider, AgentSessionConfig, } from "@getpaseo/protocol/agent-types"; -import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; +import type { + MutableDaemonConfig, + MutableDaemonConfigPatch, + PaseoAgentGetProvidersResponse, + PaseoAgentOAuthCredential, + PaseoAgentRemoveProviderResponse, + PaseoAgentSetProviderRequest, + PaseoAgentSetProviderResponse, + PaseoAgentStoreChatGptCredentialResponse, +} from "@getpaseo/protocol/messages"; import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; import { @@ -3735,6 +3744,63 @@ export class DaemonClient { }); } + async getPaseoAgentProviders( + requestId?: string, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId, + message: { + type: "config.paseo_agent.get_providers.request", + }, + timeout: 10000, + }); + } + + async setPaseoAgentProvider( + input: Omit & { requestId?: string }, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "config.paseo_agent.set_provider.request", + name: input.name, + providerType: input.providerType, + options: input.options, + }, + timeout: 30000, + }); + } + + async removePaseoAgentProvider( + name: string, + requestId?: string, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId, + message: { + type: "config.paseo_agent.remove_provider.request", + name, + }, + timeout: 30000, + }); + } + + async storePaseoAgentChatGptCredential(input: { + providerName: string; + credential: PaseoAgentOAuthCredential; + requestId?: string; + }): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "config.paseo_agent.store_chatgpt_credential.request", + providerName: input.providerName, + credential: input.credential, + }, + timeout: 30000, + }); + } + async readProjectConfig(repoRoot: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/protocol/src/messages.paseo-agent-config.test.ts b/packages/protocol/src/messages.paseo-agent-config.test.ts new file mode 100644 index 0000000000..266513e07e --- /dev/null +++ b/packages/protocol/src/messages.paseo-agent-config.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; + +import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; + +describe("Paseo Agent config RPC schemas", () => { + test("parses provider config requests with providerType outside the message type field", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.set_provider.request", + requestId: "req-set-openrouter", + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "sk-test", + models: [{ id: "anthropic/claude-3.7-sonnet", reasoning: true }], + }, + }); + + expect(parsed.type).toBe("config.paseo_agent.set_provider.request"); + expect(parsed.providerType).toBe("openrouter"); + }); + + test("parses redacted provider responses without raw secret fields", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "config.paseo_agent.get_providers.response", + payload: { + requestId: "req-get", + defaultModel: "openrouter-main/anthropic/claude-3.7-sonnet", + providers: [ + { + name: "openrouter-main", + providerType: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + api: "openai-completions", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + ], + error: null, + }, + }); + + expect(parsed.payload.providers[0]?.providerType).toBe("openrouter"); + expect(JSON.stringify(parsed)).not.toContain("apiKey"); + }); + + test("preserves future OAuth credential fields on inbound schema parse", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.store_chatgpt_credential.request", + requestId: "req-oauth", + providerName: "chatgpt", + credential: { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + accountId: "acct_123", + futureField: { keep: true }, + }, + }); + + expect(parsed.credential.futureField).toEqual({ keep: true }); + }); + + test("parses ChatGPT provider config separately from credential storage", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.set_provider.request", + requestId: "req-set-chatgpt", + name: "chatgpt", + providerType: "openai-codex", + options: { + models: [{ id: "gpt-5.3-codex", reasoning: true }], + }, + }); + + expect(parsed.providerType).toBe("openai-codex"); + expect(JSON.stringify(parsed)).not.toContain("access-token"); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 34db46266d..448a160ff8 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1857,6 +1857,112 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({ requestId: z.string(), }); +const PaseoAgentProviderTypeSchema = z.enum([ + "openrouter", + "openai", + "anthropic", + "opencode", + "openai-compatible", + "openai-codex", + "custom", +]); + +const PaseoAgentProviderModelConfigSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1).optional(), + api: z.string().min(1).optional(), + reasoning: z.boolean().optional(), + contextWindow: z.number().int().positive().optional(), + maxTokens: z.number().int().positive().optional(), + }) + .strict(); + +const PaseoAgentSetProviderOptionsSchema = z + .object({ + apiKey: z.string().min(1).optional(), + baseUrl: z.string().url().optional(), + api: z.string().min(1).optional(), + headers: z.record(z.string()).optional(), + authHeader: z.boolean().optional(), + models: z.array(PaseoAgentProviderModelConfigSchema).min(1), + }) + .strict(); + +const PaseoAgentOAuthCredentialSchema = z + .object({ + type: z.literal("oauth"), + access: z.string(), + refresh: z.string(), + expires: z.number(), + accountId: z.string().min(1).optional(), + }) + .passthrough(); + +export const PaseoAgentProviderAuthStateSchema = z + .object({ + kind: z.enum(["api_key", "oauth", "none"]), + configured: z.boolean(), + source: z + .enum(["literal", "env", "default_env", "command", "stored", "refresh_token"]) + .optional(), + hint: z.string().optional(), + }) + .strict(); + +export const RedactedPaseoAgentProviderConfigSchema = z + .object({ + name: z.string().min(1), + providerType: z.enum([ + "openrouter", + "openai", + "anthropic", + "opencode", + "openai-compatible", + "openai-codex", + "custom", + ]), + baseUrl: z.string().optional(), + api: z.string().optional(), + models: z.array(PaseoAgentProviderModelConfigSchema), + auth: PaseoAgentProviderAuthStateSchema, + available: z.boolean(), + error: z.string().nullable().optional(), + }) + .strict(); + +export const PaseoAgentGetProvidersRequestSchema = z.object({ + type: z.literal("config.paseo_agent.get_providers.request"), + requestId: z.string(), +}); + +export const PaseoAgentSetProviderRequestSchema = z.object({ + type: z.literal("config.paseo_agent.set_provider.request"), + requestId: z.string(), + name: z.string().trim().min(1), + providerType: PaseoAgentProviderTypeSchema, + options: PaseoAgentSetProviderOptionsSchema, +}); + +export const PaseoAgentRemoveProviderRequestSchema = z.object({ + type: z.literal("config.paseo_agent.remove_provider.request"), + requestId: z.string(), + name: z.string().trim().min(1), +}); + +export const PaseoAgentSetDefaultModelRequestSchema = z.object({ + type: z.literal("config.paseo_agent.set_default_model.request"), + requestId: z.string(), + model: z.string().trim().min(1).nullable(), +}); + +export const PaseoAgentStoreChatGptCredentialRequestSchema = z.object({ + type: z.literal("config.paseo_agent.store_chatgpt_credential.request"), + requestId: z.string(), + providerName: z.string().trim().min(1), + credential: PaseoAgentOAuthCredentialSchema, +}); + export const ListCommandsRequestSchema = z.object({ type: z.literal("list_commands_request"), agentId: z.string(), @@ -2006,6 +2112,11 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModelsRequestMessageSchema, ListProviderModesRequestMessageSchema, ListProviderFeaturesRequestMessageSchema, + PaseoAgentGetProvidersRequestSchema, + PaseoAgentSetProviderRequestSchema, + PaseoAgentRemoveProviderRequestSchema, + PaseoAgentSetDefaultModelRequestSchema, + PaseoAgentStoreChatGptCredentialRequestSchema, ListAvailableProvidersRequestMessageSchema, GetProvidersSnapshotRequestMessageSchema, RefreshProvidersSnapshotRequestMessageSchema, @@ -2280,6 +2391,8 @@ export const ServerInfoStatusPayloadSchema = z projectRemove: z.boolean().optional(), // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 worktreeRestore: z.boolean().optional(), + // COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30. + paseoAgentConfig: z.boolean().optional(), }) .optional(), }) @@ -3753,6 +3866,57 @@ export const ListProviderFeaturesResponseMessageSchema = z.object({ }), }); +export const PaseoAgentGetProvidersResponseSchema = z.object({ + type: z.literal("config.paseo_agent.get_providers.response"), + payload: z.object({ + requestId: z.string(), + defaultModel: z.string().nullable(), + providers: z.array(RedactedPaseoAgentProviderConfigSchema), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentSetProviderResponseSchema = z.object({ + type: z.literal("config.paseo_agent.set_provider.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + provider: RedactedPaseoAgentProviderConfigSchema.nullable(), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentRemoveProviderResponseSchema = z.object({ + type: z.literal("config.paseo_agent.remove_provider.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + removed: z.boolean(), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentSetDefaultModelResponseSchema = z.object({ + type: z.literal("config.paseo_agent.set_default_model.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + defaultModel: z.string().nullable(), + error: z.string().nullable(), + }), +}); + +export const PaseoAgentStoreChatGptCredentialResponseSchema = z.object({ + type: z.literal("config.paseo_agent.store_chatgpt_credential.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + providerName: z.string(), + auth: PaseoAgentProviderAuthStateSchema, + error: z.string().nullable(), + }), +}); + const ProviderAvailabilitySchema = z.object({ provider: AgentProviderSchema, available: z.boolean(), @@ -4065,6 +4229,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModelsResponseMessageSchema, ListProviderModesResponseMessageSchema, ListProviderFeaturesResponseMessageSchema, + PaseoAgentGetProvidersResponseSchema, + PaseoAgentSetProviderResponseSchema, + PaseoAgentRemoveProviderResponseSchema, + PaseoAgentSetDefaultModelResponseSchema, + PaseoAgentStoreChatGptCredentialResponseSchema, ListAvailableProvidersResponseSchema, GetProvidersSnapshotResponseMessageSchema, ProvidersSnapshotUpdateMessageSchema, @@ -4191,6 +4360,22 @@ export type ListProviderModesResponseMessage = z.infer< export type ListProviderFeaturesResponseMessage = z.infer< typeof ListProviderFeaturesResponseMessageSchema >; +export type RedactedPaseoAgentProviderConfig = z.infer< + typeof RedactedPaseoAgentProviderConfigSchema +>; +export type PaseoAgentProviderAuthState = z.infer; +export type PaseoAgentOAuthCredential = z.infer; +export type PaseoAgentGetProvidersResponse = z.infer; +export type PaseoAgentSetProviderResponse = z.infer; +export type PaseoAgentRemoveProviderResponse = z.infer< + typeof PaseoAgentRemoveProviderResponseSchema +>; +export type PaseoAgentSetDefaultModelResponse = z.infer< + typeof PaseoAgentSetDefaultModelResponseSchema +>; +export type PaseoAgentStoreChatGptCredentialResponse = z.infer< + typeof PaseoAgentStoreChatGptCredentialResponseSchema +>; export type ListAvailableProvidersResponse = z.infer; export type DaemonGetStatusResponse = z.infer; export type DaemonGetPairingOfferResponse = z.infer; @@ -4256,6 +4441,15 @@ export type ListProviderModesRequestMessage = z.infer; +export type PaseoAgentGetProvidersRequest = z.infer; +export type PaseoAgentSetProviderRequest = z.infer; +export type PaseoAgentRemoveProviderRequest = z.infer; +export type PaseoAgentSetDefaultModelRequest = z.infer< + typeof PaseoAgentSetDefaultModelRequestSchema +>; +export type PaseoAgentStoreChatGptCredentialRequest = z.infer< + typeof PaseoAgentStoreChatGptCredentialRequestSchema +>; export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema >; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 954d4f0fe3..ed3b79ca1d 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -772,6 +772,46 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { }); }); +describe("ProviderSnapshotManager applyPaseoAgentConfig", () => { + test("refreshes Paseo Agent models without daemon restart", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + paseoAgentConfig: {}, + }); + try { + manager.applyPaseoAgentConfig({ + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-test", + models: [{ id: "anthropic/claude-3.7-sonnet", label: "Claude" }], + }, + }, + }, + }); + + const models = await manager.listModels({ provider: "paseo", wait: true }); + expect(models).toEqual([ + expect.objectContaining({ + provider: "paseo", + id: "openrouter-main/anthropic/claude-3.7-sonnet", + label: "Claude", + }), + ]); + } finally { + manager.destroy(); + } + }); +}); + describe("ProviderSnapshotManager lifecycle", () => { test("on/off attaches and detaches change listeners", () => { const manager = new ProviderSnapshotManager({ diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index a3273e65b1..6ba84060bf 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -134,7 +134,7 @@ export class ProviderSnapshotManager { private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private readonly baseProviderOverrides: Record | undefined; - private readonly paseoAgentConfig: PaseoAgentConfig | undefined; + private paseoAgentConfig: PaseoAgentConfig | undefined; private providerRegistry: Record; private providerClients: Record; @@ -329,16 +329,12 @@ export class ProviderSnapshotManager { this.baseProviderOverrides, mutableProviders, ); - this.providerRegistry = this.buildRegistry(); - this.providerClients = { ...this.extraClients } as Record; - - for (const cwd of this.snapshots.keys()) { - this.providerLoads.delete(cwd); - this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); - this.emitChange(cwd); - } + return this.rebuildRegistryAndReconcileSnapshots(); + } - return this.getAgentManagerProviderState(); + applyPaseoAgentConfig(config: PaseoAgentConfig | undefined): AgentManagerProviderState { + this.paseoAgentConfig = config; + return this.rebuildRegistryAndReconcileSnapshots(); } on(event: "change", listener: ProviderSnapshotChangeListener): this { @@ -398,6 +394,19 @@ export class ProviderSnapshotManager { return registry; } + private rebuildRegistryAndReconcileSnapshots(): AgentManagerProviderState { + this.providerRegistry = this.buildRegistry(); + this.providerClients = { ...this.extraClients } as Record; + + for (const cwd of this.snapshots.keys()) { + this.providerLoads.delete(cwd); + this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); + this.emitChange(cwd); + } + + return this.getAgentManagerProviderState(); + } + private resolveParent(parent: ManagedAgent): AgentCreateConfigParent { const definition = this.requireProvider(parent.provider); return { diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts new file mode 100644 index 0000000000..e160e65524 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts @@ -0,0 +1,179 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { loadPersistedConfig, savePersistedConfig } from "../../../persisted-config.js"; +import { PaseoAgentConfigService } from "./config-service.js"; +import { paseoAgentAuthStoragePath } from "./oauth-store.js"; + +describe("PaseoAgentConfigService", () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "paseo-agent-config-service-")); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + test("persists an OpenRouter provider and returns only redacted auth state", () => { + const onConfigChanged = vi.fn(); + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + onConfigChanged, + }); + + const provider = service.setProvider({ + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + headers: { Authorization: "Bearer header-secret" }, + models: [{ id: "anthropic/claude-3.7-sonnet", reasoning: true }], + }, + }); + + const persisted = loadPersistedConfig(home); + expect(persisted.agents?.paseo?.providers?.["openrouter-main"]).toMatchObject({ + type: "openrouter", + options: { apiKey: "sk-secret-openrouter" }, + }); + expect(provider).toMatchObject({ + name: "openrouter-main", + providerType: "openrouter", + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + }); + expect(JSON.stringify(service.getProviders())).not.toContain("sk-secret-openrouter"); + expect(JSON.stringify(service.getProviders())).not.toContain("header-secret"); + expect(onConfigChanged).toHaveBeenCalledWith( + expect.objectContaining({ providers: expect.any(Object) }), + ); + }); + + test("preserves shared config fields when writing agents.paseo", () => { + const logger = createTestLogger(); + savePersistedConfig( + home, + { + daemon: { appendSystemPrompt: "Keep existing daemon settings." }, + app: { baseUrl: "http://localhost:8081" }, + agents: { + providers: { + gemini: { + extends: "acp", + label: "Gemini", + command: ["gemini", "--acp"], + }, + }, + }, + }, + logger, + ); + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger, + }); + + service.setProvider({ + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }); + + const persisted = loadPersistedConfig(home); + expect(persisted.daemon?.appendSystemPrompt).toBe("Keep existing daemon settings."); + expect(persisted.app?.baseUrl).toBe("http://localhost:8081"); + expect(persisted.agents?.providers?.gemini).toMatchObject({ + extends: "acp", + label: "Gemini", + }); + expect(persisted.agents?.paseo?.providers?.["openrouter-main"]?.options.apiKey).toBe( + "sk-secret-openrouter", + ); + }); + + test("stores ChatGPT OAuth credentials in the Paseo-owned auth store with future fields intact", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + + service.storeChatGptCredential("chatgpt", { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + accountId: "acct_123", + futureField: { keep: true }, + }); + + const authPath = paseoAgentAuthStoragePath({ PASEO_HOME: home }); + const stored = JSON.parse(readFileSync(authPath, "utf8")); + expect(stored.chatgpt).toMatchObject({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + futureField: { keep: true }, + }); + expect(authPath).toBe(join(home, "paseo-agent", "auth.json")); + }); + + test("reports ChatGPT auth as stored without returning tokens", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + service.setProvider({ + name: "chatgpt", + providerType: "openai-codex", + options: { + models: [{ id: "gpt-5.3-codex", reasoning: true }], + }, + }); + service.storeChatGptCredential("chatgpt", { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }); + + const providers = service.getProviders(); + expect(providers.providers).toEqual([ + expect.objectContaining({ + name: "chatgpt", + providerType: "openai-codex", + auth: { kind: "oauth", configured: true, source: "stored" }, + available: true, + }), + ]); + expect(JSON.stringify(providers)).not.toContain("access-token"); + expect(JSON.stringify(providers)).not.toContain("refresh-token"); + }); + + test("removes providers and clears a default model owned by that provider", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + service.setProvider({ + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }); + service.setDefaultModel("openrouter-main/anthropic/claude-3.7-sonnet"); + + expect(service.removeProvider("openrouter-main")).toBe(true); + expect(service.getProviders()).toEqual({ defaultModel: null, providers: [] }); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts new file mode 100644 index 0000000000..d79735df61 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -0,0 +1,268 @@ +import type { Logger } from "pino"; +import type { + PaseoAgentOAuthCredential, + PaseoAgentProviderAuthState, + RedactedPaseoAgentProviderConfig, +} from "@getpaseo/protocol/messages"; + +import { + loadPersistedConfig, + savePersistedConfig, + type PersistedConfig, +} from "../../../persisted-config.js"; +import { + PaseoAgentConfigSchema, + type PaseoAgentConfig, + type PaseoAgentProviderType, +} from "./config.js"; +import { hasStoredOAuthCredential, storeCodexOAuthCredential } from "./oauth-store.js"; +import { isRefreshTokenExpressionConfigured } from "./oauth-credentials.js"; + +interface PaseoAgentConfigServiceOptions { + paseoHome: string; + logger: Logger; + env?: NodeJS.ProcessEnv; + onConfigChanged?: (config: PaseoAgentConfig | undefined) => void; +} + +interface SetProviderInput { + name: string; + providerType: PaseoAgentProviderType; + options: { + apiKey?: string; + baseUrl?: string; + api?: string; + headers?: Record; + authHeader?: boolean; + models: Array<{ + id: string; + label?: string; + api?: string; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; + }>; + }; +} + +const PROVIDER_DEFAULTS: Record< + PaseoAgentProviderType | "openai-codex", + { baseUrl?: string; api?: string; envVar?: string } +> = { + openrouter: { + baseUrl: "https://openrouter.ai/api/v1", + api: "openai-completions", + envVar: "OPENROUTER_API_KEY", + }, + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + envVar: "OPENAI_API_KEY", + }, + anthropic: { + baseUrl: "https://api.anthropic.com", + api: "anthropic-messages", + envVar: "ANTHROPIC_API_KEY", + }, + opencode: { + baseUrl: "https://opencode.ai/zen/v1", + api: "openai-completions", + envVar: "OPENCODE_API_KEY", + }, + "openai-compatible": { + api: "openai-completions", + }, + custom: {}, + "openai-codex": { + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + }, +}; + +const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; + +function resolveEnv(paseoHome: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return env ?? { ...process.env, PASEO_HOME: paseoHome }; +} + +function authStateForApiKey( + value: string | undefined, + fallbackEnvVar: string | undefined, + env: NodeJS.ProcessEnv, +): PaseoAgentProviderAuthState { + if (!value && fallbackEnvVar) { + return { + kind: "api_key", + configured: Boolean(env[fallbackEnvVar]), + source: "default_env", + hint: fallbackEnvVar, + }; + } + if (!value) { + return { kind: "none", configured: false }; + } + if (value.startsWith("!")) { + return { kind: "api_key", configured: true, source: "command" }; + } + const referencedVars = Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]); + if (referencedVars.length > 0) { + return { + kind: "api_key", + configured: referencedVars.every((name) => Boolean(env[name])), + source: "env", + hint: referencedVars.join(","), + }; + } + return { kind: "api_key", configured: true, source: "literal" }; +} + +function readPaseoAgentConfig(persisted: PersistedConfig): PaseoAgentConfig { + return PaseoAgentConfigSchema.parse(persisted.agents?.paseo ?? {}); +} + +function redactedProviders( + config: PaseoAgentConfig, + env: NodeJS.ProcessEnv, +): RedactedPaseoAgentProviderConfig[] { + return Object.entries(config.providers ?? {}).map(([name, entry]) => { + const defaults = PROVIDER_DEFAULTS[entry.type]; + let auth: PaseoAgentProviderAuthState; + if (entry.type === "openai-codex") { + const hasRefreshToken = + entry.options.refreshToken && + isRefreshTokenExpressionConfigured(entry.options.refreshToken, env); + if (hasRefreshToken) { + auth = { kind: "oauth", configured: true, source: "refresh_token" }; + } else { + const stored = hasStoredOAuthCredential(name, env); + auth = stored + ? { kind: "oauth", configured: true, source: "stored" } + : { kind: "oauth", configured: false }; + } + } else { + auth = authStateForApiKey(entry.options.apiKey, defaults.envVar, env); + } + const provider: RedactedPaseoAgentProviderConfig = { + name, + providerType: entry.type, + models: entry.options.models.map((model) => ({ ...model })), + auth, + available: auth.configured && entry.options.models.length > 0, + error: null, + }; + const baseUrl = entry.options.baseUrl ?? defaults.baseUrl; + if (baseUrl) { + provider.baseUrl = baseUrl; + } + const api = entry.options.api ?? defaults.api; + if (api) { + provider.api = api; + } + return provider; + }); +} + +function mergePaseoAgentConfig( + persisted: PersistedConfig, + paseoConfig: PaseoAgentConfig | undefined, +): PersistedConfig { + return { + ...persisted, + agents: { + ...persisted.agents, + paseo: paseoConfig, + }, + }; +} + +export class PaseoAgentConfigService { + private readonly paseoHome: string; + private readonly logger: Logger; + private readonly env: NodeJS.ProcessEnv; + private readonly onConfigChanged: ((config: PaseoAgentConfig | undefined) => void) | undefined; + + constructor(options: PaseoAgentConfigServiceOptions) { + this.paseoHome = options.paseoHome; + this.logger = options.logger.child({ module: "paseo-agent-config-service" }); + this.env = resolveEnv(options.paseoHome, options.env); + this.onConfigChanged = options.onConfigChanged; + } + + getProviders(): { defaultModel: string | null; providers: RedactedPaseoAgentProviderConfig[] } { + const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger)); + return { + defaultModel: config.defaultModel ?? null, + providers: redactedProviders(config, this.env), + }; + } + + setProvider(input: SetProviderInput): RedactedPaseoAgentProviderConfig { + const next = this.updateConfig((current) => + PaseoAgentConfigSchema.parse({ + ...current, + providers: { + ...current.providers, + [input.name]: { + type: input.providerType, + options: input.options, + }, + }, + }), + ); + return this.requireRedactedProvider(next, input.name); + } + + removeProvider(name: string): boolean { + let removed = false; + this.updateConfig((current) => { + const providers = { ...current.providers }; + removed = Object.prototype.hasOwnProperty.call(providers, name); + delete providers[name]; + return PaseoAgentConfigSchema.parse({ + ...current, + ...(Object.keys(providers).length > 0 ? { providers } : { providers: undefined }), + ...(current.defaultModel?.startsWith(`${name}/`) ? { defaultModel: undefined } : {}), + }); + }); + return removed; + } + + setDefaultModel(model: string | null): string | null { + const next = this.updateConfig((current) => + PaseoAgentConfigSchema.parse({ + ...current, + ...(model ? { defaultModel: model } : { defaultModel: undefined }), + }), + ); + return next.defaultModel ?? null; + } + + storeChatGptCredential(providerName: string, credential: PaseoAgentOAuthCredential): void { + storeCodexOAuthCredential({ + providerInstance: providerName, + credential, + env: this.env, + }); + const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger)); + this.onConfigChanged?.(config); + } + + private requireRedactedProvider( + config: PaseoAgentConfig, + name: string, + ): RedactedPaseoAgentProviderConfig { + const provider = redactedProviders(config, this.env).find((entry) => entry.name === name); + if (!provider) { + throw new Error(`Paseo Agent provider '${name}' was not found after update.`); + } + return provider; + } + + private updateConfig(update: (current: PaseoAgentConfig) => PaseoAgentConfig): PaseoAgentConfig { + const persisted = loadPersistedConfig(this.paseoHome, this.logger); + const next = update(readPaseoAgentConfig(persisted)); + savePersistedConfig(this.paseoHome, mergePaseoAgentConfig(persisted, next), this.logger); + this.onConfigChanged?.(next); + return next; + } +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts index 596aa354c6..95cd1302b1 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts @@ -30,6 +30,14 @@ type BrowserLogin = (options: { onProgress?: (message: string) => void; }) => Promise; +export type StoredCodexOAuthCredential = { + type: "oauth"; + access: string; + refresh: string; + expires: number; + accountId?: string; +} & Record; + /** Path to the Paseo-owned auth store. Uses PASEO_HOME; falls back to ~/.paseo. */ export function paseoAgentAuthStoragePath(env: NodeJS.ProcessEnv = process.env): string { const base = env.PASEO_HOME ?? join(homedir(), ".paseo"); @@ -70,6 +78,22 @@ export function hasStoredOAuthCredential( } } +/** + * Store a credential obtained by a remote-safe client-side OAuth flow into the + * daemon's Paseo-owned AuthStorage. The caller supplies a stable wire shape, not + * Pi types, and this helper never reads or writes foreign auth files. + */ +export function storeCodexOAuthCredential(options: { + providerInstance: string; + credential: StoredCodexOAuthCredential; + env?: NodeJS.ProcessEnv; +}): { path: string } { + const path = paseoAgentAuthStoragePath(options.env); + const authStorage = AuthStorage.create(path); + authStorage.set(options.providerInstance, options.credential); + return { path }; +} + /** * Run Pi's ChatGPT/Codex device-code OAuth login and persist the resulting credential * into the Paseo-owned store under `providerInstance`. The `login` dependency defaults @@ -106,6 +130,23 @@ export async function loginAndStoreCodexBrowser(options: { env?: NodeJS.ProcessEnv; login?: BrowserLogin; }): Promise<{ path: string }> { + const credential = await loginCodexBrowser(options); + const path = paseoAgentAuthStoragePath(options.env); + const authStorage = AuthStorage.create(path); + authStorage.set(options.providerInstance, credential); + return { path }; +} + +/** + * Run Pi's browser OAuth flow and return the credential without storing it locally. + * CLI remote login uses this so the selected daemon remains the owner of persisted auth. + */ +export async function loginCodexBrowser(options: { + onAuthUrl: (url: string, instructions?: string) => void; + promptForCode?: (message: string) => Promise; + onProgress?: (message: string) => void; + login?: BrowserLogin; +}): Promise { const login = options.login ?? (loginOpenAICodex as BrowserLogin); const credentials = await login({ onAuth: (info) => options.onAuthUrl(info.url, info.instructions), @@ -117,8 +158,5 @@ export async function loginAndStoreCodexBrowser(options: { return options.promptForCode(prompt.message); }, }); - const path = paseoAgentAuthStoragePath(options.env); - const authStorage = AuthStorage.create(path); - authStorage.set(options.providerInstance, { type: "oauth", ...credentials }); - return { path }; + return { type: "oauth", ...credentials }; } diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index cd7f90b657..aab347ae54 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; +import { + DaemonConfigStore, + applyMutableProviderConfigToOverrides, + type MutableDaemonConfigPatch, +} from "./daemon-config-store.js"; import { loadPersistedConfig } from "./persisted-config.js"; describe("applyMutableProviderConfigToOverrides", () => { @@ -118,6 +122,90 @@ describe("DaemonConfigStore", () => { }); }); + test("generic mutable config strips dedicated Paseo Agent config instead of echoing secrets", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const broadcasts: unknown[] = []; + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + }, + undefined, + ); + store.onChange((config) => broadcasts.push(config)); + + const secretBearingPatch: MutableDaemonConfigPatch & { agents: unknown } = { + agents: { + paseo: { + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + headers: { Authorization: "Bearer header-secret" }, + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }, + }, + }, + }, + }; + const returned = store.patch(secretBearingPatch); + + const returnedJson = JSON.stringify(returned); + const broadcastJson = JSON.stringify(broadcasts); + expect(returnedJson).not.toContain("sk-secret-openrouter"); + expect(returnedJson).not.toContain("header-secret"); + expect(broadcastJson).not.toContain("sk-secret-openrouter"); + expect(broadcastJson).not.toContain("header-secret"); + expect(loadPersistedConfig(paseoHome).agents?.paseo).toBeUndefined(); + }); + + test("mutable provider patches preserve existing dedicated Paseo Agent config on disk", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const initial = loadPersistedConfig(paseoHome); + initial.agents = { + paseo: { + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }, + }, + }, + }; + writeFileSync(path.join(paseoHome, "config.json"), JSON.stringify(initial, null, 2) + "\n"); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + }, + undefined, + ); + + store.patch({ + providers: { + claude: { enabled: false }, + }, + }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.agents?.paseo?.providers?.["openrouter-main"]?.options.apiKey).toBe( + "sk-secret-openrouter", + ); + expect(persisted.agents?.providers?.claude).toEqual({ enabled: false }); + }); + test("patch persists append system prompt into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 24ef05b199..94b76fe430 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -62,6 +62,22 @@ function isEqualValue(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } +function stripDedicatedPaseoAgentConfig>(config: T): T { + const agents = config.agents; + if (!isRecord(agents) || !Object.prototype.hasOwnProperty.call(agents, "paseo")) { + return config; + } + + const { paseo: _paseo, ...remainingAgents } = agents; + const next: Record = { ...config }; + if (Object.keys(remainingAgents).length > 0) { + next.agents = remainingAgents; + } else { + delete next.agents; + } + return next as T; +} + export function applyMutableProviderConfigToOverrides( baseOverrides: Record | undefined, mutableProviders: MutableDaemonConfig["providers"] | undefined, @@ -91,7 +107,7 @@ export class DaemonConfigStore { constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) { this.paseoHome = paseoHome; this.logger = getLogger(logger); - this.current = MutableDaemonConfigSchema.parse(initial); + this.current = stripDedicatedPaseoAgentConfig(MutableDaemonConfigSchema.parse(initial)); } public get(): MutableDaemonConfig { @@ -99,8 +115,12 @@ export class DaemonConfigStore { } public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { - const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial); - const next = MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch)); + const parsedPatch = stripDedicatedPaseoAgentConfig( + MutableDaemonConfigPatchSchema.parse(partial), + ); + const next = stripDedicatedPaseoAgentConfig( + MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch)), + ); const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => { return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path)); diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 221a4b7da2..9719ed312f 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -50,10 +50,12 @@ export { // Paseo Agent (ChatGPT/Codex) OAuth login + Paseo-owned credential store export { + loginCodexBrowser, loginAndStoreCodexBrowser, loginAndStoreCodex, paseoAgentAuthStoragePath, type CodexDeviceCodeInfo, + type StoredCodexOAuthCredential, } from "./agent/providers/paseo-agent/oauth-store.js"; // Provider binary resolution diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f491314114..8d54bbd533 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -100,6 +100,7 @@ import type { import { AgentManager } from "./agent/agent-manager.js"; import { ProviderSnapshotManager, resolveSnapshotCwd } from "./agent/provider-snapshot-manager.js"; +import { PaseoAgentConfigService } from "./agent/providers/paseo-agent/config-service.js"; import type { AgentManagerEvent, AgentTimelineCursor, @@ -2223,6 +2224,16 @@ export class Session { return this.handleListProviderModesRequest(msg); case "list_provider_features_request": return this.handleListProviderFeaturesRequest(msg); + case "config.paseo_agent.get_providers.request": + return this.handlePaseoAgentGetProvidersRequest(msg); + case "config.paseo_agent.set_provider.request": + return this.handlePaseoAgentSetProviderRequest(msg); + case "config.paseo_agent.remove_provider.request": + return this.handlePaseoAgentRemoveProviderRequest(msg); + case "config.paseo_agent.set_default_model.request": + return this.handlePaseoAgentSetDefaultModelRequest(msg); + case "config.paseo_agent.store_chatgpt_credential.request": + return this.handlePaseoAgentStoreChatGptCredentialRequest(msg); case "list_available_providers_request": return this.handleListAvailableProvidersRequest(msg); case "get_providers_snapshot_request": @@ -4135,6 +4146,183 @@ export class Session { } } + private createPaseoAgentConfigService(): PaseoAgentConfigService { + return new PaseoAgentConfigService({ + paseoHome: this.paseoHome, + logger: this.sessionLogger, + onConfigChanged: (config) => { + const state = this.providerSnapshotManager.applyPaseoAgentConfig(config); + this.agentManager.updateProviderRegistry(state); + }, + }); + } + + private async refreshPaseoAgentRuntimeSnapshot(): Promise { + await this.providerSnapshotManager.refreshSettingsSnapshot({ providers: ["paseo"] }); + } + + private async handlePaseoAgentGetProvidersRequest( + msg: Extract, + ): Promise { + try { + const result = this.createPaseoAgentConfigService().getProviders(); + this.emit({ + type: "config.paseo_agent.get_providers.response", + payload: { + requestId: msg.requestId, + defaultModel: result.defaultModel, + providers: result.providers, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to read Paseo Agent providers"); + this.emit({ + type: "config.paseo_agent.get_providers.response", + payload: { + requestId: msg.requestId, + defaultModel: null, + providers: [], + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentSetProviderRequest( + msg: Extract, + ): Promise { + try { + const provider = this.createPaseoAgentConfigService().setProvider({ + name: msg.name, + providerType: msg.providerType, + options: msg.options, + }); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.set_provider.response", + payload: { + requestId: msg.requestId, + success: true, + provider, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, providerName: msg.name, providerType: msg.providerType }, + "Failed to set Paseo Agent provider", + ); + this.emit({ + type: "config.paseo_agent.set_provider.response", + payload: { + requestId: msg.requestId, + success: false, + provider: null, + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentRemoveProviderRequest( + msg: Extract, + ): Promise { + try { + const removed = this.createPaseoAgentConfigService().removeProvider(msg.name); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.remove_provider.response", + payload: { + requestId: msg.requestId, + success: true, + removed, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, providerName: msg.name }, + "Failed to remove Paseo Agent provider", + ); + this.emit({ + type: "config.paseo_agent.remove_provider.response", + payload: { + requestId: msg.requestId, + success: false, + removed: false, + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentSetDefaultModelRequest( + msg: Extract, + ): Promise { + try { + const defaultModel = this.createPaseoAgentConfigService().setDefaultModel(msg.model); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.set_default_model.response", + payload: { + requestId: msg.requestId, + success: true, + defaultModel, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to set Paseo Agent default model"); + this.emit({ + type: "config.paseo_agent.set_default_model.response", + payload: { + requestId: msg.requestId, + success: false, + defaultModel: null, + error: getErrorMessage(error), + }, + }); + } + } + + private async handlePaseoAgentStoreChatGptCredentialRequest( + msg: Extract< + SessionInboundMessage, + { type: "config.paseo_agent.store_chatgpt_credential.request" } + >, + ): Promise { + try { + this.createPaseoAgentConfigService().storeChatGptCredential(msg.providerName, msg.credential); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.store_chatgpt_credential.response", + payload: { + requestId: msg.requestId, + success: true, + providerName: msg.providerName, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, providerName: msg.providerName }, + "Failed to store Paseo Agent ChatGPT credential", + ); + this.emit({ + type: "config.paseo_agent.store_chatgpt_credential.response", + payload: { + requestId: msg.requestId, + success: false, + providerName: msg.providerName, + auth: { kind: "oauth", configured: false }, + error: getErrorMessage(error), + }, + }); + } + } + private async handleDaemonGetStatusRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index f7ddbdd95d..4ebf2080e5 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -163,6 +163,7 @@ export interface ProviderSnapshotManagerSpies { typeof vi.fn<[AgentProvider], Promise> >; applyMutableProviderConfig: ReturnType>; + applyPaseoAgentConfig: ReturnType>; destroy: ReturnType>; } @@ -204,6 +205,10 @@ export function createProviderSnapshotManagerStub(): { providerDefinitions: {}, clients: {}, })); + const applyPaseoAgentConfig = vi.fn<[unknown], AgentManagerProviderState>(() => ({ + providerDefinitions: {}, + clients: {}, + })); const on = vi.fn(); const off = vi.fn(); const destroy = vi.fn<[], void>(); @@ -224,6 +229,7 @@ export function createProviderSnapshotManagerStub(): { resolveDefaultModel, getProviderDiagnostic, applyMutableProviderConfig, + applyPaseoAgentConfig, on, off, destroy, @@ -249,6 +255,7 @@ export function createProviderSnapshotManagerStub(): { resolveDefaultModel, getProviderDiagnostic, applyMutableProviderConfig, + applyPaseoAgentConfig, destroy, }; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 48711dc65c..5df2d06820 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1161,6 +1161,8 @@ export class VoiceAssistantWebSocketServer { projectRemove: true, // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 worktreeRestore: true, + // COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30. + paseoAgentConfig: true, }, }; } @@ -1589,17 +1591,20 @@ export class VoiceAssistantWebSocketServer { const { ws, data, error, log } = params; const err = error instanceof Error ? error : new Error(String(error)); const { rawPayload, parsedPayload } = this.decodeRawMessagePayloadForError(data); + const redactedParsedPayload = redactPaseoAgentConfigSecrets(parsedPayload); + const redactedRawPayload = + redactedParsedPayload === parsedPayload ? rawPayload : JSON.stringify(redactedParsedPayload); const trimmedRawPayload = - typeof rawPayload === "string" && rawPayload.length > 2000 - ? `${rawPayload.slice(0, 2000)}... (truncated)` - : rawPayload; + typeof redactedRawPayload === "string" && redactedRawPayload.length > 2000 + ? `${redactedRawPayload.slice(0, 2000)}... (truncated)` + : redactedRawPayload; log.error( { err, rawPayload: trimmedRawPayload, - parsedPayload, + parsedPayload: redactedParsedPayload, }, "Failed to parse/handle message", ); @@ -2010,3 +2015,48 @@ function extractRequestInfoFromUnknownWsInbound( return null; } + +function redactPaseoAgentConfigSecrets(payload: unknown): unknown { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return payload; + } + const record = payload as Record; + if (record.type !== "session" || !record.message || typeof record.message !== "object") { + return payload; + } + + const message = record.message as Record; + if (message.type === "config.paseo_agent.set_provider.request") { + const options = message.options; + if (!options || typeof options !== "object" || Array.isArray(options)) { + return payload; + } + return { + ...record, + message: { + ...message, + options: { + ...(options as Record), + ...(Object.prototype.hasOwnProperty.call(options, "apiKey") + ? { apiKey: "" } + : {}), + ...(Object.prototype.hasOwnProperty.call(options, "headers") + ? { headers: "" } + : {}), + }, + }, + }; + } + + if (message.type === "config.paseo_agent.store_chatgpt_credential.request") { + return { + ...record, + message: { + ...message, + credential: "", + }, + }; + } + + return payload; +} From 205e6860cb0b9ee591b6a4fe5f1ae2227fd45555 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 30 May 2026 00:24:50 +0700 Subject: [PATCH 03/16] Add Paseo Agent prompt profiles --- docs/paseo-agent.md | 62 ++++ package-lock.json | 1 + packages/server/package.json | 1 + .../src/server/agent/provider-registry.ts | 11 +- .../server/agent/provider-snapshot-manager.ts | 4 + .../agent/providers/paseo-agent/agent.test.ts | 133 ++++++++- .../agent/providers/paseo-agent/agent.ts | 69 ++++- .../agent/providers/paseo-agent/config.ts | 5 +- .../providers/paseo-agent/pi-services.test.ts | 14 + .../providers/paseo-agent/pi-services.ts | 28 +- .../paseo-agent/prompt-profiles.test.ts | 167 +++++++++++ .../providers/paseo-agent/prompt-profiles.ts | 267 ++++++++++++++++++ packages/server/src/server/bootstrap.ts | 1 + 13 files changed, 756 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 2957452c74..0d2ab40915 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -27,6 +27,64 @@ Transports: HTTP (streamable) is the primary path (the injected `paseo` server i SSE and stdio transports are also wired via the MCP SDK. No extra config is needed — MCP servers come from Paseo's normal injection/config, not from `agents.paseo`. +## Prompt profiles + +Paseo Agent can load a Paseo-owned prompt profile from `$PASEO_HOME/agents/*.md`. +Configure the default profile in `agents.paseo.defaultProfile`; `orchestrator` resolves +to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are profiles. +Reusable fragments live under `$PASEO_HOME/agents/fragments/*.md`. + +```jsonc +{ + "agents": { + "paseo": { + "defaultProfile": "orchestrator", + "defaultModel": "openrouter-main/anthropic/claude-3.7-sonnet", + "providers": {}, + }, + }, +} +``` + +Example `$PASEO_HOME/agents/orchestrator.md`: + +```markdown +--- +name: Orchestrator +description: Coordinates work through Paseo-managed agents +mode: extend +include: + - fragments/collaboration.md +mcp: [paseo] +model: openrouter-main/anthropic/claude-3.7-sonnet +--- + +Use the Paseo MCP tools to inspect active agents, create focused helper agents, and +summarize handoffs clearly. + +{{include: fragments/review-rules.md}} +``` + +`mode: extend` keeps Pi's default base prompt and prepends the composed profile body to +the append list. `mode: override` uses the profile body as the custom base prompt, so +Pi's default base prompt is skipped. In both modes, per-session `systemPrompt` is appended +after the profile, and the daemon-level append prompt is appended last. + +Frontmatter supports `name`, `description`, `mode`, `include`, `mcp`, `model`, and +`projectContext`. `projectContext` is parsed for a future explicit project-context model, +but it does not activate implicit `AGENTS.md`/`CLAUDE.md` discovery; Paseo Agent still +keeps Pi context discovery off. `model` is only a lowest-precedence default: an explicit +session model wins, then `agents.paseo.defaultModel`, then the profile model. + +Includes are deliberately confined to `$PASEO_HOME/agents`: absolute paths, `..` escapes, +cycles, overly deep include chains, and oversized profiles are rejected. Frontmatter +`include` entries are prepended in order; inline `{{include: fragments/foo.md}}` entries +are expanded in place. + +`mcp: [paseo]` is an expectation check, not a new injection mechanism. The normal daemon +MCP injection still supplies the actual server; if a profile declares an MCP server that +is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues. + ## Config shape ```jsonc @@ -37,6 +95,10 @@ servers come from Paseo's normal injection/config, not from `agents.paseo`. // started without an explicit model. "defaultModel": "openrouter-main/anthropic/claude-3.7-sonnet", + // Optional. Loads $PASEO_HOME/agents/orchestrator.md by default for new + // Paseo Agent sessions. + "defaultProfile": "orchestrator", + // Inference providers, keyed by instance name. Names are free-form; you may // run several entries of the same type against different APIs/keys/models. "providers": { diff --git a/package-lock.json b/package-lock.json index 7cee03e4a8..2e93038506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40423,6 +40423,7 @@ "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", + "yaml": "^2.8.4", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/server/package.json b/packages/server/package.json index 4035c7f4b3..a70a26919c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -98,6 +98,7 @@ "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", + "yaml": "^2.8.4", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index c7a6f933c0..76999a2542 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -74,6 +74,7 @@ export interface BuildProviderRegistryOptions { providerOverrides?: Record; workspaceGitService?: Pick; isDev?: boolean; + paseoHome?: string; /** * Opaque Paseo Agent config blob. The registry only forwards it to the * paseo-agent client factory; it never reads the nested inference providers. @@ -83,7 +84,7 @@ export interface BuildProviderRegistryOptions { interface ProviderClientFactoryOptions extends Pick< BuildProviderRegistryOptions, - "workspaceGitService" | "paseoAgentConfig" + "workspaceGitService" | "paseoAgentConfig" | "paseoHome" > { providerParams?: unknown; customProvider?: { @@ -160,6 +161,7 @@ const PROVIDER_CLIENT_FACTORIES: Record = { new PaseoAgentClient({ logger, config: options?.paseoAgentConfig ?? {}, + paseoHome: options?.paseoHome, }), mock: (logger) => new MockLoadTestAgentClient(logger), "mock-slow": () => new MockSlowProviderClient(), @@ -540,7 +542,10 @@ function createResolvedProviderClient( function buildResolvedBuiltinProviders( providerOverrides: Record, runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, - options: Pick, + options: Pick< + BuildProviderRegistryOptions, + "workspaceGitService" | "paseoAgentConfig" | "paseoHome" + >, isDev: boolean, ): Map { const resolvedProviders = new Map(); @@ -571,6 +576,7 @@ function buildResolvedBuiltinProviders( workspaceGitService: options.workspaceGitService, providerParams: override?.params, paseoAgentConfig: options.paseoAgentConfig, + paseoHome: options.paseoHome, }), }); } @@ -689,6 +695,7 @@ export function buildProviderRegistry( { workspaceGitService: options?.workspaceGitService, paseoAgentConfig: options?.paseoAgentConfig, + paseoHome: options?.paseoHome, }, options?.isDev === true, ); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 6ba84060bf..e6b150676c 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -61,6 +61,7 @@ export interface ProviderSnapshotManagerOptions { extraClients?: Partial>; refreshTimeoutMs?: number; paseoAgentConfig?: PaseoAgentConfig; + paseoHome?: string; } interface ProviderSnapshotRefreshOptions { @@ -131,6 +132,7 @@ export class ProviderSnapshotManager { private readonly workspaceGitService?: Pick; private readonly isDev: boolean; private readonly extraClients: Partial>; + private readonly paseoHome: string | undefined; private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private readonly baseProviderOverrides: Record | undefined; @@ -143,6 +145,7 @@ export class ProviderSnapshotManager { this.workspaceGitService = options.workspaceGitService; this.isDev = options.isDev === true; this.extraClients = options.extraClients ?? {}; + this.paseoHome = options.paseoHome; this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; @@ -372,6 +375,7 @@ export class ProviderSnapshotManager { workspaceGitService: this.workspaceGitService, isDev: this.isDev, paseoAgentConfig: this.paseoAgentConfig, + paseoHome: this.paseoHome, }); for (const [provider, client] of Object.entries(this.extraClients) as Array< diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts index 55358deb1f..27df8b7c06 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; +import { afterEach, describe, expect, it } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { AgentSessionConfig } from "../../agent-sdk-types.js"; @@ -26,7 +30,30 @@ function sessionConfig(overrides?: Partial): AgentSessionCon return { provider: "paseo", cwd: process.cwd(), ...overrides }; } +function createRecordingLogger(): Logger & { warnings: Array<{ data: unknown; message: string }> } { + const warnings: Array<{ data: unknown; message: string }> = []; + const logger = { + warnings, + child: () => logger, + debug: () => {}, + warn: (data: unknown, message: string) => { + warnings.push({ data, message }); + }, + error: () => {}, + info: () => {}, + } as Logger & { warnings: Array<{ data: unknown; message: string }> }; + return logger; +} + describe("PaseoAgentClient", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("is available only when config has a usable inference provider", async () => { const withConfig = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); expect(await withConfig.isAvailable()).toBe(true); @@ -91,4 +118,108 @@ describe("PaseoAgentClient", () => { await session.close(); } }); + + it("uses the configured default prompt profile as a lowest-precedence model default", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + tempDirs.push(paseoHome); + mkdirSync(join(paseoHome, "agents"), { recursive: true }); + writeFileSync( + join(paseoHome, "agents", "orchestrator.md"), + `--- +model: openrouter-main/b +--- +Profile prompt. +`, + ); + const config = PaseoAgentConfigSchema.parse({ + defaultProfile: "orchestrator", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "a" }, { id: "b" }], + }, + }, + }, + }); + const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome }); + const session = await client.createSession(sessionConfig()); + try { + const info = await session.getRuntimeInfo(); + expect(info.model).toBe("openrouter-main/b"); + } finally { + await session.close(); + } + }); + + it("prefers the configured default model over the profile default model", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + tempDirs.push(paseoHome); + mkdirSync(join(paseoHome, "agents"), { recursive: true }); + writeFileSync( + join(paseoHome, "agents", "orchestrator.md"), + `--- +model: openrouter-main/b +--- +Profile prompt. +`, + ); + const config = PaseoAgentConfigSchema.parse({ + defaultProfile: "orchestrator", + defaultModel: "openrouter-main/a", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "a" }, { id: "b" }], + }, + }, + }, + }); + const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome }); + const session = await client.createSession(sessionConfig()); + try { + const info = await session.getRuntimeInfo(); + expect(info.model).toBe("openrouter-main/a"); + } finally { + await session.close(); + } + }); + + it("warns when the configured profile expects a missing MCP server", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + tempDirs.push(paseoHome); + mkdirSync(join(paseoHome, "agents"), { recursive: true }); + writeFileSync( + join(paseoHome, "agents", "orchestrator.md"), + `--- +mcp: [paseo, paseo] +--- +Profile prompt. +`, + ); + const logger = createRecordingLogger(); + const client = new PaseoAgentClient({ + logger, + config: PaseoAgentConfigSchema.parse({ ...makeConfig(), defaultProfile: "orchestrator" }), + paseoHome, + }); + const session = await client.createSession(sessionConfig()); + try { + expect(logger.warnings).toEqual([ + { + data: expect.objectContaining({ mcpServer: "paseo" }), + message: expect.stringMatching(/expects an MCP server/i), + }, + ]); + } finally { + await session.close(); + } + }); }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.ts index 0a39ed779f..d02e9644eb 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -48,6 +48,7 @@ import { import { createMcpToolBridge, type McpToolBridge } from "./mcp-bridge.js"; import { createPaseoAgentAuthStorage, hasStoredOAuthCredential } from "./oauth-store.js"; import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js"; +import { composePromptParts, loadPromptProfile } from "./prompt-profiles.js"; const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; @@ -85,6 +86,7 @@ function resolveIsolatedAgentDir(): string { interface PaseoAgentClientOptions { logger: Logger; config: PaseoAgentConfig; + paseoHome?: string; } export class PaseoAgentSession implements AgentSession { @@ -492,10 +494,12 @@ export class PaseoAgentClient implements AgentClient { private readonly logger: Logger; private readonly config: PaseoAgentConfig; + private readonly paseoHome: string | undefined; constructor(options: PaseoAgentClientOptions) { this.logger = options.logger; this.config = options.config; + this.paseoHome = options.paseoHome; } async createSession( @@ -509,10 +513,26 @@ export class PaseoAgentClient implements AgentClient { ); } - const model = resolvePaseoAgentModel(this.config, config.model, inferenceProviders); + const profile = this.loadDefaultProfile(); + this.verifyExpectedMcpServers(profile?.expectedMcpServers ?? [], config.mcpServers); + const model = resolvePaseoAgentModel( + this.config, + config.model, + inferenceProviders, + profile?.model, + ); const thinkingLevel = normalizeThinkingLevel(config.thinkingOptionId) ?? undefined; + const composedPrompt = composePromptParts({ + profile, + systemPrompt: config.systemPrompt, + daemonAppendSystemPrompt: config.daemonAppendSystemPrompt, + }); this.logger.debug( - { provider: PASEO_AGENT_PROVIDER, model: model ? `${model.provider}/${model.id}` : null }, + { + provider: PASEO_AGENT_PROVIDER, + model: model ? `${model.provider}/${model.id}` : null, + promptProfile: profile?.id ?? null, + }, "Creating Paseo Agent session", ); @@ -536,6 +556,7 @@ export class PaseoAgentClient implements AgentClient { ...(thinkingLevel ? { thinkingLevel } : {}), ...(authStorage ? { authStorage } : {}), ...(mcpBridge.tools.length > 0 ? { customTools: mcpBridge.tools } : {}), + ...(composedPrompt ? { composedPrompt } : {}), }); return new PaseoAgentSession(handle, config, mcpBridge); } catch (error) { @@ -557,4 +578,48 @@ export class PaseoAgentClient implements AgentClient { hasStoredOAuthCredential(providerInstance), ); } + + private loadDefaultProfile() { + if (!this.paseoHome || !this.config.defaultProfile) { + return null; + } + try { + const profile = loadPromptProfile(this.paseoHome, this.config.defaultProfile); + if (!profile) { + this.logger.warn( + { provider: PASEO_AGENT_PROVIDER, promptProfile: this.config.defaultProfile }, + "Configured Paseo Agent prompt profile was not found", + ); + } + return profile; + } catch (error) { + this.logger.warn( + { + provider: PASEO_AGENT_PROVIDER, + promptProfile: this.config.defaultProfile, + error: error instanceof Error ? error.message : String(error), + }, + "Configured Paseo Agent prompt profile could not be loaded", + ); + return null; + } + } + + private verifyExpectedMcpServers( + expectedServers: string[], + configuredServers: AgentSessionConfig["mcpServers"], + ): void { + for (const serverName of new Set(expectedServers)) { + if (!configuredServers?.[serverName]) { + this.logger.warn( + { + provider: PASEO_AGENT_PROVIDER, + promptProfile: this.config.defaultProfile, + mcpServer: serverName, + }, + "Paseo Agent prompt profile expects an MCP server that is not configured for this session", + ); + } + } + } } diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index 540f41dd36..a6b6da310f 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -163,6 +163,8 @@ export const PaseoAgentConfigSchema = z .object({ // Optional default model as "/". defaultModel: z.string().min(1).optional(), + // Optional default prompt profile from $PASEO_HOME/agents/.md. + defaultProfile: z.string().min(1).optional(), // Inference providers keyed by instance name. Multiple entries may share a // type while pointing at different APIs/base URLs/models. providers: z.record(PaseoAgentInferenceProviderSchema).optional(), @@ -355,12 +357,13 @@ export function resolvePaseoAgentModel( config: PaseoAgentConfig, requestedModelId: string | null | undefined, registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config), + profileDefaultModelId?: string | null, ): PaseoAgentModelReference | undefined { if (requestedModelId) { return parsePaseoAgentModelId(requestedModelId) ?? undefined; } - for (const candidate of [config.defaultModel, firstModelId(config)]) { + for (const candidate of [config.defaultModel, profileDefaultModelId, firstModelId(config)]) { if (!candidate) { continue; } diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts index 4b396f8a5b..628c98ad66 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts @@ -116,6 +116,20 @@ describe("createPaseoAgentSession (no-discovery spike)", () => { expect(resourceLoader.getPrompts().prompts).toHaveLength(0); }); + it("exposes composed prompts through the resource loader without discovery", async () => { + const { resourceLoader } = await createPaseoAgentSession({ + ...baseOptions(), + composedPrompt: { + customPrompt: "Custom Paseo base prompt.", + appendSystemPrompt: ["Profile append.", "Daemon append."], + }, + }); + + expect(resourceLoader.getSystemPrompt()).toBe("Custom Paseo base prompt."); + expect(resourceLoader.getAppendSystemPrompt()).toEqual(["Profile append.", "Daemon append."]); + expect(resourceLoader.getAgentsFiles().agentsFiles).toHaveLength(0); + }); + it("uses an in-memory session manager with no on-disk session file", async () => { const { sessionManager } = await createPaseoAgentSession(baseOptions()); diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts index a48d8f8379..2bb96eeac3 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts @@ -12,6 +12,7 @@ import { import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { openaiCodexOAuthProvider } from "@earendil-works/pi-ai/oauth"; +import type { PaseoComposedPrompt } from "./prompt-profiles.js"; // Re-export the Pi tool contract so the MCP bridge can build custom tools without // importing the Pi SDK type names itself. @@ -86,6 +87,8 @@ export interface CreatePaseoAgentSessionOptions { settings?: PiSettings; /** Paseo-bridged tools (e.g. MCP) to register alongside built-in tools. */ customTools?: ToolDefinition[]; + /** Paseo-composed prompt profile/session/daemon instructions. */ + composedPrompt?: PaseoComposedPrompt; } export interface PaseoAgentSessionHandle { @@ -106,8 +109,9 @@ function createNoDiscoveryResourceLoader(options: { cwd: string; agentDir: string; settingsManager: SettingsManager; + composedPrompt?: PaseoComposedPrompt; }): ResourceLoader { - return new DefaultResourceLoader({ + const base = new DefaultResourceLoader({ cwd: options.cwd, agentDir: options.agentDir, settingsManager: options.settingsManager, @@ -117,6 +121,27 @@ function createNoDiscoveryResourceLoader(options: { noThemes: true, noContextFiles: true, }); + return options.composedPrompt ? wrapPromptResourceLoader(base, options.composedPrompt) : base; +} + +function wrapPromptResourceLoader( + delegate: ResourceLoader, + composedPrompt: PaseoComposedPrompt, +): ResourceLoader { + return { + getExtensions: () => delegate.getExtensions(), + getSkills: () => delegate.getSkills(), + getPrompts: () => delegate.getPrompts(), + getThemes: () => delegate.getThemes(), + getAgentsFiles: () => delegate.getAgentsFiles(), + getSystemPrompt: () => composedPrompt.customPrompt ?? delegate.getSystemPrompt(), + getAppendSystemPrompt: () => [ + ...delegate.getAppendSystemPrompt(), + ...composedPrompt.appendSystemPrompt, + ], + extendResources: (paths) => delegate.extendResources(paths), + reload: () => delegate.reload(), + }; } /** @@ -160,6 +185,7 @@ export async function createPaseoAgentSession( cwd: options.cwd, agentDir: options.agentDir, settingsManager, + composedPrompt: options.composedPrompt, }); const model = options.model diff --git a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts new file mode 100644 index 0000000000..47106e136d --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts @@ -0,0 +1,167 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { composePromptParts, listPromptProfileIds, loadPromptProfile } from "./prompt-profiles.js"; + +describe("Paseo Agent prompt profiles", () => { + let paseoHome: string; + let agentsDir: string; + const tempDirs: string[] = []; + + beforeEach(() => { + paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-profiles-")); + agentsDir = join(paseoHome, "agents"); + mkdirSync(join(agentsDir, "fragments"), { recursive: true }); + }); + + afterEach(() => { + rmSync(paseoHome, { recursive: true, force: true }); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function writeProfile(name: string, content: string): void { + writeFileSync(join(agentsDir, name), content); + } + + it("parses frontmatter and lists only top-level markdown profiles", () => { + writeProfile( + "orchestrator.md", + `--- +name: Orchestrator +description: Routes work +mode: override +mcp: [paseo] +model: openrouter-main/test-model +projectContext: true +--- +Profile body. +`, + ); + writeProfile("notes.txt", "ignored"); + writeFileSync(join(agentsDir, "fragments", "piece.md"), "fragment"); + + const profile = loadPromptProfile(paseoHome, "orchestrator"); + + expect(listPromptProfileIds(paseoHome)).toEqual(["orchestrator"]); + expect(profile?.frontmatter).toMatchObject({ + name: "Orchestrator", + description: "Routes work", + mode: "override", + mcp: ["paseo"], + model: "openrouter-main/test-model", + projectContext: true, + }); + expect(profile?.composedPrompt.customPrompt).toBe("Profile body."); + }); + + it("defaults to extend mode and prepends frontmatter includes in order", () => { + writeFileSync(join(agentsDir, "fragments", "a.md"), "Fragment A"); + writeFileSync(join(agentsDir, "fragments", "b.md"), "Fragment B"); + writeProfile( + "worker.md", + `--- +include: + - fragments/a.md + - fragments/b.md +--- +Body. +`, + ); + + const profile = loadPromptProfile(paseoHome, "worker.md"); + + expect(profile?.frontmatter.mode).toBe("extend"); + expect(profile?.body).toBe("Fragment A\n\nFragment B\n\nBody."); + expect(profile?.composedPrompt.appendSystemPrompt).toEqual([ + "Fragment A\n\nFragment B\n\nBody.", + ]); + }); + + it("resolves inline includes in place", () => { + writeFileSync(join(agentsDir, "fragments", "style.md"), "Use short answers."); + writeProfile("inline.md", "Before\n{{include: fragments/style.md}}\nAfter"); + + expect(loadPromptProfile(paseoHome, "inline")?.body).toBe("Before\nUse short answers.\nAfter"); + }); + + it("detects include cycles", () => { + writeFileSync(join(agentsDir, "fragments", "a.md"), "{{include: fragments/b.md}}"); + writeFileSync(join(agentsDir, "fragments", "b.md"), "{{include: fragments/a.md}}"); + writeProfile("cycle.md", "{{include: fragments/a.md}}"); + + expect(() => loadPromptProfile(paseoHome, "cycle")).toThrow(/cycle/i); + }); + + it("rejects missing fragments and path escapes", () => { + writeProfile("missing.md", "{{include: fragments/nope.md}}"); + writeProfile("escape.md", "{{include: ../secret.md}}"); + + expect(() => loadPromptProfile(paseoHome, "missing")).toThrow(/not found/i); + expect(() => loadPromptProfile(paseoHome, "escape")).toThrow(/escape|invalid/i); + expect(() => loadPromptProfile(paseoHome, "../escape")).toThrow(/invalid/i); + }); + + it("rejects symlink escapes for profiles and includes", () => { + const outsideDir = mkdtempSync(join(tmpdir(), "paseo-agent-profile-outside-")); + tempDirs.push(outsideDir); + writeFileSync(join(outsideDir, "secret.md"), "outside secret"); + symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "linked-profile.md")); + symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "fragments", "linked.md")); + writeProfile("include-link.md", "{{include: fragments/linked.md}}"); + + expect(() => loadPromptProfile(paseoHome, "linked-profile")).toThrow(/escapes/i); + expect(() => loadPromptProfile(paseoHome, "include-link")).toThrow(/escapes/i); + }); + + it("enforces depth and total size caps", () => { + writeFileSync(join(agentsDir, "fragments", "deep.md"), "{{include: fragments/deeper.md}}"); + writeFileSync(join(agentsDir, "fragments", "deeper.md"), "done"); + writeProfile("depth.md", "{{include: fragments/deep.md}}"); + writeProfile("large.md", "0123456789"); + + expect(() => loadPromptProfile(paseoHome, "depth", { maxDepth: 1 })).toThrow(/depth/i); + expect(() => loadPromptProfile(paseoHome, "large", { maxTotalBytes: 4 })).toThrow(/bytes/i); + }); + + it("orders profile append, session prompt, and daemon append with daemon last", () => { + writeProfile("extend.md", "Profile prompt."); + const profile = loadPromptProfile(paseoHome, "extend"); + + expect( + composePromptParts({ + profile, + systemPrompt: " Agent prompt. ", + daemonAppendSystemPrompt: "Daemon prompt.", + }), + ).toEqual({ + appendSystemPrompt: ["Profile prompt.", "Agent prompt.", "Daemon prompt."], + }); + }); + + it("uses override profile body as custom prompt while appending session and daemon prompts", () => { + writeProfile( + "override.md", + `--- +mode: override +--- +Replacement base. +`, + ); + const profile = loadPromptProfile(paseoHome, "override"); + + expect( + composePromptParts({ + profile, + systemPrompt: "Agent prompt.", + daemonAppendSystemPrompt: "Daemon prompt.", + }), + ).toEqual({ + customPrompt: "Replacement base.", + appendSystemPrompt: ["Agent prompt.", "Daemon prompt."], + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts new file mode 100644 index 0000000000..49963a0f9e --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts @@ -0,0 +1,267 @@ +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, extname, isAbsolute, relative, resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; + +const DEFAULT_MAX_DEPTH = 8; +const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024; +const INLINE_INCLUDE_PATTERN = /\{\{\s*include:\s*([^}]+?)\s*\}\}/g; + +const PromptProfileFrontmatterSchema = z + .object({ + name: z.string().min(1).optional(), + description: z.string().min(1).optional(), + mode: z.enum(["extend", "override"]).default("extend"), + include: z.array(z.string().min(1)).optional(), + mcp: z.array(z.string().min(1)).optional(), + model: z.string().min(1).optional(), + // Parsed for the future explicit project-context model. It is intentionally + // inactive here; Paseo Agent still keeps implicit AGENTS.md discovery off. + projectContext: z.boolean().optional(), + }) + .strict(); + +export type PromptProfileFrontmatter = z.infer; + +export interface PaseoComposedPrompt { + customPrompt?: string; + appendSystemPrompt: string[]; +} + +export interface ResolvedPromptProfile { + id: string; + path: string; + frontmatter: PromptProfileFrontmatter; + body: string; + composedPrompt: PaseoComposedPrompt; + expectedMcpServers: string[]; + model?: string; +} + +interface LoadPromptProfileOptions { + maxDepth?: number; + maxTotalBytes?: number; +} + +interface LoadState { + totalBytes: number; +} + +interface ParsedMarkdown { + frontmatter: PromptProfileFrontmatter; + body: string; +} + +export function loadPromptProfile( + paseoHome: string, + profileName: string | undefined, + options: LoadPromptProfileOptions = {}, +): ResolvedPromptProfile | null { + if (!profileName) { + return null; + } + + const agentsDir = resolve(paseoHome, "agents"); + const profilePath = resolveProfilePath(agentsDir, profileName); + if (!existsSync(profilePath)) { + return null; + } + + const state: LoadState = { totalBytes: 0 }; + const parsed = loadMarkdownWithIncludes({ + agentsDir, + path: profilePath, + depth: 0, + stack: [], + state, + options, + }); + const body = trimPrompt(parsed.body); + const mode = parsed.frontmatter.mode; + const composedPrompt = + mode === "override" + ? { customPrompt: body, appendSystemPrompt: [] } + : { appendSystemPrompt: body ? [body] : [] }; + + return { + id: basename(profilePath, ".md"), + path: profilePath, + frontmatter: parsed.frontmatter, + body, + composedPrompt, + expectedMcpServers: parsed.frontmatter.mcp ?? [], + ...(parsed.frontmatter.model ? { model: parsed.frontmatter.model } : {}), + }; +} + +export function listPromptProfileIds(paseoHome: string): string[] { + const agentsDir = resolve(paseoHome, "agents"); + if (!existsSync(agentsDir)) { + return []; + } + return readdirSync(agentsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && extname(entry.name) === ".md") + .map((entry) => basename(entry.name, ".md")) + .sort(); +} + +function resolveProfilePath(agentsDir: string, profileName: string): string { + if (!isSafeRelativePath(profileName) || profileName.includes("/") || profileName.includes("\\")) { + throw new Error(`Invalid Paseo Agent prompt profile path: ${profileName}`); + } + const filename = profileName.endsWith(".md") ? profileName : `${profileName}.md`; + return resolveConfinedPath(agentsDir, filename); +} + +function resolveIncludePath(agentsDir: string, includePath: string): string { + if (!isSafeRelativePath(includePath)) { + throw new Error(`Invalid Paseo Agent prompt include path: ${includePath}`); + } + return resolveConfinedPath(agentsDir, includePath); +} + +function isSafeRelativePath(input: string): boolean { + return input.trim() === input && input.length > 0 && !isAbsolute(input) && !input.includes("\0"); +} + +function resolveConfinedPath(agentsDir: string, input: string): string { + const resolved = resolve(agentsDir, input); + const rel = relative(agentsDir, resolved); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`Paseo Agent prompt path escapes agents directory: ${input}`); + } + return resolved; +} + +function loadMarkdownWithIncludes(input: { + agentsDir: string; + path: string; + depth: number; + stack: string[]; + state: LoadState; + options: LoadPromptProfileOptions; +}): ParsedMarkdown { + const maxDepth = input.options.maxDepth ?? DEFAULT_MAX_DEPTH; + if (input.depth > maxDepth) { + throw new Error(`Paseo Agent prompt include depth exceeds ${maxDepth}`); + } + if (!existsSync(input.path) || !statSync(input.path).isFile()) { + throw new Error( + `Paseo Agent prompt include not found: ${relative(input.agentsDir, input.path)}`, + ); + } + + const path = realpathConfined(input.agentsDir, input.path); + if (input.stack.includes(path)) { + const cycle = [...input.stack, path].map((entry) => relative(input.agentsDir, entry)); + throw new Error(`Paseo Agent prompt include cycle: ${cycle.join(" -> ")}`); + } + + const raw = readFileSync(path, "utf8"); + input.state.totalBytes += Buffer.byteLength(raw, "utf8"); + const maxTotalBytes = input.options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES; + if (input.state.totalBytes > maxTotalBytes) { + throw new Error(`Paseo Agent prompt profile exceeds ${maxTotalBytes} bytes`); + } + + const parsed = parseMarkdown(raw); + const stack = [...input.stack, path]; + const frontmatterIncludes = parsed.frontmatter.include ?? []; + const prepended = frontmatterIncludes.map( + (includePath) => + loadMarkdownWithIncludes({ + ...input, + path: resolveIncludePath(input.agentsDir, includePath), + depth: input.depth + 1, + stack, + }).body, + ); + const bodyWithInlineIncludes = parsed.body.replace( + INLINE_INCLUDE_PATTERN, + (_match, includePath: string) => + loadMarkdownWithIncludes({ + ...input, + path: resolveIncludePath(input.agentsDir, includePath.trim()), + depth: input.depth + 1, + stack, + }).body, + ); + + return { + frontmatter: parsed.frontmatter, + body: joinPromptParts([...prepended, bodyWithInlineIncludes]), + }; +} + +function realpathConfined(agentsDir: string, path: string): string { + const realAgentsDir = realpathSync(agentsDir); + const realPath = realpathSync(path); + const rel = relative(realAgentsDir, realPath); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error( + `Paseo Agent prompt path escapes agents directory: ${relative(agentsDir, path)}`, + ); + } + return realPath; +} + +function parseMarkdown(raw: string): ParsedMarkdown { + if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) { + return { + frontmatter: PromptProfileFrontmatterSchema.parse({}), + body: raw, + }; + } + + const newline = raw.startsWith("---\r\n") ? "\r\n" : "\n"; + const closeMarker = `${newline}---${newline}`; + const closeIndex = raw.indexOf(closeMarker, 4); + if (closeIndex === -1) { + throw new Error("Paseo Agent prompt profile has unterminated frontmatter"); + } + + const yaml = raw.slice(4, closeIndex); + const body = raw.slice(closeIndex + closeMarker.length); + const value = yaml.trim() ? parseYaml(yaml) : {}; + return { + frontmatter: PromptProfileFrontmatterSchema.parse(value ?? {}), + body, + }; +} + +export function composePromptParts(input: { + profile?: ResolvedPromptProfile | null; + systemPrompt?: string; + daemonAppendSystemPrompt?: string; +}): PaseoComposedPrompt | undefined { + const profilePrompt = input.profile?.composedPrompt; + const appendSystemPrompt = [ + ...(profilePrompt?.appendSystemPrompt ?? []), + input.systemPrompt, + input.daemonAppendSystemPrompt, + ].flatMap((part) => { + const trimmed = trimPrompt(part); + return trimmed ? [trimmed] : []; + }); + const hasCustomPrompt = Boolean( + profilePrompt && Object.prototype.hasOwnProperty.call(profilePrompt, "customPrompt"), + ); + const customPrompt = trimPrompt(profilePrompt?.customPrompt); + + if (!hasCustomPrompt && appendSystemPrompt.length === 0) { + return undefined; + } + + return { + ...(hasCustomPrompt ? { customPrompt } : {}), + appendSystemPrompt, + }; +} + +function trimPrompt(value: string | undefined): string { + return value?.trim() ?? ""; +} + +function joinPromptParts(parts: string[]): string { + return parts.map(trimPrompt).filter(Boolean).join("\n\n"); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 8697c0bb62..d226a90fb9 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -644,6 +644,7 @@ export async function createPaseoDaemon( runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, paseoAgentConfig: config.paseoAgentConfig, + paseoHome: config.paseoHome, workspaceGitService, isDev: config.isDev === true, extraClients: config.agentClients, From 02e45d2ca18d029364a078b46f3add89889c7848 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 30 May 2026 00:29:37 +0700 Subject: [PATCH 04/16] Fix Paseo Agent ChatGPT auth home lookup --- .../agent/providers/paseo-agent/agent.test.ts | 33 +++++++++++++++++++ .../agent/providers/paseo-agent/agent.ts | 13 ++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts index 27df8b7c06..58b7e4ec7d 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts @@ -8,6 +8,7 @@ import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { AgentSessionConfig } from "../../agent-sdk-types.js"; import { PaseoAgentClient } from "./agent.js"; import { PaseoAgentConfigSchema, type PaseoAgentConfig } from "./config.js"; +import { storeCodexOAuthCredential } from "./oauth-store.js"; function makeConfig(): PaseoAgentConfig { return PaseoAgentConfigSchema.parse({ @@ -65,6 +66,38 @@ describe("PaseoAgentClient", () => { expect(await empty.isAvailable()).toBe(false); }); + it("checks ChatGPT OAuth credentials in the configured Paseo home", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + const wrongHome = mkdtempSync(join(tmpdir(), "paseo-agent-wrong-home-")); + tempDirs.push(paseoHome, wrongHome); + const previousPaseoHome = process.env.PASEO_HOME; + process.env.PASEO_HOME = wrongHome; + storeCodexOAuthCredential({ + providerInstance: "chatgpt", + credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 }, + env: { PASEO_HOME: paseoHome }, + }); + const config = PaseoAgentConfigSchema.parse({ + providers: { + chatgpt: { + type: "openai-codex", + options: { models: [{ id: "gpt-5.3-codex" }] }, + }, + }, + }); + + try { + const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome }); + expect(await client.isAvailable()).toBe(true); + } finally { + if (previousPaseoHome === undefined) { + delete process.env.PASEO_HOME; + } else { + process.env.PASEO_HOME = previousPaseoHome; + } + } + }); + it("lists only configured models, never Pi disk/default models", async () => { const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); const models = await client.listModels({ cwd: process.cwd(), force: false }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.ts index d02e9644eb..da29acc3f6 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -83,6 +83,10 @@ function resolveIsolatedAgentDir(): string { return join(base, "pi-harness"); } +function envForPaseoHome(paseoHome: string | undefined): NodeJS.ProcessEnv { + return paseoHome ? { ...process.env, PASEO_HOME: paseoHome } : process.env; +} + interface PaseoAgentClientOptions { logger: Logger; config: PaseoAgentConfig; @@ -539,7 +543,9 @@ export class PaseoAgentClient implements AgentClient { // OAuth providers (ChatGPT/Codex) use a Paseo-owned, file-backed AuthStorage so Pi // reads the stored credential and persists refreshed tokens (rotation) back to it. const usesOAuth = inferenceProviders.some((provider) => provider.oauth); - const authStorage = usesOAuth ? createPaseoAgentAuthStorage() : undefined; + const authStorage = usesOAuth + ? createPaseoAgentAuthStorage(envForPaseoHome(this.paseoHome)) + : undefined; // Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools. const mcpBridge = await createMcpToolBridge({ @@ -574,8 +580,9 @@ export class PaseoAgentClient implements AgentClient { } async isAvailable(): Promise { - return paseoAgentHasUsableModel(this.config, process.env, (providerInstance) => - hasStoredOAuthCredential(providerInstance), + const env = envForPaseoHome(this.paseoHome); + return paseoAgentHasUsableModel(this.config, env, (providerInstance) => + hasStoredOAuthCredential(providerInstance, env), ); } From b7f69a6ced80f2690b958172c1c432fb1e264d0b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 11 Jun 2026 12:38:13 +0700 Subject: [PATCH 05/16] Add first-class Paseo Agent definitions --- docs/paseo-agent.md | 66 ++-- .../src/hooks/use-paseo-agent-providers.ts | 2 +- packages/cli/src/commands/login/index.test.ts | 26 +- packages/cli/src/commands/login/index.ts | 89 +++-- packages/cli/src/utils/open-browser.test.ts | 81 +++++ packages/cli/src/utils/open-browser.ts | 34 +- packages/protocol/src/messages.ts | 24 -- .../paseo-agent/agent-permissions.test.ts | 31 ++ .../paseo-agent/agent-permissions.ts | 51 +++ .../agent/providers/paseo-agent/agent.test.ts | 306 +++++++++++++++- .../agent/providers/paseo-agent/agent.ts | 341 +++++++++++------- .../paseo-agent/config-service.test.ts | 22 +- .../providers/paseo-agent/config-service.ts | 10 - .../providers/paseo-agent/config.test.ts | 8 +- .../agent/providers/paseo-agent/config.ts | 8 +- .../providers/paseo-agent/pi-services.test.ts | 80 ++++ .../providers/paseo-agent/pi-services.ts | 40 +- .../paseo-agent/prompt-profiles.test.ts | 146 ++++---- .../providers/paseo-agent/prompt-profiles.ts | 164 +++++---- .../src/server/daemon-config-store.test.ts | 12 +- .../server/src/server/daemon-config-store.ts | 18 +- .../src/server/persisted-config.test.ts | 4 + packages/server/src/server/session.ts | 31 -- .../public/schemas/paseo.config.v1.json | 4 + 24 files changed, 1158 insertions(+), 440 deletions(-) create mode 100644 packages/cli/src/utils/open-browser.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/agent-permissions.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 0d2ab40915..fc78538e0a 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -27,18 +27,18 @@ Transports: HTTP (streamable) is the primary path (the injected `paseo` server i SSE and stdio transports are also wired via the MCP SDK. No extra config is needed — MCP servers come from Paseo's normal injection/config, not from `agents.paseo`. -## Prompt profiles +## Agent definitions -Paseo Agent can load a Paseo-owned prompt profile from `$PASEO_HOME/agents/*.md`. -Configure the default profile in `agents.paseo.defaultProfile`; `orchestrator` resolves -to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are profiles. -Reusable fragments live under `$PASEO_HOME/agents/fragments/*.md`. +Paseo Agent can load a Paseo-owned agent definition from `$PASEO_HOME/agents/*.md`. +Configure the default agent in `agents.paseo.defaultAgent`; `orchestrator` resolves +to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are selectable +agents. Reusable partials can live anywhere under `$PASEO_HOME/agents`. ```jsonc { "agents": { "paseo": { - "defaultProfile": "orchestrator", + "defaultAgent": "orchestrator", "defaultModel": "openrouter-main/anthropic/claude-3.7-sonnet", "providers": {}, }, @@ -52,37 +52,48 @@ Example `$PASEO_HOME/agents/orchestrator.md`: --- name: Orchestrator description: Coordinates work through Paseo-managed agents -mode: extend -include: - - fragments/collaboration.md +prompt: extend mcp: [paseo] model: openrouter-main/anthropic/claude-3.7-sonnet +tools: [read, grep, paseo__list_agents, paseo__create_agent] +permissions: + - tool: paseo__archive_* + action: deny --- +!{{./partials/collaboration.md}} + Use the Paseo MCP tools to inspect active agents, create focused helper agents, and summarize handoffs clearly. -{{include: fragments/review-rules.md}} +!{{./partials/review-rules.md}} ``` -`mode: extend` keeps Pi's default base prompt and prepends the composed profile body to -the append list. `mode: override` uses the profile body as the custom base prompt, so -Pi's default base prompt is skipped. In both modes, per-session `systemPrompt` is appended -after the profile, and the daemon-level append prompt is appended last. - -Frontmatter supports `name`, `description`, `mode`, `include`, `mcp`, `model`, and -`projectContext`. `projectContext` is parsed for a future explicit project-context model, -but it does not activate implicit `AGENTS.md`/`CLAUDE.md` discovery; Paseo Agent still -keeps Pi context discovery off. `model` is only a lowest-precedence default: an explicit -session model wins, then `agents.paseo.defaultModel`, then the profile model. - -Includes are deliberately confined to `$PASEO_HOME/agents`: absolute paths, `..` escapes, -cycles, overly deep include chains, and oversized profiles are rejected. Frontmatter -`include` entries are prepended in order; inline `{{include: fragments/foo.md}}` entries -are expanded in place. +`prompt: extend` keeps Pi's default base prompt and prepends the composed agent body to +the append list. `prompt: override` uses the agent body as the custom base prompt, so +Pi's default base prompt is skipped. In both prompt modes, per-session `systemPrompt` is +appended after the agent, and the daemon-level append prompt is appended last. + +Frontmatter supports `name`, `description`, `prompt`, `mcp`, `model`, `tools`, +`permissions`, and `projectContext`. `projectContext` is parsed for a future explicit +project-context model, but it does not activate implicit `AGENTS.md`/`CLAUDE.md` +discovery; Paseo Agent still keeps Pi context discovery off. `model` is an agent default: +an explicit session model wins, then the selected agent model, then +`agents.paseo.defaultModel`, then Pi's first available model. + +Partials use bang braces and expand exactly where they appear: `!{{./partials/base.md}}`. +Paths are relative to the file containing the directive and are confined to +`$PASEO_HOME/agents`: absolute paths, directory escapes, cycles, overly deep partial +chains, oversized definitions, and frontmatter inside partials are rejected. + +`tools` is the Pi tool allowlist for the agent: it controls what the model sees and can +call. Omit it to use Pi's default built-in tools plus bridged MCP tools. `permissions` is +an ordered first-match policy for active tool calls. The first matching `tool` pattern +wins; unmatched tools are allowed. Denied calls are blocked before execution through Pi's +tool preflight hook, so the policy applies to built-in, custom, and bridged MCP tools. `mcp: [paseo]` is an expectation check, not a new injection mechanism. The normal daemon -MCP injection still supplies the actual server; if a profile declares an MCP server that +MCP injection still supplies the actual server; if an agent declares an MCP server that is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues. ## Config shape @@ -97,6 +108,9 @@ is not present in the session's `mcpServers`, Paseo Agent logs a warning and con // Optional. Loads $PASEO_HOME/agents/orchestrator.md by default for new // Paseo Agent sessions. + "defaultAgent": "orchestrator", + + // Legacy alias for defaultAgent. Still accepted for old configs. "defaultProfile": "orchestrator", // Inference providers, keyed by instance name. Names are free-form; you may diff --git a/packages/app/src/hooks/use-paseo-agent-providers.ts b/packages/app/src/hooks/use-paseo-agent-providers.ts index c2f335b840..730585bc8a 100644 --- a/packages/app/src/hooks/use-paseo-agent-providers.ts +++ b/packages/app/src/hooks/use-paseo-agent-providers.ts @@ -36,7 +36,7 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); - // COMPAT(paseoAgentConfig): added in v0.1.X, drop the gate when floor >= v0.1.X. + // COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30. const supported = useSessionStore( (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true, ); diff --git a/packages/cli/src/commands/login/index.test.ts b/packages/cli/src/commands/login/index.test.ts index 9bb6807c21..7da40fd8a9 100644 --- a/packages/cli/src/commands/login/index.test.ts +++ b/packages/cli/src/commands/login/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createCli } from "../../cli.js"; import { createLoginCommand } from "./index.js"; @@ -152,6 +152,11 @@ describe("paseo login command", () => { it("rejects --device-code with --host instead of writing local auth for a remote host", async () => { const output: string[] = []; + const stderr: string[] = []; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); const login = createLoginCommand({ write: (message) => output.push(message), writeError: (message) => output.push(message), @@ -172,14 +177,22 @@ describe("paseo login command", () => { }, }); - await login.parseAsync(["node", "login", "chatgpt", "--device-code", "--host", "remote:7777"]); + await expect( + login.parseAsync(["node", "login", "chatgpt", "--device-code", "--host", "remote:7777"]), + ).rejects.toThrow("process.exit unexpectedly called"); + stderrSpy.mockRestore(); - expect(output.join("\n")).toContain("--device-code cannot be combined with --host"); + expect(stderr.join("\n")).toContain("--device-code cannot be combined with --host"); }); it("asks for a host update instead of sending credentials to an old daemon", async () => { const stored: unknown[] = []; const output: string[] = []; + const stderr: string[] = []; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); const login = createLoginCommand({ write: (message) => output.push(message), writeError: (message) => output.push(message), @@ -209,10 +222,13 @@ describe("paseo login command", () => { }, }); - await login.parseAsync(["node", "login", "chatgpt", "--host", "remote:7777"]); + await expect( + login.parseAsync(["node", "login", "chatgpt", "--host", "remote:7777"]), + ).rejects.toThrow("process.exit unexpectedly called"); + stderrSpy.mockRestore(); expect(stored).toEqual([]); - expect(output.join("\n")).toContain("Update the host to configure Paseo Agent providers."); + expect(stderr.join("\n")).toContain("Update the host to configure Paseo Agent providers."); }); it("does not echo password-bearing host URIs after remote login", async () => { diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index 7046d60eae..5b26e30238 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -8,9 +8,16 @@ import { } from "@getpaseo/server"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { addDaemonHostOption } from "../../utils/command-options.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { connectToDaemon } from "../../utils/client.js"; import { openBrowserUrl } from "../../utils/open-browser.js"; +import { + type CommandError, + type CommandOptions, + type OutputSchema, + type SingleResult, + withOutput, +} from "../../output/index.js"; // First-class auth UX: `paseo login chatgpt`. // Default flow is browser OAuth (PKCE + local callback on 127.0.0.1:1455) via Pi's @@ -19,13 +26,15 @@ import { openBrowserUrl } from "../../utils/open-browser.js"; const PROVIDER_INSTANCE = "chatgpt"; -interface LoginChatgptOptions { +interface LoginChatgptOptions extends CommandOptions { deviceCode?: boolean; home?: string; - host?: string; } interface LoginResult { + provider: string; + mode: "browser" | "device-code"; + target: string; path: string; } @@ -49,10 +58,24 @@ const defaultDependencies: LoginCommandDependencies = { connectDaemon: connectToDaemon, openBrowser: openBrowserUrl, promptForCode, - write: (message) => console.log(message), + write: (message) => console.error(message), writeError: (message) => console.error(message), }; +const loginResultSchema: OutputSchema = { + idField: "provider", + columns: [ + { header: "PROVIDER", field: "provider", width: 12 }, + { header: "MODE", field: "mode", width: 12 }, + { header: "TARGET", field: "target", width: 44 }, + ], + renderHuman: (result) => { + const data = result.data as LoginResult; + return `Logged in. Credential stored ${data.mode === "device-code" ? `at ${data.path}` : `on ${data.target}`} in Paseo-owned auth storage.`; + }, + serialize: (data) => data, +}; + function resolveEnv(home: string | undefined): NodeJS.ProcessEnv { return home ? { ...process.env, PASEO_HOME: home } : process.env; } @@ -61,7 +84,10 @@ function requirePaseoAgentConfigFeature(client: Pick void, info: CodexDeviceCode async function runChatgptLogin( options: LoginChatgptOptions, dependencies: LoginCommandDependencies, -): Promise { +): Promise> { const env = resolveEnv(options.home); const { write } = dependencies; if (options.deviceCode && options.host) { - throw new Error( - "--device-code cannot be combined with --host yet. Use browser login for remote hosts.", - ); + throw { + code: "INVALID_LOGIN_OPTIONS", + message: + "--device-code cannot be combined with --host yet. Use browser login for remote hosts.", + } satisfies CommandError; } if (options.deviceCode) { @@ -116,10 +144,19 @@ async function runChatgptLogin( env, onDeviceCode: (info) => printDeviceCode(write, info), }); - write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`); - return { path }; + return { + type: "single", + data: { + provider: "chatgpt", + mode: "device-code", + target: path, + path, + }, + schema: loginResultSchema, + }; } + const target = formatDaemonTarget(options.host); const client = await dependencies.connectDaemon({ host: options.host }); try { requirePaseoAgentConfigFeature(client); @@ -144,35 +181,39 @@ async function runChatgptLogin( credential, }); if (!result.success || result.error) { - throw new Error(result.error ?? "Daemon rejected the ChatGPT credential"); + throw { + code: "LOGIN_REJECTED", + message: result.error ?? "Daemon rejected the ChatGPT credential", + } satisfies CommandError; } + write(`Credential accepted by ${target}.`); } finally { await client.close().catch(() => {}); } - const target = formatDaemonTarget(options.host); - write(`\n✓ Logged in. Credential stored on ${target} in its Paseo-owned auth store.`); - return { path: target }; + return { + type: "single", + data: { + provider: "chatgpt", + mode: "browser", + target, + path: target, + }, + schema: loginResultSchema, + }; } export function createLoginCommand(dependencies: Partial = {}): Command { const deps = { ...defaultDependencies, ...dependencies }; const login = new Command("login").description("Authenticate Paseo providers"); - addDaemonHostOption( + addJsonAndDaemonHostOptions( login .command("chatgpt") .description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider") .option("--device-code", "Use the headless device-code flow instead of the browser flow") .option("--home ", "Paseo home directory for local --device-code only"), - ).action(async (options: LoginChatgptOptions) => { - try { - await runChatgptLogin(options, deps); - } catch (error) { - deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } - }); + ).action(withOutput((options, _command) => runChatgptLogin(options, deps))); return login; } diff --git a/packages/cli/src/utils/open-browser.test.ts b/packages/cli/src/utils/open-browser.test.ts new file mode 100644 index 0000000000..a5d0f5a664 --- /dev/null +++ b/packages/cli/src/utils/open-browser.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { browserOpenCommand, openBrowserUrl } from "./open-browser.js"; + +describe("browserOpenCommand", () => { + it("opens Windows URLs without cmd.exe shell parsing", () => { + const url = + "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc%20123&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Fcallback"; + + expect(browserOpenCommand(url, "win32")).toEqual({ + command: "rundll32.exe", + args: ["url.dll,FileProtocolHandler", url], + }); + }); + + it("keeps macOS opener behavior unchanged", () => { + const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc"; + + expect(browserOpenCommand(url, "darwin")).toEqual({ + command: "open", + args: [url], + }); + }); + + it("keeps Linux opener behavior unchanged", () => { + const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc"; + + expect(browserOpenCommand(url, "linux")).toEqual({ + command: "xdg-open", + args: [url], + }); + }); +}); + +describe("openBrowserUrl", () => { + it("spawns the resolved opener without launching a real browser", () => { + const spawned: Array<{ + command: string; + args: string[]; + options: { stdio: "ignore"; detached: true }; + }> = []; + const child = { + on: () => child, + unref: () => {}, + }; + + const opened = openBrowserUrl( + "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc", + { + platform: "win32", + spawn: (command, args, options) => { + spawned.push({ command, args, options }); + return child; + }, + }, + ); + + expect(opened).toBe(true); + expect(spawned).toEqual([ + { + command: "rundll32.exe", + args: [ + "url.dll,FileProtocolHandler", + "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc", + ], + options: { stdio: "ignore", detached: true }, + }, + ]); + }); + + it("returns false when spawning the opener throws", () => { + const opened = openBrowserUrl("https://auth.openai.com/oauth/authorize", { + platform: "linux", + spawn: () => { + throw new Error("spawn failed"); + }, + }); + + expect(opened).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/open-browser.ts b/packages/cli/src/utils/open-browser.ts index b0f2432a6b..fb617fb19d 100644 --- a/packages/cli/src/utils/open-browser.ts +++ b/packages/cli/src/utils/open-browser.ts @@ -1,26 +1,46 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +interface BrowserOpenCommand { + command: string; + args: string[]; +} + +type BrowserOpenSpawn = ( + command: string, + args: string[], + options: { stdio: "ignore"; detached: true }, +) => Pick; + +interface BrowserOpenDependencies { + platform?: NodeJS.Platform; + spawn?: BrowserOpenSpawn; +} /** * Best-effort cross-platform browser opener for CLI OAuth flows. Returns true if the * opener process was spawned, false otherwise. Callers must always print the URL too, * so a failed/headless open still lets the user copy it. */ -function browserOpenCommand(url: string): { command: string; args: string[] } { - switch (process.platform) { +export function browserOpenCommand( + url: string, + platform: NodeJS.Platform = process.platform, +): BrowserOpenCommand { + switch (platform) { case "darwin": return { command: "open", args: [url] }; case "win32": - return { command: "cmd", args: ["/c", "start", "", url] }; + return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] }; default: return { command: "xdg-open", args: [url] }; } } -export function openBrowserUrl(url: string): boolean { - const { command, args } = browserOpenCommand(url); +export function openBrowserUrl(url: string, dependencies: BrowserOpenDependencies = {}): boolean { + const spawnBrowser = dependencies.spawn ?? spawn; + const { command, args } = browserOpenCommand(url, dependencies.platform); try { - const child = spawn(command, args, { stdio: "ignore", detached: true }); + const child = spawnBrowser(command, args, { stdio: "ignore", detached: true }); child.on("error", () => {}); child.unref(); return true; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 448a160ff8..1ad0761984 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1950,12 +1950,6 @@ export const PaseoAgentRemoveProviderRequestSchema = z.object({ name: z.string().trim().min(1), }); -export const PaseoAgentSetDefaultModelRequestSchema = z.object({ - type: z.literal("config.paseo_agent.set_default_model.request"), - requestId: z.string(), - model: z.string().trim().min(1).nullable(), -}); - export const PaseoAgentStoreChatGptCredentialRequestSchema = z.object({ type: z.literal("config.paseo_agent.store_chatgpt_credential.request"), requestId: z.string(), @@ -2115,7 +2109,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoAgentGetProvidersRequestSchema, PaseoAgentSetProviderRequestSchema, PaseoAgentRemoveProviderRequestSchema, - PaseoAgentSetDefaultModelRequestSchema, PaseoAgentStoreChatGptCredentialRequestSchema, ListAvailableProvidersRequestMessageSchema, GetProvidersSnapshotRequestMessageSchema, @@ -3896,16 +3889,6 @@ export const PaseoAgentRemoveProviderResponseSchema = z.object({ }), }); -export const PaseoAgentSetDefaultModelResponseSchema = z.object({ - type: z.literal("config.paseo_agent.set_default_model.response"), - payload: z.object({ - requestId: z.string(), - success: z.boolean(), - defaultModel: z.string().nullable(), - error: z.string().nullable(), - }), -}); - export const PaseoAgentStoreChatGptCredentialResponseSchema = z.object({ type: z.literal("config.paseo_agent.store_chatgpt_credential.response"), payload: z.object({ @@ -4232,7 +4215,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ PaseoAgentGetProvidersResponseSchema, PaseoAgentSetProviderResponseSchema, PaseoAgentRemoveProviderResponseSchema, - PaseoAgentSetDefaultModelResponseSchema, PaseoAgentStoreChatGptCredentialResponseSchema, ListAvailableProvidersResponseSchema, GetProvidersSnapshotResponseMessageSchema, @@ -4370,9 +4352,6 @@ export type PaseoAgentSetProviderResponse = z.infer; -export type PaseoAgentSetDefaultModelResponse = z.infer< - typeof PaseoAgentSetDefaultModelResponseSchema ->; export type PaseoAgentStoreChatGptCredentialResponse = z.infer< typeof PaseoAgentStoreChatGptCredentialResponseSchema >; @@ -4444,9 +4423,6 @@ export type ListProviderFeaturesRequestMessage = z.infer< export type PaseoAgentGetProvidersRequest = z.infer; export type PaseoAgentSetProviderRequest = z.infer; export type PaseoAgentRemoveProviderRequest = z.infer; -export type PaseoAgentSetDefaultModelRequest = z.infer< - typeof PaseoAgentSetDefaultModelRequestSchema ->; export type PaseoAgentStoreChatGptCredentialRequest = z.infer< typeof PaseoAgentStoreChatGptCredentialRequestSchema >; diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.test.ts new file mode 100644 index 0000000000..726dbeb9a8 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createToolPermissionPolicy, evaluateToolPermission } from "./agent-permissions.js"; + +describe("Paseo Agent tool permissions", () => { + it("uses the first matching rule", () => { + const allowFirst = createToolPermissionPolicy([ + { tool: "bash", action: "allow" }, + { tool: "bash", action: "deny" }, + ]); + const denyFirst = createToolPermissionPolicy([ + { tool: "bash", action: "deny" }, + { tool: "bash", action: "allow" }, + ]); + + expect(evaluateToolPermission(allowFirst, "bash")).toBe("allow"); + expect(evaluateToolPermission(denyFirst, "bash")).toBe("deny"); + }); + + it("matches wildcard tool names", () => { + const policy = createToolPermissionPolicy([{ tool: "paseo__archive_*", action: "deny" }]); + + expect(evaluateToolPermission(policy, "paseo__archive_agent")).toBe("deny"); + expect(evaluateToolPermission(policy, "paseo__list_agents")).toBe("allow"); + }); + + it("allows tools when no rule matches", () => { + const policy = createToolPermissionPolicy([{ tool: "read", action: "deny" }]); + + expect(evaluateToolPermission(policy, "bash")).toBe("allow"); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts new file mode 100644 index 0000000000..3172442851 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +const ToolPermissionActionSchema = z.enum(["allow", "deny"]); + +export const ToolPermissionRuleSchema = z + .object({ + tool: z.string().min(1), + action: ToolPermissionActionSchema, + }) + .strict(); + +export type ToolPermissionAction = z.infer; +export type ToolPermissionRule = z.infer; + +export interface ToolPermissionPolicy { + rules: ToolPermissionRule[]; +} + +export function createToolPermissionPolicy( + rules: ToolPermissionRule[] | undefined, +): ToolPermissionPolicy { + return { rules: rules ?? [] }; +} + +export function evaluateToolPermission( + policy: ToolPermissionPolicy | undefined, + toolName: string, +): ToolPermissionAction { + for (const rule of policy?.rules ?? []) { + if (matchesToolPattern(rule.tool, toolName)) { + return rule.action; + } + } + return "allow"; +} + +function matchesToolPattern(pattern: string, toolName: string): boolean { + if (pattern === toolName || pattern === "*") { + return true; + } + const matcher = new RegExp(`^${wildcardPatternToRegExp(pattern)}$`); + return matcher.test(toolName); +} + +function wildcardPatternToRegExp(pattern: string): string { + return pattern.split("*").map(escapeRegExp).join(".*"); +} + +function escapeRegExp(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts index 58b7e4ec7d..bfbf28391e 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts @@ -1,14 +1,16 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { Logger } from "pino"; import { afterEach, describe, expect, it } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; -import type { AgentSessionConfig } from "../../agent-sdk-types.js"; -import { PaseoAgentClient } from "./agent.js"; +import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; +import { PaseoAgentClient, PaseoAgentSession } from "./agent.js"; import { PaseoAgentConfigSchema, type PaseoAgentConfig } from "./config.js"; import { storeCodexOAuthCredential } from "./oauth-store.js"; +import type { PaseoAgentSessionHandle } from "./pi-services.js"; function makeConfig(): PaseoAgentConfig { return PaseoAgentConfigSchema.parse({ @@ -46,6 +48,112 @@ function createRecordingLogger(): Logger & { warnings: Array<{ data: unknown; me return logger; } +function deferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +class FakeInProcessPiSession { + readonly sessionId = "pi-session-1"; + readonly thinkingLevel = "medium"; + readonly model = { provider: "openrouter-main", id: "test-model" }; + readonly messages: Array<{ role: string; content: unknown }> = []; + readonly agent = { state: { errorMessage: "" } }; + abortCalls = 0; + disposeCalls = 0; + promptCalls: Array<{ text: string; options: unknown }> = []; + promptDeferred = deferred(); + private readonly subscribers = new Set<(event: AgentSessionEvent) => void>(); + + subscribe(callback: (event: AgentSessionEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + async prompt(text: string, options?: unknown): Promise { + this.promptCalls.push({ text, options }); + await this.promptDeferred.promise; + } + + async abort(): Promise { + this.abortCalls += 1; + const error = new Error("Request was aborted"); + error.name = "AbortError"; + this.promptDeferred.reject(error); + } + + dispose(): void { + this.disposeCalls += 1; + } + + getSessionStats() { + return { + sessionFile: undefined, + sessionId: this.sessionId, + userMessages: 1, + assistantMessages: 1, + toolCalls: 0, + toolResults: 0, + totalMessages: 2, + tokens: { input: 3, output: 5, cacheRead: 2, cacheWrite: 0, total: 10 }, + cost: 0.01, + }; + } + + setThinkingLevel(): void {} + + async setModel(): Promise {} + + emit(event: AgentSessionEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } +} + +function createPaseoProviderSession(): { + fakePi: FakeInProcessPiSession; + session: PaseoAgentSession; + events: AgentStreamEvent[]; + mcpBridge: { closeCalls: number }; +} { + const fakePi = new FakeInProcessPiSession(); + const handle = { + session: fakePi, + modelRegistry: { find: () => fakePi.model }, + resourceLoader: {}, + sessionManager: {}, + } as unknown as PaseoAgentSessionHandle; + const mcpBridge = { + tools: [], + closeCalls: 0, + async close() { + this.closeCalls += 1; + }, + }; + const session = new PaseoAgentSession( + handle, + sessionConfig(), + mcpBridge as unknown as ConstructorParameters[2], + null, + [], + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + return { fakePi, session, events, mcpBridge }; +} + describe("PaseoAgentClient", () => { const tempDirs: string[] = []; @@ -152,7 +260,7 @@ describe("PaseoAgentClient", () => { } }); - it("uses the configured default prompt profile as a lowest-precedence model default", async () => { + it("uses the selected agent as a model default", async () => { const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); tempDirs.push(paseoHome); mkdirSync(join(paseoHome, "agents"), { recursive: true }); @@ -165,7 +273,7 @@ Profile prompt. `, ); const config = PaseoAgentConfigSchema.parse({ - defaultProfile: "orchestrator", + defaultAgent: "orchestrator", providers: { "openrouter-main": { type: "openrouter", @@ -188,7 +296,7 @@ Profile prompt. } }); - it("prefers the configured default model over the profile default model", async () => { + it("prefers the selected agent model over the configured default model", async () => { const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); tempDirs.push(paseoHome); mkdirSync(join(paseoHome, "agents"), { recursive: true }); @@ -201,7 +309,7 @@ Profile prompt. `, ); const config = PaseoAgentConfigSchema.parse({ - defaultProfile: "orchestrator", + defaultAgent: "orchestrator", defaultModel: "openrouter-main/a", providers: { "openrouter-main": { @@ -219,7 +327,48 @@ Profile prompt. const session = await client.createSession(sessionConfig()); try { const info = await session.getRuntimeInfo(); - expect(info.model).toBe("openrouter-main/a"); + expect(info.model).toBe("openrouter-main/b"); + } finally { + await session.close(); + } + }); + + it("uses the requested mode as the selected agent definition", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + tempDirs.push(paseoHome); + mkdirSync(join(paseoHome, "agents"), { recursive: true }); + writeFileSync(join(paseoHome, "agents", "builder.md"), "---\nname: Builder\n---\nBuild."); + writeFileSync( + join(paseoHome, "agents", "reviewer.md"), + "---\nmodel: openrouter-main/b\n---\nReview.", + ); + const config = PaseoAgentConfigSchema.parse({ + defaultAgent: "builder", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "a" }, { id: "b" }], + }, + }, + }, + }); + const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome }); + + await expect(client.listModes({ cwd: process.cwd(), force: false })).resolves.toEqual([ + { id: "builder", label: "Builder" }, + { id: "reviewer", label: "reviewer" }, + ]); + + const session = await client.createSession(sessionConfig({ modeId: "reviewer" })); + try { + const info = await session.getRuntimeInfo(); + expect(info.modeId).toBe("reviewer"); + expect(info.model).toBe("openrouter-main/b"); + await expect(session.getCurrentMode()).resolves.toBe("reviewer"); } finally { await session.close(); } @@ -240,7 +389,7 @@ Profile prompt. const logger = createRecordingLogger(); const client = new PaseoAgentClient({ logger, - config: PaseoAgentConfigSchema.parse({ ...makeConfig(), defaultProfile: "orchestrator" }), + config: PaseoAgentConfigSchema.parse({ ...makeConfig(), defaultAgent: "orchestrator" }), paseoHome, }); const session = await client.createSession(sessionConfig()); @@ -256,3 +405,144 @@ Profile prompt. } }); }); + +describe("PaseoAgentSession runtime events", () => { + it("runs through a representative Pi event sequence", async () => { + const { fakePi, session, events } = createPaseoProviderSession(); + const resultPromise = session.run("hello"); + + await Promise.resolve(); + fakePi.emit({ type: "agent_start" }); + fakePi.emit({ type: "turn_start" }); + fakePi.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "thinking_delta", delta: "thinking" }, + }); + fakePi.emit({ + type: "tool_execution_start", + toolCallId: "tool-1", + toolName: "bash", + args: { command: "pwd" }, + }); + fakePi.emit({ + type: "tool_execution_end", + toolCallId: "tool-1", + toolName: "bash", + result: { output: "/tmp" }, + isError: false, + }); + fakePi.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "done" }, + }); + fakePi.emit({ type: "agent_end", messages: [], willRetry: false }); + fakePi.promptDeferred.resolve(); + + await expect(resultPromise).resolves.toEqual({ + sessionId: "pi-session-1", + finalText: "done", + usage: { inputTokens: 3, cachedInputTokens: 2, outputTokens: 5, totalCostUsd: 0.01 }, + timeline: [ + { type: "reasoning", text: "thinking" }, + { + type: "tool_call", + callId: "tool-1", + name: "bash", + status: "running", + detail: { type: "shell", command: "pwd", output: undefined, exitCode: undefined }, + error: null, + }, + { + type: "tool_call", + callId: "tool-1", + name: "bash", + status: "completed", + detail: { type: "shell", command: "pwd", output: "/tmp", exitCode: null }, + error: null, + }, + { type: "assistant_message", text: "done" }, + ], + }); + expect(events.map((event) => event.type)).toEqual([ + "thread_started", + "turn_started", + "timeline", + "timeline", + "timeline", + "timeline", + "turn_completed", + ]); + }); + + it("keeps the active turn open when Pi agent_end says it will retry", async () => { + const { fakePi, session, events } = createPaseoProviderSession(); + const resultPromise = session.run("retry please"); + + await Promise.resolve(); + fakePi.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "first attempt " }, + }); + fakePi.agent.state.errorMessage = "transient overflow"; + fakePi.emit({ type: "agent_end", messages: [], willRetry: true }); + expect(events.some((event) => event.type === "turn_failed")).toBe(false); + expect(events.some((event) => event.type === "turn_completed")).toBe(false); + + fakePi.agent.state.errorMessage = ""; + fakePi.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "retry success" }, + }); + fakePi.emit({ type: "agent_end", messages: [], willRetry: false }); + fakePi.promptDeferred.resolve(); + + await expect(resultPromise).resolves.toMatchObject({ + finalText: "first attempt retry success", + }); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + }); + + it("maps interrupt aborts to clean turn cancellation", async () => { + const { fakePi, session, events } = createPaseoProviderSession(); + const resultPromise = session.run("cancel me"); + + await Promise.resolve(); + await session.interrupt(); + + await expect(resultPromise).resolves.toMatchObject({ + sessionId: "pi-session-1", + finalText: "", + timeline: [], + }); + expect(fakePi.abortCalls).toBe(1); + expect(events).toContainEqual({ + type: "turn_canceled", + provider: "paseo", + turnId: expect.any(String), + reason: "interrupted", + }); + expect(events.some((event) => event.type === "turn_failed")).toBe(false); + }); + + it("aborts an active turn before close and close is idempotent", async () => { + const { fakePi, session, events, mcpBridge } = createPaseoProviderSession(); + await session.startTurn("close me"); + + await Promise.all([session.close(), session.close()]); + + expect(fakePi.abortCalls).toBe(1); + expect(fakePi.disposeCalls).toBe(1); + expect(mcpBridge.closeCalls).toBe(1); + expect(events).toContainEqual({ + type: "turn_canceled", + provider: "paseo", + turnId: expect.any(String), + reason: "interrupted", + }); + expect(events.some((event) => event.type === "turn_failed")).toBe(false); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.ts index da29acc3f6..abb0d150a2 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -6,7 +6,6 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import { - getAgentStreamEventTurnId, type AgentCapabilityFlags, type AgentClient, type AgentLaunchContext, @@ -23,10 +22,10 @@ import { type AgentSessionConfig, type AgentSlashCommand, type AgentStreamEvent, - type AgentTimelineItem, - type AgentUsage, + type ListModesOptions, type ListModelsOptions, } from "../../agent-sdk-types.js"; +import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js"; import { PASEO_AGENT_PROVIDER, type PaseoAgentConfig, @@ -48,7 +47,13 @@ import { import { createMcpToolBridge, type McpToolBridge } from "./mcp-bridge.js"; import { createPaseoAgentAuthStorage, hasStoredOAuthCredential } from "./oauth-store.js"; import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js"; -import { composePromptParts, loadPromptProfile } from "./prompt-profiles.js"; +import { createToolPermissionPolicy } from "./agent-permissions.js"; +import { + composePromptParts, + listAgentDefinitionIds, + loadAgentDefinition, + type ResolvedAgentDefinition, +} from "./prompt-profiles.js"; const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; @@ -87,6 +92,17 @@ function envForPaseoHome(paseoHome: string | undefined): NodeJS.ProcessEnv { return paseoHome ? { ...process.env, PASEO_HOME: paseoHome } : process.env; } +function errorToMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isAbortError(error: unknown): boolean { + if (error instanceof Error && error.name === "AbortError") { + return true; + } + return /\brequest was aborted\b|\babort(ed)?\b/i.test(errorToMessage(error)); +} + interface PaseoAgentClientOptions { logger: Logger; config: PaseoAgentConfig; @@ -101,11 +117,14 @@ export class PaseoAgentSession implements AgentSession { private readonly activeToolCalls = new Map(); private activeTurnId: string | null = null; private lastThinkingOptionId: string | null; + private closePromise: Promise | null = null; constructor( private readonly handle: PaseoAgentSessionHandle, private readonly config: AgentSessionConfig, private readonly mcpBridge: McpToolBridge, + private readonly agentId: string | null, + private readonly availableAgents: AgentMode[], ) { this.lastThinkingOptionId = normalizeThinkingLevel(config.thinkingOptionId) ?? this.piSession.thinkingLevel ?? null; @@ -126,6 +145,27 @@ export class PaseoAgentSession implements AgentSession { } } + private clearActiveTurn(): string | null { + const turnId = this.activeTurnId; + this.activeTurnId = null; + this.activeToolCalls.clear(); + return turnId; + } + + private emitActiveTurnCanceled(reason: string): boolean { + const turnId = this.clearActiveTurn(); + if (!turnId) { + return false; + } + this.emit({ + type: "turn_canceled", + provider: PASEO_AGENT_PROVIDER, + turnId, + reason, + }); + return true; + } + private emitToolCall( toolCallId: string, toolCall: PiTrackedToolCall, @@ -158,29 +198,9 @@ export class PaseoAgentSession implements AgentSession { case "turn_start": this.emit({ type: "turn_started", provider: PASEO_AGENT_PROVIDER, turnId }); return; - case "message_update": { - if (event.message.role !== "assistant") { - return; - } - if (event.assistantMessageEvent.type === "text_delta") { - this.emit({ - type: "timeline", - provider: PASEO_AGENT_PROVIDER, - turnId, - item: { type: "assistant_message", text: event.assistantMessageEvent.delta ?? "" }, - }); - return; - } - if (event.assistantMessageEvent.type === "thinking_delta") { - this.emit({ - type: "timeline", - provider: PASEO_AGENT_PROVIDER, - turnId, - item: { type: "reasoning", text: event.assistantMessageEvent.delta ?? "" }, - }); - } + 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); @@ -216,96 +236,76 @@ export class PaseoAgentSession implements AgentSession { ); return; } - case "agent_end": { - const usage = toAgentUsage(this.piSession.getSessionStats()); - const currentTurnId = turnId; - this.activeTurnId = null; - const errorMessage = this.piSession.agent.state.errorMessage; - if (errorMessage) { - this.emit({ - type: "turn_failed", - provider: PASEO_AGENT_PROVIDER, - turnId: currentTurnId, - error: errorMessage, - }); - return; - } - this.emit({ - type: "turn_completed", - provider: PASEO_AGENT_PROVIDER, - turnId: currentTurnId, - usage, - }); + case "agent_end": + this.handleAgentEnd(event); 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)); - } + 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: PASEO_AGENT_PROVIDER, + turnId, + item: { type: "assistant_message", text: event.assistantMessageEvent.delta ?? "" }, + }); + return; + } + if (event.assistantMessageEvent.type === "thinking_delta") { + this.emit({ + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + turnId, + item: { type: "reasoning", text: event.assistantMessageEvent.delta ?? "" }, + }); + } + } - 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(); + private handleAgentEnd(event: Extract): void { + if (event.willRetry) { + return; + } + const usage = toAgentUsage(this.piSession.getSessionStats()); + const currentTurnId = this.clearActiveTurn() ?? undefined; + if (!currentTurnId) { + return; } + const errorMessage = this.piSession.agent.state.errorMessage; + if (errorMessage) { + this.emit({ + type: "turn_failed", + provider: PASEO_AGENT_PROVIDER, + turnId: currentTurnId, + error: errorMessage, + }); + return; + } + this.emit({ + type: "turn_completed", + provider: PASEO_AGENT_PROVIDER, + turnId: currentTurnId, + usage, + }); + } - return { sessionId: this.piSession.sessionId, finalText, usage, timeline }; + 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.piSession.sessionId, + reduceFinalText: appendOrReplaceGrowingAssistantMessage, + }); } async startTurn( @@ -322,13 +322,19 @@ export class PaseoAgentSession implements AgentSession { void this.piSession .prompt(payload.text, payload.images ? { images: payload.images } : undefined) .catch((error: unknown) => { - const failedTurnId = this.activeTurnId ?? turnId; - this.activeTurnId = null; + if (this.activeTurnId !== turnId) { + return; + } + if (isAbortError(error)) { + this.emitActiveTurnCanceled(errorToMessage(error)); + return; + } + const failedTurnId = this.clearActiveTurn() ?? turnId; this.emit({ type: "turn_failed", provider: PASEO_AGENT_PROVIDER, turnId: failedTurnId, - error: error instanceof Error ? error.message : String(error), + error: errorToMessage(error), }); }); @@ -426,20 +432,23 @@ export class PaseoAgentSession implements AgentSession { model: model ? `${model.provider}/${model.id}` : null, thinkingOptionId: normalizeThinkingLevel(this.lastThinkingOptionId) ?? this.piSession.thinkingLevel ?? null, - modeId: null, + modeId: this.agentId, }; } async getAvailableModes(): Promise { - return []; + return this.availableAgents; } async getCurrentMode(): Promise { - return null; + return this.agentId; } - async setMode(_modeId: string): Promise { - throw new Error("Paseo Agent does not expose selectable modes"); + async setMode(modeId: string): Promise { + if (modeId === this.agentId) { + return; + } + throw new Error("Paseo Agent selection is fixed when the session starts"); } getPendingPermissions(): AgentPermissionRequest[] { @@ -456,12 +465,28 @@ export class PaseoAgentSession implements AgentSession { } async interrupt(): Promise { - await this.piSession.abort(); + const canceled = this.emitActiveTurnCanceled("interrupted"); + try { + await this.piSession.abort(); + } catch (error) { + if (!canceled || !isAbortError(error)) { + throw error; + } + } } async close(): Promise { - this.piSession.dispose(); - await this.mcpBridge.close(); + this.closePromise ??= (async () => { + try { + if (this.activeTurnId) { + await this.interrupt(); + } + } finally { + this.piSession.dispose(); + await this.mcpBridge.close(); + } + })(); + await this.closePromise; } async listCommands(): Promise { @@ -517,17 +542,18 @@ export class PaseoAgentClient implements AgentClient { ); } - const profile = this.loadDefaultProfile(); - this.verifyExpectedMcpServers(profile?.expectedMcpServers ?? [], config.mcpServers); + const availableAgents = this.loadAvailableAgentModes(); + const agent = this.loadSelectedAgent(config.modeId); + this.verifyExpectedMcpServers(agent, config.mcpServers); const model = resolvePaseoAgentModel( this.config, config.model, inferenceProviders, - profile?.model, + agent?.model, ); const thinkingLevel = normalizeThinkingLevel(config.thinkingOptionId) ?? undefined; const composedPrompt = composePromptParts({ - profile, + agent, systemPrompt: config.systemPrompt, daemonAppendSystemPrompt: config.daemonAppendSystemPrompt, }); @@ -535,7 +561,7 @@ export class PaseoAgentClient implements AgentClient { { provider: PASEO_AGENT_PROVIDER, model: model ? `${model.provider}/${model.id}` : null, - promptProfile: profile?.id ?? null, + agent: agent?.id ?? null, }, "Creating Paseo Agent session", ); @@ -548,6 +574,7 @@ export class PaseoAgentClient implements AgentClient { : undefined; // Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools. + const permissionPolicy = createToolPermissionPolicy(agent?.permissions); const mcpBridge = await createMcpToolBridge({ mcpServers: config.mcpServers, logger: this.logger, @@ -562,9 +589,11 @@ export class PaseoAgentClient implements AgentClient { ...(thinkingLevel ? { thinkingLevel } : {}), ...(authStorage ? { authStorage } : {}), ...(mcpBridge.tools.length > 0 ? { customTools: mcpBridge.tools } : {}), + ...(agent?.tools ? { tools: agent.tools } : {}), + permissionPolicy, ...(composedPrompt ? { composedPrompt } : {}), }); - return new PaseoAgentSession(handle, config, mcpBridge); + return new PaseoAgentSession(handle, config, mcpBridge, agent?.id ?? null, availableAgents); } catch (error) { await mcpBridge.close(); throw error; @@ -579,6 +608,10 @@ export class PaseoAgentClient implements AgentClient { return listPaseoAgentModels(this.config); } + async listModes(_options: ListModesOptions): Promise { + return this.loadAvailableAgentModes(); + } + async isAvailable(): Promise { const env = envForPaseoHome(this.paseoHome); return paseoAgentHasUsableModel(this.config, env, (providerInstance) => @@ -586,45 +619,81 @@ export class PaseoAgentClient implements AgentClient { ); } - private loadDefaultProfile() { - if (!this.paseoHome || !this.config.defaultProfile) { + private loadSelectedAgent(requestedAgentId: string | undefined): ResolvedAgentDefinition | null { + const selectedAgentId = + requestedAgentId ?? this.config.defaultAgent ?? this.config.defaultProfile; + if (!this.paseoHome || !selectedAgentId) { return null; } try { - const profile = loadPromptProfile(this.paseoHome, this.config.defaultProfile); - if (!profile) { + const agent = loadAgentDefinition(this.paseoHome, selectedAgentId); + if (!agent) { this.logger.warn( - { provider: PASEO_AGENT_PROVIDER, promptProfile: this.config.defaultProfile }, - "Configured Paseo Agent prompt profile was not found", + { provider: PASEO_AGENT_PROVIDER, agent: selectedAgentId }, + "Configured Paseo Agent definition was not found", ); } - return profile; + return agent; } catch (error) { this.logger.warn( { provider: PASEO_AGENT_PROVIDER, - promptProfile: this.config.defaultProfile, + agent: selectedAgentId, error: error instanceof Error ? error.message : String(error), }, - "Configured Paseo Agent prompt profile could not be loaded", + "Configured Paseo Agent definition could not be loaded", ); return null; } } + private loadAvailableAgentModes(): AgentMode[] { + const paseoHome = this.paseoHome; + if (!paseoHome) { + return []; + } + return listAgentDefinitionIds(paseoHome).flatMap((agentId): AgentMode[] => { + try { + const agent = loadAgentDefinition(paseoHome, agentId); + if (!agent) { + return []; + } + return [ + { + id: agent.id, + label: agent.frontmatter.name ?? agent.id, + ...(agent.frontmatter.description + ? { description: agent.frontmatter.description } + : {}), + }, + ]; + } catch (error) { + this.logger.warn( + { + provider: PASEO_AGENT_PROVIDER, + agent: agentId, + error: error instanceof Error ? error.message : String(error), + }, + "Paseo Agent definition could not be listed", + ); + return []; + } + }); + } + private verifyExpectedMcpServers( - expectedServers: string[], + agent: ResolvedAgentDefinition | null, configuredServers: AgentSessionConfig["mcpServers"], ): void { - for (const serverName of new Set(expectedServers)) { + for (const serverName of new Set(agent?.expectedMcpServers ?? [])) { if (!configuredServers?.[serverName]) { this.logger.warn( { provider: PASEO_AGENT_PROVIDER, - promptProfile: this.config.defaultProfile, + agent: agent?.id ?? null, mcpServer: serverName, }, - "Paseo Agent prompt profile expects an MCP server that is not configured for this session", + "Paseo Agent definition expects an MCP server that is not configured for this session", ); } } diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts index e160e65524..6132dca0d0 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -171,7 +171,25 @@ describe("PaseoAgentConfigService", () => { models: [{ id: "anthropic/claude-3.7-sonnet" }], }, }); - service.setDefaultModel("openrouter-main/anthropic/claude-3.7-sonnet"); + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + agents: { + paseo: { + defaultModel: "openrouter-main/anthropic/claude-3.7-sonnet", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }, + }, + }, + }, + }), + ); expect(service.removeProvider("openrouter-main")).toBe(true); expect(service.getProviders()).toEqual({ defaultModel: null, providers: [] }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts index d79735df61..acd3d41a12 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -227,16 +227,6 @@ export class PaseoAgentConfigService { return removed; } - setDefaultModel(model: string | null): string | null { - const next = this.updateConfig((current) => - PaseoAgentConfigSchema.parse({ - ...current, - ...(model ? { defaultModel: model } : { defaultModel: undefined }), - }), - ); - return next.defaultModel ?? null; - } - storeChatGptCredential(providerName: string, credential: PaseoAgentOAuthCredential): void { storeCodexOAuthCredential({ providerInstance: providerName, diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts index 6b580900a6..deee44070c 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts @@ -216,12 +216,18 @@ describe("paseoAgentHasUsableModel (env-aware auth)", () => { }); describe("resolvePaseoAgentModel", () => { - it("prefers the explicit request, then default, then first configured", () => { + it("prefers the explicit request, then agent model, then default, then first configured", () => { const config = configWith({ defaultModel: "openrouter-main/openai/gpt" }); expect(resolvePaseoAgentModel(config, "openrouter-main/anthropic/claude")).toEqual({ provider: "openrouter-main", id: "anthropic/claude", }); + expect( + resolvePaseoAgentModel(config, null, undefined, "openrouter-main/anthropic/claude"), + ).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); expect(resolvePaseoAgentModel(config, null)).toEqual({ provider: "openrouter-main", id: "openai/gpt", diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index a6b6da310f..d6b7f69320 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -163,7 +163,9 @@ export const PaseoAgentConfigSchema = z .object({ // Optional default model as "/". defaultModel: z.string().min(1).optional(), - // Optional default prompt profile from $PASEO_HOME/agents/.md. + // Optional default agent definition from $PASEO_HOME/agents/.md. + defaultAgent: z.string().min(1).optional(), + // Legacy alias for defaultAgent. defaultProfile: z.string().min(1).optional(), // Inference providers keyed by instance name. Multiple entries may share a // type while pointing at different APIs/base URLs/models. @@ -357,13 +359,13 @@ export function resolvePaseoAgentModel( config: PaseoAgentConfig, requestedModelId: string | null | undefined, registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config), - profileDefaultModelId?: string | null, + agentDefaultModelId?: string | null, ): PaseoAgentModelReference | undefined { if (requestedModelId) { return parsePaseoAgentModelId(requestedModelId) ?? undefined; } - for (const candidate of [config.defaultModel, profileDefaultModelId, firstModelId(config)]) { + for (const candidate of [agentDefaultModelId, config.defaultModel, firstModelId(config)]) { if (!candidate) { continue; } diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts index 628c98ad66..6d3bdefb32 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts @@ -2,7 +2,9 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AuthStorage } from "@earendil-works/pi-coding-agent"; +import type { BeforeToolCallContext } from "@earendil-works/pi-agent-core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createToolPermissionPolicy } from "./agent-permissions.js"; import { type CreatePaseoAgentSessionOptions, @@ -35,6 +37,15 @@ function codexInferenceProvider(): PaseoAgentInferenceProvider { const FAKE_PROVIDER = "paseo-test-openrouter"; const FAKE_MODEL_ID = "test-model"; +function toolCallContext(toolName: string): BeforeToolCallContext { + return { + assistantMessage: { role: "assistant", content: [] }, + toolCall: { type: "toolCall", id: "call-1", name: toolName, arguments: {} }, + args: {}, + context: {}, + } as BeforeToolCallContext; +} + function fakeInferenceProvider(): PaseoAgentInferenceProvider { return { name: FAKE_PROVIDER, @@ -180,6 +191,75 @@ describe("createPaseoAgentSession (no-discovery spike)", () => { expect(active).toContain("bash"); }); + it("honors an explicit agent tool allowlist", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + tools: ["read", "paseo__demo"], + customTools: [ + { + name: "paseo__demo", + label: "demo", + description: "demo tool", + parameters: { type: "object" } as never, + async execute() { + return { content: [{ type: "text", text: "ok" }], details: null }; + }, + }, + ], + }); + + expect(session.getActiveToolNames().sort()).toEqual(["paseo__demo", "read"]); + }); + + it("blocks a denied built-in tool through Pi's preflight hook", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + tools: ["bash"], + permissionPolicy: createToolPermissionPolicy([{ tool: "bash", action: "deny" }]), + }); + + expect(session.getActiveToolNames()).toEqual(["bash"]); + await expect(session.agent.beforeToolCall?.(toolCallContext("bash"))).resolves.toEqual({ + block: true, + reason: 'Paseo Agent denied tool "bash" by agent permissions.', + }); + }); + + it("allows unmatched built-in tools to fall through the existing Pi hook", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + tools: ["bash"], + permissionPolicy: createToolPermissionPolicy([{ tool: "read", action: "deny" }]), + }); + + await expect(session.agent.beforeToolCall?.(toolCallContext("bash"))).resolves.toBeUndefined(); + }); + + it("blocks a denied custom tool through the same Pi preflight hook", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + tools: ["paseo__demo"], + customTools: [ + { + name: "paseo__demo", + label: "demo", + description: "demo tool", + parameters: { type: "object" } as never, + async execute() { + return { content: [{ type: "text", text: "ok" }], details: null }; + }, + }, + ], + permissionPolicy: createToolPermissionPolicy([{ tool: "paseo__*", action: "deny" }]), + }); + + expect(session.getActiveToolNames()).toEqual(["paseo__demo"]); + await expect(session.agent.beforeToolCall?.(toolCallContext("paseo__demo"))).resolves.toEqual({ + block: true, + reason: 'Paseo Agent denied tool "paseo__demo" by agent permissions.', + }); + }); + it("registers a codex provider and seeds the advanced refresh-token override", async () => { const codex = codexInferenceProvider(); const { session, modelRegistry } = await createPaseoAgentSession({ diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts index 2bb96eeac3..0f55835fd7 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts @@ -9,9 +9,10 @@ import { type ToolDefinition, createAgentSession, } from "@earendil-works/pi-coding-agent"; -import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { BeforeToolCallResult, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { openaiCodexOAuthProvider } from "@earendil-works/pi-ai/oauth"; +import { evaluateToolPermission, type ToolPermissionPolicy } from "./agent-permissions.js"; import type { PaseoComposedPrompt } from "./prompt-profiles.js"; // Re-export the Pi tool contract so the MCP bridge can build custom tools without @@ -87,7 +88,11 @@ export interface CreatePaseoAgentSessionOptions { settings?: PiSettings; /** Paseo-bridged tools (e.g. MCP) to register alongside built-in tools. */ customTools?: ToolDefinition[]; - /** Paseo-composed prompt profile/session/daemon instructions. */ + /** Optional allowlist of active Pi tool names for this agent definition. */ + tools?: string[]; + /** Runtime allow/deny policy for every Pi tool call. */ + permissionPolicy?: ToolPermissionPolicy; + /** Paseo-composed agent/session/daemon instructions. */ composedPrompt?: PaseoComposedPrompt; } @@ -144,6 +149,30 @@ function wrapPromptResourceLoader( }; } +function installPermissionPolicy( + session: PiAgentSession, + permissionPolicy: ToolPermissionPolicy | undefined, +): void { + if (!permissionPolicy || permissionPolicy.rules.length === 0) { + return; + } + + const previousBeforeToolCall = session.agent.beforeToolCall; + session.agent.beforeToolCall = async ( + context, + signal, + ): Promise => { + const toolName = context.toolCall.name; + if (evaluateToolPermission(permissionPolicy, toolName) === "deny") { + return { + block: true, + reason: `Paseo Agent denied tool "${toolName}" by agent permissions.`, + }; + } + return previousBeforeToolCall?.(context, signal); + }; +} + /** * Create a Pi agent session through the high-level `createAgentSession` API with * every service supplied in-memory and no Pi config discovery. @@ -208,14 +237,17 @@ export async function createPaseoAgentSession( ...(model ? { model } : {}), ...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}), ...(options.customTools ? { customTools: options.customTools } : {}), + ...(options.tools ? { tools: options.tools } : {}), }); // Custom (MCP) tools are registered but not active by default — only the built-in - // tool set is. Activate them so the model can actually call them. - if (options.customTools && options.customTools.length > 0) { + // tool set is. Activate them unless an agent definition supplied an explicit tool allowlist. + if (!options.tools && options.customTools && options.customTools.length > 0) { const customToolNames = options.customTools.map((tool) => tool.name); session.setActiveToolsByName([...session.getActiveToolNames(), ...customToolNames]); } + installPermissionPolicy(session, options.permissionPolicy); + return { session, modelRegistry, resourceLoader, sessionManager }; } diff --git a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts index 47106e136d..1b042010a5 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts @@ -3,9 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { composePromptParts, listPromptProfileIds, loadPromptProfile } from "./prompt-profiles.js"; +import { + composePromptParts, + listAgentDefinitionIds, + loadAgentDefinition, +} from "./prompt-profiles.js"; -describe("Paseo Agent prompt profiles", () => { +describe("Paseo Agent definitions", () => { let paseoHome: string; let agentsDir: string; const tempDirs: string[] = []; @@ -23,145 +27,149 @@ describe("Paseo Agent prompt profiles", () => { } }); - function writeProfile(name: string, content: string): void { + function writeAgent(name: string, content: string): void { writeFileSync(join(agentsDir, name), content); } - it("parses frontmatter and lists only top-level markdown profiles", () => { - writeProfile( + it("parses frontmatter and lists only top-level markdown agents", () => { + writeAgent( "orchestrator.md", `--- name: Orchestrator description: Routes work -mode: override +prompt: override mcp: [paseo] model: openrouter-main/test-model +tools: [read, paseo__list_agents] +permissions: + - tool: paseo__archive_agent + action: deny projectContext: true --- -Profile body. +Agent body. `, ); - writeProfile("notes.txt", "ignored"); + writeAgent("notes.txt", "ignored"); writeFileSync(join(agentsDir, "fragments", "piece.md"), "fragment"); - const profile = loadPromptProfile(paseoHome, "orchestrator"); + const agent = loadAgentDefinition(paseoHome, "orchestrator"); - expect(listPromptProfileIds(paseoHome)).toEqual(["orchestrator"]); - expect(profile?.frontmatter).toMatchObject({ + expect(listAgentDefinitionIds(paseoHome)).toEqual(["orchestrator"]); + expect(agent?.frontmatter).toMatchObject({ name: "Orchestrator", description: "Routes work", - mode: "override", + prompt: "override", mcp: ["paseo"], model: "openrouter-main/test-model", + tools: ["read", "paseo__list_agents"], + permissions: [{ tool: "paseo__archive_agent", action: "deny" }], projectContext: true, }); - expect(profile?.composedPrompt.customPrompt).toBe("Profile body."); + expect(agent?.composedPrompt.customPrompt).toBe("Agent body."); }); - it("defaults to extend mode and prepends frontmatter includes in order", () => { - writeFileSync(join(agentsDir, "fragments", "a.md"), "Fragment A"); - writeFileSync(join(agentsDir, "fragments", "b.md"), "Fragment B"); - writeProfile( - "worker.md", - `--- -include: - - fragments/a.md - - fragments/b.md ---- -Body. -`, - ); + it("defaults to extend prompt mode", () => { + writeAgent("worker.md", "Body."); - const profile = loadPromptProfile(paseoHome, "worker.md"); + const agent = loadAgentDefinition(paseoHome, "worker.md"); - expect(profile?.frontmatter.mode).toBe("extend"); - expect(profile?.body).toBe("Fragment A\n\nFragment B\n\nBody."); - expect(profile?.composedPrompt.appendSystemPrompt).toEqual([ - "Fragment A\n\nFragment B\n\nBody.", - ]); + expect(agent?.frontmatter.prompt).toBe("extend"); + expect(agent?.body).toBe("Body."); + expect(agent?.composedPrompt.appendSystemPrompt).toEqual(["Body."]); + }); + + it("resolves bang-brace partials in place relative to the current file", () => { + mkdirSync(join(agentsDir, "team", "partials"), { recursive: true }); + writeFileSync(join(agentsDir, "team", "partials", "style.md"), "Use short answers."); + writeFileSync(join(agentsDir, "team", "nested.md"), "nested !{{./partials/style.md}}"); + writeAgent("inline.md", "Before\n!{{./team/nested.md}}\nAfter"); + + expect(loadAgentDefinition(paseoHome, "inline")?.body).toBe( + "Before\nnested Use short answers.\nAfter", + ); }); - it("resolves inline includes in place", () => { - writeFileSync(join(agentsDir, "fragments", "style.md"), "Use short answers."); - writeProfile("inline.md", "Before\n{{include: fragments/style.md}}\nAfter"); + it("rejects frontmatter inside partials", () => { + writeFileSync(join(agentsDir, "fragments", "bad.md"), "---\nname: Nope\n---\nfragment"); + writeAgent("agent.md", "!{{./fragments/bad.md}}"); - expect(loadPromptProfile(paseoHome, "inline")?.body).toBe("Before\nUse short answers.\nAfter"); + expect(() => loadAgentDefinition(paseoHome, "agent")).toThrow(/partials cannot declare/i); }); - it("detects include cycles", () => { - writeFileSync(join(agentsDir, "fragments", "a.md"), "{{include: fragments/b.md}}"); - writeFileSync(join(agentsDir, "fragments", "b.md"), "{{include: fragments/a.md}}"); - writeProfile("cycle.md", "{{include: fragments/a.md}}"); + it("detects partial cycles", () => { + writeFileSync(join(agentsDir, "fragments", "a.md"), "!{{./b.md}}"); + writeFileSync(join(agentsDir, "fragments", "b.md"), "!{{./a.md}}"); + writeAgent("cycle.md", "!{{./fragments/a.md}}"); - expect(() => loadPromptProfile(paseoHome, "cycle")).toThrow(/cycle/i); + expect(() => loadAgentDefinition(paseoHome, "cycle")).toThrow(/cycle/i); }); - it("rejects missing fragments and path escapes", () => { - writeProfile("missing.md", "{{include: fragments/nope.md}}"); - writeProfile("escape.md", "{{include: ../secret.md}}"); + it("rejects missing partials and path escapes", () => { + writeAgent("missing.md", "!{{./fragments/nope.md}}"); + writeAgent("escape.md", "!{{../secret.md}}"); - expect(() => loadPromptProfile(paseoHome, "missing")).toThrow(/not found/i); - expect(() => loadPromptProfile(paseoHome, "escape")).toThrow(/escape|invalid/i); - expect(() => loadPromptProfile(paseoHome, "../escape")).toThrow(/invalid/i); + expect(() => loadAgentDefinition(paseoHome, "missing")).toThrow(/not found/i); + expect(() => loadAgentDefinition(paseoHome, "escape")).toThrow(/escape|invalid/i); + expect(() => loadAgentDefinition(paseoHome, "../escape")).toThrow(/invalid/i); }); - it("rejects symlink escapes for profiles and includes", () => { + it("rejects symlink escapes for agents and partials", () => { const outsideDir = mkdtempSync(join(tmpdir(), "paseo-agent-profile-outside-")); tempDirs.push(outsideDir); writeFileSync(join(outsideDir, "secret.md"), "outside secret"); symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "linked-profile.md")); symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "fragments", "linked.md")); - writeProfile("include-link.md", "{{include: fragments/linked.md}}"); + writeAgent("include-link.md", "!{{./fragments/linked.md}}"); - expect(() => loadPromptProfile(paseoHome, "linked-profile")).toThrow(/escapes/i); - expect(() => loadPromptProfile(paseoHome, "include-link")).toThrow(/escapes/i); + expect(() => loadAgentDefinition(paseoHome, "linked-profile")).toThrow(/escapes/i); + expect(() => loadAgentDefinition(paseoHome, "include-link")).toThrow(/escapes/i); }); it("enforces depth and total size caps", () => { - writeFileSync(join(agentsDir, "fragments", "deep.md"), "{{include: fragments/deeper.md}}"); + writeFileSync(join(agentsDir, "fragments", "deep.md"), "!{{./deeper.md}}"); writeFileSync(join(agentsDir, "fragments", "deeper.md"), "done"); - writeProfile("depth.md", "{{include: fragments/deep.md}}"); - writeProfile("large.md", "0123456789"); + writeAgent("depth.md", "!{{./fragments/deep.md}}"); + writeAgent("large.md", "0123456789"); - expect(() => loadPromptProfile(paseoHome, "depth", { maxDepth: 1 })).toThrow(/depth/i); - expect(() => loadPromptProfile(paseoHome, "large", { maxTotalBytes: 4 })).toThrow(/bytes/i); + expect(() => loadAgentDefinition(paseoHome, "depth", { maxDepth: 1 })).toThrow(/depth/i); + expect(() => loadAgentDefinition(paseoHome, "large", { maxTotalBytes: 4 })).toThrow(/bytes/i); }); - it("orders profile append, session prompt, and daemon append with daemon last", () => { - writeProfile("extend.md", "Profile prompt."); - const profile = loadPromptProfile(paseoHome, "extend"); + it("orders agent append, session prompt, and daemon append with daemon last", () => { + writeAgent("extend.md", "Agent prompt."); + const agent = loadAgentDefinition(paseoHome, "extend"); expect( composePromptParts({ - profile, - systemPrompt: " Agent prompt. ", + agent, + systemPrompt: " Session prompt. ", daemonAppendSystemPrompt: "Daemon prompt.", }), ).toEqual({ - appendSystemPrompt: ["Profile prompt.", "Agent prompt.", "Daemon prompt."], + appendSystemPrompt: ["Agent prompt.", "Session prompt.", "Daemon prompt."], }); }); - it("uses override profile body as custom prompt while appending session and daemon prompts", () => { - writeProfile( + it("uses override agent body as custom prompt while appending session and daemon prompts", () => { + writeAgent( "override.md", `--- -mode: override +prompt: override --- Replacement base. `, ); - const profile = loadPromptProfile(paseoHome, "override"); + const agent = loadAgentDefinition(paseoHome, "override"); expect( composePromptParts({ - profile, - systemPrompt: "Agent prompt.", + agent, + systemPrompt: "Session prompt.", daemonAppendSystemPrompt: "Daemon prompt.", }), ).toEqual({ customPrompt: "Replacement base.", - appendSystemPrompt: ["Agent prompt.", "Daemon prompt."], + appendSystemPrompt: ["Session prompt.", "Daemon prompt."], }); }); }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts index 49963a0f9e..63c1ce5302 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts @@ -1,44 +1,51 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; -import { basename, extname, isAbsolute, relative, resolve } from "node:path"; +import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import { z } from "zod"; +import { ToolPermissionRuleSchema, type ToolPermissionRule } from "./agent-permissions.js"; const DEFAULT_MAX_DEPTH = 8; const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024; -const INLINE_INCLUDE_PATTERN = /\{\{\s*include:\s*([^}]+?)\s*\}\}/g; +const PARTIAL_PATTERN = /!\{\{\s*([^}]+?)\s*\}\}/g; -const PromptProfileFrontmatterSchema = z +const AgentDefinitionFrontmatterSchema = z .object({ name: z.string().min(1).optional(), description: z.string().min(1).optional(), - mode: z.enum(["extend", "override"]).default("extend"), - include: z.array(z.string().min(1)).optional(), + prompt: z.enum(["extend", "override"]).default("extend"), mcp: z.array(z.string().min(1)).optional(), model: z.string().min(1).optional(), + tools: z.array(z.string().min(1)).optional(), + permissions: z.array(ToolPermissionRuleSchema).optional(), // Parsed for the future explicit project-context model. It is intentionally // inactive here; Paseo Agent still keeps implicit AGENTS.md discovery off. projectContext: z.boolean().optional(), }) - .strict(); + .passthrough(); -export type PromptProfileFrontmatter = z.infer; +export type AgentDefinitionFrontmatter = z.infer; +export type PromptProfileFrontmatter = AgentDefinitionFrontmatter; export interface PaseoComposedPrompt { customPrompt?: string; appendSystemPrompt: string[]; } -export interface ResolvedPromptProfile { +export interface ResolvedAgentDefinition { id: string; path: string; - frontmatter: PromptProfileFrontmatter; + frontmatter: AgentDefinitionFrontmatter; body: string; composedPrompt: PaseoComposedPrompt; expectedMcpServers: string[]; model?: string; + tools?: string[]; + permissions: ToolPermissionRule[]; } -interface LoadPromptProfileOptions { +export type ResolvedPromptProfile = ResolvedAgentDefinition; + +interface LoadAgentDefinitionOptions { maxDepth?: number; maxTotalBytes?: number; } @@ -48,53 +55,64 @@ interface LoadState { } interface ParsedMarkdown { - frontmatter: PromptProfileFrontmatter; + frontmatter: AgentDefinitionFrontmatter; body: string; } -export function loadPromptProfile( +export function loadAgentDefinition( paseoHome: string, - profileName: string | undefined, - options: LoadPromptProfileOptions = {}, -): ResolvedPromptProfile | null { - if (!profileName) { + agentName: string | undefined, + options: LoadAgentDefinitionOptions = {}, +): ResolvedAgentDefinition | null { + if (!agentName) { return null; } const agentsDir = resolve(paseoHome, "agents"); - const profilePath = resolveProfilePath(agentsDir, profileName); - if (!existsSync(profilePath)) { + const agentPath = resolveAgentPath(agentsDir, agentName); + if (!existsSync(agentPath)) { return null; } const state: LoadState = { totalBytes: 0 }; - const parsed = loadMarkdownWithIncludes({ + const parsed = loadMarkdownWithPartials({ agentsDir, - path: profilePath, + path: agentPath, depth: 0, stack: [], state, options, + allowFrontmatter: true, }); const body = trimPrompt(parsed.body); - const mode = parsed.frontmatter.mode; + const promptMode = parsed.frontmatter.prompt; const composedPrompt = - mode === "override" + promptMode === "override" ? { customPrompt: body, appendSystemPrompt: [] } : { appendSystemPrompt: body ? [body] : [] }; return { - id: basename(profilePath, ".md"), - path: profilePath, + id: basename(agentPath, ".md"), + path: agentPath, frontmatter: parsed.frontmatter, body, composedPrompt, expectedMcpServers: parsed.frontmatter.mcp ?? [], ...(parsed.frontmatter.model ? { model: parsed.frontmatter.model } : {}), + ...(parsed.frontmatter.tools ? { tools: parsed.frontmatter.tools } : {}), + permissions: parsed.frontmatter.permissions ?? [], }; } -export function listPromptProfileIds(paseoHome: string): string[] { +export function loadPromptProfile( + paseoHome: string, + profileName: string | undefined, + options: LoadAgentDefinitionOptions = {}, +): ResolvedPromptProfile | null { + return loadAgentDefinition(paseoHome, profileName, options); +} + +export function listAgentDefinitionIds(paseoHome: string): string[] { const agentsDir = resolve(paseoHome, "agents"); if (!existsSync(agentsDir)) { return []; @@ -105,19 +123,23 @@ export function listPromptProfileIds(paseoHome: string): string[] { .sort(); } -function resolveProfilePath(agentsDir: string, profileName: string): string { - if (!isSafeRelativePath(profileName) || profileName.includes("/") || profileName.includes("\\")) { - throw new Error(`Invalid Paseo Agent prompt profile path: ${profileName}`); +export function listPromptProfileIds(paseoHome: string): string[] { + return listAgentDefinitionIds(paseoHome); +} + +function resolveAgentPath(agentsDir: string, agentName: string): string { + if (!isSafeRelativePath(agentName) || agentName.includes("/") || agentName.includes("\\")) { + throw new Error(`Invalid Paseo Agent definition path: ${agentName}`); } - const filename = profileName.endsWith(".md") ? profileName : `${profileName}.md`; + const filename = agentName.endsWith(".md") ? agentName : `${agentName}.md`; return resolveConfinedPath(agentsDir, filename); } -function resolveIncludePath(agentsDir: string, includePath: string): string { - if (!isSafeRelativePath(includePath)) { - throw new Error(`Invalid Paseo Agent prompt include path: ${includePath}`); +function resolvePartialPath(agentsDir: string, currentPath: string, partialPath: string): string { + if (!isSafeRelativePath(partialPath)) { + throw new Error(`Invalid Paseo Agent partial path: ${partialPath}`); } - return resolveConfinedPath(agentsDir, includePath); + return resolveConfinedPath(agentsDir, resolve(dirname(currentPath), partialPath)); } function isSafeRelativePath(input: string): boolean { @@ -125,71 +147,63 @@ function isSafeRelativePath(input: string): boolean { } function resolveConfinedPath(agentsDir: string, input: string): string { - const resolved = resolve(agentsDir, input); - const rel = relative(agentsDir, resolved); + const realAgentsDir = existsSync(agentsDir) ? realpathSync(agentsDir) : agentsDir; + const resolved = isAbsolute(input) ? input : resolve(agentsDir, input); + const comparablePath = existsSync(resolved) ? realpathSync(resolved) : resolved; + const rel = relative(realAgentsDir, comparablePath); if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { - throw new Error(`Paseo Agent prompt path escapes agents directory: ${input}`); + throw new Error(`Paseo Agent path escapes agents directory: ${input}`); } return resolved; } -function loadMarkdownWithIncludes(input: { +function loadMarkdownWithPartials(input: { agentsDir: string; path: string; depth: number; stack: string[]; state: LoadState; - options: LoadPromptProfileOptions; + options: LoadAgentDefinitionOptions; + allowFrontmatter: boolean; }): ParsedMarkdown { const maxDepth = input.options.maxDepth ?? DEFAULT_MAX_DEPTH; if (input.depth > maxDepth) { throw new Error(`Paseo Agent prompt include depth exceeds ${maxDepth}`); } if (!existsSync(input.path) || !statSync(input.path).isFile()) { - throw new Error( - `Paseo Agent prompt include not found: ${relative(input.agentsDir, input.path)}`, - ); + throw new Error(`Paseo Agent partial not found: ${relative(input.agentsDir, input.path)}`); } const path = realpathConfined(input.agentsDir, input.path); if (input.stack.includes(path)) { const cycle = [...input.stack, path].map((entry) => relative(input.agentsDir, entry)); - throw new Error(`Paseo Agent prompt include cycle: ${cycle.join(" -> ")}`); + throw new Error(`Paseo Agent partial cycle: ${cycle.join(" -> ")}`); } const raw = readFileSync(path, "utf8"); input.state.totalBytes += Buffer.byteLength(raw, "utf8"); const maxTotalBytes = input.options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES; if (input.state.totalBytes > maxTotalBytes) { - throw new Error(`Paseo Agent prompt profile exceeds ${maxTotalBytes} bytes`); + throw new Error(`Paseo Agent definition exceeds ${maxTotalBytes} bytes`); } - const parsed = parseMarkdown(raw); + const parsed = parseMarkdown(raw, input.allowFrontmatter); const stack = [...input.stack, path]; - const frontmatterIncludes = parsed.frontmatter.include ?? []; - const prepended = frontmatterIncludes.map( - (includePath) => - loadMarkdownWithIncludes({ + const bodyWithPartials = parsed.body.replace( + PARTIAL_PATTERN, + (_match, partialPath: string) => + loadMarkdownWithPartials({ ...input, - path: resolveIncludePath(input.agentsDir, includePath), - depth: input.depth + 1, - stack, - }).body, - ); - const bodyWithInlineIncludes = parsed.body.replace( - INLINE_INCLUDE_PATTERN, - (_match, includePath: string) => - loadMarkdownWithIncludes({ - ...input, - path: resolveIncludePath(input.agentsDir, includePath.trim()), + path: resolvePartialPath(input.agentsDir, path, partialPath.trim()), depth: input.depth + 1, stack, + allowFrontmatter: false, }).body, ); return { frontmatter: parsed.frontmatter, - body: joinPromptParts([...prepended, bodyWithInlineIncludes]), + body: bodyWithPartials, }; } @@ -198,45 +212,47 @@ function realpathConfined(agentsDir: string, path: string): string { const realPath = realpathSync(path); const rel = relative(realAgentsDir, realPath); if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { - throw new Error( - `Paseo Agent prompt path escapes agents directory: ${relative(agentsDir, path)}`, - ); + throw new Error(`Paseo Agent path escapes agents directory: ${relative(agentsDir, path)}`); } return realPath; } -function parseMarkdown(raw: string): ParsedMarkdown { +function parseMarkdown(raw: string, allowFrontmatter: boolean): ParsedMarkdown { if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) { return { - frontmatter: PromptProfileFrontmatterSchema.parse({}), + frontmatter: AgentDefinitionFrontmatterSchema.parse({}), body: raw, }; } + if (!allowFrontmatter) { + throw new Error("Paseo Agent partials cannot declare frontmatter"); + } + const newline = raw.startsWith("---\r\n") ? "\r\n" : "\n"; const closeMarker = `${newline}---${newline}`; const closeIndex = raw.indexOf(closeMarker, 4); if (closeIndex === -1) { - throw new Error("Paseo Agent prompt profile has unterminated frontmatter"); + throw new Error("Paseo Agent definition has unterminated frontmatter"); } const yaml = raw.slice(4, closeIndex); const body = raw.slice(closeIndex + closeMarker.length); const value = yaml.trim() ? parseYaml(yaml) : {}; return { - frontmatter: PromptProfileFrontmatterSchema.parse(value ?? {}), + frontmatter: AgentDefinitionFrontmatterSchema.parse(value ?? {}), body, }; } export function composePromptParts(input: { - profile?: ResolvedPromptProfile | null; + agent?: ResolvedAgentDefinition | null; systemPrompt?: string; daemonAppendSystemPrompt?: string; }): PaseoComposedPrompt | undefined { - const profilePrompt = input.profile?.composedPrompt; + const agentPrompt = input.agent?.composedPrompt; const appendSystemPrompt = [ - ...(profilePrompt?.appendSystemPrompt ?? []), + ...(agentPrompt?.appendSystemPrompt ?? []), input.systemPrompt, input.daemonAppendSystemPrompt, ].flatMap((part) => { @@ -244,9 +260,9 @@ export function composePromptParts(input: { return trimmed ? [trimmed] : []; }); const hasCustomPrompt = Boolean( - profilePrompt && Object.prototype.hasOwnProperty.call(profilePrompt, "customPrompt"), + agentPrompt && Object.prototype.hasOwnProperty.call(agentPrompt, "customPrompt"), ); - const customPrompt = trimPrompt(profilePrompt?.customPrompt); + const customPrompt = trimPrompt(agentPrompt?.customPrompt); if (!hasCustomPrompt && appendSystemPrompt.length === 0) { return undefined; @@ -261,7 +277,3 @@ export function composePromptParts(input: { function trimPrompt(value: string | undefined): string { return value?.trim() ?? ""; } - -function joinPromptParts(parts: string[]): string { - return parts.map(trimPrompt).filter(Boolean).join("\n\n"); -} diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index aab347ae54..4112af9fda 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -122,7 +122,7 @@ describe("DaemonConfigStore", () => { }); }); - test("generic mutable config strips dedicated Paseo Agent config instead of echoing secrets", () => { + test("generic mutable config strips agent provider config instead of echoing secrets", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -139,6 +139,13 @@ describe("DaemonConfigStore", () => { const secretBearingPatch: MutableDaemonConfigPatch & { agents: unknown } = { agents: { + providers: { + custom: { + env: { + API_KEY: "shared-provider-secret", + }, + }, + }, paseo: { providers: { "openrouter-main": { @@ -159,8 +166,11 @@ describe("DaemonConfigStore", () => { const broadcastJson = JSON.stringify(broadcasts); expect(returnedJson).not.toContain("sk-secret-openrouter"); expect(returnedJson).not.toContain("header-secret"); + expect(returnedJson).not.toContain("shared-provider-secret"); expect(broadcastJson).not.toContain("sk-secret-openrouter"); expect(broadcastJson).not.toContain("header-secret"); + expect(broadcastJson).not.toContain("shared-provider-secret"); + expect(loadPersistedConfig(paseoHome).agents?.providers).toBeUndefined(); expect(loadPersistedConfig(paseoHome).agents?.paseo).toBeUndefined(); }); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 94b76fe430..ff554de916 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -62,19 +62,13 @@ function isEqualValue(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } -function stripDedicatedPaseoAgentConfig>(config: T): T { - const agents = config.agents; - if (!isRecord(agents) || !Object.prototype.hasOwnProperty.call(agents, "paseo")) { +function stripAgentProviderSecretConfig>(config: T): T { + if (!Object.prototype.hasOwnProperty.call(config, "agents")) { return config; } - const { paseo: _paseo, ...remainingAgents } = agents; const next: Record = { ...config }; - if (Object.keys(remainingAgents).length > 0) { - next.agents = remainingAgents; - } else { - delete next.agents; - } + delete next.agents; return next as T; } @@ -107,7 +101,7 @@ export class DaemonConfigStore { constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) { this.paseoHome = paseoHome; this.logger = getLogger(logger); - this.current = stripDedicatedPaseoAgentConfig(MutableDaemonConfigSchema.parse(initial)); + this.current = stripAgentProviderSecretConfig(MutableDaemonConfigSchema.parse(initial)); } public get(): MutableDaemonConfig { @@ -115,10 +109,10 @@ export class DaemonConfigStore { } public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { - const parsedPatch = stripDedicatedPaseoAgentConfig( + const parsedPatch = stripAgentProviderSecretConfig( MutableDaemonConfigPatchSchema.parse(partial), ); - const next = stripDedicatedPaseoAgentConfig( + const next = stripAgentProviderSecretConfig( MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch)), ); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 84588df10d..2e682b7ba3 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -171,6 +171,8 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { agents: { paseo: { defaultModel: "openrouter-main/anthropic/claude", + defaultAgent: "builder", + defaultProfile: "orchestrator", providers: { "openrouter-main": { type: "openrouter", @@ -187,6 +189,8 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { }); expect(parsed.agents?.paseo?.defaultModel).toBe("openrouter-main/anthropic/claude"); + expect(parsed.agents?.paseo?.defaultAgent).toBe("builder"); + expect(parsed.agents?.paseo?.defaultProfile).toBe("orchestrator"); expect(parsed.agents?.paseo?.providers?.["openrouter-main"]?.type).toBe("openrouter"); }); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8d54bbd533..3611609b73 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2230,8 +2230,6 @@ export class Session { return this.handlePaseoAgentSetProviderRequest(msg); case "config.paseo_agent.remove_provider.request": return this.handlePaseoAgentRemoveProviderRequest(msg); - case "config.paseo_agent.set_default_model.request": - return this.handlePaseoAgentSetDefaultModelRequest(msg); case "config.paseo_agent.store_chatgpt_credential.request": return this.handlePaseoAgentStoreChatGptCredentialRequest(msg); case "list_available_providers_request": @@ -4257,35 +4255,6 @@ export class Session { } } - private async handlePaseoAgentSetDefaultModelRequest( - msg: Extract, - ): Promise { - try { - const defaultModel = this.createPaseoAgentConfigService().setDefaultModel(msg.model); - await this.refreshPaseoAgentRuntimeSnapshot(); - this.emit({ - type: "config.paseo_agent.set_default_model.response", - payload: { - requestId: msg.requestId, - success: true, - defaultModel, - error: null, - }, - }); - } catch (error) { - this.sessionLogger.error({ err: error }, "Failed to set Paseo Agent default model"); - this.emit({ - type: "config.paseo_agent.set_default_model.response", - payload: { - requestId: msg.requestId, - success: false, - defaultModel: null, - error: getErrorMessage(error), - }, - }); - } - } - private async handlePaseoAgentStoreChatGptCredentialRequest( msg: Extract< SessionInboundMessage, diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index e896c63630..0aabe303a5 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -282,6 +282,10 @@ "type": "string", "minLength": 1 }, + "defaultProfile": { + "type": "string", + "minLength": 1 + }, "providers": { "type": "object", "additionalProperties": { From a167d154bf979ed496585ce149b7a6cb48b1751d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 18 Jun 2026 15:50:03 +0700 Subject: [PATCH 06/16] Fix Paseo Agent Zod 4 record schemas --- packages/protocol/src/messages.ts | 2 +- .../server/src/server/agent/providers/paseo-agent/config.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1ad0761984..9c8fd3aabb 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1883,7 +1883,7 @@ const PaseoAgentSetProviderOptionsSchema = z apiKey: z.string().min(1).optional(), baseUrl: z.string().url().optional(), api: z.string().min(1).optional(), - headers: z.record(z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), authHeader: z.boolean().optional(), models: z.array(PaseoAgentProviderModelConfigSchema).min(1), }) diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index d6b7f69320..9a2cdf414b 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -120,7 +120,7 @@ const PaseoAgentProviderOptionsSchema = z baseUrl: z.string().url().optional(), // Override the wire api. Required for `custom`; optional elsewhere. api: z.string().min(1).optional(), - headers: z.record(z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), // Advanced: send `Authorization: Bearer ` as a header. Only needed for // endpoints whose wire api doesn't already attach the key. authHeader: z.boolean().optional(), @@ -169,7 +169,7 @@ export const PaseoAgentConfigSchema = z defaultProfile: z.string().min(1).optional(), // Inference providers keyed by instance name. Multiple entries may share a // type while pointing at different APIs/base URLs/models. - providers: z.record(PaseoAgentInferenceProviderSchema).optional(), + providers: z.record(z.string(), PaseoAgentInferenceProviderSchema).optional(), }) .strict(); From ca312f9fb8daeb943b5ca527f97f217f03aa9fc3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 10:43:28 +0700 Subject: [PATCH 07/16] Fix Paseo Agent PR review issues --- package-lock.json | 3 + .../paseo-agent-settings-sheet-model.test.ts | 60 ++++ .../paseo-agent-settings-sheet-model.ts | 41 +++ .../paseo-agent-settings-sheet.test.tsx | 258 ------------------ .../components/paseo-agent-settings-sheet.tsx | 42 +-- .../providers/paseo-agent/config-service.ts | 37 +-- .../agent/providers/paseo-agent/config.ts | 14 +- packages/server/src/server/session.ts | 4 +- 8 files changed, 127 insertions(+), 332 deletions(-) create mode 100644 packages/app/src/components/paseo-agent-settings-sheet-model.test.ts create mode 100644 packages/app/src/components/paseo-agent-settings-sheet-model.ts delete mode 100644 packages/app/src/components/paseo-agent-settings-sheet.test.tsx diff --git a/package-lock.json b/package-lock.json index 2e93038506..0d470bceab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3853,6 +3853,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "version": "0.77.0", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.77.0.tgz", + "integrity": "sha512-l/mYLJPjpgiPDmcfnFIGdOBqICkWaf8IawCdAbae5guBrPXg+Z0o84l9OuHyRJPOb8RfedeGg8DtSnq8t7grOg==", "license": "MIT", "dependencies": { "@earendil-works/pi-ai": "^0.77.0", @@ -3867,6 +3868,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "version": "0.77.0", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", + "integrity": "sha512-H21BrQDPf3ydaeBmS5maNDHxUGFMiKBF/n3WnE+OTWloIZSayeL+/NVEgG3aKQw8fZL6HAMYAGpUIVJgFuKtnw==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -3890,6 +3892,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "version": "0.77.0", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.tgz", + "integrity": "sha512-QV/eYtcT3hM9pJjLCkjUFOUmgAm4GPzJ0K4kofVq9+BGU7wNJVzflTO4VY2tYpaI4VSo6A5Hsuw00wY/CDmY/Q==", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts b/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts new file mode 100644 index 0000000000..b9ac6da70b --- /dev/null +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + createOpenRouterProviderInput, + parsePaseoAgentModelIds, + paseoAgentAuthLabel, +} from "./paseo-agent-settings-sheet-model"; + +describe("paseo-agent-settings-sheet-model", () => { + it("parses model ids from comma and newline separated input", () => { + expect( + parsePaseoAgentModelIds(` + anthropic/claude-3.7-sonnet, openai/gpt-4o + anthropic/claude-3.7-sonnet + openai/gpt-4o-mini + `), + ).toEqual(["anthropic/claude-3.7-sonnet", "openai/gpt-4o", "openai/gpt-4o-mini"]); + }); + + it("builds the OpenRouter provider payload without an empty api key", () => { + expect( + createOpenRouterProviderInput({ + name: " openrouter-main ", + apiKey: " ", + modelIds: ["openai/gpt-4o-mini"], + }), + ).toEqual({ + name: "openrouter-main", + providerType: "openrouter", + options: { + models: [{ id: "openai/gpt-4o-mini" }], + }, + }); + }); + + it("builds the OpenRouter provider payload with a trimmed api key", () => { + expect( + createOpenRouterProviderInput({ + name: "openrouter-main", + apiKey: " sk-or-secret ", + modelIds: ["openai/gpt-4o-mini", "anthropic/claude-3.7-sonnet"], + }), + ).toEqual({ + name: "openrouter-main", + providerType: "openrouter", + options: { + apiKey: "sk-or-secret", + models: [{ id: "openai/gpt-4o-mini" }, { id: "anthropic/claude-3.7-sonnet" }], + }, + }); + }); + + it("describes the provider auth state", () => { + expect(paseoAgentAuthLabel({ kind: "api_key", configured: true })).toBe("API key configured"); + expect(paseoAgentAuthLabel({ kind: "api_key", configured: false })).toBe("API key required"); + expect(paseoAgentAuthLabel({ kind: "oauth", configured: true })).toBe("ChatGPT login stored"); + expect(paseoAgentAuthLabel({ kind: "oauth", configured: false })).toBe("Login required"); + expect(paseoAgentAuthLabel({ kind: "none", configured: false })).toBe("No auth"); + }); +}); diff --git a/packages/app/src/components/paseo-agent-settings-sheet-model.ts b/packages/app/src/components/paseo-agent-settings-sheet-model.ts new file mode 100644 index 0000000000..cb73d50565 --- /dev/null +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.ts @@ -0,0 +1,41 @@ +import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; +import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers"; + +export function paseoAgentAuthLabel(auth: RedactedPaseoAgentProviderConfig["auth"]): string { + if (auth.kind === "oauth") { + return auth.configured ? "ChatGPT login stored" : "Login required"; + } + if (auth.kind === "none") { + return "No auth"; + } + return auth.configured ? "API key configured" : "API key required"; +} + +export function parsePaseoAgentModelIds(raw: string): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const part of raw.split(/[\n,]/)) { + const id = part.trim(); + if (id.length > 0 && !seen.has(id)) { + seen.add(id); + ids.push(id); + } + } + return ids; +} + +export function createOpenRouterProviderInput(input: { + name: string; + apiKey: string; + modelIds: string[]; +}): PaseoAgentSetProviderInput { + const trimmedKey = input.apiKey.trim(); + return { + name: input.name.trim(), + providerType: "openrouter", + options: { + models: input.modelIds.map((id) => ({ id })), + ...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : {}), + }, + }; +} diff --git a/packages/app/src/components/paseo-agent-settings-sheet.test.tsx b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx deleted file mode 100644 index 32093efd34..0000000000 --- a/packages/app/src/components/paseo-agent-settings-sheet.test.tsx +++ /dev/null @@ -1,258 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; - -const { theme, hookState, setProviderMock } = vi.hoisted(() => ({ - theme: { - spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, - fontSize: { xs: 11, sm: 13 }, - fontWeight: { medium: "500" }, - borderRadius: { lg: 8 }, - colors: { - surface1: "#111", - surface2: "#222", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#555", - destructive: "#f00", - statusSuccess: "#0f0", - }, - }, - hookState: { - supported: true, - providers: [] as RedactedPaseoAgentProviderConfig[], - isLoading: false, - error: null as string | null, - }, - setProviderMock: vi.fn(async () => null), -})); - -vi.mock("react-native", () => ({ - View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => - React.createElement("div", { "data-testid": testID }, children), - Text: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => - React.createElement("span", { "data-testid": testID }, children), -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, - }, -})); - -vi.mock("lucide-react-native", () => ({ - Plus: () => React.createElement("span", { "data-icon": "Plus" }), -})); - -vi.mock("@/constants/platform", () => ({ isWeb: true })); - -vi.mock("@/components/adaptive-modal-sheet", () => ({ - AdaptiveModalSheet: ({ - children, - footer, - visible, - testID, - }: { - children?: React.ReactNode; - footer?: React.ReactNode; - visible?: boolean; - testID?: string; - }) => (visible ? React.createElement("div", { "data-testid": testID }, children, footer) : null), - AdaptiveTextInput: ({ - onChangeText, - accessibilityLabel, - testID, - }: { - onChangeText?: (value: string) => void; - accessibilityLabel?: string; - testID?: string; - }) => - React.createElement("input", { - "data-testid": testID, - "aria-label": accessibilityLabel, - onChange: (event: React.ChangeEvent) => onChangeText?.(event.target.value), - }), -})); - -vi.mock("@/components/ui/button", () => ({ - Button: ({ - children, - onPress, - disabled, - testID, - }: { - children?: React.ReactNode; - onPress?: () => void; - disabled?: boolean; - testID?: string; - }) => - React.createElement( - "button", - { - type: "button", - "data-testid": testID, - disabled, - onClick: disabled ? undefined : onPress, - }, - children, - ), -})); - -vi.mock("@/hooks/use-paseo-agent-providers", () => ({ - usePaseoAgentProviders: () => ({ - supported: hookState.supported, - providers: hookState.providers, - defaultModel: null, - isLoading: hookState.isLoading, - error: hookState.error, - refresh: vi.fn(async () => {}), - setProvider: setProviderMock, - }), -})); - -import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet"; - -function openRouterProvider(): RedactedPaseoAgentProviderConfig { - return { - name: "openrouter-main", - providerType: "openrouter", - models: [{ id: "anthropic/claude-3.7-sonnet" }], - auth: { kind: "api_key", configured: true, source: "literal" }, - available: true, - error: null, - }; -} - -describe("PaseoAgentSettingsSheet", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - - beforeEach(() => { - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - hookState.supported = true; - hookState.providers = []; - hookState.isLoading = false; - hookState.error = null; - setProviderMock.mockReset(); - setProviderMock.mockResolvedValue(null); - }); - - afterEach(() => { - if (root) { - act(() => root?.unmount()); - } - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); - }); - - function render(): void { - act(() => { - root?.render(); - }); - } - - function type(testID: string, value: string): void { - const input = container?.querySelector(`[data-testid="${testID}"]`); - if (!input) throw new Error(`No input ${testID}`); - const setValue = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - act(() => { - setValue?.call(input, value); - input.dispatchEvent(new window.Event("input", { bubbles: true })); - }); - } - - function click(testID: string): void { - const el = container?.querySelector(`[data-testid="${testID}"]`); - if (!el) throw new Error(`No element ${testID}`); - act(() => { - el.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); - }); - } - - it("shows the update-host message and hides the add button when unsupported", () => { - hookState.supported = false; - render(); - - expect( - container?.querySelector('[data-testid="paseo-agent-unsupported"]')?.textContent, - ).toContain("Update the host to configure Paseo Agent."); - expect(container?.querySelector('[data-testid="paseo-agent-add-openrouter"]')).toBeNull(); - }); - - it("shows the error message instead of the empty state when the fetch fails", () => { - hookState.error = "Host is not connected"; - render(); - - const text = container?.textContent ?? ""; - expect(text).toContain("Host is not connected"); - expect(text).not.toContain("No inference providers configured yet."); - }); - - it("lists configured providers with type, model count, and auth state", () => { - hookState.providers = [openRouterProvider()]; - render(); - - const text = container?.textContent ?? ""; - expect(text).toContain("openrouter-main"); - expect(text).toContain("openrouter"); - expect(text).toContain("1 model"); - expect(text).toContain("API key configured"); - }); - - it("submits OpenRouter setup with name, api key, and parsed models", async () => { - render(); - - click("paseo-agent-add-openrouter"); - type("paseo-openrouter-name", "my-router"); - type("paseo-openrouter-api-key", "sk-or-secret"); - type("paseo-openrouter-models", "anthropic/claude-3.7-sonnet, openai/gpt-4o"); - - await act(async () => { - click("paseo-openrouter-submit"); - }); - - expect(setProviderMock).toHaveBeenCalledTimes(1); - expect(setProviderMock).toHaveBeenCalledWith({ - name: "my-router", - providerType: "openrouter", - options: { - apiKey: "sk-or-secret", - models: [{ id: "anthropic/claude-3.7-sonnet" }, { id: "openai/gpt-4o" }], - }, - }); - }); - - it("omits api key from the payload when left blank", async () => { - render(); - - click("paseo-agent-add-openrouter"); - type("paseo-openrouter-models", "anthropic/claude-3.7-sonnet"); - - await act(async () => { - click("paseo-openrouter-submit"); - }); - - expect(setProviderMock).toHaveBeenCalledWith({ - name: "openrouter", - providerType: "openrouter", - options: { - models: [{ id: "anthropic/claude-3.7-sonnet" }], - }, - }); - }); -}); diff --git a/packages/app/src/components/paseo-agent-settings-sheet.tsx b/packages/app/src/components/paseo-agent-settings-sheet.tsx index 9f3009bad7..f599ae84bb 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet.tsx +++ b/packages/app/src/components/paseo-agent-settings-sheet.tsx @@ -11,6 +11,11 @@ import { import { Button } from "@/components/ui/button"; import { isWeb } from "@/constants/platform"; import { usePaseoAgentProviders } from "@/hooks/use-paseo-agent-providers"; +import { + createOpenRouterProviderInput, + parsePaseoAgentModelIds, + paseoAgentAuthLabel, +} from "./paseo-agent-settings-sheet-model"; interface PaseoAgentSettingsSheetProps { serverId: string; @@ -24,33 +29,10 @@ const HEADER: SheetHeader = { title: "Paseo Agent" }; const ADD_HEADER: SheetHeader = { title: "Add OpenRouter provider" }; const DEFAULT_PROVIDER_NAME = "openrouter"; -function authLabel(auth: RedactedPaseoAgentProviderConfig["auth"]): string { - if (auth.kind === "oauth") { - return auth.configured ? "ChatGPT login stored" : "Login required"; - } - if (auth.kind === "none") { - return "No auth"; - } - return auth.configured ? "API key configured" : "API key required"; -} - -function parseModelIds(raw: string): string[] { - const seen = new Set(); - const ids: string[] = []; - for (const part of raw.split(/[\n,]/)) { - const id = part.trim(); - if (id.length > 0 && !seen.has(id)) { - seen.add(id); - ids.push(id); - } - } - return ids; -} - function ProviderRow({ provider }: { provider: RedactedPaseoAgentProviderConfig }) { const modelCount = provider.models.length; const modelLabel = modelCount === 1 ? "1 model" : `${modelCount} models`; - const auth = authLabel(provider.auth); + const auth = paseoAgentAuthLabel(provider.auth); return ( parseModelIds(models), [models]); + const modelIds = useMemo(() => parsePaseoAgentModelIds(models), [models]); const canSubmit = trimmedName.length > 0 && modelIds.length > 0 && !saving; const handleSubmit = useCallback(() => { if (!canSubmit) return; setError(null); setSaving(true); - const trimmedKey = apiKey.trim(); - void setProvider({ - name: trimmedName, - providerType: "openrouter", - options: { - models: modelIds.map((id) => ({ id })), - ...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : {}), - }, - }) + void setProvider(createOpenRouterProviderInput({ name: trimmedName, apiKey, modelIds })) .then(() => { setApiKey(""); onClose(); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts index acd3d41a12..4c12c1b84f 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -14,6 +14,7 @@ import { PaseoAgentConfigSchema, type PaseoAgentConfig, type PaseoAgentProviderType, + resolvePaseoAgentProviderTypeDefaults, } from "./config.js"; import { hasStoredOAuthCredential, storeCodexOAuthCredential } from "./oauth-store.js"; import { isRefreshTokenExpressionConfigured } from "./oauth-credentials.js"; @@ -45,40 +46,6 @@ interface SetProviderInput { }; } -const PROVIDER_DEFAULTS: Record< - PaseoAgentProviderType | "openai-codex", - { baseUrl?: string; api?: string; envVar?: string } -> = { - openrouter: { - baseUrl: "https://openrouter.ai/api/v1", - api: "openai-completions", - envVar: "OPENROUTER_API_KEY", - }, - openai: { - baseUrl: "https://api.openai.com/v1", - api: "openai-responses", - envVar: "OPENAI_API_KEY", - }, - anthropic: { - baseUrl: "https://api.anthropic.com", - api: "anthropic-messages", - envVar: "ANTHROPIC_API_KEY", - }, - opencode: { - baseUrl: "https://opencode.ai/zen/v1", - api: "openai-completions", - envVar: "OPENCODE_API_KEY", - }, - "openai-compatible": { - api: "openai-completions", - }, - custom: {}, - "openai-codex": { - baseUrl: "https://chatgpt.com/backend-api", - api: "openai-codex-responses", - }, -}; - const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; function resolveEnv(paseoHome: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { @@ -125,7 +92,7 @@ function redactedProviders( env: NodeJS.ProcessEnv, ): RedactedPaseoAgentProviderConfig[] { return Object.entries(config.providers ?? {}).map(([name, entry]) => { - const defaults = PROVIDER_DEFAULTS[entry.type]; + const defaults = resolvePaseoAgentProviderTypeDefaults(entry.type); let auth: PaseoAgentProviderAuthState; if (entry.type === "openai-codex") { const hasRefreshToken = diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index 9a2cdf414b..42ba6f6bda 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -43,7 +43,7 @@ const PROVIDER_TYPES = [ export type PaseoAgentProviderType = (typeof PROVIDER_TYPES)[number]; -interface ProviderTypeDefault { +export interface PaseoAgentProviderTypeDefault { /** Pi wire protocol. `undefined` for `custom`, where the user must pick one. */ api?: string; /** Default base URL. `undefined` means the user must supply `options.baseUrl`. */ @@ -55,7 +55,7 @@ interface ProviderTypeDefault { // Defaults mirror Pi's built-in provider definitions (packages/ai models). Pi adds // its own attribution headers for openrouter/opencode based on the base URL, so we // deliberately do not inject provider headers here. -const PROVIDER_TYPE_DEFAULTS: Record = { +const PROVIDER_TYPE_DEFAULTS: Record = { openrouter: { api: "openai-completions", baseUrl: "https://openrouter.ai/api/v1", @@ -98,6 +98,12 @@ const PROVIDER_TYPE_DEFAULTS: Record { - const defaults = PROVIDER_TYPE_DEFAULTS[entry.type]; + const defaults = resolvePaseoAgentProviderTypeDefaults(entry.type); if (!defaults.baseUrl && !entry.options.baseUrl) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -195,7 +201,7 @@ function entries(config: PaseoAgentConfig): [string, PaseoAgentInferenceProvider function resolveProviderSettings( entry: PaseoAgentInferenceProviderEntry, ): ResolvedProviderSettings { - const defaults = PROVIDER_TYPE_DEFAULTS[entry.type]; + const defaults = resolvePaseoAgentProviderTypeDefaults(entry.type); const apiKey = entry.options.apiKey ?? (defaults.envVar ? `$${defaults.envVar}` : undefined); return { baseUrl: entry.options.baseUrl ?? defaults.baseUrl, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3611609b73..94ab0cc1bf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -864,6 +864,7 @@ export class Session { } | null = null; private readonly terminalManager: TerminalManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager; + private paseoAgentConfigService: PaseoAgentConfigService | null = null; private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private readonly serviceProxy: ServiceProxySubsystem | null; private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; @@ -4145,7 +4146,7 @@ export class Session { } private createPaseoAgentConfigService(): PaseoAgentConfigService { - return new PaseoAgentConfigService({ + this.paseoAgentConfigService ??= new PaseoAgentConfigService({ paseoHome: this.paseoHome, logger: this.sessionLogger, onConfigChanged: (config) => { @@ -4153,6 +4154,7 @@ export class Session { this.agentManager.updateProviderRegistry(state); }, }); + return this.paseoAgentConfigService; } private async refreshPaseoAgentRuntimeSnapshot(): Promise { From c0072e7288015f9052fcfb4fe353f39bfabca6db Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 10:45:28 +0700 Subject: [PATCH 08/16] Precompile Paseo Agent permission matchers --- .../paseo-agent/agent-permissions.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts index 3172442851..8549751b47 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts @@ -12,34 +12,50 @@ export const ToolPermissionRuleSchema = z export type ToolPermissionAction = z.infer; export type ToolPermissionRule = z.infer; +export interface CompiledToolPermissionRule { + rule: ToolPermissionRule; + matches(toolName: string): boolean; +} + export interface ToolPermissionPolicy { rules: ToolPermissionRule[]; + compiledRules: CompiledToolPermissionRule[]; } export function createToolPermissionPolicy( rules: ToolPermissionRule[] | undefined, ): ToolPermissionPolicy { - return { rules: rules ?? [] }; + const normalizedRules = rules ?? []; + return { + rules: normalizedRules, + compiledRules: normalizedRules.map((rule) => ({ + rule, + matches: compileToolPattern(rule.tool), + })), + }; } export function evaluateToolPermission( policy: ToolPermissionPolicy | undefined, toolName: string, ): ToolPermissionAction { - for (const rule of policy?.rules ?? []) { - if (matchesToolPattern(rule.tool, toolName)) { - return rule.action; + for (const compiled of policy?.compiledRules ?? []) { + if (compiled.matches(toolName)) { + return compiled.rule.action; } } return "allow"; } -function matchesToolPattern(pattern: string, toolName: string): boolean { - if (pattern === toolName || pattern === "*") { - return true; +function compileToolPattern(pattern: string): (toolName: string) => boolean { + if (pattern === "*") { + return () => true; + } + if (!pattern.includes("*")) { + return (toolName) => toolName === pattern; } const matcher = new RegExp(`^${wildcardPatternToRegExp(pattern)}$`); - return matcher.test(toolName); + return (toolName) => matcher.test(toolName); } function wildcardPatternToRegExp(pattern: string): string { From 289e88421f7173b31660b4f44a5d38785cd9755d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 10:47:16 +0700 Subject: [PATCH 09/16] Share Paseo Agent env reference helpers --- .../providers/paseo-agent/config-service.ts | 5 ++-- .../agent/providers/paseo-agent/config.ts | 5 ++-- .../providers/paseo-agent/env-references.ts | 23 ++++++++++++++++ .../paseo-agent/oauth-credentials.ts | 26 ++++--------------- 4 files changed, 32 insertions(+), 27 deletions(-) create mode 100644 packages/server/src/server/agent/providers/paseo-agent/env-references.ts diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts index 4c12c1b84f..0ed8d5469a 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -18,6 +18,7 @@ import { } from "./config.js"; import { hasStoredOAuthCredential, storeCodexOAuthCredential } from "./oauth-store.js"; import { isRefreshTokenExpressionConfigured } from "./oauth-credentials.js"; +import { findEnvReferences } from "./env-references.js"; interface PaseoAgentConfigServiceOptions { paseoHome: string; @@ -46,8 +47,6 @@ interface SetProviderInput { }; } -const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; - function resolveEnv(paseoHome: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return env ?? { ...process.env, PASEO_HOME: paseoHome }; } @@ -71,7 +70,7 @@ function authStateForApiKey( if (value.startsWith("!")) { return { kind: "api_key", configured: true, source: "command" }; } - const referencedVars = Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]); + const referencedVars = findEnvReferences(value); if (referencedVars.length > 0) { return { kind: "api_key", diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index 42ba6f6bda..e8952e41f2 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -6,6 +6,7 @@ import { resolveRefreshTokenExpression, } from "./oauth-credentials.js"; import type { PaseoAgentInferenceProvider, PaseoAgentModelReference } from "./pi-services.js"; +import { findEnvReferences } from "./env-references.js"; export const PASEO_AGENT_PROVIDER = "paseo"; @@ -212,8 +213,6 @@ function resolveProviderSettings( }; } -const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; - /** * Whether a resolved API-key value is actually configured. Mirrors Pi's config-value * semantics without importing Pi: literals and `!command` values count as present; @@ -226,7 +225,7 @@ function isAuthConfigured(value: string | undefined, env: NodeJS.ProcessEnv): bo if (value.startsWith("!")) { return true; } - const referencedVars = Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]); + const referencedVars = findEnvReferences(value); if (referencedVars.length === 0) { return true; } diff --git a/packages/server/src/server/agent/providers/paseo-agent/env-references.ts b/packages/server/src/server/agent/providers/paseo-agent/env-references.ts new file mode 100644 index 0000000000..f88b3a62ff --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/env-references.ts @@ -0,0 +1,23 @@ +const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; +const ENV_REFERENCE_DETECT = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/; + +export function findEnvReferences(value: string): string[] { + return Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]); +} + +export function hasEnvReference(value: string): boolean { + return ENV_REFERENCE_DETECT.test(value); +} + +export function substituteEnvReferences(value: string, env: NodeJS.ProcessEnv): string | undefined { + let missing = false; + const result = value.replace(ENV_REFERENCE_PATTERN, (_match, name: string) => { + const resolved = env[name]; + if (resolved === undefined) { + missing = true; + return ""; + } + return resolved; + }); + return missing ? undefined : result; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts index e4d310389b..5613efbb36 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts @@ -1,4 +1,5 @@ import { execSync } from "node:child_process"; +import { hasEnvReference, substituteEnvReferences } from "./env-references.js"; // Resolution of a *self-supplied* OAuth refresh token expression — a literal, an env // reference (`$VAR` / `${VAR}`), or a `!command` that prints the token. This is an @@ -9,23 +10,6 @@ import { execSync } from "node:child_process"; // This module deliberately does NOT read any other tool's auth files (Codex CLI, // OpenCode, Pi, etc.) and imports no Pi runtime code. Token values are never logged. -const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; -const ENV_REFERENCE_DETECT = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/; - -/** Substitute `$VAR` / `${VAR}` references. Returns undefined if any var is unset. */ -function substituteEnv(value: string, env: NodeJS.ProcessEnv): string | undefined { - let missing = false; - const result = value.replace(ENV_REFERENCE_PATTERN, (_match, name: string) => { - const resolved = env[name]; - if (resolved === undefined) { - missing = true; - return ""; - } - return resolved; - }); - return missing ? undefined : result; -} - /** * Resolve a refresh-token expression to its literal value (may run a `!command`). * Returns undefined when it can't be resolved. @@ -46,8 +30,8 @@ export function resolveRefreshTokenExpression( return undefined; } } - if (ENV_REFERENCE_DETECT.test(value)) { - const substituted = substituteEnv(value, env); + if (hasEnvReference(value)) { + const substituted = substituteEnvReferences(value, env); return substituted && substituted.length > 0 ? substituted : undefined; } return value.length > 0 ? value : undefined; @@ -64,8 +48,8 @@ export function isRefreshTokenExpressionConfigured( if (value.startsWith("!")) { return true; } - if (ENV_REFERENCE_DETECT.test(value)) { - return substituteEnv(value, env) !== undefined; + if (hasEnvReference(value)) { + return substituteEnvReferences(value, env) !== undefined; } return value.length > 0; } From db59369288a6b41e6674030fa571cfc38cd8af30 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 10:58:01 +0700 Subject: [PATCH 10/16] Expose Pi TUI dependency to Nix --- package-lock.json | 38 ++++++++++++++++++++++++++++++++++++ packages/server/package.json | 1 + 2 files changed, 39 insertions(+) diff --git a/package-lock.json b/package-lock.json index 0d470bceab..eee8d67ab3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5167,6 +5167,19 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.tgz", + "integrity": "sha512-QV/eYtcT3hM9pJjLCkjUFOUmgAm4GPzJ0K4kofVq9+BGU7wNJVzflTO4VY2tYpaI4VSo6A5Hsuw00wY/CDmY/Q==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -24544,6 +24557,18 @@ "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", @@ -28282,6 +28307,18 @@ "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", @@ -40396,6 +40433,7 @@ "@earendil-works/pi-agent-core": "0.77.0", "@earendil-works/pi-ai": "0.77.0", "@earendil-works/pi-coding-agent": "0.77.0", + "@earendil-works/pi-tui": "0.77.0", "@getpaseo/client": "0.1.97", "@getpaseo/highlight": "0.1.97", "@getpaseo/protocol": "0.1.97", diff --git a/packages/server/package.json b/packages/server/package.json index a70a26919c..38b56a7bdc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -68,6 +68,7 @@ "@earendil-works/pi-agent-core": "0.77.0", "@earendil-works/pi-ai": "0.77.0", "@earendil-works/pi-coding-agent": "0.77.0", + "@earendil-works/pi-tui": "0.77.0", "@getpaseo/client": "0.1.97", "@getpaseo/highlight": "0.1.97", "@getpaseo/protocol": "0.1.97", From 21686a0abaeac2e7841fb2265cc01c85a900c87d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 11:04:12 +0700 Subject: [PATCH 11/16] Use Nix npm fetcher v2 --- nix/package.nix | 1 + scripts/update-nix.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index b904333f59..08623bde27 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -52,6 +52,7 @@ buildNpmPackage rec { # Default hash lives in nix/npm-deps.hash (see arg default above). # CI auto-updates that file when package-lock.json changes (see .github/workflows/). inherit npmDepsHash; + npmDepsFetcherVersion = 2; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/scripts/update-nix.sh b/scripts/update-nix.sh index 108cd0d60b..b33cd4f99a 100755 --- a/scripts/update-nix.sh +++ b/scripts/update-nix.sh @@ -35,7 +35,7 @@ NIXPKGS_URL="$(node -p " STDERR_LOG="$(mktemp)" trap "rm -f '$STDERR_LOG'" EXIT -if ! NEW_HASH="$(nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-deps "$LOCK_FILE" 2>"$STDERR_LOG")"; then +if ! NEW_HASH="$(NIX_NPM_FETCHER_VERSION=2 nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-deps "$LOCK_FILE" 2>"$STDERR_LOG")"; then echo "ERROR: prefetch-npm-deps failed:" >&2 tail -20 "$STDERR_LOG" >&2 exit 1 From 5ad4c40869b64c93a0dddc721d882d7fff84f55b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 11:15:05 +0700 Subject: [PATCH 12/16] Fix PR CI follow-up failures --- nix/npm-deps.hash | 2 +- .../src/hooks/use-paseo-agent-providers.ts | 10 ++++--- scripts/update-nix.sh | 26 ++++++++++++------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index b2734d616a..6169c4b9dc 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-lwIf9Z0uwDdNyAFu+L03pVwlQSuasYWIpn1Fsx3zxJw= +sha256-RBD8SQx4szE41qGDIYcaKWVhzfC1kVUBC7M5OOQBQQY= diff --git a/packages/app/src/hooks/use-paseo-agent-providers.ts b/packages/app/src/hooks/use-paseo-agent-providers.ts index 730585bc8a..85ffbb9247 100644 --- a/packages/app/src/hooks/use-paseo-agent-providers.ts +++ b/packages/app/src/hooks/use-paseo-agent-providers.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import type { PaseoAgentSetProviderRequest, RedactedPaseoAgentProviderConfig, @@ -33,9 +34,12 @@ interface UsePaseoAgentProvidersResult { } export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult { + const { t } = useTranslation(); const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const hostDisconnectedMessage = t("workspace.terminal.hostDisconnected"); + const saveProviderFailedMessage = t("settings.host.providers.addErrorTitle"); // COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30. const supported = useSessionStore( (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true, @@ -48,7 +52,7 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr staleTime: 30_000, queryFn: async () => { if (!client) { - throw new Error("Host is not connected"); + throw new Error(hostDisconnectedMessage); } return client.getPaseoAgentProviders(); }, @@ -63,11 +67,11 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr const setProviderMutation = useMutation({ mutationFn: async (input: PaseoAgentSetProviderInput) => { if (!client) { - throw new Error("Host is not connected"); + throw new Error(hostDisconnectedMessage); } const result = await client.setPaseoAgentProvider(input); if (!result.success) { - throw new Error(result.error ?? "Failed to save provider"); + throw new Error(result.error ?? saveProviderFailedMessage); } return result.provider; }, diff --git a/scripts/update-nix.sh b/scripts/update-nix.sh index b33cd4f99a..70f0257baf 100755 --- a/scripts/update-nix.sh +++ b/scripts/update-nix.sh @@ -25,18 +25,26 @@ node "$SCRIPT_DIR/fix-lockfile.mjs" "$LOCK_FILE" # 2. Prefetch deps and compute hash echo "Prefetching npm dependencies..." -# Resolve prefetch-npm-deps from the same nixpkgs pinned in flake.lock -NIXPKGS_URL="$(node -p " - const l = JSON.parse(require('fs').readFileSync('$ROOT_DIR/flake.lock', 'utf8')); - const n = l.nodes.nixpkgs.locked; - 'github:' + n.owner + '/' + n.repo + '/' + n.rev; -")" - STDERR_LOG="$(mktemp)" trap "rm -f '$STDERR_LOG'" EXIT -if ! NEW_HASH="$(NIX_NPM_FETCHER_VERSION=2 nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-deps "$LOCK_FILE" 2>"$STDERR_LOG")"; then - echo "ERROR: prefetch-npm-deps failed:" >&2 +HASH_EXPR=" +let + flake = builtins.getFlake \"path:$ROOT_DIR\"; + system = builtins.currentSystem; + fakeHash = \"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"; +in + (flake.packages.\${system}.default.override { npmDepsHash = fakeHash; }).npmDeps +" + +if nix build --no-link --impure --expr "$HASH_EXPR" >/dev/null 2>"$STDERR_LOG"; then + echo "ERROR: fake npmDepsHash unexpectedly succeeded." >&2 + exit 1 +fi + +NEW_HASH="$(sed -n 's/.*got:[[:space:]]*\(sha256-[^[:space:]]*\).*/\1/p' "$STDERR_LOG" | tail -1)" +if [[ -z "$NEW_HASH" ]]; then + echo "ERROR: failed to compute npmDepsHash:" >&2 tail -20 "$STDERR_LOG" >&2 exit 1 fi From 0627d279a7aaa5d14b15decda838d53cee0b4440 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 11:22:21 +0700 Subject: [PATCH 13/16] Align desktop Nix npm deps fetcher --- nix/desktop-package.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index 9703c497ae..87dcd9ac46 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -45,6 +45,7 @@ buildNpmPackage rec { nodejs = nodejs_22; inherit (paseo) npmDeps; + npmDepsFetcherVersion = 2; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild. We manually rebuild only node-pty in buildPhase. From 1633283a530d81d53c61c08a95b5f7dbb4f3e940 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 11:27:45 +0700 Subject: [PATCH 14/16] Resolve Paseo Agent credentials asynchronously --- .../agent/providers/paseo-agent/agent.ts | 2 +- .../providers/paseo-agent/config.test.ts | 26 +++++---- .../agent/providers/paseo-agent/config.ts | 58 +++++++++++-------- .../paseo-agent/oauth-credentials.test.ts | 22 ++++--- .../paseo-agent/oauth-credentials.ts | 43 ++++++++++---- 5 files changed, 93 insertions(+), 58 deletions(-) diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.ts index abb0d150a2..03f5a7244f 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -535,7 +535,7 @@ export class PaseoAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise { - const inferenceProviders = paseoAgentInferenceProviders(this.config); + const inferenceProviders = await paseoAgentInferenceProviders(this.config); if (inferenceProviders.length === 0) { throw new Error( "Paseo Agent has no configured inference providers. Add agents.paseo.providers to your Paseo config.", diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts index deee44070c..92e279f53f 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts @@ -115,8 +115,8 @@ describe("listPaseoAgentModels", () => { }); describe("paseoAgentInferenceProviders (per-type defaults)", () => { - it("applies openrouter defaults (base url, api, model fields)", () => { - const [provider] = paseoAgentInferenceProviders(configWith()); + it("applies openrouter defaults (base url, api, model fields)", async () => { + const [provider] = await paseoAgentInferenceProviders(configWith()); expect(provider.name).toBe("openrouter-main"); expect(provider.config.baseUrl).toBe("https://openrouter.ai/api/v1"); expect(provider.config.apiKey).toBe("sk-test"); @@ -130,19 +130,19 @@ describe("paseoAgentInferenceProviders (per-type defaults)", () => { }); }); - it("falls back to the type's env var when no apiKey is given", () => { + it("falls back to the type's env var when no apiKey is given", async () => { const config = PaseoAgentConfigSchema.parse({ providers: { anthropic: { type: "anthropic", options: { models: [{ id: "claude-x" }] } }, }, }); - const [provider] = paseoAgentInferenceProviders(config); + const [provider] = await paseoAgentInferenceProviders(config); expect(provider.config.baseUrl).toBe("https://api.anthropic.com"); expect(provider.config.apiKey).toBe("$ANTHROPIC_API_KEY"); expect(provider.config.models?.[0]?.api).toBe("anthropic-messages"); }); - it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", () => { + it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", async () => { const config = PaseoAgentConfigSchema.parse({ providers: { zen: { @@ -155,13 +155,13 @@ describe("paseoAgentInferenceProviders (per-type defaults)", () => { }, }, }); - const [provider] = paseoAgentInferenceProviders(config); + const [provider] = await paseoAgentInferenceProviders(config); expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/v1"); expect(provider.config.models?.[0]?.api).toBe("openai-completions"); expect(provider.config.models?.[1]?.api).toBe("anthropic-messages"); }); - it("passes through the custom escape hatch (explicit api + authHeader)", () => { + it("passes through the custom escape hatch (explicit api + authHeader)", async () => { const config = PaseoAgentConfigSchema.parse({ providers: { vertex: { @@ -177,7 +177,7 @@ describe("paseoAgentInferenceProviders (per-type defaults)", () => { }, }, }); - const [provider] = paseoAgentInferenceProviders(config); + const [provider] = await paseoAgentInferenceProviders(config); expect(provider.config.api).toBe("google-generative-ai"); expect(provider.config.authHeader).toBe(true); expect(provider.config.headers).toEqual({ "x-extra": "1" }); @@ -288,8 +288,8 @@ describe("openai-codex (ChatGPT subscription) provider", () => { ).toThrow(); }); - it("maps to a codex inference provider with an oauth marker and no api key", () => { - const [provider] = paseoAgentInferenceProviders(codexConfig({}), {}); + it("maps to a codex inference provider with an oauth marker and no api key", async () => { + const [provider] = await paseoAgentInferenceProviders(codexConfig({}), {}); expect(provider.name).toBe("chatgpt"); expect(provider.oauth).toEqual({ kind: "openai-codex" }); expect(provider.config.apiKey).toBeUndefined(); @@ -298,9 +298,11 @@ describe("openai-codex (ChatGPT subscription) provider", () => { expect(provider.config.models?.[0]?.api).toBe("openai-codex-responses"); }); - it("carries an advanced self-supplied refresh token resolved from an env var", () => { + it("carries an advanced self-supplied refresh token resolved from an env var", async () => { const config = codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" }); - const [provider] = paseoAgentInferenceProviders(config, { CODEX_REFRESH_TOKEN: "rt-env" }); + const [provider] = await paseoAgentInferenceProviders(config, { + CODEX_REFRESH_TOKEN: "rt-env", + }); expect(provider.oauth).toEqual({ kind: "openai-codex", refreshToken: "rt-env" }); }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index e8952e41f2..a1d2f55bec 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -268,45 +268,46 @@ function toPiModels(entry: PaseoAgentInferenceProviderEntry, settings: ResolvedP * normally lives in the Paseo-owned store (populated by `paseo login chatgpt`); an * advanced `options.refreshToken` may supply the user's own token instead. */ -export function paseoAgentInferenceProviders( +export async function paseoAgentInferenceProviders( config: PaseoAgentConfig, env: NodeJS.ProcessEnv = process.env, -): PaseoAgentInferenceProvider[] { - return entries(config).flatMap(([name, entry]): PaseoAgentInferenceProvider[] => { +): Promise { + const providers: PaseoAgentInferenceProvider[] = []; + + for (const [name, entry] of entries(config)) { const settings = resolveProviderSettings(entry); const models = toPiModels(entry, settings); if (entry.type === "openai-codex") { const refreshToken = entry.options.refreshToken - ? resolveRefreshTokenExpression(entry.options.refreshToken, env) + ? await resolveRefreshTokenExpression(entry.options.refreshToken, env) : undefined; - return [ - { - name, - config: { - ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), - ...(settings.api ? { api: settings.api } : {}), - models, - }, - oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) }, - }, - ]; - } - - return [ - { + providers.push({ name, config: { ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), - ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), ...(settings.api ? { api: settings.api } : {}), - ...(settings.headers ? { headers: settings.headers } : {}), - ...(settings.authHeader ? { authHeader: settings.authHeader } : {}), models, }, + oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) }, + }); + continue; + } + + providers.push({ + name, + config: { + ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), + ...(settings.api ? { api: settings.api } : {}), + ...(settings.headers ? { headers: settings.headers } : {}), + ...(settings.authHeader ? { authHeader: settings.authHeader } : {}), + models, }, - ]; - }); + }); + } + + return providers; } /** Enumerate configured models as Paseo model definitions (no Pi disk/auth reads). */ @@ -363,7 +364,7 @@ export function paseoAgentHasUsableModel( export function resolvePaseoAgentModel( config: PaseoAgentConfig, requestedModelId: string | null | undefined, - registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config), + registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentModelInventory(config), agentDefaultModelId?: string | null, ): PaseoAgentModelReference | undefined { if (requestedModelId) { @@ -383,6 +384,13 @@ export function resolvePaseoAgentModel( return firstRegisteredModel(registeredProviders); } +function paseoAgentModelInventory(config: PaseoAgentConfig): PaseoAgentInferenceProvider[] { + return entries(config).map(([name, entry]) => { + const settings = resolveProviderSettings(entry); + return { name, config: { models: toPiModels(entry, settings) } }; + }); +} + function firstModelId(config: PaseoAgentConfig): string | undefined { for (const [name, entry] of entries(config)) { const first = entry.options.models[0]; diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts index d8e65b5e6b..a59b6f6301 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.test.ts @@ -6,19 +6,23 @@ import { } from "./oauth-credentials.js"; describe("resolveRefreshTokenExpression", () => { - it("returns a literal value", () => { - expect(resolveRefreshTokenExpression("rt-literal", {})).toBe("rt-literal"); + it("returns a literal value", async () => { + await expect(resolveRefreshTokenExpression("rt-literal", {})).resolves.toBe("rt-literal"); }); - it("resolves an env reference and returns undefined when unset", () => { - expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).toBe("rt-env"); - expect(resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" })).toBe("rt-env"); - expect(resolveRefreshTokenExpression("$CODEX_RT", {})).toBeUndefined(); + it("resolves an env reference and returns undefined when unset", async () => { + await expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).resolves.toBe( + "rt-env", + ); + await expect( + resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" }), + ).resolves.toBe("rt-env"); + await expect(resolveRefreshTokenExpression("$CODEX_RT", {})).resolves.toBeUndefined(); }); - it("runs a !command and returns its trimmed output", () => { - expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).toBe("rt-cmd"); - expect(resolveRefreshTokenExpression("!exit 1", {})).toBeUndefined(); + it("runs a !command asynchronously and returns its trimmed output", async () => { + await expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).resolves.toBe("rt-cmd"); + await expect(resolveRefreshTokenExpression("!exit 1", {})).resolves.toBeUndefined(); }); }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts index 5613efbb36..ed7f17f02a 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts @@ -1,6 +1,10 @@ -import { execSync } from "node:child_process"; +import { exec } from "node:child_process"; +import { promisify } from "node:util"; import { hasEnvReference, substituteEnvReferences } from "./env-references.js"; +const execAsync = promisify(exec); +const CREDENTIAL_COMMAND_TIMEOUT_MS = 30_000; + // Resolution of a *self-supplied* OAuth refresh token expression — a literal, an env // reference (`$VAR` / `${VAR}`), or a `!command` that prints the token. This is an // advanced/manual escape hatch for users who already hold their own ChatGPT/Codex @@ -17,23 +21,40 @@ import { hasEnvReference, substituteEnvReferences } from "./env-references.js"; export function resolveRefreshTokenExpression( value: string, env: NodeJS.ProcessEnv = process.env, -): string | undefined { +): Promise { if (value.startsWith("!")) { const command = value.slice(1).trim(); if (!command) { - return undefined; - } - try { - const output = execSync(command, { encoding: "utf8", env }).trim(); - return output.length > 0 ? output : undefined; - } catch { - return undefined; + return Promise.resolve(undefined); } + + return execAsync(command, { + encoding: "utf8", + env, + timeout: CREDENTIAL_COMMAND_TIMEOUT_MS, + }) + .then(({ stdout }) => { + const output = stdout.trim(); + return output.length > 0 ? output : undefined; + }) + .catch(() => undefined); } + + return Promise.resolve(resolveStaticRefreshTokenExpression(value, env)); +} + +function resolveStaticRefreshTokenExpression( + value: string, + env: NodeJS.ProcessEnv, +): string | undefined { if (hasEnvReference(value)) { - const substituted = substituteEnvReferences(value, env); - return substituted && substituted.length > 0 ? substituted : undefined; + const output = substituteEnvReferences(value, env); + if (output) { + return output.length > 0 ? output : undefined; + } + return undefined; } + return value.length > 0 ? value : undefined; } From 4375e49455160247e8870ebc526e7aa1b9dfbfb2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 11:43:27 +0700 Subject: [PATCH 15/16] Move OAuth file mode checks to POSIX tests --- .../paseo-agent/oauth-store.posix.test.ts | 51 +++++++++++++++++++ .../providers/paseo-agent/oauth-store.test.ts | 6 +-- 2 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts new file mode 100644 index 0000000000..d4b9df8884 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts @@ -0,0 +1,51 @@ +// POSIX-only: file mode bits are not represented the same way on Windows. +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { isPlatform } from "../../../../test-utils/platform.js"; +import { loginAndStoreCodex, loginAndStoreCodexBrowser } from "./oauth-store.js"; + +describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => { + let home: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "paseo-oauth-store-")); + env = { PASEO_HOME: home }; + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("stores device-code credentials in a private file", async () => { + const login = async () => ({ refresh: "rt-from-login", access: "ac", expires: 123 }); + + const { path } = await loginAndStoreCodex({ + providerInstance: "chatgpt", + env, + onDeviceCode: () => {}, + login, + }); + + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("stores browser-login credentials in a private file", async () => { + const login = async (opts: { onAuth: (info: { url: string }) => void }) => { + opts.onAuth({ url: "https://auth.openai.com/oauth/authorize?x=1" }); + return { refresh: "rt-browser", access: "ac", expires: 456 }; + }; + + const { path } = await loginAndStoreCodexBrowser({ + providerInstance: "chatgpt", + env, + onAuthUrl: () => {}, + login, + }); + + expect(statSync(path).mode & 0o777).toBe(0o600); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts index c3d0e6a270..18341b3ad1 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -59,9 +59,6 @@ describe("oauth-store", () => { expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); const stored = JSON.parse(readFileSync(path, "utf8")); expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-from-login" }); - - // Stored private (0600). - expect(statSync(path).mode & 0o777).toBe(0o600); }); it("keys the credential by provider instance name", async () => { @@ -99,7 +96,6 @@ describe("oauth-store", () => { expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); const stored = JSON.parse(readFileSync(path, "utf8")); expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-browser" }); - expect(statSync(path).mode & 0o777).toBe(0o600); }); it("browser login falls back to manual code entry only when the callback can't complete", async () => { From b9d5d4d3473a1a2155b5e0ecfc39b5a0de3b1ef6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 19 Jun 2026 12:21:35 +0700 Subject: [PATCH 16/16] Open Paseo Agent E2E provider settings --- packages/app/e2e/helpers/paseo-agent.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/helpers/paseo-agent.ts b/packages/app/e2e/helpers/paseo-agent.ts index 3a2aea032c..65a297674e 100644 --- a/packages/app/e2e/helpers/paseo-agent.ts +++ b/packages/app/e2e/helpers/paseo-agent.ts @@ -4,7 +4,7 @@ import { expect } from "@playwright/test"; import { gotoAppShell, openSettings } from "./app"; import { connectDaemonClient } from "./daemon-client-loader"; import { getServerId } from "./server-id"; -import { openSettingsHost } from "./settings"; +import { openSettingsHostSection } from "./settings"; type PaseoAgentDaemonClient = Pick< InternalDaemonClient, @@ -35,7 +35,7 @@ async function connectPaseoAgentClient(): Promise { export async function openPaseoAgentSettings(page: Page): Promise { await gotoAppShell(page); await openSettings(page); - await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); await page.getByRole("button", { name: "Paseo Agent provider details", exact: true }).click(); const sheet = page.getByTestId("paseo-agent-settings-sheet"); await expect(sheet).toBeVisible();