diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 94186ce18..92e78517b 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -64,7 +64,7 @@ model can choose the right profile. ### `provider` -Required built-in provider id: +Required provider instance id. The default instance ids are: ```yaml provider: codex @@ -76,22 +76,29 @@ provider: copilot provider: grok ``` -Unsupported or custom providers are rejected. DevSpace maps providers to their -native integration: +Profiles may also reference named provider instances configured under +`subagents.providers`, such as `provider: codex-work`. Provider instances map +to one of these built-in driver integrations: - `codex`: the host-installed `codex app-server` command - `claude`: Claude Code SDK -- `opencode`: OpenCode SDK -- `pi`: the installed Pi coding-agent SDK, one in-process session per DevSpace agent -- `cursor`: ACP -- `copilot`: ACP -- `grok`: Grok Build ACP (`grok agent stdio`) - -Codex is resolved from the user's environment rather than bundled with -DevSpace. Run `codex login` normally before using it; set `CODEX_COMMAND` when -the executable is not on the normal PATH. OpenCode, Cursor, and Copilot -runtimes are started and reused by the daemon internally, while Pi is embedded -through its Node SDK. +- `opencode`: the host-installed OpenCode CLI; DevSpace probes its major version + and uses the matching 1.x or 2.x protocol internally +- `pi`: Pi's normal `--mode rpc` CLI process. DevSpace preserves Pi's native + auth, settings, extensions, skills, context files, and session store. +- `acp`: generic Agent Client Protocol integration. Cursor, Copilot, and Grok + are default provider instances using this driver; other ACP agents can be + configured without adding a DevSpace driver. + +Codex and OpenCode are resolved from the user's environment rather than bundled +as executables. Run their normal login/setup flows before using them; set +`CODEX_COMMAND` when Codex is not on the normal PATH. OpenCode is discovered as +`opencode`, and DevSpace automatically routes OpenCode 1.x and 2.x to their +matching runtime implementations. OpenCode, Cursor, and Copilot runtimes are +started and reused by the daemon internally. Pi is launched in RPC mode and +uses its native persisted session file as DevSpace's continuation identity; +`PI_COMMAND` selects another Pi executable (otherwise `pi` on `PATH`, then the +bundled package). Pi 0.80.5 or newer is required for the `agent_settled` event. ### `model` @@ -118,10 +125,10 @@ DevSpace passes this through to providers that expose a matching control: - `claude`: SDK effort with adaptive thinking. - `codex`: app-server model reasoning effort. -- `pi`: the AgentSession thinking-level control. +- `pi`: RPC `set_thinking_level`. - `opencode`: model variant. -- `cursor` and `copilot`: ACP thought-level config when supported. -- `grok`: `--reasoning-effort` on startup and xAI's ACP model metadata for resumed sessions. +- ACP: standard model/thought-level config when advertised by the agent. The + Grok flavor also understands xAI's typed model metadata and startup effort. ### `disabled` diff --git a/docs/configuration.md b/docs/configuration.md index 408018c7f..cfadaefa4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -149,6 +149,27 @@ Subagent providers are explicit. Omitted providers are disabled: } ``` +`id` is the provider instance id used by profiles and `devspace agents run`. +For the default built-in instance ids, `driver` is optional and defaults to the +same value. Named instances declare the driver explicitly, which allows +multiple independently configured instances of one integration: + +```jsonc +{ + "id": "codex-work", + "driver": "codex", + "enabled": true, + "env": { + "CODEX_HOME": "/home/alice/.codex-work", + }, +} +``` + +Profiles can then select that instance with `provider: codex-work`. The driver +is the implementation (`codex`, `claude`, `opencode`, `pi`, or `acp`); +provider-native session ids remain internal to DevSpace. Cursor, Copilot, and +Grok are default ACP provider instances rather than separate drivers. + `subagents.instructions` controls when ChatGPT receives the managed workflow: | Value | Behavior | @@ -171,15 +192,42 @@ does not expand `$NAME` references in these values. All subagent providers accept `env`. The daemon inherits its startup environment, then overlays the provider's `env` without mutating the daemon's process environment. OpenCode receives that environment on its managed server -process; embedded Pi scopes it to its provider requests and command execution. +process; Pi receives it on its managed RPC process. + +The `opencode` driver runs `opencode --version` before creating its first +runtime and automatically selects the OpenCode 1.x or 2.x protocol. Both major +versions use the same provider instance and profile configuration; `opencode2` +is not a separate driver. + +Codex, Claude, Pi, and ACP instances also accept `command`. OpenCode does not +expose a command override. Cursor, Copilot, and Grok retain their built-in ACP +commands when `command` is omitted. An arbitrary ACP instance declares its +executable and may provide fixed startup arguments under `config.args`: + +```jsonc +{ + "id": "kiro", + "driver": "acp", + "enabled": true, + "command": "kiro", + "config": { "args": ["acp"] } +} +``` + +`config.flavor` is reserved for the small built-in protocol quirks currently +needed by `cursor`, `copilot`, and `grok`; generic ACP agents normally omit it. +Those built-in flavors derive their sandbox arguments from the write mode, so +they reject `config.args`. DevSpace cannot enforce `read_only` or `allowed` on an +arbitrary ACP agent, so generic ACP instances run only with `full_access`. -Codex, Claude, Cursor, Copilot, and Grok also accept `command`. OpenCode and Pi -do not expose a command override. For providers that support it, an explicit -`command` wins over both the inherited command override and a command override -placed in `env`. +Pi runs as the normal CLI in `--mode rpc`, so user-installed extensions, +skills, context files, auth, models, and settings load normally. Restricted +DevSpace modes inject a sandbox extension and pin Pi's active built-in tools to +the matching read-only/workspace-safe set; `full_access` leaves Pi's normal +tool configuration untouched. Existing process-level overrides remain supported: `CODEX_COMMAND`, -`CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, +`CODEX_HOME`, `CLAUDE_COMMAND`, `PI_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, `GROK_COMMAND`, and `GROK_AGENT_PROFILE`. Provider configuration takes precedence where the same value is set in both places. diff --git a/package.json b/package.json index fa39fb46c..925bb0187 100644 --- a/package.json +++ b/package.json @@ -52,12 +52,13 @@ "@anthropic-ai/claude-agent-sdk": "0.3.200", "@anthropic-ai/sandbox-runtime": "0.0.71", "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.80.3", + "@earendil-works/pi-coding-agent": "^0.80.10", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "1.17.13", + "@opencode/client": "2.0.18", "@pierre/diffs": "^1.3.6", "better-result": "^2.10.0", "better-sqlite3": "^12.10.0", @@ -66,7 +67,7 @@ "drizzle-orm": "^0.45.2", "express": "^5.2.1", "jsonc-parser": "^3.3.1", - "koffi": "^3.1.2", + "koffi": "3.2.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92ca7f368..875c1be5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^1.5.1 version: 1.5.1 '@earendil-works/pi-coding-agent': - specifier: ^0.80.3 - version: 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.10 + version: 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@modelcontextprotocol/ext-apps': specifier: ^1.7.2 version: 1.7.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) @@ -38,6 +38,9 @@ importers: '@opencode-ai/sdk': specifier: 1.17.13 version: 1.17.13 + '@opencode/client': + specifier: 2.0.18 + version: 2.0.18(effect@4.0.0-rc.112) '@pierre/diffs': specifier: ^1.3.6 version: 1.3.6(@shikijs/themes@3.23.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -63,7 +66,7 @@ importers: specifier: ^3.3.1 version: 3.3.1 koffi: - specifier: ^3.1.2 + specifier: 3.2.1 version: 3.2.1 lucide: specifier: ^1.24.0 @@ -311,22 +314,22 @@ packages: resolution: {integrity: sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==} engines: {node: '>= 20.12.0'} - '@earendil-works/pi-agent-core@0.80.3': - resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} + '@earendil-works/pi-agent-core@0.80.10': + resolution: {integrity: sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.80.3': - resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} + '@earendil-works/pi-ai@0.80.10': + resolution: {integrity: sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.80.3': - resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} + '@earendil-works/pi-coding-agent@0.80.10': + resolution: {integrity: sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.80.3': - resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} + '@earendil-works/pi-tui@0.80.10': + resolution: {integrity: sha512-c2JO29PbhKPEQ6fgHQKAl0WhwuFqzWfzspMmP+8B5tpDuP+0mvarRbKKg8gq4b+pQx/QX+6aVS4ko7deoyjQjg==} engines: {node: '>=22.19.0'} '@emnapi/core@1.10.0': @@ -717,6 +720,36 @@ packages: resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} engines: {node: '>=20'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -729,6 +762,23 @@ packages: '@opencode-ai/sdk@1.17.13': resolution: {integrity: sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ==} + '@opencode/client@2.0.18': + resolution: {integrity: sha512-N74FNdXkTTC1Re5T+PNxH6fsVf1tZiV3kESu3UWL/545oAkBRXoiikMmDszuuHZ4AxGwtr9NiQjKrHiMdnLVYQ==} + peerDependencies: + effect: 4.0.0-rc.112 + solid-js: '>=1.9.0' + peerDependenciesMeta: + effect: + optional: true + solid-js: + optional: true + + '@opencode/protocol@2.0.18': + resolution: {integrity: sha512-IhzzlCz/hAlFvBLWQ+kDceX0/CkYOrjVyTPwYJYvWwKze5AHzVxxDYxkhCqLJj0hn/wtdNL10nhjVothuHcaBQ==} + + '@opencode/schema@2.0.18': + resolution: {integrity: sha512-f+lp6f+kxhygdeSxjqkiu6vk9t0onH/6OVBghSNyo9ZUUFP9BU/2JKthMgy9kHGyFu2vp0lg01H9umXn76u52A==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1317,6 +1367,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1373,6 +1426,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1737,6 +1794,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1769,6 +1833,10 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-pty@1.1.0: resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} @@ -1869,6 +1937,9 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -2492,9 +2563,9 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@earendil-works/pi-agent-core@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -2506,7 +2577,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -2527,11 +2598,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.80.3 + '@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.80.10 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -2557,7 +2628,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.3': + '@earendil-works/pi-tui@0.80.10': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -2830,6 +2901,24 @@ snapshots: '@modelcontextprotocol/core': 2.0.0 zod: 4.4.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2843,6 +2932,23 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@opencode/client@2.0.18(effect@4.0.0-rc.112)': + dependencies: + '@opencode/protocol': 2.0.18 + '@opencode/schema': 2.0.18 + optionalDependencies: + effect: 4.0.0-rc.112 + + '@opencode/protocol@2.0.18': + dependencies: + '@opencode/schema': 2.0.18 + effect: 4.0.0-rc.112 + + '@opencode/schema@2.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + effect: 4.0.0-rc.112 + '@opentelemetry/api@1.9.0': {} '@opentelemetry/semantic-conventions@1.41.1': {} @@ -3280,6 +3386,11 @@ snapshots: ee-first@1.1.1: {} + effect@4.0.0-rc.112: + dependencies: + fast-check: 4.10.2 + msgpackr: 2.1.0 + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -3375,6 +3486,10 @@ snapshots: extend@3.0.2: {} + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} fast-sha256@1.3.0: {} @@ -3742,6 +3857,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + nanoid@3.3.12: {} napi-build-utils@2.0.0: {} @@ -3765,6 +3896,11 @@ snapshots: node-forge@1.4.0: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-pty@1.1.0: dependencies: node-addon-api: 7.1.1 @@ -3874,6 +4010,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pure-rand@8.4.2: {} + qs@6.15.2: dependencies: side-channel: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b10c94d08..f7842d097 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ allowBuilds: better-sqlite3: true esbuild: true koffi: true + msgpackr-extract: false node-pty: true protobufjs: false diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index cf9ce8f84..ecd6270c9 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -177,92 +177,77 @@ "providers": { "type": "array", "items": { - "oneOf": [ - { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "driver": { + "type": "string", + "enum": [ + "codex", + "claude", + "opencode", + "pi", + "acp", + "cursor", + "copilot", + "grok" + ] + }, + "enabled": { + "type": "boolean" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "effort": { + "type": "string", + "minLength": 1 + }, + "env": { "type": "object", - "properties": { - "id": { - "type": "string", - "enum": [ - "codex", - "claude", - "cursor", - "copilot", - "grok" - ] - }, - "enabled": { - "type": "boolean" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "effort": { - "type": "string", - "minLength": 1 - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - }, - "additionalProperties": { - "type": "string" - } - }, - "command": { - "type": "string", - "minLength": 1, - "pattern": "\\S" - } + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, - "required": [ - "id", - "enabled" - ], - "additionalProperties": false + "additionalProperties": { + "type": "string" + } + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "\\S" }, - { + "config": { "type": "object", "properties": { - "id": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "flavor": { "type": "string", "enum": [ - "opencode", - "pi" + "cursor", + "copilot", + "grok" ] - }, - "enabled": { - "type": "boolean" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "effort": { - "type": "string", - "minLength": 1 - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - }, - "additionalProperties": { - "type": "string" - } } }, - "required": [ - "id", - "enabled" - ], "additionalProperties": false } - ] + }, + "required": [ + "id", + "enabled" + ], + "additionalProperties": false } } }, diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 5ef4f5bb7..e486bd60b 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -60,6 +60,14 @@ devspace agents continue "" devspace agents wait ``` +Stop an active turn when its work is no longer needed: + +```bash +devspace agents stop +``` + +Stopping is scoped to the current project and preserves the durable agent record as `stopped`. + ## Good uses - Review a change for correctness, security, or missing tests. diff --git a/src/cli.test.ts b/src/cli.test.ts index 3a622eb19..1cc6ba5e6 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -68,7 +68,8 @@ try { workspaceId: "ws_current", workspaceRoot: projectRoot, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", model: "gpt-5.4", effort: "high", }).id, @@ -79,7 +80,8 @@ try { workspaceId: "ws_other", workspaceRoot: projectRoot, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }).id, { status: "running" }, ); diff --git a/src/cli.ts b/src/cli.ts index 3037f65e8..47c398264 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ import { formatLocalAgentProviderStatusSummary, } from "./local-agent-catalog.js"; import { loadLocalAgentProfiles } from "./local-agent-profiles.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { resolveSubagentsConfig } from "./local-agent-config.js"; import { parseLocalAgentContinueArgs, parseLocalAgentRunArgs, @@ -218,7 +218,7 @@ async function runInit({ force }: { force: boolean }): Promise { })); } - const currentSubagents = files.config.subagents; + const currentSubagents = resolveSubagentsConfig(files.config.subagents); const availability = getLocalAgentProviderAvailabilitySnapshot( process.env, currentSubagents, @@ -247,7 +247,7 @@ async function runInit({ force }: { force: boolean }): Promise { required: true, }); if (prompts.isCancel(providerAnswer)) throw new SetupCancelledError(); - const selectedProviders = providerAnswer as LocalAgentProvider[]; + const selectedProviders = providerAnswer as string[]; const subagents = updateOnboardingSubagentsConfig( currentSubagents, selectedProviders, @@ -497,6 +497,7 @@ function printHelp(): void { " devspace agents continue [--model ] [--effort ] ", " devspace agents show [--json]", " devspace agents wait ... [--timeout ] [--json]", + " devspace agents stop [--json]", " devspace agents daemon ", " devspace -v, --version Print the installed version", "", @@ -544,6 +545,9 @@ async function runAgentsCommand(args: string[]): Promise { case "wait": await runAgentWorkflowCommand(json, () => runAgentsWait(commandArgs, json)); return; + case "stop": + await runAgentWorkflowCommand(json, () => runAgentsStop(commandArgs, json)); + return; case "targets": await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; @@ -665,6 +669,19 @@ async function runAgentsWait(args: string[], json: boolean): Promise { printAgentXml(results.map(formatAgentObservation).join("\n")); } +async function runAgentsStop(args: string[], json: boolean): Promise { + const [id, ...extra] = args; + if (!id || extra.length > 0) throw new Error("Usage: devspace agents stop [--json]"); + const config = loadConfig(); + const client = createLocalAgentClient(config); + const scope = resolveCliWorkspaceContext(config.allowedRoots); + const record = presentAgentWorkflowResult(await client.stopAgent(id, scope), json); + if (!record) return; + const observation = presentAgentObservation(record); + if (json) printJson(observation); + else printAgentXml(formatAgentObservation(observation)); +} + function parseAgentsWaitArgs(args: string[]): { ids: string[]; timeoutMs?: number } { const ids: string[] = []; let timeoutMs: number | undefined; @@ -808,6 +825,7 @@ function printAgentsHelp(): void { " devspace agents continue [--model ] [--effort ] [--json] ", " devspace agents show [--json]", " devspace agents wait ... [--timeout ] [--json]", + " devspace agents stop [--json]", " devspace agents targets [--json]", " devspace agents daemon [--json]", ].join("\n"), diff --git a/src/config-migration.ts b/src/config-migration.ts index 837d46f32..e7ec9665b 100644 --- a/src/config-migration.ts +++ b/src/config-migration.ts @@ -5,7 +5,7 @@ import { type DevspaceConfig, } from "./config-schema.js"; import { storedSubagentsConfigSchema } from "./local-agent-config.js"; -import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; +import { LOCAL_AGENT_DEFAULT_PROVIDER_IDS } from "./local-agent-provider.js"; const legacyConfigSchema = z.object({ host: z.string().optional(), @@ -92,7 +92,7 @@ function migrateLegacySubagents( return { enabled: value, providers: value - ? LOCAL_AGENT_PROVIDERS.map((id) => ({ id, enabled: true })) + ? LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => ({ id, enabled: true })) : [], }; } diff --git a/src/config.ts b/src/config.ts index 34fcdfc25..b9cd6c7fb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import { expandHomePath } from "./roots.js"; import type { LoggingConfig } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; -import type { SubagentsConfig } from "./local-agent-config.js"; +import { resolveSubagentsConfig, type SubagentsConfig } from "./local-agent-config.js"; export type { ToolMode } from "./config-schema.js"; @@ -75,7 +75,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { skillPaths: stored.skills.paths, devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), - subagents: stored.subagents, + subagents: resolveSubagentsConfig(stored.subagents), agentDir: normalizePath(stored.skills.agentDir), logging: { ...stored.logging, diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 8bb1f7e4f..a047306f7 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -47,6 +47,16 @@ const migrations: Migration[] = [ name: "local-agent-turns", up: migrateLocalAgentTurns, }, + { + version: 9, + name: "local-agent-provider-instances", + up: migrateLocalAgentProviderInstances, + }, + { + version: 10, + name: "local-agent-generic-acp-driver", + up: migrateLocalAgentGenericAcpDriver, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -289,6 +299,30 @@ function migrateLocalAgentTurns(sqlite: Database.Database): void { `); } +function migrateLocalAgentProviderInstances(sqlite: Database.Database): void { + const columns = sqlite.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ + name: string; + }>; + const names = new Set(columns.map((column) => column.name)); + if (names.has("provider") && !names.has("provider_instance_id")) { + sqlite.exec("alter table local_agent_sessions rename column provider to provider_instance_id"); + } + addColumnIfMissing(sqlite, "local_agent_sessions", "driver", "text not null default ''"); + sqlite.exec(` + update local_agent_sessions + set driver = provider_instance_id + where driver is null or driver = '' + `); +} + +function migrateLocalAgentGenericAcpDriver(sqlite: Database.Database): void { + sqlite.exec(` + update local_agent_sessions + set driver = 'acp' + where driver in ('cursor', 'copilot', 'grok') + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 77b234368..a80f9c71f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -98,7 +98,8 @@ export const localAgentSessions = sqliteTable( workspaceId: text("workspace_id"), workspaceRoot: text("workspace_root").notNull(), profileName: text("profile_name").notNull(), - provider: text("provider").notNull(), + providerInstanceId: text("provider_instance_id").notNull(), + driver: text("driver").notNull(), model: text("model"), effort: text("effort"), providerSessionId: text("provider_session_id"), diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 7a5222261..df0ca63c3 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -16,6 +16,7 @@ const requests: Array<{ method: string; params?: unknown }> = []; const queues = new Map(); const connection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { requests.push({ method, params }); const input = params as { sessionId?: string } | undefined; @@ -54,7 +55,7 @@ const connection = { const sessionIds: string[] = []; const runtime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -105,7 +106,7 @@ assert.equal(requests.filter(({ method }) => method === "session/close").length, assert.equal(runtime.isAlive(), true); const resumedRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -144,7 +145,7 @@ assert.equal(queues.has("cursor_session_1"), false); assert.equal(requests.filter(({ method }) => method === "session/close").length, 1); const closeOnlyRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -203,6 +204,7 @@ const overlappingPrompt = new Promise((resolvePrompt) => { releaseOverlapp const promptEntered = new Promise((resolveEntered) => { markPromptEntered = resolveEntered; }); const overlapConnection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { const input = params as { sessionId?: string } | undefined; if (method === "session/new") { @@ -227,7 +229,7 @@ const overlapConnection = { closed: new Promise(() => undefined), }; const overlapRuntime = new AcpRuntime({ - provider: "cursor", + flavor: "cursor", command: "cursor-agent", args: ["acp"], env: {}, @@ -256,7 +258,8 @@ await overlapRuntime.close(); const cachedContext = { agentId: "agt_acp", - provider: "cursor" as const, + providerInstanceId: "cursor", + provider: "acp" as const, workspaceRoot: "/tmp/project", writeMode: "allowed" as const, }; @@ -266,12 +269,14 @@ assert.deepEqual(acpCommandArgs("cursor", cachedContext), [ ]); assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, - provider: "grok", + providerInstanceId: "grok", + provider: "acp", effort: "low", }), ["agent", "--reasoning-effort", "low", "stdio"]); assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, - provider: "grok", + providerInstanceId: "grok", + provider: "acp", effort: "low", }, { GROK_AGENT_PROFILE: " /tmp/grok-coding-only.md " }), [ "agent", "--agent-profile", "/tmp/grok-coding-only.md", "--reasoning-effort", "low", "stdio", @@ -286,11 +291,20 @@ assert.deepEqual(acpCommandArgs("copilot", { ...cachedContext, writeMode: "full_ "--acp", "--no-sandbox", "--allow-all", "-C", resolvedProject, ]); -const missingCommandDriver = new AcpLocalAgentDriver( - "cursor", - process.env, - () => join(tmpdir(), "devspace-definitely-missing-acp-command"), -); +const missingCommandDriver = new AcpLocalAgentDriver({ + flavor: "cursor", + env: process.env, + commandResolver: () => join(tmpdir(), "devspace-definitely-missing-acp-command"), +}); +// A stop that outlives the cancel grace period closes the runtime, so it must not be shared. +assert.equal(missingCommandDriver.runtimePolicy.scope, "agent"); +// Generic ACP agents cannot be held to a restricted write mode, so they run only with full_access. +const genericDriver = new AcpLocalAgentDriver({ command: process.execPath, args: ["-e", ""] }); +for (const writeMode of ["read_only", "allowed"] as const) { + const restricted = await genericDriver.createRuntime({ ...cachedContext, writeMode }); + assert.equal(restricted.isErr(), true); + if (restricted.isErr()) assert.match(restricted.error.message, /full_access/); +} const missingCommand = await missingCommandDriver.createRuntime(cachedContext); assert.equal(missingCommand.isErr(), true); if (missingCommand.isErr()) { @@ -313,8 +327,13 @@ if (process.platform === "win32") { `require("node:fs").writeFileSync(${JSON.stringify(marker)}, JSON.stringify(process.argv.slice(2)));\n`, ); await writeFile(command, `@ECHO OFF\r\n"${process.execPath}" "${recorder}" %*\r\n`); - const shimDriver = new AcpLocalAgentDriver("copilot", process.env, () => command); - const shimStartup = await shimDriver.createRuntime({ ...cachedContext, provider: "copilot", workspaceRoot }); + const shimDriver = new AcpLocalAgentDriver({ flavor: "copilot", env: process.env, commandResolver: () => command }); + const shimStartup = await shimDriver.createRuntime({ + ...cachedContext, + providerInstanceId: "copilot", + provider: "acp", + workspaceRoot, + }); assert.equal(shimStartup.isErr(), true); if (shimStartup.isErr()) assert.equal(shimStartup.error.code, "PROVIDER_PROTOCOL_ERROR"); const forwarded = JSON.parse(await readFile(marker, "utf8")) as string[]; @@ -348,6 +367,7 @@ const grokQueues = new Map(); const grokCompletionRegistry = new GrokPromptCompletionRegistry(); const grokConnection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { grokRequests.push({ method, params }); const input = params as { sessionId?: string; _meta?: { promptId?: string } } | undefined; @@ -386,7 +406,7 @@ const grokConnection = { closed: new Promise(() => undefined), }; const grokRuntime = new AcpRuntime({ - provider: "grok", + flavor: "grok", command: "grok", args: ["agent", "--reasoning-effort", "low", "stdio"], env: {}, @@ -413,6 +433,7 @@ await grokRuntime.close(); const grokConfigurationConnection = { agent: { + async notify(_method: string, _params?: unknown): Promise {}, async request(method: string): Promise { if (method === "session/new") { return { @@ -433,7 +454,7 @@ const grokConfigurationConnection = { closed: new Promise(() => undefined), }; const grokConfigurationRuntime = new AcpRuntime({ - provider: "grok", + flavor: "grok", command: "grok", args: ["agent", "stdio"], env: {}, @@ -465,3 +486,64 @@ await grokConfigurationRuntime.close(); await resumedRuntime.close(); await resumedRuntime.close(); assert.equal(resumedRuntime.isAlive(), false); + +// Stop: a provider that never answers session/prompt after session/cancel is given up on. +const stuckRequests: string[] = []; +const stuckNotifications: string[] = []; +const stuckRuntime = new AcpRuntime({ + flavor: "cursor", + command: "cursor-agent", + args: ["acp"], + env: {}, + cancelGraceMs: 50, +}, { + agent: { + async notify(method: string): Promise { stuckNotifications.push(method); }, + async request(method: string): Promise { + stuckRequests.push(method); + if (method === "session/new") return { sessionId: "stuck_session" }; + return new Promise(() => undefined); + }, + }, + close() {}, + closed: new Promise(() => undefined), +}); +const stuckAbort = new AbortController(); +const stuckRun = stuckRuntime.run({ prompt: "never answers", workspaceRoot: "/tmp/project", signal: stuckAbort.signal }); +await new Promise((resolveTick) => setTimeout(resolveTick, 10)); +stuckAbort.abort(); +const stuckResult = await stuckRun; +assert.equal(stuckResult.isErr(), true); +if (stuckResult.isErr()) assert.equal(stuckResult.error.code, "PROVIDER_CANCELLED"); +assert.deepEqual(stuckNotifications, ["session/cancel"]); +assert.equal(stuckRuntime.isAlive(), false, "an unresponsive provider is dropped from the pool"); + +// Stop requested before the prompt is sent must not start provider work. +const preAbortRequests: string[] = []; +const preAbortRuntime = new AcpRuntime({ + flavor: "cursor", + command: "cursor-agent", + args: ["acp"], + env: {}, +}, { + agent: { + async notify(): Promise {}, + async request(method: string): Promise { + preAbortRequests.push(method); + return method === "session/new" ? { sessionId: "pre_abort_session" } : { stopReason: "end_turn" }; + }, + }, + close() {}, + closed: new Promise(() => undefined), +}); +const preAborted = new AbortController(); +preAborted.abort(); +const preAbortResult = await preAbortRuntime.run({ + prompt: "should not run", + workspaceRoot: "/tmp/project", + signal: preAborted.signal, +}); +assert.equal(preAbortResult.isErr(), true); +if (preAbortResult.isErr()) assert.equal(preAbortResult.error.code, "PROVIDER_CANCELLED"); +assert.equal(preAbortRequests.includes("session/prompt"), false); +await preAbortRuntime.close(); diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 078391ea9..d12bf473f 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -3,11 +3,13 @@ import { createRequire } from "node:module"; import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import { + AgentProviderExecutionError, AgentProviderProtocolError, AgentProviderUnavailableError, captureAgentProviderResult, isProgrammerDefect, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import { terminateProcessTree } from "./process-platform.js"; import { DEVSPACE_VERSION } from "./version.js"; import { @@ -29,18 +31,21 @@ import type { } from "./local-agent-runtime.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; -export type AcpProvider = "cursor" | "copilot" | "grok"; +export type AcpFlavor = "cursor" | "copilot" | "grok" | "generic"; +export type AcpProvider = AcpFlavor; const MAX_ACP_QUEUE_ITEMS = 10_000; const MAX_ACP_STDERR_BYTES = 32 * 1024; const ACP_INITIALIZE_TIMEOUT_MS = 10_000; const ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS = 10 * 60_000; +// How long a provider gets to answer session/prompt after session/cancel before its process is dropped. +const ACP_CANCEL_GRACE_MS = 10_000; const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; const observeChildError = (): void => {}; -const ACP_COMMANDS: Record = { +const ACP_COMMANDS: Partial> = { cursor: ["cursor-agent", "acp"], copilot: ["copilot", "--acp"], grok: ["grok", "agent", "stdio"], @@ -49,6 +54,7 @@ const ACP_COMMANDS: Record = { interface AcpConnectionLike { agent: { request(method: string, params?: unknown): Promise; + notify(method: string, params?: unknown): Promise; }; close(error?: unknown): void; closed: Promise; @@ -65,7 +71,7 @@ interface AcpSessionQueue { } export interface AcpRuntimeOptions { - provider: AcpProvider; + flavor: AcpFlavor; command: string; args: string[]; env: NodeJS.ProcessEnv; @@ -77,10 +83,12 @@ export interface AcpRuntimeOptions { sessionMetadata?: Map; grokCompletionRegistry?: GrokPromptCompletionRegistry; promptCompletionTimeoutMs?: number; + cancelGraceMs?: number; } export class AcpRuntime implements LocalAgentRuntime { - readonly provider: AcpProvider; + readonly provider = "acp" as const; + private readonly flavor: AcpFlavor; private readonly child?: ChildProcessWithoutNullStreams; private readonly connection: AcpConnectionLike; private readonly capabilities: AcpCapabilities; @@ -90,13 +98,14 @@ export class AcpRuntime implements LocalAgentRuntime { private readonly sessionMetadata: Map; private readonly grokCompletionRegistry?: GrokPromptCompletionRegistry; private readonly promptCompletionTimeoutMs: number; + private readonly cancelGraceMs: number; private readonly activeSessions = new Set(); private promptSequence = 0; private alive = true; private closed = false; constructor(options: AcpRuntimeOptions, connection: AcpConnectionLike) { - this.provider = options.provider; + this.flavor = options.flavor; this.child = options.child; this.connection = connection; this.capabilities = options.capabilities ?? { resume: false, close: false }; @@ -106,6 +115,7 @@ export class AcpRuntime implements LocalAgentRuntime { this.sessionMetadata = options.sessionMetadata ?? new Map(); this.grokCompletionRegistry = options.grokCompletionRegistry; this.promptCompletionTimeoutMs = options.promptCompletionTimeoutMs ?? ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS; + this.cancelGraceMs = options.cancelGraceMs ?? ACP_CANCEL_GRACE_MS; void this.connection.closed.then(() => { if (!this.closed) this.alive = false; this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP connection closed.`)); @@ -138,13 +148,34 @@ export class AcpRuntime implements LocalAgentRuntime { }); } const sessionId = await this.openSession(input, callbacks); + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); if (this.activeSessions.has(sessionId)) { throw new TypeError(`${this.provider} ACP session ${sessionId} already has an active turn.`); } this.activeSessions.add(sessionId); + const removeAbort = bindLocalAgentAbort(input.signal, () => ( + this.connection.agent.notify("session/cancel", { sessionId }) + )); + // A provider that ignores session/cancel would leave the turn running forever: + // give up on it after a grace period and drop the process so the next turn starts clean. + let cancelTimer: NodeJS.Timeout | undefined; + let onAbort: (() => void) | undefined; + const cancelDeadline = input.signal && new Promise((_, reject) => { + onAbort = () => { + cancelTimer = setTimeout(() => { + void this.close(); + reject(localAgentCancelledError(this.provider, "run", new Error( + `${this.provider} did not acknowledge session/cancel within ${this.cancelGraceMs}ms.`, + ))); + }, this.cancelGraceMs); + }; + if (input.signal?.aborted) onAbort(); + else input.signal?.addEventListener("abort", onAbort, { once: true }); + }); + cancelDeadline?.catch(() => undefined); const queue = this.queues.get(sessionId) ?? { values: [] }; this.queues.set(sessionId, queue); - const promptId = this.provider === "grok" ? this.nextPromptId() : undefined; + const promptId = this.flavor === "grok" ? this.nextPromptId() : undefined; const completion = promptId && this.grokCompletionRegistry ? this.grokCompletionRegistry.wait( sessionId, @@ -166,9 +197,18 @@ export class AcpRuntime implements LocalAgentRuntime { prompt: [{ type: "text", text: input.prompt }], ...(promptId ? { _meta: { promptId, requestId: promptId } } : {}), }); - const response = completion - ? await Promise.race([standardResponse, completion]) - : await standardResponse; + let response: unknown; + try { + response = await Promise.race([ + standardResponse, + ...(completion ? [completion] : []), + ...(cancelDeadline ? [cancelDeadline] : []), + ]); + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run", cause); + throw cause; + } + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); if (completion && isGrokPromptCompletion(response)) { await yieldToAcpQueue(); } else if (promptId) { @@ -193,6 +233,9 @@ export class AcpRuntime implements LocalAgentRuntime { items: updates, }; } finally { + removeAbort(); + clearTimeout(cancelTimer); + if (onAbort) input.signal?.removeEventListener("abort", onAbort); if (promptId) this.grokCompletionRegistry?.remove(sessionId, promptId); this.activeSessions.delete(sessionId); } @@ -296,7 +339,7 @@ export class AcpRuntime implements LocalAgentRuntime { } private cacheSessionMetadata(sessionId: string, response: unknown): void { - if (hasAcpConfigOptions(response) || (this.provider === "grok" && readGrokSessionState(response))) { + if (hasAcpConfigOptions(response) || (this.flavor === "grok" && readGrokSessionState(response))) { this.sessionMetadata.set(sessionId, response); } } @@ -308,7 +351,7 @@ export class AcpRuntime implements LocalAgentRuntime { isNewSession = false, ): Promise { const metadata = response ?? this.sessionMetadata.get(sessionId); - if (this.provider === "grok") { + if (this.flavor === "grok") { await this.configureGrokSession(sessionId, input, metadata, isNewSession); return; } @@ -335,11 +378,11 @@ export class AcpRuntime implements LocalAgentRuntime { return; } if (input.model) { - const config = resolveAcpModelConfigUpdate(metadata, input.model, this.provider, sessionId); + const config = resolveAcpModelConfigUpdate(metadata, input.model, this.flavor, sessionId); await this.connection.agent.request("session/set_config_option", config); } if (input.effort) { - const config = resolveAcpEffortConfigUpdate(metadata, input.effort, this.provider, sessionId); + const config = resolveAcpEffortConfigUpdate(metadata, input.effort, this.flavor, sessionId); await this.connection.agent.request("session/set_config_option", config); } } @@ -409,25 +452,42 @@ export class AcpRuntime implements LocalAgentRuntime { } export class AcpLocalAgentDriver implements LocalAgentDriver { - readonly provider: AcpProvider; - // Keep ACP warm briefly, then let the generic pool close the process so the - // daemon can reach its own idle shutdown state. - readonly idleTimeoutMs = 5 * 60_000; + readonly provider = "acp" as const; + readonly providerInstanceId = "acp"; + // Agent-scoped: a stop that outlives the cancel grace period closes the + // runtime, which must not take other agents' turns down with it. + readonly runtimePolicy = { + scope: "agent", + authority: "write_mode", + idleTimeoutMs: 5 * 60_000, + } as const; + readonly capabilities = { + sessions: { resume: true, close: true }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; private commandResolved = false; private resolvedCommand?: string; - - constructor( - provider: AcpProvider, - private readonly env: NodeJS.ProcessEnv = process.env, - private readonly commandResolver: AcpCommandResolver = resolveAcpCommand, - ) { - this.provider = provider; - } - - runtimeKey(context: LocalAgentRuntimeContext): string { - const command = this.resolveCommand() ?? ACP_COMMANDS[this.provider][0]; - const writeMode = context.writeMode ?? "allowed"; - return `acp:${this.provider}:${command}:${writeMode}:${resolve(context.workspaceRoot)}`; + private readonly flavor: AcpFlavor; + private readonly env: NodeJS.ProcessEnv; + private readonly configuredCommand?: string; + private readonly configuredArgs?: string[]; + private readonly commandResolver: AcpCommandResolver; + + constructor(options: { + flavor?: Exclude; + command?: string; + args?: string[]; + env?: NodeJS.ProcessEnv; + commandResolver?: AcpCommandResolver; + } = {}) { + this.flavor = options.flavor ?? "generic"; + this.env = options.env ?? process.env; + this.configuredCommand = options.command; + this.configuredArgs = options.args; + this.commandResolver = options.commandResolver ?? resolveAcpCommand; } async createRuntime(context: LocalAgentRuntimeContext) { @@ -447,7 +507,23 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { message: `${this.provider} executable was not found.`, }); } - const args = acpCommandArgs(this.provider, context, this.env); + if (this.flavor === "generic" && context.writeMode !== "full_access") { + // DevSpace cannot bound what an arbitrary ACP agent writes: it may act + // without asking for permission, so only an explicit full_access turn may run it. + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: this.provider, + agentId: context.agentId, + operation: "create_runtime", + retryable: false, + message: `Generic ACP providers cannot enforce the ${context.writeMode ?? "allowed"} write mode; run them with full_access.`, + }); + } + // Built-in flavors derive their sandbox flags from the write mode, so only + // generic providers take configured arguments. + const args = this.flavor === "generic" + ? this.configuredArgs ?? [] + : acpCommandArgs(this.flavor, context, this.env); const child = spawn(command, args, { cwd: resolve(context.workspaceRoot), env: this.env, @@ -489,13 +565,13 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { const { client, methods, ndJsonStream } = await import("@agentclientprotocol/sdk"); const queues = new Map(); const sessionWriteModes = new Map(); - const grokCompletionRegistry = this.provider === "grok" + const grokCompletionRegistry = this.flavor === "grok" ? new GrokPromptCompletionRegistry() : undefined; const app = client({ name: "DevSpace" }) .onRequest(methods.client.session.requestPermission, (context) => { const writeMode = sessionWriteModes.get(context.params.sessionId); - const selected = selectAcpPermissionOption(context.params.options, writeMode, this.provider); + const selected = selectAcpPermissionOption(context.params.options, writeMode, this.flavor); return selected ? { outcome: { outcome: "selected", optionId: selected.optionId } } : { outcome: { outcome: "cancelled" } }; @@ -535,7 +611,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { ); const capabilities = readAcpCapabilities(init); const runtime = new AcpRuntime({ - provider: this.provider, + flavor: this.flavor, command, args, env: this.env, @@ -581,7 +657,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { private resolveCommand(): string | undefined { if (!this.commandResolved) { - this.resolvedCommand = this.commandResolver(this.provider, this.env); + this.resolvedCommand = this.commandResolver(this.flavor, this.env, this.configuredCommand); this.commandResolved = true; } return this.resolvedCommand; @@ -608,22 +684,30 @@ async function waitForProcessExit( } export function resolveAcpCommand( - provider: AcpProvider, + provider: AcpFlavor, env: NodeJS.ProcessEnv = process.env, + explicitCommand?: string, ): string | undefined { - const configured = provider === "cursor" + const configured = explicitCommand ?? (provider === "cursor" ? env.CURSOR_COMMAND : provider === "copilot" ? env.COPILOT_COMMAND - : env.GROK_COMMAND; - const command = configured ?? ACP_COMMANDS[provider][0]; + : provider === "grok" + ? env.GROK_COMMAND + : undefined); + const command = configured ?? ACP_COMMANDS[provider]?.[0]; + if (!command) return undefined; return resolveExecutableCommand(command, env); } -export type AcpCommandResolver = (provider: AcpProvider, env: NodeJS.ProcessEnv) => string | undefined; +export type AcpCommandResolver = ( + provider: AcpFlavor, + env: NodeJS.ProcessEnv, + explicitCommand?: string, +) => string | undefined; export function acpCommandArgs( - provider: AcpProvider, + provider: AcpFlavor, context: LocalAgentRuntimeContext, env: NodeJS.ProcessEnv = process.env, ): string[] { @@ -649,6 +733,7 @@ export function acpCommandArgs( "stdio", ]; } + if (provider === "generic") return []; const sandboxArgs = writeMode === "full_access" ? ["--no-sandbox"] : ["--experimental", "--sandbox"]; @@ -749,7 +834,7 @@ export function selectAcpAllowPermissionOption( export function selectAcpPermissionOption( options: Array<{ optionId: string; kind: string }>, writeMode: LocalAgentWriteMode | undefined, - provider?: AcpProvider, + provider?: AcpFlavor, ): { optionId: string } | undefined { if (!writeMode) return undefined; // Copilot's native sandbox has a per-command escape hatch enabled by diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 395072a1d..29315ce83 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -2,12 +2,14 @@ import assert from "node:assert/strict"; import { delimiter } from "node:path"; import { claudeCommandEnvironment, + createLocalAgentDrivers, extractOpenCodeFinalResponse, extractPiFinalResponse, extractPiProviderError, resolveAcpModelConfigUpdate, resolveAcpEffortConfigUpdate, } from "./local-agent-adapters.js"; +import { parseSubagentsConfig } from "./local-agent-config.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; assert.deepEqual( resolveAcpModelConfigUpdate({ @@ -330,5 +332,20 @@ assert.equal( removeDevspaceNodeModulesBinFromPath([devspaceBin, userBin].join(delimiter)), userBin, ); +} +{ + const subagents = parseSubagentsConfig({ + enabled: true, + providers: [ + { id: "claude-work", driver: "claude", enabled: true }, + { id: "claude-personal", driver: "claude", enabled: true }, + ], + }); + const drivers = createLocalAgentDrivers({ subagents }); + assert.deepEqual( + drivers.map((driver) => [driver.providerInstanceId, driver.provider]), + [["claude-work", "claude"], ["claude-personal", "claude"]], + ); + assert.deepEqual(drivers.map((driver) => driver.runtimePolicy.scope), ["agent", "agent"]); } diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index d83d2fc8e..765a439a7 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,9 +1,12 @@ import { - localAgentProviderEnvironment, - localAgentProviderEnvironmentOverrides, + subagentAcpFlavor, + type SubagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { + LOCAL_AGENT_DEFAULT_PROVIDER_IDS, + LOCAL_AGENT_DRIVER_KINDS, +} from "./local-agent-provider.js"; import { AcpLocalAgentDriver, resolveAcpCommand, @@ -25,9 +28,10 @@ import { PiLocalAgentDriver, extractPiFinalResponse, extractPiProviderError, - type PiSessionFactory, + type PiRpcFactory, } from "./local-agent-pi.js"; import type { LocalAgentDriver } from "./local-agent-runtime.js"; +import { LocalAgentProviderRegistry } from "./local-agent-provider-registry.js"; export type LocalAgentAdapter = LocalAgentDriver; @@ -36,28 +40,36 @@ export interface LocalAgentDriverOptions { subagents?: SubagentsConfig; claudeQueryFactory?: ClaudeQueryFactory; opencodeFactory?: OpencodeFactory; - piSessionFactory?: PiSessionFactory; + piRpcFactory?: PiRpcFactory; } export function createLocalAgentDrivers( options: LocalAgentDriverOptions = {}, ): LocalAgentDriver[] { const env = options.env ?? process.env; - const providerEnv = (provider: LocalAgentProvider) => options.subagents - ? localAgentProviderEnvironment(options.subagents, provider, env) - : env; - const providerEnvOverrides = (provider: LocalAgentProvider) => options.subagents - ? localAgentProviderEnvironmentOverrides(options.subagents, provider) - : {}; - return [ - new CodexLocalAgentDriver(providerEnv("codex")), - new ClaudeLocalAgentDriver(options.claudeQueryFactory, providerEnv("claude")), - new OpencodeLocalAgentDriver(options.opencodeFactory, providerEnv("opencode")), - new PiLocalAgentDriver(options.piSessionFactory, providerEnvOverrides("pi")), - new AcpLocalAgentDriver("cursor", providerEnv("cursor")), - new AcpLocalAgentDriver("copilot", providerEnv("copilot")), - new AcpLocalAgentDriver("grok", providerEnv("grok")), - ]; + const instances = options.subagents?.providers ?? LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => ({ + id, + driver: id === "cursor" || id === "copilot" || id === "grok" ? "acp" : id, + enabled: true, + } satisfies SubagentProviderConfig)); + const registry = createLocalAgentProviderRegistry(options); + return instances.map((instance) => registry.create(instance, options.subagents, env)); +} + +export function createLocalAgentProviderRegistry( + options: LocalAgentDriverOptions = {}, +): LocalAgentProviderRegistry { + return new LocalAgentProviderRegistry() + .register("codex", ({ env }) => new CodexLocalAgentDriver(env)) + .register("claude", ({ env }) => new ClaudeLocalAgentDriver(options.claudeQueryFactory, env)) + .register("opencode", ({ env }) => new OpencodeLocalAgentDriver({ factory: options.opencodeFactory, env })) + .register("pi", ({ env }) => new PiLocalAgentDriver(options.piRpcFactory, env)) + .register("acp", ({ instance, env }) => new AcpLocalAgentDriver({ + env, + command: instance.command, + args: instance.config?.args, + flavor: subagentAcpFlavor(instance), + })); } export function extractLocalAgentResponseText(value: unknown): string { diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index a5592b42a..c2f3d8e7b 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -43,6 +43,7 @@ assert.equal( instructions: "on-demand", providers: [{ id: "codex", + driver: "codex", enabled: true, command: executable, env: { OPENAI_API_KEY: "configured-secret", EMPTY_VALUE: "" }, @@ -59,3 +60,45 @@ assert.equal( rmSync(directory, { recursive: true, force: true }); } } + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-acp-availability-")); + try { + const grok = join(directory, "grok"); + writeFileSync(grok, "#!/bin/sh\nexit 0\n"); + chmodSync(grok, 0o700); + const env = { PATH: directory }; + const config = { + enabled: true, + instructions: "on-demand" as const, + providers: [ + { id: "cursor", driver: "acp" as const, enabled: true, config: { flavor: "cursor" as const } }, + { id: "cursor-work", driver: "acp" as const, enabled: true, config: { flavor: "grok" as const } }, + { id: "kiro", driver: "acp" as const, enabled: true, command: "kiro" }, + ], + }; + const snapshot = getLocalAgentProviderAvailabilitySnapshot(env, config); + assert.deepEqual(snapshot.filter((provider) => provider.name === "cursor").length, 1, "configured built-in ACP ids are listed once"); + assert.equal(snapshot.find((provider) => provider.name === "cursor-work")?.available, true, "preflight checks the flavor's executable"); + assert.equal(snapshot.find((provider) => provider.name === "kiro")?.reason, "kiro executable not found"); + assert.equal(snapshot.find((provider) => provider.name === "cursor")?.available, false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +{ + const directory = mkdtempSync(join(tmpdir(), "devspace-pi-availability-")); + try { + const pi = join(directory, "pi"); + writeFileSync(pi, "#!/bin/sh\nexit 0\n"); + chmodSync(pi, 0o700); + const availability = (env: NodeJS.ProcessEnv) => getLocalAgentProviderAvailabilitySnapshot(env) + .find((provider) => provider.name === "pi"); + assert.equal(availability({ PATH: directory })?.available, true, "a system pi on PATH is launchable"); + assert.equal(availability({ PATH: directory, PI_COMMAND: "/definitely/missing/pi" })?.available, false); + assert.match(availability({ PATH: directory, PI_COMMAND: "/definitely/missing/pi" })?.reason ?? "", /missing\/pi/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 14764488c..9ebcf4349 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,15 +1,21 @@ -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; +import { resolveAcpCommand } from "./local-agent-acp.js"; +import { resolvePiCommand } from "./local-agent-pi.js"; import { localAgentProviderEnvironment, + subagentAcpFlavor, + subagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; +import { + defaultDriverForProviderId, + LOCAL_AGENT_DEFAULT_PROVIDER_IDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export interface LocalAgentProviderAvailability { - name: LocalAgentProvider; + name: LocalAgentProviderInstanceId; available: boolean; reason?: string; note?: string; @@ -19,67 +25,105 @@ export function getLocalAgentProviderAvailabilitySnapshot( env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): LocalAgentProviderAvailability[] { - return LOCAL_AGENT_PROVIDERS.map((provider) => ( - checkLocalAgentProviderAvailability(provider, env, config) + const configured = new Map(config?.providers.map((provider) => [provider.id, provider]) ?? []); + const defaultIds = new Set(LOCAL_AGENT_DEFAULT_PROVIDER_IDS); + const instances = [ + ...LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => configured.get(id) ?? { id, driver: defaultDriverForProviderId(id)! }), + ...(config?.providers.filter((provider) => !defaultIds.has(provider.id)) ?? []), + ]; + return instances.map((instance) => checkLocalAgentProviderAvailability( + instance.id, + instance.driver, + env, + config, )); } function checkLocalAgentProviderAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, + driver: LocalAgentDriverKind, env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): LocalAgentProviderAvailability { - const providerEnv = config ? localAgentProviderEnvironment(config, provider, env) : env; - switch (provider) { + const providerEnv = config ? localAgentProviderEnvironment(config, providerInstanceId, env) : env; + switch (driver) { case "codex": - return codexAvailability(providerEnv); + return codexAvailability(providerInstanceId, providerEnv); case "claude": return providerEnv.CLAUDE_COMMAND - ? commandAvailability(provider, providerEnv.CLAUDE_COMMAND, providerEnv) - : packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); + ? commandAvailability(providerInstanceId, providerEnv.CLAUDE_COMMAND, providerEnv) + : packageAvailability(providerInstanceId, "@anthropic-ai/claude-agent-sdk"); case "opencode": - return packageAvailability(provider, "@opencode-ai/sdk/v2"); + return commandAvailability(providerInstanceId, "opencode", providerEnv); case "pi": - return packageAvailability(provider, "@earendil-works/pi-coding-agent"); - case "cursor": - return commandAvailability(provider, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); - case "copilot": - return commandAvailability(provider, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); - case "grok": - return commandAvailability(provider, providerEnv.GROK_COMMAND ?? "grok", providerEnv); + // Same resolution as the runtime: PI_COMMAND, then `pi` on PATH, then the bundled package. + try { + resolvePiCommand(providerEnv); + return { name: providerInstanceId, available: true }; + } catch (cause) { + return { + name: providerInstanceId, + available: false, + reason: cause instanceof Error ? cause.message : "Pi executable not found", + }; + } + case "acp": { + // Resolve exactly as the driver does, so preflight and startup agree on the executable. + const configured = config ? subagentProviderConfig(config, providerInstanceId) : undefined; + const flavor = subagentAcpFlavor({ id: providerInstanceId, config: configured?.config }); + if (resolveAcpCommand(flavor ?? "generic", providerEnv, configured?.command)) { + return { name: providerInstanceId, available: true }; + } + return { + name: providerInstanceId, + available: false, + reason: configured?.command + ? `${configured.command} executable not found` + : flavor + ? `${flavor} executable not found` + : "ACP command is not configured", + }; + } } } export function assertLocalAgentProviderAvailable( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): void { - const availability = checkLocalAgentProviderAvailability(provider, env, config); + const provider = config ? subagentProviderConfig(config, providerInstanceId) : undefined; + const driver = provider?.driver + ?? defaultDriverForProviderId(providerInstanceId); + if (!driver) throw new Error(`${providerInstanceId} provider is not configured.`); + const availability = checkLocalAgentProviderAvailability(providerInstanceId, driver, env, config); if (availability.available) return; throw new Error( - `${provider} provider is not available: ${availability.reason ?? "provider preflight failed"}`, + `${providerInstanceId} provider is not available: ${availability.reason ?? "provider preflight failed"}`, ); } function packageAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, packageName: string, ): LocalAgentProviderAvailability { try { import.meta.resolve(packageName); - return { name: provider, available: true }; + return { name: providerInstanceId, available: true }; } catch { return { - name: provider, + name: providerInstanceId, available: false, reason: `${packageName} package not found`, }; } } -function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailability { - const availability = commandAvailability("codex", env.CODEX_COMMAND ?? "codex", env); +function codexAvailability( + providerInstanceId: LocalAgentProviderInstanceId, + env: NodeJS.ProcessEnv, +): LocalAgentProviderAvailability { + const availability = commandAvailability(providerInstanceId, env.CODEX_COMMAND ?? "codex", env); return availability.available ? { ...availability, @@ -89,13 +133,13 @@ function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailabili } function commandAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, command: string, env: NodeJS.ProcessEnv, ): LocalAgentProviderAvailability { - if (resolveExecutableCommand(command, env)) return { name: provider, available: true }; + if (resolveExecutableCommand(command, env)) return { name: providerInstanceId, available: true }; return { - name: provider, + name: providerInstanceId, available: false, reason: `${command} executable not found`, }; diff --git a/src/local-agent-cancellation.ts b/src/local-agent-cancellation.ts new file mode 100644 index 000000000..e8d47a9d7 --- /dev/null +++ b/src/local-agent-cancellation.ts @@ -0,0 +1,28 @@ +import { AgentProviderCancelledError } from "./local-agent-errors.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; + +export function localAgentCancelledError( + provider: LocalAgentDriverKind, + operation: string, + cause?: unknown, +): AgentProviderCancelledError { + return new AgentProviderCancelledError({ + code: "PROVIDER_CANCELLED", + provider, + operation, + retryable: false, + ...(cause === undefined ? {} : { cause }), + message: `${provider} agent turn was stopped.`, + }); +} + +export function bindLocalAgentAbort( + signal: AbortSignal | undefined, + interrupt: () => void | Promise, +): () => void { + if (!signal) return () => undefined; + const onAbort = () => { void Promise.resolve(interrupt()).catch(() => undefined); }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + return () => signal.removeEventListener("abort", onAbort); +} diff --git a/src/local-agent-catalog.test.ts b/src/local-agent-catalog.test.ts index cc156251d..42acea196 100644 --- a/src/local-agent-catalog.test.ts +++ b/src/local-agent-catalog.test.ts @@ -10,9 +10,9 @@ const config: SubagentsConfig = { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true, model: "gpt-default", effort: "medium" }, - { id: "claude", enabled: true, model: "sonnet" }, - { id: "pi", enabled: false }, + { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, + { id: "claude", driver: "claude", enabled: true, model: "sonnet" }, + { id: "pi", driver: "pi", enabled: false }, ], }; const statuses = buildLocalAgentProviderStatuses(config, [ @@ -23,7 +23,7 @@ const statuses = buildLocalAgentProviderStatuses(config, [ assert.equal(statuses.find((provider) => provider.id === "codex")?.usable, true); assert.equal(statuses.find((provider) => provider.id === "claude")?.usable, false); assert.equal(statuses.find((provider) => provider.id === "pi")?.usable, false); -assert.equal(statuses.find((provider) => provider.id === "opencode")?.enabled, false); +assert.equal(statuses.find((provider) => provider.id === "opencode"), undefined); const profiles: LocalAgentProfile[] = [ { diff --git a/src/local-agent-catalog.ts b/src/local-agent-catalog.ts index c19904ba2..b446adab0 100644 --- a/src/local-agent-catalog.ts +++ b/src/local-agent-catalog.ts @@ -1,14 +1,14 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProfile, - type LocalAgentProfileSummary, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; +import type { LocalAgentProfile, LocalAgentProfileSummary } from "./local-agent-profiles.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export interface LocalAgentProviderStatus { - id: LocalAgentProvider; + id: LocalAgentProviderInstanceId; + driver: LocalAgentDriverKind; enabled: boolean; available: boolean; usable: boolean; @@ -28,13 +28,14 @@ export function buildLocalAgentProviderStatuses( config: SubagentsConfig, availability: readonly LocalAgentProviderAvailability[], ): LocalAgentProviderStatus[] { - return LOCAL_AGENT_PROVIDERS.map((id) => { - const configured = config.providers.find((entry) => entry.id === id); + return config.providers.map((configured) => { + const id = configured.id; const live = availability.find((entry) => entry.name === id); const enabled = configured?.enabled === true; const available = live?.available === true; return { id, + driver: configured.driver, enabled, available, usable: config.enabled && enabled && available, diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index 14e364005..2a470b8c4 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -6,7 +6,7 @@ import { type ClaudeUserMessage, } from "./local-agent-claude.js"; import { createLocalAgentDrivers } from "./local-agent-adapters.js"; -import { subagentsConfigSchema } from "./local-agent-config.js"; +import { parseSubagentsConfig } from "./local-agent-config.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { @@ -41,6 +41,8 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { this.closeCount += 1; } + async interrupt(): Promise {} + async setPermissionMode(mode: string): Promise { this.permissionModes.push(mode); } @@ -56,6 +58,7 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { const context: LocalAgentRuntimeContext = { agentId: "agt_claude", + providerInstanceId: "claude", provider: "claude", workspaceRoot: "/tmp/project", model: "sonnet", @@ -71,17 +74,11 @@ const driver = new ClaudeLocalAgentDriver(({ prompt, options }) => { query = new FakeClaudeQuery(prompt); return query; }, { PATH: "/usr/bin" }); -assert.equal(driver.runtimeKey(context), "claude:agt_claude:restricted"); -assert.equal( - driver.runtimeKey({ ...context, writeMode: "allowed" }), - "claude:agt_claude:restricted", - "restricted Claude modes can share one query because per-turn settings are dynamic", -); -assert.equal( - driver.runtimeKey({ ...context, writeMode: "full_access" }), - "claude:agt_claude:full_access", - "full access uses a query initialized with the explicit dangerous-permission opt-in", -); +assert.deepEqual(driver.runtimePolicy, { + scope: "agent", + authority: "full_access_boundary", + idleTimeoutMs: 3 * 60_000, +}); const runtimeResult = await driver.createRuntime(context); assert.equal(runtimeResult.isOk(), true); @@ -212,6 +209,7 @@ const brokenStreamQuery: ClaudeQueryLike = { }; }, close() {}, + async interrupt() {}, async setPermissionMode() {}, async applyFlagSettings() {}, }; @@ -241,7 +239,7 @@ const configuredDriver = createLocalAgentDrivers({ ANTHROPIC_API_KEY: "inherited", INHERITED: "yes", }, - subagents: subagentsConfigSchema.parse({ + subagents: parseSubagentsConfig({ enabled: true, providers: [{ id: "claude", diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index a639f2f46..d33a848a1 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -5,7 +5,8 @@ import { captureAgentProviderResult, isProgrammerDefect, } from "./local-agent-errors.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -28,6 +29,7 @@ const CLAUDE_WORKSPACE_ALLOWED_TOOLS = [ export interface ClaudeQueryLike extends AsyncIterable { close(): void; + interrupt(): Promise; setPermissionMode(mode: ClaudePermissionMode): Promise; applyFlagSettings(settings: Record): Promise; setModel?(model?: string): Promise; @@ -77,7 +79,7 @@ class AsyncInputQueue implements AsyncIterable { } export class ClaudeQueryRuntime implements LocalAgentRuntime { - readonly provider: LocalAgentProvider = "claude"; + readonly provider: LocalAgentDriverKind = "claude"; private readonly iterator: AsyncIterator; private alive = true; private closed = false; @@ -106,6 +108,8 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude runtime is not running.", }); } + const removeAbort = bindLocalAgentAbort(input.signal, () => this.query.interrupt()); + try { if (this.providerSessionId) await callbacks?.onSessionId?.(this.providerSessionId); const flagSettings = claudeAuthoritySettings(input.workspaceRoot, input.writeMode); if (input.effort) { @@ -117,6 +121,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { await this.query.applyFlagSettings(flagSettings); await this.query.setPermissionMode(claudePermissionMode(input.writeMode)); if (input.model && this.query.setModel) await this.query.setModel(input.model); + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run"); this.inputQueue.push({ type: "user", message: { role: "user", content: input.prompt }, @@ -129,6 +134,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { try { next = await this.iterator.next(); } catch (error) { + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run", error); this.alive = false; if (isProgrammerDefect(error)) throw error; throw new AgentProviderUnavailableError({ @@ -161,6 +167,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { } } if (record?.type !== "result") continue; + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run"); const resultError = claudeResultError(record); if (resultError) { @@ -190,6 +197,9 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { items, }; } + } finally { + removeAbort(); + } }, }); } @@ -213,18 +223,25 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { export class ClaudeLocalAgentDriver implements LocalAgentDriver { readonly provider = "claude" as const; - readonly idleTimeoutMs = 3 * 60_000; + readonly providerInstanceId = "claude"; + readonly runtimePolicy = { + scope: "agent", + authority: "full_access_boundary", + idleTimeoutMs: 3 * 60_000, + } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; constructor( private readonly factory: ClaudeQueryFactory = defaultClaudeQueryFactory, private readonly env: NodeJS.ProcessEnv = process.env, ) {} - runtimeKey(context: LocalAgentRuntimeContext): string { - const authority = context.writeMode === "full_access" ? "full_access" : "restricted"; - return `claude:${context.agentId}:${authority}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 5aa5bce35..74d735d6b 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -49,6 +49,7 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentStopError, AgentWaitError, LocalAgentWaitResult, RunOverrides, @@ -160,6 +161,14 @@ export class LocalAgentClient { return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults); } + async stopAgent( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const result = await this.request("agent.stop", { id: agentId, scope }); + return decodeRequestResult(result, "agent.stop", decodeAgentRecord); + } + async status(): Promise> { const result = await this.requestExisting("daemon.status", {}); return decodeRequestResult(result, "daemon.status", decodeDaemonStatus); @@ -695,6 +704,11 @@ function isRequestError( case "agent.get": case "agent.wait": return category === "target" || category === "scope" || category === "store"; + case "agent.stop": + return category === "target" + || category === "scope" + || category === "conflict" + || category === "store"; case "agent.list": return category === "scope" || category === "store"; case "hello": diff --git a/src/local-agent-codex.test.ts b/src/local-agent-codex.test.ts index 1dcebc043..74383e882 100644 --- a/src/local-agent-codex.test.ts +++ b/src/local-agent-codex.test.ts @@ -12,7 +12,12 @@ import { } from "./local-agent-codex.js"; import { toAgentErrorPayload } from "./local-agent-errors.js"; -const cachedContext = { agentId: "agt_test", provider: "codex" as const, workspaceRoot: "/tmp/project" }; +const cachedContext = { + agentId: "agt_test", + providerInstanceId: "codex", + provider: "codex" as const, + workspaceRoot: "/tmp/project", +}; assert.equal(parseCodexVersion("codex-cli 0.9.1"), "0.9.1"); assert.equal(sandboxFor("read_only"), "read-only"); diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index 937e44481..3a978875e 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -8,6 +8,7 @@ import { AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { terminateProcessTree } from "./process-platform.js"; import { DEVSPACE_VERSION } from "./version.js"; @@ -147,7 +148,15 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { } await callbacks?.onSessionId?.(threadId); - const completed = await this.rpc.runTurn(threadId, turnParams(input, threadId)); + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); + let completed: CodexTurnResult; + try { + completed = await this.rpc.runTurn(threadId, turnParams(input, threadId), input.signal); + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run", cause); + throw cause; + } + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); const parsed = parseCompletedTurn(completed.event.params, completed.items); if (parsed.failure) { throw new AgentProviderExecutionError({ @@ -230,7 +239,15 @@ async function waitForProcessExit( export class CodexLocalAgentDriver implements LocalAgentDriver { readonly provider = "codex" as const; - readonly idleTimeoutMs = 5 * 60_000; + readonly providerInstanceId = "codex"; + readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; private commandResolved = false; private resolvedCommand?: ResolvedCodexCommand; @@ -240,13 +257,6 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { private readonly commandResolver: CodexCommandResolver = resolveCodexCommand, ) {} - runtimeKey(_context: LocalAgentRuntimeContext): string { - const command = this.resolveCommand(); - const executable = command?.executable ?? this.env.CODEX_COMMAND ?? "codex"; - const codexHome = resolve(this.env.CODEX_HOME ?? join(homedir(), ".codex")); - return `codex:${executable}:${codexHome}`; - } - async createRuntime(_context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, @@ -360,7 +370,7 @@ class CodexAppServerRpc { this.write({ method, ...(params === undefined ? {} : { params }) }); } - async runTurn(threadId: string, params: unknown): Promise { + async runTurn(threadId: string, params: unknown, signal?: AbortSignal): Promise { if (this.fatalError) throw this.fatalError; if (this.turns.has(threadId)) throw new Error(`Codex thread ${threadId} already has an active turn.`); let resolveTurn!: (result: CodexTurnResult) => void; @@ -376,12 +386,22 @@ class CodexAppServerRpc { reject: rejectTurn, }; this.turns.set(threadId, turn); + const removeAbort = bindLocalAgentAbort(signal, async () => { + if (!turn.turnId) return; + await this.request("turn/interrupt", { threadId, turnId: turn.turnId }); + }); try { const response = await this.request("turn/start", params); turn.turnId = readString(asRecord(response)?.turn, "id"); + if (signal?.aborted && turn.turnId) { + // A failed interrupt must not report the turn as stopped: keep waiting for the + // provider's terminal event; the manager bounds how long stop waits for it. + await this.request("turn/interrupt", { threadId, turnId: turn.turnId }).catch(() => undefined); + } if (turn.completed) return { event: turn.completed, items: turn.items }; return await completion; } finally { + removeAbort(); if (this.turns.get(threadId) === turn) this.turns.delete(threadId); } } diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 6cf19515a..f936c175d 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -3,11 +3,12 @@ import { isSubagentProviderEnabled, localAgentProviderConfigRevision, localAgentProviderEnvironment, + parseSubagentsConfig, subagentProviderConfig, subagentsConfigSchema, } from "./local-agent-config.js"; -const config = subagentsConfigSchema.parse({ +const config = parseSubagentsConfig({ enabled: true, providers: [ { @@ -27,13 +28,14 @@ assert.deepEqual(config, { providers: [ { id: "codex", + driver: "codex", enabled: true, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude", enabled: false, model: "sonnet" }, + { id: "claude", driver: "claude", enabled: false, model: "sonnet" }, ], }); assert.equal(isSubagentProviderEnabled(config, "codex"), true); @@ -63,7 +65,7 @@ assert.deepEqual(inherited, { }); assert.equal( localAgentProviderConfigRevision(config), - localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + localAgentProviderConfigRevision(parseSubagentsConfig({ enabled: true, providers: [ { id: "claude", enabled: false, model: "sonnet" }, @@ -81,7 +83,7 @@ assert.equal( ); assert.notEqual( localAgentProviderConfigRevision(config), - localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + localAgentProviderConfigRevision(parseSubagentsConfig({ ...config, providers: config.providers.map((provider) => provider.id === "codex" ? { ...provider, command: "/opt/bin/another-wrapper" } @@ -100,8 +102,59 @@ assert.throws( enabled: true, providers: [{ id: "unknown", enabled: true }], }), - /Invalid discriminator value/, + /must declare a driver/, ); +const namedInstance = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }], +}); +assert.deepEqual(namedInstance.providers[0], { + id: "codex-work", + driver: "codex", + enabled: true, + model: "gpt-work", +}); +assert.equal(subagentProviderConfig(namedInstance, "codex-work")?.driver, "codex"); +const genericAcp = parseSubagentsConfig({ + enabled: true, + providers: [{ + id: "kiro", + driver: "acp", + enabled: true, + command: "kiro", + config: { args: ["acp"] }, + }], +}); +assert.deepEqual(genericAcp.providers[0], { + id: "kiro", + driver: "acp", + enabled: true, + command: "kiro", + config: { args: ["acp"] }, +}); +// Built-in ACP flavors derive sandbox flags from the write mode; custom args would drop them. +for (const provider of [ + { id: "cursor", driver: "cursor", enabled: true, config: { args: ["acp"] } }, + { id: "copilot-work", driver: "acp", enabled: true, command: "copilot", config: { flavor: "copilot", args: ["--acp"] } }, +]) { + assert.throws(() => parseSubagentsConfig({ enabled: true, providers: [provider] }), /config\.args applies only to generic/); +} +const legacyAcp = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "cursor", driver: "cursor", enabled: true }], +}); +assert.equal(legacyAcp.providers[0]?.driver, "acp"); +assert.deepEqual(legacyAcp.providers[0]?.config, { flavor: "cursor" }); +const namedLegacyAcp = parseSubagentsConfig({ + enabled: true, + providers: [ + { id: "cursor-work", driver: "cursor", enabled: true, command: "cursor-agent" }, + { id: "grok", driver: "codex", enabled: true }, + ], +}); +assert.equal(namedLegacyAcp.providers[0]?.driver, "acp"); +assert.deepEqual(namedLegacyAcp.providers[0]?.config, { flavor: "cursor" }, "a named legacy-driver instance keeps its flavor"); +assert.equal(namedLegacyAcp.providers[1]?.config, undefined, "a built-in id with another driver is not an ACP provider"); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, @@ -124,15 +177,20 @@ assert.throws( /Invalid environment variable name/, ); for (const id of ["opencode", "pi"] as const) { - const embedded = subagentsConfigSchema.parse({ + const embedded = parseSubagentsConfig({ enabled: true, providers: [{ id, enabled: true, env: { HARNESS_ENV: id } }], }); assert.equal(localAgentProviderEnvironment(embedded, id, {}).HARNESS_ENV, id); - assert.throws( - () => subagentsConfigSchema.parse({ - enabled: true, - providers: [{ id, enabled: true, command: "/opt/bin/agent" }], - }), - ); } +assert.throws( + () => subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id: "opencode", enabled: true, command: "/opt/bin/agent" }], + }), +); +const piCommand = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "pi", enabled: true, command: "/opt/bin/pi" }], +}); +assert.equal(localAgentProviderEnvironment(piCommand, "pi", {}).PI_COMMAND, "/opt/bin/pi"); diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 39d42f09d..7720918c0 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,8 +1,14 @@ import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + defaultDriverForProviderId, + isLocalAgentDriverKind, + isLegacyAcpDriverKind, + LEGACY_ACP_DRIVER_KINDS, + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; const environmentSchema = z.record( z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Invalid environment variable name"), @@ -22,24 +28,70 @@ const commandSchema = z.string() .min(1) .optional(); -const providerSchema = z.discriminatedUnion("id", [ - z.object({ - id: z.enum(["codex", "claude", "cursor", "copilot", "grok"]), - ...providerShape, - command: commandSchema, - }).strict(), - z.object({ - id: z.enum(["opencode", "pi"]), - ...providerShape, - }).strict(), -]); +const acpConfigSchema = z.object({ + args: z.array(z.string()).optional(), + flavor: z.enum(LEGACY_ACP_DRIVER_KINDS).optional(), +}).strict(); + +const inputDriverKinds = [...LOCAL_AGENT_DRIVER_KINDS, ...LEGACY_ACP_DRIVER_KINDS] as const; + +const providerSchema = z.object({ + id: z.string().trim().min(1), + driver: z.enum(inputDriverKinds).optional(), + ...providerShape, + command: commandSchema, + config: acpConfigSchema.optional(), +}).strict().superRefine((provider, context) => { + const driver = provider.driver + ? (isLegacyAcpDriverKind(provider.driver) ? "acp" : provider.driver) + : defaultDriverForProviderId(provider.id); + if (!driver) { + context.addIssue({ + code: "custom", + path: ["driver"], + message: `Subagent provider instance ${provider.id} must declare a driver.`, + }); + return; + } + if (driver === "opencode" && provider.command !== undefined) { + context.addIssue({ + code: "custom", + path: ["command"], + message: `${driver} does not support a command override.`, + }); + } + if (driver !== "acp" && provider.config !== undefined) { + context.addIssue({ + code: "custom", + path: ["config"], + message: `${driver} does not support ACP config.`, + }); + } + const flavor = provider.config?.flavor + ?? (provider.driver && isLegacyAcpDriverKind(provider.driver) ? provider.driver : undefined) + ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); + if (driver === "acp" && flavor && provider.config?.args !== undefined) { + context.addIssue({ + code: "custom", + path: ["config", "args"], + message: `${flavor} sets its own ACP arguments for each write mode; config.args applies only to generic ACP providers.`, + }); + } + if (driver === "acp" && !provider.command && !isLegacyAcpDriverKind(provider.id)) { + context.addIssue({ + code: "custom", + path: ["command"], + message: `ACP provider instance ${provider.id} must declare a command.`, + }); + } +}); export const subagentsConfigSchema = z.object({ enabled: z.boolean(), instructions: z.enum(["on-demand", "preload"]).default("on-demand"), providers: z.array(providerSchema), }).strict().superRefine((value, context) => { - const seen = new Set(); + const seen = new Set(); for (const [index, provider] of value.providers.entries()) { if (seen.has(provider.id)) { context.addIssue({ @@ -57,53 +109,91 @@ export const storedSubagentsConfigSchema = z.union([ subagentsConfigSchema, ]); -export type SubagentProviderConfig = z.infer; -export type SubagentsConfig = z.infer; +type ParsedSubagentProviderConfig = z.infer; +type ParsedSubagentsConfig = z.infer; + +export interface SubagentProviderConfig extends Omit { + driver: LocalAgentDriverKind; +} + +export interface SubagentsConfig extends Omit { + providers: SubagentProviderConfig[]; +} + export type StoredSubagentsConfig = z.infer; +export function resolveSubagentsConfig(config: ParsedSubagentsConfig): SubagentsConfig { + return { + ...config, + providers: config.providers.map((provider) => { + const driver = provider.driver + ? (isLegacyAcpDriverKind(provider.driver) ? "acp" : provider.driver) + : defaultDriverForProviderId(provider.id)!; + // `driver: "cursor"` (or a built-in id) predates generic ACP; keep it as the ACP flavor. + const flavor = provider.config?.flavor + ?? (provider.driver && isLegacyAcpDriverKind(provider.driver) ? provider.driver : undefined) + ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); + return { + ...provider, + driver, + ...(driver === "acp" && flavor ? { config: { ...provider.config, flavor } } : {}), + }; + }), + }; +} + +/** Built-in ids (`cursor`, `copilot`, `grok`) imply their flavor even when a config object omits it. */ +export function subagentAcpFlavor( + provider: Pick, +): typeof LEGACY_ACP_DRIVER_KINDS[number] | undefined { + return provider.config?.flavor ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); +} + +export function parseSubagentsConfig(value: unknown): SubagentsConfig { + return resolveSubagentsConfig(subagentsConfigSchema.parse(value)); +} + export function subagentProviderConfig( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): SubagentProviderConfig | undefined { - return config.providers.find((entry) => entry.id === provider); + return config.providers.find((entry) => entry.id === providerInstanceId); } export function isSubagentProviderEnabled( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): boolean { - return config.enabled && subagentProviderConfig(config, provider)?.enabled === true; + return config.enabled && subagentProviderConfig(config, providerInstanceId)?.enabled === true; } export function localAgentProviderEnvironment( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, inherited: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - const providerConfig = subagentProviderConfig(config, provider); + const providerConfig = subagentProviderConfig(config, providerInstanceId); const env = { ...inherited, ...providerConfig?.env }; - const commandVariable = providerCommandVariable(provider); - const command = providerConfig && "command" in providerConfig ? providerConfig.command : undefined; + const commandVariable = providerConfig ? providerCommandVariable(providerConfig.driver) : undefined; + const command = providerConfig?.command; if (commandVariable && command) env[commandVariable] = command; return env; } export function localAgentProviderEnvironmentOverrides( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): Record { - return { ...subagentProviderConfig(config, provider)?.env }; + return { ...subagentProviderConfig(config, providerInstanceId)?.env }; } -export function providerCommandVariable(provider: LocalAgentProvider): string | undefined { - switch (provider) { +export function providerCommandVariable(driver: LocalAgentDriverKind): string | undefined { + switch (driver) { case "codex": return "CODEX_COMMAND"; case "claude": return "CLAUDE_COMMAND"; - case "cursor": return "CURSOR_COMMAND"; - case "copilot": return "COPILOT_COMMAND"; - case "grok": return "GROK_COMMAND"; + case "pi": return "PI_COMMAND"; case "opencode": - case "pi": + case "acp": return undefined; } } @@ -113,6 +203,7 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin .sort((left, right) => left.id.localeCompare(right.id)) .map((provider) => ({ id: provider.id, + driver: provider.driver, enabled: provider.enabled, ...(provider.model ? { model: provider.model } : {}), ...(provider.effort ? { effort: provider.effort } : {}), @@ -124,6 +215,7 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin ), } : {}), + ...(provider.config ? { config: provider.config } : {}), })); return createHash("sha256") .update(JSON.stringify({ enabled: config.enabled, providers })) diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 5ac32b93b..fd0405e50 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 5; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 7; export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock"; export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid"; export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock"; diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index bd340d5b5..50641ed9b 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -119,7 +119,8 @@ const record = decodeAgentRecord({ workspaceId: "ws_test", workspaceRoot: "/tmp/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "idle", latestResponse: " response whitespace \n", createdAt: "now", @@ -168,7 +169,8 @@ const failedRecord = decodeAgentRecord({ workspaceId: "ws_error", workspaceRoot: "/tmp/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "error", error: "Timed out waiting for the local agent daemon.", errorCode: "DAEMON_TIMEOUT", @@ -192,6 +194,20 @@ const waitRequest = decodeLocalAgentDaemonRequest({ }); assert.equal(waitRequest.method, "agent.wait"); if (waitRequest.method !== "agent.wait") throw new Error("expected agent.wait request"); + +const stopRequest = decodeLocalAgentDaemonRequest({ + requestId: "stop", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "secret", + method: "agent.stop", + params: { + id: "agt_stop", + scope: { workspaceId: "ws_test", workspaceRoot: "/tmp/project" }, + }, +}); +assert.equal(stopRequest.method, "agent.stop"); +if (stopRequest.method !== "agent.stop") throw new Error("expected agent.stop request"); +assert.equal(stopRequest.params.id, "agt_stop"); assert.deepEqual(waitRequest.params.ids, ["agt_one", "agt_two"]); assert.equal(waitRequest.params.timeoutMs, 5_000); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 21aeb6806..37f72b613 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -10,6 +10,7 @@ import type { } from "./local-agent-manager.js"; import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; +import { isLocalAgentDriverKind } from "./local-agent-provider.js"; export type LocalAgentDaemonMethod = | "hello" @@ -18,6 +19,7 @@ export type LocalAgentDaemonMethod = | "agent.get" | "agent.list" | "agent.wait" + | "agent.stop" | "daemon.status" | "daemon.stop" | "daemon.logs"; @@ -33,6 +35,7 @@ export type LocalAgentDaemonRequest = scope: LocalAgentWorkspaceScope; timeoutMs?: number; }> + | AgentDaemonRequestBase<"agent.stop", { id: string; scope: LocalAgentWorkspaceScope }> | AgentDaemonRequestBase<"daemon.status", Record> | AgentDaemonRequestBase<"daemon.stop", { ifIdle?: boolean }> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; @@ -168,6 +171,17 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR method, params: decodeWaitParams(params), } as LocalAgentDaemonRequest; + case "agent.stop": + return { + requestId, + protocolVersion, + authToken, + method, + params: { + id: requiredString(asRecord(params)?.id, "id"), + scope: decodeWorkspaceScope(asRecord(params)?.scope), + }, + } as LocalAgentDaemonRequest; case "daemon.logs": return { requestId, @@ -213,12 +227,17 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { const record = asRecord(value); const status = requiredString(record?.status, "status"); if (!isLocalAgentStatus(status)) throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent status."); + const driver = requiredString(record?.driver, "driver"); + if (!isLocalAgentDriverKind(driver)) { + throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent driver."); + } return { id: requiredString(record?.id, "id"), workspaceId: optionalString(record?.workspaceId), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), profileName: requiredString(record?.profileName, "profileName"), - provider: requiredString(record?.provider, "provider"), + providerInstanceId: requiredString(record?.providerInstanceId, "providerInstanceId"), + driver, model: optionalString(record?.model), effort: optionalString(record?.effort), providerSessionId: optionalString(record?.providerSessionId), diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 88722dafa..5cf7d4dcd 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -30,7 +30,8 @@ const record: LocalAgentRecord = { workspaceId: "ws_test", workspaceRoot: join(root, "project"), profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "running", createdAt: "now", updatedAt: "now", @@ -95,6 +96,11 @@ class FakeManager implements LocalAgentDaemonManager { return Result.ok(agentIds.map((id) => ({ id, status: "running" as const }))); } + async stop(_agentId: string, _scope: unknown) { + this.activeTurnCount = 0; + return Result.ok({ ...record, status: "stopped" } as LocalAgentRecord); + } + async evictIdle(): Promise {} async close(): Promise { @@ -173,6 +179,7 @@ try { assert.deepEqual(unwrap(await client.wait([record.id], recordScope, 0)), [ { id: record.id, status: "running" }, ]); + assert.equal(unwrap(await client.stopAgent(record.id, recordScope)).status, "stopped"); assert.equal(unwrap(await client.status()).state, "ready"); unwrap(await client.stop()); diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index aac96e9d9..3f929e25a 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -37,6 +37,7 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentStopError, AgentWaitError, LocalAgentWaitResult, RunOverrides, @@ -61,6 +62,7 @@ export interface LocalAgentDaemonManager { timeoutMs?: number, signal?: AbortSignal, ): Promise>; + stop(agentId: string, scope: LocalAgentWorkspaceScope): Promise>; evictIdle(now?: number): Promise; close(): Promise; readonly activeTurnCount: number; @@ -339,6 +341,11 @@ export class LocalAgentDaemon { request.params.timeoutMs, signal, )); + case "agent.stop": + return unwrapManagerResult(await this.manager.stop( + request.params.id, + request.params.scope, + )); case "daemon.status": return this.status(); case "daemon.stop": diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index 32c33cd77..424aa2cb4 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -5,9 +5,9 @@ import { type Result as BetterResult, } from "better-result"; import { - isLocalAgentProvider, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + isLocalAgentDriverKind, + type LocalAgentDriverKind, +} from "./local-agent-provider.js"; export type AgentTargetErrorCode = | "UNKNOWN_TARGET" @@ -19,7 +19,7 @@ export type AgentTargetErrorCode = export class AgentTargetError extends TaggedError("AgentTargetError")<{ code: AgentTargetErrorCode; target: string; - provider?: LocalAgentProvider; + provider?: string; operation?: string; retryable: boolean; cause?: unknown; @@ -50,7 +50,7 @@ export class AgentScopeError extends TaggedError("AgentScopeError")<{ }>() {} interface AgentProviderErrorFields extends Record { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; retryable: boolean; @@ -165,7 +165,7 @@ export interface AgentErrorPayload { code: LocalAgentError["code"]; message: string; retryable?: boolean; - provider?: LocalAgentProvider; + provider?: string; agentId?: string; workspaceId?: string; operation?: string; @@ -233,7 +233,7 @@ export function agentErrorFromPayload(payload: { target?: string; }): LocalAgentError | undefined { const retryable = payload.retryable ?? false; - const provider = payload.provider && isLocalAgentProvider(payload.provider) + const provider = payload.provider && isLocalAgentDriverKind(payload.provider) ? payload.provider : undefined; switch (payload.code) { @@ -245,7 +245,7 @@ export function agentErrorFromPayload(payload: { return new AgentTargetError({ code: payload.code, target: payload.target ?? payload.agentId ?? payload.provider ?? "unknown", - provider, + provider: payload.provider, operation: payload.operation, retryable, message: payload.message, @@ -363,7 +363,7 @@ export function agentErrorFromPayload(payload: { } export function providerErrorFromCause(input: { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; cause: unknown; @@ -406,7 +406,7 @@ export function providerErrorFromCause(input: { } export async function captureAgentProviderResult(input: { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; run: () => T | Promise; @@ -456,15 +456,13 @@ function unavailableCauseKind(error: unknown): "permanent" | "transient" | undef return undefined; } -function displayProvider(provider: LocalAgentProvider): string { +function displayProvider(provider: LocalAgentDriverKind): string { switch (provider) { case "codex": return "Codex"; case "claude": return "Claude"; case "opencode": return "OpenCode"; case "pi": return "Pi"; - case "cursor": return "Cursor"; - case "copilot": return "Copilot"; - case "grok": return "Grok"; + case "acp": return "ACP"; } } diff --git a/src/local-agent-grok.ts b/src/local-agent-grok.ts index d4df607d9..09c851bf1 100644 --- a/src/local-agent-grok.ts +++ b/src/local-agent-grok.ts @@ -208,7 +208,7 @@ export function resolveGrokEffort( function grokConfigurationError(message: string): AgentProviderProtocolError { return new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", - provider: "grok", + provider: "acp", operation: "configure_session", retryable: false, message, diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 0df247006..456a93cb7 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { LocalAgentManager } from "./local-agent-manager.js"; import { + AgentProviderCancelledError, AgentProviderExecutionError, type AgentProviderError, } from "./local-agent-errors.js"; @@ -16,6 +17,14 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; + +const TEST_CAPABILITIES = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: false }, +} as const; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; import type { SubagentsConfig } from "./local-agent-config.js"; @@ -38,12 +47,21 @@ const disabledProfile: LocalAgentProfile = { filePath: join(root, "disabled-reviewer.md"), disabled: true, }; +const shadowProfile: LocalAgentProfile = { + ...profile, + name: "codex-shadow", + provider: "codex-shadow", + filePath: join(root, "codex-shadow.md"), + body: "Shadow profile body.", +}; const subagents: SubagentsConfig = { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true, model: "gpt-default", effort: "medium" }, - { id: "claude", enabled: true }, + { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, + { id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }, + { id: "codex-shadow", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: true }, ], }; @@ -67,6 +85,23 @@ class FakeRuntime implements LocalAgentRuntime { if (input.prompt.includes("hold")) { await new Promise((resolve) => { this.releaseHold = resolve; }); } + if (input.prompt.includes("finish-despite-stop")) { + await new Promise((resolve) => input.signal?.addEventListener("abort", () => resolve(), { once: true })); + } + if (input.prompt.includes("cancel-me")) { + await new Promise((resolve) => { + const done = () => resolve(); + if (input.signal?.aborted) done(); + else input.signal?.addEventListener("abort", done, { once: true }); + }); + return Result.err(new AgentProviderCancelledError({ + code: "PROVIDER_CANCELLED", + provider: "codex", + operation: "run", + retryable: false, + message: "codex agent turn was stopped.", + })); + } return Result.ok({ provider: this.provider, providerSessionId: "thread_test", @@ -96,14 +131,18 @@ class FakeRuntime implements LocalAgentRuntime { const runtimes = new Map(); const driver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: (context: LocalAgentRuntimeContext) => context.agentId, + runtimePolicy: { scope: "agent" }, + capabilities: TEST_CAPABILITIES, createRuntime: async (context) => { const runtime = new FakeRuntime(); runtimes.set(context.agentId, runtime); return Result.ok(runtime); }, }; +const workDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-work" }; +const shadowDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-shadow" }; function providerFailure(message: string): AgentProviderExecutionError { return new AgentProviderExecutionError({ @@ -115,23 +154,27 @@ function providerFailure(message: string): AgentProviderExecutionError { }); } +// Profiles added after an agent started, to exercise continuation lookups. +const lateProfiles: LocalAgentProfile[] = []; const store = new LocalAgentStore(stateDir); const stale = store.create({ workspaceId: scope.workspaceId, workspaceRoot: root, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }); const staleTurn = store.beginTurn(stale.id, { prompt: "interrupted turn" }); store.update(stale.id, { latestResponse: "previous response" }); const manager = new LocalAgentManager({ store, - drivers: [driver], + drivers: [driver, workDriver, shadowDriver], pool: new LocalAgentRuntimePool(), - loadProfiles: async () => [profile, disabledProfile], + loadProfiles: async () => [profile, disabledProfile, shadowProfile, ...lateProfiles], allowedRoots: [root], subagents, + stopTimeoutMs: 200, }); const defectStore = new LocalAgentStore(join(root, "defect-state")); @@ -174,6 +217,86 @@ const unknown = await manager.start({ assert.equal(unknown.isErr(), true); if (unknown.isErr()) assert.equal(unknown.error.code, "UNKNOWN_TARGET"); +const cancellable = unwrap(await manager.start({ + target: "codex", + prompt: "cancel-me", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => getRecord(cancellable.id).status === "running"); +const stopped = unwrap(await manager.stop(cancellable.id, scope)); +assert.equal(stopped.status, "stopped"); +assert.equal(store.getLatestTurn(cancellable.id)?.status, "stopped"); +assert.equal(store.getLatestTurn(cancellable.id)?.errorCode, "PROVIDER_CANCELLED"); + +// A provider that ignores stop leaves the turn running; stop reports that instead of hanging. +const stubborn = unwrap(await manager.start({ + target: "codex", + prompt: "hold", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(stubborn.id)?.inputs.length === 1); +const stubbornStop = await manager.stop(stubborn.id, scope); +assert.equal(stubbornStop.isErr(), true); +if (stubbornStop.isErr()) { + assert.equal(stubbornStop.error.code, "AGENT_CONFLICT"); + assert.equal(stubbornStop.error.retryable, true); +} +assert.equal(getRecord(stubborn.id).status, "running"); +runtimes.get(stubborn.id)?.release(); +await waitFor(() => getRecord(stubborn.id).status === "idle"); + +// A turn that completes normally despite the stop request is not reported as stopped. +const finishing = unwrap(await manager.start({ + target: "codex", + prompt: "finish-despite-stop", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(finishing.id)?.inputs.length === 1); +const finishingStop = await manager.stop(finishing.id, scope); +assert.equal(finishingStop.isErr(), true); +if (finishingStop.isErr()) { + assert.equal(finishingStop.error.code, "AGENT_CONFLICT"); + assert.equal(finishingStop.error.retryable, false); +} + +const namedInstance = unwrap(await manager.start({ + target: "codex-work", + prompt: "inspect named instance", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +assert.equal(namedInstance.providerInstanceId, "codex-work"); +assert.equal(namedInstance.driver, "codex"); +assert.equal(namedInstance.model, "gpt-work"); +await waitFor(() => getRecord(namedInstance.id).status === "idle"); + +// A later same-name profile for another provider instance does not apply to the stored instance. +lateProfiles.push({ ...profile, name: "codex-work", provider: "claude", body: "Claude profile body." }); +unwrap(await manager.continue(namedInstance.id, "again", {}, scope)); +await waitFor(() => runtimes.get(namedInstance.id)?.inputs.length === 2); +assert.equal(runtimes.get(namedInstance.id)?.inputs[1]?.prompt, "again"); +await waitFor(() => getRecord(namedInstance.id).status === "idle"); +lateProfiles.length = 0; + +// A profile shadows a provider instance with the same name, on start and on every continuation. +const shadowed = unwrap(await manager.start({ + target: "codex-shadow", + prompt: "first", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => getRecord(shadowed.id).status === "idle"); +unwrap(await manager.continue(shadowed.id, "second", {}, scope)); +await waitFor(() => runtimes.get(shadowed.id)?.inputs.length === 2); +assert.deepEqual( + runtimes.get(shadowed.id)?.inputs.map((input) => input.prompt), + ["Shadow profile body.\n\nTask:\nfirst", "Shadow profile body.\n\nTask:\nsecond"], +); +await waitFor(() => getRecord(shadowed.id).status === "idle"); + const disabled = await manager.start({ target: "disabled-reviewer", prompt: "inspect", @@ -199,13 +322,14 @@ const disabledProvider = await manager.start({ workspaceRoot: root, }); assert.equal(disabledProvider.isErr(), true); -if (disabledProvider.isErr()) assert.equal(disabledProvider.error.code, "PROVIDER_DISABLED"); +if (disabledProvider.isErr()) assert.equal(disabledProvider.error.code, "UNKNOWN_TARGET"); const previouslyCreatedDisabled = store.create({ workspaceId: scope.workspaceId, workspaceRoot: root, profileName: disabledProfile.name, - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }); store.update(previouslyCreatedDisabled.id, { status: "idle" }); const disabledContinuation = await manager.continue(previouslyCreatedDisabled.id, "inspect", {}, scope); @@ -277,7 +401,7 @@ const second = unwrap(await manager.start({ })); await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); -assert.equal(runtimes.size, 2, "different agents receive independent logical runtimes"); +assert.notEqual(runtimes.get(first.id), runtimes.get(second.id), "different agents receive independent logical runtimes"); const failed = unwrap(await manager.start({ target: "reviewer", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 30fe1e1f4..29efedeec 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -2,6 +2,7 @@ import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { AgentConflictError, + AgentProviderCancelledError, AgentScopeError, AgentStoreError, AgentTargetError, @@ -11,9 +12,8 @@ import { } from "./local-agent-errors.js"; import { type LocalAgentProfile, - type LocalAgentProvider, - isLocalAgentProvider, } from "./local-agent-profiles.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import { resolveLocalAgentTarget, } from "./local-agent-targets.js"; @@ -34,6 +34,7 @@ import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { assertAllowedPath } from "./roots.js"; import { isSubagentProviderEnabled, + subagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; @@ -57,6 +58,9 @@ export interface LocalAgentManagerLogger { (level: "info" | "warn" | "error", event: string, fields: Record): void; } +// Kept below the client's default request deadline so a slow stop is reported as a conflict, not a daemon timeout. +const DEFAULT_STOP_TIMEOUT_MS = 20_000; + export interface LocalAgentManagerOptions { store: LocalAgentStore; drivers: readonly LocalAgentDriver[]; @@ -66,6 +70,7 @@ export interface LocalAgentManagerOptions { allowedRoots?: readonly string[]; logger?: LocalAgentManagerLogger; subagents: SubagentsConfig; + stopTimeoutMs?: number; } export type AgentStartError = AgentTargetError | AgentScopeError | AgentConflictError | AgentStoreError; @@ -73,6 +78,7 @@ export type AgentContinueError = AgentStartError; export type AgentLookupError = AgentTargetError | AgentScopeError | AgentStoreError; export type AgentListError = AgentScopeError | AgentStoreError; export type AgentWaitError = AgentLookupError; +export type AgentStopError = AgentLookupError | AgentConflictError; export type LocalAgentWaitResult = | { id: string; status: "running"; wait?: "timeout" } @@ -83,6 +89,7 @@ export type LocalAgentWaitResult = interface ActiveLocalAgentTurn { turnId: number; completion: Promise; + abortController: AbortController; } /** @@ -92,26 +99,28 @@ interface ActiveLocalAgentTurn { */ export class LocalAgentManager { private readonly store: LocalAgentStore; - private readonly drivers = new Map(); + private readonly drivers = new Map(); private readonly pool: LocalAgentRuntimePool; private readonly loadProfiles: (workspaceRoot: string) => Promise; private readonly agentDir?: string; private readonly allowedRoots?: readonly string[]; private readonly logger?: LocalAgentManagerLogger; private readonly subagents: SubagentsConfig; + private readonly stopTimeoutMs: number; private readonly activeTurns = new Map(); private accepting = true; private closePromise?: Promise; constructor(options: LocalAgentManagerOptions) { this.store = options.store; - for (const driver of options.drivers) this.drivers.set(driver.provider, driver); + for (const driver of options.drivers) this.drivers.set(driver.providerInstanceId, driver); this.pool = options.pool; this.loadProfiles = options.loadProfiles; this.agentDir = options.agentDir; this.allowedRoots = options.allowedRoots; this.logger = options.logger; this.subagents = options.subagents; + this.stopTimeoutMs = options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; } reconcileActiveRuns(message?: string): BetterResult { @@ -153,12 +162,13 @@ export class LocalAgentManager { })); } yield* manager.providerEnabledResult(target.provider, target.name, "start"); - yield* manager.driverResult(target.provider, "start"); + const driver = yield* manager.driverResult(target.provider, "start"); const record = yield* manager.store.createResult({ workspaceId: input.workspaceId, workspaceRoot, profileName: target.name, - provider: target.provider, + providerInstanceId: target.provider, + driver: driver.provider, model: target.model, effort: target.effort, }); @@ -184,8 +194,8 @@ export class LocalAgentManager { yield* manager.agentWorkspaceResult(record, scope, "continue"); const profiles = yield* Result.await(manager.loadProfilesResult(record.workspaceRoot, record.profileName)); yield* manager.profileForRecordResult(record, profiles); - yield* manager.providerEnabledResult(record.provider, record.profileName, "continue"); - yield* manager.driverResult(record.provider, "continue", agentId); + yield* manager.providerEnabledResult(record.providerInstanceId, record.profileName, "continue"); + yield* manager.driverResult(record.providerInstanceId, "continue", agentId, record.driver); return manager.begin(record, prompt, overrides, scope.workspaceId); }); } @@ -259,6 +269,58 @@ export class LocalAgentManager { return Result.ok(results); } + async stop( + agentId: string, + scope: LocalAgentWorkspaceScope, + ): Promise> { + const record = this.get(agentId, scope); + if (record.isErr()) return record; + if (record.value.status === "stopped") return Result.ok(record.value); + const active = this.activeTurns.get(agentId); + if (!active) { + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "stop", + retryable: false, + message: `Agent ${agentId} does not have a running turn.`, + })); + } + const driver = this.drivers.get(record.value.providerInstanceId); + if (!driver?.capabilities.turns.interrupt) { + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "stop", + retryable: false, + message: `Agent ${agentId} provider does not support stopping an active turn.`, + })); + } + active.abortController.abort(); + const settled = await this.settledWithin(active.completion, this.stopTimeoutMs); + const refreshed = this.get(agentId, scope); + if (refreshed.isErr() || refreshed.value.status === "stopped") return refreshed; + return Result.err(new AgentConflictError({ + code: "AGENT_CONFLICT", + agentId, + operation: "stop", + retryable: !settled, + message: settled + ? `Agent ${agentId} finished before it could be stopped.` + : `Agent ${agentId} did not stop within ${this.stopTimeoutMs}ms; its turn is still running.`, + })); + } + + private async settledWithin(completion: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }); + try { + return await Promise.race([completion.then(() => true as const, () => true as const), timeout]); + } finally { + clearTimeout(timer); + } + } + async close(): Promise { if (this.closePromise) return this.closePromise; this.accepting = false; @@ -314,10 +376,22 @@ export class LocalAgentManager { if (begun.isErr()) return begun; // Defer invocation until after the tracking entry is visible. This keeps // cleanup correct even if runTurn later gains a synchronous completion path. + const abortController = new AbortController(); const turn = Promise.resolve().then(() => ( - this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId) + this.runTurn( + begun.value.agent, + begun.value.turn.id, + prompt, + overrides, + workspaceId, + abortController.signal, + ) )); - this.activeTurns.set(record.id, { turnId: begun.value.turn.id, completion: turn }); + this.activeTurns.set(record.id, { + turnId: begun.value.turn.id, + completion: turn, + abortController, + }); void turn.catch(() => undefined); return Result.ok(begun.value.agent); } @@ -328,10 +402,12 @@ export class LocalAgentManager { prompt: string, overrides: RunOverrides, workspaceId?: string, + signal?: AbortSignal, ): Promise { const startedAt = Date.now(); this.log("info", "agent_run_started", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), }); @@ -360,13 +436,14 @@ export class LocalAgentManager { this.persistRunError(record, turnId, input.error, startedAt); return; } - const driver = this.driverResult(record.provider, "run", record.id); + const driver = this.driverResult(record.providerInstanceId, "run", record.id, record.driver); if (driver.isErr()) { this.persistRunError(record, turnId, driver.error, startedAt); return; } const context: LocalAgentRuntimeContext = { agentId: record.id, + providerInstanceId: record.providerInstanceId, provider: driver.value.provider, workspaceRoot, providerSessionId: record.providerSessionId, @@ -384,7 +461,7 @@ export class LocalAgentManager { if (updated.isErr()) throw updated.error; }, }; - const result = await this.pool.run(driver.value, context, input.value, callbacks); + const result = await this.pool.run(driver.value, context, { ...input.value, signal }, callbacks); if (result.isErr()) { this.persistRunError(record, turnId, result.error, startedAt); return; @@ -400,7 +477,8 @@ export class LocalAgentManager { }); if (updated.isErr()) throw updated.error; this.log("info", "agent_run_completed", { - provider: updated.value.provider, + providerInstanceId: updated.value.providerInstanceId, + driver: updated.value.driver, agentId: updated.value.id, providerSessionIdPrefix: updated.value.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -417,7 +495,8 @@ export class LocalAgentManager { errorRetryable: false, }); this.log("error", "agent_run_failed", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -437,14 +516,16 @@ export class LocalAgentManager { error: LocalAgentError, startedAt: number, ): void { + const stopped = AgentProviderCancelledError.is(error); const persisted = this.store.finishTurnResult(record.id, turnId, { - status: "failed", + status: stopped ? "stopped" : "failed", error: error.message, errorCode: error.code, errorRetryable: error.retryable, }); - this.log("error", "agent_run_failed", { - provider: record.provider, + this.log(stopped ? "info" : "error", stopped ? "agent_run_stopped" : "agent_run_failed", { + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -461,12 +542,12 @@ export class LocalAgentManager { prompt: string, overrides: RunOverrides, ): BetterResult { - const isRawProvider = record.profileName === record.provider; + const isRawProvider = record.profileName === record.providerInstanceId; if (!profile && !isRawProvider) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", target: record.profileName, - provider: isLocalAgentProvider(record.provider) ? record.provider : undefined, + provider: record.providerInstanceId, retryable: false, message: `Subagent profile not found: ${record.profileName}.`, })); @@ -489,13 +570,17 @@ export class LocalAgentManager { record: LocalAgentRecord, profiles: readonly LocalAgentProfile[], ): BetterResult { - if (record.profileName === record.provider) return Result.ok(undefined); - const profile = profiles.find((candidate) => candidate.name === record.profileName); + // Profiles win over provider instances of the same name, matching start-time target resolution. + // A same-name profile for another provider instance would not match the runtime that executes the turn. + const profile = profiles.find( + (candidate) => candidate.name === record.profileName && candidate.provider === record.providerInstanceId, + ); + if (!profile && record.profileName === record.providerInstanceId) return Result.ok(undefined); if (!profile) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", target: record.profileName, - provider: isLocalAgentProvider(record.provider) ? record.provider : undefined, + provider: record.providerInstanceId, retryable: false, message: `Subagent profile not found: ${record.profileName}.`, })); @@ -513,47 +598,59 @@ export class LocalAgentManager { } private driverResult( - provider: string, + providerInstanceId: string, operation: string, agentId?: string, + expectedDriver?: LocalAgentDriverKind, ): BetterResult { - if (!isLocalAgentProvider(provider)) { + const driver = this.drivers.get(providerInstanceId); + if (!driver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", - target: provider, + target: agentId ?? providerInstanceId, + provider: providerInstanceId, operation, retryable: false, - message: `No local agent driver is configured for provider: ${provider}.`, + message: `No local agent driver is configured for provider instance: ${providerInstanceId}.`, })); } - const driver = this.drivers.get(provider); - if (!driver) { + if (expectedDriver && driver.provider !== expectedDriver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", - target: agentId ?? provider, - provider, + target: agentId ?? providerInstanceId, + provider: providerInstanceId, operation, retryable: false, - message: `No local agent driver is configured for provider: ${provider}.`, + message: `Provider instance ${providerInstanceId} now uses ${driver.provider}; this agent was created with ${expectedDriver}.`, })); } return Result.ok(driver); } private providerEnabledResult( - provider: string, + providerInstanceId: string, target: string, operation: string, ): BetterResult { - if (!isLocalAgentProvider(provider)) return Result.ok(undefined); - if (isSubagentProviderEnabled(this.subagents, provider)) return Result.ok(undefined); + const configured = subagentProviderConfig(this.subagents, providerInstanceId); + if (!configured) { + return Result.err(new AgentTargetError({ + code: "PROVIDER_NOT_CONFIGURED", + target, + provider: providerInstanceId, + operation, + retryable: false, + message: `Subagent provider instance is not configured: ${providerInstanceId}.`, + })); + } + if (isSubagentProviderEnabled(this.subagents, providerInstanceId)) return Result.ok(undefined); return Result.err(new AgentTargetError({ code: "PROVIDER_DISABLED", target, - provider, + provider: providerInstanceId, operation, retryable: false, - message: `Subagent provider is disabled: ${provider}.`, + message: `Subagent provider is disabled: ${providerInstanceId}.`, })); } diff --git a/src/local-agent-opencode-server.ts b/src/local-agent-opencode-server.ts new file mode 100644 index 000000000..d3de5c440 --- /dev/null +++ b/src/local-agent-opencode-server.ts @@ -0,0 +1,116 @@ +import { createRequire } from "node:module"; +import { createServer as createNetServer } from "node:net"; +import { terminateProcessTree } from "./process-platform.js"; + +const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; +const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; +const OPENCODE_SERVER_START_ATTEMPTS = 3; +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; + +export interface OpencodeServerLike { + close(): void; +} + +export interface StartedOpencodeServer extends OpencodeServerLike { + url: string; +} + +export async function startOpencodeServer(env: NodeJS.ProcessEnv): Promise { + for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { + const port = await allocateOpencodePort(); + try { + return await launchOpencodeServer(env, port); + } catch (error) { + if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; + } + } + throw new Error("OpenCode server failed to start."); +} + +async function launchOpencodeServer(env: NodeJS.ProcessEnv, port: number): Promise { + const detached = process.platform !== "win32"; + const child = spawn("opencode", [ + "serve", + `--hostname=${OPENCODE_SERVER_HOSTNAME}`, + `--port=${port}`, + ], { + detached, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + terminateProcessTree(child, "SIGTERM", detached); + }; + const url = await new Promise((resolve, reject) => { + let output = ""; + let ready = false; + const timer = setTimeout(() => { + if (ready) return; + close(); + reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); + }, OPENCODE_SERVER_START_TIMEOUT_MS); + timer.unref(); + const inspect = (chunk: Buffer | string) => { + if (ready) return; + output += chunk.toString(); + for (const line of output.split("\n")) { + const match = line.match(/(?:opencode )?server listening on\s+(https?:\/\/[^\s]+)/); + if (!match?.[1]) continue; + ready = true; + clearTimeout(timer); + resolve(match[1]); + return; + } + }; + child.stdout?.on("data", inspect); + child.stderr?.on("data", inspect); + child.once("error", (error) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(error); + }); + child.once("exit", (code) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); + }); + }); + return { url, close }; +} + +async function allocateOpencodePort(): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to allocate an OpenCode server port.")); + return; + } + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); +} + +async function isOpencodePortInUse(port: number): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") resolve(true); + else reject(error); + }); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port, exclusive: true }, () => { + server.close((error) => error ? reject(error) : resolve(false)); + }); + }); +} diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts new file mode 100644 index 000000000..5485462aa --- /dev/null +++ b/src/local-agent-opencode-v2.test.ts @@ -0,0 +1,317 @@ +import assert from "node:assert/strict"; +import { + extractOpenCodeV2FinalResponse, + OpencodeV2Runtime, + opencodeV2Permissions, + opencodeV2ServerEnvironment, + parseOpenCodeV2Model, + type OpencodeV2ClientLike, +} from "./local-agent-opencode-v2.js"; +import type { SessionMessageAssistant } from "@opencode/client"; +import { OpencodeLocalAgentDriver } from "./local-agent-opencode.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; + +const createInputs: unknown[] = []; +const updateInputs: unknown[] = []; +const switchModelInputs: unknown[] = []; +const promptInputs: unknown[] = []; +const waitInputs: unknown[] = []; +const messageInputs: unknown[] = []; +let closeCalls = 0; + +const assistant: SessionMessageAssistant = { + id: "msg_assistant", + time: { created: 1, completed: 2 }, + type: "assistant", + agent: "build", + model: { id: "sonnet", providerID: "anthropic", variant: "high" }, + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "hello" }, + { type: "text", text: " world" }, + ], + finish: "stop", +}; + +const client = { + server: { + async info() { return { version: "2.0.20", pid: 1, urls: [], paths: { tmp: "/tmp" } }; }, + }, + session: { + async create(input: unknown) { + createInputs.push(input); + return { + id: "ses_v2", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp/project" }, + }; + }, + async update(input: unknown) { updateInputs.push(input); }, + async get() { + return { + id: "ses_v2", + projectID: "project", + model: { id: "sonnet", providerID: "anthropic" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp/project" }, + }; + }, + async switchModel(input: unknown) { switchModelInputs.push(input); }, + async prompt(input: unknown) { + promptInputs.push(input); + return { id: "inbox", sessionID: "ses_v2", time: { created: 1 }, type: "user", payload: { text: "x" }, delivery: "queue" }; + }, + async wait(input: unknown) { waitInputs.push(input); }, + }, + message: { + async list(input: unknown) { + messageInputs.push(input); + return { data: [assistant], cursor: { previous: null, next: null } }; + }, + }, + model: { + async default() { + return { + location: { directory: "/tmp/project" }, + data: { + id: "default-model", + modelID: "default-model", + providerID: "openrouter", + name: "Default", + capabilities: {}, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 1, output: 1 }, + }, + }; + }, + }, +} as unknown as OpencodeV2ClientLike; + +const runtime = new OpencodeV2Runtime(client, { + close: () => { closeCalls += 1; }, +}); +let sessionId: string | undefined; +const first = await runtime.run({ + prompt: "first", + workspaceRoot: "/tmp/project", + writeMode: "read_only", + model: "anthropic/sonnet", + effort: "high", +}, { + onSessionId: (id) => { sessionId = id; }, +}); +assert.equal(first.isOk(), true); +if (first.isErr()) throw first.error; +assert.equal(sessionId, "opencode:v2:ses_v2"); +assert.equal(first.value.providerSessionId, "opencode:v2:ses_v2"); +assert.equal(first.value.finalResponse, "hello world"); +assert.deepEqual(createInputs[0], { + location: { directory: "/tmp/project" }, + permissions: opencodeV2Permissions("read_only"), + model: { providerID: "anthropic", id: "sonnet", variant: "high" }, +}); +assert.equal(updateInputs.length, 0, "new sessions are configured at creation"); +assert.equal(switchModelInputs.length, 0, "new sessions select their model at creation"); +assert.deepEqual(promptInputs[0], { sessionID: "ses_v2", text: "first" }); +assert.deepEqual(waitInputs[0], { sessionID: "ses_v2" }); +assert.deepEqual(messageInputs[0], { sessionID: "ses_v2", limit: 1, order: "desc", type: "assistant" }); + +const continued = await runtime.run({ + prompt: "continued", + workspaceRoot: "/tmp/project", + providerSessionId: "opencode:v2:ses_v2", + writeMode: "allowed", + effort: "low", +}); +assert.equal(continued.isOk(), true); +if (continued.isErr()) throw continued.error; +assert.deepEqual(updateInputs[0], { + sessionID: "ses_v2", + permissions: opencodeV2Permissions("allowed"), +}); +assert.deepEqual(switchModelInputs[0], { + sessionID: "ses_v2", + model: { id: "sonnet", providerID: "anthropic", variant: "low" }, +}); + +const wrongGeneration = await runtime.run({ + prompt: "legacy continuation", + workspaceRoot: "/tmp/project", + providerSessionId: "ses_v1_legacy", +}); +assert.equal(wrongGeneration.isErr(), true); +if (wrongGeneration.isErr()) { + assert.equal(wrongGeneration.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.match(wrongGeneration.error.message, /different OpenCode protocol generation/); +} + +assert.deepEqual(parseOpenCodeV2Model("anthropic/sonnet", "high"), { + providerID: "anthropic", + id: "sonnet", + variant: "high", +}); +assert.deepEqual(parseOpenCodeV2Model("custom-model"), { + providerID: "opencode", + id: "custom-model", +}); +assert.equal(extractOpenCodeV2FinalResponse(assistant), "hello world"); + +assert.deepEqual(opencodeV2Permissions("full_access"), [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + { action: "question", resource: "*", effect: "deny" }, +]); +assert.deepEqual(opencodeV2Permissions("read_only").slice(-3), [ + { action: "external_directory", resource: "*", effect: "deny" }, + { action: "edit", resource: "*", effect: "deny" }, + { action: "shell", resource: "*", effect: "deny" }, +]); +assert.deepEqual(opencodeV2ServerEnvironment({ + PATH: "/bin", + OPENCODE_PASSWORD: "ambient-v2", + OPENCODE_SERVER_PASSWORD: "ambient-v1", +}, "generated"), { + PATH: "/bin", + OPENCODE_PASSWORD: "generated", +}); + +await runtime.close(); +await runtime.close(); +assert.equal(closeCalls, 1); + +let v1FactoryCalls = 0; +let v2FactoryCalls = 0; +const routedDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + v1FactoryCalls += 1; + throw new Error("v1 factory must not be selected for OpenCode 2"); + }, + v2Factory: async () => { + v2FactoryCalls += 1; + return { client, server: { close: () => undefined } }; + }, + probeRuntime: async () => ({ generation: "v2", version: "2.0.20" }), +}); +const routedPool = new LocalAgentRuntimePool(); +const routed = await routedPool.run(routedDriver, { + agentId: "agt_v2", + providerInstanceId: "opencode", + provider: "opencode", + workspaceRoot: "/tmp/project", +}, { + prompt: "route through v2", + workspaceRoot: "/tmp/project", +}); +assert.equal(routed.isOk(), true); +assert.equal(v1FactoryCalls, 0); +assert.equal(v2FactoryCalls, 1); +await routedPool.close(); + +// A runtime created after OpenCode is upgraded must follow the installed executable, not a stale probe. +let installedGeneration: "v1" | "v2" = "v1"; +const calls = { v1: 0, v2: 0 }; +const upgradingDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + calls.v1 += 1; + throw new Error("stop after routing"); + }, + v2Factory: async () => { + calls.v2 += 1; + throw new Error("stop after routing"); + }, + probeRuntime: async () => ({ generation: installedGeneration, version: "x.y.z" }), +}); +const upgradeContext = { + agentId: "agt_upgrade", + providerInstanceId: "opencode", + provider: "opencode", + workspaceRoot: "/tmp/project", +} as const; +await upgradingDriver.createRuntime(upgradeContext); +installedGeneration = "v2"; +await upgradingDriver.createRuntime(upgradeContext); +assert.deepEqual(calls, { v1: 1, v2: 1 }); + +// A turn that fails before any assistant output surfaces the provider's error, not a protocol error. +const failedIdle = { + id: "msg_idle", + time: { created: 3 }, + type: "idle", + outcome: "failed", + error: { type: "provider.auth", message: "xAI request failed (400): invalid_grant" }, +}; +const failedClient = { + ...client, + message: { + async list(input: { type?: string }) { + return { data: input.type === "assistant" ? [] : [failedIdle], cursor: { previous: null, next: null } }; + }, + }, +} as unknown as OpencodeV2ClientLike; +const failedRuntime = new OpencodeV2Runtime(failedClient, { close: () => undefined }); +const failedRun = await failedRuntime.run({ prompt: "auth fails", workspaceRoot: "/tmp/project" }); +assert.equal(failedRun.isErr(), true); +if (failedRun.isErr()) { + assert.equal(failedRun.error.code, "PROVIDER_EXECUTION_ERROR"); + assert.match(failedRun.error.message, /invalid_grant/); +} +await failedRuntime.close(); + +// A continued turn that fails before output must not return the previous turn's response. +const staleClient = { + ...client, + session: { + ...client.session, + async prompt() { + return { id: "inbox", sessionID: "ses_v2", time: { created: 5 }, type: "user", payload: { text: "x" }, delivery: "queue" }; + }, + }, + message: { + async list(input: { type?: string }) { + return { data: input.type === "assistant" ? [assistant] : [failedIdle], cursor: { previous: null, next: null } }; + }, + }, +} as unknown as OpencodeV2ClientLike; +const staleRuntime = new OpencodeV2Runtime(staleClient, { close: () => undefined }); +const staleRun = await staleRuntime.run({ + prompt: "continued but failing", + workspaceRoot: "/tmp/project", + providerSessionId: "opencode:v2:ses_v2", +}); +assert.equal(staleRun.isErr(), true); +if (staleRun.isErr()) assert.match(staleRun.error.message, /invalid_grant/); +await staleRuntime.close(); + +// Stop ends the turn only once OpenCode confirms the interrupt; a failed interrupt leaves it running. +const interruptRun = async (interrupt: () => Promise) => { + const interruptClient = { + ...client, + session: { + ...client.session, + interrupt, + async wait(_input: unknown, options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + }, + }, + } as unknown as OpencodeV2ClientLike; + const interruptRuntime = new OpencodeV2Runtime(interruptClient, { close: () => undefined }, 100); + const stop = new AbortController(); + const run = interruptRuntime.run({ prompt: "stop me", workspaceRoot: "/tmp/project", signal: stop.signal }); + setTimeout(() => stop.abort(), 10); + const result = await run; + await interruptRuntime.close(); + return result.isErr() ? result.error.code : "ok"; +}; +assert.equal(await interruptRun(async () => ({ interrupted: true })), "PROVIDER_CANCELLED"); +assert.equal(await interruptRun(async () => { throw new Error("interrupt failed"); }), "PROVIDER_PROTOCOL_ERROR"); diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts new file mode 100644 index 000000000..2f5ce3da4 --- /dev/null +++ b/src/local-agent-opencode-v2.ts @@ -0,0 +1,381 @@ +import { randomBytes } from "node:crypto"; +import { + OpenCode, + type ModelRef, + type OpenCodeClient, + type PermissionRule, + type SessionMessageAssistant, +} from "@opencode/client"; +import { + AgentProviderExecutionError, + AgentProviderProtocolError, + AgentProviderUnavailableError, + captureAgentProviderResult, +} from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import type { + LocalAgentRunCallbacks, + LocalAgentRunInput, + LocalAgentRunResult, + LocalAgentRuntime, + LocalAgentRuntimeContext, +} from "./local-agent-runtime.js"; +import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; +import { + encodeOpenCodeV2SessionId, + requireOpenCodeV2NativeSessionId, +} from "./local-agent-opencode-version.js"; + +const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; + +export type OpencodeV2ClientLike = Pick; + +export type OpencodeV2Factory = ( + context?: LocalAgentRuntimeContext, + env?: NodeJS.ProcessEnv, +) => Promise<{ + client: OpencodeV2ClientLike; + server: OpencodeServerLike; +}>; + +export class OpencodeV2Runtime implements LocalAgentRuntime { + readonly provider = "opencode" as const; + private alive = true; + private closed = false; + private readonly promptControllers = new Set(); + + constructor( + private readonly client: OpencodeV2ClientLike, + private readonly server: OpencodeServerLike, + private readonly promptTimeoutMs = OPENCODE_PROMPT_TIMEOUT_MS, + ) {} + + async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { + return captureAgentProviderResult({ + provider: this.provider, + operation: "run", + run: async (): Promise => { + if (!this.alive) { + throw new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: this.provider, + operation: "run", + retryable: true, + message: "OpenCode runtime is not running.", + }); + } + try { + await this.client.server.info(); + const continuing = input.providerSessionId !== undefined; + const nativeSessionId = input.providerSessionId + ? requireOpenCodeV2NativeSessionId(input.providerSessionId) + : await this.createSession(input); + const providerSessionId = encodeOpenCodeV2SessionId(nativeSessionId); + await callbacks?.onSessionId?.(providerSessionId); + if (continuing) await this.configureSession(nativeSessionId, input); + const { promptedAt, messages } = await this.prompt(nativeSessionId, input); + // On a continued session the latest assistant message may predate this + // prompt when the new turn failed before producing output. + const fresh = messages.data.filter((message) => message.time.created >= promptedAt); + if (fresh.length === 0) await this.assertTurnNotFailed(nativeSessionId); + const assistant = requireLatestAssistant(fresh); + assertOpenCodeV2AssistantSucceeded(assistant); + return { + provider: this.provider, + providerSessionId, + finalResponse: requireOpenCodeV2FinalResponse(assistant), + items: fresh, + }; + } catch (error) { + if (isOpenCodeTransportFailure(error)) { + this.alive = false; + throw new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: this.provider, + operation: "run", + retryable: true, + cause: error, + message: "OpenCode provider is unavailable.", + }); + } + throw error; + } + }, + }); + } + + async releaseSession(_providerSessionId: string): Promise { + // OpenCode keeps durable sessions independently of this process. + } + + isAlive(): boolean { + return this.alive && !this.closed; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.alive = false; + for (const controller of this.promptControllers) controller.abort(); + this.promptControllers.clear(); + this.server.close(); + } + + /** + * A turn that fails before any assistant output (for example provider auth) + * is recorded as a failed `idle` message, which the assistant-only listing hides. + */ + private async assertTurnNotFailed(sessionId: string): Promise { + const recent = await this.client.message.list({ sessionID: sessionId, limit: 5, order: "desc" }); + const idle = recent.data.find((message) => ( + message !== null + && typeof message === "object" + && "type" in message + && message.type === "idle" + )) as { outcome?: string; error?: { type?: string; message?: string } } | undefined; + if (idle?.outcome !== "failed") return; + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "opencode", + operation: "prompt", + retryable: false, + cause: idle.error, + message: idle.error?.message || "OpenCode turn failed before producing a response.", + }); + } + + private async createSession(input: LocalAgentRunInput): Promise { + const model = await resolveOpenCodeV2Model(this.client, input); + const session = await this.client.session.create({ + location: { directory: input.workspaceRoot }, + permissions: opencodeV2Permissions(input.writeMode), + ...(model ? { model } : {}), + }); + if (!session.id) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "create_session", + retryable: false, + message: "OpenCode did not return a session id.", + }); + } + return session.id; + } + + private async configureSession(sessionId: string, input: LocalAgentRunInput): Promise { + await this.client.session.update({ + sessionID: sessionId, + permissions: opencodeV2Permissions(input.writeMode), + }); + if (!input.model && !input.effort) return; + const current = await this.client.session.get({ sessionID: sessionId }); + const model = await resolveOpenCodeV2Model(this.client, input, current.model); + if (!model) return; + await this.client.session.switchModel({ sessionID: sessionId, model }); + } + + private async prompt(sessionId: string, input: LocalAgentRunInput) { + const controller = new AbortController(); + this.promptControllers.add(controller); + let timedOut = false; + // End the local request only once OpenCode confirms the interrupt (`interrupted: false` + // means the session was already idle). A failed or stalled interrupt keeps the turn + // running, and the manager reports the stop as a conflict. + const removeAbort = bindLocalAgentAbort(input.signal, async () => { + await this.client.session.interrupt({ sessionID: sessionId }); + controller.abort(); + }); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, this.promptTimeoutMs); + try { + const prompted = await this.client.session.prompt({ + sessionID: sessionId, + text: input.prompt, + }, { signal: controller.signal }); + await this.client.session.wait({ sessionID: sessionId }, { signal: controller.signal }); + const messages = await this.client.message.list({ + sessionID: sessionId, + limit: 1, + order: "desc", + type: "assistant", + }, { signal: controller.signal }); + return { promptedAt: prompted.time.created, messages }; + } catch (error) { + // A provider timeout after a stop request is still a timeout: the interrupt was never confirmed. + if (input.signal?.aborted && !timedOut) throw localAgentCancelledError("opencode", "prompt", error); + if (!timedOut) throw error; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "prompt", + retryable: true, + cause: error, + message: "OpenCode did not finish the prompt before the provider timeout.", + }); + } finally { + clearTimeout(timer); + removeAbort(); + this.promptControllers.delete(controller); + } + } +} + +export async function defaultOpencodeV2Factory( + _context?: LocalAgentRuntimeContext, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ client: OpencodeV2ClientLike; server: OpencodeServerLike }> { + const password = randomBytes(32).toString("base64url"); + const server = await startOpencodeServer(opencodeV2ServerEnvironment(env, password)); + const authorization = `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; + return { + client: OpenCode.make({ + baseUrl: server.url, + headers: { authorization }, + }), + server, + }; +} + +export function opencodeV2ServerEnvironment( + env: NodeJS.ProcessEnv, + password: string, +): NodeJS.ProcessEnv { + const { + OPENCODE_PASSWORD: _opencodePassword, + OPENCODE_SERVER_PASSWORD: _legacyPassword, + ...rest + } = env; + return { ...rest, OPENCODE_PASSWORD: password }; +} + +export function opencodeV2Permissions( + writeMode: LocalAgentRunInput["writeMode"], +): PermissionRule[] { + const rules: PermissionRule[] = [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + { action: "question", resource: "*", effect: "deny" }, + ]; + if (writeMode !== "full_access") { + rules.push({ action: "external_directory", resource: "*", effect: "deny" }); + } + if (writeMode === "read_only") { + rules.push( + { action: "edit", resource: "*", effect: "deny" }, + { action: "shell", resource: "*", effect: "deny" }, + ); + } + return rules; +} + +async function resolveOpenCodeV2Model( + client: OpencodeV2ClientLike, + input: LocalAgentRunInput, + current?: ModelRef, +): Promise { + if (input.model) return parseOpenCodeV2Model(input.model, input.effort); + if (!input.effort) return undefined; + if (current) return { ...current, variant: input.effort }; + const fallback = await client.model.default({ + location: { directory: input.workspaceRoot }, + }); + if (!fallback.data) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "select_model", + retryable: false, + message: "OpenCode has no default model to apply the requested effort variant to.", + }); + } + return { + id: fallback.data.id, + providerID: fallback.data.providerID, + variant: input.effort, + }; +} + +export function parseOpenCodeV2Model(model: string, effort?: string): ModelRef { + const separator = model.indexOf("/"); + return { + providerID: separator === -1 ? "opencode" : model.slice(0, separator), + id: separator === -1 ? model : model.slice(separator + 1), + ...(effort ? { variant: effort } : {}), + }; +} + +export function extractOpenCodeV2FinalResponse(message: SessionMessageAssistant): string { + return message.content + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join("") + .trim(); +} + +function requireOpenCodeV2FinalResponse(message: SessionMessageAssistant): string { + const response = extractOpenCodeV2FinalResponse(message); + if (!response) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "run", + retryable: false, + message: "OpenCode did not return a final assistant response.", + }); + } + return response; +} + +function requireLatestAssistant(messages: unknown[]): SessionMessageAssistant { + const assistant = messages.find((message) => ( + message !== null + && typeof message === "object" + && "type" in message + && message.type === "assistant" + )); + if (!assistant) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "run", + retryable: false, + message: "OpenCode did not return an assistant message after the prompt completed.", + }); + } + return assistant as SessionMessageAssistant; +} + +function assertOpenCodeV2AssistantSucceeded(message: SessionMessageAssistant): void { + if (!message.error) return; + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "opencode", + operation: "prompt", + retryable: message.error.status === 429 || (message.error.status ?? 0) >= 500, + cause: message.error, + message: message.error.message || `OpenCode returned ${message.error.type}.`, + }); +} + +function isOpenCodeTransportFailure(error: unknown): boolean { + const code = transportErrorCode(error); + return code === "ECONNREFUSED" + || code === "ECONNRESET" + || code === "EPIPE" + || code === "ENETDOWN" + || code === "ENETUNREACH" + || code === "ETIMEDOUT"; +} + +function transportErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string") return code; + const cause = (error as Error & { cause?: unknown }).cause; + return cause && typeof cause === "object" && typeof (cause as NodeJS.ErrnoException).code === "string" + ? (cause as NodeJS.ErrnoException).code + : undefined; +} diff --git a/src/local-agent-opencode-version.test.ts b/src/local-agent-opencode-version.test.ts new file mode 100644 index 000000000..8ef59810d --- /dev/null +++ b/src/local-agent-opencode-version.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { + classifyOpenCodeCliVersion, + encodeOpenCodeV2SessionId, + requireOpenCodeV1NativeSessionId, + requireOpenCodeV2NativeSessionId, +} from "./local-agent-opencode-version.js"; + +assert.deepEqual(classifyOpenCodeCliVersion("1.18.32\n"), { + generation: "v1", + version: "1.18.32", +}); +assert.deepEqual(classifyOpenCodeCliVersion("opencode v2.0.20\n"), { + generation: "v2", + version: "2.0.20", +}); +assert.deepEqual(classifyOpenCodeCliVersion("opencode v2.1.0-beta.2\n"), { + generation: "v2", + version: "2.1.0-beta.2", +}); +assert.equal(classifyOpenCodeCliVersion("opencode unknown"), undefined); + +assert.equal(encodeOpenCodeV2SessionId("ses_v2"), "opencode:v2:ses_v2"); +assert.equal(requireOpenCodeV2NativeSessionId("opencode:v2:ses_v2"), "ses_v2"); +assert.equal(requireOpenCodeV1NativeSessionId("ses_v1"), "ses_v1"); +assert.throws( + () => requireOpenCodeV2NativeSessionId("ses_v1"), + /different OpenCode protocol generation/, +); +assert.throws( + () => requireOpenCodeV1NativeSessionId("opencode:v2:ses_v2"), + /different OpenCode protocol generation/, +); diff --git a/src/local-agent-opencode-version.ts b/src/local-agent-opencode-version.ts new file mode 100644 index 000000000..d4e8e452d --- /dev/null +++ b/src/local-agent-opencode-version.ts @@ -0,0 +1,131 @@ +import { createRequire } from "node:module"; +import { + AgentProviderProtocolError, + AgentProviderUnavailableError, +} from "./local-agent-errors.js"; + +const OPENCODE_VERSION_PROBE_TIMEOUT_MS = 4_000; +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; + +export type OpenCodeGeneration = "v1" | "v2"; + +export interface ProbedOpenCodeRuntime { + generation: OpenCodeGeneration; + version: string; +} + +export type OpenCodeRuntimeProbeFunction = () => Promise; + +const OPENCODE_V2_SESSION_PREFIX = "opencode:v2:"; + +export function classifyOpenCodeCliVersion(output: string): ProbedOpenCodeRuntime | undefined { + const match = output.trim().match(/(?:^|\s)v?((\d+)\.\d+\.\d+(?:[-+][^\s]+)?)(?:$|\s)/); + if (!match?.[1] || !match[2]) return undefined; + const major = Number(match[2]); + const version = match[1]; + return { + generation: major >= 2 ? "v2" : "v1", + version, + }; +} + +export function encodeOpenCodeV2SessionId(nativeSessionId: string): string { + return `${OPENCODE_V2_SESSION_PREFIX}${nativeSessionId}`; +} + +export function requireOpenCodeV2NativeSessionId(providerSessionId: string): string { + if (providerSessionId.startsWith(OPENCODE_V2_SESSION_PREFIX)) { + const nativeSessionId = providerSessionId.slice(OPENCODE_V2_SESSION_PREFIX.length); + if (nativeSessionId) return nativeSessionId; + } + throw incompatibleOpenCodeSession("v2", providerSessionId); +} + +export function requireOpenCodeV1NativeSessionId(providerSessionId: string): string { + if (!providerSessionId.startsWith(OPENCODE_V2_SESSION_PREFIX)) return providerSessionId; + throw incompatibleOpenCodeSession("v1", providerSessionId); +} + +function incompatibleOpenCodeSession( + generation: OpenCodeGeneration, + providerSessionId: string, +): AgentProviderProtocolError { + return new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "resume_session", + retryable: false, + message: `OpenCode ${generation} cannot resume session ${providerSessionId}; the session belongs to a different OpenCode protocol generation. Start a new agent after changing OpenCode major versions.`, + }); +} + +export async function probeOpenCodeBinary( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const output = await runOpenCodeVersion(env); + const result = classifyOpenCodeCliVersion(output); + if (result) return result; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "probe_version", + retryable: false, + message: "Unable to determine the installed OpenCode version from `opencode --version`.", + }); +} + +function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn("opencode", ["--version"], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGTERM"); + reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + message: `OpenCode version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT_MS}ms.`, + })); + }, OPENCODE_VERSION_PROBE_TIMEOUT_MS); + timer.unref(); + child.stdout?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + child.stderr?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); + child.once("error", (cause) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + cause, + message: (cause as NodeJS.ErrnoException).code === "ENOENT" + ? "OpenCode executable is unavailable." + : `OpenCode version probe failed to start: ${cause.message}`, + })); + }); + // `close` waits for stdout/stderr to drain; `exit` can fire before the version is read. + child.once("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) resolve(output); + else reject(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: "opencode", + operation: "probe_version", + retryable: true, + message: `OpenCode version probe exited with code ${code}${output.trim() ? `: ${output.trim()}` : "."}`, + })); + }); + }); +} diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 69c358004..648d041d2 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -13,6 +13,8 @@ import { } from "./local-agent-opencode.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; +const probeV1 = async () => ({ generation: "v1", version: "1.17.13" }) as const; + let sessionNumber = 0; const createInputs: unknown[] = []; const promptInputs: unknown[] = []; @@ -53,11 +55,16 @@ const factory: OpencodeFactory = async (_context, env) => { server: { close: () => { closeCalls += 1; } }, }; }; -const driver = new OpencodeLocalAgentDriver(factory, { HARNESS_ENV: "opencode" }); +const driver = new OpencodeLocalAgentDriver({ + factory, + env: { HARNESS_ENV: "opencode" }, + probeRuntime: probeV1, +}); const pool = new LocalAgentRuntimePool(); const first = await pool.run(driver, { agentId: "agt_one", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -68,6 +75,7 @@ const first = await pool.run(driver, { }); const second = await pool.run(driver, { agentId: "agt_two", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -129,19 +137,23 @@ if (process.platform !== "win32") { "", ].join("\n")); await chmod(command, 0o700); - const envDriver = new OpencodeLocalAgentDriver(undefined, { - PATH: commandRoot, - HARNESS_ENV: "opencode-child", - MARKER: marker, - ARGS_MARKER: argsMarker, - COLLISION_MARKER: collisionMarker, - HOLDER_READY: holderReady, - HOLDER_PROCESS: holderProcess, - HOLDER_LAUNCHER: holderLauncher, - NODE_EXECUTABLE: process.execPath, + const envDriver = new OpencodeLocalAgentDriver({ + env: { + PATH: commandRoot, + HARNESS_ENV: "opencode-child", + MARKER: marker, + ARGS_MARKER: argsMarker, + COLLISION_MARKER: collisionMarker, + HOLDER_READY: holderReady, + HOLDER_PROCESS: holderProcess, + HOLDER_LAUNCHER: holderLauncher, + NODE_EXECUTABLE: process.execPath, + }, + probeRuntime: probeV1, }); const created = await envDriver.createRuntime({ agentId: "agt_env", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }); @@ -179,6 +191,7 @@ assert.deepEqual(promptInputs[0], { let callbackSessionId: string | undefined; await pool.run(driver, { agentId: "agt_one", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -224,6 +237,34 @@ if (timedOutPrompt.isErr()) { } await timeoutRuntime.close(); +// Stop ends the turn only once OpenCode confirms the abort; a stalled abort leaves it running. +const abortClient = (abort: () => Promise) => ({ + global: { + async health() { return { data: { healthy: true } }; }, + }, + session: { + async create() { return { data: { id: "session_abort" } }; }, + abort, + async prompt(_input: unknown, options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + }, + }, +}) as unknown as OpencodeClientLike; +const stopRun = async (abort: () => Promise) => { + const stopRuntime = new OpencodeRuntime(abortClient(abort), { close: () => undefined }, 100); + const stop = new AbortController(); + const run = stopRuntime.run({ prompt: "stop me", workspaceRoot: "/tmp/project", signal: stop.signal }); + setTimeout(() => stop.abort(), 10); + const result = await run; + await stopRuntime.close(); + return result.isErr() ? result.error.code : "ok"; +}; +assert.equal(await stopRun(async () => ({ data: true })), "PROVIDER_CANCELLED"); +assert.equal(await stopRun(async () => ({ error: { name: "UnknownError" } })), "PROVIDER_PROTOCOL_ERROR"); +assert.equal(await stopRun(() => new Promise(() => undefined)), "PROVIDER_PROTOCOL_ERROR"); + assert.equal(opencodeAgentFor("read_only"), "devspace_read_only"); assert.equal(opencodeAgentFor("full_access"), "devspace_full_access"); assert.deepEqual(opencodePermissionFor("allowed"), { @@ -277,12 +318,16 @@ const applicationErrorClient = { }, } as unknown as OpencodeClientLike; const applicationErrorPool = new LocalAgentRuntimePool(); -const applicationErrorDriver = new OpencodeLocalAgentDriver(async () => ({ - client: applicationErrorClient, - server: { close: () => undefined }, -})); +const applicationErrorDriver = new OpencodeLocalAgentDriver({ + factory: async () => ({ + client: applicationErrorClient, + server: { close: () => undefined }, + }), + probeRuntime: probeV1, +}); const applicationFailure = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "bad input", workspaceRoot: "/tmp/project" }); @@ -295,6 +340,7 @@ if (applicationFailure.isErr()) { assert.equal(applicationErrorPool.size, 1, "ordinary provider errors must not evict a healthy server runtime"); const recoveredApplicationTurn = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "valid input", workspaceRoot: "/tmp/project" }); @@ -304,14 +350,18 @@ assert.equal(recoveredApplicationTurn.value.finalResponse, "ok"); await applicationErrorPool.close(); let recoveringFactoryCalls = 0; -const recoveringDriver = new OpencodeLocalAgentDriver(async () => { - recoveringFactoryCalls += 1; - healthAvailable = true; - return { client, server: { close: () => undefined } }; +const recoveringDriver = new OpencodeLocalAgentDriver({ + factory: async () => { + recoveringFactoryCalls += 1; + healthAvailable = true; + return { client, server: { close: () => undefined } }; + }, + probeRuntime: probeV1, }); const recoveringPool = new LocalAgentRuntimePool(); await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -321,6 +371,7 @@ await recoveringPool.run(recoveringDriver, { healthAvailable = false; const deadRuntime = await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -335,6 +386,7 @@ if (deadRuntime.isErr()) { assert.equal(recoveringPool.size, 0, "a failed health check removes the dead runtime immediately"); await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index d9980aba2..c8ecdc4ba 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,5 +1,3 @@ -import { createRequire } from "node:module"; -import { createServer as createNetServer } from "node:net"; import type { OpencodeClient, PermissionConfig, @@ -10,6 +8,7 @@ import { AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -18,14 +17,19 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import { terminateProcessTree } from "./process-platform.js"; +import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; +import { + defaultOpencodeV2Factory, + OpencodeV2Runtime, + type OpencodeV2Factory, +} from "./local-agent-opencode-v2.js"; +import { + probeOpenCodeBinary, + requireOpenCodeV1NativeSessionId, + type OpenCodeRuntimeProbeFunction, +} from "./local-agent-opencode-version.js"; -const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; -const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; -const OPENCODE_SERVER_START_ATTEMPTS = 3; const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; -const require = createRequire(import.meta.url); -const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; interface OpencodeModelRef { providerID: string; @@ -34,10 +38,6 @@ interface OpencodeModelRef { export type OpencodeClientLike = Pick; -export interface OpencodeServerLike { - close(): void; -} - export type OpencodeFactory = ( context?: LocalAgentRuntimeContext, env?: NodeJS.ProcessEnv, @@ -74,7 +74,9 @@ export class OpencodeRuntime implements LocalAgentRuntime { } try { await assertOpencodeHealthy(this.client); - const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input); + const sessionId = input.providerSessionId + ? requireOpenCodeV1NativeSessionId(input.providerSessionId) + : await createOpencodeSession(this.client, input); await callbacks?.onSessionId?.(sessionId); const promptResult = await this.prompt(sessionId, input); assertOpenCodePromptSucceeded(promptResult); @@ -124,6 +126,15 @@ export class OpencodeRuntime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; + // End the local request only once OpenCode confirms the abort. A failed or + // stalled abort keeps the turn running, and the manager reports the stop as a conflict. + const removeAbort = bindLocalAgentAbort(input.signal, async () => { + const aborted = await this.client.session.abort({ + sessionID: sessionId, + directory: input.workspaceRoot, + }, { throwOnError: false }); + if (!aborted.error) controller.abort(); + }); const timer = setTimeout(() => { timedOut = true; controller.abort(); @@ -131,6 +142,8 @@ export class OpencodeRuntime implements LocalAgentRuntime { try { return await promptOpencodeSession(this.client, sessionId, input, controller.signal); } catch (error) { + // A provider timeout after a stop request is still a timeout: the abort was never confirmed. + if (input.signal?.aborted && !timedOut) throw localAgentCancelledError("opencode", "prompt", error); if (!timedOut) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -142,6 +155,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { }); } finally { clearTimeout(timer); + removeAbort(); this.promptControllers.delete(controller); } } @@ -149,15 +163,30 @@ export class OpencodeRuntime implements LocalAgentRuntime { export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; - readonly idleTimeoutMs = 5 * 60_000; - - constructor( - private readonly factory: OpencodeFactory = defaultOpencodeFactory, - private readonly env: NodeJS.ProcessEnv = process.env, - ) {} - - runtimeKey(_context: LocalAgentRuntimeContext): string { - return "opencode:default"; + readonly providerInstanceId = "opencode"; + readonly runtimePolicy = { scope: "instance", idleTimeoutMs: 5 * 60_000 } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: true }, + } as const; + private readonly factory: OpencodeFactory; + private readonly v2Factory: OpencodeV2Factory; + private readonly env: NodeJS.ProcessEnv; + private readonly probeRuntime: OpenCodeRuntimeProbeFunction; + + constructor(options: { + factory?: OpencodeFactory; + v2Factory?: OpencodeV2Factory; + env?: NodeJS.ProcessEnv; + probeRuntime?: OpenCodeRuntimeProbeFunction; + } = {}) { + this.factory = options.factory ?? defaultOpencodeFactory; + this.v2Factory = options.v2Factory ?? defaultOpencodeV2Factory; + this.env = options.env ?? process.env; + this.probeRuntime = options.probeRuntime ?? (() => probeOpenCodeBinary(this.env)); } async createRuntime(context: LocalAgentRuntimeContext) { @@ -166,6 +195,12 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { + // Probe on every runtime creation so an upgraded executable is routed to the matching protocol generation. + const runtime = await this.probeRuntime(); + if (runtime.generation === "v2") { + const { client, server } = await this.v2Factory(context, this.env); + return new OpencodeV2Runtime(client, server); + } const { client, server } = await this.factory(context, this.env); return new OpencodeRuntime(client, server); }, @@ -185,124 +220,16 @@ async function defaultOpencodeFactory( devspace_full_access: opencodeAgentConfig("full_access"), }, }; - const server = await startOpencodeServer(env, config); + const server = await startOpencodeServer({ + ...env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + }); return { client: createOpencodeClient({ baseUrl: server.url }), server, }; } -async function startOpencodeServer( - env: NodeJS.ProcessEnv, - config: Record, -): Promise { - for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { - const port = await allocateOpencodePort(); - try { - return await launchOpencodeServer(env, config, port); - } catch (error) { - if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; - } - } - throw new Error("OpenCode server failed to start."); -} - -async function launchOpencodeServer( - env: NodeJS.ProcessEnv, - config: Record, - port: number, -): Promise { - const detached = process.platform !== "win32"; - const child = spawn("opencode", [ - "serve", - `--hostname=${OPENCODE_SERVER_HOSTNAME}`, - `--port=${port}`, - ], { - detached, - env: { - ...env, - OPENCODE_CONFIG_CONTENT: JSON.stringify(config), - }, - stdio: ["ignore", "pipe", "pipe"], - }); - let closed = false; - const close = () => { - if (closed) return; - closed = true; - terminateProcessTree(child, "SIGTERM", detached); - }; - const url = await new Promise((resolve, reject) => { - let output = ""; - let ready = false; - const timer = setTimeout(() => { - if (ready) return; - close(); - reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); - }, OPENCODE_SERVER_START_TIMEOUT_MS); - timer.unref(); - child.stdout?.on("data", (chunk: Buffer | string) => { - if (ready) return; - output += chunk.toString(); - for (const line of output.split("\n")) { - if (!line.startsWith("opencode server listening")) continue; - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - if (!match?.[1]) continue; - ready = true; - clearTimeout(timer); - resolve(match[1]); - return; - } - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - if (!ready) output += chunk.toString(); - }); - child.once("error", (error) => { - if (ready) return; - clearTimeout(timer); - close(); - reject(error); - }); - child.once("exit", (code) => { - if (ready) return; - clearTimeout(timer); - close(); - reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); - }); - }); - return { url, close }; -} - -async function allocateOpencodePort(): Promise { - const server = createNetServer(); - server.unref(); - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Failed to allocate an OpenCode server port.")); - return; - } - server.close((error) => error ? reject(error) : resolve(address.port)); - }); - }); -} - -async function isOpencodePortInUse(port: number): Promise { - const server = createNetServer(); - server.unref(); - return new Promise((resolve, reject) => { - server.once("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EADDRINUSE") resolve(true); - else reject(error); - }); - server.listen({ host: OPENCODE_SERVER_HOSTNAME, port, exclusive: true }, () => { - server.close((error) => error ? reject(error) : resolve(false)); - }); - }); -} - export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): { mode: "primary"; permission: PermissionConfig; diff --git a/src/local-agent-pi-rpc.test.ts b/src/local-agent-pi-rpc.test.ts new file mode 100644 index 000000000..4bba0628d --- /dev/null +++ b/src/local-agent-pi-rpc.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { PiRpcConnection } from "./local-agent-pi-rpc.js"; + +const script = String.raw` +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line); + process.stdout.write("not-json\n"); + process.stdout.write(JSON.stringify({ type: "agent_start" }) + "\n"); + process.stdout.write(JSON.stringify({ + type: "response", + id: request.id, + command: request.type, + success: true, + data: { echoed: request.value }, + }) + "\n"); + } +}); +`; + +const connection = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + env: process.env, +}); +const response = await connection.request({ type: "echo", value: "hello" }); +assert.deepEqual(response, { echoed: "hello" }); +assert.deepEqual(await connection.nextEvent(1_000), { type: "agent_start" }); +connection.close(); + +// A Pi that writes a lot to stderr must not block on a full pipe, and a crash reports what it said. +const noisyScript = String.raw` +process.stderr.write("x".repeat(256 * 1024)); +process.stderr.write("fatal: broken extension\n", () => process.exit(3)); +`; +const noisy = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", noisyScript], + cwd: process.cwd(), + env: process.env, +}); +await assert.rejects( + () => noisy.nextEvent(5_000), + /exited with code 3[\s\S]*fatal: broken extension/, +); +assert.equal(noisy.isAlive(), false); + +// Events nobody consumed (for example the tail of a cancelled turn) can be dropped. +const clearable = PiRpcConnection.spawn({ + command: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + env: process.env, +}); +await clearable.request({ type: "echo", value: "again" }); +clearable.clearEvents(); +assert.equal(await clearable.nextEvent(100), undefined); +clearable.close(); diff --git a/src/local-agent-pi-rpc.ts b/src/local-agent-pi-rpc.ts new file mode 100644 index 000000000..135ad1048 --- /dev/null +++ b/src/local-agent-pi-rpc.ts @@ -0,0 +1,214 @@ +import { createRequire } from "node:module"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { terminateProcessTree } from "./process-platform.js"; + +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const MAX_RECORD_CHARS = 8 * 1024 * 1024; +const MAX_STDERR_CHARS = 8 * 1024; + +export type PiRpcRecord = Record; + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +} + +export class PiRpcConnection { + private readonly pending = new Map(); + private readonly queuedEvents: PiRpcRecord[] = []; + private readonly eventWaiters: Array<{ + resolve(value: PiRpcRecord): void; + reject(error: Error): void; + timer?: NodeJS.Timeout; + }> = []; + private nextRequestId = 0; + private buffer = ""; + private closed = false; + private failure?: Error; + private stderrTail = ""; + + private constructor(private readonly child: ChildProcessWithoutNullStreams) { + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => this.consume(chunk)); + // Drain stderr so a chatty Pi or extension cannot fill the pipe and block; keep the tail for diagnostics. + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + this.stderrTail = (this.stderrTail + chunk).slice(-MAX_STDERR_CHARS); + }); + // A write racing with process exit emits EPIPE here; fail this connection instead of the daemon. + child.stdin.on("error", (error) => this.fail(error)); + child.once("error", (error) => this.fail(error)); + // "close" (not "exit") so stderr is fully drained before it is reported. + child.once("close", (code) => { + if (this.closed) return; + const stderr = this.stderrTail.trim(); + this.fail(new Error(`Pi RPC process exited with code ${code}.${stderr ? `\n${stderr}` : ""}`)); + }); + } + + static spawn(input: { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + }): PiRpcConnection { + const detached = process.platform !== "win32"; + const child = spawn(input.command, input.args, { + cwd: input.cwd, + env: input.env, + stdio: ["pipe", "pipe", "pipe"], + detached, + windowsHide: true, + }); + if (!child.stdin || !child.stdout || !child.stderr) { + throw new Error("Pi RPC process did not expose stdio pipes."); + } + return new PiRpcConnection(child as ChildProcessWithoutNullStreams); + } + + async request(record: PiRpcRecord, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise { + this.assertOpen(); + const id = `devspace-${++this.nextRequestId}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${String(record.type ?? "request")} timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + timer.unref(); + this.pending.set(id, { resolve, reject, timer }); + this.write({ ...record, id }).catch((error) => { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + }); + }); + } + + async send(record: PiRpcRecord): Promise { + this.assertOpen(); + await this.write(record); + } + + async nextEvent(timeoutMs?: number): Promise { + this.assertOpen(); + const queued = this.queuedEvents.shift(); + if (queued) return queued; + return new Promise((resolve, reject) => { + const waiter: (typeof this.eventWaiters)[number] = { + resolve: (value) => { + if (waiter.timer) clearTimeout(waiter.timer); + resolve(value); + }, + reject: (error) => { + if (waiter.timer) clearTimeout(waiter.timer); + reject(error); + }, + }; + if (timeoutMs !== undefined) { + waiter.timer = setTimeout(() => { + const index = this.eventWaiters.indexOf(waiter); + if (index >= 0) this.eventWaiters.splice(index, 1); + resolve(undefined); + }, timeoutMs); + waiter.timer.unref(); + } + this.eventWaiters.push(waiter); + }); + } + + /** Drops events that were not consumed, e.g. the tail of a cancelled turn. */ + clearEvents(): void { + this.queuedEvents.length = 0; + } + + isAlive(): boolean { + return !this.closed && !this.failure && this.child.exitCode === null; + } + + close(): void { + if (this.closed) return; + this.closed = true; + const detached = process.platform !== "win32"; + terminateProcessTree(this.child, "SIGTERM", detached); + this.fail(new Error("Pi RPC process closed.")); + } + + private async write(record: PiRpcRecord): Promise { + const line = `${JSON.stringify(record)}\n`; + await new Promise((resolve, reject) => { + this.child.stdin.write(line, (error) => error ? reject(error) : resolve()); + }); + } + + private consume(chunk: string): void { + this.buffer += chunk; + if (this.buffer.length > MAX_RECORD_CHARS && !this.buffer.includes("\n")) { + this.buffer = ""; + return; + } + for (;;) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) return; + let line = this.buffer.slice(0, newline); + this.buffer = this.buffer.slice(newline + 1); + if (line.endsWith("\r")) line = line.slice(0, -1); + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; + this.route(parsed as PiRpcRecord); + } + } + + private route(record: PiRpcRecord): void { + if (record.type === "response" && typeof record.id === "string") { + const pending = this.pending.get(record.id); + if (pending) { + this.pending.delete(record.id); + clearTimeout(pending.timer); + if (record.success === true) pending.resolve(record.data); + else pending.reject(new Error(String(record.error ?? `Pi RPC ${String(record.command)} failed.`))); + return; + } + } + const waiter = this.eventWaiters.shift(); + if (waiter) waiter.resolve(record); + else this.queuedEvents.push(record); + } + + private assertOpen(): void { + if (this.failure) throw this.failure; + if (this.closed) throw new Error("Pi RPC process is closed."); + } + + private fail(error: Error): void { + if (!this.failure) this.failure = error; + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + for (const waiter of this.eventWaiters.splice(0)) waiter.reject(error); + } +} + +export function piRecordString(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const field = (value as Record)[key]; + return typeof field === "string" ? field : undefined; +} + +export function parsePiModelSlug(model: string): { provider: string; modelId: string } | undefined { + const separator = model.indexOf("/"); + if (separator <= 0 || separator === model.length - 1) return undefined; + return { provider: model.slice(0, separator), modelId: model.slice(separator + 1) }; +} diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index df111666c..085adc7e1 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -1,72 +1,115 @@ import assert from "node:assert/strict"; -import type { AgentSessionEvent, AgentSessionEventListener } from "@earendil-works/pi-coding-agent"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { PiLocalAgentDriver, - type PiSessionFactory, - type PiSessionLike, + type PiRpcFactory, + type PiRpcRuntimeConnection, } from "./local-agent-pi.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; -class FakePiSession implements PiSessionLike { - readonly sessionId = "pi_session_1"; - readonly messages: any[] = []; - readonly modelRegistry = { find: () => ({ id: "model" }) } as unknown as PiSessionLike["modelRegistry"]; - private readonly listeners = new Set(); - disposeCount = 0; - model?: unknown; - effort?: unknown; - activeTools: string[] = []; - toolHistory: string[][] = []; - - async prompt(text: string): Promise { - const message = { - role: "assistant", - content: [{ type: "text", text: `response:${text}` }], - }; - this.messages.push(message); - for (const listener of this.listeners) listener({ type: "agent_end" } as AgentSessionEvent); - } +const sessionDirectory = mkdtempSync(join(tmpdir(), "devspace-pi-test-")); +let sessionCount = 0; +// Pi session references are real files; resuming validates that. +function sessionFile(name = `session-${++sessionCount}`): string { + const path = join(sessionDirectory, `${name}.jsonl`); + writeFileSync(path, ""); + return path; +} + +class FakePiRpcConnection implements PiRpcRuntimeConnection { + readonly requests: Array> = []; + readonly events: Array> = []; + sessionFile = sessionFile("one"); + lastPrompt = ""; + alive = true; + cleanupCount = 0; - subscribe(listener: AgentSessionEventListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); + async request(record: Record): Promise { + this.requests.push(record); + switch (record.type) { + case "new_session": + this.sessionFile = sessionFile(); + return { cancelled: false }; + case "switch_session": + this.sessionFile = String(record.sessionPath); + return { cancelled: false }; + case "get_state": + return { + sessionFile: this.sessionFile, + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + }; + case "get_available_models": + return { models: [{ provider: "provider", id: "model" }] }; + case "set_model": + case "set_thinking_level": + case "abort": + return undefined; + case "prompt": + this.lastPrompt = String(record.message); + this.events.push( + { type: "agent_start" }, + { type: "agent_end" }, + { type: "agent_settled" }, + ); + return undefined; + case "get_last_assistant_text": + return { text: `response:${this.lastPrompt}` }; + default: + throw new Error(`unexpected request: ${String(record.type)}`); + } } - async setModel(model: any): Promise { - this.model = model; + async send(_record: Record): Promise {} + + async nextEvent(timeoutMs = 0): Promise | undefined> { + const event = this.events.shift(); + if (event) return event; + // Like the real connection, an empty queue waits instead of spinning on microtasks. + await new Promise((resolveWait) => setTimeout(resolveWait, Math.min(timeoutMs, 5))); + return undefined; } - setActiveToolsByName(toolNames: string[]): void { - this.activeTools = [...toolNames]; - this.toolHistory.push([...toolNames]); + clearEvents(): void { + this.events.length = 0; } - setThinkingLevel(level: any): void { - this.effort = level; + isAlive(): boolean { + return this.alive; } - dispose(): void { - this.disposeCount += 1; + close(): void { + this.alive = false; } } const contexts: LocalAgentRuntimeContext[] = []; -const sessions: FakePiSession[] = []; +const connections: FakePiRpcConnection[] = []; let factoryEnv: NodeJS.ProcessEnv | undefined; -const factory: PiSessionFactory = async (context, _input, env) => { +let cleanupCount = 0; +const factory: PiRpcFactory = async (context, env) => { contexts.push(context); factoryEnv = env; - const session = new FakePiSession(); - sessions.push(session); - return session; + const connection = new FakePiRpcConnection(); + connections.push(connection); + return { + connection, + cleanup: async () => { cleanupCount += 1; }, + }; }; + const driver = new PiLocalAgentDriver(factory, { HARNESS_ENV: "pi" }); const pool = new LocalAgentRuntimePool(); const context: LocalAgentRuntimeContext = { agentId: "agt_pi", + providerInstanceId: "pi", provider: "pi", workspaceRoot: "/tmp/project", + writeMode: "read_only", }; const sessionIds: string[] = []; @@ -80,75 +123,158 @@ const first = await pool.run(driver, context, { onSessionId: (sessionId) => { sessionIds.push(sessionId); }, }); assert.equal(factoryEnv?.HARNESS_ENV, "pi"); +assert.equal(first.isOk(), true); +if (first.isErr()) throw first.error; +assert.match(first.value.providerSessionId ?? "", /session-\d+\.jsonl$/); +assert.equal(first.value.finalResponse, "response:first"); +assert.deepEqual(sessionIds, [first.value.providerSessionId]); +assert.deepEqual( + connections[0]?.requests.filter((request) => request.type === "set_model")[0], + { type: "set_model", provider: "provider", modelId: "model" }, +); +assert.deepEqual( + connections[0]?.requests.filter((request) => request.type === "set_thinking_level")[0], + { type: "set_thinking_level", level: "high" }, +); +assert.ok(first.value.items.some((event: any) => event.type === "agent_end")); +assert.ok(first.value.items.some((event: any) => event.type === "agent_settled")); + const second = await pool.run(driver, context, { prompt: "second", workspaceRoot: "/tmp/project", - writeMode: "allowed", -}); -await pool.run(driver, context, { - prompt: "third", - workspaceRoot: "/tmp/project", - writeMode: "full_access", -}); -await pool.run(driver, context, { - prompt: "fourth", - workspaceRoot: "/tmp/project", + providerSessionId: first.value.providerSessionId ?? undefined, writeMode: "read_only", }); -assert.equal(contexts.length, 1, "one warm Pi session serves successive turns"); -assert.equal(first.isOk(), true); assert.equal(second.isOk(), true); -if (first.isErr()) throw first.error; -if (second.isErr()) throw second.error; -assert.equal(first.value.providerSessionId, "pi_session_1"); -assert.equal(second.value.finalResponse, "response:second"); -assert.deepEqual(sessions[0]?.model, { id: "model" }); -assert.equal(sessions[0]?.effort, "high"); -assert.deepEqual(sessionIds, ["pi_session_1"]); -assert.deepEqual(sessions[0]?.activeTools, ["read", "grep", "find", "ls"]); -assert.deepEqual(sessions[0]?.toolHistory, [ - ["read", "grep", "find", "ls"], - ["read", "grep", "find", "ls", "edit", "write", "bash"], - ["read", "grep", "find", "ls", "edit", "write", "bash"], - ["read", "grep", "find", "ls"], -]); - -await pool.run(driver, { ...context, providerSessionId: "pi_session_1" }, { - prompt: "fifth", +assert.equal(contexts.length, 1, "same agent and write mode reuse one Pi RPC process"); + +await pool.run(driver, { ...context, writeMode: "allowed" }, { + prompt: "allowed", workspaceRoot: "/tmp/project", - providerSessionId: "pi_session_1", + providerSessionId: first.value.providerSessionId ?? undefined, writeMode: "allowed", }); -assert.deepEqual(sessions[0]?.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); +assert.equal(contexts.length, 2, "changing Pi write mode creates a process with the matching fixed permission policy"); +assert.ok(connections[1]?.requests.some((request) => request.type === "switch_session")); await pool.evictIdle(Date.now() + 10 * 60_000); -assert.equal(sessions[0]?.disposeCount, 1, "idle eviction disposes the in-process session"); +assert.equal(cleanupCount, 2, "idle eviction closes external Pi processes and sandbox wrappers"); -await pool.run(driver, { ...context, providerSessionId: "pi_session_1" }, { +await pool.run(driver, { ...context, providerSessionId: first.value.providerSessionId ?? undefined }, { prompt: "resumed", workspaceRoot: "/tmp/project", - providerSessionId: "pi_session_1", + providerSessionId: first.value.providerSessionId ?? undefined, + writeMode: "read_only", }); -assert.equal(contexts.length, 2, "cold continuation creates a new AgentSession"); -assert.equal(contexts[1]?.providerSessionId, "pi_session_1"); -assert.deepEqual(sessions[1]?.activeTools, ["read", "grep", "find", "ls", "edit", "write", "bash"]); +assert.equal(contexts.length, 3, "cold continuation starts a new Pi RPC process"); +assert.ok(connections[2]?.requests.some((request) => request.type === "switch_session")); await pool.close(); -const missingModelSession = new FakePiSession(); -Object.defineProperty(missingModelSession, "modelRegistry", { value: { find: () => undefined } }); -const missingModelDriver = new PiLocalAgentDriver(async () => missingModelSession); -const missingModelRuntime = await missingModelDriver.createRuntime(context); -assert.equal(missingModelRuntime.isOk(), true); -if (missingModelRuntime.isErr()) throw missingModelRuntime.error; -const missingModel = await missingModelRuntime.value.run({ +const missingModel = new FakePiRpcConnection(); +missingModel.request = async function(record: Record): Promise { + this.requests.push(record); + if (record.type === "get_available_models") return { models: [] }; + if (record.type === "new_session") return { cancelled: false }; + if (record.type === "get_state") return { sessionFile: sessionFile("missing") }; + throw new Error(`unexpected request: ${String(record.type)}`); +}; +const missingModelDriver = new PiLocalAgentDriver(async () => ({ connection: missingModel })); +const missingRuntime = await missingModelDriver.createRuntime(context); +assert.equal(missingRuntime.isOk(), true); +if (missingRuntime.isErr()) throw missingRuntime.error; +const missingResult = await missingRuntime.value.run({ prompt: "inspect", workspaceRoot: "/tmp/project", - model: "provider/missing-model", + model: "missing-model", }); -assert.equal(missingModel.isErr(), true); -if (missingModel.isErr()) { - assert.equal(missingModel.error.code, "PROVIDER_PROTOCOL_ERROR"); - assert.equal(missingModel.error.retryable, false); - assert.match(missingModel.error.message, /provider\/missing-model/); +assert.equal(missingResult.isErr(), true); +if (missingResult.isErr()) { + assert.equal(missingResult.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.match(missingResult.error.message, /missing-model/); } -await missingModelRuntime.value.close(); +await missingRuntime.value.close(); + +// A bare session id from before Pi ran over RPC must fail instead of opening an empty session. +const legacyConnection = new FakePiRpcConnection(); +const legacyDriver = new PiLocalAgentDriver(async () => ({ connection: legacyConnection })); +const legacyRuntime = await legacyDriver.createRuntime(context); +if (legacyRuntime.isErr()) throw legacyRuntime.error; +const legacyResult = await legacyRuntime.value.run({ + prompt: "continue", + workspaceRoot: "/tmp/project", + providerSessionId: "019f-legacy-session-id", +}); +assert.equal(legacyResult.isErr(), true); +if (legacyResult.isErr()) assert.match(legacyResult.error.message, /Start a new agent/); +assert.equal(legacyConnection.requests.some((request) => request.type === "switch_session"), false); +await legacyRuntime.value.close(); + +// An exact model id wins over provider/model parsing (gateway providers use ids containing "/"). +const gatewayConnection = new FakePiRpcConnection(); +gatewayConnection.request = async function(record: Record): Promise { + if (record.type === "get_available_models") { + this.requests.push(record); + return { models: [{ provider: "openrouter", id: "openai/gpt-4o" }] }; + } + return FakePiRpcConnection.prototype.request.call(this, record); +}; +const gatewayRuntime = await new PiLocalAgentDriver(async () => ({ connection: gatewayConnection })).createRuntime(context); +if (gatewayRuntime.isErr()) throw gatewayRuntime.error; +const gatewayResult = await gatewayRuntime.value.run({ + prompt: "inspect", + workspaceRoot: "/tmp/project", + model: "openai/gpt-4o", +}); +assert.equal(gatewayResult.isOk(), true); +assert.deepEqual( + gatewayConnection.requests.find((request) => request.type === "set_model"), + { type: "set_model", provider: "openrouter", modelId: "openai/gpt-4o" }, +); +await gatewayRuntime.value.close(); + +// Stop: Pi is aborted, a Pi that never settles is discarded, and stale events never reach the next turn. +const stalledConnection = new FakePiRpcConnection(); +stalledConnection.request = async function(record: Record): Promise { + if (record.type === "prompt") { + this.requests.push(record); + this.events.push({ type: "agent_start" }); + return undefined; + } + return FakePiRpcConnection.prototype.request.call(this, record); +}; +const stalledRuntime = await new PiLocalAgentDriver(async () => ({ connection: stalledConnection })).createRuntime(context); +if (stalledRuntime.isErr()) throw stalledRuntime.error; +const stop = new AbortController(); +setTimeout(() => stop.abort(), 20); +const stalledResult = await stalledRuntime.value.run({ + prompt: "stop me", + workspaceRoot: "/tmp/project", + signal: stop.signal, +}); +assert.equal(stalledResult.isErr(), true); +if (stalledResult.isErr()) assert.equal(stalledResult.error.code, "PROVIDER_CANCELLED"); +assert.ok(stalledConnection.requests.some((request) => request.type === "abort")); +assert.equal(stalledRuntime.value.isAlive(), false, "a Pi that does not settle after abort is discarded"); + +const preAbortConnection = new FakePiRpcConnection(); +const preAbortRuntime = await new PiLocalAgentDriver(async () => ({ connection: preAbortConnection })).createRuntime(context); +if (preAbortRuntime.isErr()) throw preAbortRuntime.error; +const alreadyStopped = new AbortController(); +alreadyStopped.abort(); +const preAbortResult = await preAbortRuntime.value.run({ + prompt: "never starts", + workspaceRoot: "/tmp/project", + signal: alreadyStopped.signal, +}); +assert.equal(preAbortResult.isErr(), true); +assert.equal(preAbortConnection.requests.some((request) => request.type === "prompt"), false); +await preAbortRuntime.value.close(); + +const staleConnection = new FakePiRpcConnection(); +staleConnection.events.push({ type: "agent_start" }, { type: "agent_settled" }); +const staleRuntime = await new PiLocalAgentDriver(async () => ({ connection: staleConnection })).createRuntime(context); +if (staleRuntime.isErr()) throw staleRuntime.error; +const staleResult = await staleRuntime.value.run({ prompt: "fresh", workspaceRoot: "/tmp/project" }); +if (staleResult.isErr()) throw staleResult.error; +assert.ok(staleResult.value.items.some((event: any) => event.type === "agent_end"), "leftover events from an earlier turn do not settle this one"); +await staleRuntime.value.close(); diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index bfb85cb3a..48672b645 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -1,11 +1,23 @@ -import { join } from "node:path"; -import type { AgentSession, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { AgentProviderExecutionError, AgentProviderProtocolError, AgentProviderUnavailableError, captureAgentProviderResult, } from "./local-agent-errors.js"; +import { localAgentCancelledError } from "./local-agent-cancellation.js"; +import { resolveExecutableCommand } from "./local-agent-command.js"; +import { + PiRpcConnection, + parsePiModelSlug, + piRecordString, + type PiRpcRecord, +} from "./local-agent-pi-rpc.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -14,55 +26,38 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import { - createPiSandboxExtension, - createPiSandboxModeRef, - registerPiSandboxSession, - releasePiSandboxSession, - updatePiSandboxSession, -} from "./local-agent-pi-sandbox.js"; +const require = createRequire(import.meta.url); const PI_READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const; -const PI_WORKSPACE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash"] as const; -const PI_FULL_ACCESS_TOOLS = [...PI_WORKSPACE_TOOLS] as const; +const PI_ALLOWED_TOOLS = [...PI_READ_ONLY_TOOLS, "edit", "write", "bash"] as const; +const PI_TURN_TIMEOUT_MS = 5 * 60_000; +const PI_STATE_POLL_MS = 200; +const PI_ABORT_SETTLE_MS = 2_000; const MAX_PI_EVENTS = 10_000; -export type PiSessionLike = Pick< - AgentSession, - | "sessionId" - | "messages" - | "modelRegistry" - | "prompt" - | "subscribe" - | "setActiveToolsByName" - | "setModel" - | "setThinkingLevel" - | "dispose" ->; +export interface PiRpcRuntimeConnection { + request(record: PiRpcRecord, timeoutMs?: number): Promise; + send(record: PiRpcRecord): Promise; + nextEvent(timeoutMs?: number): Promise; + clearEvents(): void; + isAlive(): boolean; + close(): void; +} -export type PiSessionFactory = ( +export type PiRpcFactory = ( context: LocalAgentRuntimeContext, - input: LocalAgentRunInput, - env?: NodeJS.ProcessEnv, -) => Promise; + env: NodeJS.ProcessEnv, +) => Promise<{ connection: PiRpcRuntimeConnection; cleanup?: () => Promise }>; -export class PiSessionRuntime implements LocalAgentRuntime { +export class PiRpcRuntime implements LocalAgentRuntime { readonly provider = "pi" as const; - private readonly unsubscribe: () => void; - private alive = true; + private sessionFile?: string; private closed = false; - private collectingEvents = false; - private events: unknown[] = []; constructor( - private readonly session: PiSessionLike, - ) { - this.unsubscribe = session.subscribe((event) => { - if (!this.collectingEvents) return; - if (this.events.length >= MAX_PI_EVENTS) this.events.shift(); - this.events.push(event); - }); - } + private readonly connection: PiRpcRuntimeConnection, + private readonly cleanup?: () => Promise, + ) {} async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { return captureAgentProviderResult({ @@ -75,276 +70,319 @@ export class PiSessionRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", retryable: true, - message: "Pi runtime is not running.", + message: "Pi RPC process is not running.", }); } - await callbacks?.onSessionId?.(this.session.sessionId); + + const providerSessionId = await this.ensureSession(input.providerSessionId); + await callbacks?.onSessionId?.(providerSessionId); await this.applyOverrides(input); - this.events = []; - const messageStart = this.session.messages.length; - this.collectingEvents = true; + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run"); + + const events: PiRpcRecord[] = []; + // Events left over from an earlier turn must not settle this one. + this.connection.clearEvents(); try { - await this.session.prompt(input.prompt); - } finally { - this.collectingEvents = false; - } - const currentMessages = this.session.messages.slice(messageStart); - const finalResponse = extractPiFinalResponse({ messages: currentMessages }); - if (!finalResponse) { - const providerError = extractPiProviderError(this.events) || extractPiProviderError(currentMessages); - if (providerError) { - throw new AgentProviderExecutionError({ - code: "PROVIDER_EXECUTION_ERROR", - provider: this.provider, + await this.connection.request({ type: "prompt", message: input.prompt }); + await this.waitForSettlement(events, input.signal); + if (input.signal?.aborted) { + await this.abortTurn(); + throw localAgentCancelledError("pi", "run"); + } + const response = await this.connection.request({ type: "get_last_assistant_text" }); + const finalResponse = piRecordString(response, "text")?.trim(); + if (!finalResponse) { + const providerError = extractPiProviderError(events); + if (providerError) { + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "pi", + operation: "run", + retryable: false, + cause: new Error(providerError), + message: "Pi agent turn failed.", + }); + } + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "pi", operation: "run", retryable: false, - cause: new Error(providerError), - message: "Pi agent turn failed.", + message: "Pi did not return a final assistant response.", }); } - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", + return { provider: this.provider, - operation: "run", - retryable: false, - message: "Pi did not return a final assistant response.", - }); + providerSessionId, + finalResponse, + items: events, + }; + } catch (cause) { + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run", cause); + throw cause; } - return { - provider: this.provider, - providerSessionId: this.session.sessionId, - finalResponse, - items: [...this.events, ...currentMessages], - }; }, }); } async releaseSession(_providerSessionId: string): Promise { - // The runtime is already scoped to one logical Pi session. + // Pi owns durable session files independently of this process. } isAlive(): boolean { - return this.alive && !this.closed; + return !this.closed && this.connection.isAlive(); } async close(): Promise { if (this.closed) return; this.closed = true; - this.alive = false; - this.unsubscribe(); - try { - await releasePiSandboxSession(this.session); - } finally { - this.session.dispose(); + this.connection.close(); + await this.cleanup?.(); + } + + private async ensureSession(providerSessionId?: string): Promise { + if (providerSessionId && this.sessionFile !== providerSessionId) { + // Agents created before Pi ran over RPC stored a bare session id; switch_session would + // silently open an empty session for it instead of failing. + if (!isAbsolute(providerSessionId) || !existsSync(providerSessionId)) { + throw piProtocolError( + "resume_session", + `Pi session ${providerSessionId} is not a session file DevSpace can resume. Start a new agent.`, + ); + } + const result = await this.connection.request({ type: "switch_session", sessionPath: providerSessionId }); + if (recordBoolean(result, "cancelled")) { + throw piProtocolError("resume_session", "A Pi extension cancelled the session switch."); + } + this.sessionFile = undefined; + } else if (!providerSessionId && !this.sessionFile) { + const result = await this.connection.request({ type: "new_session" }); + if (recordBoolean(result, "cancelled")) { + throw piProtocolError("create_session", "A Pi extension cancelled new session creation."); + } + } + if (this.sessionFile) return this.sessionFile; + const state = await this.connection.request({ type: "get_state" }); + const sessionFile = piRecordString(state, "sessionFile"); + if (!sessionFile) { + throw piProtocolError("resolve_session", "Pi get_state returned no persisted session file."); } + this.sessionFile = sessionFile; + return sessionFile; } private async applyOverrides(input: LocalAgentRunInput): Promise { - await updatePiSandboxSession(this.session, input.workspaceRoot, input.writeMode ?? "allowed"); - this.session.setActiveToolsByName([...piToolsForWriteMode(input.writeMode)]); if (input.model) { - const model = resolvePiModel(this.session.modelRegistry, input.model); - if (!model) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - operation: "configure_model", - retryable: false, - message: `Pi model not found: ${input.model}.`, - }); - } - await this.session.setModel(model as never); + const model = await this.resolveModel(input.model); + await this.connection.request({ type: "set_model", provider: model.provider, modelId: model.modelId }); } if (input.effort) { - this.session.setThinkingLevel(input.effort as never); + await this.connection.request({ type: "set_thinking_level", level: input.effort }); + } + } + + private async resolveModel(model: string): Promise<{ provider: string; modelId: string }> { + // Gateway providers have model ids that contain "/", so an exact id wins over provider/model. + const available = await this.connection.request({ type: "get_available_models" }); + const models = recordArray(available, "models"); + const matches = models.filter((entry) => piRecordString(entry, "id") === model); + if (matches.length === 1) { + const provider = piRecordString(matches[0], "provider"); + const modelId = piRecordString(matches[0], "id"); + if (provider && modelId) return { provider, modelId }; + } + const parsed = parsePiModelSlug(model); + if (parsed) return parsed; + throw piProtocolError("configure_model", `Pi model not found or ambiguous: ${model}.`); + } + + /** + * Asks Pi to stop and waits for it to settle. A Pi that does not settle is + * discarded so its late events cannot be mistaken for the next turn's. + */ + private async abortTurn(): Promise { + await this.connection.request({ type: "abort" }, PI_ABORT_SETTLE_MS).catch(() => undefined); + const deadline = Date.now() + PI_ABORT_SETTLE_MS; + try { + while (Date.now() < deadline) { + const event = await this.connection.nextEvent(PI_STATE_POLL_MS); + if (event?.type === "agent_settled") { + this.connection.clearEvents(); + return; + } + } + } catch { + // The connection already failed; closing below is idempotent. + } + this.connection.close(); + } + + private async waitForSettlement(events: PiRpcRecord[], signal?: AbortSignal): Promise { + const deadline = Date.now() + PI_TURN_TIMEOUT_MS; + let sawAgentActivity = false; + for (;;) { + if (signal?.aborted) return; + if (Date.now() >= deadline) { + await this.abortTurn(); + throw piProtocolError("prompt", "Pi did not settle before the provider timeout.", true); + } + const event = await this.connection.nextEvent(PI_STATE_POLL_MS); + if (event) { + if (events.length >= MAX_PI_EVENTS) events.shift(); + events.push(event); + if (event.type === "agent_start" || event.type === "message_start" || event.type === "tool_execution_start") { + sawAgentActivity = true; + } + if (event.type === "extension_ui_request") { + await cancelPiUiRequest(this.connection, event); + continue; + } + if (event.type === "agent_settled") return; + continue; + } + const state = await this.connection.request({ type: "get_state" }, 2_000); + const streaming = recordBoolean(state, "isStreaming"); + const compacting = recordBoolean(state, "isCompacting"); + const pending = recordNumber(state, "pendingMessageCount") ?? 0; + if (!streaming && !compacting && pending === 0 && !sawAgentActivity) return; } } } export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; - readonly idleTimeoutMs = 3 * 60_000; + readonly providerInstanceId = "pi"; + readonly runtimePolicy = { + scope: "agent", + authority: "write_mode", + idleTimeoutMs: 3 * 60_000, + } as const; + readonly capabilities = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "client-boundary" }, + mcp: { supported: false }, + } as const; constructor( - private readonly factory: PiSessionFactory = defaultPiSessionFactory, - private readonly env: NodeJS.ProcessEnv = {}, + private readonly factory: PiRpcFactory = defaultPiRpcFactory, + private readonly env: NodeJS.ProcessEnv = process.env, ) {} - runtimeKey(context: LocalAgentRuntimeContext): string { - return `pi:${context.agentId}`; - } - async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { - const input: LocalAgentRunInput = { - prompt: "", - workspaceRoot: context.workspaceRoot, - providerSessionId: context.providerSessionId, - writeMode: context.writeMode, - model: context.model, - effort: context.effort, - }; - const session = await this.factory(context, input, this.env); - return new PiSessionRuntime(session); + const { connection, cleanup } = await this.factory(context, this.env); + return new PiRpcRuntime(connection, cleanup); }, }); } } -async function defaultPiSessionFactory( +async function defaultPiRpcFactory( context: LocalAgentRuntimeContext, - input: LocalAgentRunInput, - env: NodeJS.ProcessEnv = {}, -): Promise { - const { - AuthStorage, - ModelRegistry, - SessionManager, - DefaultResourceLoader, - createAgentSession, - getAgentDir, - } = await import("@earendil-works/pi-coding-agent"); - // DevSpace's agentDir is the compatibility directory used for instructions; - // Pi keeps its own native auth, model, and session state under getAgentDir(). - const agentDir = getAgentDir(); - const authStorage = AuthStorage.create(join(agentDir, "auth.json")); - const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); - applyPiProviderEnvironment(modelRegistry, env); - const sessionManager = await resolveSessionManager(SessionManager, input.workspaceRoot, input.providerSessionId); - const model = input.model ? resolvePiModel(modelRegistry, input.model) : undefined; - if (input.model && !model) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - agentId: context.agentId, - operation: "configure_model", - retryable: false, - message: `Pi model not found: ${input.model}.`, - }); - } - const modeRef = createPiSandboxModeRef(input.writeMode ?? "allowed"); - const resourceLoader = new DefaultResourceLoader({ - cwd: input.workspaceRoot, - agentDir, - extensionFactories: [createPiSandboxExtension(input.workspaceRoot, modeRef, env)], - }); - let session: PiSessionLike | undefined; + env: NodeJS.ProcessEnv, +): Promise<{ connection: PiRpcRuntimeConnection; cleanup?: () => Promise }> { + const launch = resolvePiCommand(env); + const writeMode = context.writeMode ?? "allowed"; + const sandbox = writeMode === "full_access" + ? undefined + : await materializePiSandboxExtension(context.workspaceRoot, writeMode); + const args = [ + ...launch.args, + "--mode", + "rpc", + "--approve", + ...(writeMode === "full_access" ? [] : ["--extension", sandbox!.path]), + ...(writeMode === "read_only" + ? ["--tools", PI_READ_ONLY_TOOLS.join(",")] + : writeMode === "allowed" + ? ["--tools", PI_ALLOWED_TOOLS.join(",")] + : []), + ]; try { - const result = await createAgentSession({ - cwd: input.workspaceRoot, - agentDir, - authStorage, - modelRegistry, - sessionManager: sessionManager as never, - resourceLoader, - ...(model ? { model: model as never } : {}), - ...(input.effort ? { thinkingLevel: input.effort as never } : {}), - // Keep the full built-in registry available so warm turns can narrow or - // broaden active tools without recreating the session. - tools: [...PI_FULL_ACCESS_TOOLS], - }); - session = result.session; - await registerPiSandboxSession(session, input.workspaceRoot, modeRef, input.writeMode ?? "allowed"); - session.setActiveToolsByName([...piToolsForWriteMode(input.writeMode)]); - return session; + return { + connection: PiRpcConnection.spawn({ + command: launch.command, + args, + cwd: context.workspaceRoot, + env, + }), + ...(sandbox ? { cleanup: sandbox.cleanup } : {}), + }; } catch (error) { - if (session) { - try { - await releasePiSandboxSession(session); - } finally { - session.dispose(); - } - } + await sandbox?.cleanup(); throw error; } } -function applyPiProviderEnvironment( - modelRegistry: ModelRegistry, - env: NodeJS.ProcessEnv, -): void { - const getApiKeyAndHeaders = modelRegistry.getApiKeyAndHeaders.bind(modelRegistry); - const providerEnv = Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); - modelRegistry.getApiKeyAndHeaders = async (model) => { - const auth = await getApiKeyAndHeaders(model); - if (!auth.ok) return auth; - return { - ...auth, - env: { - ...auth.env, - ...providerEnv, - }, - }; - }; -} - -export function piToolsForWriteMode(writeMode: LocalAgentRunInput["writeMode"]): readonly string[] { - switch (writeMode) { - case "read_only": return PI_READ_ONLY_TOOLS; - case "full_access": return PI_FULL_ACCESS_TOOLS; - case "allowed": - case undefined: - return PI_WORKSPACE_TOOLS; +export function resolvePiCommand(env: NodeJS.ProcessEnv): { command: string; args: string[] } { + const configured = env.PI_COMMAND?.trim(); + if (configured) { + const command = resolveExecutableCommand(configured, env); + if (!command) throw new Error(`Pi executable not found: ${configured}`); + return { command, args: [] }; } + const system = resolveExecutableCommand("pi", env); + if (system) return { command: system, args: [] }; + const packageJson = require.resolve("@earendil-works/pi-coding-agent/package.json"); + const cli = join(dirname(packageJson), "dist", "cli.js"); + return { command: process.execPath, args: [cli] }; } -interface PiSessionManagerApi { - create(cwd: string): unknown; - open(path: string): unknown; - list(cwd: string): Promise>; -} - -async function resolveSessionManager( - SessionManager: PiSessionManagerApi, +async function materializePiSandboxExtension( workspaceRoot: string, - providerSessionId: string | undefined, -): Promise { - if (!providerSessionId) return SessionManager.create(workspaceRoot); - const sessions = await SessionManager.list(workspaceRoot); - const match = sessions.find((session) => session.id === providerSessionId); - if (!match) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "pi", - operation: "session", - retryable: false, - message: `Pi session not found: ${providerSessionId}.`, - }); - } - return SessionManager.open(match.path); + writeMode: "read_only" | "allowed", +): Promise<{ path: string; cleanup: () => Promise }> { + const directory = await mkdtemp(join(tmpdir(), "devspace-pi-")); + const extensionPath = join(directory, "devspace-sandbox.ts"); + const jsModule = new URL("./local-agent-pi-sandbox.js", import.meta.url); + const tsModule = new URL("./local-agent-pi-sandbox.ts", import.meta.url); + const moduleUrl = existsSync(fileURLToPath(jsModule)) ? jsModule : tsModule; + const source = [ + `import { createPiSandboxExtension, createPiSandboxModeRef } from ${JSON.stringify(moduleUrl.href)};`, + `export default createPiSandboxExtension(${JSON.stringify(resolve(workspaceRoot))}, createPiSandboxModeRef(${JSON.stringify(writeMode)}));`, + "", + ].join("\n"); + await writeFile(extensionPath, source, "utf8"); + return { + path: extensionPath, + cleanup: () => rm(directory, { recursive: true, force: true }), + }; } -function resolvePiModel(registry: { find(provider: string, modelId: string): unknown; getAll?: () => unknown[] }, reference: string): unknown { - const separator = reference.indexOf("/"); - if (separator !== -1) { - return registry.find(reference.slice(0, separator), reference.slice(separator + 1)); - } - const all = registry.getAll?.() ?? []; - return all.find((model) => asRecord(model)?.id === reference); +async function cancelPiUiRequest(connection: PiRpcRuntimeConnection, event: PiRpcRecord): Promise { + if (typeof event.id !== "string") return; + if (event.method === "notify" || event.method === "setStatus" || event.method === "setWidget" || event.method === "setTitle") return; + await connection.send({ type: "extension_ui_response", id: event.id, cancelled: true }); } export function extractPiFinalResponse(value: unknown): string { - const root = unwrapProviderPayload(value); - const messages = Array.isArray(root) ? root : readArray(root, "messages"); - if (!messages) return ""; + if (!value || typeof value !== "object") return ""; + const root = value as Record; + const data = root.data && typeof root.data === "object" && !Array.isArray(root.data) + ? root.data as Record + : root; + const messages = Array.isArray(data.messages) ? data.messages : [data]; for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = asRecord(messages[index]); - if (!message || message.role !== "assistant") continue; - const content = message.content; + const message = messages[index]; + if (!message || typeof message !== "object") continue; + if ((message as { role?: unknown }).role !== "assistant") continue; + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content.trim(); if (!Array.isArray(content)) continue; const text = content - .map((part) => { - const record = asRecord(part); - return record?.type === "text" && typeof record.text === "string" ? record.text : ""; - }) - .filter(Boolean) + .filter((block): block is { type: string; text: string } => ( + Boolean(block) + && typeof block === "object" + && (block as { type?: unknown }).type === "text" + && typeof (block as { text?: unknown }).text === "string" + )) + .map((block) => block.text) .join("\n\n") .trim(); if (text) return text; @@ -352,35 +390,54 @@ export function extractPiFinalResponse(value: unknown): string { return ""; } -export function extractPiProviderError(value: unknown): string { - const root = unwrapProviderPayload(value); - if (Array.isArray(root)) { - for (let index = root.length - 1; index >= 0; index -= 1) { - const error = extractPiProviderError(root[index]); - if (error) return error; +export function extractPiProviderError(value: unknown): string | undefined { + const records = Array.isArray(value) ? [...value] : [value]; + if (value && typeof value === "object" && !Array.isArray(value)) { + const nested = (value as Record).messages; + if (Array.isArray(nested)) records.push(...nested); + } + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]; + if (!record || typeof record !== "object") continue; + const event = record as Record; + if (event.type === "extension_error" && typeof event.error === "string") return event.error; + if (event.type === "message_end") { + const message = event.message; + if (message && typeof message === "object") { + const error = (message as Record).errorMessage; + if (typeof error === "string" && error) return error; + } + } + if (event.stopReason === "error" && typeof event.errorMessage === "string" && event.errorMessage) { + return event.errorMessage; } - return ""; } - const messages = readArray(root, "messages"); - if (messages) return extractPiProviderError(messages); - const record = asRecord(asRecord(root)?.message ?? root); - if (!record) return ""; - const error = record.errorMessage ?? record.error; - return typeof error === "string" ? error.trim() : ""; + return undefined; +} + +function recordBoolean(value: unknown, key: string): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return (value as Record)[key] === true; } -function unwrapProviderPayload(value: unknown): unknown { - const record = asRecord(value); - return record ? record.data ?? record.result ?? value : value; +function recordNumber(value: unknown, key: string): number | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = (value as Record)[key]; + return typeof result === "number" && Number.isFinite(result) ? result : undefined; } -function readArray(value: unknown, key: string): unknown[] | undefined { - const result = asRecord(value)?.[key]; - return Array.isArray(result) ? result : undefined; +function recordArray(value: unknown, key: string): unknown[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const result = (value as Record)[key]; + return Array.isArray(result) ? result : []; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : undefined; +function piProtocolError(operation: string, message: string, retryable = false): AgentProviderProtocolError { + return new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "pi", + operation, + retryable, + message, + }); } diff --git a/src/local-agent-presentation.test.ts b/src/local-agent-presentation.test.ts index ea1a4d6e6..fca1e3eae 100644 --- a/src/local-agent-presentation.test.ts +++ b/src/local-agent-presentation.test.ts @@ -13,7 +13,8 @@ const record: LocalAgentRecord = { workspaceId: "ws_private", workspaceRoot: "/private/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", model: "gpt-5.4", effort: "high", providerSessionId: "provider_private", @@ -65,9 +66,10 @@ assert.deepEqual(failed, { const catalog: LocalAgentCatalog = { enabled: true, providers: [ - { id: "codex", enabled: true, available: true, usable: true, model: "gpt-5.4", effort: "high" }, + { id: "codex", driver: "codex", enabled: true, available: true, usable: true, model: "gpt-5.4", effort: "high" }, { id: "claude", + driver: "claude", enabled: true, available: false, usable: false, diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 8a7d57db1..f22258b13 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -76,16 +76,17 @@ try { [ "---", "name: custom", - "description: Unsupported custom agent.", - "provider: custom", + "description: Named provider instance.", + "provider: codex-work", "---", "", "Custom body.", "", ].join("\n"), ); - const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); - assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); + const profilesWithNamedInstance = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); + assert.deepEqual(profilesWithNamedInstance.map((profile) => profile.name), ["custom", "reviewer"]); + assert.equal(profilesWithNamedInstance[0]?.provider, "codex-work"); const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { workspaces: { allowedRoots: [workspaceRoot] }, diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index ad99a225c..2c55f824a 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -3,23 +3,12 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import type { ServerConfig } from "./config.js"; - -export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot" | "grok"; - -export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ - "codex", - "claude", - "opencode", - "pi", - "cursor", - "copilot", - "grok", -]; +import type { LocalAgentProviderInstanceId } from "./local-agent-provider.js"; export interface LocalAgentProfile { name: string; description: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; filePath: string; @@ -30,7 +19,7 @@ export interface LocalAgentProfile { export interface LocalAgentProfileSummary { name: string; description: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; } @@ -41,7 +30,6 @@ interface ParsedFrontmatter { } const FRONTMATTER_DELIMITER = "---"; -const PROVIDERS = new Set(LOCAL_AGENT_PROVIDERS); export async function loadLocalAgentProfiles( config: ServerConfig, @@ -154,21 +142,12 @@ function profileFromFrontmatter( }; } -function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { +function readProvider(frontmatter: Record, filePath: string): LocalAgentProviderInstanceId { const provider = readString(frontmatter, "provider"); if (!provider) { throw new Error(`Subagent profile is missing provider: ${filePath}`); } - if (!PROVIDERS.has(provider as LocalAgentProvider)) { - throw new Error( - `Subagent profile provider must be codex, claude, opencode, pi, cursor, copilot, or grok: ${filePath}`, - ); - } - return provider as LocalAgentProvider; -} - -export function isLocalAgentProvider(value: string): value is LocalAgentProvider { - return PROVIDERS.has(value as LocalAgentProvider); + return provider; } function readString(frontmatter: Record, key: string): string | undefined { diff --git a/src/local-agent-provider-registry.ts b/src/local-agent-provider-registry.ts new file mode 100644 index 000000000..60c70ffbb --- /dev/null +++ b/src/local-agent-provider-registry.ts @@ -0,0 +1,68 @@ +import type { + SubagentProviderConfig, + SubagentsConfig, +} from "./local-agent-config.js"; +import { + localAgentProviderEnvironment, + localAgentProviderEnvironmentOverrides, +} from "./local-agent-config.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; +import type { LocalAgentDriver } from "./local-agent-runtime.js"; + +export interface LocalAgentDriverFactoryContext { + instance: SubagentProviderConfig; + env: NodeJS.ProcessEnv; + envOverrides: Record; +} + +export type LocalAgentDriverFactory = ( + context: LocalAgentDriverFactoryContext, +) => LocalAgentDriver; + +export class LocalAgentProviderRegistry { + private readonly factories = new Map(); + + register(kind: LocalAgentDriverKind, factory: LocalAgentDriverFactory): this { + if (this.factories.has(kind)) throw new Error(`Local agent driver already registered: ${kind}`); + this.factories.set(kind, factory); + return this; + } + + create( + instance: SubagentProviderConfig, + subagents: SubagentsConfig | undefined, + inheritedEnv: NodeJS.ProcessEnv, + ): LocalAgentDriver { + const factory = this.factories.get(instance.driver); + if (!factory) throw new Error(`No local agent driver registered for: ${instance.driver}`); + const env = subagents + ? localAgentProviderEnvironment(subagents, instance.id, inheritedEnv) + : inheritedEnv; + const envOverrides = subagents + ? localAgentProviderEnvironmentOverrides(subagents, instance.id) + : {}; + return new ProviderInstanceDriver(instance.id, factory({ instance, env, envOverrides })); + } +} + +class ProviderInstanceDriver implements LocalAgentDriver { + readonly provider: LocalAgentDriverKind; + readonly runtimePolicy: LocalAgentDriver["runtimePolicy"]; + readonly capabilities: LocalAgentDriver["capabilities"]; + + constructor( + readonly providerInstanceId: LocalAgentProviderInstanceId, + private readonly driver: LocalAgentDriver, + ) { + this.provider = driver.provider; + this.runtimePolicy = driver.runtimePolicy; + this.capabilities = driver.capabilities; + } + + createRuntime(context: Parameters[0]) { + return this.driver.createRuntime(context); + } +} diff --git a/src/local-agent-provider.ts b/src/local-agent-provider.ts new file mode 100644 index 000000000..671ad11c6 --- /dev/null +++ b/src/local-agent-provider.ts @@ -0,0 +1,38 @@ +export const LOCAL_AGENT_DRIVER_KINDS = [ + "codex", + "claude", + "opencode", + "pi", + "acp", +] as const; + +export const LEGACY_ACP_DRIVER_KINDS = ["cursor", "copilot", "grok"] as const; +export const LOCAL_AGENT_DEFAULT_PROVIDER_IDS = [ + "codex", + "claude", + "opencode", + "pi", + ...LEGACY_ACP_DRIVER_KINDS, +] as const; + +export type LocalAgentDriverKind = typeof LOCAL_AGENT_DRIVER_KINDS[number]; + +/** User-configured routing identity. Multiple instances may share one driver. */ +export type LocalAgentProviderInstanceId = string; + +const DRIVER_KINDS = new Set(LOCAL_AGENT_DRIVER_KINDS); +const LEGACY_ACP_DRIVERS = new Set(LEGACY_ACP_DRIVER_KINDS); + +export function isLocalAgentDriverKind(value: string): value is LocalAgentDriverKind { + return DRIVER_KINDS.has(value); +} + +export function isLegacyAcpDriverKind(value: string): value is typeof LEGACY_ACP_DRIVER_KINDS[number] { + return LEGACY_ACP_DRIVERS.has(value); +} + +export function defaultDriverForProviderId(value: string): LocalAgentDriverKind | undefined { + if (isLocalAgentDriverKind(value)) return value; + if (isLegacyAcpDriverKind(value)) return "acp"; + return undefined; +} diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index a03150240..399c1833a 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -12,7 +12,7 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000; const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 60_000; @@ -209,7 +209,7 @@ export class LocalAgentRuntimePool { private async discardRuntime( entry: RuntimeEntry, - provider: LocalAgentProvider, + provider: LocalAgentDriverKind, reason: string, ): Promise { try { @@ -255,7 +255,7 @@ export class LocalAgentRuntimePool { driver: LocalAgentDriver, context: LocalAgentRuntimeContext, ): Promise> { - const key = driver.runtimeKey(context); + const key = runtimePoolKey(driver, context); while (true) { const existing = this.entries.get(key); if (existing && !existing.closing) { @@ -311,8 +311,8 @@ export class LocalAgentRuntimePool { entry = { key, driver, - idleTimeoutMs: driver.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, - sessionIdleTimeoutMs: this.sessionIdleTimeoutMs, + idleTimeoutMs: driver.runtimePolicy.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + sessionIdleTimeoutMs: driver.runtimePolicy.sessionIdleTimeoutMs ?? this.sessionIdleTimeoutMs, createPromise, activeRuns: 0, lastUsedAt: this.now(), @@ -496,6 +496,35 @@ export class LocalAgentRuntimePool { } } +function runtimePoolKey(driver: LocalAgentDriver, context: LocalAgentRuntimeContext): string { + const policy = driver.runtimePolicy; + const parts: Array = [ + context.providerInstanceId, + policy.scope, + ]; + switch (policy.scope) { + case "instance": + break; + case "workspace": + parts.push(context.workspaceRoot); + break; + case "agent": + parts.push(context.agentId); + break; + } + switch (policy.authority ?? "none") { + case "none": + break; + case "write_mode": + parts.push(context.writeMode ?? "allowed"); + break; + case "full_access_boundary": + parts.push(context.writeMode === "full_access" ? "full_access" : "restricted"); + break; + } + return JSON.stringify(parts); +} + function poolClosedError( driver: LocalAgentDriver, context: LocalAgentRuntimeContext, diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index da2364005..23d8393da 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -15,8 +15,17 @@ import type { LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; +const TEST_CAPABILITIES = { + sessions: { resume: true, close: false }, + turns: { interrupt: true }, + configuration: { modelOverride: true, effortOverride: true }, + permissions: { enforcement: "native" }, + mcp: { supported: false }, +} as const; + const context: LocalAgentRuntimeContext = { agentId: "agt_test", + providerInstanceId: "codex", provider: "codex", workspaceRoot: "/tmp/project", }; @@ -92,9 +101,10 @@ class FakeRuntime implements LocalAgentRuntime { const runtime = new FakeRuntime(); let createCount = 0; const driver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "shared", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => { createCount += 1; await Promise.resolve(); @@ -132,9 +142,10 @@ const sessionPool = new LocalAgentRuntimePool({ sessionIdleTimeoutMs: 10, }); const sessionDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "session-runtime", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(sessionRuntime), }; await sessionPool.run(sessionDriver, context, input); @@ -160,9 +171,10 @@ const shutdownReleasePool = new LocalAgentRuntimePool({ sessionIdleTimeoutMs: 10, }); const shutdownReleaseDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "shutdown-release-runtime", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(shutdownReleaseRuntime), }; await shutdownReleasePool.run(shutdownReleaseDriver, context, input); @@ -209,8 +221,10 @@ class CleanupFailureRuntime extends FakeRuntime { const cleanupPool = new LocalAgentRuntimePool(); const cleanupRuntime = new CleanupFailureRuntime(); const cleanupDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "cleanup-runtime", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(cleanupRuntime), }; const cleanupFailure = await cleanupPool.run(cleanupDriver, context, input); @@ -225,8 +239,10 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider let attempts = 0; const recoveryPool = new LocalAgentRuntimePool(); const recoveryDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "dead-runtime-recovery", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(attempts++ === 0 ? deadRuntime : replacementRuntime), }; @@ -249,8 +265,10 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider const completedTurnRuntime = new ClosingAfterTurnRuntime(); completedTurnPool = new LocalAgentRuntimePool(); const completedTurnDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "completed-turn-during-close", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(completedTurnRuntime), }; @@ -265,8 +283,10 @@ const creating = new Promise const raceRuntime = new FakeRuntime(); const racePool = new LocalAgentRuntimePool(); const raceDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "creation-race", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, createRuntime: () => creating, }; const pendingRun = racePool.run(raceDriver, context, input); @@ -296,8 +316,10 @@ if (afterClose.isErr()) { const recoveryRuntime = new FakeRuntime(); const creationPool = new LocalAgentRuntimePool(); const creationDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - runtimeKey: () => "creation-failure", + runtimePolicy: { scope: "instance" }, + capabilities: TEST_CAPABILITIES, async createRuntime() { createAttempts += 1; if (createAttempts === 1) { @@ -349,9 +371,10 @@ if (afterClose.isErr()) { sessionIdleTimeoutMs: 10, }); const releaseDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", - idleTimeoutMs: Number.POSITIVE_INFINITY, - runtimeKey: () => "release-failure", + runtimePolicy: { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY }, + capabilities: TEST_CAPABILITIES, createRuntime: async () => Result.ok(releaseRuntime), }; @@ -371,6 +394,61 @@ if (afterClose.isErr()) { await releasePool.close(); } +{ + let creates = 0; + const policyPool = new LocalAgentRuntimePool(); + const policyDriver = (providerInstanceId: string, runtimePolicy: LocalAgentDriver["runtimePolicy"]): LocalAgentDriver => ({ + providerInstanceId, + provider: "codex", + runtimePolicy, + capabilities: TEST_CAPABILITIES, + createRuntime: async () => { + creates += 1; + return Result.ok(new FakeRuntime()); + }, + }); + + const instanceDriver = policyDriver("codex-work", { scope: "instance" }); + await policyPool.run(instanceDriver, { ...context, providerInstanceId: "codex-work" }, input); + await policyPool.run(instanceDriver, { + ...context, + agentId: "agt_other", + providerInstanceId: "codex-work", + }, input); + assert.equal(creates, 1, "instance-scoped runtimes are shared across agents in one provider instance"); + + const personalDriver = policyDriver("codex-personal", { scope: "instance" }); + await policyPool.run(personalDriver, { + ...context, + providerInstanceId: "codex-personal", + }, input); + assert.equal(creates, 2, "provider instances never share a runtime even when they use the same driver"); + + const claudeDriver = policyDriver("claude-work", { + scope: "agent", + authority: "full_access_boundary", + }); + const claudeContext = { + ...context, + agentId: "agt_claude", + providerInstanceId: "claude-work", + }; + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "read_only" }, input); + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "allowed" }, input); + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "full_access" }, input); + assert.equal(creates, 4, "full-access authority is isolated while restricted modes share one agent runtime"); + + const acpDriver = policyDriver("cursor", { scope: "workspace", authority: "write_mode" }); + const acpContext = { ...context, providerInstanceId: "cursor", workspaceRoot: "/tmp/a" }; + await policyPool.run(acpDriver, { ...acpContext, writeMode: "allowed" }, input); + await policyPool.run(acpDriver, { ...acpContext, agentId: "agt_acp_2", writeMode: "allowed" }, input); + await policyPool.run(acpDriver, { ...acpContext, writeMode: "read_only" }, input); + await policyPool.run(acpDriver, { ...acpContext, workspaceRoot: "/tmp/b", writeMode: "allowed" }, input); + assert.equal(creates, 7, "workspace and write mode are explicit ACP runtime isolation dimensions"); + + await policyPool.close(); +} + function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; return result.value; diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index ecc40f545..e73e3e7d6 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -1,6 +1,9 @@ import type { Result } from "better-result"; import type { AgentProviderError } from "./local-agent-errors.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; @@ -13,10 +16,11 @@ export interface LocalAgentRunInput { effort?: string; modelOverrideRequested?: boolean; effortOverrideRequested?: boolean; + signal?: AbortSignal; } export interface LocalAgentRunResult { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; providerSessionId: string | null; finalResponse: string; items: unknown[]; @@ -33,7 +37,8 @@ export interface LocalAgentRunCallbacks { export interface LocalAgentRuntimeContext { agentId: string; - provider: LocalAgentProvider; + providerInstanceId: LocalAgentProviderInstanceId; + provider: LocalAgentDriverKind; workspaceRoot: string; providerSessionId?: string; writeMode?: LocalAgentWriteMode; @@ -42,13 +47,31 @@ export interface LocalAgentRuntimeContext { agentDir?: string; } +export type LocalAgentRuntimeScope = "instance" | "workspace" | "agent"; +export type LocalAgentRuntimeAuthority = "none" | "write_mode" | "full_access_boundary"; + +export interface LocalAgentRuntimePolicy { + scope: LocalAgentRuntimeScope; + authority?: LocalAgentRuntimeAuthority; + idleTimeoutMs?: number; + sessionIdleTimeoutMs?: number; +} + +export interface LocalAgentCapabilities { + sessions: { resume: boolean; close: boolean }; + turns: { interrupt: boolean }; + configuration: { modelOverride: boolean; effortOverride: boolean }; + permissions: { enforcement: "native" | "client-boundary" | "unsupported" }; + mcp: { supported: boolean }; +} + /** * A runtime is deliberately disposable. Nothing from this interface is * persisted; the provider session ID in LocalAgentStore is the continuation * identity used when a later runtime is created. */ export interface LocalAgentRuntime { - readonly provider: LocalAgentProvider; + readonly provider: LocalAgentDriverKind; run( input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks, @@ -59,8 +82,9 @@ export interface LocalAgentRuntime { } export interface LocalAgentDriver { - readonly provider: LocalAgentProvider; - runtimeKey(context: LocalAgentRuntimeContext): string; + readonly providerInstanceId: LocalAgentProviderInstanceId; + readonly provider: LocalAgentDriverKind; + readonly runtimePolicy: LocalAgentRuntimePolicy; + readonly capabilities: LocalAgentCapabilities; createRuntime(context: LocalAgentRuntimeContext): Promise>; - readonly idleTimeoutMs?: number; } diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index e29bd319b..aa5d48456 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -16,7 +16,8 @@ try { workspaceId: "ws_1", workspaceRoot: join(root, "project"), profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex-work", + driver: "codex", model: "gpt-5.4", effort: "high", }); @@ -25,6 +26,8 @@ try { assert.equal(created.status, "starting"); assert.equal(store.getById(created.id)?.effort, "high"); assert.equal(store.getById(created.id)?.profileName, "reviewer"); + assert.equal(store.getById(created.id)?.providerInstanceId, "codex-work"); + assert.equal(store.getById(created.id)?.driver, "codex"); assert.equal(store.getById(created.id.slice(0, 7)), undefined); const updated = store.update(created.id, { @@ -130,7 +133,8 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot workspaceId: "ws_1", workspaceRoot: join(root, "project"), profileName: "explorer", - provider: "claude", + providerInstanceId: "claude", + driver: "claude", }); assert.deepEqual( @@ -180,7 +184,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot "agt_legacy", join(root, "legacy-project"), "reviewer", - "codex", + "cursor", "high", "error", "old error", @@ -194,6 +198,13 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot const legacyRecord = upgradedStore.getById("agt_legacy"); assert.equal(legacyRecord?.error, "old error"); assert.equal(legacyRecord?.effort, "high"); + assert.equal(legacyRecord?.providerInstanceId, "cursor"); + assert.equal(legacyRecord?.driver, "acp"); + const inspect = new Database(databasePath(legacyStateDir), { readonly: true }); + const driverColumn = (inspect.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ name: string; notnull: number }>) + .find((column) => column.name === "driver"); + inspect.close(); + assert.equal(driverColumn?.notnull, 1, "migrated driver column rejects NULL like a fresh schema"); assert.equal(legacyRecord?.errorCode, undefined); assert.equal(legacyRecord?.errorRetryable, undefined); const upgradedRecord = upgradedStore.update("agt_legacy", { diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 3aab64da6..83336aa19 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { AgentStoreError, isProgrammerDefect } from "./local-agent-errors.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "stopped"; @@ -12,7 +13,8 @@ export interface LocalAgentRecord { workspaceId?: string; workspaceRoot: string; profileName: string; - provider: string; + providerInstanceId: string; + driver: LocalAgentDriverKind; model?: string; effort?: string; providerSessionId?: string; @@ -29,7 +31,8 @@ export interface CreateLocalAgentRecordInput { workspaceId?: string; workspaceRoot: string; profileName: string; - provider: string; + providerInstanceId: string; + driver: LocalAgentDriverKind; model?: string; effort?: string; } @@ -78,7 +81,8 @@ interface LocalAgentRow { workspace_id: string | null; workspace_root: string; profile_name: string; - provider: string; + provider_instance_id: string; + driver: LocalAgentDriverKind; model: string | null; effort: string | null; provider_session_id: string | null; @@ -157,7 +161,8 @@ export class LocalAgentStore { workspaceId: input.workspaceId, workspaceRoot: resolve(input.workspaceRoot), profileName: input.profileName, - provider: input.provider, + providerInstanceId: input.providerInstanceId, + driver: input.driver, model: input.model, effort: input.effort, status: "starting", @@ -172,20 +177,22 @@ export class LocalAgentStore { workspace_id, workspace_root, profile_name, - provider, + provider_instance_id, + driver, model, effort, status, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( record.id, record.workspaceId ?? null, record.workspaceRoot, record.profileName, - record.provider, + record.providerInstanceId, + record.driver, record.model ?? null, record.effort ?? null, record.status, @@ -239,7 +246,8 @@ export class LocalAgentStore { workspace_id = ?, workspace_root = ?, profile_name = ?, - provider = ?, + provider_instance_id = ?, + driver = ?, model = ?, effort = ?, provider_session_id = ?, @@ -255,7 +263,8 @@ export class LocalAgentStore { updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, - updated.provider, + updated.providerInstanceId, + updated.driver, updated.model ?? null, updated.effort ?? null, updated.providerSessionId ?? null, @@ -463,7 +472,8 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { workspaceId: row.workspace_id ?? undefined, workspaceRoot: row.workspace_root, profileName: row.profile_name, - provider: row.provider, + providerInstanceId: row.provider_instance_id, + driver: row.driver, model: row.model ?? undefined, effort: row.effort ?? undefined, providerSessionId: row.provider_session_id ?? undefined, diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index fde0c000e..ff7a197cb 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -106,7 +106,8 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { } { - const target = resolveLocalAgentTarget("opencode", profiles); + const providers = [{ id: "opencode", driver: "opencode", enabled: true }] as const; + const target = resolveLocalAgentTarget("opencode", profiles, undefined, undefined, providers); assert.equal(target?.kind, "provider"); assert.equal(target?.name, "opencode"); assert.equal(target?.provider, "opencode"); @@ -115,7 +116,8 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { } { - const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep"); + const providers = [{ id: "opencode", driver: "opencode", enabled: true }] as const; + const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep", providers); assert.equal(target?.kind, "provider"); assert.equal(target?.model, "kimi-k2"); assert.equal(target?.effort, "deep"); @@ -124,6 +126,7 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { { const providerDefaults = [{ id: "codex", + driver: "codex", enabled: true, model: "gpt-default", effort: "medium", @@ -139,6 +142,19 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { assert.equal(overridden?.effort, "xhigh"); } +{ + const providerInstances = [{ + id: "codex-work", + driver: "codex", + enabled: true, + model: "gpt-work", + }] as const; + const target = resolveLocalAgentTarget("codex-work", profiles, undefined, undefined, providerInstances); + assert.equal(target?.kind, "provider"); + assert.equal(target?.provider, "codex-work"); + assert.equal(target?.model, "gpt-work"); +} + { const target = resolveLocalAgentTarget("claude", profiles); assert.equal(target?.kind, "profile"); diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index 367b04761..d3794810c 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -1,9 +1,5 @@ -import { - isLocalAgentProvider, - LOCAL_AGENT_PROVIDERS, - type LocalAgentProfile, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; +import type { LocalAgentProviderInstanceId } from "./local-agent-provider.js"; import type { SubagentProviderConfig } from "./local-agent-config.js"; export interface ParsedLocalAgentRunArgs { @@ -24,15 +20,15 @@ export type LocalAgentTarget = | { kind: "profile"; name: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; profile: LocalAgentProfile; } | { kind: "provider"; - name: LocalAgentProvider; - provider: LocalAgentProvider; + name: LocalAgentProviderInstanceId; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; }; @@ -143,8 +139,8 @@ export function resolveLocalAgentTarget( }; } - if (isLocalAgentProvider(target)) { - const providerConfig = providerConfigs.find((entry) => entry.id === target); + const providerConfig = providerConfigs.find((entry) => entry.id === target); + if (providerConfig) { return { kind: "provider", name: target, diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 90a8ade25..20f71310e 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -52,6 +52,8 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 6, name: "local-agent-effort-rename" }, { version: 7, name: "workspace-recovery-state" }, { version: 8, name: "local-agent-turns" }, + { version: 9, name: "local-agent-provider-instances" }, + { version: 10, name: "local-agent-generic-acp-driver" }, ]); } finally { database.close(); diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index 9a5d02e8c..48049943e 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -22,8 +22,8 @@ assert.deepEqual( enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true }, - { id: "claude", enabled: true }, + { id: "codex", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: true }, ], }, ); @@ -34,30 +34,34 @@ const configured = { providers: [ { id: "codex" as const, + driver: "codex" as const, enabled: true, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude" as const, enabled: true, model: "sonnet" }, + { id: "claude" as const, driver: "claude" as const, enabled: true, model: "sonnet" }, + { id: "codex-work", driver: "codex" as const, enabled: true, model: "gpt-work" }, ], }; assert.deepEqual( - updateOnboardingSubagentsConfig(configured, ["claude"]), + updateOnboardingSubagentsConfig(configured, ["claude", "codex-work"]), { enabled: true, instructions: "preload", providers: [ { id: "codex", + driver: "codex", enabled: false, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude", enabled: true, model: "sonnet" }, + { id: "claude", driver: "claude", enabled: true, model: "sonnet" }, + { id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }, ], }, ); diff --git a/src/onboarding.ts b/src/onboarding.ts index 193dd57dd..fb2f0c2ec 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -1,8 +1,10 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + defaultDriverForProviderId, + LOCAL_AGENT_DEFAULT_PROVIDER_IDS, + isLocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export const SUBAGENT_SKILL_INSTALL_COMMAND = "npx skills add Waishnav/devspace --skill subagents --global"; @@ -31,21 +33,29 @@ export function usesCodingAgents(usage: OnboardingUsage): boolean { export function updateOnboardingSubagentsConfig( current: SubagentsConfig, - selectedProviders: readonly LocalAgentProvider[], + selectedProviders: readonly LocalAgentProviderInstanceId[], ): SubagentsConfig { const selected = new Set(selectedProviders); + const defaultIds = new Set(LOCAL_AGENT_DEFAULT_PROVIDER_IDS); + const customInstances = current.providers + .filter((provider) => !defaultIds.has(provider.id)) + .map((provider) => ({ ...provider, enabled: selected.has(provider.id) })); return { enabled: true, instructions: current.instructions, - providers: LOCAL_AGENT_PROVIDERS + providers: [ + ...LOCAL_AGENT_DEFAULT_PROVIDER_IDS .filter((id) => selected.has(id) || current.providers.some((provider) => provider.id === id)) .map((id) => { const existing = current.providers.find((provider) => provider.id === id); return { ...existing, id, + driver: existing?.driver ?? defaultDriverForProviderId(id)!, enabled: selected.has(id), }; }), + ...customInstances, + ], }; } diff --git a/src/server.test.ts b/src/server.test.ts index 59bcc5d37..d93515a61 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -12,6 +12,7 @@ import { loadConfig, type ServerConfig, type ToolMode } from "./config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { buildLocalAgentProviderStatuses } from "./local-agent-catalog.js"; import type { SubagentsConfig } from "./local-agent-config.js"; +import { isLocalAgentDriverKind } from "./local-agent-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer, createServer } from "./server.js"; @@ -415,8 +416,8 @@ test("open_workspace omits providers disabled by configuration", async (t) => { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true }, - { id: "claude", enabled: false }, + { id: "codex", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: false }, ], }, }); @@ -445,7 +446,7 @@ test("open_workspace preloads subagent instructions when configured", async (t) subagents: { enabled: true, instructions: "preload", - providers: [{ id: "codex", enabled: true }], + providers: [{ id: "codex", driver: "codex", enabled: true }], }, }); @@ -775,10 +776,12 @@ async function fixture( subagents: options.subagents ?? { enabled: true, instructions: "on-demand", - providers: initialProviderAvailability.map((provider) => ({ - id: provider.name, - enabled: true, - })), + providers: initialProviderAvailability.map((provider) => { + if (!isLocalAgentDriverKind(provider.name)) { + throw new Error(`Test provider instance requires explicit config: ${provider.name}`); + } + return { id: provider.name, driver: provider.name, enabled: true }; + }), }, } : modeConfig;