mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 11:21:06 +00:00
Compare commits
1 Commits
v0.103.0
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39e511c97 |
2
.github/workflows/conventional-prs.yml
vendored
2
.github/workflows/conventional-prs.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
# Config reference: https://github.com/amannn/action-semantic-pull-request#configuration
|
||||
- uses: amannn/action-semantic-pull-request@2252a14e810aa9b85c2d560bd7070c802206ecf6 # v6.1.2
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.103.0"
|
||||
".": "0.101.0"
|
||||
}
|
||||
|
||||
35
AGENTS.md
35
AGENTS.md
@@ -1,11 +1,7 @@
|
||||
# Agent guide
|
||||
|
||||
An ACP-compatible coding agent powered by the Claude Agent SDK. Source in `src/`,
|
||||
TypeScript, ESM, Node >= 22. `src/v1/` holds the v1 agent (`ClaudeAcpAgent` in
|
||||
`src/v1/acp-agent.ts`), `src/v2/` the v2 agent, and the rest of `src/` the modules
|
||||
both share. The package exports only `src/lib.ts`. ESLint rejects imports between `src/v1/` and
|
||||
`src/v2/`, and from shared modules into either agent. The entry points (`index.ts`, `lib.ts`,
|
||||
`serve.ts`, `binary-entry.ts`) are exempt.
|
||||
TypeScript, ESM, Node >= 22.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -44,9 +40,9 @@ opening a PR.
|
||||
|
||||
- Before coding, read the code the change plugs into and find the module that
|
||||
owns the state or concept. Build on that module, not beside it.
|
||||
- Don't add new features or components to `src/v1/acp-agent.ts`. If no existing module fits
|
||||
the new code cleanly, create a dedicated one that owns the feature's state and logic,
|
||||
and keep the change in `src/v1/acp-agent.ts` to the calls that wire it in.
|
||||
- Don't add new features or components to `acp-agent.ts`. If no existing module fits the new
|
||||
code cleanly, create a dedicated one that owns the feature's state and logic,
|
||||
and keep the change in `acp-agent.ts` to the calls that wire it in.
|
||||
- Prefer extending an existing mechanism over adding a new one, even if it takes
|
||||
a small refactor (moving a helper, generalizing a function). Add a parallel
|
||||
path, special case or flag only when the existing structure can't support the
|
||||
@@ -62,14 +58,11 @@ opening a PR.
|
||||
- If a result is meant to match another request's output, reuse that request's
|
||||
logic.
|
||||
- The adapter serves ACP v1 and v2. A change that is not meant for one protocol
|
||||
version only must work correctly in both. `ClaudeAcpAgent` serves v1, and
|
||||
`src/v2/` serves v2; they share modules (the SDK mapping, tool call
|
||||
rendering, permissions, config options). Put a change meant for both versions
|
||||
in the shared module, or make it in both cores.
|
||||
- A session runs one turn at a time. On v1, "Claude is working" means
|
||||
`startTurn`'s check (an unsettled turn in `turnQueue`, or `foregroundWork` for
|
||||
the session); on v2, an open turn of the v2 session. Don't write a
|
||||
narrower one. Anything that sends `session/update` must go
|
||||
version only must work correctly in both. `src/v2/` converts v2 requests to v1
|
||||
and reuses the v1 logic; keep v2-only differences in the conversion.
|
||||
- A session runs one turn at a time. "Claude is working" means `startTurn`'s
|
||||
check (an unsettled turn in `turnQueue`, or `foregroundWork` for the session);
|
||||
don't write a narrower one. Anything that sends `session/update` must go
|
||||
through the turn queue or be refused while the session is busy, or its output
|
||||
lands inside another turn. Keep the session marked busy across awaited network
|
||||
calls.
|
||||
@@ -85,13 +78,9 @@ opening a PR.
|
||||
|
||||
## Testing guidelines
|
||||
|
||||
- Tests live in `src/tests/`: `v1/` holds the tests that drive the v1 agent,
|
||||
`v2/` the tests of the v2 agent, and the top level the tests of shared
|
||||
modules, with `helpers.ts`, the scenario harness (`acp-scenarios/`) and
|
||||
`fixtures/`. Before adding a test, search `src/tests/` for existing tests of
|
||||
the same API and scenario, and add to their file and `describe` block. Use
|
||||
the helpers in `helpers.ts` and `v1/session-doubles.ts` instead of copying
|
||||
setup.
|
||||
- Before adding a test, search `src/tests/` for existing tests of the same API
|
||||
and scenario, and add to their file and `describe` block. Use the helpers in
|
||||
`helpers.ts` and `session-doubles.ts` instead of copying setup.
|
||||
- Don't add cases that take the same code path. Tests that differ only in input
|
||||
and expected output belong in one `it.each`. Don't repeat another test's
|
||||
assertions; assert only what this test is about.
|
||||
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,58 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [0.103.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.102.0...v0.103.0) (2026-11-10)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* deep imports of `dist/` files fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`, and `lib.ts` exports only `serveAcp`, `AUTH_STATUS_UPDATE_METHOD` and their types.
|
||||
|
||||
### Features
|
||||
|
||||
* serve ACP v2 with a direct v2 agent and remove the v1 bridge ([#1560](https://github.com/agentclientprotocol/claude-agent-acp/issues/1560)) ([cb285cd](https://github.com/agentclientprotocol/claude-agent-acp/commit/cb285cd2857924c07038c129cc836da007e2737f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* give subagent checklists their own plan ID ([#1558](https://github.com/agentclientprotocol/claude-agent-acp/issues/1558)) ([2f7e386](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f7e386f3600658d905906f3b37301120b07605f))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* export only lib.ts, with serveAcp and the authStatus wire names ([#1566](https://github.com/agentclientprotocol/claude-agent-acp/issues/1566)) ([566e265](https://github.com/agentclientprotocol/claude-agent-acp/commit/566e2652a9da5a31dab57a23ea1b06e9fff2ead8))
|
||||
|
||||
## [0.102.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.1...v0.102.0) (2026-11-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* list workflow phases and agents on the workflow card ([#1552](https://github.com/agentclientprotocol/claude-agent-acp/issues/1552)) ([176ae06](https://github.com/agentclientprotocol/claude-agent-acp/commit/176ae0634c2ef7cb0549339f4b9824884574b2f0))
|
||||
* send the checklist as a plan operation and remove plans on reset ([#1554](https://github.com/agentclientprotocol/claude-agent-acp/issues/1554)) ([766fd14](https://github.com/agentclientprotocol/claude-agent-acp/commit/766fd142f60bda2f434d55baaa8e72939f215800))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* report v2 background tasks without a tool call as their own tool call ([#1556](https://github.com/agentclientprotocol/claude-agent-acp/issues/1556)) ([d098fb0](https://github.com/agentclientprotocol/claude-agent-acp/commit/d098fb08d54f521d5056d920ebcc5a2656334083))
|
||||
* update to @anthropic-ai/claude-agent-sdk 0.3.321 ([#1557](https://github.com/agentclientprotocol/claude-agent-acp/issues/1557)) ([855447c](https://github.com/agentclientprotocol/claude-agent-acp/commit/855447c7cd41aa566bdf361d6d59e6a5b038fe6e))
|
||||
|
||||
## [0.101.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.0...v0.101.1) (2026-11-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* keep autonomous turn tasks open across owed trailing idles ([#1455](https://github.com/agentclientprotocol/claude-agent-acp/issues/1455)) ([1ee2422](https://github.com/agentclientprotocol/claude-agent-acp/commit/1ee242293401372aa71ea5765e8dfd0679146dc9))
|
||||
* list a resumed Fable 5.1 session's model as `fable` ([#1548](https://github.com/agentclientprotocol/claude-agent-acp/issues/1548)) ([4cceb06](https://github.com/agentclientprotocol/claude-agent-acp/commit/4cceb06b31525ccaaecfd999ff9b21d401b2659a))
|
||||
* list a subagent's background Bash commands ([#1540](https://github.com/agentclientprotocol/claude-agent-acp/issues/1540)) ([6597626](https://github.com/agentclientprotocol/claude-agent-acp/commit/6597626d86ddb8c10ea9f1091d921806752d045a))
|
||||
* list Monitor calls in the session's background work ([#1538](https://github.com/agentclientprotocol/claude-agent-acp/issues/1538)) ([ba16234](https://github.com/agentclientprotocol/claude-agent-acp/commit/ba162346c9ccb4be51f13a3271198351d7822e78))
|
||||
* relay a rate_limit_event that arrives before the turn's first assistant frame ([#1534](https://github.com/agentclientprotocol/claude-agent-acp/issues/1534)) ([54392e1](https://github.com/agentclientprotocol/claude-agent-acp/commit/54392e144d307d5dd5b7732ae14634d4b2678bd5))
|
||||
* render workflow tool cards and progress correctly in ACP v2 ([#1550](https://github.com/agentclientprotocol/claude-agent-acp/issues/1550)) ([30600ba](https://github.com/agentclientprotocol/claude-agent-acp/commit/30600ba348e5f5c3755a50f5797fd24645e0ff48))
|
||||
* replay a compaction summary by its flag, not its framing ([#1536](https://github.com/agentclientprotocol/claude-agent-acp/issues/1536)) ([aa0c123](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa0c1234b5c52a63d96c46c9826631bd0f428062))
|
||||
* report setup-token logins as logged in ([#1376](https://github.com/agentclientprotocol/claude-agent-acp/issues/1376)) ([7d1ef76](https://github.com/agentclientprotocol/claude-agent-acp/commit/7d1ef76c1d32e390f8ff8824087df3003825a170))
|
||||
* start transcript notices on their own line for clients without session.notices ([#1362](https://github.com/agentclientprotocol/claude-agent-acp/issues/1362)) ([47b64c0](https://github.com/agentclientprotocol/claude-agent-acp/commit/47b64c04de83145c4d42033c4408c811736c9c40))
|
||||
* **test:** assert the blockquoted MCP startup transcript line ([#1543](https://github.com/agentclientprotocol/claude-agent-acp/issues/1543)) ([3b22586](https://github.com/agentclientprotocol/claude-agent-acp/commit/3b22586cf63cef06f1029a8418a9abb3b2cc3236))
|
||||
* update to @agentclientprotocol/sdk 2.5.0 ([#1549](https://github.com/agentclientprotocol/claude-agent-acp/issues/1549)) ([ef65ab3](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef65ab31b405a5d339133c980eb606a70a2d3624))
|
||||
* update to @anthropic-ai/claude-agent-sdk 0.3.319 ([#1545](https://github.com/agentclientprotocol/claude-agent-acp/issues/1545)) ([a58293f](https://github.com/agentclientprotocol/claude-agent-acp/commit/a58293f4c9aa559a500d61834ef97d2a23b3722b))
|
||||
|
||||
## [0.101.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.100.0...v0.101.0) (2026-11-05)
|
||||
|
||||
|
||||
|
||||
@@ -142,11 +142,7 @@ On v2 the adapter serves the session baseline (`session/new`, `session/list`, `s
|
||||
with replay, `session/close`, `session/prompt`, `session/cancel`), `session/delete`,
|
||||
`session/fork`, additional directories, config options (the mode is one), plans, notices,
|
||||
compaction, display terminals for commands, permission requests, elicitation, providers, auth, and
|
||||
[subagents](#subagent-sessions). Steering is v1 only for now.
|
||||
|
||||
v2 is served by the v2 agent in `src/v2/`, which runs straight on Claude
|
||||
Code instead of translating to v1. A v2 client that declares JetBrains AIR
|
||||
(`_meta.jetbrains.air`) also gets the AIR extensions that ACP v2 does not cover.
|
||||
[subagents](#subagent-sessions). Steering and the AIR extensions are v1 only for now.
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ A page with more sessions after it has a `nextCursor`, as ACP defines.
|
||||
A client that does not follow `nextCursor` sees only the newest 1000 sessions of the directory.
|
||||
An unknown cursor gives an `invalidParams` error.
|
||||
|
||||
`src/tests/v1/acp-scenarios.test.ts` compares the traffic of a plain client and of Zed with recordings of origin/main.
|
||||
`src/tests/acp-scenarios.test.ts` compares the traffic of a plain client and of Zed with recordings of origin/main.
|
||||
|
||||
## Negotiation
|
||||
|
||||
@@ -145,15 +145,6 @@ The agent list does not depend on the capabilities that AIR declares, except `se
|
||||
An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
|
||||
The response to a client that is not AIR has no `_meta.jetbrains` key.
|
||||
|
||||
### ACP v2
|
||||
|
||||
The v2 core (`src/v2/`) serves an AIR client only the extensions that ACP v2 does not cover.
|
||||
Its `initialize` response lists `agentFileChangeReport` and `recommendedValue`.
|
||||
It also serves the [permission presentation](#permission-presentation), the [question custom answers](#question-custom-answers), the [predicted follow-up prompt](#predicted-follow-up-prompt), and [load replay end](#load-replay-end).
|
||||
The [agent file-change report](#agent-file-change-report) goes out at the end of the turn that serves the prompt, before the turn's idle `state_update`, because a v2 `session/prompt` is answered when Claude Code takes the prompt in.
|
||||
ACP v2 covers subagents, background work, file plans, patches, compaction, the system prompt, and turn failures itself, so the v2 core serves no `nativeSubagentSessions`, `backgroundSubagents`, `asyncTasks`, `planFile`, `diffPatch`, `contextCompaction`, `customInstructions`, or `sessionFailure`.
|
||||
It does not serve the [tool call contract](#tool-call-contract) yet.
|
||||
|
||||
### Capabilities
|
||||
|
||||
| Capability | Advertised | What the adapter does when the client declares it | Section |
|
||||
@@ -557,7 +548,7 @@ AIR puts the hooks into `_meta.claudeCode.options.settings.hooks` of `session/ne
|
||||
```
|
||||
|
||||
- The settings go to the flag settings tier of that session only. Other sessions and the settings files do not change.
|
||||
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/v1/session-hooks.test.ts` checks this with the Claude Code binary.
|
||||
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/session-hooks.test.ts` checks this with the Claude Code binary.
|
||||
- `CLAUDE_MODEL_CONFIG` stays in effect (see [Model configuration](model-configuration.md#precedence)).
|
||||
- A `session/load` with different settings recreates the query, so it applies the new hooks.
|
||||
- Claude asks for no hook trust step.
|
||||
@@ -1071,8 +1062,7 @@ The adapter ignores a malformed request and a repeated request id.
|
||||
|
||||
### Report
|
||||
|
||||
The adapter sends one `session_info_update` before the `PromptResponse`.
|
||||
On ACP v2 it comes before the idle `state_update` of the turn that serves the prompt (see [ACP v2](#acp-v2)):
|
||||
The adapter sends one `session_info_update` before the `PromptResponse`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1648,8 +1638,7 @@ The hold protects a client that stops listening at the prompt response.
|
||||
### Behavior with the capability
|
||||
|
||||
AIR declares `backgroundSubagents` in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`.
|
||||
The capability applies to ACP v1 only, and the agent does not advertise it to ACP v2.
|
||||
ACP v2 has no hold: the v2 agent answers `session/prompt` when Claude Code takes the prompt in, lists a background subagent in its parent session's `background` while it works, and reports each subagent's work in the state of its child session (see [ACP v2](#acp-v2)).
|
||||
The capability applies to ACP v1 only. ACP v2 keeps the hold, and the agent does not advertise the capability to ACP v2.
|
||||
|
||||
- The turn ends at its result with the stop reason of the result, usually `end_turn`.
|
||||
- The subagent updates, permission requests and elicitations go out after the prompt response.
|
||||
|
||||
@@ -4,23 +4,6 @@ import tsparser from "@typescript-eslint/parser";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import globals from "globals";
|
||||
|
||||
/**
|
||||
* Rules that reject a module specifier matching `regex` in static imports and
|
||||
* re-exports (type-only ones too), `import()` calls and `import("...")` types.
|
||||
*/
|
||||
function protocolBoundary(regex, message) {
|
||||
// esquery regex literals end at the first `/`, so spell it `\x2F`.
|
||||
const literal = `/${regex.replaceAll("/", "\\x2F")}/`;
|
||||
return {
|
||||
"no-restricted-imports": ["error", { patterns: [{ regex, message }] }],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{ selector: `ImportExpression[source.value=${literal}]`, message },
|
||||
{ selector: `TSImportType[argument.literal.value=${literal}]`, message },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
|
||||
@@ -61,38 +44,5 @@ export default [
|
||||
curly: ["error", "all"],
|
||||
},
|
||||
},
|
||||
// `src/v2/` must not import the v1 agent.
|
||||
{
|
||||
files: ["src/v2/**/*.ts"],
|
||||
rules: protocolBoundary(
|
||||
"^(\\.\\./)+v1/",
|
||||
"src/v2/ must not import the v1 agent. Move what both versions need into a shared module in src/.",
|
||||
),
|
||||
},
|
||||
// `src/v1/` must not import the v2 agent.
|
||||
{
|
||||
files: ["src/v1/**/*.ts"],
|
||||
rules: protocolBoundary(
|
||||
"^(\\.\\./)+v2/",
|
||||
"src/v1/ must not import the v2 agent. Move what both versions need into a shared module in src/.",
|
||||
),
|
||||
},
|
||||
// Shared modules must not import either agent. The entry points wire the agents up.
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
ignores: [
|
||||
"src/v1/**",
|
||||
"src/v2/**",
|
||||
"src/tests/**",
|
||||
"src/index.ts",
|
||||
"src/lib.ts",
|
||||
"src/serve.ts",
|
||||
"src/binary-entry.ts",
|
||||
],
|
||||
rules: protocolBoundary(
|
||||
"^(\\./|(\\.\\./)+)(v1|v2)/",
|
||||
"Shared modules must not import src/v1/ or src/v2/. Move the code into the shared module, or pass it in from the agent.",
|
||||
),
|
||||
},
|
||||
prettierConfig,
|
||||
];
|
||||
|
||||
204
package-lock.json
generated
204
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.103.0",
|
||||
"version": "0.101.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.103.0",
|
||||
"version": "0.101.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.5.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.321",
|
||||
"@agentclientprotocol/sdk": "2.4.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.319",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
@@ -18,12 +18,12 @@
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "1.7.0",
|
||||
"@anthropic-ai/sdk": "1.6.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.72.2",
|
||||
"@typescript-eslint/parser": "8.72.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.72.1",
|
||||
"@typescript-eslint/parser": "8.72.1",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.14.0",
|
||||
@@ -38,9 +38,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.5.0.tgz",
|
||||
"integrity": "sha512-MFlXeIuwN6nZSK45SAsSnVjbLgQ2r4fnthoF1lQoGJP3PdaPRpOZeCF2Dds8S4cN8yZP5QbxB1bkJccGskmcYw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.4.0.tgz",
|
||||
"integrity": "sha512-7qJ8nnyj7WesC5ve185ioTpB2/svQoeGV4HA4nLEhgDUrcmBe54/08VcXc/oL4p14tDbxxECZzdYmUYQ+4l+Wg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/server": "^2.4.0",
|
||||
@@ -53,22 +53,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.321",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.321.tgz",
|
||||
"integrity": "sha512-Smer/h/jQLTtT+l4sxaAUwZq7wif/9RzzuqltS5Gy+kAFo/iP89H7JHa8avhVT0xGkPBvBD/1Bbdg3pcArhQcw==",
|
||||
"version": "0.3.319",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.319.tgz",
|
||||
"integrity": "sha512-7X+sh4uZv/u4OL4I4cQvMr9N3NM6FDiuyk829hFylgbrnNwVyiGElWEDWGuO26zwMIxmgpKdPoDILoFIHh1uwA==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.14.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.321",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.321",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.321",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.321",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.321",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.321",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.321",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.321"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.319",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.319",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.319",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.319",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.319",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.319",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.319",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.319"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -77,9 +77,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.321",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.321.tgz",
|
||||
"integrity": "sha512-r718mW98hvRbmCa0TtSgImHkgyPUjPXSHOsORrehn2a8Z0FU1A2RkX9gCjY7EzZgL0RNbZXsBbkuab//kUjzJw==",
|
||||
"version": "0.3.319",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.319.tgz",
|
||||
"integrity": "sha512-rNiAbMbRhgOM2CELsDTIq7q++sFVIZukTkgeswcncGbcX8STIgtf0XaZzKdRXtKK+v9ez+mQn8P4pb2Ka17wGA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -90,9 +90,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.321",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.321.tgz",
|
||||
"integrity": "sha512-C08vEnnDylWDaFprx/B1B13RZK3la1HOqR986igkZSwhY//Yxsk5eRI+gz7sxMfhcLZX2NS8PftCOiXr3S74/Q==",
|
||||
"version": "0.3.319",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.319.tgz",
|
||||
"integrity": "sha512-jM730Fl+cFotDnjF9cStIf+Xe57YdfDB3TXaSVVv8uVAXW4xoWFwIj2eDvmsciEEe5UMkKzjVqXu9kR21OrXng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -103,9 +103,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.321",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.321.tgz",
|
||||
"integrity": "sha512-vdk9tON09SnsSh/6ULzYTBgsKRat6FyIaqnh4snYl3uy3qX0jpv5UuFUWgS5tvQ1ewI0qYF2k+D78El4gi7/iQ==",
|
||||
"version": "0.3.319",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.319.tgz",
|
||||
"integrity": "sha512-g2tpMsHLMQahI4bk5CIcMHAB/RwzxoPGmL5Q5G5MDir5WTrl71vulWbtLQISUPm/yFPRG97tdzZnCa5Uq1v/dw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -119,9 +119,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.321",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.321.tgz",
|
||||
"integrity": "sha512-+8zFXMpJT2r+0j4E1v9BgRf+Qjtmmp4IG/3mDLT6jB6iA+9UvG9RLex+cqct09d+7xP2mDP8kL4cD1WtKUFX1w==",
|
||||
"version": "0.3.319",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.319.tgz",
|
||||
"integrity": "sha512-8KDZQJAS2CEb5aGAvuXkpyHMc9TOM/zYTw2iKmbbVi4im0atw93EtJPapYyp0g/lQI/Xk8jMYdWU6Zlv9EWjLw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -135,9 +135,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.321",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.321.tgz",
|
||||
"integrity": "sha512-i/Y8Ps4q68SRwWB54yFXyItBwn5YHIOYY4R1gla6p4UwF/pM+xxrHycv+yecbC7oU30ShuBYKWN9mRIZ/BCMqw==",
|
||||
"version": "0.3.319",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.319.tgz",
|
||||
"integrity": "sha512-uBAZA8x0hd5gVifyL5Lo73dVmi9xZeJ6jro2NO0al6yyWaw/6UL9xbsVh9lOhLFOmYIdSbQ2j4zAfnvG/t5F3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -151,9 +151,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.321",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.321.tgz",
|
||||
"integrity": "sha512-ils3IGsNKIDiURuOgSWorLsjMOlKcMdXQCQyaygEycdtPFItPoIBIYDVjoC1I0XV4tlfXXCHfUvNrs23g4HKNw==",
|
||||
"version": "0.3.319",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.319.tgz",
|
||||
"integrity": "sha512-XkKYj3c1jVFjLATrZr4YTsmS5AIb5s2iphF9X670EcVQqDCCGIKKTVNLLg9mg1e7D+2xX8FU0XNlOJatWfpBeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -167,9 +167,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.321",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.321.tgz",
|
||||
"integrity": "sha512-v6/8ikUcYRSYhH5PSGcHq8L6+DKRq/M4I3BzFckis6kMh3degvSuUD1u3+M/oSJ/bcSOA8tXbUgTTwKrlOgvwQ==",
|
||||
"version": "0.3.319",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.319.tgz",
|
||||
"integrity": "sha512-YoP0lYHJCziENFCotHQq9qd/gaJrwUNkAcnZyVjXxg1OPmtKuyw31aG60s4cWXRDXXGm/m1siNSQaGGfAYBvrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -180,9 +180,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.321",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.321.tgz",
|
||||
"integrity": "sha512-9i7ziCR+Wg47cRhYONHnUpTGxUgxfy/+J3N3/4F8YGqz7WLSNH3hpGRj6uJzNK4EMyEhFsca532uD2we0ZMckQ==",
|
||||
"version": "0.3.319",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.319.tgz",
|
||||
"integrity": "sha512-UC27x+8gfCS2OjNGJWrueP0gLOOKsLEsJ/kg0p6aa8GToZcJYjJ8KeAapHHRYMUAIB6Sarqe49Itm5P1wswCcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -193,9 +193,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.7.0.tgz",
|
||||
"integrity": "sha512-Z+0c1iHQozlt4ymMHapPXEXfFnSbPEWGnSKbkavt+qeyaMQ3Sd6QNxfQj7vyhHYqiqYadC9shrXub8ISKf7xjQ==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.6.0.tgz",
|
||||
"integrity": "sha512-XqiO/46n8S6So8jQ/6/uNCkHntyW6QHHs1gZ+hlwTx1fWDr929g4VmwIXfdMVk7SuBWNdQvAlwyi1G3t6SEIuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
@@ -913,17 +913,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.2.tgz",
|
||||
"integrity": "sha512-222PKFXKihmFpi/pS5njoBgdAqsPhj2/GBmFbrNw8Tq8gEG9Sv+kSlKFy4u2q+mJv9ZzJgM2Gkv/bUzRZwZw5g==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz",
|
||||
"integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.2",
|
||||
"@typescript-eslint/type-utils": "8.72.2",
|
||||
"@typescript-eslint/utils": "8.72.2",
|
||||
"@typescript-eslint/visitor-keys": "8.72.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.1",
|
||||
"@typescript-eslint/type-utils": "8.72.1",
|
||||
"@typescript-eslint/utils": "8.72.1",
|
||||
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -936,22 +936,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.72.2",
|
||||
"@typescript-eslint/parser": "^8.72.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.2.tgz",
|
||||
"integrity": "sha512-llP+T1YuJOLlxEciP6OyQGtz5SMxyn+9LA2BvZw5RmToGVa1y4beEDVQL6Gje7T5WFYh19WREjDCN9Ev4gm5AA==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz",
|
||||
"integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.72.2",
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/typescript-estree": "8.72.2",
|
||||
"@typescript-eslint/visitor-keys": "8.72.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.1",
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/typescript-estree": "8.72.1",
|
||||
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -967,14 +967,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.2.tgz",
|
||||
"integrity": "sha512-6y5dLTZdHhI8Eh3yIQ/bfQhRKwn5e+TpJj64o18Cngc0iW/BCrVvrr6zGYqXPnYH31u6YXAYiFh0+qxX5mybJw==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
|
||||
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.72.2",
|
||||
"@typescript-eslint/types": "^8.72.2",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.72.1",
|
||||
"@typescript-eslint/types": "^8.72.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -989,14 +989,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.2.tgz",
|
||||
"integrity": "sha512-iIoMJ6/8c1/QpFNyFjU0d1YzPco4MsZjQw0k3V+vfzNWAFmUmvhlyMoYEoYYDQZXc+J6Gi1X5XcxlzW2taIIGQ==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
|
||||
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/visitor-keys": "8.72.2"
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/visitor-keys": "8.72.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1007,9 +1007,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.2.tgz",
|
||||
"integrity": "sha512-+h3iCgZXuTNtoLXcnnjP12ZUHLMqoUcIHPfQrFEXTUqvb9dF8QK9b5nv5VtSo5fLkdHuks8ehbTzZj3rImAm9w==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
|
||||
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1024,15 +1024,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.2.tgz",
|
||||
"integrity": "sha512-STfkR38qIdhfkLpgtXnUjAzsNy4xouqiBO0lrVc/Wpk9Kem5/aHXUx1J8dGC16zpdYN1+yc2ILQ28er1kXX7ag==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz",
|
||||
"integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/typescript-estree": "8.72.2",
|
||||
"@typescript-eslint/utils": "8.72.2",
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/typescript-estree": "8.72.1",
|
||||
"@typescript-eslint/utils": "8.72.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -1049,9 +1049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.2.tgz",
|
||||
"integrity": "sha512-Xfn37OLZx03+PCU7qWx9DTv2FCRh1E6fk2+wONeNY15HXIwtcwuveaSwVpGAUptkNmPQegRxW5JyRel9T7wrog==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
|
||||
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1063,16 +1063,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.2.tgz",
|
||||
"integrity": "sha512-pQEGqIK/cqEvKivVWaFLYpe7XYejFxfuAOi4mu1oAWFvIo36aRHI9esRFTUxcIy/z8N482RJyH+LpZB0ZIU7Ow==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
|
||||
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.72.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.72.2",
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/visitor-keys": "8.72.2",
|
||||
"@typescript-eslint/project-service": "8.72.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.72.1",
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -1091,16 +1091,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.2.tgz",
|
||||
"integrity": "sha512-hv3eRYbpPbbpezuax1EfGrzpa3C4ySgNZkPUkLOWOAjU8HsOKowEDAr8C7nsNl1C+SBSKDpvno6VKHnlhvp9iA==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
|
||||
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.72.2",
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/typescript-estree": "8.72.2"
|
||||
"@typescript-eslint/scope-manager": "8.72.1",
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/typescript-estree": "8.72.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1115,13 +1115,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.2.tgz",
|
||||
"integrity": "sha512-NAGrjn0Pitjqzq/GkgMC4kBYm233PQ8GffyQ9+kTA3AlNQYEzOf25EIM02BAHZv5FRJSpHpJk+OGUxVkOB93GQ==",
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
|
||||
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
14
package.json
14
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.103.0",
|
||||
"version": "0.101.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -16,7 +16,7 @@
|
||||
"types": "./dist/lib.d.ts",
|
||||
"import": "./dist/lib.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
"./*": "./*"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
@@ -68,18 +68,18 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.5.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.321",
|
||||
"@agentclientprotocol/sdk": "2.4.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.319",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "1.7.0",
|
||||
"@anthropic-ai/sdk": "1.6.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.72.2",
|
||||
"@typescript-eslint/parser": "8.72.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.72.1",
|
||||
"@typescript-eslint/parser": "8.72.1",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.14.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,36 @@
|
||||
import type {
|
||||
import {
|
||||
AgentContext,
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
methods,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification } from "./acp-subagents.js";
|
||||
import type { AcpPermissionRequest } from "./permissions/presentation.js";
|
||||
import { AcpSessionNotification, asSdkSessionNotification } from "./acp-subagents.js";
|
||||
import { type AcpPermissionRequest } from "./permissions/presentation.js";
|
||||
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
|
||||
|
||||
/**
|
||||
* Logger interface for customizing logging output
|
||||
*/
|
||||
export interface Logger {
|
||||
log: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
|
||||
warn?: (...args: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-facing surface the agent calls back into. This is the subset of ACP
|
||||
* client methods the agent actually uses, expressed as a narrow interface so
|
||||
* tests can supply lightweight mocks. In the v1 agent it is backed by
|
||||
* `ClientConnection` (`v1/acp-agent.ts`) over the SDK's typed `AgentContext`.
|
||||
* The v2 session output passes one that only takes `sessionUpdate`
|
||||
* (`v2/stream.ts`).
|
||||
* tests can supply lightweight mocks. In production it is backed by
|
||||
* {@link ClientConnection} over the SDK's typed `AgentContext`.
|
||||
*/
|
||||
export interface AcpClient {
|
||||
sessionUpdate(params: AcpSessionNotification): Promise<void>;
|
||||
@@ -41,3 +54,105 @@ export interface AcpClient {
|
||||
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
|
||||
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
|
||||
* valid for the entire connection lifetime, so it is captured once at
|
||||
* construction.
|
||||
*/
|
||||
export class ClientConnection implements AcpClient {
|
||||
constructor(private readonly ctx: AgentContext) {}
|
||||
|
||||
sessionUpdate(params: AcpSessionNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
|
||||
}
|
||||
|
||||
requestPermission(
|
||||
params: RequestPermissionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
return this.ctx.request(methods.client.session.requestPermission, params, {
|
||||
cancellationSignal: signal,
|
||||
stopWaitingOnCancel: true,
|
||||
});
|
||||
}
|
||||
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return this.ctx.request(methods.client.fs.readTextFile, params);
|
||||
}
|
||||
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return this.ctx.request(methods.client.fs.writeTextFile, params);
|
||||
}
|
||||
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
return this.ctx.request(methods.client.elicitation.create, params, {
|
||||
cancellationSignal: signal,
|
||||
stopWaitingOnCancel: true,
|
||||
});
|
||||
}
|
||||
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.elicitation.complete, params);
|
||||
}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
return this.ctx.notify(method, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client that the agent talks to. For an AIR client, every session update
|
||||
* passes the {@link ChangedMetaFilter} last, after the native subagent routing,
|
||||
* so a `tool_call_update` carries only the `_meta` keys that changed. ACP
|
||||
* merges only the top-level tool call fields, not the `_meta` keys. So another
|
||||
* client gets the full `_meta` on each update.
|
||||
*/
|
||||
export class ChangedMetaClient implements AcpClient {
|
||||
private readonly filter = new ChangedMetaFilter();
|
||||
|
||||
constructor(
|
||||
private readonly inner: AcpClient,
|
||||
private readonly airClient: () => boolean,
|
||||
) {}
|
||||
|
||||
async sessionUpdate(params: AcpSessionNotification): Promise<void> {
|
||||
if (!this.airClient()) return this.inner.sessionUpdate(params);
|
||||
const update = this.filter.apply(params.update as SessionNotification["update"]);
|
||||
if (update) await this.inner.sessionUpdate({ ...params, update } as AcpSessionNotification);
|
||||
}
|
||||
|
||||
requestPermission(
|
||||
params: AcpPermissionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
return this.inner.requestPermission(params, signal);
|
||||
}
|
||||
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return this.inner.readTextFile(params);
|
||||
}
|
||||
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return this.inner.writeTextFile(params);
|
||||
}
|
||||
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
return this.inner.createElicitation(params, signal);
|
||||
}
|
||||
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.inner.completeElicitation(params);
|
||||
}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
return this.inner.extNotification(method, params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
|
||||
* - `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
|
||||
* as revised): `subagent_update` and `session_message`, which the SDK types.
|
||||
* A client that declares `clientCapabilities.subagents` and is not AIR gets
|
||||
* it.
|
||||
* it. (An ACP v2 client gets it without declaring anything, since subagents
|
||||
* are part of v2's baseline.)
|
||||
* - `air`: the earlier draft of that RFD, which AIR implements:
|
||||
* `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it,
|
||||
* whichever capability it declares (`docs/air-extensions.md`).
|
||||
|
||||
@@ -105,28 +105,6 @@ type TaskProgress = TaskIdentity & {
|
||||
output_file?: unknown;
|
||||
};
|
||||
|
||||
/** The display fields of a task progress report, shared by AIR and v2 cards. */
|
||||
export type TaskProgressFields = {
|
||||
description?: string;
|
||||
summary?: string;
|
||||
lastToolName?: string;
|
||||
usage?: { totalTokens: number; toolUses: number; durationMs: number };
|
||||
};
|
||||
|
||||
/** Reads the display fields supplied by a task progress report. */
|
||||
export function taskProgressFields(message: TaskProgress): TaskProgressFields {
|
||||
const usage = taskUsage(message.usage);
|
||||
const description = nonBlankString(message.description);
|
||||
const summary = nonBlankString(message.summary);
|
||||
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
|
||||
return {
|
||||
...(description ? { description } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(lastToolName ? { lastToolName } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type BackgroundTaskLevel = TaskIdentity & {
|
||||
taskType?: unknown;
|
||||
task_type?: unknown;
|
||||
@@ -289,8 +267,15 @@ export class AsyncTaskRuntime {
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = taskUsage(message.usage);
|
||||
const description = nonBlankString(message.description);
|
||||
const summary = nonBlankString(message.summary);
|
||||
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
|
||||
const progress = {
|
||||
...taskProgressFields(message),
|
||||
...(description ? { description } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(lastToolName ? { lastToolName } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(outputChanged && task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId !== previousToolCallId && task.toolCallId
|
||||
? { toolCallId: task.toolCallId }
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { AuthMethod } from "@agentclientprotocol/sdk/unstable";
|
||||
import { agentCommand } from "./agent-command.js";
|
||||
import { shouldHideClaudeAuth } from "./hide-claude-auth.js";
|
||||
|
||||
/**
|
||||
* The terminal login methods the agent advertises, in v1 form.
|
||||
*
|
||||
* - `terminal`: the client runs `args` against the agent's own command
|
||||
* (`auth.terminal` in v1, an `auth.terminal` object in v2).
|
||||
* - `metaTerminal`: the client reads `_meta["terminal-auth"]`, which names
|
||||
* the command too.
|
||||
*
|
||||
* Without either, the client cannot run a login, and the list is empty. On a
|
||||
* remote machine (SSH, `NO_BROWSER`, `CLAUDE_CODE_REMOTE`, as the SDK's own
|
||||
* isRemote check) the OAuth redirect to localhost cannot reach a browser, and
|
||||
* `claude auth login` falls back to a manual flow that does not work over ACP,
|
||||
* so the one method runs the TUI's `claude /login` instead. The Claude
|
||||
* subscription method is left out when `shouldHideClaudeAuth()` says so.
|
||||
*/
|
||||
export function terminalAuthMethods({
|
||||
terminal,
|
||||
metaTerminal,
|
||||
}: {
|
||||
terminal: boolean;
|
||||
metaTerminal: boolean;
|
||||
}): AuthMethod[] {
|
||||
if (!terminal && !metaTerminal) return [];
|
||||
const withMeta = (method: AuthMethod & { args: string[] }, label: string): AuthMethod => {
|
||||
if (!metaTerminal) return method;
|
||||
const agent = agentCommand();
|
||||
return {
|
||||
...method,
|
||||
_meta: {
|
||||
"terminal-auth": { command: agent.command, args: [...agent.args, ...method.args], label },
|
||||
},
|
||||
};
|
||||
};
|
||||
const isRemote = !!(
|
||||
process.env.NO_BROWSER ||
|
||||
process.env.SSH_CONNECTION ||
|
||||
process.env.SSH_CLIENT ||
|
||||
process.env.SSH_TTY ||
|
||||
process.env.CLAUDE_CODE_REMOTE
|
||||
);
|
||||
if (isRemote) {
|
||||
if (shouldHideClaudeAuth()) return [];
|
||||
return [
|
||||
withMeta(
|
||||
{
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
type: "terminal",
|
||||
args: ["--cli"],
|
||||
},
|
||||
"Claude Login",
|
||||
),
|
||||
];
|
||||
}
|
||||
const methods: AuthMethod[] = [];
|
||||
if (!shouldHideClaudeAuth()) {
|
||||
methods.push(
|
||||
withMeta(
|
||||
{
|
||||
description: "Use Claude subscription ",
|
||||
name: "Claude Subscription",
|
||||
id: "claude-ai-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
},
|
||||
"Claude Login",
|
||||
),
|
||||
);
|
||||
}
|
||||
methods.push(
|
||||
withMeta(
|
||||
{
|
||||
description: "Use Anthropic Console (API usage billing)",
|
||||
name: "Anthropic Console",
|
||||
id: "console-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
},
|
||||
"Anthropic Console Login",
|
||||
),
|
||||
);
|
||||
return methods;
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* The connection's `authStatus` state, shared by the v1 core and the v2
|
||||
* core: the last identity it reported, the `claude auth status --json` probe,
|
||||
* and the `_auth/status_update` pushes (see `auth-status.ts` for when each
|
||||
* core reads the identity).
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
type AccountInfo,
|
||||
filterEscalatingDefaultMode,
|
||||
type Options,
|
||||
resolveSettings,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
type AuthStatus,
|
||||
type AuthStatusKind,
|
||||
fromAccountInfo,
|
||||
fromCliStatus,
|
||||
mergeAuthStatus,
|
||||
sameAuthStatus,
|
||||
} from "./auth-status.js";
|
||||
import { claudeCliPath } from "./claude-cli.js";
|
||||
import type { SettingsManager } from "./settings.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type ReporterLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
|
||||
/** The settings a probe runs under: a session's cwd, its key helper, and its launch env. */
|
||||
export type AuthStatusSettings = Pick<SettingsManager, "getCwd" | "getApiKeyHelper">;
|
||||
|
||||
export interface AuthStatusReporterOptions {
|
||||
/** Sends `_auth/status_update`. */
|
||||
push: (status: AuthStatus) => Promise<void>;
|
||||
logger: ReporterLogger;
|
||||
/** An ACP gateway login is active: the gateway, not the CLI store, is the identity. */
|
||||
gatewayLogin: () => boolean;
|
||||
/** A `providers/set` override is active: a session account describes its route. */
|
||||
providerOverride: () => boolean;
|
||||
/** A completed probe read `kind`. */
|
||||
onProbe?: (kind: AuthStatusKind) => void;
|
||||
}
|
||||
|
||||
export class AuthStatusReporter {
|
||||
/** Last identity reported to the client. Undefined means "not determined yet". */
|
||||
current?: AuthStatus;
|
||||
/**
|
||||
* Counts auth-affecting events (a login, a logout, a new settings context).
|
||||
* A probe records it at start and publishes only if it has not moved, so a
|
||||
* slow read can never overwrite a newer login or logout.
|
||||
*/
|
||||
epoch = 0;
|
||||
private settings?: {
|
||||
manager: AuthStatusSettings;
|
||||
override?: Settings;
|
||||
env?: Options["env"];
|
||||
};
|
||||
private helper?: string;
|
||||
/** In-flight `claude auth status --json` probe, shared by every caller so
|
||||
* a concurrent `initialize` and start-of-prompt read never spawn two CLI
|
||||
* processes. */
|
||||
private probing: Promise<AuthStatus | undefined> | null = null;
|
||||
/** Keeps the "session account told us nothing" note to one line per process
|
||||
* instead of one per session. */
|
||||
private loggedUninformativeAccount = false;
|
||||
/** Same, for the "client provider override active" note. */
|
||||
private loggedOverriddenAccount = false;
|
||||
/** Same, for the "the CLI probe timed out" warning: a wedged CLI stays wedged
|
||||
* and would otherwise warn once per probe, i.e. once per user prompt. */
|
||||
private loggedProbeTimeout = false;
|
||||
|
||||
constructor(private readonly options: AuthStatusReporterOptions) {}
|
||||
|
||||
/**
|
||||
* Stores `next` and pushes it to the client.
|
||||
*
|
||||
* A push goes out only when the payload changed. The identity is read on many
|
||||
* occasions — each session create, each guarded turn, the start of each user
|
||||
* prompt — and almost all of them see the same login. Clients replace their
|
||||
* whole state on each update and tolerate duplicates, so a repeat is
|
||||
* harmless, but it is also pure noise; {@link sameAuthStatus} drops it.
|
||||
*
|
||||
* What the agent owes is truth — a stale or uninformative source must not
|
||||
* reach here at all (see the epoch check in the probe and the guards at
|
||||
* session create).
|
||||
*/
|
||||
set(next: AuthStatus | undefined): void {
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
if (sameAuthStatus(this.current, next)) {
|
||||
return;
|
||||
}
|
||||
this.current = next;
|
||||
// ACP notifications get no reply, and clients that do not know the method
|
||||
// drop it silently — send unconditionally and never fail a caller on it.
|
||||
void Promise.resolve()
|
||||
.then(() => this.options.push(next))
|
||||
.catch((error) => {
|
||||
this.options.logger.error("Failed to send _auth/status_update:", error);
|
||||
});
|
||||
}
|
||||
|
||||
/** A login or a logout: any probe already running read the world before it. */
|
||||
changed(): void {
|
||||
this.epoch += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes run under these settings from now on. A new context (cwd, key
|
||||
* helper, or launch env) starts a fresh probe in it.
|
||||
*/
|
||||
useSettings(
|
||||
manager: AuthStatusSettings,
|
||||
settingsOverride?: Settings,
|
||||
launchEnv?: Options["env"],
|
||||
) {
|
||||
const helper = manager.getApiKeyHelper(settingsOverride);
|
||||
const changed =
|
||||
manager.getCwd() !== (this.settings?.manager.getCwd() ?? process.cwd()) ||
|
||||
helper !== this.helper ||
|
||||
JSON.stringify(launchEnv) !== JSON.stringify(this.settings?.env);
|
||||
this.settings = { manager, override: settingsOverride, env: launchEnv };
|
||||
this.helper = helper;
|
||||
if (changed) {
|
||||
// The epoch bump discards any probe that read the old context, and the
|
||||
// session account may carry no identity signal (an apiKeyHelper), so
|
||||
// only a probe in the new context can classify it.
|
||||
this.epoch += 1;
|
||||
void this.probe({ fresh: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the identity behind a session's `AccountInfo`.
|
||||
*
|
||||
* `AccountInfo` is richer than the CLI probe (it is what the live query
|
||||
* actually authenticates with). The v1 core calls it before the
|
||||
* `--hide-claude-auth` guard may refuse the session or the turn: that flag
|
||||
* blocks the *use* of a subscription, it does not make the state secret, and
|
||||
* a refusal is when the client most needs to know the account it was
|
||||
* refused for.
|
||||
*
|
||||
* Two cases skip it:
|
||||
*
|
||||
* - ACP gateway *authentication*: the gateway, not this account, is the
|
||||
* agent-owned identity — already reported as `kind: "gateway"` by the login.
|
||||
* - A client-driven provider override (`providers/set`): the session then
|
||||
* routes through the client's endpoint and `AccountInfo` describes that
|
||||
* route (e.g. `apiProvider: "gateway"`), which is NOT agent-owned state.
|
||||
* `authStatus` reports the agent's own login only, so the CLI probe —
|
||||
* which reads the credential store the override never touches — stays
|
||||
* authoritative.
|
||||
*
|
||||
* An account with no identity signal (e.g. `{apiProvider: "firstParty"}`
|
||||
* under an apiKeyHelper) means "nothing to add", not "logged out" — keep
|
||||
* what the CLI probe already established instead of overwriting it.
|
||||
*/
|
||||
publishSessionAccount(account: AccountInfo | undefined, apiKeyHelper?: string): void {
|
||||
if (this.options.providerOverride()) {
|
||||
if (!this.loggedOverriddenAccount) {
|
||||
this.loggedOverriddenAccount = true;
|
||||
this.options.logger.log(
|
||||
"[authStatus] client provider override active; keeping agent-owned probe state",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.options.gatewayLogin()) {
|
||||
return;
|
||||
}
|
||||
const fromSession = fromAccountInfo(account, apiKeyHelper);
|
||||
if (fromSession) {
|
||||
this.set(fromSession);
|
||||
} else if (!this.loggedUninformativeAccount) {
|
||||
this.loggedUninformativeAccount = true;
|
||||
this.options.logger.log(
|
||||
"[authStatus] session account carries no identity signal; keeping probe",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shares one in-flight `claude auth status --json` run between callers. The
|
||||
* promise is released once settled so a later call re-probes instead of
|
||||
* replaying a stale verdict. `fresh` forces a new run even when one is in
|
||||
* flight — `logout` needs a read that started after the credentials were
|
||||
* cleared. */
|
||||
probe(options?: { fresh?: boolean }): Promise<AuthStatus | undefined> {
|
||||
if (!options?.fresh && this.probing) {
|
||||
return this.probing;
|
||||
}
|
||||
const probe = this.runProbe();
|
||||
this.probing = probe;
|
||||
void probe.finally(() => {
|
||||
if (this.probing === probe) {
|
||||
this.probing = null;
|
||||
}
|
||||
});
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** Never rejects: an unavailable CLI means "not reported", not an error. */
|
||||
private async runProbe(): Promise<AuthStatus | undefined> {
|
||||
const { logger } = this.options;
|
||||
// Monotonicity: a read that started before the connection's latest
|
||||
// auth-affecting event describes a world that no longer exists. Remember
|
||||
// which one this read belongs to and drop the answer if it moved on.
|
||||
const epoch = this.epoch;
|
||||
const context = this.settings;
|
||||
const apiKeyHelper = context?.manager.getApiKeyHelper(context.override);
|
||||
// ACP gateway auth bypasses the CLI credential store entirely, so the
|
||||
// probe would report an identity that is not the one being used. A mere
|
||||
// client provider override (`providers/set`) does NOT skip the probe: it
|
||||
// reroutes traffic without touching the credential store, and the store is
|
||||
// exactly the agent-owned login `authStatus` reports.
|
||||
if (this.options.gatewayLogin()) {
|
||||
return this.current;
|
||||
}
|
||||
let stdout: string;
|
||||
try {
|
||||
const cliPath = await claudeCliPath();
|
||||
const args = ["auth", "status", "--json"];
|
||||
// `--settings` is a global CLI option: after the `auth status` subcommand the CLI rejects
|
||||
// it with "unknown option '--settings'", so it has to come first.
|
||||
if (context?.override) args.unshift("--settings", JSON.stringify(context.override));
|
||||
({ stdout } = await execFileAsync(cliPath, args, {
|
||||
timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
cwd: context?.manager.getCwd() ?? process.cwd(),
|
||||
env: context?.env ? { ...process.env, ...context.env } : process.env,
|
||||
}));
|
||||
} catch (error) {
|
||||
const failed = error as { stdout?: unknown; killed?: boolean; signal?: unknown } | null;
|
||||
// Node killed the child on the timeout. Whatever it printed so far is a
|
||||
// truncated fragment, never valid JSON: report nothing and keep the last
|
||||
// known state, so nothing regresses and no push goes out.
|
||||
if (failed?.killed === true && failed.signal) {
|
||||
if (!this.loggedProbeTimeout) {
|
||||
this.loggedProbeTimeout = true;
|
||||
logger.error(
|
||||
"claude auth status did not answer within 5 s; the identity is not refreshed",
|
||||
);
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
// The logged-out case exits 1 while still printing valid JSON, so the
|
||||
// stdout of a failed exec is parsed just like a successful one.
|
||||
if (typeof failed?.stdout !== "string" || failed.stdout.trim().length === 0) {
|
||||
logger.error(
|
||||
"claude auth status failed:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
stdout = failed.stdout;
|
||||
}
|
||||
let status = fromCliStatus(stdout, apiKeyHelper);
|
||||
if (!context && status?.kind === "api_key" && status.detail === "API key helper") {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: process.cwd() });
|
||||
status = fromCliStatus(stdout, filterEscalatingDefaultMode(resolved).apiKeyHelper);
|
||||
} catch {
|
||||
logger.error("Could not resolve auth source settings");
|
||||
}
|
||||
}
|
||||
if (!status) {
|
||||
logger.error("claude auth status returned unparseable output");
|
||||
return undefined;
|
||||
}
|
||||
if (epoch !== this.epoch) {
|
||||
// A login or logout landed while this probe was running; the newer
|
||||
// state wins and this answer is discarded, never published.
|
||||
logger.log("[authStatus] discarding a probe that predates the latest auth change");
|
||||
return this.current;
|
||||
}
|
||||
// The CLI probe can be poorer than the session `AccountInfo` for the very
|
||||
// same login (no organization, say). Keep those extra fields rather than
|
||||
// regressing the payload; a different identity replaces it wholesale.
|
||||
const merged = mergeAuthStatus(this.current, status);
|
||||
this.set(merged);
|
||||
this.options.onProbe?.(status.kind);
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `claude auth logout`. The Claude and Console logins keep their
|
||||
* credentials in the CLI's store (keychain or config dir), which only the
|
||||
* binary can clear. The command is non-interactive and idempotent. Throws an
|
||||
* `internal_error` with the CLI's stderr when it fails.
|
||||
*/
|
||||
export async function cliLogout(): Promise<void> {
|
||||
try {
|
||||
const cliPath = await claudeCliPath();
|
||||
await execFileAsync(cliPath, ["auth", "logout"]);
|
||||
} catch (error) {
|
||||
const stderr =
|
||||
typeof error === "object" && error && "stderr" in error
|
||||
? String((error as { stderr: unknown }).stderr).trim()
|
||||
: undefined;
|
||||
throw RequestError.internalError(
|
||||
{ stderr: stderr || undefined },
|
||||
`claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
629
src/background-work.ts
Normal file
629
src/background-work.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* The background work of a session: the work that Claude Code keeps running
|
||||
* independently of the session's foreground work, which ACP v2 lists on every
|
||||
* `state_update` of the session as `background` (the v2 prompt lifecycle,
|
||||
* "Background work").
|
||||
*
|
||||
* The lists are built from Claude Code's live background task set
|
||||
* (`background_tasks_changed`). Claude Code marks `ambient` its own
|
||||
* housekeeping and every live-update watcher; those are left out, except a
|
||||
* task that a tool call started, such as a `Monitor` call's. The root session and each
|
||||
* announced subagent session (the subagents RFD form) has its own list. Each
|
||||
* task is listed once, in the list of the session that runs it:
|
||||
*
|
||||
* - A command that went to the background: its display terminal, from the
|
||||
* Bash tool result until its task ends (`Session.backgroundTerminals`).
|
||||
* - A detached subagent: its child session, in its parent's list, while it
|
||||
* works, and while it is idle with background work of its own.
|
||||
* - A task that a tool call started, such as a `Monitor`, a `Workflow` or a
|
||||
* backgrounded MCP call: that tool call, kept `in_progress` (or `paused`)
|
||||
* from its tool result until its task ends (`Session.backgroundToolCalls`).
|
||||
* - Any other task, once its `task_started` came and the tool call that
|
||||
* started it, if any, returned: a `_claude/task` item with the task's id
|
||||
* and description. For example, a task whose tool call ended before the
|
||||
* task joined the level.
|
||||
*
|
||||
* Only a session whose interrupt spares background tasks reports its lists
|
||||
* (`Session.reportsBackground`): `session/cancel` must leave listed work
|
||||
* running.
|
||||
*/
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification } from "./acp-subagents.js";
|
||||
import type { NativeSubagent } from "./native-subagents.js";
|
||||
import { type AcpToolCallRenderer, V2_CANCELLED, V2_PAUSED } from "./tool-calls/renderer.js";
|
||||
import { type BackgroundOutput, BackgroundOutputTail } from "./background-output.js";
|
||||
|
||||
/** An item of a `background` list. */
|
||||
export type BackgroundItem =
|
||||
| { type: "terminal"; terminalId: string }
|
||||
| { type: "tool_call"; toolCallId: string }
|
||||
| { type: "subagent"; sessionId: string }
|
||||
| { type: "_claude/task"; taskId: string; description: string };
|
||||
|
||||
/** The display terminal of a command that went to the background (see `Session.backgroundTerminals`). */
|
||||
export interface BackgroundTerminal {
|
||||
/** The Bash tool call, whose id names the terminal. */
|
||||
toolCallId: string;
|
||||
/** The subagent tool call that ran the command, if any. */
|
||||
parentToolUseId?: string;
|
||||
/** The file that Claude Code writes the command's output to, from the Bash result. */
|
||||
outputFilePath?: string;
|
||||
/** Reads that file into the terminal while the command runs. */
|
||||
output?: BackgroundOutputTail;
|
||||
}
|
||||
|
||||
/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */
|
||||
export interface BackgroundToolCall {
|
||||
toolCallId: string;
|
||||
/** The tool use of the call, which Claude Code no longer caches after its result. */
|
||||
toolUse: { name: string; input: unknown };
|
||||
/** The subagent tool call that made the call, if any. */
|
||||
parentToolUseId?: string;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
/** How a background task ended, as Claude Code reports it. Undefined when it did not say. */
|
||||
export type BackgroundTaskEnd = "completed" | "failed" | "stopped" | undefined;
|
||||
|
||||
/** The live background task set of `background_tasks_changed`, by task id. */
|
||||
export type BackgroundLevel = Map<
|
||||
string,
|
||||
{ ambient: boolean; taskType: string; description: string; parentTaskId?: string }
|
||||
>;
|
||||
|
||||
/** The work state of a session, as its `state_update` names it. */
|
||||
export type WorkStateName = "idle" | "running" | "requires_action" | "unknown";
|
||||
|
||||
/** A new list of the session `sessionId`, with what its repeated state needs. */
|
||||
export interface BackgroundReport {
|
||||
sessionId: string;
|
||||
items: BackgroundItem[];
|
||||
/** The session's work state now, or undefined when it has none yet. */
|
||||
state: WorkStateName | undefined;
|
||||
/** The parent of a subagent session, which mirrors the child's state. */
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
/** Where the agent reports the lists (see the agent's `background` option). */
|
||||
export interface BackgroundSink {
|
||||
/** The list of a session changed, or is reported for the first time. */
|
||||
changed(report: BackgroundReport): void;
|
||||
/** The session ended: its list is no longer reported. */
|
||||
forget(sessionId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a session that other modules own, and that the background
|
||||
* work reads: Claude Code's tasks as the consumer tracks them, the subagent
|
||||
* sessions, and the query that stops a task.
|
||||
*/
|
||||
export interface BackgroundOwner {
|
||||
liveBackgroundTasks: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
|
||||
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
|
||||
query: { stopTask(taskId: string): Promise<void> };
|
||||
}
|
||||
|
||||
/** A task of a `background_tasks_changed` payload. */
|
||||
export type LevelTask = {
|
||||
task_id: string;
|
||||
task_type: string;
|
||||
description: string;
|
||||
parent_task_id?: string;
|
||||
ambient?: boolean;
|
||||
};
|
||||
|
||||
/** The most returned tool calls that {@link SessionBackgroundWork} remembers. */
|
||||
const MAX_RETURNED_CALLS = 1024;
|
||||
|
||||
/** The tools whose structured result names the task that the call started. */
|
||||
const TASK_TOOLS = new Set(["Monitor", "Workflow"]);
|
||||
|
||||
/**
|
||||
* The background work of one root session, and of its subagent sessions: the
|
||||
* terminals and tool calls of the tasks that run in the background, and what
|
||||
* the client is told of them.
|
||||
*
|
||||
* Terminals are kept for every client that renders them, as before the
|
||||
* background lists. The rest is kept only when `reported`.
|
||||
*/
|
||||
export class SessionBackgroundWork {
|
||||
/** Claude Code's live background task set, from its last `background_tasks_changed`. */
|
||||
private level: BackgroundLevel = new Map();
|
||||
/** The terminals of the commands that went to the background, by task id. */
|
||||
private readonly terminals = new Map<string, BackgroundTerminal>();
|
||||
/** The tool calls whose tasks went on in the background, by task id. */
|
||||
private readonly toolCalls = new Map<string, BackgroundToolCall>();
|
||||
/**
|
||||
* The tool calls that returned, oldest first. A task that a call started
|
||||
* is part of that call until it returns, and the foreground work waits on
|
||||
* it. The task's `task_started` can come after the call returned. Bounded
|
||||
* by {@link MAX_RETURNED_CALLS}: a task joins the level around the time its
|
||||
* call returns.
|
||||
*/
|
||||
private readonly returned = new Set<string>();
|
||||
|
||||
/**
|
||||
* `reported`: whether the client gets the lists. Only when Claude Code's
|
||||
* interrupt spares background tasks, as `perTaskStopAffordance` makes it.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionId: string,
|
||||
readonly reported: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Keeps the display terminal of the command `taskId` running after its Bash
|
||||
* result, until its task ends (see `withRunningTerminal`).
|
||||
*/
|
||||
terminalStarted(taskId: string, terminal: BackgroundTerminal): void {
|
||||
this.terminals.set(taskId, terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads what a tool result message tells of the background tasks:
|
||||
*
|
||||
* - The returned calls no longer hold the tasks that they started.
|
||||
* - Each call whose task goes on in the background is registered: a
|
||||
* `Monitor` or `Workflow` call, whose structured result names its task,
|
||||
* or a call that a live task names as its tool use, such as a
|
||||
* backgrounded MCP call. The task must be in the level: one that ended
|
||||
* already leaves its call as it is. Claude Code marks every live-update
|
||||
* watcher `ambient`, including the task of a `Monitor` call; a call
|
||||
* started it, so it is work the client can see and stop, and is
|
||||
* registered all the same. Ambient tasks without a call of their own,
|
||||
* such as watchers Claude Code starts by itself, are never registered.
|
||||
*
|
||||
* Returns the ids of the registered calls, whose result then reports
|
||||
* `in_progress` (see `withRunningToolCall`).
|
||||
*/
|
||||
toolResults(
|
||||
owner: BackgroundOwner,
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
parentToolUseId: string | null,
|
||||
): Set<string> {
|
||||
const registered = new Set<string>();
|
||||
if (!this.reported || !Array.isArray(content)) return registered;
|
||||
const results = content.filter(
|
||||
(block): block is { type: "tool_result"; tool_use_id: string; is_error?: boolean } =>
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
block.type === "tool_result" &&
|
||||
typeof block.tool_use_id === "string",
|
||||
);
|
||||
for (const { tool_use_id: toolCallId } of results) {
|
||||
this.returned.add(toolCallId);
|
||||
if (this.returned.size > MAX_RETURNED_CALLS) {
|
||||
this.returned.delete(this.returned.values().next().value!);
|
||||
}
|
||||
}
|
||||
const succeeded = results.filter((block) => block.is_error !== true);
|
||||
const structuredTaskId =
|
||||
succeeded.length === 1 &&
|
||||
typeof toolUseResult === "object" &&
|
||||
toolUseResult !== null &&
|
||||
typeof (toolUseResult as { taskId?: unknown }).taskId === "string"
|
||||
? (toolUseResult as { taskId: string }).taskId
|
||||
: undefined;
|
||||
for (const { tool_use_id: toolCallId } of succeeded) {
|
||||
const toolUse = toolUses[toolCallId];
|
||||
if (!toolUse) continue;
|
||||
const taskId =
|
||||
(TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ??
|
||||
[...owner.liveBackgroundTasks].find(
|
||||
([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent,
|
||||
)?.[0];
|
||||
const task = taskId === undefined ? undefined : this.level.get(taskId);
|
||||
if (!task || task.taskType === "local_bash" || task.taskType === "local_agent") {
|
||||
continue;
|
||||
}
|
||||
this.toolCalls.set(taskId!, {
|
||||
toolCallId,
|
||||
toolUse,
|
||||
...(parentToolUseId ? { parentToolUseId } : {}),
|
||||
});
|
||||
registered.add(toolCallId);
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reading the output of the background command `taskId` into its
|
||||
* terminal, when the client gets the lists and the Bash result named the
|
||||
* command's output file. `send` sends an update of the terminal's tool
|
||||
* call, routed as the call's other updates. Called once the Bash result's
|
||||
* own updates are sent: the first output replaces the placeholder that they
|
||||
* gave the terminal.
|
||||
*/
|
||||
followOutput(taskId: string, send: Send): void {
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (!this.reported || !terminal?.outputFilePath || terminal.output) return;
|
||||
terminal.output = new BackgroundOutputTail(terminal.outputFilePath, (output) =>
|
||||
send(terminalOutputUpdate(this.sessionId, terminal, output)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The task `taskId` ended. Reads the rest of its command's output, then
|
||||
* exits its terminal, and gives a tool call whose task it was its final
|
||||
* status: `cancelled` for a task that Claude Code stopped, and `completed`
|
||||
* for one that ended without saying how, such as one that the level
|
||||
* dropped: it no longer runs, and reported no failure. Claude Code reports
|
||||
* no exit code for a command, and `completed` does not mean 0: Claude Code
|
||||
* accepts some non-zero codes as a success, such as grep's 1.
|
||||
*/
|
||||
async taskEnded(taskId: string, end: BackgroundTaskEnd, send: Send): Promise<void> {
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (terminal) {
|
||||
await terminal.output?.finish();
|
||||
this.terminals.delete(taskId);
|
||||
await send(terminalExit(this.sessionId, terminal));
|
||||
}
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (toolCall) {
|
||||
this.toolCalls.delete(taskId);
|
||||
await send(
|
||||
toolCallStatusUpdate(
|
||||
this.sessionId,
|
||||
toolCall,
|
||||
end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The update that reports the background tool call of the task `taskId` as
|
||||
* `paused`, or as `in_progress` again, when `paused` changes it. Claude Code
|
||||
* pauses a workflow this way.
|
||||
*/
|
||||
taskPaused(taskId: string, paused: boolean): AcpSessionNotification | undefined {
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
|
||||
toolCall.paused = paused;
|
||||
return toolCallStatusUpdate(this.sessionId, toolCall, paused ? V2_PAUSED : "in_progress");
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes Claude Code's new live background task set. The terminals and tool
|
||||
* calls whose tasks it no longer holds end first, before a list drops them,
|
||||
* as v2 requires. A terminal or tool call is registered at its tool result,
|
||||
* after its task joined the level, so a later level without the task means
|
||||
* that it ended. Claude Code usually sends a task's end before that level,
|
||||
* which ends them first, but the SDK does not promise that order.
|
||||
*/
|
||||
async levelChanged(tasks: LevelTask[], send: Send): Promise<void> {
|
||||
if (!this.reported) return;
|
||||
const level: BackgroundLevel = new Map(
|
||||
tasks.map((task) => [
|
||||
task.task_id,
|
||||
{
|
||||
ambient: task.ambient === true,
|
||||
taskType: task.task_type,
|
||||
description: task.description,
|
||||
...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
for (const taskId of [...this.terminals.keys(), ...this.toolCalls.keys()]) {
|
||||
if (!level.has(taskId)) await this.taskEnded(taskId, undefined, send);
|
||||
}
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query closed: Claude Code's background tasks end with its process.
|
||||
* Stops reading output, and returns the updates that exit the root
|
||||
* session's terminals and cancel its tool calls. The level empties, so the
|
||||
* next lists are empty. A subagent's command or tool call is left as it
|
||||
* was: it is on the child's stream, which the closed query's routing owned.
|
||||
*/
|
||||
queryClosed(): AcpSessionNotification[] {
|
||||
for (const terminal of this.terminals.values()) terminal.output?.stop();
|
||||
if (!this.reported) return [];
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
for (const [taskId, terminal] of this.terminals) {
|
||||
if (terminal.parentToolUseId) continue;
|
||||
this.terminals.delete(taskId);
|
||||
updates.push(terminalExit(this.sessionId, terminal));
|
||||
}
|
||||
for (const [taskId, toolCall] of this.toolCalls) {
|
||||
if (toolCall.parentToolUseId) continue;
|
||||
this.toolCalls.delete(taskId);
|
||||
updates.push(toolCallStatusUpdate(this.sessionId, toolCall, V2_CANCELLED));
|
||||
}
|
||||
this.level = new Map();
|
||||
this.returned.clear();
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `background` list of the root session and of each of its announced
|
||||
* subagent sessions, with what their repeated states need, deeper sessions
|
||||
* first and the root last: a child's own list goes out before its parent's
|
||||
* list drops it. Each list is empty while the level has not been reported:
|
||||
* Claude Code sends none before the first background task starts.
|
||||
*/
|
||||
reports(owner: BackgroundOwner, rootState: WorkStateName): BackgroundReport[] {
|
||||
const children = [...(owner.nativeSubagentsByTaskId ?? [])].filter(
|
||||
([, child]) => child.announced,
|
||||
);
|
||||
const childByTask = new Map(children);
|
||||
const childByToolUse = new Map(
|
||||
children.flatMap(([, child]) =>
|
||||
child.parentToolUseId ? [[child.parentToolUseId, child] as const] : [],
|
||||
),
|
||||
);
|
||||
const lists = new Map<string, BackgroundItem[]>([
|
||||
[this.sessionId, []],
|
||||
...children.map(([, child]): [string, BackgroundItem[]] => [child.sessionId, []]),
|
||||
]);
|
||||
// A subagent's command or tool call runs on the child's stream.
|
||||
const ownerOf = (parentToolUseId: string | undefined) =>
|
||||
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : this.sessionId;
|
||||
for (const [taskId, task] of this.level) {
|
||||
// A registered call's task is listed even when ambient, as a `Monitor`
|
||||
// call's is (see `toolResults`).
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (toolCall) {
|
||||
const session = ownerOf(toolCall.parentToolUseId);
|
||||
if (session)
|
||||
lists.get(session)!.push({ type: "tool_call", toolCallId: toolCall.toolCallId });
|
||||
continue;
|
||||
}
|
||||
if (task.ambient) continue;
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (terminal) {
|
||||
const session = ownerOf(terminal.parentToolUseId);
|
||||
if (session)
|
||||
lists.get(session)!.push({ type: "terminal", terminalId: terminal.toolCallId });
|
||||
continue;
|
||||
}
|
||||
// A command without a terminal yet is one that foreground work waits on.
|
||||
// A subagent is listed below, as its session.
|
||||
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;
|
||||
// A task joins the level before its `task_started`, and before the tool
|
||||
// call that starts it returns. Until both, the task is unknown or part
|
||||
// of that tool call.
|
||||
const live = owner.liveBackgroundTasks.get(taskId);
|
||||
if (!live || (live.parentToolUseId && !this.returned.has(live.parentToolUseId))) continue;
|
||||
const parent = task.parentTaskId ? childByTask.get(task.parentTaskId) : undefined;
|
||||
lists
|
||||
.get(parent?.sessionId ?? this.sessionId)!
|
||||
.push({ type: "_claude/task", taskId, description: task.description });
|
||||
}
|
||||
// A child is listed in its parent's list while it works in the background,
|
||||
// and while it is idle with background work of its own. Deeper children
|
||||
// first, so a child's list is whole before its parent's reads it.
|
||||
const sessions = new Map(children.map(([, child]) => [child.sessionId, child]));
|
||||
const depth = (child: NativeSubagent): number => {
|
||||
const parent = sessions.get(child.parentSessionId);
|
||||
return parent ? depth(parent) + 1 : 0;
|
||||
};
|
||||
const ordered = children.sort(([, a], [, b]) => depth(b) - depth(a));
|
||||
for (const [taskId, child] of ordered) {
|
||||
const task = this.level.get(taskId);
|
||||
const working =
|
||||
child.workState !== undefined &&
|
||||
child.workState !== "idle" &&
|
||||
task !== undefined &&
|
||||
!task.ambient;
|
||||
if (working || lists.get(child.sessionId)!.length > 0) {
|
||||
lists.get(child.parentSessionId)?.push({ type: "subagent", sessionId: child.sessionId });
|
||||
}
|
||||
}
|
||||
return [
|
||||
...ordered.map(([, child]) => ({
|
||||
sessionId: child.sessionId,
|
||||
items: lists.get(child.sessionId)!,
|
||||
state: workStateName(child.workState),
|
||||
parentSessionId: child.parentSessionId,
|
||||
})),
|
||||
{ sessionId: this.sessionId, items: lists.get(this.sessionId)!, state: rootState },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of each background tool call in `toolCallIds`: the
|
||||
* call as `renderer` shows its tool use, with its status now (see
|
||||
* `ClaudeAcpAgent.publishBackgroundToolCalls`).
|
||||
*/
|
||||
toolCallStates(
|
||||
toolCallIds: string[],
|
||||
renderer: AcpToolCallRenderer,
|
||||
cwd: string,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.toolCalls.values()]
|
||||
.filter((toolCall) => toolCallIds.includes(toolCall.toolCallId))
|
||||
.map((toolCall) => {
|
||||
const update = renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd });
|
||||
const status = toolCall.paused ? V2_PAUSED : "in_progress";
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
update: { ...update, status } as AcpSessionNotification["update"],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The task of the terminal or tool call `target` that the session
|
||||
* `sessionId` lists, or undefined when it lists no such item.
|
||||
*/
|
||||
listedTask(
|
||||
owner: BackgroundOwner,
|
||||
sessionId: string,
|
||||
target: BackgroundTarget,
|
||||
): string | undefined {
|
||||
if (!this.reported) return undefined;
|
||||
const listed = this.reports(owner, "idle")
|
||||
.find((report) => report.sessionId === sessionId)
|
||||
?.items.some((item) =>
|
||||
"terminalId" in target
|
||||
? item.type === "terminal" && item.terminalId === target.terminalId
|
||||
: item.type === "tool_call" && item.toolCallId === target.toolCallId,
|
||||
);
|
||||
if (!listed) return undefined;
|
||||
const id = "terminalId" in target ? target.terminalId : target.toolCallId;
|
||||
const entries = "terminalId" in target ? this.terminals : this.toolCalls;
|
||||
return [...entries].find(([, entry]) => entry.toolCallId === id)?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
type Send = (notification: AcpSessionNotification) => Promise<void>;
|
||||
|
||||
/** A terminal or tool call that `terminal/cancel` or `tool_call/cancel` names. */
|
||||
export type BackgroundTarget = { terminalId: string } | { toolCallId: string };
|
||||
|
||||
/**
|
||||
* Stops the task of a terminal or tool call that the session `sessionId`
|
||||
* lists (v2 `terminal/cancel` and `tool_call/cancel`). `sessions` are the
|
||||
* root sessions: a subagent session's list is built with its root's. The
|
||||
* terminal exits, or the tool call gets its final status, when Claude Code
|
||||
* reports the task stopped.
|
||||
*/
|
||||
export async function cancelBackgroundWork(
|
||||
sessions: Array<BackgroundOwner & { background?: SessionBackgroundWork }>,
|
||||
sessionId: string,
|
||||
target: BackgroundTarget,
|
||||
): Promise<void> {
|
||||
const notCancellable = () =>
|
||||
"terminalId" in target
|
||||
? RequestError.invalidParams(
|
||||
{ sessionId, terminalId: target.terminalId },
|
||||
`Terminal ${target.terminalId} is not a command running in the background, so it cannot be cancelled.`,
|
||||
)
|
||||
: RequestError.invalidParams(
|
||||
{ sessionId, toolCallId: target.toolCallId },
|
||||
`Tool call ${target.toolCallId} is not running in the background, so it cannot be cancelled.`,
|
||||
);
|
||||
for (const session of sessions) {
|
||||
const taskId = session.background?.listedTask(session, sessionId, target);
|
||||
if (taskId === undefined) continue;
|
||||
try {
|
||||
await session.query.stopTask(taskId);
|
||||
} catch (error) {
|
||||
// The task ended meanwhile: it has ended on the client too, or is about to.
|
||||
if (session.background?.listedTask(session, sessionId, target) === undefined) {
|
||||
throw notCancellable();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw notCancellable();
|
||||
}
|
||||
|
||||
/** The update that gives the terminal of `terminal` the text `output`. */
|
||||
function terminalOutputUpdate(
|
||||
sessionId: string,
|
||||
terminal: BackgroundTerminal,
|
||||
output: BackgroundOutput,
|
||||
): AcpSessionNotification {
|
||||
const data = { terminal_id: terminal.toolCallId, data: output.text };
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: terminal.toolCallId,
|
||||
_meta: {
|
||||
...(terminal.parentToolUseId
|
||||
? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
|
||||
: {}),
|
||||
...(output.replaces ? { terminal_output: data } : { terminal_output_delta: data }),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The update that exits the terminal of `terminal`, with no exit code. */
|
||||
function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: terminal.toolCallId,
|
||||
_meta: {
|
||||
...(terminal.parentToolUseId
|
||||
? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
|
||||
: {}),
|
||||
terminal_exit: { terminal_id: terminal.toolCallId, exit_code: null, signal: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallStatusUpdate(
|
||||
sessionId: string,
|
||||
toolCall: BackgroundToolCall,
|
||||
status: NonNullable<
|
||||
Extract<AcpSessionNotification["update"], { sessionUpdate: "tool_call_update" }>["status"]
|
||||
>,
|
||||
): AcpSessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
status,
|
||||
...(toolCall.parentToolUseId
|
||||
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the lists of each session that reports them, each time one
|
||||
* changes. The sink sends them to the client synchronously, so a list follows
|
||||
* the updates sent before it.
|
||||
*/
|
||||
export class BackgroundWorkReporter {
|
||||
/** The list last published for each session, as JSON, and the root session it is built with. */
|
||||
private readonly published = new Map<string, { json: string; root: string }>();
|
||||
|
||||
constructor(private readonly sink?: BackgroundSink) {}
|
||||
|
||||
/** Whether the lists are reported at all. */
|
||||
get enabled(): boolean {
|
||||
return this.sink !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes each list of the root session `rootSessionId` that changed. The
|
||||
* first call for a session publishes its list, also when it is empty.
|
||||
* `rootState` is the root session's state now.
|
||||
*/
|
||||
sync(
|
||||
rootSessionId: string,
|
||||
session: BackgroundOwner & { background?: SessionBackgroundWork },
|
||||
rootState: WorkStateName,
|
||||
): void {
|
||||
if (!this.sink || !session.background?.reported) return;
|
||||
for (const report of session.background.reports(session, rootState)) {
|
||||
const json = JSON.stringify(report.items);
|
||||
if (this.published.get(report.sessionId)?.json === json) continue;
|
||||
this.published.set(report.sessionId, { json, root: rootSessionId });
|
||||
this.sink.changed(report);
|
||||
}
|
||||
}
|
||||
|
||||
/** The root session `rootSessionId` ended, and its subagent sessions with it. */
|
||||
forget(rootSessionId: string): void {
|
||||
for (const [sessionId, { root }] of this.published) {
|
||||
if (root !== rootSessionId) continue;
|
||||
this.published.delete(sessionId);
|
||||
this.sink?.forget(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A child's work state, when it is one that v2 names. */
|
||||
function workStateName(state: string | undefined): WorkStateName | undefined {
|
||||
return state === "idle" ||
|
||||
state === "running" ||
|
||||
state === "requires_action" ||
|
||||
state === "unknown"
|
||||
? state
|
||||
: undefined;
|
||||
}
|
||||
@@ -72,30 +72,3 @@ export function sdkSystemPrompt(
|
||||
? { type: "preset", preset: "claude_code", append: text }
|
||||
: { type: "preset", preset: "claude_code" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK `systemPrompt` of a session: the spec's `systemPrompt`
|
||||
* (`clientPrompt`), else the `_meta.systemPrompt` extension (`metaPrompt`), a
|
||||
* string that replaces the preset or an object of preset options, else the
|
||||
* Claude Code preset. `customInstructions` are appended to the preset.
|
||||
*/
|
||||
export function sessionSystemPrompt(
|
||||
clientPrompt: ClientSystemPrompt | undefined,
|
||||
metaPrompt: unknown,
|
||||
customInstructions?: string,
|
||||
): NonNullable<Options["systemPrompt"]> {
|
||||
if (clientPrompt) return sdkSystemPrompt(clientPrompt, customInstructions);
|
||||
if (typeof metaPrompt === "string" && metaPrompt) return metaPrompt;
|
||||
if (typeof metaPrompt === "object" && metaPrompt !== null && !Array.isArray(metaPrompt)) {
|
||||
// Forward all preset options (append, excludeDynamicSections, and
|
||||
// anything the SDK adds later) while locking type/preset.
|
||||
return {
|
||||
...(metaPrompt as object),
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
} as NonNullable<Options["systemPrompt"]>;
|
||||
}
|
||||
return customInstructions === undefined
|
||||
? { type: "preset", preset: "claude_code" }
|
||||
: { type: "preset", preset: "claude_code", append: customInstructions };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ContextCompactionTrigger,
|
||||
createContextCompactionMeta,
|
||||
} from "./context-compaction-meta.js";
|
||||
import { assistantMessageText } from "./session-failure-extension.js";
|
||||
import { compactionToolCall } from "./tool-calls/reporters/compaction.js";
|
||||
|
||||
type CompactionStatus = "completed" | "failed";
|
||||
@@ -133,36 +132,6 @@ export function isCompactSummaryMessage(message: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays the compact summary message `message` (see
|
||||
* {@link isCompactSummaryMessage}) of the root conversation as one completed
|
||||
* `compaction_update` at its transcript position, keyed by the message's
|
||||
* uuid. Returns false, and sends nothing, for any other message.
|
||||
*
|
||||
* The boundary's own system record comes back from `getSessionMessages`
|
||||
* without its metadata, so the summary message is the one durable marker.
|
||||
* Its uuid differs from the live id (the `compacting` status uuid): replay
|
||||
* is terminal-first by the RFD, and no client merges a replay into a live
|
||||
* entity store. A summary whose framing is unrecognized is still this
|
||||
* compaction, without summary text.
|
||||
*/
|
||||
export async function replayCompactSummary(
|
||||
message: { uuid?: unknown; message?: unknown },
|
||||
sendUpdate: SendUpdate,
|
||||
options: { sessionId: string; airClient?: boolean },
|
||||
): Promise<boolean> {
|
||||
if (!isCompactSummaryMessage(message) || typeof message.uuid !== "string" || !message.uuid) {
|
||||
return false;
|
||||
}
|
||||
const replayed = new ContextCompactionLifecycle(sendUpdate, {
|
||||
...options,
|
||||
presentation: "compaction_update",
|
||||
});
|
||||
replayed.recordSummary(assistantMessageText(message.message));
|
||||
await replayed.finish(message.uuid, "completed");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates Claude's compaction signals into one idempotent ACP lifecycle.
|
||||
*
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* The context window of a session's model, as both the v1 core and the
|
||||
* v2 core report it in `usage_update.size`: seeded without IPC when the
|
||||
* session starts or switches models, refined by a background
|
||||
* `getContextUsage`, and learned from each result's `modelUsage`.
|
||||
*/
|
||||
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_CONTEXT_WINDOW, inferContextWindowFromModel } from "./session-usage.js";
|
||||
|
||||
/** Cross-session cache of authoritative context windows, keyed by
|
||||
* `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}).
|
||||
* The window is a property of (model id, backend): the same resolved model id
|
||||
* (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys)
|
||||
* can name different context lanes behind different base URLs, routing
|
||||
* headers, or credentials, so the key carries both. Caching it module-level
|
||||
* lets a later session/new or switch that resolves to the same (backend,
|
||||
* model) — in this session or any other, within the adapter's lifetime — seed
|
||||
* the correct window synchronously with no IPC. Keying on the resolved id
|
||||
* (rather than the picker value) means aliases that resolve to the same
|
||||
* concrete model share one entry; the result handler additionally writes the
|
||||
* bare assistant-message spelling so seed-time reads that fall back to a
|
||||
* verbatim live id (rows without `resolvedModel`) can hit too.
|
||||
*
|
||||
* Populated authoritatively by each `result.modelUsage` a turn confirms (see
|
||||
* the consumer's result handler). We deliberately never populate it from
|
||||
* `getContextUsage`: its `model` spelling and `rawMaxTokens` can differ from
|
||||
* the `result.modelUsage` key and window (e.g. 967000 vs 1000000 for sonnet),
|
||||
* so it only refines the live session (`refreshContextWindowInBackground`).
|
||||
* Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
|
||||
* windows learned under one login must not seed sessions under the next. */
|
||||
const contextWindowCache = new Map<string, number>();
|
||||
|
||||
/** The env vars that determine which LLM backend — and which context lane on
|
||||
* it — a query's API traffic reaches: endpoint selection (base URLs and the
|
||||
* Bedrock/Vertex switches with their project/region), routing/beta headers
|
||||
* (an `anthropic-beta: context-1m-…` header flips the same model id at the
|
||||
* same endpoint between context lanes), and credential identity (extended
|
||||
* context is entitlement-gated per account). Used to derive the
|
||||
* provider-cache key from the exact env a query is created with, so
|
||||
* `providers/set` config, per-session `_meta` env overrides, and ambient
|
||||
* process env are all distinguished exactly as the CLI will see them. */
|
||||
const PROVIDER_ROUTING_ENV_VARS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_BEDROCK_BASE_URL",
|
||||
"ANTHROPIC_VERTEX_BASE_URL",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"ANTHROPIC_VERTEX_PROJECT_ID",
|
||||
"CLOUD_ML_REGION",
|
||||
"AWS_REGION",
|
||||
"ANTHROPIC_CUSTOM_HEADERS",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
] as const;
|
||||
|
||||
/** Stable identifier for the LLM backend a session's query is created against,
|
||||
* used to scope {@link contextWindowCache} per backend. Positional `\0`-join
|
||||
* of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade
|
||||
* as another and unset vars everywhere yield one stable "default" bucket.
|
||||
* Header/credential values can be secrets; the key only ever lives as an
|
||||
* in-memory Map key and is never logged or surfaced. Over-keying is the safe
|
||||
* side: a var change that didn't really change the backend costs one cache
|
||||
* miss (heuristic seed until the next result), while under-keying would serve
|
||||
* one backend's window for another's. */
|
||||
export function providerCacheKeyFor(env: Record<string, string | undefined>): string {
|
||||
return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0");
|
||||
}
|
||||
|
||||
/** Compose the `contextWindowCache` key from a session's provider key and a
|
||||
* model id. `\0`-joined so the model segment can't collide with a provider
|
||||
* segment. */
|
||||
export function contextWindowCacheKey(providerCacheKey: string, modelId: string): string {
|
||||
return `${providerCacheKey}\0${modelId}`;
|
||||
}
|
||||
|
||||
function cacheContextWindow(modelKey: string, window: number): void {
|
||||
if (window > 0) {
|
||||
contextWindowCache.set(modelKey, window);
|
||||
}
|
||||
}
|
||||
|
||||
/** Caches the window that a result's `modelUsage` reported, keyed on (the
|
||||
* session's provider, the resolved model id — the `modelUsage` key, e.g.
|
||||
* "claude-sonnet-5[1m]") so a later session/new or switch on the same
|
||||
* provider that resolves to this model seeds the correct window
|
||||
* synchronously, with no getContextUsage IPC.
|
||||
*
|
||||
* Also caches it under the assistant message's own (bare) spelling. Seed-time
|
||||
* reads fall back to a picker value / verbatim live id when a row carries no
|
||||
* resolvedModel (the synthesized out-of-allowlist resume row sets it
|
||||
* undefined on purpose), and those spellings match `.model` from the
|
||||
* assistant message, not the decorated modelUsage key — without this entry
|
||||
* such rows could never hit the cache. A non-positive window is not cached. */
|
||||
export function cacheResultContextWindow(
|
||||
providerCacheKey: string,
|
||||
modelUsageKey: string,
|
||||
assistantModel: string | null | undefined,
|
||||
window: number,
|
||||
): void {
|
||||
cacheContextWindow(contextWindowCacheKey(providerCacheKey, modelUsageKey), window);
|
||||
if (assistantModel && assistantModel !== modelUsageKey) {
|
||||
cacheContextWindow(contextWindowCacheKey(providerCacheKey, assistantModel), window);
|
||||
}
|
||||
}
|
||||
|
||||
/** The context window to report *right now* for a model, with NO IPC on the
|
||||
* critical path: the cached authoritative value if we've learned it (from a
|
||||
* prior turn's `result.modelUsage`, this or any session on the same backend),
|
||||
* else the text heuristic over the model row's identity strings, else the
|
||||
* default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`,
|
||||
* the same rule at every seed site — so read keys can't drift from the write
|
||||
* site's spelling. `authoritative` reports whether the value came from the
|
||||
* cache: an authoritative window can legitimately equal
|
||||
* DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */
|
||||
export function immediateContextWindow(
|
||||
providerCacheKey: string,
|
||||
modelId: string,
|
||||
modelInfo?: Pick<ModelInfo, "resolvedModel" | "displayName" | "description">,
|
||||
): { size: number; authoritative: boolean } {
|
||||
const cached = contextWindowCache.get(
|
||||
contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId),
|
||||
);
|
||||
if (cached !== undefined) return { size: cached, authoritative: true };
|
||||
return {
|
||||
size:
|
||||
inferContextWindowFromModel(
|
||||
modelId,
|
||||
modelInfo?.resolvedModel,
|
||||
modelInfo?.displayName,
|
||||
modelInfo?.description,
|
||||
) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
authoritative: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Forgets the learned windows, as a logout asks: 1M-context entitlement is
|
||||
* gated per org/tier, and an OAuth re-login is invisible to the env-derived
|
||||
* provider cache key, so windows learned under the old login must not seed
|
||||
* sessions under the next. */
|
||||
export function clearContextWindowCache(): void {
|
||||
contextWindowCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the window of the query's current model with
|
||||
* `getContextUsage().rawMaxTokens`. Undefined when Claude Code reports none.
|
||||
*
|
||||
* Requests the `summary` detail instead of the default `full` breakdown,
|
||||
* which issues one `messages/count_tokens` call per category (~18 for a bare
|
||||
* session) and hit rate limits on every model switch; `summary` answers
|
||||
* locally with the same `rawMaxTokens`. Callers never await it on the setup
|
||||
* or switch path: SDK control requests are serialized, so awaiting would
|
||||
* delay session/new or a model switch (~15s on older CLIs, issues
|
||||
* #886/#880; ~0.5s on 2.1.283).
|
||||
*
|
||||
* The result does not go into the cross-session cache: its `rawMaxTokens` can
|
||||
* differ from the `result.modelUsage` window (e.g. 967000 vs 1000000 for
|
||||
* sonnet), so it only refines the live session.
|
||||
*/
|
||||
export async function readContextWindow(
|
||||
query: Pick<Query, "getContextUsage">,
|
||||
): Promise<number | undefined> {
|
||||
const usage = await query.getContextUsage({ detail: "summary" });
|
||||
return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : undefined;
|
||||
}
|
||||
@@ -717,7 +717,7 @@ function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCa
|
||||
*
|
||||
* The agent builds tool call content in v1 types, and v1 cannot express a v2
|
||||
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
|
||||
* diff, and only a v2 client gets one: the v2 core sends it as it is
|
||||
* diff, and only a v2 client gets one: the v2 surface sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
export function v2DiffContent(
|
||||
|
||||
@@ -6,13 +6,7 @@ import type {
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
ElicitationRequest,
|
||||
ElicitationResult,
|
||||
PermissionResult,
|
||||
UserDialogRequest,
|
||||
UserDialogResult,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
@@ -518,156 +512,3 @@ export function refusalFallbackResultFromResponse(response: CreateElicitationRes
|
||||
const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY];
|
||||
return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* What became of an elicitation: the client's response, or `withdrawn` when
|
||||
* its signal aborted first (Claude Code withdrew the request, or a cancel or
|
||||
* close of the session did). A response that comes after the abort counts as
|
||||
* withdrawn too.
|
||||
*/
|
||||
export type ElicitationOutcome = { response: CreateElicitationResponse } | { withdrawn: true };
|
||||
|
||||
/**
|
||||
* Sends an elicitation with `send` and reads what became of it. A rejection
|
||||
* while `signal` is not aborted, such as the client's -32602 for a form it
|
||||
* cannot render, is rethrown.
|
||||
*/
|
||||
export async function elicitUnlessWithdrawn(
|
||||
signal: AbortSignal,
|
||||
send: (signal: AbortSignal) => Promise<CreateElicitationResponse>,
|
||||
): Promise<ElicitationOutcome> {
|
||||
try {
|
||||
const response = await send(signal);
|
||||
return signal.aborted ? { withdrawn: true } : { response };
|
||||
} catch (error) {
|
||||
if (signal.aborted) return { withdrawn: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a built elicitation to the client (see {@link elicitUnlessWithdrawn}). */
|
||||
export type Elicit = (request: CreateElicitationRequest) => Promise<ElicitationOutcome>;
|
||||
|
||||
type ElicitationLogger = { error: (...args: unknown[]) => void };
|
||||
|
||||
/**
|
||||
* Answers an MCP server's elicitation (the SDK's `onElicitation`) through
|
||||
* `elicit`. Only the user's own decline reaches the MCP server as `decline`.
|
||||
* A request the user never saw, because the client did not advertise its
|
||||
* mode, it cannot be represented, the client answered with an error, or it
|
||||
* was withdrawn, is a `cancel`.
|
||||
*/
|
||||
export async function answerMcpElicitation(
|
||||
request: ElicitationRequest,
|
||||
{
|
||||
sessionId,
|
||||
support,
|
||||
elicit,
|
||||
logger,
|
||||
}: { sessionId: string; support: ElicitationSupport; elicit: Elicit; logger: ElicitationLogger },
|
||||
): Promise<ElicitationResult> {
|
||||
const isUrl = request.mode === "url";
|
||||
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
|
||||
if (!createRequest) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
try {
|
||||
const outcome = await elicit(createRequest);
|
||||
if ("withdrawn" in outcome) return { action: "cancel" };
|
||||
return createElicitationResponseToElicitResult(outcome.response);
|
||||
} catch (error) {
|
||||
// The client answered with an error, e.g. -32602 for a form it cannot
|
||||
// render, so the user never saw the request. MCP's `decline` means the
|
||||
// user refused; `cancel` means they made no explicit choice.
|
||||
logger.error(`Failed to forward MCP elicitation: ${error}`);
|
||||
return { action: "cancel" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents the AskUserQuestion tool's questions as a form elicitation of
|
||||
* `sessionId` through `elicit`, and returns the answers as the tool's
|
||||
* `updatedInput`. A withdrawn question, or one the user cancelled, aborts the
|
||||
* tool use. `reportUnanswered` is read once the answer is in (see
|
||||
* {@link applyAskElicitationResponse}).
|
||||
*/
|
||||
export async function answerAskUserQuestion(
|
||||
toolInput: Record<string, unknown>,
|
||||
{
|
||||
sessionId,
|
||||
toolUseID,
|
||||
airClient = false,
|
||||
reportUnanswered,
|
||||
elicit,
|
||||
logger,
|
||||
}: {
|
||||
sessionId: string;
|
||||
toolUseID: string;
|
||||
airClient?: boolean;
|
||||
reportUnanswered: () => boolean;
|
||||
elicit: Elicit;
|
||||
logger: ElicitationLogger;
|
||||
},
|
||||
): Promise<PermissionResult> {
|
||||
const questions = extractAskUserQuestions(toolInput);
|
||||
if (!questions) {
|
||||
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
||||
}
|
||||
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, airClient);
|
||||
let outcome: ElicitationOutcome;
|
||||
try {
|
||||
outcome = await elicit(createRequest);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
||||
return { behavior: "deny", message: "Could not present the question to the user." };
|
||||
}
|
||||
if ("withdrawn" in outcome) {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
const answer = applyAskElicitationResponse(outcome.response, toolInput, questions, {
|
||||
reportUnanswered: reportUnanswered(),
|
||||
});
|
||||
if (answer.action === "cancel") {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
return { behavior: "allow", updatedInput: answer.updatedInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a `request_user_dialog` of Claude Code (the SDK's `onUserDialog`).
|
||||
* Only kinds declared in `supportedDialogKinds` are ever emitted; everything
|
||||
* unexpected is answered `cancelled` (the required answer for unrecognized
|
||||
* kinds), which applies the dialog's default behavior CLI-side. The only
|
||||
* declared kind is the refusal-fallback consent prompt, rendered as a form
|
||||
* elicitation. A failed or withdrawn elicitation is `cancelled` too: the CLI
|
||||
* then keeps the refusal rather than switching models without consent.
|
||||
*/
|
||||
export async function answerUserDialog(
|
||||
request: UserDialogRequest,
|
||||
{ sessionId, elicit, logger }: { sessionId: string; elicit: Elicit; logger: ElicitationLogger },
|
||||
): Promise<UserDialogResult> {
|
||||
if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
const prompt = extractRefusalFallbackPrompt(request.payload);
|
||||
if (!prompt) {
|
||||
logger.error(
|
||||
`refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`,
|
||||
);
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
let outcome: ElicitationOutcome;
|
||||
try {
|
||||
outcome = await elicit(refusalFallbackToCreateRequest(prompt, sessionId));
|
||||
} catch (error) {
|
||||
logger.error(`Failed to present refusal fallback elicitation: ${error}`);
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
if ("withdrawn" in outcome) {
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
return { behavior: "completed", result: refusalFallbackResultFromResponse(outcome.response) };
|
||||
}
|
||||
|
||||
@@ -104,12 +104,11 @@ const FILE_EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
||||
* Adds to `turn.editedPaths` the `file_path` (`notebook_path` for NotebookEdit)
|
||||
* of each successful file-tool edit among the tool results of a subagent
|
||||
* message. Root-agent messages (`parentToolUseId === null`) are skipped: the
|
||||
* checkpoint already tracks their files. A turn that v1 marked `settled`
|
||||
* records nothing. Must run before `toAcpNotifications` drops the tool use
|
||||
* cache entries.
|
||||
* checkpoint already tracks their files. Must run before `toAcpNotifications`
|
||||
* drops the tool use cache entries.
|
||||
*/
|
||||
export function recordSubagentFileEdits(
|
||||
turn: (NativeFileChangeReportTurn & { settled?: boolean }) | null | undefined,
|
||||
turn: (NativeFileChangeReportTurn & { settled: boolean }) | null | undefined,
|
||||
parentToolUseId: string | null,
|
||||
content: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown } | undefined>,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* `--hide-claude-auth`: this integration must never bill a claude.ai
|
||||
* subscription. Under the flag the agent hides the claude.ai login method from
|
||||
* `initialize`, and refuses with `authRequired` every session and every turn
|
||||
* that a subscription would pay for. `v1/acp-agent.ts` decides when the guard
|
||||
* that a subscription would pay for. `acp-agent.ts` decides when the guard
|
||||
* applies (flag on, no provider override); this module decides what it refuses.
|
||||
*
|
||||
* The guard judges the `AccountInfo` that the SDK caches at `initialize`. That
|
||||
@@ -208,14 +208,14 @@ type GuardOptions = {
|
||||
* covers a session created under a provider override that `providers/disable`
|
||||
* later removed, so the create-time check never ran for the routing in force
|
||||
* now. A sign-out ends the query (see `markSessionForSignOutRespawn` in
|
||||
* `v1/acp-agent.ts`), so the next turn recreates it and `initialize` reports the
|
||||
* `acp-agent.ts`), so the next turn recreates it and `initialize` reports the
|
||||
* real account. This guard does not see the residual case the module header
|
||||
* names either.
|
||||
*
|
||||
* A turn is refused when the account bills a subscription, and also when it
|
||||
* holds nothing this integration accepts, which is the user signing out in
|
||||
* mid-session. The refusal mirrors the signed-out path exactly (see
|
||||
* `failActiveWithSessionFailure` for `auth_required` in `v1/acp-agent.ts`): every
|
||||
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
|
||||
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
|
||||
* flow. The guard sends no separate access message.
|
||||
*
|
||||
|
||||
@@ -83,7 +83,7 @@ export class IdleCompaction {
|
||||
this.setTimer = options.setTimeout ?? ((cb, ms) => setTimeout(cb, ms));
|
||||
}
|
||||
|
||||
/** Whether an idle compaction it started is still running. `v1/acp-agent.ts` reports
|
||||
/** Whether an idle compaction it started is still running. `acp-agent.ts` reports
|
||||
* `trigger: "idle"` while it is true. A client's `/compact` queued behind is read
|
||||
* only after the adapter's turn's `result`, which clears `compacting`; this relies
|
||||
* on the CLI emitting that compaction's `compact_boundary` after the previous
|
||||
|
||||
29
src/lib.ts
29
src/lib.ts
@@ -1,7 +1,25 @@
|
||||
// The package's public API: serve ACP (v1 and v2) on stdio, and the wire
|
||||
// names of the `authStatus` extension that a client needs to read its traffic.
|
||||
export { serveAcp, type AcpProtocols } from "./serve.js";
|
||||
export type { Logger } from "./logger.js";
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
isLocalCommandMetadata,
|
||||
stripLocalCommandMetadata,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
|
||||
// The `authStatus` extension's wire surface: the notification's method name and
|
||||
// the payload types needed to read the traffic. The mappers stay internal.
|
||||
export {
|
||||
AUTH_STATUS_UPDATE_METHOD,
|
||||
type AuthStatus,
|
||||
@@ -9,3 +27,6 @@ export {
|
||||
type AuthStatusKind,
|
||||
type AuthStatusUpdateNotification,
|
||||
} from "./auth-status.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Logger interface for customizing logging output
|
||||
*/
|
||||
export interface Logger {
|
||||
log: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
|
||||
warn?: (...args: any[]) => void;
|
||||
}
|
||||
214
src/mcp-oauth.ts
214
src/mcp-oauth.ts
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* OAuth for the MCP servers that Claude Code reports as `needs-auth`, as an
|
||||
* ACP URL elicitation. Protocol-neutral: the v1 core and the v2 core
|
||||
* both start it once a session's setup response is sent.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Claude Code keeps the OAuth callback listener open in the background after
|
||||
* `mcpAuthenticate` returns the authorization URL. The SDK does not expose
|
||||
* that listener's completion promise, so watch the server status while the
|
||||
* ACP client has the URL elicitation open. */
|
||||
const MCP_OAUTH_STATUS_POLL_MS = 1_000;
|
||||
const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** Runtime MCP OAuth control exposed by the pinned Agent SDK. It is not yet in
|
||||
* the public `Query` declaration, even though the method is present on the
|
||||
* SDK query object and backed by Claude Code's `mcp_authenticate` control. */
|
||||
type McpOAuthQuery = Query & {
|
||||
mcpAuthenticate(
|
||||
serverName: string,
|
||||
redirectUri?: string,
|
||||
): Promise<{
|
||||
authUrl?: string;
|
||||
requiresUserAction: boolean;
|
||||
callbackExpected: boolean;
|
||||
redirectScheme?: "localhost" | "custom";
|
||||
callbackPort?: number;
|
||||
state?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function supportsMcpOAuth(query: Query): query is McpOAuthQuery {
|
||||
return typeof (query as Partial<McpOAuthQuery>).mcpAuthenticate === "function";
|
||||
}
|
||||
|
||||
/** What an OAuth run needs of the client connection. */
|
||||
export type McpOAuthClient = {
|
||||
/** Whether the client advertised URL elicitation. Without it, OAuth does not start. */
|
||||
readonly urlElicitation: boolean;
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CreateElicitationResponse>;
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||
readonly logger: { error: (...args: unknown[]) => void };
|
||||
};
|
||||
|
||||
/** What an OAuth run needs of the session. */
|
||||
export type McpOAuthSession = {
|
||||
/** One OAuth run at a time per key: the session object. */
|
||||
readonly key: object;
|
||||
readonly sessionId: string;
|
||||
/** The session's query now, or `undefined` once the session is gone. */
|
||||
currentQuery(): Query | undefined;
|
||||
/** Aborts when the session closes. */
|
||||
readonly closed: AbortSignal;
|
||||
};
|
||||
|
||||
const mcpAuthentications = new WeakMap<object, Promise<void>>();
|
||||
|
||||
/** Start OAuth in the background for the servers in `requestedServers` that
|
||||
* Claude reports as needing authentication. It does nothing while another
|
||||
* OAuth run of the session is in progress. */
|
||||
export function startMcpServerAuthentication(
|
||||
client: McpOAuthClient,
|
||||
session: McpOAuthSession,
|
||||
requestedServers: Set<string>,
|
||||
): void {
|
||||
if (!client.urlElicitation || requestedServers.size === 0) return;
|
||||
const query = session.currentQuery();
|
||||
if (!query || mcpAuthentications.has(session.key)) return;
|
||||
|
||||
const authentication = authenticateMcpServers(client, session, query, requestedServers)
|
||||
.catch((error) => {
|
||||
if (!session.closed.aborted) {
|
||||
client.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mcpAuthentications.get(session.key) === authentication) {
|
||||
mcpAuthentications.delete(session.key);
|
||||
}
|
||||
});
|
||||
mcpAuthentications.set(session.key, authentication);
|
||||
}
|
||||
|
||||
async function authenticateMcpServers(
|
||||
client: McpOAuthClient,
|
||||
session: McpOAuthSession,
|
||||
query: Query,
|
||||
requestedServers: Set<string>,
|
||||
): Promise<void> {
|
||||
if (!supportsMcpOAuth(query)) {
|
||||
client.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication.");
|
||||
return;
|
||||
}
|
||||
|
||||
const statuses = await query.mcpServerStatus();
|
||||
for (const status of statuses) {
|
||||
if (status.status !== "needs-auth" || !requestedServers.has(status.name)) continue;
|
||||
try {
|
||||
await authenticateMcpServer(client, session, query, status.name);
|
||||
} catch (error) {
|
||||
if (session.currentQuery() && !session.closed.aborted) {
|
||||
client.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation.
|
||||
* Claude opens and owns the localhost callback listener; the ACP client only
|
||||
* needs to present the returned authorization URL. */
|
||||
async function authenticateMcpServer(
|
||||
client: McpOAuthClient,
|
||||
session: McpOAuthSession,
|
||||
query: McpOAuthQuery,
|
||||
serverName: string,
|
||||
): Promise<void> {
|
||||
if (!session.currentQuery()) return;
|
||||
|
||||
const login = await query.mcpAuthenticate(serverName);
|
||||
if (!login.requiresUserAction) return;
|
||||
if (!login.authUrl) {
|
||||
throw new Error("Claude Code requested user action without returning an authorization URL");
|
||||
}
|
||||
|
||||
const elicitationId = `mcp-oauth-${randomUUID()}`;
|
||||
const flowAbort = new AbortController();
|
||||
const abortFlow = () => flowAbort.abort(session.closed.reason);
|
||||
session.closed.addEventListener("abort", abortFlow, { once: true });
|
||||
if (session.closed.aborted) abortFlow();
|
||||
|
||||
try {
|
||||
const completed = waitForMcpAuthentication(session, query, serverName, flowAbort.signal);
|
||||
const elicitation = client.createElicitation(
|
||||
{
|
||||
mode: "url",
|
||||
sessionId: session.sessionId,
|
||||
message: `Authenticate with MCP server ${serverName}`,
|
||||
url: login.authUrl,
|
||||
elicitationId,
|
||||
},
|
||||
flowAbort.signal,
|
||||
);
|
||||
const first = await Promise.race([
|
||||
completed.then((authenticated) => ({ type: "completed" as const, authenticated })),
|
||||
elicitation.then((response) => ({ type: "elicitation" as const, response })),
|
||||
]);
|
||||
|
||||
// A url-mode accept carries no content to check. The guard
|
||||
// `CreateElicitationResponse.isAccept` would also reject an accept whose
|
||||
// `content` holds a v2-only value, such as an object.
|
||||
if (first.type === "elicitation" && first.response.action !== "accept") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.type === "elicitation") {
|
||||
await completed;
|
||||
}
|
||||
try {
|
||||
await client.completeElicitation({ elicitationId });
|
||||
} catch (error) {
|
||||
if (!flowAbort.signal.aborted) {
|
||||
client.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flowAbort.abort();
|
||||
session.closed.removeEventListener("abort", abortFlow);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMcpAuthentication(
|
||||
session: McpOAuthSession,
|
||||
query: Query,
|
||||
serverName: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS;
|
||||
while (!signal.aborted && Date.now() < deadline) {
|
||||
if (session.currentQuery() !== query) return false;
|
||||
|
||||
const status: McpServerStatus | undefined = (await query.mcpServerStatus()).find(
|
||||
(server) => server.name === serverName,
|
||||
);
|
||||
if (!status || status.status === "failed" || status.status === "disabled") return false;
|
||||
if (status.status === "connected") return true;
|
||||
if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal))) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Wait for a polling interval, resolving false when the session is aborted. */
|
||||
export function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, milliseconds);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { McpServer } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/**
|
||||
* The SDK configs of a session's ACP MCP servers, by name. HTTP and SSE
|
||||
* servers keep their URL and headers; a server without `type` is stdio. A
|
||||
* server of any other transport is left out: the session's `initialize`
|
||||
* advertises none.
|
||||
*/
|
||||
export function sdkMcpServers(
|
||||
servers: readonly McpServer[] | null | undefined,
|
||||
): Record<string, McpServerConfig> {
|
||||
const configs: Record<string, McpServerConfig> = {};
|
||||
for (const server of servers ?? []) {
|
||||
if ("type" in server && (server.type === "http" || server.type === "sse")) {
|
||||
configs[server.name] = {
|
||||
type: server.type,
|
||||
url: server.url,
|
||||
headers: server.headers
|
||||
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
|
||||
: undefined,
|
||||
};
|
||||
} else if (!("type" in server)) {
|
||||
configs[server.name] = {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* The Claude Code messages and content that a client never sees as the user's
|
||||
* or the agent's text: local slash command markers, context the CLI injects,
|
||||
* the synthetic `/login` message and meta messages from other agents. The live
|
||||
* stream and the `session/load` replay both filter with these.
|
||||
*/
|
||||
import { isLoginRequiredText } from "./result-end.js";
|
||||
import { isSyntheticUsageLimitMessage } from "./session-failure-extension.js";
|
||||
import { markerTagText, TASK_NOTIFICATION_TAG } from "./task-notifications.js";
|
||||
|
||||
// Slash commands that the SDK handles locally without replaying the user
|
||||
// message and without invoking the model.
|
||||
export const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
||||
|
||||
// Commands whose marker-only transcript records are ACP/client-local UI noise,
|
||||
// not model-visible user prompts. Marker-only custom slash skills are not in
|
||||
// this list, so replay can reconstruct them from `<command-name>`/`<command-args>`.
|
||||
const REPLAY_HIDDEN_COMMANDS = new Set([
|
||||
...LOCAL_ONLY_COMMANDS,
|
||||
"/compact",
|
||||
"/model",
|
||||
"/mcp",
|
||||
"/status",
|
||||
"/usage",
|
||||
]);
|
||||
|
||||
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
|
||||
// their output as user messages in the session transcript, wrapping the
|
||||
// payload in these XML-like markers that the CLI uses for its own display.
|
||||
// The live prompt loop drops them; replay must strip them too or they leak
|
||||
// into the UI on session/load.
|
||||
const LOCAL_COMMAND_MARKERS = [
|
||||
"command-name",
|
||||
"command-message",
|
||||
"command-args",
|
||||
"local-command-stdout",
|
||||
"local-command-stderr",
|
||||
].map((tag) => ({ open: `<${tag}>`, close: `</${tag}>` }));
|
||||
|
||||
// Context the CLI injects into a user turn to steer the model, appended to
|
||||
// whatever the user typed. Nobody wrote it and no client sees it live — the
|
||||
// prompt loop's user-message skip covers the whole message — but it is
|
||||
// persisted alongside that prose, so replay would hand the client a prompt
|
||||
// with instructions in it the user never gave. A `task-notification` tells the
|
||||
// model that a background task stopped; live, the SDK `task_notification`
|
||||
// frame reports the same stop, so replay restores the task state from it (see
|
||||
// taskNotificationsOf) instead of showing it.
|
||||
const INJECTED_CONTEXT_MARKERS = ["system-reminder", TASK_NOTIFICATION_TAG].map((tag) => ({
|
||||
open: `<${tag}>`,
|
||||
close: `</${tag}>`,
|
||||
}));
|
||||
|
||||
// Everything a replayed user message may be wrapped in that is not speech.
|
||||
const TRANSCRIPT_MARKERS = [...LOCAL_COMMAND_MARKERS, ...INJECTED_CONTEXT_MARKERS];
|
||||
|
||||
// Single-pass scanner that removes each `<tag>…</tag>` marker (matching the
|
||||
// nearest closing tag of the same name, like a lazy regex would).
|
||||
function stripMarkerTags(text: string): string {
|
||||
const dead = new Set<string>();
|
||||
let result = "";
|
||||
let copiedUpTo = 0;
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (text[i] === "<") {
|
||||
const marker = TRANSCRIPT_MARKERS.find(
|
||||
(m) => !dead.has(m.open) && text.startsWith(m.open, i),
|
||||
);
|
||||
if (marker) {
|
||||
const end = text.indexOf(marker.close, i + marker.open.length);
|
||||
if (end !== -1) {
|
||||
result += text.slice(copiedUpTo, i);
|
||||
i = copiedUpTo = end + marker.close.length;
|
||||
continue;
|
||||
}
|
||||
// No closing marker remains anywhere ahead, and `indexOf` only ever
|
||||
// searches forward from here on, so stop treating this tag as an
|
||||
// opener — that avoids rescanning the tail for it on every match.
|
||||
dead.add(marker.open);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return result + text.slice(copiedUpTo);
|
||||
}
|
||||
|
||||
function hasMarkerTag(text: string, tag: string): boolean {
|
||||
return markerTagText(text, tag) !== undefined;
|
||||
}
|
||||
|
||||
function commandInvocationFromMarkerOnlyText(text: string): string | null {
|
||||
if (hasMarkerTag(text, "local-command-stdout") || hasMarkerTag(text, "local-command-stderr")) {
|
||||
return null;
|
||||
}
|
||||
const commandName = markerTagText(text, "command-name")?.trim();
|
||||
if (!commandName?.startsWith("/")) return null;
|
||||
if (REPLAY_HIDDEN_COMMANDS.has(commandName.split(" ", 1)[0])) return null;
|
||||
|
||||
const commandArgs = markerTagText(text, "command-args")?.trim();
|
||||
return commandArgs ? `${commandName} ${commandArgs}` : commandName;
|
||||
}
|
||||
|
||||
function stripLocalCommandMetadataText(text: string): string | null {
|
||||
const stripped = stripMarkerTags(text);
|
||||
if (stripped.trim() !== "") return stripped;
|
||||
return commandInvocationFromMarkerOnlyText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return user-message content with local-command and injected-context marker
|
||||
* tags removed, or `null` if nothing meaningful remains (caller should skip
|
||||
* the message). Preserves real prose that's mixed in alongside the markers —
|
||||
* e.g. a message like `<command-name>…</command-name>hi` becomes `hi`, and
|
||||
* `hi<system-reminder>…</system-reminder>` becomes `hi`.
|
||||
*/
|
||||
export function stripLocalCommandMetadata(content: unknown): unknown | null {
|
||||
if (typeof content === "string") {
|
||||
return stripLocalCommandMetadataText(content);
|
||||
}
|
||||
if (!Array.isArray(content)) return content;
|
||||
|
||||
const kept: unknown[] = [];
|
||||
for (const block of content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
(block as { type: unknown }).type === "text" &&
|
||||
"text" in block &&
|
||||
typeof (block as { text: unknown }).text === "string"
|
||||
) {
|
||||
const stripped = stripLocalCommandMetadataText((block as { text: string }).text);
|
||||
if (stripped === null) continue;
|
||||
kept.push({ ...(block as object), text: stripped });
|
||||
} else {
|
||||
kept.push(block);
|
||||
}
|
||||
}
|
||||
if (kept.length === 0) return null;
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the synthetic assistant message the CLI injects into the transcript
|
||||
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
|
||||
* "Session expired. Please run /login to sign in again."). The `/login`
|
||||
* instruction is Claude Code TUI-specific and meaningless to ACP clients
|
||||
* (issue #863). The live prompt loop suppresses the text and fails the turn
|
||||
* with `authRequired` so the client can run its own auth flow; replay must
|
||||
* skip it too — both for parity with what the client saw live and because the
|
||||
* message stays in the transcript forever, so it would resurface on every
|
||||
* session/load even after the user has logged back in.
|
||||
*
|
||||
* Takes the API message (`message.message`), which replay only knows as
|
||||
* `unknown`. The persisted record's structured `error` marker is stripped by
|
||||
* `getSessionMessages`, and the CLI does not always set it to
|
||||
* `authentication_failed` anyway: an expired login with no refresh token
|
||||
* ("Login expired · Run /login to sign in again, …") arrives as
|
||||
* `invalid_request`. So the synthetic model + text is what both paths match on.
|
||||
* Usage-limit banners are excluded because some of them also mention `/login`
|
||||
* (to switch accounts) and have their own handling.
|
||||
*/
|
||||
export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
|
||||
if (!apiMessage || typeof apiMessage !== "object") {
|
||||
return false;
|
||||
}
|
||||
const { model, content } = apiMessage as { model?: unknown; content?: unknown };
|
||||
if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const block = content[0] as { type?: unknown; text?: unknown } | null;
|
||||
return (
|
||||
!!block &&
|
||||
block.type === "text" &&
|
||||
typeof block.text === "string" &&
|
||||
isLoginRequiredText(block.text) &&
|
||||
!isSyntheticUsageLimitMessage(apiMessage)
|
||||
);
|
||||
}
|
||||
|
||||
/** Origin kinds of the meta user messages `getSessionMessages` returns since
|
||||
* SDK 0.3.284 (messages from other agents, sessions and channels). */
|
||||
const REPLAY_HIDDEN_META_ORIGIN_KINDS = new Set([
|
||||
"peer",
|
||||
"channel",
|
||||
"observer",
|
||||
"observer-activity",
|
||||
"slack-ping",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True for a transcript message delivered to the model from another agent,
|
||||
* session or channel. Since SDK 0.3.284 `getSessionMessages` returns these as
|
||||
* `is_meta` user messages whose content is the full harness framing (envelope
|
||||
* XML plus the "not typed by your user" preamble). The live prompt loop never
|
||||
* renders them, so replay skips them too rather than presenting them as text
|
||||
* the user typed. Other `is_meta` messages (compact summaries) are kept.
|
||||
*
|
||||
* `is_meta` and `origin` are runtime fields not declared on `SessionMessage`.
|
||||
*/
|
||||
export function isReplayHiddenMetaMessage(message: unknown): boolean {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const { type, is_meta, origin } = message as {
|
||||
type?: unknown;
|
||||
is_meta?: unknown;
|
||||
origin?: { kind?: unknown } | null;
|
||||
};
|
||||
return (
|
||||
type === "user" &&
|
||||
is_meta === true &&
|
||||
typeof origin?.kind === "string" &&
|
||||
REPLAY_HIDDEN_META_ORIGIN_KINDS.has(origin.kind)
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import path from "node:path";
|
||||
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
import { MODEL_CONFIG_ID } from "./session-model.js";
|
||||
import { orderedSubagentConfiguration } from "./subagent-configuration.js";
|
||||
import { isFinalToolCallStatus } from "./tool-calls/renderer.js";
|
||||
|
||||
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
|
||||
@@ -131,7 +130,7 @@ type TaskStarted = {
|
||||
prompt?: unknown;
|
||||
};
|
||||
|
||||
export type SubagentIdentity = {
|
||||
type SubagentIdentity = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
@@ -293,6 +292,30 @@ export class NativeSubagentRuntime {
|
||||
return child?.announced ? child.workState : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: the `subagent_update` that tells a client that holds nothing
|
||||
* of the announced child `sessionId` of this live session what it is now:
|
||||
* its labels, its directory, its `cancel` capability, and its work state.
|
||||
* Undefined when no such child runs here.
|
||||
*/
|
||||
currentUpdate(sessionId: string): AcpSessionNotification | undefined {
|
||||
if (this.form !== "rfd") return undefined;
|
||||
const child = this.childOfSession(sessionId);
|
||||
if (!child?.announced || !child.workState) return undefined;
|
||||
return {
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...(child.description ? { description: child.description } : {}),
|
||||
...(child.reportedCwd ? { cwd: child.reportedCwd } : {}),
|
||||
capabilities: { cancel: {} },
|
||||
state: { state: child.workState },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the model of a child response, an exact model id, as the entry of
|
||||
* the root session's `model` option that matches it, else as the id (see
|
||||
@@ -389,7 +412,14 @@ export class NativeSubagentRuntime {
|
||||
// so a delayed publication cannot replace newer settings with older ones.
|
||||
const publishing = config.publishing.then(async () => {
|
||||
if (this.configBySession.get(child.sessionId) !== config || ended()) return;
|
||||
const configOptions = orderedSubagentConfiguration(config.options.values());
|
||||
const priority = ["mode", "model", "thought_level", "model_config"];
|
||||
const rank = (option: SessionConfigOption) => {
|
||||
const index = priority.indexOf(option.category ?? "");
|
||||
return index < 0 ? priority.length : index;
|
||||
};
|
||||
const configOptions = [...config.options.values()].sort(
|
||||
(left, right) => rank(left) - rank(right),
|
||||
);
|
||||
const snapshot = JSON.stringify(configOptions);
|
||||
if (config.published === snapshot) return;
|
||||
await this.publish({
|
||||
@@ -1948,7 +1978,7 @@ function isWorking(child: NativeSubagent): boolean {
|
||||
* A failure is idle with the `error` stop reason and, when the SDK gives its
|
||||
* text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt
|
||||
* response, so this is how a client learns that its work failed. The error is
|
||||
* an internal error, as for a failed turn on v2.
|
||||
* an internal error, as for a failed turn on the v2 surface.
|
||||
*
|
||||
* A task that a worker restart orphaned, or that was still running when the
|
||||
* stream ended, is idle with no stop reason: its work ended, but the adapter
|
||||
@@ -2103,7 +2133,7 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole
|
||||
* one), a SendMessage call whose message reached no announced child, or a
|
||||
* failed SubagentHandback call.
|
||||
*/
|
||||
export function unexposedControlFallback(
|
||||
function unexposedControlFallback(
|
||||
initial: AcpSessionNotification | undefined,
|
||||
terminal: AcpSessionNotification,
|
||||
sessionId: string,
|
||||
@@ -2197,8 +2227,7 @@ function subagentDescription(prompt: unknown, description: unknown): string {
|
||||
return "Delegated task";
|
||||
}
|
||||
|
||||
/** The labels and prompt of a child, from the input of the Agent or Task call that launched it. */
|
||||
export function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return undefined;
|
||||
const value = input as Record<string, unknown>;
|
||||
const identity: SubagentIdentity = {
|
||||
@@ -2210,7 +2239,7 @@ export function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
return Object.values(identity).some(Boolean) ? identity : undefined;
|
||||
}
|
||||
|
||||
export function mergeSubagentIdentity(
|
||||
function mergeSubagentIdentity(
|
||||
previous: SubagentIdentity | undefined,
|
||||
next: SubagentIdentity,
|
||||
): SubagentIdentity {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Options, SDKMessageOrigin } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export type SDKMessageFilter = {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
origin?: SDKMessageOrigin["kind"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra metadata that can be given when creating a new session.
|
||||
*/
|
||||
export type NewSessionMeta = {
|
||||
claudeCode?: {
|
||||
/**
|
||||
* Options forwarded to Claude Code when starting a new session.
|
||||
* Those parameters will be ignored and managed by ACP:
|
||||
* - cwd
|
||||
* - includePartialMessages
|
||||
* - permissionMode
|
||||
* - canUseTool
|
||||
* - executable
|
||||
* Those parameters will be used and updated to work with ACP:
|
||||
* - hooks (merged with ACP's hooks)
|
||||
* - mcpServers (merged with ACP's mcpServers)
|
||||
* - disallowedTools (merged with ACP's disallowedTools)
|
||||
* - tools (passed through; defaults to claude_code preset if not provided)
|
||||
* - allowDangerouslySkipPermissions (set to `false` to remove bypassPermissions
|
||||
* from this session; repeat it on session/load. `true` cannot override root)
|
||||
*/
|
||||
options?: Options;
|
||||
/**
|
||||
* When set, raw SDK messages are emitted as extNotification("_claude/sdkMessage", message)
|
||||
* in addition to normal processing.
|
||||
* - true: emit all messages
|
||||
* - false/undefined: emit nothing (default)
|
||||
* - SDKMessageFilter[]: emit only messages matching at least one filter
|
||||
*/
|
||||
emitRawSDKMessages?: boolean | SDKMessageFilter[];
|
||||
};
|
||||
additionalRoots?: string[];
|
||||
};
|
||||
@@ -120,64 +120,18 @@ export function buildClaudePermissionPresentation(
|
||||
}),
|
||||
// Only AIR gets the permission presentation.
|
||||
...(title && capabilities.air.client
|
||||
? { _meta: airPermissionMeta({ title, description, defaultToNo: value.defaultToNo }) }
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_PERMISSION_KEY, {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// The CLI's own hint, forwarded so a client that can pre-select
|
||||
// an option keeps the decline focused; the option order already
|
||||
// leads with the reject options when this is set.
|
||||
...(value.defaultToNo === true ? { defaultToNo: true } : {}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta.jetbrains.air.permission` of a permission request, from the
|
||||
* heading and explanation that {@link buildClaudePermissionPresentation}
|
||||
* built. The v2 core adds it for an AIR client, whose tool calls it
|
||||
* renders without the AIR tool call contract.
|
||||
*/
|
||||
export function airPermissionMeta({
|
||||
title,
|
||||
description,
|
||||
defaultToNo,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
defaultToNo?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
return withAirMeta(undefined, AIR_PERMISSION_KEY, {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// The CLI's own hint, forwarded so a client that can pre-select an
|
||||
// option keeps the decline focused; the option order already leads with
|
||||
// the reject options when this is set.
|
||||
...(defaultToNo === true ? { defaultToNo: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta.claudeCode` of a permission request's tool call: which MCP
|
||||
* server serves an `mcp__*` tool and where its definition came from
|
||||
* (`mcpServer`, SDK 0.3.274+), so a client can key trust on `source`, and,
|
||||
* for clients other than AIR, the tool name and the Agent or Task call of a
|
||||
* subagent's request. Undefined when there is nothing to add.
|
||||
*/
|
||||
export function permissionToolCallMeta({
|
||||
toolName,
|
||||
parentToolUseId,
|
||||
mcpServer,
|
||||
airClient,
|
||||
}: {
|
||||
toolName: string;
|
||||
parentToolUseId: string | undefined;
|
||||
mcpServer: { name: string; source: string } | undefined;
|
||||
airClient: boolean;
|
||||
}): { claudeCode: Record<string, unknown> } | undefined {
|
||||
if (!mcpServer && !(parentToolUseId && !airClient)) return undefined;
|
||||
// The name is the config key as authored: untrusted text, so it rides
|
||||
// `_meta` rather than the title.
|
||||
return {
|
||||
claudeCode: {
|
||||
...(airClient ? {} : { toolName }),
|
||||
...(parentToolUseId && !airClient ? { parentToolUseId } : {}),
|
||||
...(mcpServer ? { mcpServer: { name: mcpServer.name, source: mcpServer.source } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
110
src/plans.ts
110
src/plans.ts
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* The session updates that show the session's plans. A client with the `plan`
|
||||
* capability (every v2 client, and a v1 client that asks for it) tracks plans
|
||||
* by ID and gets `plan_update` and `plan_removed`; any other client gets the
|
||||
* legacy `plan` update, which holds one ID-less plan per session.
|
||||
*/
|
||||
import type {
|
||||
PlanEntry,
|
||||
PlanItems,
|
||||
PlanRemoved,
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { PLAN_MODE_PLAN_ID } from "./exit-plan.js";
|
||||
|
||||
type PlanRemovalUpdate = PlanRemoved & { sessionUpdate: "plan_removed" };
|
||||
|
||||
/** The ID of the session's checklist, from TodoWrite and the Task tools. Every
|
||||
* update replaces it whole. An approved ExitPlanMode plan is a separate plan
|
||||
* ({@link PLAN_MODE_PLAN_ID}), and so is a subagent's TodoWrite checklist in
|
||||
* its parent's session ({@link subagentChecklistPlanId}). */
|
||||
export const CHECKLIST_PLAN_ID = "plan";
|
||||
|
||||
/** The ID of the TodoWrite checklist of the subagent that the tool call
|
||||
* `parentToolUseId` launched, when the checklist goes to its parent's session.
|
||||
* Claude Code keeps a TodoWrite list per agent, so it is a separate plan from
|
||||
* the main conversation's checklist. The Task tools share one task list
|
||||
* between all agents of a session, so their checklist is always
|
||||
* {@link CHECKLIST_PLAN_ID}. */
|
||||
export function subagentChecklistPlanId(parentToolUseId: string): string {
|
||||
return `${CHECKLIST_PLAN_ID}:${parentToolUseId}`;
|
||||
}
|
||||
|
||||
/** The plan operation that shows the checklist `entries` as the plan `planId`:
|
||||
* `plan_update`, or `plan_removed` for an empty checklist. */
|
||||
export function checklistPlanOperation(
|
||||
entries: PlanEntry[],
|
||||
planId: string = CHECKLIST_PLAN_ID,
|
||||
): { sessionUpdate: "plan_update"; plan: PlanItems & { type: "items" } } | PlanRemovalUpdate {
|
||||
if (entries.length === 0) return { sessionUpdate: "plan_removed", planId };
|
||||
return { sessionUpdate: "plan_update", plan: { type: "items", planId, entries } };
|
||||
}
|
||||
|
||||
/** The update that shows the checklist `entries`: a plan operation (see
|
||||
* {@link checklistPlanOperation}), else a legacy `plan`, which with no entries
|
||||
* clears the client's plan. */
|
||||
export function checklistPlanUpdate(
|
||||
entries: PlanEntry[],
|
||||
planOperations: boolean,
|
||||
): SessionNotification["update"] {
|
||||
return planOperations ? checklistPlanOperation(entries) : { sessionUpdate: "plan", entries };
|
||||
}
|
||||
|
||||
/** The plan IDs of the subagent checklists that the client holds in a session
|
||||
* (see {@link subagentChecklistPlanId}). */
|
||||
export type SubagentChecklists = Set<string>;
|
||||
|
||||
/**
|
||||
* The update that shows the TodoWrite checklist `entries` of the agent that
|
||||
* made the call: the main conversation, or the subagent that the tool call
|
||||
* `parentToolUseId` launched.
|
||||
*
|
||||
* A subagent's checklist gets its own plan ID, recorded in `shown` while the
|
||||
* client holds it, when it goes to the parent's session. It does not with
|
||||
* `childSessions` (native subagent sessions), where it goes to the subagent's
|
||||
* own session, which holds no other checklist. A client without plan
|
||||
* operations holds one plan per session, so there a subagent's checklist
|
||||
* replaces the main one.
|
||||
*/
|
||||
export function todoWritePlanUpdate(
|
||||
entries: PlanEntry[],
|
||||
options: {
|
||||
planOperations: boolean;
|
||||
parentToolUseId?: string | null;
|
||||
childSessions: boolean;
|
||||
shown?: SubagentChecklists;
|
||||
},
|
||||
): SessionNotification["update"] {
|
||||
const { planOperations, parentToolUseId, childSessions, shown } = options;
|
||||
if (!planOperations || !parentToolUseId || childSessions) {
|
||||
return checklistPlanUpdate(entries, planOperations);
|
||||
}
|
||||
const planId = subagentChecklistPlanId(parentToolUseId);
|
||||
if (entries.length === 0) shown?.delete(planId);
|
||||
else shown?.add(planId);
|
||||
return checklistPlanOperation(entries, planId);
|
||||
}
|
||||
|
||||
/** The updates that dismiss the plans of a conversation that a reset (`/clear`)
|
||||
* replaced: the checklist, the subagent checklists in `subagentChecklists`
|
||||
* (which it then empties), and the approved ExitPlanMode plan. AIR keeps its
|
||||
* approved plans, each joined to its approval card in the transcript (see
|
||||
* planModeUpdate). Without plan operations, a legacy empty `plan` clears the
|
||||
* one plan the client holds. */
|
||||
export function resetPlanUpdates(
|
||||
planOperations: boolean,
|
||||
airClient: boolean,
|
||||
subagentChecklists?: SubagentChecklists,
|
||||
): SessionNotification["update"][] {
|
||||
const updates = [checklistPlanUpdate([], planOperations)];
|
||||
if (planOperations) {
|
||||
for (const planId of subagentChecklists ?? []) {
|
||||
updates.push({ sessionUpdate: "plan_removed", planId });
|
||||
}
|
||||
}
|
||||
subagentChecklists?.clear();
|
||||
if (planOperations && !airClient) {
|
||||
updates.push({ sessionUpdate: "plan_removed", planId: PLAN_MODE_PLAN_ID });
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
@@ -42,51 +42,3 @@ export function promptSuggestionsMeta(text: string | null): Record<string, unkno
|
||||
const value: PromptSuggestionsMetaValue = { suggestions: text === null ? [] : [{ text }] };
|
||||
return { [PROMPT_SUGGESTIONS_META_KEY]: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* The prediction a session holds on the client, and whether an arriving
|
||||
* `prompt_suggestion` still answers the turn the user waits on. Both cores
|
||||
* keep one per session and send the `_meta` that its methods return as a
|
||||
* `session_info_update`, to an AIR client only. Undefined: nothing to send.
|
||||
*/
|
||||
export class PromptSuggestions {
|
||||
/** The last text sent (`null` after a clear). Undefined until the first send. */
|
||||
private published: string | null | undefined;
|
||||
/**
|
||||
* Set at a turn's result, cleared when the next prompt arrives. A
|
||||
* prediction that the next prompt outran (sent before the prediction was
|
||||
* read off the stream) follows a turn that is answered already, so it is
|
||||
* dropped instead of published mid-turn.
|
||||
*/
|
||||
private expected = false;
|
||||
|
||||
/** A result came: the CLI predicts the next prompt right after it. */
|
||||
resultArrived(): void {
|
||||
this.expected = true;
|
||||
}
|
||||
|
||||
/** The `_meta` of the CLI's `prompt_suggestion`, if it answers the last result and changes what the client holds. */
|
||||
suggested(raw: string | null | undefined): Record<string, unknown> | undefined {
|
||||
if (!this.expected) return undefined;
|
||||
this.expected = false;
|
||||
return this.publish(promptSuggestionText(raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta` that clears the prediction: a client prompt spent it, or a
|
||||
* `conversation_reset` dropped the transcript it came from. It also drops
|
||||
* a prediction still in flight for the last result. Undefined when the
|
||||
* client holds none.
|
||||
*/
|
||||
clear(): Record<string, unknown> | undefined {
|
||||
this.expected = false;
|
||||
return this.publish(null);
|
||||
}
|
||||
|
||||
private publish(text: string | null): Record<string, unknown> | undefined {
|
||||
// An unchanged prediction is not sent again.
|
||||
if ((this.published ?? null) === text) return undefined;
|
||||
this.published = text;
|
||||
return promptSuggestionsMeta(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* The conversion of an ACP prompt into the Claude Code user message.
|
||||
*/
|
||||
import type { PromptRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { blobResourceToClaude } from "./blob-resources.js";
|
||||
|
||||
function formatUriAsLink(uri: string): string {
|
||||
try {
|
||||
if (uri.startsWith("file://")) {
|
||||
const path = uri.slice(7); // Remove "file://"
|
||||
const name = path.split("/").pop() || path;
|
||||
return `[@${name}](${uri})`;
|
||||
} else if (uri.startsWith("zed://")) {
|
||||
const parts = uri.split("/");
|
||||
const name = parts[parts.length - 1] || uri;
|
||||
return `[@${name}](${uri})`;
|
||||
}
|
||||
return uri;
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
/** The prompt content block types that `promptToClaude` converts. The agent
|
||||
* advertises no `audio` prompt capability. */
|
||||
export const SUPPORTED_PROMPT_CONTENT_TYPES: readonly string[] = [
|
||||
"text",
|
||||
"image",
|
||||
"resource_link",
|
||||
"resource",
|
||||
];
|
||||
|
||||
export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
const content: any[] = [];
|
||||
const context: any[] = [];
|
||||
|
||||
const firstText = prompt.prompt.find((chunk) => chunk.type === "text");
|
||||
for (const chunk of prompt.prompt) {
|
||||
switch (chunk.type) {
|
||||
case "text": {
|
||||
let text = chunk.text;
|
||||
// A slash command is the first text block. Change
|
||||
// `/mcp:server:command args` to `/server:command (MCP) args`; the
|
||||
// arguments can span lines.
|
||||
const mcpMatch =
|
||||
chunk === firstText ? text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s+([\s\S]*))?$/) : null;
|
||||
if (mcpMatch) {
|
||||
const [, server, command, args] = mcpMatch;
|
||||
text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
|
||||
}
|
||||
content.push({ type: "text", text });
|
||||
break;
|
||||
}
|
||||
case "resource_link": {
|
||||
const formattedUri = formatUriAsLink(chunk.uri);
|
||||
content.push({
|
||||
type: "text",
|
||||
text: formattedUri,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "resource": {
|
||||
const { uri } = chunk.resource;
|
||||
// A blob resource becomes an image or document block, or its decoded
|
||||
// text. A v1 prompt skips a blob of any other type, e.g.
|
||||
// `application/zip`; a v2 prompt rejects it (see `v1PromptRequest`).
|
||||
const blob = "blob" in chunk.resource ? blobResourceToClaude(chunk.resource) : undefined;
|
||||
const text =
|
||||
"text" in chunk.resource
|
||||
? chunk.resource.text
|
||||
: blob?.kind === "text"
|
||||
? blob.text
|
||||
: undefined;
|
||||
if (blob?.kind === "image" || blob?.kind === "document") {
|
||||
content.push({ type: "text", text: formatUriAsLink(uri) }, blob.block);
|
||||
} else if (text !== undefined) {
|
||||
content.push({ type: "text", text: formatUriAsLink(uri) });
|
||||
context.push({
|
||||
type: "text",
|
||||
text: `\n<context ref="${uri}">\n${text}\n</context>`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "image":
|
||||
if (chunk.data) {
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
data: chunk.data,
|
||||
media_type: chunk.mimeType,
|
||||
},
|
||||
});
|
||||
} else if (chunk.uri && chunk.uri.startsWith("http")) {
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "url",
|
||||
url: chunk.uri,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
// Ignore audio and other unsupported types
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
content.push(...context);
|
||||
|
||||
return {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: content,
|
||||
},
|
||||
session_id: prompt.sessionId,
|
||||
parent_tool_use_id: null,
|
||||
// ACP prompts are the user's own input relayed by the client. Stamp the
|
||||
// provenance explicitly: per the SDK, a host wrapping keyboard input must
|
||||
// send `{kind: "human"}` — an absent `origin` is treated as unattributed
|
||||
// and fails closed at the CLI's strict isHuman() trust gates (e.g. the
|
||||
// ultracode keyword opt-in honors only human-originated turns).
|
||||
origin: { kind: "human" },
|
||||
};
|
||||
}
|
||||
421
src/providers.ts
421
src/providers.ts
@@ -1,421 +0,0 @@
|
||||
/**
|
||||
* Client-managed LLM routing, shared by the v1 core and the v2 core:
|
||||
* the `main` provider of `providers/list`, `providers/set` and
|
||||
* `providers/disable`, and the legacy gateway logins (`authenticate` or
|
||||
* `auth/login` with `gateway` or `gateway-bedrock`). Each core holds its own
|
||||
* {@link ProviderConfig} and gateway login, and builds the query env of its
|
||||
* sessions with {@link createEnvForProvider}.
|
||||
*/
|
||||
import {
|
||||
RequestError,
|
||||
type AuthenticateRequest,
|
||||
type AuthMethod,
|
||||
type ListProvidersResponse,
|
||||
type LlmProtocol,
|
||||
type ProviderInfo,
|
||||
type SetProviderRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
GatewayListingError,
|
||||
listBedrockModels,
|
||||
listGatewayModels,
|
||||
PROVIDER_AUTH_TOKEN_PLACEHOLDER,
|
||||
selectBedrockRegion,
|
||||
} from "./gateway-models.js";
|
||||
|
||||
type ProviderLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
|
||||
/**
|
||||
* Extra metadata for 'gateway' authentication requests.
|
||||
*/
|
||||
export type GatewayAuthMeta = {
|
||||
/**
|
||||
* These parameters are mapped to environment variables to:
|
||||
* - Redirect API calls via baseUrl
|
||||
* - Inject custom headers
|
||||
* - Bypass the default Claude login requirement
|
||||
*
|
||||
* Both members are optional in the type because the payload arrives
|
||||
* unvalidated from the client. `authenticate` rejects a request that lacks a
|
||||
* usable `baseUrl`.
|
||||
*/
|
||||
gateway?: {
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta };
|
||||
|
||||
export const SUPPORTED_PROTOCOLS: LlmProtocol[] = ["anthropic", "bedrock", "vertex"];
|
||||
export const PROVIDER_ID = "main";
|
||||
export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
export const DEFAULT_VERTEX_BASE_URL = "https://aiplatform.googleapis.com";
|
||||
|
||||
/**
|
||||
* Vertex needs project + region that the standard `providers/set` payload
|
||||
* (`apiType`/`baseUrl`/`headers`) does not model, so clients pass them through
|
||||
* `_meta.claudeCode.vertex`. Required only when `apiType === "vertex"`.
|
||||
*/
|
||||
export type SetProviderMeta = {
|
||||
claudeCode?: {
|
||||
vertex?: {
|
||||
projectId: string;
|
||||
region: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolved, non-secret + secret routing config for the `main` provider. This is
|
||||
* the shared shape produced by both `providers/set` and the legacy gateway auth
|
||||
* path, and consumed by {@link createEnvForProvider}. `null` means the provider
|
||||
* is unconfigured (no client-managed routing in effect).
|
||||
*/
|
||||
export type ProviderConfig = {
|
||||
apiType: LlmProtocol;
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
/** Present only for `apiType === "vertex"`. */
|
||||
vertex?: {
|
||||
projectId: string;
|
||||
region: string;
|
||||
};
|
||||
/**
|
||||
* Model ids an `anthropic` or `bedrock` gateway listed when it was set via
|
||||
* `providers/set`; the model picker offers only these. Absent when the
|
||||
* listing failed or the config came from elsewhere (gateway `authenticate`),
|
||||
* so nothing is narrowed.
|
||||
*/
|
||||
listedModels?: string[];
|
||||
/**
|
||||
* The cross-region prefix of the model ids a `bedrock` gateway
|
||||
* listed (`eu` for `eu.anthropic.claude-sonnet-5`), passed to the CLI as
|
||||
* `ANTHROPIC_BEDROCK_REGION_PREFIX` so it calls the models under that prefix.
|
||||
*/
|
||||
bedrockRegionPrefix?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adapt a legacy gateway `authenticate` request into the shared
|
||||
* {@link ProviderConfig} shape. Returns `null` when no gateway request is
|
||||
* present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
|
||||
* otherwise anthropic.
|
||||
*/
|
||||
export function gatewayRequestToProviderConfig(
|
||||
request?: GatewayAuthRequest,
|
||||
): ProviderConfig | null {
|
||||
// `authenticate` validates the payload before it stores one, so a stored
|
||||
// request always carries a usable gateway. Re-check the shape here anyway:
|
||||
// this function decides whether a provider override is active, and the
|
||||
// `--hide-claude-auth` guard is off while one is.
|
||||
const gateway = request?._meta?.gateway;
|
||||
if (!gateway || !isValidBaseUrl(gateway.baseUrl)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
apiType: request?.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
|
||||
baseUrl: gateway.baseUrl,
|
||||
headers: gateway.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a resolved provider config into the Claude Code env vars that redirect API
|
||||
* traffic and inject headers. Returns an empty object when routing is
|
||||
* unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
|
||||
* skips its normal login/credential checks when a gateway is in use.
|
||||
*/
|
||||
export function createEnvForProvider(config: ProviderConfig | null): Record<string, string> {
|
||||
if (!config) {
|
||||
return {};
|
||||
}
|
||||
const resetRouting = {
|
||||
ANTHROPIC_BASE_URL: "",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "",
|
||||
ANTHROPIC_VERTEX_BASE_URL: "",
|
||||
CLAUDE_CODE_USE_BEDROCK: "0",
|
||||
CLAUDE_CODE_USE_VERTEX: "0",
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: "",
|
||||
CLOUD_ML_REGION: "",
|
||||
AWS_REGION: "",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "",
|
||||
};
|
||||
const customHeaders = Object.entries(config.headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n");
|
||||
|
||||
if (config.apiType === "bedrock") {
|
||||
return {
|
||||
...resetRouting,
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
AWS_BEARER_TOKEN_BEDROCK: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local AWS credential checks
|
||||
ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
...(config.bedrockRegionPrefix
|
||||
? { ANTHROPIC_BEDROCK_REGION_PREFIX: config.bedrockRegionPrefix }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (config.apiType === "vertex") {
|
||||
// `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
|
||||
// validation; fall back to empty strings defensively.
|
||||
// Leaving ANTHROPIC_VERTEX_BASE_URL empty for the default endpoint lets
|
||||
// Claude Code use its native Vertex endpoint/model resolution. Supplying
|
||||
// even the default host marks the session as a custom Vertex deployment and
|
||||
// can hide otherwise-valid models.
|
||||
return {
|
||||
...resetRouting,
|
||||
CLAUDE_CODE_USE_VERTEX: "1",
|
||||
...(config.baseUrl !== DEFAULT_VERTEX_BASE_URL
|
||||
? { ANTHROPIC_VERTEX_BASE_URL: config.baseUrl }
|
||||
: {}),
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
|
||||
CLOUD_ML_REGION: config.vertex?.region ?? "",
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...resetRouting,
|
||||
ANTHROPIC_BASE_URL: config.baseUrl,
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
ANTHROPIC_AUTH_TOKEN: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local Claude login checks
|
||||
};
|
||||
}
|
||||
|
||||
function sameHeaders(a: Record<string, string>, b: Record<string, string>): boolean {
|
||||
const aKeys = Object.keys(a);
|
||||
return aKeys.length === Object.keys(b).length && aKeys.every((key) => b[key] === a[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider base URL as logs may show it: its origin only. The URL can come
|
||||
* from the env or the client and can carry credentials (`https://user:key@host`)
|
||||
* or a key in its path or query, so a log line must not print it whole.
|
||||
*/
|
||||
export function loggableBaseUrl(baseUrl: string | undefined): string {
|
||||
if (baseUrl === undefined) return "native";
|
||||
try {
|
||||
return new URL(baseUrl).origin;
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a provider base URL: must be a non-empty absolute http(s) URL.
|
||||
*/
|
||||
export function isValidBaseUrl(baseUrl: string | undefined): baseUrl is string {
|
||||
if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
}
|
||||
|
||||
/** The gateway login methods, advertised to a client with `auth._meta.gateway`. */
|
||||
export function gatewayAuthMethods(): AuthMethod[] {
|
||||
return (["anthropic", "bedrock"] as const).map((protocol) => ({
|
||||
id: protocol === "anthropic" ? "gateway" : "gateway-bedrock",
|
||||
name: "Custom model gateway",
|
||||
description: "Use a custom gateway to authenticate and access models",
|
||||
_meta: { gateway: { protocol } },
|
||||
}));
|
||||
}
|
||||
|
||||
export function isGatewayAuthMethod(methodId: string): boolean {
|
||||
return methodId === "gateway" || methodId === "gateway-bedrock";
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway login `params` asks for, once its payload is usable: the same
|
||||
* base URL rule as `providers/set`. An unchecked payload either throws a
|
||||
* `TypeError` deep in session creation, or installs an empty base URL that
|
||||
* silently turns the `--hide-claude-auth` subscription guard off.
|
||||
*
|
||||
* A request that carries no gateway payload at all keeps its historical
|
||||
* meaning: it installs no override and succeeds. A client that probes the
|
||||
* method this way must keep working. Only a payload that IS present has to be
|
||||
* usable.
|
||||
*/
|
||||
export function validatedGatewayAuthRequest(params: AuthenticateRequest): GatewayAuthRequest {
|
||||
const gateway = (params as GatewayAuthRequest)._meta?.gateway;
|
||||
if (gateway !== undefined && gateway !== null) {
|
||||
if (typeof gateway !== "object" || !isValidBaseUrl(gateway.baseUrl)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ baseUrl: (gateway as { baseUrl?: unknown }).baseUrl },
|
||||
"`_meta.gateway.baseUrl` must be a non-empty absolute http(s) URL.",
|
||||
);
|
||||
}
|
||||
if (gateway.headers !== undefined && typeof gateway.headers !== "object") {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"`_meta.gateway.headers` must be an object of header names to values.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return params as GatewayAuthRequest;
|
||||
}
|
||||
|
||||
/** The native routing of the agent: a gateway login, else the process env. */
|
||||
export function defaultProviderConfig(gatewayAuthRequest?: GatewayAuthRequest): ProviderConfig {
|
||||
const gatewayConfig = gatewayRequestToProviderConfig(gatewayAuthRequest);
|
||||
if (gatewayConfig) {
|
||||
return gatewayConfig;
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK) {
|
||||
return {
|
||||
apiType: "bedrock",
|
||||
baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX) {
|
||||
return {
|
||||
apiType: "vertex",
|
||||
baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? DEFAULT_VERTEX_BASE_URL,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
apiType: "anthropic",
|
||||
baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** The `providers/list` response for the `main` provider routed by `config`. */
|
||||
export function listProvidersResponse(config: ProviderConfig): ListProvidersResponse {
|
||||
const provider: ProviderInfo = {
|
||||
providerId: PROVIDER_ID,
|
||||
supported: SUPPORTED_PROTOCOLS,
|
||||
required: false,
|
||||
current: { apiType: config.apiType, baseUrl: config.baseUrl },
|
||||
};
|
||||
return { providers: [provider] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration a `providers/set` request asks for. Rejects unknown IDs,
|
||||
* unsupported protocols, empty or invalid base URLs, and a `vertex` request
|
||||
* without its project and region, with `invalid_params`.
|
||||
*/
|
||||
export function providerConfigFromSetRequest(params: SetProviderRequest): ProviderConfig {
|
||||
if (params.providerId !== PROVIDER_ID) {
|
||||
throw RequestError.invalidParams(
|
||||
{ providerId: params.providerId },
|
||||
`Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`,
|
||||
);
|
||||
}
|
||||
if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ apiType: params.apiType, supported: SUPPORTED_PROTOCOLS },
|
||||
`Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`,
|
||||
);
|
||||
}
|
||||
if (!isValidBaseUrl(params.baseUrl)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ baseUrl: params.baseUrl },
|
||||
"baseUrl must be a non-empty absolute http(s) URL.",
|
||||
);
|
||||
}
|
||||
|
||||
const config: ProviderConfig = {
|
||||
apiType: params.apiType,
|
||||
baseUrl: params.baseUrl,
|
||||
headers: params.headers ?? {},
|
||||
};
|
||||
|
||||
// Vertex requires project + region, which the standard payload cannot
|
||||
// carry, so they arrive via `_meta.claudeCode.vertex`.
|
||||
if (params.apiType === "vertex") {
|
||||
const vertex = (params._meta as SetProviderMeta | undefined)?.claudeCode?.vertex;
|
||||
if (
|
||||
!vertex ||
|
||||
typeof vertex.projectId !== "string" ||
|
||||
vertex.projectId.trim() === "" ||
|
||||
typeof vertex.region !== "string" ||
|
||||
vertex.region.trim() === ""
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.",
|
||||
);
|
||||
}
|
||||
config.vertex = { projectId: vertex.projectId, region: vertex.region };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds to `config` the model ids its gateway lists, and for `bedrock` the
|
||||
* region prefix picked from them, so the sessions it routes offer only those.
|
||||
* Setting the same gateway as `current` again reuses its listing. `vertex`
|
||||
* gateways are not listed. A failed listing throws an `internal_error`.
|
||||
*/
|
||||
export async function withListedModels(
|
||||
config: ProviderConfig,
|
||||
current: ProviderConfig | undefined,
|
||||
logger: ProviderLogger,
|
||||
): Promise<ProviderConfig> {
|
||||
try {
|
||||
Object.assign(config, await listProviderModels(config, current, logger));
|
||||
} catch (error) {
|
||||
if (error instanceof GatewayListingError) {
|
||||
throw RequestError.internalError(
|
||||
{ providerId: PROVIDER_ID, apiType: config.apiType, baseUrl: config.baseUrl },
|
||||
`Model discovery for provider "${PROVIDER_ID}" failed: ${error.message}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
logger.log(
|
||||
`[providers/set] listedModels=${config.listedModels?.length ?? "none"} bedrockRegionPrefix=${config.bedrockRegionPrefix ?? "none"}`,
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
async function listProviderModels(
|
||||
config: ProviderConfig,
|
||||
current: ProviderConfig | undefined,
|
||||
logger: ProviderLogger,
|
||||
): Promise<Pick<ProviderConfig, "listedModels" | "bedrockRegionPrefix">> {
|
||||
// Reuse needs the very same headers, so a rotated token lists again, and a
|
||||
// failed listing then fails that `providers/set`. Deliberate: AIR keeps its
|
||||
// proxy key for the IDE's lifetime and does not rotate it.
|
||||
if (
|
||||
current?.listedModels &&
|
||||
current.apiType === config.apiType &&
|
||||
current.baseUrl === config.baseUrl &&
|
||||
sameHeaders(current.headers, config.headers)
|
||||
) {
|
||||
return {
|
||||
listedModels: current.listedModels,
|
||||
bedrockRegionPrefix: current.bedrockRegionPrefix,
|
||||
};
|
||||
}
|
||||
if (config.apiType === "anthropic") {
|
||||
return { listedModels: await listGatewayModels(config.baseUrl, config.headers, logger) };
|
||||
}
|
||||
if (config.apiType === "bedrock") {
|
||||
const modelIds = await listBedrockModels(config.baseUrl, config.headers, logger);
|
||||
if (!modelIds) return {};
|
||||
const region = selectBedrockRegion(modelIds);
|
||||
if (region.modelIds.length < modelIds.length) {
|
||||
logger.log(
|
||||
`[providers/set] Bedrock models span several region prefixes; using ${region.regionPrefix} and dropping ${modelIds.length - region.modelIds.length} id(s) of other prefixes`,
|
||||
);
|
||||
}
|
||||
return { listedModels: region.modelIds, bedrockRegionPrefix: region.regionPrefix };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* `/remote-control`, which the adapter serves itself, and the messages that a
|
||||
* person types in another client of the session (claude.ai or the Claude
|
||||
* mobile app, over Remote Control). Shared by the v1 core and the v2
|
||||
* core, which run the command each in their own turn model.
|
||||
*/
|
||||
/** The content blocks of a prompt, in v1 or v2 form: the command is one text block. */
|
||||
type PromptContent = ReadonlyArray<{ type: string; text?: unknown }>;
|
||||
|
||||
import type {
|
||||
Query,
|
||||
SDKUserMessage,
|
||||
SDKUserMessageReplay,
|
||||
SlashCommand,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Runtime Remote Control control exposed by the pinned Agent SDK. Like
|
||||
* `mcpAuthenticate`, it is not in the public `Query` declaration yet. The CLI's
|
||||
* own `/remote-control` is terminal-bound and not offered to SDK hosts, so the
|
||||
* adapter handles the command itself and answers with the session URL. */
|
||||
export type RemoteControlQuery = Query & {
|
||||
enableRemoteControl(
|
||||
enabled: boolean,
|
||||
name?: string,
|
||||
): Promise<{ session_url?: string; connect_url?: string }>;
|
||||
};
|
||||
|
||||
export function supportsRemoteControl(query: Query): query is RemoteControlQuery {
|
||||
return typeof (query as Partial<RemoteControlQuery>).enableRemoteControl === "function";
|
||||
}
|
||||
|
||||
export const REMOTE_CONTROL_COMMAND_NAME = "remote-control";
|
||||
|
||||
export type RemoteControlCommand = { action: "connect"; name?: string } | { action: "disconnect" };
|
||||
|
||||
/** Parses `/remote-control [name]` and `/remote-control off` from a single-text
|
||||
* prompt. Connecting is idempotent, so the command never needs to know whether
|
||||
* Remote Control is already on. */
|
||||
export function parseRemoteControlCommand(prompt: PromptContent): RemoteControlCommand | null {
|
||||
const [first] = prompt;
|
||||
if (prompt.length !== 1 || first?.type !== "text" || typeof first.text !== "string") return null;
|
||||
const match = /^\/remote-control(?:\s+([\s\S]*))?$/.exec(first.text.trim());
|
||||
if (!match) return null;
|
||||
const argument = match[1]?.trim();
|
||||
if (argument?.toLowerCase() === "off") return { action: "disconnect" };
|
||||
return argument ? { action: "connect", name: argument } : { action: "connect" };
|
||||
}
|
||||
|
||||
/** Whether Claude Code offers a slash command of this name to an ACP client.
|
||||
* This is the rule `getAvailableSlashCommands` applies when it lists the
|
||||
* commands, so the adapter stands in for the command exactly when the client
|
||||
* sees the adapter's entry: Claude Code's terminal-bound commands are not
|
||||
* offered, and a user's own command or skill of the name is never shadowed. */
|
||||
export function claudeCodeOffersCommand(
|
||||
commands: readonly SlashCommand[],
|
||||
terminalCommands: readonly string[] | undefined,
|
||||
name: string,
|
||||
): boolean {
|
||||
return commands.some(
|
||||
(command) => command.name === name && !terminalCommands?.includes(command.name),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/remote-control` command of `prompt` that the adapter runs: the query
|
||||
* serves Remote Control and Claude Code does not offer a command of that name.
|
||||
* `null` for any other prompt, which goes to Claude Code.
|
||||
*/
|
||||
export async function adapterRemoteControlCommand(
|
||||
prompt: PromptContent,
|
||||
query: Query,
|
||||
terminalCommands: readonly string[] | undefined,
|
||||
): Promise<{ query: RemoteControlQuery; command: RemoteControlCommand } | null> {
|
||||
const command = parseRemoteControlCommand(prompt);
|
||||
if (!command || !supportsRemoteControl(query)) return null;
|
||||
if (
|
||||
claudeCodeOffersCommand(
|
||||
await query.supportedCommands(),
|
||||
terminalCommands,
|
||||
REMOTE_CONTROL_COMMAND_NAME,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { query, command };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects Remote Control (again, if it is already on) or disconnects it, and
|
||||
* returns the text that tells the user how it went. A failure is in the text:
|
||||
* this never throws.
|
||||
*/
|
||||
export async function runRemoteControlCommand(
|
||||
query: RemoteControlQuery,
|
||||
command: RemoteControlCommand,
|
||||
logError: (message: string) => void,
|
||||
): Promise<string> {
|
||||
if (command.action === "disconnect") {
|
||||
try {
|
||||
await query.enableRemoteControl(false);
|
||||
return "Remote Control is off. Run /remote-control to reconnect.";
|
||||
} catch (error) {
|
||||
logError(`Failed to disable Remote Control: ${error}`);
|
||||
return `Could not stop Remote Control: ${error instanceof Error ? error.message : error}`;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await query.enableRemoteControl(true, command.name);
|
||||
return result.session_url
|
||||
? `Remote Control is active. Continue this session from claude.ai/code or the Claude mobile app:\n\n${result.session_url}\n\nRun /remote-control off to disconnect.`
|
||||
: "Remote Control is active, but Claude Code did not return a session URL.";
|
||||
} catch (error) {
|
||||
logError(`Failed to enable Remote Control: ${error}`);
|
||||
return `Could not start Remote Control: ${error instanceof Error ? error.message : error}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** A top-level message a person typed in another client of the session, such
|
||||
* as claude.ai or the Claude mobile app over Remote Control. Tool results and
|
||||
* the CLI's own synthetic messages are user-role too, but carry no human
|
||||
* origin. */
|
||||
export function isRemoteHumanMessage(message: SDKUserMessage | SDKUserMessageReplay): boolean {
|
||||
return (
|
||||
message.type === "user" &&
|
||||
message.origin?.kind === "human" &&
|
||||
message.parent_tool_use_id === null &&
|
||||
!message.isSynthetic
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* How Claude Code's `result` ends the work that it reports, and whether that
|
||||
* work was the user's turn or a cycle Claude Code started on its own: the
|
||||
* rules that both cores read a turn's result with (the v1 prompt loop in
|
||||
* `v1/acp-agent.ts` and `v2/`). Each core adds its own side effects, such as v1's
|
||||
* session failure category.
|
||||
*/
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKMessageOrigin,
|
||||
SDKResultMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export type ResultEnd =
|
||||
| { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" }
|
||||
/**
|
||||
* The result failed the work, with Claude Code's error text. `loginRequired`:
|
||||
* the text of a `success` result tells the user to run `/login`.
|
||||
*/
|
||||
| { error: string; loginRequired: boolean };
|
||||
|
||||
/**
|
||||
* True when CLI error text tells the user to sign in with `/login`. The CLI
|
||||
* words this several ways — "Not logged in · Please run /login", "Not logged
|
||||
* in · Run /login", "Login expired · Run /login to sign in again, or
|
||||
* re-authenticate your Anthropic profile" — so match the "run /login"
|
||||
* instruction rather than one sentence.
|
||||
*/
|
||||
export function isLoginRequiredText(text: string): boolean {
|
||||
return /\brun \/login\b/i.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* - Any subtype with `stop_reason: "refusal"`: `refusal`, also on an error.
|
||||
* - `success`: an error with the `/login` text fails the work, then
|
||||
* `stop_reason: "max_tokens"` is `max_tokens`, then any other error fails
|
||||
* the work. Otherwise `end_turn`.
|
||||
* - `error_during_execution`: `max_tokens` as for `success`, then an error
|
||||
* fails the work. Otherwise `end_turn`.
|
||||
* - `error_max_turns`, `error_max_budget_usd` and
|
||||
* `error_max_structured_output_retries`: an error fails the work, whatever
|
||||
* the stop reason. Otherwise `max_turn_requests`.
|
||||
*/
|
||||
export function resultEnd(result: SDKResultMessage): ResultEnd {
|
||||
if (result.stop_reason === "refusal") return { stopReason: "refusal" };
|
||||
switch (result.subtype) {
|
||||
case "success":
|
||||
if (result.is_error && isLoginRequiredText(result.result)) {
|
||||
return { error: result.result, loginRequired: true };
|
||||
}
|
||||
if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" };
|
||||
if (result.is_error) return { error: result.result, loginRequired: false };
|
||||
return { stopReason: "end_turn" };
|
||||
case "error_during_execution":
|
||||
if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" };
|
||||
if (result.is_error) return failed(result);
|
||||
return { stopReason: "end_turn" };
|
||||
default:
|
||||
// `error_max_turns`, `error_max_budget_usd`, `error_max_structured_output_retries`.
|
||||
if (result.is_error) return failed(result);
|
||||
return { stopReason: "max_turn_requests" };
|
||||
}
|
||||
}
|
||||
|
||||
function failed(result: Exclude<SDKResultMessage, { subtype: "success" }>): ResultEnd {
|
||||
return { error: result.errors.join(", ") || result.subtype, loginRequired: false };
|
||||
}
|
||||
|
||||
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
|
||||
* model did on its own (a task-notification followup, a peer/coordinator/
|
||||
* observer message it handled) rather than the user's prompt. Absent,
|
||||
* `human`, and `channel` origins are the user's own turn (this adapter's
|
||||
* prompts arrive as the ACP channel on some CLI configurations — ALL
|
||||
* channel servers are treated as user, so a foreign channel integration's
|
||||
* autonomously-handled result is misclassified as the user's; accepted,
|
||||
* see below), and `auto-continuation` continues the user's turn, so its
|
||||
* result is the turn's real terminal.
|
||||
*
|
||||
* Deliberately fail-OPEN: an unknown future kind defaults to the user
|
||||
* lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
|
||||
* attribute this" marker, which gets the same safe default. Misrouting a
|
||||
* USER result into the autonomous lane hangs the prompt un-detectably
|
||||
* (the result is skipped, its trailing idle absorbed as owed, so the
|
||||
* #825 detector can't fire); misrouting an autonomous result into the
|
||||
* user lane is the bounded misattribution class this set exists to
|
||||
* reduce.
|
||||
*
|
||||
* Exception: a result from one of these origins that names a pending prompt
|
||||
* in `user_message_uuids` consumed that folded prompt and takes the user
|
||||
* lane (see the result handler of each core). */
|
||||
export const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet<SDKMessageOrigin["kind"]> = new Set([
|
||||
"task-notification",
|
||||
"peer",
|
||||
"coordinator",
|
||||
"observer",
|
||||
"observer-activity",
|
||||
]);
|
||||
|
||||
/** Whether Claude Code ended the work of `result` because an interrupt
|
||||
* stopped it, while the model streamed or while a tool ran. */
|
||||
export function isAbortedResult(result: Extract<SDKMessage, { type: "result" }>): boolean {
|
||||
return (
|
||||
result.terminal_reason === "aborted_streaming" || result.terminal_reason === "aborted_tools"
|
||||
);
|
||||
}
|
||||
@@ -1,853 +0,0 @@
|
||||
/**
|
||||
* The mapping of Claude Code output to ACP session updates: content blocks
|
||||
* (`toAcpNotifications`) and partial stream events
|
||||
* (`streamEventToAcpNotifications`) become message chunks, thoughts, tool
|
||||
* calls and plans. The live stream and the `session/load` replay of both
|
||||
* protocol versions use it; v2 converts the v1 updates it returns.
|
||||
*/
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import type {
|
||||
BetaContentBlock,
|
||||
BetaRawContentBlockDelta,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import type { AcpClient } from "./acp-client.js";
|
||||
import { clientSupportsSubagents } from "./acp-subagents.js";
|
||||
import { approvedPlan, planModeUpdate } from "./exit-plan.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import { checklistPlanUpdate, todoWritePlanUpdate, type SubagentChecklists } from "./plans.js";
|
||||
import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
|
||||
import type { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
|
||||
import { AcpToolCallRenderer, type ToolUpdateMeta } from "./tool-calls/renderer.js";
|
||||
import { parseToolResultMeta } from "./tool-result-meta.js";
|
||||
import {
|
||||
applyTaskCreate,
|
||||
applyTaskList,
|
||||
applyTaskUpdate,
|
||||
changedTaskPlanEntries,
|
||||
type ClaudePlanEntry,
|
||||
completeHookCallback,
|
||||
hasHookCallback,
|
||||
parseTaskCreateOutput,
|
||||
parseTaskListOutput,
|
||||
parseTaskUpdateOutput,
|
||||
planEntries,
|
||||
registerHookCallback,
|
||||
type TaskState,
|
||||
unregisterHookCallback,
|
||||
} from "./tools.js";
|
||||
import { unreachable } from "./utils.js";
|
||||
|
||||
/** Attributes a report to the Agent/Task tool call of the subagent that made it. */
|
||||
export function stampParentToolUseId(
|
||||
update: SessionNotification["update"],
|
||||
parentToolUseId: string,
|
||||
) {
|
||||
update._meta = {
|
||||
...update._meta,
|
||||
claudeCode: {
|
||||
...((update._meta?.claudeCode as Record<string, unknown> | undefined) ?? {}),
|
||||
parentToolUseId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ToolUseCache = {
|
||||
[key: string]: {
|
||||
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type StreamedToolInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
partialJson: string;
|
||||
inString: boolean;
|
||||
escaped: boolean;
|
||||
objectDepth: number;
|
||||
arrayDepth: number;
|
||||
/** Offset of the most recent comma at the top level of the input object
|
||||
* (-1 before the first). Everything before it is a complete field. */
|
||||
lastTopLevelComma: number;
|
||||
/** The comma offset the last emitted refinement was sliced at (-1 before the
|
||||
* first), so a field boundary only triggers one recovery attempt. */
|
||||
emittedThroughComma: number;
|
||||
};
|
||||
|
||||
export type StreamedToolInputCache = Map<string, Map<number, StreamedToolInput>>;
|
||||
|
||||
/**
|
||||
* Advance the lexer state across the fragment appended since the last delta:
|
||||
* just enough JSON awareness (string/escape, nesting depth) to spot commas
|
||||
* that sit at the top level of the input object — everything before such a
|
||||
* comma is a set of complete fields. Returns true once the input object's
|
||||
* closing brace arrives.
|
||||
*
|
||||
* The lexer reads the fragment itself, never `partialJson` by index. V8 keeps a
|
||||
* string built with `+=` as a chain of parts, and the first index access
|
||||
* copies the whole chain into one flat string. An index scan of `partialJson`
|
||||
* would copy the whole input on each delta, so a large Write would cost
|
||||
* quadratic time. A test in `acp-agent.test.ts` checks this.
|
||||
*/
|
||||
function scanStreamedToolInput(state: StreamedToolInput, fragment: string): boolean {
|
||||
const offset = state.partialJson.length;
|
||||
state.partialJson += fragment;
|
||||
let complete = false;
|
||||
for (let index = 0; index < fragment.length; index++) {
|
||||
const character = fragment[index];
|
||||
if (state.inString) {
|
||||
if (state.escaped) {
|
||||
state.escaped = false;
|
||||
} else if (character === "\\") {
|
||||
state.escaped = true;
|
||||
} else if (character === '"') {
|
||||
state.inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
state.inString = true;
|
||||
} else if (character === "{") {
|
||||
state.objectDepth++;
|
||||
} else if (character === "}") {
|
||||
state.objectDepth--;
|
||||
if (state.objectDepth === 0) {
|
||||
complete = true;
|
||||
}
|
||||
} else if (character === "[") {
|
||||
state.arrayDepth++;
|
||||
} else if (character === "]") {
|
||||
state.arrayDepth--;
|
||||
} else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
|
||||
state.lastTopLevelComma = offset + index;
|
||||
}
|
||||
}
|
||||
return complete;
|
||||
}
|
||||
|
||||
/** Parse the complete top-level fields before a top-level comma by closing the
|
||||
* object at that boundary. */
|
||||
function recoveredToolInput(prefix: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(prefix + "}");
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the ACP `messageId` for a Claude SDK message (live) or a persisted
|
||||
* transcript message (replay) so chunk grouping is identical in both views.
|
||||
*
|
||||
* Assistant turns are keyed by the Anthropic API message id (`message.id`),
|
||||
* which is identical at `message_start`, on the consolidated assistant message,
|
||||
* and in the persisted transcript — unlike the per-`stream_event` uuid, which is
|
||||
* unique per event and never persisted. User messages have no API id, but they
|
||||
* are never streamed, so their (stable) SDK uuid is used instead. ACP message
|
||||
* ids are opaque strings, so no particular format is required.
|
||||
*/
|
||||
export function messageIdForGrouping(message: {
|
||||
type?: string;
|
||||
uuid?: string | null;
|
||||
message?: unknown;
|
||||
}): string | undefined {
|
||||
if (message.type === "assistant") {
|
||||
const inner = message.message;
|
||||
const apiId =
|
||||
inner && typeof inner === "object" && "id" in inner
|
||||
? (inner as { id?: unknown }).id
|
||||
: undefined;
|
||||
if (typeof apiId === "string" && apiId.length > 0) {
|
||||
return apiId;
|
||||
}
|
||||
}
|
||||
return typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamps an ACP `messageId` onto a session update, but only on the message/
|
||||
* thought chunk variants that carry one — tool_call/plan/etc. updates never do.
|
||||
* No-op when `messageId` is falsy, so callers can pass it through unconditionally.
|
||||
*/
|
||||
function applyMessageId(
|
||||
update: SessionNotification["update"],
|
||||
messageId: string | undefined,
|
||||
): void {
|
||||
if (
|
||||
messageId &&
|
||||
(update.sessionUpdate === "agent_message_chunk" ||
|
||||
update.sessionUpdate === "user_message_chunk" ||
|
||||
update.sessionUpdate === "agent_thought_chunk")
|
||||
) {
|
||||
update.messageId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Built-in tools that drive the task list (headless/SDK sessions use these
|
||||
* instead of TodoWrite). Their tool_use/tool_result are surfaced as `plan`
|
||||
* snapshots rather than as tool_calls. */
|
||||
function isTaskTool(toolName: string): boolean {
|
||||
return (
|
||||
toolName === "TaskCreate" ||
|
||||
toolName === "TaskUpdate" ||
|
||||
toolName === "TaskList" ||
|
||||
toolName === "TaskGet"
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the streamed tool_use path surfaces this tool as a standalone
|
||||
* `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
|
||||
* suppressed (their plan snapshot is emitted at tool_result time), so neither
|
||||
* produces a streamed tool_call/tool_call_update — which means a
|
||||
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
|
||||
* resolved explicitly at tool_result time. */
|
||||
function shouldEmitToolCall(toolName: string): boolean {
|
||||
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer of the notification functions: the agent's tool call
|
||||
* capabilities when they are passed, else those of the ACP capabilities.
|
||||
*/
|
||||
function toolCallRenderer(options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
toolCallCapabilities?: ToolCallClientCapabilities;
|
||||
replay?: boolean;
|
||||
}): AcpToolCallRenderer {
|
||||
return options?.toolCallCapabilities
|
||||
? new AcpToolCallRenderer(options.toolCallCapabilities, options.replay)
|
||||
: AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
||||
* Only handles text, image, and thinking chunks for now.
|
||||
*/
|
||||
export function toAcpNotifications(
|
||||
content: string | ContentBlockParam[] | BetaContentBlock[] | BetaRawContentBlockDelta[],
|
||||
role: "assistant" | "user",
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AcpClient,
|
||||
logger: Logger,
|
||||
options?: {
|
||||
registerHooks?: boolean;
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
// The agent's tool call capabilities. They win over clientCapabilities,
|
||||
// and carry what ACP capabilities cannot (ToolCallClientCapabilities.v2).
|
||||
toolCallCapabilities?: ToolCallClientCapabilities;
|
||||
parentToolUseId?: string | null;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
// The subagent TodoWrite checklists that the client holds in the session.
|
||||
// Mutated in place.
|
||||
subagentChecklists?: SubagentChecklists;
|
||||
// Tracks tool_use ids already emitted as a `tool_call` so a permission
|
||||
// request (which emits the tool_call eagerly) and the streamed tool_use
|
||||
// chunk don't both emit one — whichever arrives second emits a
|
||||
// `tool_call_update` instead. Mutated in place. When omitted, the
|
||||
// tool_call/update decision falls back to `toolUseCache` presence (the
|
||||
// historical single-source behavior).
|
||||
emittedToolCalls?: Set<string>;
|
||||
// False while the input of a streamed tool_use still streams: the first
|
||||
// tool_call then leaves `rawInput` out, and the consolidated message
|
||||
// sends it once it is complete.
|
||||
inputComplete?: boolean;
|
||||
// Remembers the fields sent for each open tool call. When present, a
|
||||
// tool_call_update carries only the fields that changed, and an update
|
||||
// with nothing new is dropped. Mutated in place.
|
||||
toolCallFields?: ToolCallFieldTracker;
|
||||
// Opaque id identifying the message these chunks belong to (ACP message ids
|
||||
// are opaque strings — no particular format is required). Attached to
|
||||
// user/agent message and thought chunks so clients can group streamed chunks
|
||||
// into a single message. Omit it (leave undefined) when unknown — never send
|
||||
// an explicit `null`.
|
||||
messageId?: string;
|
||||
// The SDK user message's `tool_use_result`: the structured Output object of
|
||||
// the tool_result this message carries (shape is per-tool). Used to render
|
||||
// Agent/Task results from the structured subagent report instead of the raw
|
||||
// text (which ends in a model-directed agentId/usage trailer).
|
||||
toolUseResult?: unknown;
|
||||
// The SDK user message's `tool_result_meta` sidecar, passed raw (it's
|
||||
// untyped in sdk.d.ts) and validated by `parseToolResultMeta`. Stamps
|
||||
// denied/interrupted tool_call_updates with why the tool never ran.
|
||||
toolResultMeta?: unknown;
|
||||
// True when the content is a result of the work that a `session/cancel`
|
||||
// stopped. An error result then ends a run that the cancel aborted,
|
||||
// whichever kind the SDK stamped on it (see `resultStatus`).
|
||||
cancelled?: boolean;
|
||||
// True when the content comes from the history of a loaded session.
|
||||
replay?: boolean;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const taskState = options?.taskState ?? new Map();
|
||||
const registerHooks = options?.registerHooks !== false;
|
||||
const renderer = toolCallRenderer(options);
|
||||
if (typeof content === "string") {
|
||||
if (content.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const update: SessionNotification["update"] = {
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: content,
|
||||
},
|
||||
};
|
||||
applyMessageId(update, options?.messageId);
|
||||
|
||||
if (options?.parentToolUseId) stampParentToolUseId(update, options.parentToolUseId);
|
||||
|
||||
return [{ sessionId, update }];
|
||||
}
|
||||
|
||||
// `tool_use_result` is message-level and carries no tool_use_id of its own:
|
||||
// it describes "the" tool_result block of the message it rode in on. If
|
||||
// several tool_result blocks were ever batched into one message it couldn't
|
||||
// be attributed, so it is only honored when the message carries exactly one.
|
||||
const toolUseResult =
|
||||
options?.toolUseResult !== undefined &&
|
||||
content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
|
||||
.length === 1
|
||||
? options.toolUseResult
|
||||
: undefined;
|
||||
|
||||
// Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
|
||||
// messages need no single-block guard.
|
||||
const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
|
||||
const output = [];
|
||||
// Only handle the first chunk for streaming; extend as needed for batching
|
||||
for (const chunk of content) {
|
||||
let update: SessionNotification["update"] | null = null;
|
||||
// The tool call that this chunk finishes, if it is a tool result.
|
||||
let finishedToolCallId: string | undefined;
|
||||
// The plan that this chunk approves, if it is an ExitPlanMode result.
|
||||
let approvedPlanUpdate: SessionNotification["update"] | undefined;
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
case "text_delta": {
|
||||
if (chunk.text) {
|
||||
update = {
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.text,
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "image":
|
||||
update = {
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "image",
|
||||
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
||||
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
|
||||
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "thinking":
|
||||
case "thinking_delta": {
|
||||
// Recent models default `thinking.display` to "omitted", which streams
|
||||
// signature-only thinking blocks whose text is empty.
|
||||
if (chunk.thinking) {
|
||||
update = {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.thinking,
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_use":
|
||||
case "server_tool_use":
|
||||
case "mcp_tool_use": {
|
||||
const alreadyCached = chunk.id in toolUseCache;
|
||||
toolUseCache[chunk.id] = chunk;
|
||||
if (chunk.name === "TodoWrite") {
|
||||
// @ts-expect-error - sometimes input is empty object or undefined
|
||||
if (Array.isArray(chunk.input?.todos)) {
|
||||
update = todoWritePlanUpdate(planEntries(chunk.input as { todos: ClaudePlanEntry[] }), {
|
||||
planOperations: options?.clientCapabilities?.plan != null,
|
||||
parentToolUseId: options?.parentToolUseId,
|
||||
// With native subagent sessions, an update of a subagent goes
|
||||
// to the subagent's session (see NativeSubagentRuntime).
|
||||
childSessions: clientSupportsSubagents(options?.clientCapabilities),
|
||||
shown: options?.subagentChecklists,
|
||||
});
|
||||
}
|
||||
} else if (isTaskTool(chunk.name)) {
|
||||
// Task* tool_use is suppressed; the plan update is emitted at
|
||||
// tool_result time once we have the task ID (for TaskCreate) and
|
||||
// confirmation that the change took effect.
|
||||
} else {
|
||||
// Only register hooks on first encounter to avoid double-firing
|
||||
if (registerHooks && !alreadyCached) {
|
||||
// Capture the tool name in the closure rather than re-reading the
|
||||
// cache when the hook fires. The cache entry is pruned at
|
||||
// tool_result time, and a PostToolUse hook can fire after that, so
|
||||
// closing over the name keeps the diff working without depending on
|
||||
// (or pinning) the cache entry's lifetime.
|
||||
const toolName = chunk.name;
|
||||
const hookToolCallId = chunk.id;
|
||||
registerHookCallback(
|
||||
chunk.id,
|
||||
{
|
||||
onPostToolUseHook: async (toolUseId, _toolInput, toolResponse) => {
|
||||
// The final diff of an Edit or a Write replaces the
|
||||
// optimistic content built from the input. Only the marker
|
||||
// fields of the tool_response travel: the rest repeats
|
||||
// output that the content already carries.
|
||||
const update = await renderer.hookResult(
|
||||
{ id: toolUseId, name: toolName },
|
||||
toolResponse,
|
||||
options?.cwd,
|
||||
);
|
||||
if (!update) return;
|
||||
if (options?.parentToolUseId) {
|
||||
stampParentToolUseId(update, options.parentToolUseId);
|
||||
}
|
||||
// The final result may replace a pinned approval patch.
|
||||
if (
|
||||
options?.toolCallFields &&
|
||||
!options.toolCallFields.apply(update, { replacePinnedContent: true })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await client.sessionUpdate({ sessionId, update });
|
||||
},
|
||||
onRelease: () => options?.toolCallFields?.finishHook(hookToolCallId),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
let rawInput;
|
||||
try {
|
||||
rawInput = JSON.parse(JSON.stringify(chunk.input));
|
||||
} catch {
|
||||
// ignore if we can't turn it to JSON
|
||||
}
|
||||
const toolUse = { id: chunk.id, name: chunk.name, input: rawInput };
|
||||
|
||||
// Emit a `tool_call` only the first time this id surfaces to the
|
||||
// client; afterwards refine it with a `tool_call_update`. The first
|
||||
// surface may be this stream chunk OR an earlier permission request
|
||||
// (see `ensureToolCallEmitted`), so emission is tracked separately
|
||||
// from `toolUseCache`. Without an `emittedToolCalls` set we fall back
|
||||
// to cache presence — the historical streaming-only behavior.
|
||||
const emittedToolCalls = options?.emittedToolCalls;
|
||||
const alreadyEmitted = emittedToolCalls ? emittedToolCalls.has(chunk.id) : alreadyCached;
|
||||
emittedToolCalls?.add(chunk.id);
|
||||
|
||||
// A permission request surfaced the tool call with its complete
|
||||
// input. The empty input at the stream start has nothing to add.
|
||||
if (alreadyEmitted && options?.inputComplete === false) break;
|
||||
update = alreadyEmitted
|
||||
? // Already surfaced (full assistant message after streaming, or a
|
||||
// permission request emitted it first): refine it with the
|
||||
// complete input.
|
||||
renderer.refinement(toolUse, options?.cwd)
|
||||
: // First surface (streaming content_block_start or replay).
|
||||
renderer.toolCall(toolUse, {
|
||||
cwd: options?.cwd,
|
||||
inputComplete: options?.inputComplete,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "advisor_tool_result": {
|
||||
// Advisor results arrive in assistant content, including encrypted advice.
|
||||
// Resolve the server tool through the normal result path without rendering
|
||||
// opaque ciphertext. Server tools have no later PostToolUse hook to await.
|
||||
const result = chunk.content;
|
||||
unregisterHookCallback(chunk.tool_use_id);
|
||||
output.push(
|
||||
...toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: chunk.tool_use_id,
|
||||
is_error: result.type === "advisor_tool_result_error",
|
||||
content:
|
||||
result.type === "advisor_result"
|
||||
? result.text
|
||||
: result.type === "advisor_redacted_result"
|
||||
? "Advisor guidance applied server-side."
|
||||
: `Advisor error: ${result.error_code}`,
|
||||
},
|
||||
],
|
||||
role,
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
client,
|
||||
logger,
|
||||
options,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_result":
|
||||
case "tool_search_tool_result":
|
||||
case "web_fetch_tool_result":
|
||||
case "web_search_tool_result":
|
||||
case "code_execution_tool_result":
|
||||
case "bash_code_execution_tool_result":
|
||||
case "text_editor_code_execution_tool_result":
|
||||
case "mcp_tool_result": {
|
||||
const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
|
||||
options?.emittedToolCalls?.delete(chunk.tool_use_id);
|
||||
completeHookCallback(chunk.tool_use_id);
|
||||
finishedToolCallId = chunk.tool_use_id;
|
||||
// Why this is_error result carries harness prose instead of tool
|
||||
// output (user-rejected / interrupted / …), when the SDK said so.
|
||||
// Spread into the claudeCode meta of every update emitted below; the
|
||||
// untracked-tool fallback can't carry it (claudeCode metas always
|
||||
// carry `toolName`, which is unknown there).
|
||||
const nonExecution = toolResultMeta?.get(chunk.tool_use_id);
|
||||
const toolUse = toolUseCache[chunk.tool_use_id];
|
||||
if (!toolUse) {
|
||||
// The permission flow may have surfaced this tool_call even though
|
||||
// its tool_use never reached the cache (e.g. the assistant message
|
||||
// carrying it was dropped by the cancelled-turn guard and a straggler
|
||||
// result landed later). Resolve the surfaced call anyway so it can't
|
||||
// stay pending in the client forever; without the cache entry the
|
||||
// tool name is unknown, so no claudeCode meta is attached.
|
||||
if (wasEmitted) {
|
||||
output.push({
|
||||
sessionId,
|
||||
update: {
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update" as const,
|
||||
status: renderer.resultStatus(
|
||||
"is_error" in chunk && chunk.is_error === true,
|
||||
nonExecution,
|
||||
{ cancelled: options?.cancelled },
|
||||
),
|
||||
rawOutput: chunk.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
logger.error(
|
||||
`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// A permission request may have surfaced a plan-rendered (TodoWrite) or
|
||||
// suppressed (Task*) tool as a real tool_call so the request referenced
|
||||
// a tool call the client knows about (see `ensureToolCallEmitted`,
|
||||
// issue #851). The branches below never emit a tool_call_update for
|
||||
// those tools, which would leave the surfaced call pending in the
|
||||
// client forever — resolve it here. `wasEmitted` is only ever true for
|
||||
// these tools via the permission flow: the streamed plan/suppressed
|
||||
// branches don't record emissions.
|
||||
if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
|
||||
output.push({
|
||||
sessionId,
|
||||
update: {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: toolUse.name,
|
||||
...(nonExecution ?? {}),
|
||||
...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update" as const,
|
||||
status: renderer.resultStatus(
|
||||
"is_error" in chunk && chunk.is_error === true,
|
||||
nonExecution,
|
||||
{ cancelled: options?.cancelled },
|
||||
),
|
||||
rawOutput: chunk.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isTaskTool(toolUse.name)) {
|
||||
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
||||
// TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
|
||||
// reconciles it from the SDK's authoritative snapshot, which repairs
|
||||
// resumed or compacted sessions whose creating calls are no longer in
|
||||
// replay history. TaskGet is read-only and remains suppressed. Plan
|
||||
// updates always carry the full accumulated snapshot, mirroring the
|
||||
// legacy TodoWrite behavior.
|
||||
const isError = "is_error" in chunk && chunk.is_error;
|
||||
let shouldEmitTaskPlan = false;
|
||||
if (!isError) {
|
||||
if (toolUse.name === "TaskCreate") {
|
||||
applyTaskCreate(
|
||||
taskState,
|
||||
toolUse.input as Parameters<typeof applyTaskCreate>[1],
|
||||
parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content),
|
||||
);
|
||||
shouldEmitTaskPlan = true;
|
||||
} else if (toolUse.name === "TaskUpdate") {
|
||||
const input = toolUse.input as Parameters<typeof applyTaskUpdate>[1];
|
||||
const output =
|
||||
parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
|
||||
parseTaskUpdateOutput(chunk.content, input?.taskId);
|
||||
// Older CLI transcripts have no structured output, so retain the
|
||||
// input-based fallback. When an output is available, only apply a
|
||||
// confirmed update for the same task.
|
||||
if (!output || (output.success && output.taskId === input?.taskId)) {
|
||||
applyTaskUpdate(taskState, input);
|
||||
shouldEmitTaskPlan = true;
|
||||
}
|
||||
} else if (toolUse.name === "TaskList") {
|
||||
const output =
|
||||
parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
|
||||
if (output) {
|
||||
applyTaskList(taskState, output);
|
||||
shouldEmitTaskPlan = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const entries = shouldEmitTaskPlan
|
||||
? changedTaskPlanEntries(taskState, renderer.capabilities.air.client)
|
||||
: undefined;
|
||||
if (entries) {
|
||||
update = checklistPlanUpdate(entries, options?.clientCapabilities?.plan != null);
|
||||
}
|
||||
} else if (toolUse.name !== "TodoWrite") {
|
||||
// A command sends its output first, then the exit and the status.
|
||||
const [finalUpdate, ...rest] = renderer
|
||||
.result(toolUse, chunk as Parameters<AcpToolCallRenderer["result"]>[1], {
|
||||
structured: toolUseResult,
|
||||
nonExecution,
|
||||
cancelled: options?.cancelled,
|
||||
})
|
||||
.reverse();
|
||||
for (const outputUpdate of rest.reverse()) {
|
||||
if (options?.parentToolUseId) {
|
||||
stampParentToolUseId(outputUpdate, options.parentToolUseId);
|
||||
}
|
||||
output.push({ sessionId, update: outputUpdate });
|
||||
}
|
||||
// An approved plan of the main conversation becomes the session's
|
||||
// markdown plan, after the tool call's result. An approval that
|
||||
// clears the context ends the tool with an error result instead, and
|
||||
// the restart into the fresh context publishes that plan (see
|
||||
// ExitPlanCoordinator.restart).
|
||||
if (
|
||||
toolUse.name === "ExitPlanMode" &&
|
||||
options?.clientCapabilities?.plan &&
|
||||
!options.parentToolUseId &&
|
||||
!("is_error" in chunk && chunk.is_error)
|
||||
) {
|
||||
const plan = approvedPlan(toolUse.input, toolUseResult);
|
||||
if (plan) {
|
||||
approvedPlanUpdate = planModeUpdate(
|
||||
plan,
|
||||
renderer.capabilities.air.client ? toolUse.id : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
update = finalUpdate;
|
||||
}
|
||||
// The tool_use is fully resolved now — drop it so a long session doesn't
|
||||
// retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
|
||||
// over the tool name and no longer reads the cache, so pruning here is
|
||||
// safe regardless of hook/result ordering.
|
||||
delete toolUseCache[chunk.tool_use_id];
|
||||
break;
|
||||
}
|
||||
|
||||
case "document":
|
||||
case "search_result":
|
||||
case "redacted_thinking":
|
||||
case "input_json_delta":
|
||||
case "citations_delta":
|
||||
case "signature_delta":
|
||||
case "container_upload":
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
case "fallback":
|
||||
case "mcp_tool_listing":
|
||||
break;
|
||||
|
||||
default:
|
||||
unreachable(chunk, logger);
|
||||
break;
|
||||
}
|
||||
if (update) {
|
||||
if (options?.parentToolUseId) stampParentToolUseId(update, options.parentToolUseId);
|
||||
applyMessageId(update, options?.messageId);
|
||||
// A tool result is final, so it may replace a pinned approval patch,
|
||||
// for example with the error text of a rejected Edit.
|
||||
if (
|
||||
!options?.toolCallFields ||
|
||||
options.toolCallFields.apply(update, {
|
||||
replacePinnedContent: finishedToolCallId !== undefined,
|
||||
})
|
||||
) {
|
||||
output.push({ sessionId, update });
|
||||
}
|
||||
}
|
||||
if (approvedPlanUpdate) output.push({ sessionId, update: approvedPlanUpdate });
|
||||
if (finishedToolCallId !== undefined) {
|
||||
// The PostToolUse hook can still send the final diff, so the fields
|
||||
// stay tracked until its callback leaves the registry.
|
||||
options?.toolCallFields?.finishResult(
|
||||
finishedToolCallId,
|
||||
hasHookCallback(finishedToolCallId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function streamEventToAcpNotifications(
|
||||
message: SDKPartialAssistantMessage,
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AcpClient,
|
||||
logger: Logger,
|
||||
options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
// See toAcpNotifications.
|
||||
toolCallCapabilities?: ToolCallClientCapabilities;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
subagentChecklists?: SubagentChecklists;
|
||||
emittedToolCalls?: Set<string>;
|
||||
toolCallFields?: ToolCallFieldTracker;
|
||||
messageId?: string;
|
||||
streamedToolInputs?: StreamedToolInputCache;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
const streamKey = message.parent_tool_use_id ?? "";
|
||||
const streamedToolInputs = options?.streamedToolInputs;
|
||||
const forwardedOptions = {
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
toolCallCapabilities: options?.toolCallCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
subagentChecklists: options?.subagentChecklists,
|
||||
emittedToolCalls: options?.emittedToolCalls,
|
||||
toolCallFields: options?.toolCallFields,
|
||||
messageId: options?.messageId,
|
||||
};
|
||||
switch (event.type) {
|
||||
case "content_block_start": {
|
||||
const block = event.content_block;
|
||||
if (
|
||||
streamedToolInputs &&
|
||||
(block.type === "tool_use" ||
|
||||
block.type === "server_tool_use" ||
|
||||
block.type === "mcp_tool_use")
|
||||
) {
|
||||
let inputsForMessage = streamedToolInputs.get(streamKey);
|
||||
if (!inputsForMessage) {
|
||||
inputsForMessage = new Map();
|
||||
streamedToolInputs.set(streamKey, inputsForMessage);
|
||||
}
|
||||
inputsForMessage.set(event.index, {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
partialJson: "",
|
||||
inString: false,
|
||||
escaped: false,
|
||||
objectDepth: 0,
|
||||
arrayDepth: 0,
|
||||
lastTopLevelComma: -1,
|
||||
emittedThroughComma: -1,
|
||||
});
|
||||
}
|
||||
// The input of a streamed tool_use starts empty and streams after this
|
||||
// event, so the tool_call waits for the complete input.
|
||||
return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, {
|
||||
...forwardedOptions,
|
||||
inputComplete: false,
|
||||
});
|
||||
}
|
||||
case "content_block_delta": {
|
||||
if (event.delta.type === "input_json_delta") {
|
||||
const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
|
||||
if (!streamedInput) return [];
|
||||
|
||||
if (scanStreamedToolInput(streamedInput, event.delta.partial_json)) {
|
||||
// Input complete: the consolidated assistant message replays the
|
||||
// block with its full input and refines the call there; emitting
|
||||
// here too would send a duplicate identical update.
|
||||
const inputsForMessage = streamedToolInputs?.get(streamKey);
|
||||
inputsForMessage?.delete(event.index);
|
||||
if (inputsForMessage?.size === 0) streamedToolInputs?.delete(streamKey);
|
||||
return [];
|
||||
}
|
||||
if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
|
||||
return [];
|
||||
}
|
||||
streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
|
||||
const input = recoveredToolInput(
|
||||
streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma),
|
||||
);
|
||||
if (!input) return [];
|
||||
// TodoWrite and the Task* tools never surfaced a tool_call to refine.
|
||||
if (!shouldEmitToolCall(streamedInput.name)) return [];
|
||||
const update: SessionNotification["update"] = toolCallRenderer(options).partialRefinement(
|
||||
streamedInput,
|
||||
input,
|
||||
options?.cwd,
|
||||
);
|
||||
if (message.parent_tool_use_id) stampParentToolUseId(update, message.parent_tool_use_id);
|
||||
applyMessageId(update, options?.messageId);
|
||||
// A refinement resends only what changed: rawInput grows with every
|
||||
// field, and title, kind, and locations usually stay the same.
|
||||
if (options?.toolCallFields && !options.toolCallFields.apply(update)) return [];
|
||||
return [{ sessionId, update }];
|
||||
}
|
||||
return toAcpNotifications(
|
||||
[event.delta],
|
||||
"assistant",
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
client,
|
||||
logger,
|
||||
forwardedOptions,
|
||||
);
|
||||
}
|
||||
// No content. `ping` is a Messages-API keep-alive event that the SDK's
|
||||
// `BetaRawMessageStreamEvent` union doesn't include even though the
|
||||
// wire format emits it; the `as never` cast lets us no-op it here
|
||||
// instead of letting it fall through to `unreachable`.
|
||||
case "ping" as never:
|
||||
case "message_delta":
|
||||
return [];
|
||||
// A message boundary ends every input stream on this lane: message_stop is
|
||||
// the normal end, and a message_start clears anything a prior message on
|
||||
// the lane left behind (e.g. a stream cut short mid-block).
|
||||
case "message_start":
|
||||
case "message_stop":
|
||||
streamedToolInputs?.delete(streamKey);
|
||||
return [];
|
||||
case "content_block_stop": {
|
||||
const inputsForMessage = streamedToolInputs?.get(streamKey);
|
||||
inputsForMessage?.delete(event.index);
|
||||
if (inputsForMessage?.size === 0) streamedToolInputs?.delete(streamKey);
|
||||
return [];
|
||||
}
|
||||
|
||||
default:
|
||||
unreachable(event, logger);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
24
src/serve.ts
24
src/serve.ts
@@ -1,8 +1,7 @@
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent } from "./v1/acp-agent.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp, type AgentHandle } from "./v2/agent.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* The ACP versions that the adapter serves: `v1` or `v2` alone, or both with
|
||||
@@ -33,13 +32,13 @@ export function serveAcp(
|
||||
const input = nodeToWebReadable(process.stdin);
|
||||
// The router opens the connection of the negotiated version only when it
|
||||
// reads `initialize`, so a client that leaves before then leaves no agent.
|
||||
let agent: AgentHandle | undefined;
|
||||
const onAgent = (created: AgentHandle) => {
|
||||
let agent: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols, {
|
||||
onV2Agent: onAgent,
|
||||
}).connect(v2.ndJsonStream(output, input));
|
||||
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols).connect(
|
||||
v2.ndJsonStream(output, input),
|
||||
);
|
||||
return {
|
||||
closed,
|
||||
dispose: async () => {
|
||||
@@ -54,20 +53,15 @@ export function serveAcp(
|
||||
* the draft ACP v2, and every other client gets ACP v1. With `v1`, a v2
|
||||
* client is negotiated down to v1. With `v2`, the router rejects the
|
||||
* `initialize` of a v1 client as an unsupported protocol version. `onAgent`
|
||||
* receives the agent of a v1 connection (`ClaudeAcpAgent`) once the router
|
||||
* has opened it, and `onV2Agent` the agent of a v2 connection, which the
|
||||
* v2 core (`src/v2/`) serves.
|
||||
* receives the agent of the connection once the router has opened it.
|
||||
*/
|
||||
export function acpProtocolRouter(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
protocols: AcpProtocols = "auto",
|
||||
{ onV2Agent = () => {} }: { onV2Agent?: (agent: AgentHandle) => void } = {},
|
||||
): v2.AgentProtocolRouter {
|
||||
const router = v2.agentProtocolRouter();
|
||||
if (protocols !== "v2") router.withV1(v1AgentApp(logger, onAgent));
|
||||
if (protocols !== "v1") {
|
||||
router.withV2(v2AgentApp(logger, onV2Agent));
|
||||
}
|
||||
if (protocols !== "v1") router.withV2(v2AgentApp(logger, onAgent));
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Shared sentinels and ids for session preferences managed by the adapter. */
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
export const AGENT_CONFIG_ID = "agent";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
export const ULTRACODE_CONFIG_ID = "ultracode";
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* The config options of a session, as both the v1 core and the v2 core
|
||||
* build them: mode, model, effort, Fast mode, ultracode and the main-thread
|
||||
* agent.
|
||||
*/
|
||||
import {
|
||||
RequestError,
|
||||
type ClientCapabilities,
|
||||
type SessionConfigOption,
|
||||
type SessionModeState,
|
||||
type SetSessionConfigOptionRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AgentInfo, ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
||||
clientSupportsAirCapability,
|
||||
} from "./air-extension.js";
|
||||
import { AGENT_CONFIG_ID, DEFAULT_AGENT_ID } from "./session-config-ids.js";
|
||||
import { buildEffortConfigOption } from "./session-effort.js";
|
||||
import {
|
||||
createFastModeConfigOption,
|
||||
FAST_MODE_CONFIG_ID,
|
||||
resolveFastModeEnabled,
|
||||
type FastModeOptionState,
|
||||
} from "./session-fast-mode.js";
|
||||
import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
|
||||
import {
|
||||
buildModelConfigOption,
|
||||
MODEL_CONFIG_ID,
|
||||
resolveModelPreference,
|
||||
type SessionModelState,
|
||||
} from "./session-model.js";
|
||||
import {
|
||||
createUltracodeConfigOption,
|
||||
resolveUltracodeEnabled,
|
||||
ULTRACODE_CONFIG_ID,
|
||||
type UltracodeOptionState,
|
||||
} from "./session-ultracode.js";
|
||||
|
||||
// `supportedAgents()` always returns Claude Code's built-in subagents — the
|
||||
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
|
||||
// has configured none of their own. Those aren't meaningful *main-thread*
|
||||
// personas, so we filter them out and only surface the Agent picker when the
|
||||
// user (or a plugin/project) has configured custom agents. Update this set if
|
||||
// the SDK's built-in roster changes.
|
||||
export const BUILTIN_AGENT_NAMES = new Set([
|
||||
"claude",
|
||||
"general-purpose",
|
||||
"Explore",
|
||||
"Plan",
|
||||
"statusline-setup",
|
||||
]);
|
||||
|
||||
// Value of the synthetic "Default" entry in the agent picker, which maps to the
|
||||
// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
|
||||
// reserved sentinel: a custom agent named exactly this would collide with it
|
||||
// (two options sharing the value, selection silently routing to `null`), so we
|
||||
// exclude that name from discovery.
|
||||
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
||||
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
||||
* list if discovery fails so a flaky control request never blocks session
|
||||
* creation. */
|
||||
export async function discoverCustomAgents(q: Query): Promise<AgentInfo[]> {
|
||||
try {
|
||||
return customAgents(await q.supportedAgents());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The configured agents of `agents`: neither a built-in subagent nor the "default" sentinel. */
|
||||
export function customAgents(agents: readonly AgentInfo[]): AgentInfo[] {
|
||||
return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
/** Stable ids for the session config options surfaced via `configOptions`.
|
||||
* Centralized so the option declarations in `buildConfigOptions` and the
|
||||
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
|
||||
* same identifiers and can't drift apart. */
|
||||
export { MODE_CONFIG_ID };
|
||||
export { AGENT_CONFIG_ID };
|
||||
|
||||
/** Whether the Client advertised support for boolean session config options
|
||||
* (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
|
||||
* config options to Clients that opt in; otherwise we fall back to a `select`.
|
||||
* See https://agentclientprotocol.com/rfds/boolean-config-option. */
|
||||
export function clientSupportsBooleanConfigOptions(
|
||||
clientCapabilities?: ClientCapabilities | null,
|
||||
): boolean {
|
||||
return clientCapabilities?.session?.configOptions?.boolean != null;
|
||||
}
|
||||
|
||||
/** Whether the Client advertised the AIR `recommendedValue` capability for
|
||||
* select-style model and effort options. Missing or malformed AIR metadata
|
||||
* retains the legacy `default` entries. */
|
||||
export function clientSupportsRecommendedConfigValue(
|
||||
clientCapabilities?: ClientCapabilities | null,
|
||||
): boolean {
|
||||
return clientSupportsAirCapability(clientCapabilities, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY);
|
||||
}
|
||||
|
||||
export type ConfigOptionPresentation = {
|
||||
/** Replace ambiguous `default` rows with concrete values and advertise the
|
||||
* SDK/adapter recommendation as `_meta.jetbrains.air.recommendedValue`. */
|
||||
useRecommendedValue: boolean;
|
||||
};
|
||||
|
||||
export function buildConfigOptions(
|
||||
modes: SessionModeState,
|
||||
models: SessionModelState,
|
||||
modelInfos: ModelInfo[],
|
||||
currentEffortLevel?: string,
|
||||
agents: AgentInfo[] = [],
|
||||
currentAgent: string = DEFAULT_AGENT_ID,
|
||||
fastMode?: FastModeOptionState,
|
||||
presentation?: ConfigOptionPresentation,
|
||||
ultracode?: UltracodeOptionState,
|
||||
): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [SessionModeManager.configOption(modes)];
|
||||
// A gateway that lists none of the SDK's models leaves no model to offer, so
|
||||
// there is no model option at all.
|
||||
if (models.availableModels.length > 0) {
|
||||
options.push(
|
||||
buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true),
|
||||
);
|
||||
}
|
||||
const effort = buildEffortConfigOption(
|
||||
modelInfos,
|
||||
models.currentModelId,
|
||||
currentEffortLevel,
|
||||
presentation?.useRecommendedValue === true,
|
||||
);
|
||||
if (effort) options.push(effort);
|
||||
|
||||
// Surface the Fast mode toggle only when the current model supports it. The
|
||||
// option renders as a native boolean toggle for Clients that opted in, and a
|
||||
// two-value select otherwise.
|
||||
if (fastMode?.supported) {
|
||||
options.push(
|
||||
createFastModeConfigOption(
|
||||
fastMode.enabled,
|
||||
fastMode.useBooleanOption,
|
||||
fastMode.disabledReason,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the ultracode toggle only while the CLI can run it: dynamic
|
||||
// workflows are enabled and the current model supports it.
|
||||
if (ultracode?.supported) {
|
||||
options.push(createUltracodeConfigOption(ultracode.enabled, ultracode.useBooleanOption));
|
||||
}
|
||||
|
||||
// Only surface the Agent picker when there's a real choice — i.e. the user
|
||||
// has configured at least one custom agent (built-ins are filtered out in
|
||||
// discoverCustomAgents). With none configured, "Default" would be the only
|
||||
// entry, so we omit the option entirely.
|
||||
if (agents.length > 0) {
|
||||
options.push({
|
||||
id: AGENT_CONFIG_ID,
|
||||
name: "Agent",
|
||||
description: "Main-thread agent persona",
|
||||
type: "select",
|
||||
currentValue: currentAgent,
|
||||
options: [
|
||||
{ value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
|
||||
...agents.map((a) => ({
|
||||
value: a.name,
|
||||
name: a.name,
|
||||
description: a.description || undefined,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value that a `session/set_config_option` request sets on `option`, the
|
||||
* option as the session advertised it. The value must match the option's
|
||||
* type: a `type: "boolean"` option takes `true`/`false`, not "on"/"off", and a
|
||||
* `type: "select"` option takes one of its values.
|
||||
*
|
||||
* Fast mode and ultracode give `enabled`: a boolean, or the "on"/"off" select
|
||||
* fallback for clients without boolean config options. A model takes an alias
|
||||
* too, such as "opus" or "sonnet" for a full model id. It is resolved against
|
||||
* `modelInfos` first: those entries carry `resolvedModel`, so a full model id
|
||||
* (in either hint spelling) lands on the right row via the exact tier instead
|
||||
* of a fuzzier one picking a same-family sibling from a different context
|
||||
* lane. The option's own entries (which never carry `resolvedModel`) remain as
|
||||
* a fallback for resolutions that don't map back onto a selectable option
|
||||
* (e.g. a fuzzy hit on an out-of-picker verbatim entry). `value` is then the
|
||||
* canonical option value, the model id rather than the alias.
|
||||
*/
|
||||
export function configOptionValue(
|
||||
option: SessionConfigOption,
|
||||
params: SetSessionConfigOptionRequest,
|
||||
modelInfos: ModelInfo[],
|
||||
): { enabled: boolean } | { value: string } {
|
||||
const expectedType = option.type === "boolean" ? "boolean" : "string";
|
||||
if (typeof params.value !== expectedType) {
|
||||
throw RequestError.invalidParams(
|
||||
{ configId: params.configId, value: params.value },
|
||||
`Config option ${params.configId} takes a ${expectedType} value, but received: ${JSON.stringify(params.value)}`,
|
||||
);
|
||||
}
|
||||
if (params.configId === FAST_MODE_CONFIG_ID) return { enabled: resolveFastModeEnabled(params) };
|
||||
if (params.configId === ULTRACODE_CONFIG_ID) {
|
||||
return { enabled: resolveUltracodeEnabled(params) };
|
||||
}
|
||||
if (typeof params.value !== "string") {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Invalid value for config option ${params.configId}: ${params.value}`,
|
||||
);
|
||||
}
|
||||
const requested = params.value;
|
||||
const allValues =
|
||||
"options" in option && Array.isArray(option.options)
|
||||
? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
|
||||
: [];
|
||||
let validValue = allValues.find((o) => o.value === requested);
|
||||
if (!validValue && params.configId === MODEL_CONFIG_ID) {
|
||||
const toOptionValue = (resolved: ModelInfo | null) =>
|
||||
resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
|
||||
validValue = toOptionValue(resolveModelPreference(modelInfos, requested));
|
||||
if (!validValue) {
|
||||
const optionInfos: ModelInfo[] = allValues.map((o) => ({
|
||||
value: o.value,
|
||||
displayName: o.name,
|
||||
description: o.description ?? "",
|
||||
}));
|
||||
validValue = toOptionValue(resolveModelPreference(optionInfos, requested));
|
||||
}
|
||||
}
|
||||
if (!validValue) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Invalid value for config option ${params.configId}: ${requested}`,
|
||||
);
|
||||
}
|
||||
return { value: validValue.value };
|
||||
}
|
||||
@@ -25,22 +25,6 @@ export function effortFlagSettings(
|
||||
return ultracode ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
/**
|
||||
* `params` without `_meta.claudeCode.options.effort`, for a restart of the
|
||||
* session's query: the restart restores the current effort pin instead of
|
||||
* the one the session was created with.
|
||||
*/
|
||||
export function withoutEffort<T extends { _meta?: unknown }>(params: T): T {
|
||||
const meta = params._meta as
|
||||
{ claudeCode?: { options?: { effort?: unknown } } } | null | undefined;
|
||||
if (meta?.claudeCode?.options?.effort === undefined) {
|
||||
return params;
|
||||
}
|
||||
const options = { ...meta.claudeCode.options };
|
||||
delete options.effort;
|
||||
return { ...params, _meta: { ...meta, claudeCode: { ...meta.claudeCode, options } } };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
@@ -149,130 +133,3 @@ export function buildEffortConfigOption(
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A session's effort bookkeeping: the level the user pinned through the
|
||||
* picker, and the level last applied to the SDK's flag layer. */
|
||||
export type EffortState = {
|
||||
pinned: string | undefined;
|
||||
applied: string | undefined;
|
||||
};
|
||||
|
||||
/** The effort after a switch to `newModelInfo`, before the option is rebuilt
|
||||
* (see {@link settleEffortAfterModelSwitch}). */
|
||||
export type EffortSwitch = {
|
||||
/** The effort to build the new model's option with. */
|
||||
seed: string | undefined;
|
||||
/** Whether the user's pin is one of the new model's levels. */
|
||||
pinnedForNewModel: boolean;
|
||||
before: EffortState;
|
||||
/** The level applied before the switch, restored if the sync fails. */
|
||||
appliedBeforeSwitch: string | undefined;
|
||||
};
|
||||
|
||||
/** The effort seed of a model switch. It depends on who owns the choice: a
|
||||
* user pin made through the ACP picker this session lives at the SDK's flag
|
||||
* layer and follows the session across switches, so it carries forward.
|
||||
* Otherwise the CLI resolves effort itself from the persisted settings — the
|
||||
* NEW model's `modelSettings` entry first (the CLI persists /effort per
|
||||
* model), then the legacy top-level value — so the option shows what it
|
||||
* will actually run instead of dragging the old model's value along. */
|
||||
export function effortForModelSwitch(input: {
|
||||
state: EffortState;
|
||||
/** The current value of the effort option, if it is shown. */
|
||||
displayed: string | undefined;
|
||||
newModelInfo: ModelInfo | undefined;
|
||||
modelId: string;
|
||||
/** The merged effort settings (see {@link mergeEffortSettings}). */
|
||||
settings: Settings;
|
||||
useRecommendedValue: boolean;
|
||||
}): EffortSwitch {
|
||||
const { state, displayed, newModelInfo, useRecommendedValue } = input;
|
||||
const pinned = state.pinned;
|
||||
const pinnedForNewModel =
|
||||
pinned !== undefined &&
|
||||
newModelInfo?.supportsEffort === true &&
|
||||
newModelInfo.supportedEffortLevels?.some((level) => level === pinned) === true;
|
||||
return {
|
||||
seed: pinnedForNewModel
|
||||
? pinned
|
||||
: settingsEffortForModel(input.settings, newModelInfo, input.modelId),
|
||||
pinnedForNewModel,
|
||||
before: state,
|
||||
appliedBeforeSwitch:
|
||||
state.applied ??
|
||||
(useRecommendedValue && typeof displayed === "string" && displayed !== "default"
|
||||
? displayed
|
||||
: pinned),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the effort of a model switch to the SDK's flag layer, once the new
|
||||
* model's effort `option` is built from `change.seed`. Returns the new state
|
||||
* and the option to show (undefined to hide it).
|
||||
*
|
||||
* Opted-in clients (`useRecommendedValue`) apply the concrete displayed effort
|
||||
* on every switch, including settings-derived values, so a previous automatic
|
||||
* flag cannot shadow the new model's settings. This does not create a user
|
||||
* pin. For other clients, the flag is synced only when a user pin is lost
|
||||
* across the switch — the new model clamped it away, where the flag must be
|
||||
* cleared too or the SDK would keep running the old pin invisibly.
|
||||
* Settings-derived seeds are display-only: the CLI resolves persisted effort
|
||||
* itself, and pinning it at the flag layer would shadow the per-model values
|
||||
* on every later switch.
|
||||
*
|
||||
* The model switch already succeeded, so a failed sync is returned as
|
||||
* `error`, not thrown. The old bookkeeping stays, and the unapplied value is
|
||||
* never shown: the option shows the last applied value when the new model can
|
||||
* select it, else it is hidden until a later switch rebuilds it.
|
||||
*/
|
||||
export async function settleEffortAfterModelSwitch(
|
||||
change: EffortSwitch,
|
||||
option: SessionConfigOption | undefined,
|
||||
ultracode: boolean,
|
||||
useRecommendedValue: boolean,
|
||||
applyFlagSettings: (settings: ReturnType<typeof effortFlagSettings>) => Promise<void>,
|
||||
): Promise<{ state: EffortState; option: SessionConfigOption | undefined; error?: unknown }> {
|
||||
const { pinnedForNewModel, before } = change;
|
||||
const sync = useRecommendedValue || (before.pinned !== undefined && !pinnedForNewModel);
|
||||
if (!sync) {
|
||||
return {
|
||||
state: pinnedForNewModel ? { pinned: before.pinned, applied: before.pinned } : before,
|
||||
option,
|
||||
};
|
||||
}
|
||||
const newEffort = typeof option?.currentValue === "string" ? option.currentValue : undefined;
|
||||
try {
|
||||
// A legacy client's unpinned effort is display-only: the CLI resolves the
|
||||
// persisted value for the new model. When an old user pin is no longer
|
||||
// supported, clear the flag layer instead of replacing it with that
|
||||
// displayed value. Opted-in clients deliberately apply their concrete
|
||||
// displayed effort.
|
||||
await applyFlagSettings(
|
||||
effortFlagSettings(useRecommendedValue ? newEffort : undefined, ultracode),
|
||||
);
|
||||
return {
|
||||
state: {
|
||||
pinned: pinnedForNewModel ? before.pinned : undefined,
|
||||
applied: useRecommendedValue && newEffort !== "default" ? newEffort : undefined,
|
||||
},
|
||||
option,
|
||||
};
|
||||
} catch (error) {
|
||||
const applied = change.appliedBeforeSwitch;
|
||||
const selectable =
|
||||
applied !== undefined &&
|
||||
option?.type === "select" &&
|
||||
option.options.some((entry) =>
|
||||
"value" in entry
|
||||
? entry.value === applied
|
||||
: entry.options.some((nested) => nested.value === applied),
|
||||
);
|
||||
return {
|
||||
state: { pinned: before.pinned, applied },
|
||||
option:
|
||||
selectable && option?.type === "select" ? { ...option, currentValue: applied } : undefined,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Session options that come from the agent's environment, shared by the v1
|
||||
* core and the v2 core.
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { Options, Settings, ThinkingConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Translate the legacy `MAX_THINKING_TOKENS` env var, plus the optional
|
||||
* `CLAUDE_CODE_THINKING_DISPLAY` env var, into the SDK's `thinking` option.
|
||||
* The `maxThinkingTokens` option it used to feed is deprecated and reduced
|
||||
* to on/off on current models, so map the value to explicit thinking config
|
||||
* instead: unset → `undefined` (SDK default, adaptive on models that support
|
||||
* it) unless a display mode is requested, in which case it becomes explicit
|
||||
* `adaptive`; `0` → disabled; a positive integer → a fixed token budget.
|
||||
* Anything else is ignored with a warning. `CLAUDE_CODE_THINKING_DISPLAY`
|
||||
* must be `summarized` or `omitted`; it has no effect when thinking is
|
||||
* disabled. */
|
||||
export function resolveThinkingConfig(
|
||||
rawTokens: string | undefined,
|
||||
rawDisplay: string | undefined,
|
||||
logger: { error: (...args: unknown[]) => void },
|
||||
): ThinkingConfig | undefined {
|
||||
let display: "summarized" | "omitted" | undefined;
|
||||
if (rawDisplay !== undefined) {
|
||||
if (rawDisplay === "summarized" || rawDisplay === "omitted") {
|
||||
display = rawDisplay;
|
||||
} else {
|
||||
logger.error(
|
||||
`Ignoring CLAUDE_CODE_THINKING_DISPLAY: expected 'summarized' or 'omitted', got '${rawDisplay}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rawTokens === undefined) {
|
||||
return display ? { type: "adaptive", display } : undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(rawTokens, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
logger.error(
|
||||
`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${rawTokens}'.`,
|
||||
);
|
||||
return display ? { type: "adaptive", display } : undefined;
|
||||
}
|
||||
return parsed === 0
|
||||
? { type: "disabled" }
|
||||
: { type: "enabled", budgetTokens: parsed, ...(display && { display }) };
|
||||
}
|
||||
|
||||
/** The `CLAUDE_MODEL_CONFIG` JSON: Bedrock model overrides and an allowlist, say. */
|
||||
function parseModelConfig(
|
||||
raw: string | undefined,
|
||||
): { modelOverrides?: Record<string, string>; availableModels?: string[] } | undefined {
|
||||
if (!raw) return undefined;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object");
|
||||
}
|
||||
const result: { modelOverrides?: Record<string, string>; availableModels?: string[] } = {};
|
||||
if (parsed.modelOverrides !== undefined) result.modelOverrides = parsed.modelOverrides;
|
||||
if (parsed.availableModels !== undefined) result.availableModels = parsed.availableModels;
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The programmatic settings of a session, before the provider routing: the
|
||||
* models of `CLAUDE_MODEL_CONFIG`, then the client's `settings` option, an
|
||||
* object or a file path relative to `cwd`. Throws for a `CLAUDE_MODEL_CONFIG`
|
||||
* that is no JSON object, before it reads the file.
|
||||
*/
|
||||
export async function configuredSettings(
|
||||
provided: Options["settings"],
|
||||
cwd: string,
|
||||
): Promise<Settings | undefined> {
|
||||
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
|
||||
const object =
|
||||
typeof provided === "string"
|
||||
? (JSON.parse(await fs.readFile(path.resolve(cwd, provided), "utf8")) as Settings)
|
||||
: (provided as Settings | undefined);
|
||||
return modelConfig ? { ...modelConfig, ...object } : object;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* The Fast mode toggle of a session: its config option, the value a
|
||||
* `session/set_config_option` request sets, and the state Claude Code reports
|
||||
* (`fast_mode_state` and `fast_mode_disabled_reason`, on `system`/init and on
|
||||
* results). Shared by the v1 core and the v2 core.
|
||||
*/
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
SessionConfigOption,
|
||||
SetSessionConfigOptionRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { FastModeDisabledReason, FastModeState } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { sentenceCase } from "./session-notices.js";
|
||||
|
||||
export const FAST_MODE_CONFIG_ID = "fast";
|
||||
|
||||
/** Select-fallback values used when the client has not opted into boolean
|
||||
* config options (see {@link createFastModeConfigOption}). */
|
||||
export const FAST_MODE_ON = "on";
|
||||
export const FAST_MODE_OFF = "off";
|
||||
const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
|
||||
|
||||
/** Map the SDK's tri-state `fast_mode_state` onto the boolean config toggle.
|
||||
* `cooldown` (fast mode temporarily suspended after a rate limit, per the SDK
|
||||
* docs) keeps the toggle on so it reflects the user's intent — only an
|
||||
* explicit `off` clears it. */
|
||||
export function fastModeStateEnabled(state: FastModeState): boolean {
|
||||
return state !== "off";
|
||||
}
|
||||
|
||||
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
|
||||
* that a user can act on (or at least wants to know about). Deliberately
|
||||
* partial — the omitted reasons are not worth surfacing:
|
||||
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
|
||||
* opt-in), so it describes the default, not a problem.
|
||||
* - `preference`: the user turned Fast mode off themselves.
|
||||
* - `pending`: eligibility is still resolving; the next report supersedes it.
|
||||
* - `unknown`: nothing meaningful to say.
|
||||
* Unknown future reasons fall through the same way (open set — the SDK's docs
|
||||
* say to ignore values you don't handle). */
|
||||
const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason, string>> = {
|
||||
free: "not available on the free plan",
|
||||
extra_usage_disabled: "requires extra usage to be enabled for this account",
|
||||
model_not_allowed: "not available for the selected model",
|
||||
not_first_party: "not available on this API provider",
|
||||
disabled_by_env: "disabled by environment configuration",
|
||||
network_error: "eligibility could not be verified (network error)",
|
||||
};
|
||||
|
||||
/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
|
||||
* a reason we have an explanation for, else `undefined`. Keeping only
|
||||
* explainable reasons means state comparisons (see `fastModeStateChange`) track
|
||||
* exactly what the user can see, so routine reports like
|
||||
* `sdk_opt_in_required` never churn the config option. */
|
||||
export function normalizeFastModeDisabledReason(
|
||||
reason: FastModeDisabledReason | undefined,
|
||||
): FastModeDisabledReason | undefined {
|
||||
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
|
||||
}
|
||||
|
||||
/** Build the Fast mode config option. When the Client supports boolean config
|
||||
* options we expose a native `type: "boolean"` toggle; otherwise we degrade to
|
||||
* a two-value `select` ("on"/"off") so older Clients still get a usable
|
||||
* control.
|
||||
*
|
||||
* `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the
|
||||
* description while the toggle reads off, so a user whose account or provider
|
||||
* can't serve Fast mode sees why instead of a switch that silently refuses to
|
||||
* stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown`
|
||||
* state isn't blocking anything right now. */
|
||||
export function createFastModeConfigOption(
|
||||
enabled: boolean,
|
||||
useBooleanOption: boolean,
|
||||
disabledReason?: FastModeDisabledReason,
|
||||
): SessionConfigOption {
|
||||
const explanation = enabled
|
||||
? undefined
|
||||
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
|
||||
const base = {
|
||||
id: FAST_MODE_CONFIG_ID,
|
||||
name: "Fast mode",
|
||||
description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
|
||||
category: "model_config",
|
||||
} as const;
|
||||
|
||||
if (useBooleanOption) {
|
||||
return { ...base, type: "boolean", currentValue: enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
type: "select",
|
||||
currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
|
||||
options: [
|
||||
{ value: FAST_MODE_ON, name: "On" },
|
||||
{ value: FAST_MODE_OFF, name: "Off" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the requested Fast mode value from a `session/set_config_option`
|
||||
* request. Accepts a native boolean (boolean-capable Clients) or the
|
||||
* "on"/"off" select-fallback strings. */
|
||||
export function resolveFastModeEnabled(params: SetSessionConfigOptionRequest): boolean {
|
||||
const value = params.value;
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (value === FAST_MODE_ON) {
|
||||
return true;
|
||||
}
|
||||
if (value === FAST_MODE_OFF) {
|
||||
return false;
|
||||
}
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-model Fast mode state threaded into `buildConfigOptions`. The
|
||||
* option is only surfaced when the current model `supported`s fast mode. */
|
||||
export type FastModeOptionState = {
|
||||
supported: boolean;
|
||||
enabled: boolean;
|
||||
/** Whether the Client opted into boolean config options. */
|
||||
useBooleanOption: boolean;
|
||||
/** Latest explainable `fast_mode_disabled_reason`, folded into the option's
|
||||
* description while the toggle reads off. */
|
||||
disabledReason?: FastModeDisabledReason;
|
||||
};
|
||||
|
||||
/** What a reported `fast_mode_state` changes in a Fast mode toggle that the
|
||||
* current model shows. The SDK can flip fast mode independently of the
|
||||
* user — e.g. back to `on` once a rate-limit `cooldown` clears — so
|
||||
* definitive on/off changes are mirrored into the option.
|
||||
*
|
||||
* Undefined when nothing changes: an absent state, a `cooldown` (a transient
|
||||
* suspension of an already-enabled fast mode: the toggle stays as-is rather
|
||||
* than flapping, and a stray cooldown never enables a toggle the user has
|
||||
* off), or the same state with the same explainable reason.
|
||||
*
|
||||
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
|
||||
* state. Only explainable reasons are retained (see
|
||||
* {@link normalizeFastModeDisabledReason}), so a routine
|
||||
* `sdk_opt_in_required` report on every turn's result can't churn the
|
||||
* option, while a real blocker updates the description even when the
|
||||
* toggle's own value is unchanged.
|
||||
*
|
||||
* `notice` is set when the user had Fast mode on and the SDK turned it off
|
||||
* for an explainable reason: the description carries the same explanation,
|
||||
* but a toggle silently snapping back is worth saying out loud once. */
|
||||
export function fastModeStateChange(
|
||||
current: { enabled: boolean; disabledReason?: FastModeDisabledReason },
|
||||
state: FastModeState | undefined,
|
||||
reason: FastModeDisabledReason | undefined,
|
||||
):
|
||||
| { enabled: boolean; disabledReason?: FastModeDisabledReason; notice?: FastModeOffNotice }
|
||||
| undefined {
|
||||
if (state === undefined || state === "cooldown") return undefined;
|
||||
const enabled = state === "on";
|
||||
// A reason only describes an off state; drop any that rides an `on` report
|
||||
// so it can't decorate the option the next time fast mode goes off.
|
||||
const disabledReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
|
||||
if (enabled === current.enabled && disabledReason === current.disabledReason) return undefined;
|
||||
const explanation =
|
||||
disabledReason !== undefined ? FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason] : undefined;
|
||||
if (!current.enabled || enabled || explanation === undefined) {
|
||||
return { enabled, disabledReason };
|
||||
}
|
||||
return {
|
||||
enabled,
|
||||
disabledReason,
|
||||
notice: {
|
||||
notice: {
|
||||
severity: "warning",
|
||||
title: "Fast mode turned off",
|
||||
description: `${sentenceCase(explanation)}.`,
|
||||
},
|
||||
transcript: `**Fast mode turned off:** ${explanation}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The notice of Fast mode that the SDK turned off, and its transcript line
|
||||
* for a client without `notice` updates. */
|
||||
export type FastModeOffNotice = {
|
||||
notice: { severity: "warning"; title: string; description: string };
|
||||
transcript: string;
|
||||
};
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import { TASK_NOTIFICATION_TAG, taskNotificationsOf } from "../../task-notifications.js";
|
||||
import { TASK_NOTIFICATION_TAG, taskNotificationsOf } from "../task-notifications.js";
|
||||
|
||||
export type BackgroundTasks = {
|
||||
/** The ids of the tasks that started and did not end, in start order. */
|
||||
@@ -17,9 +17,7 @@ import {
|
||||
type ListSessionsResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Session } from "../acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { AcpClient, Logger, Session } from "../acp-agent.js";
|
||||
import {
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_BATCH_GET_CAPABILITY,
|
||||
@@ -29,7 +27,7 @@ import {
|
||||
AIR_SESSION_WORKTREES_CAPABILITY,
|
||||
clientSupportsAirCapability,
|
||||
withAirMeta,
|
||||
} from "../../air-extension.js";
|
||||
} from "../air-extension.js";
|
||||
import type { OwnSessionState } from "./activity.js";
|
||||
import { isArchivedTitle, storedTitle, visibleTitle } from "./archive-title.js";
|
||||
import {
|
||||
@@ -123,11 +121,13 @@ export class SessionIndexConnection {
|
||||
|
||||
constructor(private readonly host: SessionIndexHost) {}
|
||||
|
||||
/** Sets the index up for the client of `initialize`. */
|
||||
negotiate(request: InitializeRequest): void {
|
||||
/** Sets the index up for the client of `initialize`. ACP v2 does not route
|
||||
* the session index methods yet. */
|
||||
negotiate(request: InitializeRequest, options: { v2: boolean }): void {
|
||||
this.service?.dispose();
|
||||
this.service = undefined;
|
||||
if (
|
||||
!options.v2 &&
|
||||
this.host.isAirClient() &&
|
||||
clientSupportsAirCapability(request.clientCapabilities, AIR_SESSION_INDEX_CAPABILITY)
|
||||
) {
|
||||
@@ -23,7 +23,7 @@ import { execFile } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { claudeConfigDir } from "../../paths.js";
|
||||
import { claudeConfigDir } from "../paths.js";
|
||||
import { errorCode } from "./project-dirs.js";
|
||||
|
||||
const SNAPSHOT_TTL_MS = 5_000;
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { claudeConfigDir } from "../../paths.js";
|
||||
import { claudeConfigDir } from "../paths.js";
|
||||
|
||||
/** The SDK cuts an encoded path at this length and appends a hash. */
|
||||
const MAX_SANITIZED_LENGTH = 200;
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
type SessionInfo,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { airExtensionMeta } from "../../air-extension.js";
|
||||
import { sanitizeTitle } from "../../session-titles.js";
|
||||
import { airExtensionMeta } from "../air-extension.js";
|
||||
import { sanitizeTitle } from "../session-titles.js";
|
||||
import type { OwnSessionState } from "./activity.js";
|
||||
import {
|
||||
effectiveTitle,
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { sanitizeTitle } from "../../session-titles.js";
|
||||
import { sanitizeTitle } from "../session-titles.js";
|
||||
import { effectiveTitle, visibleTitle } from "./archive-title.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
|
||||
import { withAirMeta } from "../../air-extension.js";
|
||||
import { withAirMeta } from "../air-extension.js";
|
||||
import { deriveActivity, selectCost, type OwnSessionState } from "./activity.js";
|
||||
import type { LiveRecord } from "./live-registry.js";
|
||||
import type { IndexRow } from "./session-index.js";
|
||||
@@ -405,7 +405,7 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAvailableModes(
|
||||
function buildAvailableModes(
|
||||
{ allowBypass, allowAuto }: ModeAvailability,
|
||||
air = false,
|
||||
): SessionModeState["availableModes"] {
|
||||
|
||||
@@ -84,73 +84,39 @@ function versionedModelDisplayName(model: ModelInfo): string {
|
||||
return `${withoutContext} ${version}${contextSuffix[0]}`;
|
||||
}
|
||||
|
||||
function modelWords(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
|
||||
const lower = model.trim().toLowerCase();
|
||||
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
|
||||
const tokens = lower
|
||||
.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ")
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean);
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
if (token === "opusplan") return "opus";
|
||||
if (token === "best" || token === "default") return "";
|
||||
return token;
|
||||
})
|
||||
.filter((token) => token && token !== "claude")
|
||||
.filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
|
||||
return { tokens, contextHint };
|
||||
}
|
||||
|
||||
/** Whether `needle`'s words appear in `haystack`'s words, adjacent and in
|
||||
* order: "opus" in "claude-opus-4-5", but not "son" in "sonnet" or "a" in
|
||||
* "Default (recommended)". */
|
||||
function containsWords(haystack: string, needle: string): boolean {
|
||||
const words = modelWords(haystack);
|
||||
const wanted = modelWords(needle);
|
||||
if (wanted.length === 0) return false;
|
||||
for (let start = 0; start + wanted.length <= words.length; start++) {
|
||||
if (wanted.every((word, offset) => words[start + offset] === word)) return true;
|
||||
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
|
||||
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
|
||||
let score = 0;
|
||||
let nonHintMatched = false;
|
||||
for (const token of tokens) {
|
||||
if (!haystack.includes(token)) continue;
|
||||
if (token !== contextHint) nonHintMatched = true;
|
||||
score += token === contextHint ? 3 : 1;
|
||||
}
|
||||
return false;
|
||||
return contextHint && !nonHintMatched ? 0 : score;
|
||||
}
|
||||
|
||||
/** `id` without the gateway spellings that `normalizeModelId` keeps for
|
||||
* `deniedModels` and gateway listings: path segments before `claude-`
|
||||
* (`vertex_ai/`, `openrouter/anthropic/`, `publishers/anthropic/models/`)
|
||||
* and the Anthropic API's `-latest` suffix (`claude-3-5-haiku-latest`). */
|
||||
function withoutGatewaySpelling(id: string): string {
|
||||
return id.replace(/^(?:[^/]+\/)+(?=claude-)/, "").replace(/-latest$/, "");
|
||||
}
|
||||
|
||||
/** The words of `preference` that name a model: without what
|
||||
* `normalizeModelId` drops (provider prefixes, dates, `-v1:0`, `-fast`,
|
||||
* context hints), version numbers, "claude", and "best"/"default".
|
||||
* "us.anthropic.claude-sonnet-4-5-v1:0" gives ["sonnet"], "opusplan" gives
|
||||
* ["opus"]. */
|
||||
function modelPreferenceTokens(preference: string): string[] {
|
||||
return modelWords(withoutGatewaySpelling(normalizeModelId(preference)))
|
||||
.map((token) => (token === "opusplan" ? "opus" : token))
|
||||
.filter((token) => /[a-z]/.test(token) && !["claude", "best", "default"].includes(token));
|
||||
}
|
||||
|
||||
/** Whether every one of `tokens` is a word of `model`'s value or display name. */
|
||||
function modelCoversTokens(model: ModelInfo, tokens: string[]): boolean {
|
||||
const words = new Set(modelWords(`${model.value} ${model.displayName}`));
|
||||
return tokens.length > 0 && tokens.every((token) => words.has(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry of `models` that `preference` names, or null when it names none.
|
||||
* `best` names the entry `opus` resolves to, unless an entry's value is `best`.
|
||||
* In order: an entry whose value, display name or `resolvedModel` equals it
|
||||
* (in either context-hint spelling); an entry whose value or display name
|
||||
* contains its words ("opus" for "claude-opus-4-5"), or whose value it
|
||||
* contains while every model word of the preference is the entry's
|
||||
* ("sonnet" for "us.anthropic.claude-sonnet-5-v1:0"); then any entry with all
|
||||
* of its model words, preferring one with the same context hint ("sonnet[1m]"
|
||||
* for "claude-sonnet-5-1m"). The fuzzy tiers need a compatible version, and
|
||||
* words match whole: "not-a-model", "son" and "foo-sonnet" name no entry.
|
||||
*/
|
||||
export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null {
|
||||
const trimmed = preference.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
// The CLI resolves `best` to its flagship: Opus, unless a server-side flag
|
||||
// names another family that this adapter cannot read. The CLI ignores a
|
||||
// `[1m]` hint on `best`. A listed entry named `best` still matches directly.
|
||||
if (stripContextHints(lower) === "best" && !models.some((model) => model.value === trimmed)) {
|
||||
return resolveModelPreference(models, "opus");
|
||||
}
|
||||
const canonicalPreference = canonicalizeModelId(trimmed);
|
||||
const directMatch = models.find(
|
||||
(model) =>
|
||||
@@ -168,26 +134,28 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
|
||||
if (resolvedMatch) return resolvedMatch;
|
||||
|
||||
const preferenceHint = contextHintOf(trimmed);
|
||||
const tokens = modelPreferenceTokens(trimmed);
|
||||
const includesMatch = models.find((model) => {
|
||||
if (!modelVersionsCompatible(trimmed, model)) return false;
|
||||
if (contextHintOf(model.value) !== preferenceHint) return false;
|
||||
return (
|
||||
containsWords(model.value, trimmed) ||
|
||||
containsWords(model.displayName, trimmed) ||
|
||||
(containsWords(trimmed, model.value) && modelCoversTokens(model, tokens))
|
||||
);
|
||||
const value = model.value.toLowerCase();
|
||||
const display = model.displayName.toLowerCase();
|
||||
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
||||
});
|
||||
if (includesMatch) return includesMatch;
|
||||
|
||||
const candidates = models.filter(
|
||||
(model) => modelVersionsCompatible(trimmed, model) && modelCoversTokens(model, tokens),
|
||||
);
|
||||
return (
|
||||
candidates.find((model) => contextHintOf(model.value) === preferenceHint) ??
|
||||
candidates[0] ??
|
||||
null
|
||||
);
|
||||
const { tokens, contextHint } = tokenizeModelPreference(trimmed);
|
||||
if (tokens.length === 0) return null;
|
||||
let bestMatch: ModelInfo | null = null;
|
||||
let bestScore = 0;
|
||||
for (const model of models) {
|
||||
if (!modelVersionsCompatible(trimmed, model)) continue;
|
||||
const score = scoreModelMatch(model, tokens, contextHint);
|
||||
if (score > 0 && (!bestMatch || score > bestScore)) {
|
||||
bestMatch = model;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {
|
||||
@@ -313,67 +281,6 @@ function resolveSettingsModel(
|
||||
|
||||
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
|
||||
|
||||
/** The family and version of a `claude-<family>-<major>[-<minor>]` id:
|
||||
* `claude-fable-5-1` is fable 5.1, `claude-haiku-4-5-20251001` haiku 4.5. */
|
||||
function familyVersionOf(id: string): { family: string; major: number; minor: number } | null {
|
||||
const match = id.toLowerCase().match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?![\d.])/);
|
||||
if (!match) return null;
|
||||
return { family: match[1], major: Number(match[2]), minor: Number(match[3] ?? 0) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back the family alias value to the row the CLI lists under a resumed
|
||||
* session's model id.
|
||||
*
|
||||
* The CLI lists Fable 5.2 as `fable`, but when it resumes a session that ran
|
||||
* Fable 5.2 it lists the same row as `claude-fable-5-2` and has no `fable`
|
||||
* row. Clients store the option value as the session's choice, so a value
|
||||
* from a new session would not be offered by a resumed one, and the reverse.
|
||||
*
|
||||
* A row gets its family's alias as its value when its `value` is
|
||||
* `resumedModel`, equals its own `resolvedModel` and has no context hint, no
|
||||
* row has that alias, and no row of the family has a newer version (an alias
|
||||
* runs its family's newest model). Every other row is returned as it is.
|
||||
*
|
||||
* The CLI lists a row the same way when `ANTHROPIC_MODEL` or `settings.model`
|
||||
* pins `claude-fable-5-2`. Those rows keep the pinned id, so a client's choice
|
||||
* stays on that version after a newer Fable becomes `fable`. A resumed Fable
|
||||
* 5.1 session keeps `claude-fable-5-1` too, because Fable 5.2 is newer.
|
||||
*/
|
||||
export function aliasResumedModelRow(
|
||||
models: ModelInfo[],
|
||||
resumedModel: string | undefined,
|
||||
): ModelInfo[] {
|
||||
if (resumedModel === undefined) return models;
|
||||
const resumed = canonicalizeModelId(resumedModel);
|
||||
const values = new Set(models.map((model) => model.value));
|
||||
const versions = models.flatMap((model) => {
|
||||
const version = familyVersionOf(model.resolvedModel ?? model.value);
|
||||
return version ? [version] : [];
|
||||
});
|
||||
return models.map((model) => {
|
||||
const version = familyVersionOf(model.value);
|
||||
if (
|
||||
version === null ||
|
||||
!MODEL_FAMILY_ALIASES.has(version.family) ||
|
||||
values.has(version.family) ||
|
||||
model.resolvedModel !== model.value ||
|
||||
contextHintOf(model.value) !== null ||
|
||||
canonicalizeModelId(model.value) !== resumed ||
|
||||
versions.some(
|
||||
(other) =>
|
||||
other.family === version.family &&
|
||||
(other.major > version.major ||
|
||||
(other.major === version.major && other.minor > version.minor)),
|
||||
)
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
values.add(version.family);
|
||||
return { ...model, value: version.family };
|
||||
});
|
||||
}
|
||||
|
||||
/** A model id without the spellings that name the same model: provider
|
||||
* prefixes, dates, version suffixes, `:variant` suffixes, dotted versions,
|
||||
* `-fast` and context hints. `deniedModels` and gateway listings both match on
|
||||
@@ -549,61 +456,6 @@ export function narrowToGatewayModels(
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The capabilities of the session's current model, and the model infos that
|
||||
* every later lookup reads.
|
||||
*
|
||||
* A resumed session can be running a model outside the `availableModels`
|
||||
* allowlist (currentModelId is then the verbatim live id, see
|
||||
* `matchResumedModel`); its capabilities are still known to the SDK's
|
||||
* unfiltered list, so they are resolved from that before the model is treated
|
||||
* as unknown — otherwise auto mode would be spuriously clamped and the
|
||||
* Fast-mode/Effort options hidden for a model that supports them.
|
||||
*
|
||||
* The fallback-resolved capabilities are registered under the verbatim live
|
||||
* id, so every modelInfos consumer (the effort option, later rebuilds) agrees
|
||||
* with that gating. `buildModelConfigOption` lists this model as its own
|
||||
* entry while it is current and drops it once the user switches to a listed
|
||||
* model; it is never added to `availableModels`. The spread keeps every
|
||||
* capability flag (current and future); the identity fields are overridden
|
||||
* because the fuzzy-matched sibling's resolvedModel/displayName/description
|
||||
* can describe a different context lane and would poison later resolvedModel
|
||||
* matching (a model switch that the adapter did not make) and context-window
|
||||
* inference if they traveled under this id. `allowlistedModelInfo` is the
|
||||
* allowlisted entry alone, the only one whose identity strings are a
|
||||
* trustworthy window signal.
|
||||
*/
|
||||
export function withCurrentModelInfo(
|
||||
allowedModels: ModelInfo[],
|
||||
sdkModels: ModelInfo[],
|
||||
currentModelId: string,
|
||||
): {
|
||||
allowlistedModelInfo: ModelInfo | undefined;
|
||||
currentModelInfo: ModelInfo | undefined;
|
||||
modelInfos: ModelInfo[];
|
||||
} {
|
||||
const allowlistedModelInfo = allowedModels.find((m) => m.value === currentModelId);
|
||||
const fallbackModelInfo = allowlistedModelInfo
|
||||
? undefined
|
||||
: (resolveModelPreference(sdkModels, currentModelId) ?? undefined);
|
||||
return {
|
||||
allowlistedModelInfo,
|
||||
currentModelInfo: allowlistedModelInfo ?? fallbackModelInfo,
|
||||
modelInfos: fallbackModelInfo
|
||||
? [
|
||||
...allowedModels,
|
||||
{
|
||||
...fallbackModelInfo,
|
||||
value: currentModelId,
|
||||
displayName: currentModelId,
|
||||
description: "",
|
||||
resolvedModel: undefined,
|
||||
},
|
||||
]
|
||||
: allowedModels,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a rejected `setModel` was vetoed by a user-configured
|
||||
* PreModelSwitch hook. The CLI's rejection message is the stable,
|
||||
* distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
|
||||
|
||||
@@ -118,98 +118,3 @@ export function splitNoticeText(
|
||||
}
|
||||
return rest ? { title: firstLine, description: rest } : { title: firstLine };
|
||||
}
|
||||
|
||||
/** The notice of Claude Code's `informational` message (e.g. why a Stop hook
|
||||
* blocked continuation): a warning at level `warning`, else info. */
|
||||
export function informationalNotice(message: { level: string; content: string }): SessionNotice {
|
||||
const severity = message.level === "warning" ? "warning" : "info";
|
||||
return {
|
||||
severity,
|
||||
...splitNoticeText(
|
||||
message.content,
|
||||
severity === "warning" ? "Claude reported a warning" : "Claude reported a notice",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** The notice of a client MCP server that the `system/init` lists as not
|
||||
* started: `failed`, or `needs-auth` when the client cannot run the OAuth
|
||||
* flow (`oauthAvailable`). Undefined for any other status. */
|
||||
export function mcpServerStartupNotice(
|
||||
server: { name: string; status: string },
|
||||
oauthAvailable: boolean,
|
||||
): SessionNotice | undefined {
|
||||
if (server.status === "failed") {
|
||||
return {
|
||||
severity: "warning",
|
||||
title: `MCP server "${server.name}" failed to start`,
|
||||
description: "Its tools are unavailable in this session. Run /mcp for the error.",
|
||||
};
|
||||
}
|
||||
if (server.status === "needs-auth" && !oauthAvailable) {
|
||||
return {
|
||||
severity: "info",
|
||||
title: `MCP server "${server.name}" needs authentication`,
|
||||
description: "Its tools are unavailable until the server is authenticated.",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Claude Code's `model_refusal_fallback` tells the user: the SDK retried
|
||||
* a refused turn on the fallback model.
|
||||
*
|
||||
* Current CLIs only emit direction "retry", a swap that lasts for the
|
||||
* session. "revert"/"sticky" are retained in the SDK enum for older CLIs,
|
||||
* where "revert" marked a turn-only fallback: the session stays on the
|
||||
* original model. `scope` (CLI 2.1.232+) "local" means a subagent,
|
||||
* side-question or background fork response fell back and the session model
|
||||
* is unchanged. Absent scope means an older CLI, where every retry was a
|
||||
* session-level swap. `persistent` is whether the session now runs
|
||||
* `fallback_model`, so its model option must follow.
|
||||
*/
|
||||
export function modelRefusalFallback(message: {
|
||||
original_model: string;
|
||||
fallback_model: string;
|
||||
direction?: string;
|
||||
scope?: string;
|
||||
api_refusal_category?: string | null;
|
||||
api_refusal_explanation?: string | null;
|
||||
}): {
|
||||
persistent: boolean;
|
||||
/** The one-line summary. */
|
||||
summary: string;
|
||||
explanation: string | undefined;
|
||||
/** The notice: the summary as its title when it is short enough, else a
|
||||
* generic title with everything in the description. */
|
||||
notice: SessionNotice;
|
||||
/** The transcript line for a client without `notice` updates. */
|
||||
transcript: string;
|
||||
} {
|
||||
const local = message.scope === "local";
|
||||
const persistent = message.direction !== "revert" && !local;
|
||||
const category = message.api_refusal_category ? ` (${message.api_refusal_category})` : "";
|
||||
const outcome = persistent
|
||||
? `The session will continue on ${message.fallback_model}.`
|
||||
: local
|
||||
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
|
||||
: `The session stays on ${message.original_model}.`;
|
||||
const summary =
|
||||
`${message.original_model} declined this request${category}; ` +
|
||||
`retried with ${message.fallback_model}. ${outcome}`;
|
||||
const explanation = message.api_refusal_explanation || undefined;
|
||||
const full = explanation ? `${summary}\n\n${explanation}` : summary;
|
||||
return {
|
||||
persistent,
|
||||
summary,
|
||||
explanation,
|
||||
notice: {
|
||||
severity: "warning",
|
||||
...(summary.length > MAX_NOTICE_TITLE_LENGTH
|
||||
? { title: "Model fallback", description: full }
|
||||
: { title: summary, ...(explanation ? { description: explanation } : {}) }),
|
||||
},
|
||||
transcript: `**Model fallback:** ${full}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,42 +11,19 @@
|
||||
* `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
|
||||
* `CLAUDE_CODE_DISABLE_TERMINAL_TITLE` set), the same request generates it.
|
||||
*
|
||||
* {@link SessionTitles} holds the per-session state and is owned by the
|
||||
* session, in the v1 core (`v1/acp-agent.ts`) and the v2 core
|
||||
* (`v2/session.ts`). Each drives it from the same places:
|
||||
* {@link SessionTitles.onPrompt} and {@link SessionTitles.onAssistantText} to
|
||||
* collect text, {@link SessionTitles.onPromptQueued} once a prompt is sent to
|
||||
* the CLI, {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`,
|
||||
* and, in v1, {@link SessionTitles.reset} on `conversation_reset`.
|
||||
* {@link SessionTitles} holds the per-session state and is owned by `Session`.
|
||||
* `acp-agent.ts` drives it from five places: {@link SessionTitles.onPrompt} and
|
||||
* {@link SessionTitles.onAssistantText} to collect text,
|
||||
* {@link SessionTitles.onPromptQueued} once a prompt is sent to the CLI,
|
||||
* {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`, and
|
||||
* {@link SessionTitles.reset} on `conversation_reset`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ContentBlock,
|
||||
PromptRequest,
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent, Session } from "./acp-agent.js";
|
||||
import { ExplicitTitle } from "./session-index/explicit-title.js";
|
||||
|
||||
/** What titles read of their session. */
|
||||
export interface TitledSession {
|
||||
readonly query: Query;
|
||||
readonly queryClosed?: boolean;
|
||||
/** Whether a cancel stopped the session's work: no title is generated then. */
|
||||
readonly cancelled?: boolean;
|
||||
readonly cwd: string;
|
||||
}
|
||||
|
||||
/** What titles need of the core that serves the session (`ClaudeAcpAgent` in v1). */
|
||||
export interface TitleOwner {
|
||||
readonly client: { sessionUpdate(notification: SessionNotification): Promise<void> };
|
||||
readonly logger: { error: (...args: any[]) => void };
|
||||
/** The live sessions by id: a session replaced or closed meanwhile adopts no title. */
|
||||
readonly sessions: Readonly<Record<string, TitledSession | undefined>>;
|
||||
/** The title as the client shows it, such as with an archive prefix removed. */
|
||||
clientTitle?(title: string): string;
|
||||
}
|
||||
|
||||
const MAX_TITLE_LENGTH = 256;
|
||||
|
||||
/** How much session text the title is generated from. The CLI caps its own
|
||||
@@ -160,7 +137,7 @@ export class SessionTitles {
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly agent: TitleOwner,
|
||||
private readonly agent: ClaudeAcpAgent,
|
||||
private readonly sessionId: string,
|
||||
) {}
|
||||
|
||||
@@ -181,7 +158,7 @@ export class SessionTitles {
|
||||
* prompt alone is the description, because the CLI titles from the prompt
|
||||
* alone. When this yields no title, the stored summary is published and
|
||||
* the next prompt tries again. */
|
||||
onPromptQueued(session: TitledSession, prompt: PromptRequest["prompt"]): void {
|
||||
onPromptQueued(session: Session, prompt: PromptRequest["prompt"]): void {
|
||||
this.askedAtPrompt = false;
|
||||
// `session.cancelled` is not checked: after a cancelled turn it stays set
|
||||
// until the next turn starts, which is after this prompt is queued.
|
||||
@@ -232,7 +209,7 @@ export class SessionTitles {
|
||||
* turn-end must not wait on it. That covers a prompt too short to title
|
||||
* from, such as "hi". Only when that is not possible do we fall back to
|
||||
* `summary`, which for an SDK-driven session is just the raw first prompt. */
|
||||
async onTurnEnd(session: TitledSession): Promise<void> {
|
||||
async onTurnEnd(session: Session): Promise<void> {
|
||||
if (this.explicit.settledByClient) {
|
||||
// No title is generated over the one the client set, but a custom title
|
||||
// stored since by someone else (a `/rename`) is adopted, as before.
|
||||
@@ -324,7 +301,7 @@ export class SessionTitles {
|
||||
* have it. Both come from the session file, as `session/list` reports
|
||||
* them; a session with no file yet sends the last title published, if any,
|
||||
* without `updatedAt`. */
|
||||
async publishCurrent(session: TitledSession): Promise<void> {
|
||||
async publishCurrent(session: Session): Promise<void> {
|
||||
const info = await this.readSessionInfo(session);
|
||||
// A title published but not yet stored (a generated title still being
|
||||
// written) wins over `summary`, which is then only the raw first prompt.
|
||||
@@ -352,7 +329,7 @@ export class SessionTitles {
|
||||
|
||||
/** Read the SDK's stored info for this session. A missing session file or read
|
||||
* error is non-fatal: the title is best-effort and another turn will retry. */
|
||||
private async readSessionInfo(session: TitledSession): Promise<SDKSessionInfo | undefined> {
|
||||
private async readSessionInfo(session: Session): Promise<SDKSessionInfo | undefined> {
|
||||
try {
|
||||
return await getSessionInfo(this.sessionId, { dir: session.cwd });
|
||||
} catch (error) {
|
||||
@@ -380,7 +357,7 @@ export class SessionTitles {
|
||||
|
||||
/** Whether to ask the SDK for a generated title now: once per session, on a
|
||||
* live query, with enough collected text for the generator to work with. */
|
||||
private canRequest(session: TitledSession): boolean {
|
||||
private canRequest(session: Session): boolean {
|
||||
if (this.settled || session.queryClosed || session.cancelled) {
|
||||
return false;
|
||||
}
|
||||
@@ -406,7 +383,7 @@ export class SessionTitles {
|
||||
* session's title, or a `/rename`), else one generated from `description`.
|
||||
* The file is read first because the CLI checks only the titles it holds in
|
||||
* memory before generating one. */
|
||||
private async titleFromPrompt(session: TitledSession, description: string): Promise<void> {
|
||||
private async titleFromPrompt(session: Session, description: string): Promise<void> {
|
||||
const info = await this.readSessionInfo(session);
|
||||
if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
|
||||
return;
|
||||
@@ -428,7 +405,7 @@ export class SessionTitles {
|
||||
* prompt or turn end retries, and publishes `fallback` (the stored summary)
|
||||
* so a backend that can't generate titles is no worse off than before. */
|
||||
private async requestGenerateTitle(
|
||||
session: TitledSession,
|
||||
session: Session,
|
||||
description: string,
|
||||
fallback: TitleFallback | undefined,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* Token usage as Claude Code reports it: the context window figures of
|
||||
* `usage_update` and the `_meta.quota` of a turn. Shared by the v1 core and
|
||||
* the v2 core.
|
||||
*/
|
||||
import type { ModelUsage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export type AccumulatedUsage = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedReadTokens: number;
|
||||
cachedWriteTokens: number;
|
||||
};
|
||||
|
||||
/** Per-model token tallies keyed by the model id the SDK reported them under
|
||||
* (its resolved spelling, e.g. "claude-opus-5[1m]"). */
|
||||
export type ModelTokenTally = Record<string, AccumulatedUsage>;
|
||||
|
||||
export type UsageSnapshot = {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
};
|
||||
|
||||
export const ZERO_USAGE = Object.freeze({
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
});
|
||||
|
||||
export const DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
|
||||
/** `_meta.quota` for work that spent `usage` in its main agent loop and
|
||||
* `modelUsage` by model (see `turnQuotaMeta` in `v1/acp-agent.ts`). */
|
||||
export function quotaMeta(usage: AccumulatedUsage, modelUsage: ModelTokenTally | undefined) {
|
||||
return {
|
||||
quota: {
|
||||
token_count: quotaTokenCount(usage),
|
||||
model_usage: Object.entries(modelUsage ?? {}).map(([model, usage]) => ({
|
||||
model,
|
||||
token_count: quotaTokenCount(usage),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One `token_count` object. `cachedInputTokens` is cache reads, matching
|
||||
* codex's field; Claude also reports cache writes, which codex has no slot
|
||||
* for, so those ride along in an extra sibling (the same name the ACP `usage`
|
||||
* field already uses) and are counted in `totalTokens`. `reasoningOutputTokens`
|
||||
* is always 0: Claude bills thinking inside its output tokens and never breaks
|
||||
* it out — the key is kept so the shape stays uniform across agents. */
|
||||
function quotaTokenCount(usage: AccumulatedUsage) {
|
||||
return {
|
||||
totalTokens: tallyTotal(usage),
|
||||
inputTokens: usage.inputTokens,
|
||||
cachedInputTokens: usage.cachedReadTokens,
|
||||
cachedWriteTokens: usage.cachedWriteTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
reasoningOutputTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum a tally's four counters. Per the Anthropic API `inputTokens` excludes
|
||||
* the cache counters, so this is not double-counting (see `totalTokens`). */
|
||||
export function tallyTotal(usage: AccumulatedUsage): number {
|
||||
return usage.inputTokens + usage.outputTokens + usage.cachedReadTokens + usage.cachedWriteTokens;
|
||||
}
|
||||
|
||||
/** Project `result.modelUsage` into our tally shape. Counters are coerced the
|
||||
* way `snapshotFromUsage` coerces the stream's: third-party backends have been
|
||||
* observed omitting fields, and a missing or NaN counter must not reach the
|
||||
* wire as NaN (which `JSON.stringify` writes as `null`). */
|
||||
export function normalizeModelUsage(
|
||||
modelUsage: Record<string, ModelUsage> | undefined,
|
||||
): ModelTokenTally {
|
||||
const tally: ModelTokenTally = {};
|
||||
for (const [model, usage] of Object.entries(modelUsage ?? {})) {
|
||||
tally[model] = {
|
||||
inputTokens: finiteCount(usage?.inputTokens),
|
||||
outputTokens: finiteCount(usage?.outputTokens),
|
||||
cachedReadTokens: finiteCount(usage?.cacheReadInputTokens),
|
||||
cachedWriteTokens: finiteCount(usage?.cacheCreationInputTokens),
|
||||
};
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
function finiteCount(value: number | null | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
/** `current - previous` per model, dropping models with nothing to report so a
|
||||
* turn only lists the models it actually ran on. A reading that fell BELOW the
|
||||
* previous one means the running total restarted under us (a mid-session
|
||||
* /clear, a zeroed crash result): there is no usable reference left to
|
||||
* subtract, so the reading itself is the increment. */
|
||||
export function modelUsageIncrement(
|
||||
current: ModelTokenTally,
|
||||
previous: ModelTokenTally,
|
||||
): ModelTokenTally {
|
||||
const increment: ModelTokenTally = {};
|
||||
for (const [model, usage] of Object.entries(current)) {
|
||||
const base = previous[model];
|
||||
const subtracted: AccumulatedUsage = base
|
||||
? {
|
||||
inputTokens: usage.inputTokens - base.inputTokens,
|
||||
outputTokens: usage.outputTokens - base.outputTokens,
|
||||
cachedReadTokens: usage.cachedReadTokens - base.cachedReadTokens,
|
||||
cachedWriteTokens: usage.cachedWriteTokens - base.cachedWriteTokens,
|
||||
}
|
||||
: usage;
|
||||
const rewound = Object.values(subtracted).some((count) => count < 0);
|
||||
const resolved = rewound ? usage : subtracted;
|
||||
if (tallyTotal(resolved) > 0) {
|
||||
increment[model] = resolved;
|
||||
}
|
||||
}
|
||||
return increment;
|
||||
}
|
||||
|
||||
/** The per-model rows for the first result of a resumed session. Its
|
||||
* `modelUsage` reading continues from the totals the transcript saved (SDK
|
||||
* 0.3.277+; older transcripts may hold none), so there is no earlier reading
|
||||
* to subtract from, and taking the reading itself would charge the whole
|
||||
* pre-resume history to this one turn. The result's own `usage` is per-turn
|
||||
* (main agent loop only, like the response's `usage`), so it stands in under
|
||||
* the turn's top-level model — `key` is that model's `modelUsage` spelling
|
||||
* when the reading has one, else the assistant message's own. Any subagent
|
||||
* spend on this one turn goes unlisted rather than over-listed. */
|
||||
export function resumedFirstResultModelUsage(
|
||||
usage: {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
cache_creation_input_tokens?: number | null;
|
||||
},
|
||||
key: string | null,
|
||||
): ModelTokenTally {
|
||||
if (key === null) {
|
||||
return {};
|
||||
}
|
||||
const row: AccumulatedUsage = {
|
||||
inputTokens: finiteCount(usage.input_tokens),
|
||||
outputTokens: finiteCount(usage.output_tokens),
|
||||
cachedReadTokens: finiteCount(usage.cache_read_input_tokens),
|
||||
cachedWriteTokens: finiteCount(usage.cache_creation_input_tokens),
|
||||
};
|
||||
return tallyTotal(row) > 0 ? { [key]: row } : {};
|
||||
}
|
||||
|
||||
/** Fold `increment` into `base` per model — the per-model counterpart of the
|
||||
* `+=` the turn's flat accumulator uses. */
|
||||
export function addModelUsage(base: ModelTokenTally, increment: ModelTokenTally): ModelTokenTally {
|
||||
const merged: ModelTokenTally = { ...base };
|
||||
for (const [model, usage] of Object.entries(increment)) {
|
||||
const existing = merged[model];
|
||||
merged[model] = existing
|
||||
? {
|
||||
inputTokens: existing.inputTokens + usage.inputTokens,
|
||||
outputTokens: existing.outputTokens + usage.outputTokens,
|
||||
cachedReadTokens: existing.cachedReadTokens + usage.cachedReadTokens,
|
||||
cachedWriteTokens: existing.cachedWriteTokens + usage.cachedWriteTokens,
|
||||
}
|
||||
: usage;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Sum all four fields as a proxy for post-turn context occupancy: the current
|
||||
* turn's output becomes next turn's input. Per the Anthropic API, input_tokens
|
||||
* excludes cache tokens — cache_read and cache_creation are reported
|
||||
* separately — so summing all four is not double-counting. */
|
||||
export function totalTokens(usage: UsageSnapshot): number {
|
||||
return (
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_read_input_tokens +
|
||||
usage.cache_creation_input_tokens
|
||||
);
|
||||
}
|
||||
|
||||
/** Project a nullable API usage object into our non-null snapshot shape.
|
||||
* Both SDK message_start and assistant message `usage` have `number | null`
|
||||
* cache fields; we coerce absent values to 0 so `totalTokens` never hits
|
||||
* NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but
|
||||
* synthetic or third-party-backend stream events have been observed emitting
|
||||
* them as null/undefined — coerce those too so a malformed upstream event
|
||||
* can't leak NaN into the wire `used` field. Delta events have different
|
||||
* semantics (cumulative + prev fallback) and are handled inline. */
|
||||
export function snapshotFromUsage(usage: {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
cache_creation_input_tokens?: number | null;
|
||||
}): UsageSnapshot {
|
||||
return {
|
||||
input_tokens: usage.input_tokens ?? 0,
|
||||
output_tokens: usage.output_tokens ?? 0,
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
||||
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function commonPrefixLength(a: string, b: string) {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Best-effort first guess of a model's context window, used to seed the
|
||||
* window synchronously (via `immediateContextWindow`) until a `result` message
|
||||
* arrives with the authoritative `modelUsage` value.
|
||||
*
|
||||
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
||||
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
||||
* matching things like "10m" or embedded substrings. Semantic aliases like
|
||||
* `default` carry no such token in the ID, but their `resolvedModel` and the
|
||||
* SDK's human-facing `displayName`/`description` can (e.g.
|
||||
* "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
|
||||
* This text scan can't catch every model — some resolve to extended-context
|
||||
* models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
|
||||
* ~1M). Such a miss falls back to the default window, is refined by the
|
||||
* background `getContextUsage` (`refreshContextWindowInBackground`), and is
|
||||
* corrected by `result.modelUsage` (and cached) within one turn. */
|
||||
export function inferContextWindowFromModel(...texts: Array<string | undefined>): number | null {
|
||||
if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMatchingModelUsage(
|
||||
modelUsage: Record<string, ModelUsage>,
|
||||
currentModel: string,
|
||||
) {
|
||||
let bestKey: string | null = null;
|
||||
let bestLen = 0;
|
||||
|
||||
for (const key of Object.keys(modelUsage)) {
|
||||
const len = commonPrefixLength(key, currentModel);
|
||||
if (len > bestLen) {
|
||||
bestLen = len;
|
||||
bestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestKey) {
|
||||
// `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"),
|
||||
// the same spelling as ModelInfo.resolvedModel — the primary key the
|
||||
// window is cached under. `currentModel` (the assistant message's
|
||||
// `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result
|
||||
// handler caches under that spelling too, for seed-time reads that fall
|
||||
// back to a bare id (rows without `resolvedModel`).
|
||||
return { key: bestKey, usage: modelUsage[bestKey] };
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* The `available_commands_update` list: the slash commands Claude Code reports,
|
||||
* minus the ones ACP clients can't run, plus the commands the adapter adds.
|
||||
*/
|
||||
import type { AvailableCommand } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Query, SlashCommand } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_KIND_KEY, AIR_SKILL_PATH_KEY, withAirMeta } from "./air-extension.js";
|
||||
import { MCP_AVAILABLE_COMMAND } from "./mcp-command.js";
|
||||
import {
|
||||
claudeCodeOffersCommand,
|
||||
REMOTE_CONTROL_COMMAND_NAME,
|
||||
supportsRemoteControl,
|
||||
} from "./remote-control.js";
|
||||
import { resolveSkillPath, type SkillScanCache } from "./tool-calls/reporters/interaction.js";
|
||||
|
||||
export function getAvailableSlashCommands(
|
||||
commands: SlashCommand[],
|
||||
// Names the CLI tagged terminal-bound on `system`/init (their UX lives in
|
||||
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
|
||||
// the static list. Raw CLI names, matched before the MCP rename.
|
||||
terminalCommands?: readonly string[],
|
||||
// The session cwd of an AIR client, or undefined for every other client.
|
||||
// AIR gets the kind and the SKILL.md path of each skill command.
|
||||
airSkillCwd?: string,
|
||||
// The resolved SKILL.md paths of the session, keyed by the cwd and the name.
|
||||
skillPaths?: Map<string, string | undefined>,
|
||||
query?: Query,
|
||||
// The skill files Claude Code reports (`getContextUsage().skills.skillFrontmatter`).
|
||||
// A listed skill gets its path from here; one that is not listed, or has no
|
||||
// path (remote connections), is looked up on disk.
|
||||
skillFiles?: ReadonlyMap<string, { path?: string }>,
|
||||
): AvailableCommand[] {
|
||||
// The container listings of this one pass, so a list of a hundred commands lists each container once.
|
||||
const skillScans: SkillScanCache = new Map();
|
||||
const skillPath = (name: string, cwd: string): string | undefined => {
|
||||
const key = `${cwd}\0${name}`;
|
||||
if (skillPaths?.has(key)) return skillPaths.get(key);
|
||||
const resolved = resolveSkillPath(name, cwd, skillScans);
|
||||
skillPaths?.set(key, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"clear",
|
||||
"cost",
|
||||
"keybindings-help",
|
||||
"login",
|
||||
"logout",
|
||||
"output-style:new",
|
||||
"release-notes",
|
||||
"todos",
|
||||
];
|
||||
|
||||
const remoteControlCommands: AvailableCommand[] =
|
||||
query && supportsRemoteControl(query)
|
||||
? [
|
||||
{
|
||||
name: REMOTE_CONTROL_COMMAND_NAME,
|
||||
description:
|
||||
"Continue this session from claude.ai/code or the Claude mobile app; `off` disconnects",
|
||||
input: { hint: "[session name | off]" },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
// The adapter replaces the terminal text of `/mcp` and runs its actions,
|
||||
// even when Claude Code tags the command terminal-bound.
|
||||
const advertised = commands
|
||||
.filter((command) => command.name !== "mcp" && !terminalCommands?.includes(command.name))
|
||||
.map((command) => {
|
||||
const input = command.argumentHint
|
||||
? {
|
||||
hint: Array.isArray(command.argumentHint)
|
||||
? command.argumentHint.join(" ")
|
||||
: command.argumentHint,
|
||||
}
|
||||
: null;
|
||||
let name = command.name;
|
||||
const mcpPrompt = command.name.endsWith(" (MCP)");
|
||||
if (mcpPrompt) {
|
||||
name = `mcp:${name.replace(" (MCP)", "")}`;
|
||||
}
|
||||
const skillFile = airSkillCwd && !mcpPrompt ? skillFiles?.get(command.name) : undefined;
|
||||
const skillMdPath =
|
||||
skillFile?.path ??
|
||||
(airSkillCwd && !command.builtin && !mcpPrompt
|
||||
? skillPath(command.name, airSkillCwd)
|
||||
: undefined);
|
||||
return {
|
||||
name,
|
||||
description: command.description || "",
|
||||
input,
|
||||
...(skillMdPath
|
||||
? {
|
||||
_meta: withAirMeta(
|
||||
withAirMeta(undefined, AIR_KIND_KEY, "skill"),
|
||||
AIR_SKILL_PATH_KEY,
|
||||
skillMdPath,
|
||||
),
|
||||
}
|
||||
: skillFile
|
||||
? // A skill without a file (bundled with the CLI, like `simplify`, or a remote connection): kind only.
|
||||
{ _meta: withAirMeta(undefined, AIR_KIND_KEY, "skill") }
|
||||
: {}),
|
||||
};
|
||||
})
|
||||
.filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name));
|
||||
|
||||
// Command names must not contain whitespace and must be unique. A name
|
||||
// like "my command" is dropped, and of two commands named "review" only the
|
||||
// first is kept.
|
||||
const names = new Set<string>();
|
||||
return [
|
||||
...advertised,
|
||||
MCP_AVAILABLE_COMMAND,
|
||||
...remoteControlCommands.filter(
|
||||
(command) => !claudeCodeOffersCommand(commands, terminalCommands, command.name),
|
||||
),
|
||||
].filter((command) => {
|
||||
if (/\s/.test(command.name) || names.has(command.name)) return false;
|
||||
names.add(command.name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* The text and thinking of an assistant message that streamed live as
|
||||
* deltas, and what its consolidated message adds to them. Shared by the v1
|
||||
* core and the v2 core.
|
||||
*/
|
||||
import type { SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Text or thinking that streamed live as deltas, accumulated per block. */
|
||||
export type StreamedBlock = { index: number; type: "text" | "thinking"; text: string };
|
||||
|
||||
/**
|
||||
* Records the text or thinking that `event` streamed in `streamedBlocks`.
|
||||
* Contiguous deltas of the same block (same index and type) extend the
|
||||
* current entry; anything else opens a new one. Other events change nothing.
|
||||
*/
|
||||
export function recordStreamedDelta(
|
||||
streamedBlocks: StreamedBlock[],
|
||||
event: SDKPartialAssistantMessage["event"],
|
||||
): void {
|
||||
if (event.type !== "content_block_delta") return;
|
||||
const delta = event.delta;
|
||||
const chunk =
|
||||
delta.type === "text_delta"
|
||||
? { type: "text" as const, text: delta.text }
|
||||
: delta.type === "thinking_delta"
|
||||
? { type: "thinking" as const, text: delta.thinking }
|
||||
: undefined;
|
||||
// Skip empty deltas (some gateways emit empty thinking chunks — #793):
|
||||
// appending "" is a no-op, but pushing a "" entry would create a block the
|
||||
// `text.length > 0` guard of `unstreamedRemainder` can never consume,
|
||||
// stalling the diff cursor and re-emitting the next block as a duplicate.
|
||||
if (!chunk?.text) return;
|
||||
const last = streamedBlocks[streamedBlocks.length - 1];
|
||||
if (last && last.index === event.index && last.type === chunk.type) {
|
||||
last.text += chunk.text;
|
||||
} else {
|
||||
streamedBlocks.push({ index: event.index, type: chunk.type, text: chunk.text });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks of a consolidated assistant message without the text that
|
||||
* already streamed as deltas.
|
||||
*
|
||||
* Each assembled text/thinking block is diffed against the streamed blocks in
|
||||
* document order: nothing is left if it streamed in full (the common case),
|
||||
* the whole block if it never streamed (a non-streaming gateway), and just the
|
||||
* tail if the stream was cut short mid-block. Matching on content rather than
|
||||
* the message id keeps the dedupe robust for gateways without a stable id.
|
||||
* Tool-use and other blocks pass through untouched.
|
||||
*/
|
||||
export function unstreamedRemainder<Block extends { type: string }>(
|
||||
blocks: Block[],
|
||||
streamedBlocks: StreamedBlock[],
|
||||
): Block[] {
|
||||
const kept: Block[] = [];
|
||||
let streamPos = 0;
|
||||
for (const item of blocks) {
|
||||
if (item.type !== "text" && item.type !== "thinking") {
|
||||
kept.push(item);
|
||||
continue;
|
||||
}
|
||||
const block = item as Block & { text?: string; thinking?: string };
|
||||
const full = (item.type === "text" ? block.text : block.thinking) ?? "";
|
||||
// Empty assembled blocks carry nothing: drop them.
|
||||
if (full.length === 0) continue;
|
||||
// A streamed block of the same type whose text is a prefix of this one
|
||||
// was already delivered, at least partly. A non-empty streamed text is
|
||||
// required so an empty or aborted streamed block does not swallow it.
|
||||
const streamed = streamedBlocks[streamPos];
|
||||
if (
|
||||
streamed &&
|
||||
streamed.type === item.type &&
|
||||
streamed.text.length > 0 &&
|
||||
full.startsWith(streamed.text)
|
||||
) {
|
||||
streamPos++;
|
||||
const remainder = full.slice(streamed.text.length);
|
||||
if (remainder.length === 0) continue;
|
||||
kept.push({ ...item, [item.type === "text" ? "text" : "thinking"]: remainder });
|
||||
continue;
|
||||
}
|
||||
kept.push(item);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* The configuration of a subagent's session, as its hooks report it. Both
|
||||
* the v1 core (`ClaudeAcpAgent.subagentConfigurationHook`, for both forms of
|
||||
* subagent sessions) and the v2 core (`v2/child-config.ts`)
|
||||
* build a child's config options with these.
|
||||
*/
|
||||
import type { SessionConfigOption, SessionModeState } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AgentInfo, HookInput } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { childModelOption, responseModelEntry, type SubagentModels } from "./native-subagents.js";
|
||||
import { AGENT_CONFIG_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
||||
import { effortLevelName } from "./session-effort.js";
|
||||
import { SessionModeManager } from "./session-mode.js";
|
||||
|
||||
/** What the root session knows that a child's options name. */
|
||||
export type SubagentConfigurationContext = {
|
||||
/** The root session's modes, for the name of the child's mode. */
|
||||
availableModes: SessionModeState["availableModes"];
|
||||
/** The root session's `model` entries, for the child's model. */
|
||||
models: SubagentModels | undefined;
|
||||
/** The custom agents of the session, for the description of the child's agent type. */
|
||||
agents: readonly AgentInfo[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The config options that a hook of a subagent (`agent_id`) reports of it:
|
||||
* its permission mode, its model after a `PostModelSwitch`, its effort, and
|
||||
* its agent type. Each option lists only the child's current value. Empty
|
||||
* for a hook of the root session, and for one that reports none of these.
|
||||
*/
|
||||
export function subagentHookOptions(
|
||||
input: HookInput,
|
||||
context: SubagentConfigurationContext,
|
||||
): SessionConfigOption[] {
|
||||
if (!input.agent_id) return [];
|
||||
const options: SessionConfigOption[] = [];
|
||||
if (input.permission_mode) {
|
||||
const mode = context.availableModes.find((mode) => mode.id === input.permission_mode);
|
||||
options.push(
|
||||
SessionModeManager.configOption({
|
||||
currentModeId: input.permission_mode,
|
||||
availableModes: [mode ?? { id: input.permission_mode, name: input.permission_mode }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
const switchedModel =
|
||||
input.hook_event_name === "PostModelSwitch"
|
||||
? responseModelEntry(input.to_model, context.models)
|
||||
: undefined;
|
||||
// The entry of the root session's `model` option, as for a child response.
|
||||
if (switchedModel) options.push(childModelOption(switchedModel));
|
||||
if (input.effort?.level) {
|
||||
options.push({
|
||||
id: EFFORT_CONFIG_ID,
|
||||
name: "Effort",
|
||||
description: "Current reasoning effort",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: input.effort.level,
|
||||
options: [{ value: input.effort.level, name: effortLevelName(input.effort.level) }],
|
||||
});
|
||||
}
|
||||
if (input.agent_type) {
|
||||
const agent = context.agents.find((agent) => agent.name === input.agent_type);
|
||||
options.push({
|
||||
id: AGENT_CONFIG_ID,
|
||||
name: "Agent",
|
||||
description: "Subagent type",
|
||||
type: "select",
|
||||
currentValue: input.agent_type,
|
||||
options: [
|
||||
{
|
||||
value: input.agent_type,
|
||||
name: input.agent_type,
|
||||
...(agent?.description ? { description: agent.description } : {}),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* A child's configuration, as it goes to the client: every option it holds,
|
||||
* the mode first, then the model, the effort and the model config, then the
|
||||
* rest in the order they came.
|
||||
*/
|
||||
export function orderedSubagentConfiguration(
|
||||
options: Iterable<SessionConfigOption>,
|
||||
): SessionConfigOption[] {
|
||||
const priority = ["mode", "model", "thought_level", "model_config"];
|
||||
const rank = (option: SessionConfigOption) => {
|
||||
const index = priority.indexOf(option.category ?? "");
|
||||
return index < 0 ? priority.length : index;
|
||||
};
|
||||
return [...options].sort((left, right) => rank(left) - rank(right));
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export type ReplayChild = {
|
||||
responseModel?: string;
|
||||
};
|
||||
|
||||
export type SendMessageCall = {
|
||||
type SendMessageCall = {
|
||||
toolUseId: string;
|
||||
to: string;
|
||||
message: string;
|
||||
@@ -97,27 +97,34 @@ export type SendMessageCall = {
|
||||
outgoingSent: boolean;
|
||||
};
|
||||
|
||||
/** A SubagentHandback call: its report, whether its result failed, and whether the replay sent it. */
|
||||
type ReplayHandback = { message: string; failed: boolean; sent: boolean };
|
||||
|
||||
/**
|
||||
* What both replays read from the transcripts before they send anything: the
|
||||
* children that launches with metadata started, the launches without metadata
|
||||
* (left out), the SendMessage calls, and the SubagentHandback calls. It sends
|
||||
* nothing; `RfdSubagentReplay` (v1) and `src/v2/subagent-replay.ts` send the
|
||||
* replay from it.
|
||||
*/
|
||||
export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
|
||||
export class RfdSubagentReplay {
|
||||
/** The children by the id of the tool use that launched each. */
|
||||
readonly children = new Map<string, Child>();
|
||||
readonly byAgentId = new Map<string, Child>();
|
||||
readonly sendMessages: SendMessageCall[] = [];
|
||||
private readonly children = new Map<string, ReplayChild>();
|
||||
private readonly byAgentId = new Map<string, ReplayChild>();
|
||||
private readonly sendMessages: SendMessageCall[] = [];
|
||||
/** Launches without metadata, which the replay leaves out. */
|
||||
readonly omitted = new Set<string>();
|
||||
/** The SubagentHandback calls, by tool use id. */
|
||||
readonly handbacks = new Map<string, ReplayHandback>();
|
||||
private readonly omitted = new Set<string>();
|
||||
/** Children whose transcript could not be read: only their association is replayed. */
|
||||
private withoutHistory = 0;
|
||||
/** The SubagentHandback calls, with their report and whether their result failed. */
|
||||
private readonly handbacks = new Map<
|
||||
string,
|
||||
{ message: string; failed: boolean; sent: boolean }
|
||||
>();
|
||||
|
||||
constructor(private readonly history: SubagentHistory) {}
|
||||
constructor(
|
||||
private readonly history: SubagentHistory,
|
||||
private readonly publish: Publish,
|
||||
private readonly models?: SubagentModels,
|
||||
/**
|
||||
* Whether the replay maps a model to the session's model list as it goes.
|
||||
* A load lacks the list until the CLI started, so it maps only in
|
||||
* {@link currentStates}, after the load response.
|
||||
*/
|
||||
private readonly mapsDuringReplay = true,
|
||||
/** The working directory of the root session, which a child inherits unless it reports its own. */
|
||||
private readonly rootCwd?: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registers the launches, the launch results, and the SendMessage calls in
|
||||
@@ -158,64 +165,9 @@ export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
|
||||
}
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
toolUseId: string,
|
||||
input: Record<string, unknown>,
|
||||
parentSessionId: string,
|
||||
): void {
|
||||
if (this.children.has(toolUseId)) return;
|
||||
const record = this.history.agents.get(toolUseId);
|
||||
if (!record) {
|
||||
this.omitted.add(toolUseId);
|
||||
return;
|
||||
}
|
||||
// A child type adds only optional fields, which a new child leaves unset.
|
||||
const child = {
|
||||
launchId: toolUseId,
|
||||
sessionId: record.agentId,
|
||||
record,
|
||||
parentSessionId,
|
||||
input,
|
||||
announced: false,
|
||||
promptSent: false,
|
||||
} as Child;
|
||||
this.children.set(toolUseId, child);
|
||||
this.byAgentId.set(record.agentId, child);
|
||||
}
|
||||
}
|
||||
|
||||
export class RfdSubagentReplay {
|
||||
private readonly transcript: ReplayTranscript;
|
||||
/** Children whose transcript could not be read: only their association is replayed. */
|
||||
private withoutHistory = 0;
|
||||
|
||||
constructor(
|
||||
private readonly history: SubagentHistory,
|
||||
private readonly publish: Publish,
|
||||
private readonly models?: SubagentModels,
|
||||
/**
|
||||
* Whether the replay maps a model to the session's model list as it goes.
|
||||
* A load lacks the list until the CLI started, so it maps only in
|
||||
* {@link currentStates}, after the load response.
|
||||
*/
|
||||
private readonly mapsDuringReplay = true,
|
||||
/** The working directory of the root session, which a child inherits unless it reports its own. */
|
||||
private readonly rootCwd?: string,
|
||||
) {
|
||||
this.transcript = new ReplayTranscript(history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the launches, the launch results, and the SendMessage calls in
|
||||
* `messages`, the transcript of the session `ownerSessionId`.
|
||||
*/
|
||||
scan(messages: readonly SessionMessage[], ownerSessionId: string): void {
|
||||
this.transcript.scan(messages, ownerSessionId);
|
||||
}
|
||||
|
||||
/** The child that the tool use `toolUseId` launched, when the replay shows it. */
|
||||
childOfLaunch(toolUseId: string): ReplayChild | undefined {
|
||||
return this.transcript.children.get(toolUseId);
|
||||
return this.children.get(toolUseId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,11 +211,11 @@ export class RfdSubagentReplay {
|
||||
*/
|
||||
private effectiveCwd(sessionId: string): string | undefined {
|
||||
const seen = new Set<string>();
|
||||
let child = this.transcript.byAgentId.get(sessionId);
|
||||
let child = this.byAgentId.get(sessionId);
|
||||
while (child && !seen.has(child.sessionId)) {
|
||||
if (child.cwd) return child.cwd;
|
||||
seen.add(child.sessionId);
|
||||
child = this.transcript.byAgentId.get(child.parentSessionId);
|
||||
child = this.byAgentId.get(child.parentSessionId);
|
||||
}
|
||||
return this.rootCwd;
|
||||
}
|
||||
@@ -347,9 +299,7 @@ export class RfdSubagentReplay {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return false;
|
||||
}
|
||||
const sendMessage = this.transcript.sendMessages.find(
|
||||
(call) => call.toolUseId === update.toolCallId,
|
||||
);
|
||||
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
|
||||
if (sendMessage) {
|
||||
if (sendMessage.recipientSessionId === undefined) {
|
||||
sendMessage.shownAsToolCall = true;
|
||||
@@ -367,7 +317,7 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const handback = owner ? this.transcript.handbacks.get(update.toolCallId) : undefined;
|
||||
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
|
||||
if (owner && handback && !handback.failed) {
|
||||
if (!handback.sent) {
|
||||
handback.sent = true;
|
||||
@@ -383,7 +333,7 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const child = this.transcript.children.get(update.toolCallId);
|
||||
const child = this.children.get(update.toolCallId);
|
||||
if (!child) return false;
|
||||
if (
|
||||
update.status === "completed" &&
|
||||
@@ -402,31 +352,41 @@ export class RfdSubagentReplay {
|
||||
* its task id is the agent id.
|
||||
*/
|
||||
taskNotification(taskId: string, status: string): boolean {
|
||||
const child = this.transcript.byAgentId.get(taskId);
|
||||
const child = this.byAgentId.get(taskId);
|
||||
if (!child) return false;
|
||||
child.notified = nativeSubagentState(status) ?? child.notified;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the end of every announced child (see `replayEndState`), and a notice when
|
||||
* Sends the end of every announced child (see `endState`), and a notice when
|
||||
* subagent history is missing.
|
||||
*/
|
||||
async finish(supportsNotices: boolean, rootSessionId: string): Promise<void> {
|
||||
for (const child of [...this.transcript.children.values()].reverse()) {
|
||||
for (const child of [...this.children.values()].reverse()) {
|
||||
if (!child.announced) continue;
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
state: replayEndState(child),
|
||||
state: endState(child),
|
||||
},
|
||||
});
|
||||
}
|
||||
const missing = this.transcript.omitted.size + this.withoutHistory;
|
||||
const missing = this.omitted.size + this.withoutHistory;
|
||||
if (missing > 0 && supportsNotices) {
|
||||
await this.publish({ sessionId: rootSessionId, update: missingHistoryNotice(missing) });
|
||||
await this.publish({
|
||||
sessionId: rootSessionId,
|
||||
update: noticeUpdate({
|
||||
severity: "info",
|
||||
title: "Some subagent history is not available",
|
||||
description:
|
||||
missing === 1
|
||||
? "The history of 1 subagent of this session could not be restored."
|
||||
: `The history of ${missing} subagents of this session could not be restored.`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,14 +409,12 @@ export class RfdSubagentReplay {
|
||||
live: (sessionId: string) => WorkState["state"] | undefined,
|
||||
liveConfiguration?: (sessionId: string) => SessionConfigOption[] | undefined,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.transcript.children.values()]
|
||||
return [...this.children.values()]
|
||||
.filter((child) => child.announced)
|
||||
.flatMap((child): AcpSessionNotification[] => {
|
||||
const liveState = live(child.sessionId);
|
||||
const state: WorkState =
|
||||
liveState === undefined || liveState === "idle"
|
||||
? replayEndState(child)
|
||||
: { state: liveState };
|
||||
liveState === undefined || liveState === "idle" ? endState(child) : { state: liveState };
|
||||
// The session's model list may be known only now, after the load response.
|
||||
const entry = responseModelEntry(child.responseModel, this.models);
|
||||
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
|
||||
@@ -487,6 +445,30 @@ export class RfdSubagentReplay {
|
||||
});
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
toolUseId: string,
|
||||
input: Record<string, unknown>,
|
||||
parentSessionId: string,
|
||||
): void {
|
||||
if (this.children.has(toolUseId)) return;
|
||||
const record = this.history.agents.get(toolUseId);
|
||||
if (!record) {
|
||||
this.omitted.add(toolUseId);
|
||||
return;
|
||||
}
|
||||
const child: ReplayChild = {
|
||||
launchId: toolUseId,
|
||||
sessionId: record.agentId,
|
||||
record,
|
||||
parentSessionId,
|
||||
input,
|
||||
announced: false,
|
||||
promptSent: false,
|
||||
};
|
||||
this.children.set(toolUseId, child);
|
||||
this.byAgentId.set(record.agentId, child);
|
||||
}
|
||||
|
||||
/** The prompt of the launch, with the launch's id, to the parent and the child, as live. */
|
||||
private async sendPrompt(child: ReplayChild, text: string): Promise<void> {
|
||||
child.promptSent = true;
|
||||
@@ -516,10 +498,12 @@ export class RfdSubagentReplay {
|
||||
message: SessionMessage,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const call = deliveredCall(
|
||||
this.transcript.sendMessages,
|
||||
[child.sessionId, child.record.name, child.input.name],
|
||||
text,
|
||||
const names = [child.sessionId, child.record.name, child.input.name];
|
||||
const call = this.sendMessages.find(
|
||||
(candidate) =>
|
||||
candidate.recipientSessionId === undefined &&
|
||||
names.includes(candidate.to) &&
|
||||
text.includes(candidate.message.trim()),
|
||||
);
|
||||
if (call) {
|
||||
call.recipientSessionId = child.sessionId;
|
||||
@@ -581,7 +565,7 @@ export function replaySubagentTerminalState(
|
||||
* `cancelled` for a child the user stopped, else the result of its foreground
|
||||
* launch, else `idle` with no stop reason.
|
||||
*/
|
||||
export function replayEndState(child: ReplayChild): WorkState {
|
||||
function endState(child: ReplayChild): WorkState {
|
||||
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
|
||||
return end ? rfdEndState(end, undefined) : { state: "idle" };
|
||||
}
|
||||
@@ -614,7 +598,7 @@ function inputOf(block: Record<string, unknown>): Record<string, unknown> {
|
||||
}
|
||||
|
||||
/** The text of user message content: a string, or its text blocks. */
|
||||
export function textOf(content: unknown): string {
|
||||
function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
@@ -626,7 +610,7 @@ export function textOf(content: unknown): string {
|
||||
}
|
||||
|
||||
/** Whether `dir` is an existing directory. */
|
||||
export function isDirectory(dir: string): boolean {
|
||||
function isDirectory(dir: string): boolean {
|
||||
try {
|
||||
return statSync(dir).isDirectory();
|
||||
} catch {
|
||||
@@ -635,38 +619,6 @@ export function isDirectory(dir: string): boolean {
|
||||
}
|
||||
|
||||
/** A message that a SendMessage call delivered to the child. */
|
||||
export function isDelivery(message: SessionMessage): boolean {
|
||||
function isDelivery(message: SessionMessage): boolean {
|
||||
return (message as { origin?: { kind?: unknown } | null }).origin?.kind === "coordinator";
|
||||
}
|
||||
|
||||
/**
|
||||
* The SendMessage call of `calls` that a delivered message `text` came from,
|
||||
* for the child that `names` names (its agent id, its name). Claude Code
|
||||
* frames the message for the child's model ("The coordinator sent a message
|
||||
* while you were working:", then the message, then an instruction), so it is
|
||||
* the first undelivered call to the child whose message the text contains.
|
||||
*/
|
||||
export function deliveredCall<
|
||||
T extends { to: string; message: string; recipientSessionId?: string },
|
||||
>(calls: readonly T[], names: readonly unknown[], text: string): T | undefined {
|
||||
return calls.find(
|
||||
(call) =>
|
||||
call.recipientSessionId === undefined &&
|
||||
names.includes(call.to) &&
|
||||
text.includes(call.message.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
/** The notice that the history of `missing` subagents of a replayed session is not available. */
|
||||
export function missingHistoryNotice(
|
||||
missing: number,
|
||||
): Extract<AcpSessionNotification["update"], { sessionUpdate: "notice" }> {
|
||||
return noticeUpdate({
|
||||
severity: "info",
|
||||
title: "Some subagent history is not available",
|
||||
description:
|
||||
missing === 1
|
||||
? "The history of 1 subagent of this session could not be restored."
|
||||
: `The history of ${missing} subagents of this session could not be restored.`,
|
||||
}) as Extract<AcpSessionNotification["update"], { sessionUpdate: "notice" }>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The `<task-notification>` inputs that Claude Code writes to a transcript
|
||||
* when a background task stops. The replay of a session (`v1/acp-agent.ts`) and
|
||||
* the session index (`v1/session-index/background-tasks.ts`) read them here, so
|
||||
* when a background task stops. The replay of a session (`acp-agent.ts`) and
|
||||
* the session index (`session-index/background-tasks.ts`) read them here, so
|
||||
* both read the same fields.
|
||||
*/
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { makeMockQuery } from "../helpers.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({
|
||||
querySpy: vi.fn(),
|
||||
@@ -31,22 +30,22 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
function mockQuery(
|
||||
models: unknown[] = [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
) {
|
||||
function mockQuery() {
|
||||
let capturedOptions: any;
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({ models }),
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
}) as any;
|
||||
});
|
||||
@@ -85,7 +84,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -129,7 +128,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
if (recommended) {
|
||||
(agent as any).clientCapabilities = {
|
||||
@@ -180,7 +179,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
@@ -227,7 +226,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
@@ -271,7 +270,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -296,7 +295,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -319,7 +318,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
|
||||
@@ -363,7 +362,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
|
||||
@@ -388,7 +387,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -414,7 +413,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
|
||||
@@ -433,7 +432,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
describe("on resume", () => {
|
||||
const sessionId = "resumed-mode-session";
|
||||
|
||||
async function setup(records: unknown[], models?: unknown[]) {
|
||||
async function setup(records: unknown[]) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "plan" } }),
|
||||
@@ -447,11 +446,11 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
|
||||
const { getCapturedOptions, setModelSpy } = mockQuery(models);
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
return { agent, projectDir, getCapturedOptions, setModelSpy };
|
||||
return { agent, projectDir, getCapturedOptions };
|
||||
}
|
||||
|
||||
async function resume(records: unknown[], meta: object = {}) {
|
||||
@@ -480,26 +479,6 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
previousUuid = uuid;
|
||||
return record;
|
||||
};
|
||||
const assistant = (model: string) => {
|
||||
const uuid = crypto.randomUUID();
|
||||
const record = {
|
||||
type: "assistant",
|
||||
uuid,
|
||||
parentUuid: previousUuid,
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
isSidechain: false,
|
||||
message: {
|
||||
id: `msg_${uuid}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
};
|
||||
previousUuid = uuid;
|
||||
return record;
|
||||
};
|
||||
|
||||
it("continues in the last permission mode of the transcript", async () => {
|
||||
const { permissionMode, response } = await resume([user("plan"), user("acceptEdits")]);
|
||||
@@ -534,41 +513,6 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("offers Fable 5.1 as `fable` when the CLI resumed on it", async () => {
|
||||
// The CLI names the Fable 5.1 row after its startup model, which on
|
||||
// resume is the transcript's model; a fresh session lists it as `fable`.
|
||||
const fable = (value: string, resolvedModel: string, displayName: string) => ({
|
||||
value,
|
||||
resolvedModel,
|
||||
displayName,
|
||||
description: "",
|
||||
});
|
||||
const { agent, projectDir, setModelSpy } = await setup(
|
||||
[user(), assistant("claude-fable-5-1")],
|
||||
[
|
||||
fable("default", "claude-opus-5-5", "Default (recommended)"),
|
||||
fable("claude-fable-5-1", "claude-fable-5-1", "Fable 5.1"),
|
||||
fable("claude-fable-5", "claude-fable-5", "Fable 5"),
|
||||
],
|
||||
);
|
||||
|
||||
const response = await (agent as any).createSession(
|
||||
{ cwd: projectDir, mcpServers: [], _meta: { disableBuiltInTools: true } },
|
||||
{ resume: sessionId },
|
||||
);
|
||||
|
||||
const model = response.configOptions.find((option: any) => option.id === "model");
|
||||
expect(model).toMatchObject({
|
||||
currentValue: "fable",
|
||||
options: [
|
||||
{ value: "default" },
|
||||
{ value: "fable", name: "Fable 5.1" },
|
||||
{ value: "claude-fable-5", name: "Fable 5" },
|
||||
],
|
||||
});
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues in the last permission mode on session/load", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([
|
||||
user("plan"),
|
||||
@@ -647,7 +591,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -674,7 +618,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { setModelSpy } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -729,7 +673,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -756,7 +700,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -808,7 +752,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "claude-haiku-4-5", displayName: "Claude Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -845,7 +789,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -892,7 +836,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -957,7 +901,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -997,7 +941,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1029,7 +973,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1051,7 +995,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1085,7 +1029,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1136,7 +1080,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1197,7 +1141,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1249,7 +1193,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1304,7 +1248,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1355,7 +1299,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1410,7 +1354,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
}) as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1451,7 +1395,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1502,7 +1446,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
}) as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -22,44 +22,44 @@ import {
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification } from "../../acp-subagents.js";
|
||||
import { NativeSubagentRuntime } from "../../native-subagents.js";
|
||||
import { nodeToWebWritable, nodeToWebReadable } from "../../utils.js";
|
||||
import { createPostToolUseHook, markdownEscape, toDisplayPath } from "../../tools.js";
|
||||
import { toolUpdateFromDiffToolResponse } from "../../diff.js";
|
||||
import { ToolCallFieldTracker } from "../../tool-calls/field-tracker.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { NativeSubagentRuntime } from "../native-subagents.js";
|
||||
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import {
|
||||
ClaudeAcpAgent,
|
||||
computeSessionFingerprint,
|
||||
runPromptWithCancellation,
|
||||
type SteerRequest,
|
||||
} from "../../v1/acp-agent.js";
|
||||
import { buildConfigOptions, discoverCustomAgents } from "../../session-config-options.js";
|
||||
import { createFastModeConfigOption } from "../../session-fast-mode.js";
|
||||
createPostToolUseHook,
|
||||
markdownEscape,
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
toolUpdateFromToolResult,
|
||||
} from "../tools.js";
|
||||
import { toolUpdateFromDiffToolResponse } from "../diff.js";
|
||||
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import {
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
messageIdForGrouping,
|
||||
type StreamedToolInputCache,
|
||||
} from "../../sdk-to-acp.js";
|
||||
import { promptToClaude } from "../../prompt-to-claude.js";
|
||||
import {
|
||||
promptToClaude,
|
||||
isLocalCommandMetadata,
|
||||
isReplayHiddenMetaMessage,
|
||||
isSyntheticLoginMessage,
|
||||
stripLocalCommandMetadata,
|
||||
} from "../../message-filters.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import type { SDKMessageFilter } from "../../new-session-meta.js";
|
||||
import { SessionTitles } from "../../session-titles.js";
|
||||
import { SessionBackgroundWork } from "../../v1/background-work.js";
|
||||
import {
|
||||
formatUsageResponse,
|
||||
isUsageCommandText,
|
||||
parseUsageResponse,
|
||||
} from "../../usage-markdown.js";
|
||||
import { Pushable } from "../../utils.js";
|
||||
import { heldRequest, initializeClient, makeMockQuery } from "../helpers.js";
|
||||
import { acpProtocolRouter } from "../../serve.js";
|
||||
ClaudeAcpAgent,
|
||||
computeSessionFingerprint,
|
||||
streamEventToAcpNotifications,
|
||||
messageIdForGrouping,
|
||||
buildConfigOptions,
|
||||
createFastModeConfigOption,
|
||||
discoverCustomAgents,
|
||||
runPromptWithCancellation,
|
||||
type AcpClient,
|
||||
type SDKMessageFilter,
|
||||
type SteerRequest,
|
||||
type StreamedToolInputCache,
|
||||
} from "../acp-agent.js";
|
||||
import { SessionTitles } from "../session-titles.js";
|
||||
import { SessionBackgroundWork } from "../background-work.js";
|
||||
import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "../usage-markdown.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { heldRequest, initializeClient, makeMockQuery } from "./helpers.js";
|
||||
import { acpProtocolRouter } from "../serve.js";
|
||||
import * as agentSdk from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
deleteSession,
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
getSessionInfo,
|
||||
getSessionMessages,
|
||||
getSubagentMessages,
|
||||
type HookCallback,
|
||||
importSessionToStore,
|
||||
listSessions,
|
||||
type ModelInfo,
|
||||
@@ -91,7 +92,7 @@ import {
|
||||
goalUpdateFromPrompt,
|
||||
parseGoalRequest,
|
||||
toGoalSnapshot,
|
||||
} from "../../v1/goal-extension.js";
|
||||
} from "../goal-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
@@ -116,12 +117,7 @@ import type {
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { validateRecorded } from "../acp-scenarios/schema.js";
|
||||
import {
|
||||
isLocalCommandMetadata,
|
||||
toolInfoFromToolUse,
|
||||
toolUpdateFromToolResult,
|
||||
} from "../tool-render.js";
|
||||
import { validateRecorded } from "./acp-scenarios/schema.js";
|
||||
|
||||
/** The capabilities of an AIR client. Only AIR gets the AIR extensions of
|
||||
* `docs/air-extensions.md`. */
|
||||
@@ -350,116 +346,12 @@ describe("task plan lifecycle", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const inProgressPlan = [{ content: "Run tests", status: "in_progress", priority: "medium" }];
|
||||
const planUpdateKinds = new Set(["plan", "plan_update", "plan_removed"]);
|
||||
|
||||
it.each([
|
||||
{
|
||||
client: "a client without plan operations",
|
||||
clientCapabilities: {},
|
||||
expected: [
|
||||
{ sessionUpdate: "plan", entries: inProgressPlan },
|
||||
{ sessionUpdate: "plan", entries: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
client: "a client with plan operations",
|
||||
clientCapabilities: { plan: {} },
|
||||
expected: [
|
||||
{
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "items", planId: "plan", entries: inProgressPlan },
|
||||
},
|
||||
{ sessionUpdate: "plan_removed", planId: "plan" },
|
||||
{ sessionUpdate: "plan_removed", planId: "plan-mode" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// AIR keeps each approved plan with its approval card.
|
||||
client: "AIR with plan operations",
|
||||
clientCapabilities: { ...AIR_CLIENT_CAPABILITIES, plan: {} },
|
||||
expected: [
|
||||
{
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "items", planId: "plan", entries: inProgressPlan },
|
||||
},
|
||||
{ sessionUpdate: "plan_removed", planId: "plan" },
|
||||
],
|
||||
},
|
||||
])(
|
||||
"clears the plans on a conversation reset for $client",
|
||||
async ({ clientCapabilities, expected }) => {
|
||||
const taskState = new Map([["task-1", { subject: "Run tests", status: "in_progress" }]]);
|
||||
const { agent, updates } = setup(taskState, async function* (input) {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const first = await iter.next();
|
||||
yield userEcho(first.value);
|
||||
yield {
|
||||
type: "conversation_reset",
|
||||
new_conversation_id: randomUUID(),
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
yield successfulResult();
|
||||
|
||||
const second = await iter.next();
|
||||
yield userEcho(second.value);
|
||||
yield successfulResult();
|
||||
});
|
||||
await initializeClient(agent, clientCapabilities);
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "/clear" }],
|
||||
});
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "Start fresh" }],
|
||||
});
|
||||
|
||||
expect(taskState.size).toBe(0);
|
||||
expect(
|
||||
updates
|
||||
.map((notification) => notification.update)
|
||||
.filter((update) => planUpdateKinds.has(update.sessionUpdate)),
|
||||
).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a subagent's checklist apart from the main one, and removes both on a conversation reset", async () => {
|
||||
const todoWrite = (id: string, content: string, parentToolUseId: string | null) => ({
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: `msg_${id}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id,
|
||||
name: "TodoWrite",
|
||||
input: { todos: [{ content, status: "in_progress", activeForm: content }] },
|
||||
},
|
||||
],
|
||||
stop_reason: "tool_use",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
});
|
||||
const { agent, updates } = setup(new Map(), async function* (input) {
|
||||
it("clears the task plan on a conversation reset", async () => {
|
||||
const taskState = new Map([["task-1", { subject: "Run tests", status: "in_progress" }]]);
|
||||
const { agent, updates } = setup(taskState, async function* (input) {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const first = await iter.next();
|
||||
yield userEcho(first.value);
|
||||
yield todoWrite("toolu_main_todo", "Main step", null);
|
||||
yield todoWrite("toolu_agent_todo", "Agent step", "toolu_agent");
|
||||
yield successfulResult();
|
||||
|
||||
const second = await iter.next();
|
||||
yield userEcho(second.value);
|
||||
yield {
|
||||
type: "conversation_reset",
|
||||
new_conversation_id: randomUUID(),
|
||||
@@ -467,30 +359,34 @@ describe("task plan lifecycle", () => {
|
||||
session_id: "test-session",
|
||||
};
|
||||
yield successfulResult();
|
||||
|
||||
const second = await iter.next();
|
||||
yield userEcho(second.value);
|
||||
yield successfulResult();
|
||||
});
|
||||
await initializeClient(agent, { plan: {} });
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "Plan" }] });
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "/clear" }] });
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "/clear" }],
|
||||
});
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "Start fresh" }],
|
||||
});
|
||||
|
||||
const entries = (content: string) => [{ content, status: "in_progress", priority: "medium" }];
|
||||
expect(
|
||||
updates
|
||||
.map((notification) => notification.update)
|
||||
.filter((update) => planUpdateKinds.has(update.sessionUpdate)),
|
||||
).toEqual([
|
||||
expect(taskState.size).toBe(0);
|
||||
expect(updates.filter((notification) => notification.update.sessionUpdate === "plan")).toEqual([
|
||||
{
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "items", planId: "plan", entries: entries("Main step") },
|
||||
sessionId: "test-session",
|
||||
update: {
|
||||
sessionUpdate: "plan",
|
||||
entries: [{ content: "Run tests", status: "in_progress", priority: "medium" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "items", planId: "plan:toolu_agent", entries: entries("Agent step") },
|
||||
_meta: { claudeCode: { parentToolUseId: "toolu_agent" } },
|
||||
sessionId: "test-session",
|
||||
update: { sessionUpdate: "plan", entries: [] },
|
||||
},
|
||||
{ sessionUpdate: "plan_removed", planId: "plan" },
|
||||
{ sessionUpdate: "plan_removed", planId: "plan:toolu_agent" },
|
||||
{ sessionUpdate: "plan_removed", planId: "plan-mode" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3606,7 +3502,7 @@ describe("usage-limit failure replay", () => {
|
||||
|
||||
it("recognizes a sanitized raw transcript through the real SDK parser", async () => {
|
||||
const raw = await readFile(
|
||||
new URL("../fixtures/usage-limit-session.jsonl", import.meta.url),
|
||||
new URL("./fixtures/usage-limit-session.jsonl", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const entries = raw
|
||||
@@ -5297,11 +5193,10 @@ describe("permission request cancellation", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -5371,14 +5266,14 @@ describe("permission request cancellation", () => {
|
||||
});
|
||||
|
||||
describe("a cancelled outcome", () => {
|
||||
function setup() {
|
||||
function setup(options: { v2?: boolean } = {}) {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (): Promise<RequestPermissionResponse> => ({
|
||||
outcome: { outcome: "cancelled" },
|
||||
}),
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }, options);
|
||||
const session = injectSession(agent, "session-1");
|
||||
session.emittedToolCalls.add("tool-1");
|
||||
const ask = (agentID?: string) =>
|
||||
@@ -5400,6 +5295,29 @@ describe("permission request cancellation", () => {
|
||||
await expect(ask("agent-fg")).rejects.toThrow("Tool use aborted");
|
||||
});
|
||||
|
||||
it("denies the tool use of foreground work on v2 without a session/cancel", async () => {
|
||||
// On v2 the client also answers `cancelled` when the user dismissed the
|
||||
// prompt or its policy declined it. The turn goes on without the tool.
|
||||
const { session, ask } = setup({ v2: true });
|
||||
session.liveBackgroundTasks.set("agent-fg", { isSubagent: true });
|
||||
|
||||
for (const agentID of [undefined, "agent-fg"]) {
|
||||
await expect(ask(agentID)).resolves.toEqual({
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
toolUseID: "tool-1",
|
||||
decisionClassification: "user_reject",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts the tool use of foreground work on v2 during a session/cancel", async () => {
|
||||
const { session, ask } = setup({ v2: true });
|
||||
session.cancelled = true;
|
||||
|
||||
await expect(ask()).rejects.toThrow("Tool use aborted");
|
||||
});
|
||||
|
||||
it("denies the tool use of a background subagent", async () => {
|
||||
const { session, ask } = setup();
|
||||
session.liveBackgroundTasks.set("agent-bg", { isSubagent: true, backgrounded: true });
|
||||
@@ -7591,6 +7509,76 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("releases a tool call that ends cancelled on v2, as one that ends failed", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: AcpSessionNotification) => {
|
||||
updates.push(update);
|
||||
},
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{ v2: true },
|
||||
);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
injectGeneratorSession(
|
||||
agent,
|
||||
makeGenerator([
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
id: "msg-bash",
|
||||
model: "claude-sonnet-4-5",
|
||||
role: "assistant",
|
||||
type: "message",
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_bash", name: "Bash", input: { command: "make" } },
|
||||
],
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
parent_tool_use_id: null,
|
||||
// The user interrupted the turn while the command ran.
|
||||
tool_result_meta: [{ id: "toolu_bash", non_execution_kind: "interrupted" }],
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: "[Request interrupted by user for tool use]",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
successResult(),
|
||||
]),
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
|
||||
|
||||
expect(
|
||||
updates
|
||||
.map(({ update }) => update)
|
||||
.filter(
|
||||
(update) =>
|
||||
update.sessionUpdate === "tool_call_update" && "status" in update && update.status,
|
||||
)
|
||||
.at(-1),
|
||||
).toMatchObject({ toolCallId: "toolu_bash", status: "cancelled" });
|
||||
// The agent stops tracking the call once it ends, whichever way.
|
||||
expect(agent.sessions["test-session"]?.dispatchedToolCalls?.has("toolu_bash")).toBe(false);
|
||||
});
|
||||
|
||||
it("records task_started's task_id → tool_use_id mapping while consuming the stream", async () => {
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{ sessionUpdate: vi.fn(async () => {}) } as unknown as AcpClient,
|
||||
@@ -15003,11 +14991,10 @@ describe("session/close", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -15127,11 +15114,10 @@ describe("session/delete", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -15292,11 +15278,10 @@ describe("getOrCreateSession param change detection", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -19830,11 +19815,10 @@ describe("post-error recovery", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork("test-session"),
|
||||
background: new SessionBackgroundWork("test-session", false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -21830,6 +21814,23 @@ describe("output after a cancel", () => {
|
||||
toolUseID: id,
|
||||
} as any);
|
||||
/** The v2 PreToolUse preview hook for the Write call `id`, as Claude Code calls it. */
|
||||
const previewWrite = (agent: ClaudeAcpAgent, id: string) =>
|
||||
(
|
||||
agent as unknown as { fileChangePreviewHook(sessionId: string): HookCallback }
|
||||
).fileChangePreviewHook("test-session")(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "Write",
|
||||
tool_input: writeInput,
|
||||
tool_use_id: id,
|
||||
session_id: "test-session",
|
||||
transcript_path: "",
|
||||
cwd: "/workspace/proj",
|
||||
} as any,
|
||||
id,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
/** Sends the prompt `text` and waits until its turn is active. Returns the
|
||||
* prompt and the session's consumer, which ends with the scripted stream. */
|
||||
async function promptActive(agent: ClaudeAcpAgent, text: string) {
|
||||
@@ -21978,6 +21979,7 @@ describe("output after a cancel", () => {
|
||||
async (_when, order, streamed) => {
|
||||
const cancelled = Promise.withResolvers<void>();
|
||||
let asked: Promise<unknown> | undefined;
|
||||
let previewed: Promise<unknown> | undefined;
|
||||
const { agent, texts, updates, permissionRequests } = setup((input) =>
|
||||
(async function* () {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
@@ -21990,12 +21992,14 @@ describe("output after a cancel", () => {
|
||||
yield abortedResult(first.uuid);
|
||||
yield lifecycleFrame(first.uuid, "cancelled");
|
||||
// Claude Code had already taken the second prompt, so it still runs
|
||||
// it, and its answer writes a file. It asks for the permission on
|
||||
// its control channel, which can run ahead of the consumer.
|
||||
// it, and its answer writes a file. It calls the preview hook and
|
||||
// asks for the permission on its control channel, which can run
|
||||
// ahead of the consumer.
|
||||
yield lifecycleFrame(second.uuid, "started");
|
||||
yield requesting(second.uuid);
|
||||
yield userEcho(second);
|
||||
const ask = () => {
|
||||
previewed = previewWrite(agent, "toolu_late");
|
||||
asked = askToWrite(agent, "toolu_late");
|
||||
};
|
||||
if (order === "before") ask();
|
||||
@@ -22004,13 +22008,13 @@ describe("output after a cancel", () => {
|
||||
// Claude Code can run a tool whose input is complete before it
|
||||
// sends the whole message.
|
||||
ask();
|
||||
await asked;
|
||||
await Promise.all([previewed, asked]);
|
||||
}
|
||||
yield* streamed
|
||||
? toolUseMessage("toolu_late", "Write", writeInput)
|
||||
: [toolUseMessage("toolu_late", "Write", writeInput)[0]];
|
||||
if (order === "after") ask();
|
||||
await asked;
|
||||
await Promise.all([previewed, asked]);
|
||||
yield toolResult("toolu_late", "The user cancelled this prompt.", true);
|
||||
yield stampedResult(second.uuid);
|
||||
yield lifecycleFrame(second.uuid, "completed");
|
||||
@@ -23075,6 +23079,67 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
});
|
||||
|
||||
it("resumes a held turn's foreground work for a steer, and ends it again at the steered result", async () => {
|
||||
const foreground: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{ sessionUpdate: async () => {} } as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
foreground: () => ({
|
||||
running: () => foreground.push("running"),
|
||||
awaitingUser: () => foreground.push("awaitingUser"),
|
||||
ended: (outcome) =>
|
||||
foreground.push(`ended ${outcome.stopReason} ${outcome.usage?.totalTokens}`),
|
||||
failed: () => foreground.push("failed"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
yield userEcho((await iter.next()).value);
|
||||
yield running();
|
||||
yield subagentStarted("agent-1");
|
||||
yield resultMessage(); // held for agent-1
|
||||
yield idle();
|
||||
// The steer runs as a cycle of the held turn.
|
||||
yield userEcho((await iter.next()).value);
|
||||
yield assistantText("Steered answer");
|
||||
yield resultMessage();
|
||||
// The steered turn settles at an idle, held again for agent-1.
|
||||
yield idle();
|
||||
yield taskNotification("agent-1");
|
||||
yield assistantText("promised summary");
|
||||
yield resultMessage({ origin: { kind: "task-notification" } });
|
||||
yield idle();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const turn = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "explore" }],
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(agent.sessions["test-session"]?.activeTurn?.deferredSettle).toBeDefined(),
|
||||
);
|
||||
await agent.steer({ sessionId: "test-session", prompt: [{ type: "text", text: "also X" }] });
|
||||
|
||||
expect((await turn).stopReason).toBe("end_turn");
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
expect(foreground).toEqual([
|
||||
"running",
|
||||
"ended end_turn 15",
|
||||
// The steer resumes the turn's work, which ends at the steered result
|
||||
// with the turn's usage, both of its cycles.
|
||||
"running",
|
||||
"ended end_turn 30",
|
||||
// The follow-up after the subagent finished is a cycle of its own.
|
||||
"running",
|
||||
"ended end_turn 15",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("ExitPlanMode answered inside a held turn's followup (issue #1167)", () => {
|
||||
// Plan mode + a background Explore agent: the user turn's result holds,
|
||||
// the subagent finishes, and the model writes the plan and calls
|
||||
@@ -23275,6 +23340,98 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the turn as the session's foreground work across the clear-context restart", async () => {
|
||||
const foreground: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request: any) => ({
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: request.options.find((option: any) => option.kind === "allow_once")
|
||||
.optionId,
|
||||
},
|
||||
}),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
foreground: () => ({
|
||||
running: () => foreground.push("running"),
|
||||
awaitingUser: () => foreground.push("awaitingUser"),
|
||||
ended: (outcome) =>
|
||||
foreground.push(`ended ${outcome.stopReason} ${outcome.usage?.totalTokens}`),
|
||||
failed: () => foreground.push("failed"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
vi.spyOn(agent as any, "createSession").mockImplementation(
|
||||
async (_params: any, options: any) => {
|
||||
const input = new Pushable<any>();
|
||||
async function* freshGenerator() {
|
||||
const next = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(next.value);
|
||||
yield running();
|
||||
// The continuation asks for permission in the fresh query.
|
||||
await agent.canUseTool("test-session")(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID: "toolu_ls",
|
||||
requestId: "request-ls",
|
||||
},
|
||||
);
|
||||
yield assistantText("Implemented");
|
||||
yield resultMessage({ stop_reason: "max_tokens" });
|
||||
yield idle();
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({
|
||||
query: wrapQuery(freshGenerator()),
|
||||
input,
|
||||
modes: { currentModeId: options.permissionMode, availableModes: [] },
|
||||
fastModeEnabled: false,
|
||||
});
|
||||
return { sessionId: "test-session" };
|
||||
},
|
||||
);
|
||||
injectGeneratorSession(
|
||||
agent,
|
||||
(input) => {
|
||||
async function* messageGenerator() {
|
||||
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||
yield* heldTurnPlanningFollowup(first);
|
||||
yield idle();
|
||||
}
|
||||
return messageGenerator();
|
||||
},
|
||||
{
|
||||
creationParams: { cwd: "/test", mcpServers: [] },
|
||||
pendingExitPlanContextReset: {
|
||||
toolUseId: "tool-plan",
|
||||
plan: "Implement it",
|
||||
mode: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "plan" }] });
|
||||
|
||||
// The turn's work ends at its result, though v1's answer is held. The
|
||||
// follow-up that plans is a cycle, which the continuation takes over
|
||||
// without a new `running`. The restart replaces the session behind the
|
||||
// id; the work stays with the id.
|
||||
expect(foreground).toEqual([
|
||||
"running",
|
||||
"ended end_turn 15",
|
||||
"running",
|
||||
"awaitingUser",
|
||||
"running",
|
||||
// The turn's usage: the carried 15 and the continuation's.
|
||||
"ended max_tokens 30",
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels the held turn when the user keeps planning", async () => {
|
||||
const agent = createMockAgent();
|
||||
const createSession = vi.spyOn(agent as any, "createSession");
|
||||
@@ -23299,6 +23456,40 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
||||
expect(agent.sessions["test-session"].pendingExitPlanModeInterruption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ends the follow-up cycle cancelled, not failed, when the user keeps planning", async () => {
|
||||
const foreground: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{ sessionUpdate: async () => {} } as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
foreground: () => ({
|
||||
running: () => foreground.push("running"),
|
||||
awaitingUser: () => foreground.push("awaitingUser"),
|
||||
ended: (outcome) => foreground.push(`ended ${outcome.stopReason}`),
|
||||
failed: () => foreground.push("failed"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||
yield* heldTurnPlanningFollowup(first);
|
||||
yield idle();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "plan" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("cancelled");
|
||||
// The turn's work ended at its result; the follow-up that planned is a
|
||||
// cycle, which the error-shaped diagnostic of the answer ends cancelled.
|
||||
expect(foreground).toEqual(["running", "ended end_turn", "running", "ended cancelled"]);
|
||||
});
|
||||
|
||||
describe("with backgroundSubagents", () => {
|
||||
it("asks out of the turn without a clear-context option and drops the interrupt", async () => {
|
||||
let request: RequestPermissionRequest | undefined;
|
||||
@@ -28136,11 +28327,10 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork("test-session"),
|
||||
background: new SessionBackgroundWork("test-session", false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -30046,11 +30236,10 @@ describe("agent selection config option", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2
|
||||
* core (`src/v2/`): through the protocol router, with the SDK's v2
|
||||
* client.
|
||||
* surface: through the protocol router, with the SDK's v2 client.
|
||||
*
|
||||
* - Status: each scenario passes, every prompt turn ending with a stop reason,
|
||||
* unless {@link V2_STOPS} names why it stops. Every scenario passes today;
|
||||
@@ -11,29 +10,29 @@
|
||||
* - Tool calls: the first report of each tool call has a title. v2 has no
|
||||
* `tool_call`, so a report of a tool call that the client has not seen
|
||||
* creates it, where v1 drops it.
|
||||
* - Golden files: `../acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
|
||||
* passing scenario, every message of the run. Run
|
||||
* `npx vitest run src/tests/v2/acp-scenarios-v2.test.ts -u` to update them.
|
||||
* - Golden files: `acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
|
||||
* passing scenario. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u`
|
||||
* to update them.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AgentHandle } from "../../v2/agent.js";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import {
|
||||
resetIds,
|
||||
runScenarioV2,
|
||||
type ScenarioRunV2,
|
||||
type WireRecorded,
|
||||
} from "../acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "../acp-scenarios/scenarios.js";
|
||||
import { canonical } from "../acp-scenarios/compare.js";
|
||||
import { validateV2Message } from "../acp-scenarios/schema.js";
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import { canonical } from "./acp-scenarios/compare.js";
|
||||
import { validateV2Message } from "./acp-scenarios/schema.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("../acp-scenarios/harness.js");
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
@@ -46,7 +45,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("../acp-scenarios/harness.js");
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
@@ -62,8 +61,8 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PASSES = "passes";
|
||||
|
||||
/**
|
||||
* The scenarios that stop on the v2 surface, each with the reason. Every
|
||||
* other scenario passes.
|
||||
* The scenarios that stop on the v2 surface, each with the reason, which names
|
||||
* the step of `docs/acp-v2.md` that removes it. Every other scenario passes.
|
||||
*/
|
||||
const V2_STOPS: Record<string, string> = {};
|
||||
|
||||
@@ -84,18 +83,15 @@ beforeAll(async () => {
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { acpProtocolRouter } = await import("../../serve.js");
|
||||
const { acpProtocolRouter } = await import("../serve.js");
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
const agents: { dispose(): Promise<void> }[] = [];
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
const quiet = { log: () => {}, error: () => {} };
|
||||
runs.set(
|
||||
scenario.name,
|
||||
await runScenarioV2(
|
||||
(stream) =>
|
||||
acpProtocolRouter(quiet, (agent) => agents.push(agent), "auto", {
|
||||
onV2Agent: (agent: AgentHandle) => agents.push(agent),
|
||||
}).connect(stream),
|
||||
(stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream),
|
||||
scenario,
|
||||
),
|
||||
);
|
||||
@@ -167,10 +163,10 @@ describe("ACP v2 scenarios", () => {
|
||||
const passing = SCENARIOS.map((scenario) => scenario.name).filter(
|
||||
(scenario) => statusOf(scenario) === PASSES,
|
||||
);
|
||||
const golden = (scenario: string) =>
|
||||
path.join(here, "..", "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`);
|
||||
it.each(passing)("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(golden(scenario));
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,11 @@
|
||||
* - Recordings are JSON Lines: one outbound message on each line, with sorted
|
||||
* keys. `normalize` in `harness.ts` replaces only exact run-specific values:
|
||||
* the generated ids, the paths, and the argv and version of this process.
|
||||
* - AIR golden files: `../acp-scenarios/__snapshots__/air/<scenario>.jsonl`.
|
||||
* Run `npx vitest run src/tests/v1/acp-scenarios.test.ts -u` to update them.
|
||||
* - AIR golden files: `acp-scenarios/__snapshots__/air/<scenario>.jsonl`.
|
||||
* Run `npx vitest run src/tests/acp-scenarios.test.ts -u` to update them.
|
||||
* - Schema: every message is valid against the ACP schema of the SDK.
|
||||
* - Plain and Zed: the same information as origin/main
|
||||
* (`../acp-scenarios/origin-main/<profile>/<scenario>.jsonl`). `compare.ts`
|
||||
* (`acp-scenarios/origin-main/<profile>/<scenario>.jsonl`). `compare.ts`
|
||||
* allows only the documented differences. These profiles have no golden
|
||||
* files of their own. `origin-main/zed/` holds a scenario only when its
|
||||
* recording differs from the plain client.
|
||||
@@ -17,9 +17,9 @@
|
||||
* - AIR: the AIR extensions of `docs/air-extensions.md`, each fact once.
|
||||
*
|
||||
* To record the origin/main baseline again, copy `src/tests/acp-scenarios/`
|
||||
* and this file to the same paths in a checkout of origin/main, and run this file there with
|
||||
* and this file into a checkout of origin/main, and run this file there with
|
||||
* `ACP_SCENARIO_BASELINE_DIR=<dir>`. Then copy `<dir>/plain` and `<dir>/zed`
|
||||
* to `src/tests/acp-scenarios/origin-main/`.
|
||||
* to `acp-scenarios/origin-main/`.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
@@ -41,20 +41,20 @@ import {
|
||||
streamMessage,
|
||||
toolCall,
|
||||
toolResult,
|
||||
} from "../acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "../acp-scenarios/scenarios.js";
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import {
|
||||
AIR_ONLY_CLAUDE_CODE_KEYS,
|
||||
AIR_ONLY_META_KEYS,
|
||||
canonical,
|
||||
compareWithBaseline,
|
||||
} from "../acp-scenarios/compare.js";
|
||||
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "../acp-scenarios/schema.js";
|
||||
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../../air-extension.js";
|
||||
} from "./acp-scenarios/compare.js";
|
||||
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
|
||||
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("../acp-scenarios/harness.js");
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
@@ -66,7 +66,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("../acp-scenarios/harness.js");
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
@@ -97,7 +97,7 @@ beforeAll(async () => {
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
Agent = ClaudeAcpAgent;
|
||||
for (const profile of profiles) {
|
||||
for (const scenario of SCENARIOS) {
|
||||
@@ -189,7 +189,7 @@ function metaObjects(value: unknown, at = "$"): { at: string; meta: Record<strin
|
||||
/** The origin/main recording of a scenario. Zed has a file only where it differs from the plain client. */
|
||||
function readBaseline(profile: "plain" | "zed", scenario: string): string {
|
||||
const file = (name: string) =>
|
||||
path.join(here, "..", "acp-scenarios", "origin-main", name, `${scenario}.jsonl`);
|
||||
path.join(here, "acp-scenarios", "origin-main", name, `${scenario}.jsonl`);
|
||||
return fs.readFileSync(
|
||||
profile === "zed" && !fs.existsSync(file("zed")) ? file("plain") : file(profile),
|
||||
"utf8",
|
||||
@@ -219,7 +219,7 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
const second = "cccccccc-0000-4000-8000-000000000003";
|
||||
const scripted = "00000000-0000-4000-8000-00000000c001";
|
||||
const version = JSON.parse(
|
||||
fs.readFileSync(path.join(here, "..", "..", "..", "package.json"), "utf8"),
|
||||
fs.readFileSync(path.join(here, "..", "..", "package.json"), "utf8"),
|
||||
).version;
|
||||
const argv = process.argv.slice(1);
|
||||
|
||||
@@ -322,7 +322,7 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
describe("air golden files", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run("air", scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "..", "acp-scenarios", "__snapshots__", "air", `${scenario}.jsonl`),
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "air", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,6 @@ export const PROFILES: Record<Profile["name"], Profile> = {
|
||||
type QueryOptions = {
|
||||
sessionId?: string;
|
||||
resume?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
canUseTool?: (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
@@ -235,16 +234,6 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
|
||||
);
|
||||
},
|
||||
};
|
||||
// With `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS`, Claude Code reports that it
|
||||
// works once it takes a prompt in, and that it is idle after the result.
|
||||
const stateEvents = options.env?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS === "1";
|
||||
const state = (value: "running" | "idle") => ({
|
||||
type: "system",
|
||||
subtype: "session_state_changed",
|
||||
state: value,
|
||||
uuid: `state-${value}`,
|
||||
session_id: sessionId,
|
||||
});
|
||||
async function* run() {
|
||||
const iterator = args.prompt[Symbol.asyncIterator]();
|
||||
for (const turn of script.scenario.turns) {
|
||||
@@ -252,7 +241,6 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
|
||||
if (next.done) return;
|
||||
const user = next.value;
|
||||
ctx.promptUuid = user.uuid;
|
||||
if (stateEvents) yield state("running");
|
||||
yield {
|
||||
type: "user",
|
||||
message: user.message,
|
||||
@@ -262,7 +250,6 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
|
||||
isReplay: true,
|
||||
};
|
||||
yield* turn(ctx);
|
||||
if (stateEvents) yield state("idle");
|
||||
}
|
||||
// Stay open like a live Claude Code process.
|
||||
await iterator.next();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
@@ -12,7 +11,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
const { makeMockQuery } = await import("../helpers.js");
|
||||
const { makeMockQuery } = await import("./helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
query: ({ options }: { options: Options }) => {
|
||||
@@ -32,8 +31,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../../tools.js")>("../../tools.js")),
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -47,7 +46,7 @@ describe("additionalRoots", () => {
|
||||
capturedOptions = undefined;
|
||||
tempDirs.length = 0;
|
||||
vi.resetModules();
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../session-config-options.js";
|
||||
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../acp-agent.js";
|
||||
|
||||
// `discoverCustomAgents` distinguishes user/plugin-configured agents from
|
||||
// Claude Code's built-in subagents by name (BUILTIN_AGENT_NAMES), since the
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import type { Mock } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import type { AuthStatus } from "../../auth-status.js";
|
||||
import {
|
||||
fromAccountInfo,
|
||||
fromCliStatus,
|
||||
mergeAuthStatus,
|
||||
sameIdentity,
|
||||
} from "../../auth-status.js";
|
||||
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "../helpers.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType, Logger } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { AuthStatus } from "../auth-status.js";
|
||||
import { fromAccountInfo, fromCliStatus, mergeAuthStatus, sameIdentity } from "../auth-status.js";
|
||||
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
@@ -4,16 +4,15 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import { AcpClient } from "../../acp-client.js";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import {
|
||||
billsClaudeSubscription,
|
||||
CLAUDE_LOGIN_REQUIRED_MESSAGE,
|
||||
CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
|
||||
CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON,
|
||||
} from "../../hide-claude-auth.js";
|
||||
import { SessionFailureController } from "../../session-failure-extension.js";
|
||||
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "../helpers.js";
|
||||
} from "../hide-claude-auth.js";
|
||||
import { SessionFailureController } from "../session-failure-extension.js";
|
||||
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";
|
||||
|
||||
const mockQuery = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -1713,7 +1712,7 @@ describe("authorization", () => {
|
||||
describe("session failure reason field", () => {
|
||||
it("carries reason only when the publisher passes one", async () => {
|
||||
const { SessionFailureController, createSessionFailureState } =
|
||||
await import("../../session-failure-extension.js");
|
||||
await import("../session-failure-extension.js");
|
||||
const capabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } } },
|
||||
} as any;
|
||||
@@ -11,11 +11,11 @@ import * as fsSync from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ActivityState } from "../../v1/session-index/activity.js";
|
||||
import { endedTaskId, searchBackgroundTasks } from "../../v1/session-index/background-tasks.js";
|
||||
import { encodeProjectPath } from "../../v1/session-index/project-dirs.js";
|
||||
import { SessionIndex } from "../../v1/session-index/session-index.js";
|
||||
import { SessionIndexService } from "../../v1/session-index/service.js";
|
||||
import type { ActivityState } from "../session-index/activity.js";
|
||||
import { endedTaskId, searchBackgroundTasks } from "../session-index/background-tasks.js";
|
||||
import { encodeProjectPath } from "../session-index/project-dirs.js";
|
||||
import { SessionIndex } from "../session-index/session-index.js";
|
||||
import { SessionIndexService } from "../session-index/service.js";
|
||||
|
||||
let configDir: string;
|
||||
let workspace: string;
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import type { AcpSessionNotification } from "../../acp-subagents.js";
|
||||
import { ALLOW_BYPASS } from "../../permissions/modes.js";
|
||||
import { NativeSubagentRuntime } from "../../native-subagents.js";
|
||||
import { startupFailure } from "../helpers.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { ALLOW_BYPASS } from "../permissions/modes.js";
|
||||
import { NativeSubagentRuntime } from "../native-subagents.js";
|
||||
import { startupFailure } from "./helpers.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -38,7 +37,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
const { makeMockQuery, DEFAULT_CONTEXT_USAGE } = await import("../helpers.js");
|
||||
const { makeMockQuery, DEFAULT_CONTEXT_USAGE } = await import("./helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
query: (args: { prompt: unknown; options: Options }) => {
|
||||
@@ -77,8 +76,8 @@ function initializationResponse() {
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../tools.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../tools.js")>("../../tools.js");
|
||||
vi.mock("../tools.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
|
||||
return {
|
||||
...actual,
|
||||
registerHookCallback: vi.fn(),
|
||||
@@ -118,7 +117,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../../v1/acp-agent.js");
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
@@ -177,19 +176,25 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("registers no PreToolUse hook of change previews", async () => {
|
||||
it("registers the PreToolUse hook of the change previews only on a v2 connection", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
// A v1 client gets the input's diff, so Claude Code waits on no hook.
|
||||
expect(capturedOptions!.hooks?.PreToolUse).toBeUndefined();
|
||||
|
||||
const v2Agent = new ClaudeAcpAgent(createMockClient(), undefined, { v2: true });
|
||||
await v2Agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.hooks?.PreToolUse).toEqual([
|
||||
{ matcher: "Edit|Write", hooks: [expect.any(Function)] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports child settings through registered hooks", async () => {
|
||||
it.each([false, true])("reports child settings through registered hooks (v2: %s)", async (v2) => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const client = createMockClient();
|
||||
client.sessionUpdate = async (notification) => {
|
||||
updates.push(notification);
|
||||
};
|
||||
const observedAgent = new ClaudeAcpAgent(client);
|
||||
const observedAgent = new ClaudeAcpAgent(client, undefined, { v2 });
|
||||
await observedAgent.initialize({ protocolVersion: 1, clientCapabilities: { subagents: {} } });
|
||||
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const session = observedAgent.sessions[sessionId]!;
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from "../diff.js";
|
||||
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
|
||||
import { buildClaudePermissionPresentation } from "../permissions/presentation.js";
|
||||
import { toolInfoFromToolUse } from "../tools.js";
|
||||
import { WriteReporter } from "../tool-calls/reporters/file-edit.js";
|
||||
import { toolInfoFromToolUse } from "./tool-render.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { Writable } from "node:stream";
|
||||
import { EventLoopYielder, nodeToWebWritable, Pushable } from "../../utils.js";
|
||||
import { EventLoopYielder, nodeToWebWritable, Pushable } from "../utils.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
@@ -1,12 +1,9 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { FastModeDisabledReason, ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import {
|
||||
buildConfigOptions,
|
||||
clientSupportsBooleanConfigOptions,
|
||||
} from "../../session-config-options.js";
|
||||
import {
|
||||
createFastModeConfigOption,
|
||||
fastModeStateEnabled,
|
||||
normalizeFastModeDisabledReason,
|
||||
@@ -14,8 +11,9 @@ import {
|
||||
FAST_MODE_CONFIG_ID,
|
||||
FAST_MODE_ON,
|
||||
FAST_MODE_OFF,
|
||||
} from "../../session-fast-mode.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
ClaudeAcpAgent,
|
||||
type AcpClient,
|
||||
} from "../acp-agent.js";
|
||||
|
||||
const MODES = {
|
||||
currentModeId: "default",
|
||||
@@ -3,13 +3,13 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AcpClient } from "../acp-client.js";
|
||||
import {
|
||||
AcpClient,
|
||||
StreamedToolInputCache,
|
||||
streamEventToAcpNotifications,
|
||||
toAcpNotifications,
|
||||
ToolUseCache,
|
||||
} from "../sdk-to-acp.js";
|
||||
} from "../acp-agent.js";
|
||||
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import { createPostToolUseHook } from "../tools.js";
|
||||
|
||||
@@ -151,36 +151,6 @@ describe("ToolCallFieldTracker", () => {
|
||||
expect(final.content).toEqual(snippet);
|
||||
});
|
||||
|
||||
it("prefers a call's own content to an earlier staged preview", () => {
|
||||
const tracker = new ToolCallFieldTracker();
|
||||
tracker.stagePreview("t", [{ type: "diff", path: "/a.ts", oldText: "a", newText: "b" }]);
|
||||
const content = [{ type: "diff" as const, path: "/a.ts", oldText: "a", newText: "c" }];
|
||||
const update = { sessionUpdate: "tool_call" as const, toolCallId: "t", title: "Edit", content };
|
||||
tracker.apply(update);
|
||||
|
||||
expect(update.content).toEqual(content);
|
||||
});
|
||||
|
||||
it("bounds unclaimed previews and releases them when a call or session is forgotten", () => {
|
||||
const tracker = new ToolCallFieldTracker();
|
||||
const patch = [{ type: "diff" as const, path: "/a.ts", oldText: "a", newText: "b" }];
|
||||
for (let index = 0; index < 65; index++) tracker.stagePreview(`${index}`, patch);
|
||||
const start = (id: string) => {
|
||||
const update = { sessionUpdate: "tool_call" as const, toolCallId: id, title: "Edit" };
|
||||
tracker.apply(update);
|
||||
return update;
|
||||
};
|
||||
|
||||
expect(start("0")).not.toHaveProperty("content");
|
||||
expect(start("1")).toHaveProperty("content", patch);
|
||||
// A preview belongs only to the initial publication, not a later restart.
|
||||
expect(start("1")).not.toHaveProperty("content");
|
||||
tracker.delete("2");
|
||||
expect(start("2")).not.toHaveProperty("content");
|
||||
tracker.clear();
|
||||
expect(start("3")).not.toHaveProperty("content");
|
||||
});
|
||||
|
||||
it("ends an entry after both the tool_result and the hook", () => {
|
||||
const tracker = new ToolCallFieldTracker();
|
||||
tracker.apply({ sessionUpdate: "tool_call", toolCallId: "a", title: "A" });
|
||||
|
||||
@@ -3,13 +3,12 @@ import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import {
|
||||
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
|
||||
type AgentFileChangeReportResult,
|
||||
} from "../../file-change-audit.js";
|
||||
import type { Pushable } from "../../utils.js";
|
||||
} from "../file-change-audit.js";
|
||||
import type { Pushable } from "../utils.js";
|
||||
|
||||
type Scenario =
|
||||
| "reported"
|
||||
@@ -89,8 +88,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../../tools.js")>("../../tools.js")),
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -258,7 +257,7 @@ describe("native file-change report integration", () => {
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(client, {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
type Scenario,
|
||||
system,
|
||||
toolCall,
|
||||
} from "../acp-scenarios/harness.js";
|
||||
} from "./acp-scenarios/harness.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("../acp-scenarios/harness.js");
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
@@ -34,7 +34,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("../acp-scenarios/harness.js");
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
@@ -170,7 +170,7 @@ beforeAll(async () => {
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
for (const profile of runProfiles) {
|
||||
resetIds();
|
||||
const run = await unlessHung(runScenario(ClaudeAcpAgent, profile, scenario));
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { Pushable } from "../../utils.js";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
@@ -4,9 +4,9 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { messageIdForGrouping } from "../../sdk-to-acp.js";
|
||||
import { forkSession } from "../../fork-session.js";
|
||||
import { encodeProjectPath } from "../../v1/session-index/project-dirs.js";
|
||||
import { messageIdForGrouping } from "../acp-agent.js";
|
||||
import { forkSession } from "../fork-session.js";
|
||||
import { encodeProjectPath } from "../session-index/project-dirs.js";
|
||||
|
||||
// Use the real SDK against temporary transcripts, without starting Claude Code.
|
||||
let root: string;
|
||||
@@ -34,7 +34,6 @@ export function makeMockQuery(overrides: Record<string, unknown> = {}) {
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
mcpServerStatus: async () => [],
|
||||
setMcpServers: async () => ({ added: [], removed: [], errors: {} }),
|
||||
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
|
||||
// No ultracode fields: session creation falls back to settings and the
|
||||
// model's effort levels, as with a CLI that does not report them.
|
||||
|
||||
@@ -9,9 +9,8 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { initializeClient } from "../helpers.js";
|
||||
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { initializeClient } from "./helpers.js";
|
||||
import { successfulResultMessage } from "./session-doubles.js";
|
||||
|
||||
const execFileSpy = vi.hoisted(() => vi.fn());
|
||||
@@ -37,7 +36,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
const { makeMockQuery } = await import("../helpers.js");
|
||||
const { makeMockQuery } = await import("./helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
query: ({ prompt, options }: { prompt: AsyncIterable<SDKUserMessage>; options: Options }) => {
|
||||
@@ -52,8 +51,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../../tools.js")>("../../tools.js")),
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -228,7 +227,7 @@ function scriptClaudeCode({
|
||||
|
||||
async function connect(clientCapabilities: Record<string, unknown> = {}) {
|
||||
vi.resetModules();
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const updates: Array<{ update: Record<string, any> }> = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: { update: Record<string, any> }) => {
|
||||
@@ -1,8 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../../tools.js";
|
||||
import { Pushable } from "../../utils.js";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../tools.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
@@ -9,8 +9,7 @@ import type {
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
@@ -9,8 +9,7 @@ import type {
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
@@ -2,17 +2,15 @@ import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { McpServerStatus } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import { stripLocalCommandMetadata } from "../../message-filters.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { ClaudeAcpAgent, stripLocalCommandMetadata, type AcpClient } from "../acp-agent.js";
|
||||
import {
|
||||
cleanMcpError,
|
||||
formatMcpStatus,
|
||||
MCP_AVAILABLE_COMMAND,
|
||||
parseMcpCommand,
|
||||
} from "../../mcp-command.js";
|
||||
import { Pushable } from "../../utils.js";
|
||||
import { initializeClient } from "../helpers.js";
|
||||
} from "../mcp-command.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { initializeClient } from "./helpers.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
@@ -4,7 +4,6 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
aliasResumedModelRow,
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
isDeniedModel,
|
||||
@@ -74,72 +73,10 @@ describe("resolveModelPreference - resolvedModel matching", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8")?.value).toBe("opus[1m]");
|
||||
});
|
||||
|
||||
// Words match whole: "a" is inside "Default", "son" inside "sonnet".
|
||||
it.each(["claude-gpt-99", "not-a-model", "a", "son", "foo-sonnet", "not-default-model"])(
|
||||
"returns null for %s, which names no model",
|
||||
(preference) => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["us.anthropic.claude-sonnet-5-v1:0", "sonnet"],
|
||||
["global.anthropic.claude-haiku-4-5-20251001-v1:0", "haiku"],
|
||||
["anthropic/claude-sonnet-5:batch", "sonnet"],
|
||||
["claude-opus-4-8@20260101", "opus[1m]"],
|
||||
["claude-sonnet-5-fast", "sonnet"],
|
||||
["claude-haiku-4-5", "haiku"],
|
||||
["opusplan", "opus[1m]"],
|
||||
["opus", "opus[1m]"],
|
||||
["Sonnet 5", "sonnet"],
|
||||
// The CLI's `best` alias runs Opus and ignores a `[1m]` hint.
|
||||
["best", "opus[1m]"],
|
||||
["BEST", "opus[1m]"],
|
||||
["best[1m]", "opus[1m]"],
|
||||
])("resolves the spelling %s to %s", (preference, value) => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)?.value).toBe(value);
|
||||
it("returns null for a preference that matches nothing", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves best to the Opus entry rather than the default when the default runs Sonnet", () => {
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Use the default model (currently Sonnet 5)",
|
||||
},
|
||||
{ value: "opus", resolvedModel: "claude-opus-4-8", displayName: "Opus", description: "" },
|
||||
{ value: "sonnet", resolvedModel: "claude-sonnet-5", displayName: "Sonnet", description: "" },
|
||||
];
|
||||
expect(resolveModelPreference(models, "best")?.value).toBe("opus");
|
||||
});
|
||||
|
||||
// A gateway listing of dated ids, as `ANTHROPIC_MODEL`, `settings.model` or
|
||||
// `set_config_option` may spell them.
|
||||
const DATED_MODELS: ModelInfo[] = [
|
||||
{ value: "claude-3-7-sonnet-20250219", displayName: "Claude 3.7 Sonnet", description: "" },
|
||||
{ value: "claude-3-5-haiku-20241022", displayName: "Claude 3.5 Haiku", description: "" },
|
||||
{ value: "claude-sonnet-4-5-20250929", displayName: "Claude Sonnet 4.5", description: "" },
|
||||
];
|
||||
|
||||
it.each([
|
||||
["claude-3-7-sonnet-latest", "claude-3-7-sonnet-20250219"],
|
||||
["claude-3-5-haiku-latest", "claude-3-5-haiku-20241022"],
|
||||
["openrouter/anthropic/claude-sonnet-4.5", "claude-sonnet-4-5-20250929"],
|
||||
["vertex_ai/claude-sonnet-4-5@20250929", "claude-sonnet-4-5-20250929"],
|
||||
["litellm/claude-sonnet-4-5", "claude-sonnet-4-5-20250929"],
|
||||
["publishers/anthropic/models/claude-sonnet-4-5", "claude-sonnet-4-5-20250929"],
|
||||
])("resolves the gateway spelling %s to %s", (preference, value) => {
|
||||
expect(resolveModelPreference(DATED_MODELS, preference)?.value).toBe(value);
|
||||
});
|
||||
|
||||
it.each(["openai/gpt-5", "openrouter/foo-sonnet", "claude-3-5-opus-latest"])(
|
||||
"returns null for the gateway spelling %s, which names no listed model",
|
||||
(preference) => {
|
||||
expect(resolveModelPreference(DATED_MODELS, preference)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("matches a '-1m' id-suffix preference against a '[1m]'-spelled resolvedModel", () => {
|
||||
// Without hint canonicalization the exact tier misses and the substring
|
||||
// tier lands on the bare 200k sibling, silently downgrading a 1M pin.
|
||||
@@ -259,50 +196,6 @@ describe("matchResumedModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliasResumedModelRow", () => {
|
||||
const row = (value: string, resolvedModel = value): ModelInfo => ({
|
||||
value,
|
||||
resolvedModel,
|
||||
displayName: value,
|
||||
description: "",
|
||||
});
|
||||
// What CLI 2.1.321 lists after resuming a session on `fable` (Fable 5.2).
|
||||
const STARTED_ON_FABLE = [
|
||||
row("default", "claude-opus-5-5"),
|
||||
row("claude-fable-5-2"),
|
||||
row("claude-fable-5-1"),
|
||||
row("claude-fable-5"),
|
||||
];
|
||||
|
||||
it("lists the resumed model's row under its family alias", () => {
|
||||
expect(aliasResumedModelRow(STARTED_ON_FABLE, "claude-fable-5-2").map((m) => m.value)).toEqual([
|
||||
"default",
|
||||
"fable",
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"the alias already has a row",
|
||||
[row("fable", "claude-fable-5-1"), row("claude-fable-5")],
|
||||
"claude-fable-5",
|
||||
],
|
||||
["the row is not the resumed model", STARTED_ON_FABLE, "claude-opus-5-5"],
|
||||
["the session is not resumed", STARTED_ON_FABLE, undefined],
|
||||
["the family has a newer row", STARTED_ON_FABLE, "claude-fable-5-1"],
|
||||
[
|
||||
"the row carries a context hint",
|
||||
[row("claude-fable-5-1[1m]", "claude-fable-5-1")],
|
||||
"claude-fable-5-1[1m]",
|
||||
],
|
||||
["the row is not a family model", [row("claude-gpt-9")], "claude-gpt-9"],
|
||||
])("keeps every value when %s", (_case, models, resumedModel) => {
|
||||
expect(aliasResumedModelRow(models, resumedModel)).toEqual(models);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - resolvedModel matching", () => {
|
||||
it("resolves an allowlist entry pinned to a canonical resolvedModel id", () => {
|
||||
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
|
||||
describe("module runtime exports", () => {
|
||||
/**
|
||||
* The public runtime exports of lib.ts, the package's only entry point, must change only deliberately; consumers import these names.
|
||||
*/
|
||||
it("lib.ts exports the expected names", async () => {
|
||||
expect(Object.keys(await import("../lib.js")).sort()).toEqual([
|
||||
"AUTH_STATUS_UPDATE_METHOD",
|
||||
"serveAcp",
|
||||
]);
|
||||
});
|
||||
|
||||
it("package.json exposes lib.ts as the only module entry point", () => {
|
||||
expect(Object.keys(packageJson.exports)).toEqual([".", "./package.json"]);
|
||||
expect(packageJson.exports["."]).toEqual({
|
||||
types: "./dist/lib.d.ts",
|
||||
import: "./dist/lib.js",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { promptSuggestionsMeta, promptSuggestionText } from "../../prompt-suggestions.js";
|
||||
import { Pushable } from "../../utils.js";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { promptSuggestionsMeta, promptSuggestionText } from "../prompt-suggestions.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
@@ -3,11 +3,11 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ModelInfo, Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import { FAST_MODE_CONFIG_ID } from "../../session-fast-mode.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../../air-extension.js";
|
||||
import { initializeClient, makeMockQuery } from "../helpers.js";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import { ClaudeAcpAgent, FAST_MODE_CONFIG_ID, type AcpClient } from "../acp-agent.js";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
|
||||
import { acpProtocolRouter } from "../serve.js";
|
||||
import { initializeClient, makeMockQuery } from "./helpers.js";
|
||||
import { successfulResultMessage, userEcho } from "./session-doubles.js";
|
||||
|
||||
const sdk = vi.hoisted(() => ({
|
||||
@@ -747,4 +747,50 @@ describe("provider updates preserve session configuration", { timeout: 10_000 },
|
||||
expect(lastQuery().state).toMatchObject({ model: "opus", effort: "high" });
|
||||
expect(lastQuery().request.options.env?.ANTHROPIC_BASE_URL).toBe("https://recovered.example");
|
||||
});
|
||||
|
||||
it("publishes the restored configuration through the v2 protocol router", async () => {
|
||||
await agent.dispose();
|
||||
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
|
||||
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
|
||||
acpProtocolRouter({ log() {}, error() {} }, (created) => (agent = created)).connect({
|
||||
readable: toAgent.readable,
|
||||
writable: toClient.writable,
|
||||
});
|
||||
const received: v2.UpdateSessionNotification[] = [];
|
||||
await v2
|
||||
.client({ name: "provider-test" })
|
||||
.onNotification(v2.methods.client.session.update, ({ params }) => void received.push(params))
|
||||
.connectWith({ readable: toClient.readable, writable: toAgent.writable }, async (client) => {
|
||||
await client.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "provider-test", version: "1" },
|
||||
capabilities: AIR_CAPABILITIES,
|
||||
});
|
||||
await client.request(v2.methods.agent.providers.set, PROVIDER);
|
||||
const { sessionId } = await client.request(v2.methods.agent.session.new, { cwd });
|
||||
for (const [configId, value] of [
|
||||
["model", "opus"],
|
||||
["effort", "high"],
|
||||
]) {
|
||||
await client.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId,
|
||||
value,
|
||||
type: "id",
|
||||
});
|
||||
}
|
||||
|
||||
await client.request(v2.methods.agent.providers.set, PROVIDER);
|
||||
|
||||
const config = received.findLast(
|
||||
({ update }) => update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(config?.update).toMatchObject({
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({ configId: "model", currentValue: "opus" }),
|
||||
expect.objectContaining({ configId: "effort", currentValue: "high" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user