mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 03:12:09 +00:00
Compare commits
166 Commits
v0.96.1-pr
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
231178127a | ||
|
|
071b2e8098 | ||
|
|
e42cc82304 | ||
|
|
d8d8d3b6dd | ||
|
|
885e765662 | ||
|
|
4be19db524 | ||
|
|
8c53cfa2be | ||
|
|
9a47567e59 | ||
|
|
f541c267f2 | ||
|
|
6c709a98d7 | ||
|
|
89cdd12e29 | ||
|
|
2f7e386f36 | ||
|
|
dc24f8a7bc | ||
|
|
05ea8b2a5b | ||
|
|
cf902892d3 | ||
|
|
51a9cbc480 | ||
|
|
21f8666709 | ||
|
|
600fb6f15d | ||
|
|
da70e3031a | ||
|
|
bc476f6a2f | ||
|
|
a031eabe10 | ||
|
|
1607023bdf | ||
|
|
854a347bf0 | ||
|
|
32d530a3dc | ||
|
|
c66464eace | ||
|
|
6db2e6ae20 | ||
|
|
1bae40d6cc | ||
|
|
7af0804b8a | ||
|
|
97b77d7cf7 | ||
|
|
dbe8a3fce8 | ||
|
|
3dc4582b95 | ||
|
|
d31f4a9306 | ||
|
|
f0e99ea5a0 | ||
|
|
c9c3f092d2 | ||
|
|
8e18eb7c75 | ||
|
|
400e531404 | ||
|
|
855447c7cd | ||
|
|
d098fb08d5 | ||
|
|
8af9df957c | ||
|
|
1cf208b68b | ||
|
|
3f0beb033b | ||
|
|
4a79f0487a | ||
|
|
aa13302252 | ||
|
|
71792d29a9 | ||
|
|
c99d3a1e59 | ||
|
|
92e695d1cc | ||
|
|
e3c5a8af96 | ||
|
|
afe4434be7 | ||
|
|
05a827c69f | ||
|
|
acf21d2468 | ||
|
|
766fd142f6 | ||
|
|
0dc4b370e0 | ||
|
|
176ae0634c | ||
|
|
a14c72b080 | ||
|
|
4f0780584d | ||
|
|
01fe4b00d7 | ||
|
|
fcb7e175ee | ||
|
|
f90e379f6c | ||
|
|
51ab74538c | ||
|
|
e9e0d414fe | ||
|
|
4dc6e656a6 | ||
|
|
30600ba348 | ||
|
|
39244f7952 | ||
|
|
911ba98f14 | ||
|
|
9a15f4889a | ||
|
|
dd9eed4e46 | ||
|
|
1a56268aae | ||
|
|
ce892a59ae | ||
|
|
19f42bfb96 | ||
|
|
b658390078 | ||
|
|
d649d2c423 | ||
|
|
529531f726 | ||
|
|
4cceb06b31 | ||
|
|
ab2ff616a8 | ||
|
|
ef65ab31b4 | ||
|
|
e83770c609 | ||
|
|
6494eaa95a | ||
|
|
9bf4fedee6 | ||
|
|
6db28b2cba | ||
|
|
35dee0a20f | ||
|
|
c700127ad2 | ||
|
|
a32fe7a6cc | ||
|
|
d81307c108 | ||
|
|
a9bc3b81ae | ||
|
|
fd41fb0055 | ||
|
|
b5ada6848a | ||
|
|
cad5166361 | ||
|
|
94a7731257 | ||
|
|
4cea4c8e22 | ||
|
|
63a69d09fb | ||
|
|
7b3cd48edd | ||
|
|
c4a4899cdb | ||
|
|
50dd028877 | ||
|
|
64fdb9364f | ||
|
|
4a86415a37 | ||
|
|
ecbc310304 | ||
|
|
4a19a9dfb8 | ||
|
|
1801c037b8 | ||
|
|
a58293f4c9 | ||
|
|
3b22586cf6 | ||
|
|
47b64c04de | ||
|
|
1ee2422934 | ||
|
|
7d1ef76c1d | ||
|
|
aa0c1234b5 | ||
|
|
54392e144d | ||
|
|
6597626d86 | ||
|
|
ba162346c9 | ||
|
|
85aec26ef9 | ||
|
|
7ea9eec839 | ||
|
|
a79d07324d | ||
|
|
1d4418f80d | ||
|
|
21d24f9e8b | ||
|
|
e3f0b6c89c | ||
|
|
8bbdea6cc6 | ||
|
|
1e986b526d | ||
|
|
53e3eec013 | ||
|
|
981bf9a28a | ||
|
|
4d79b9ffbc | ||
|
|
5565259771 | ||
|
|
720bbb3e4c | ||
|
|
38cf656fc9 | ||
|
|
1d0306468b | ||
|
|
c6de68797b | ||
|
|
eb78afa855 | ||
|
|
d2ef490a31 | ||
|
|
f50598a39c | ||
|
|
3bc671193c | ||
|
|
c3428de1bf | ||
|
|
1d2b92feae | ||
|
|
99a6e76158 | ||
|
|
e01a288384 | ||
|
|
2fd46c866d | ||
|
|
0f78dbbdd0 | ||
|
|
8bd0bc9b03 | ||
|
|
52c241e715 | ||
|
|
fccaebcf87 | ||
|
|
7dcb62923d | ||
|
|
4b4b2c4de7 | ||
|
|
106e3031e3 | ||
|
|
4cb8906d32 | ||
|
|
d0ee30813f | ||
|
|
54b35ee0f4 | ||
|
|
93c1f43519 | ||
|
|
f646c821f1 | ||
|
|
54b869c990 | ||
|
|
8e6fe92a98 | ||
|
|
f6ed4a4f7d | ||
|
|
2a0c188a0a | ||
|
|
2480888b90 | ||
|
|
c2e85cb580 | ||
|
|
9f9aebf44b | ||
|
|
2f1f4d1441 | ||
|
|
c90f856c11 | ||
|
|
059d76e14a | ||
|
|
4324dce4be | ||
|
|
c7c80f7b26 | ||
|
|
8f43ac8089 | ||
|
|
0ce42f2c31 | ||
|
|
782e463ff4 | ||
|
|
c9374abfc7 | ||
|
|
d52c7c64d8 | ||
|
|
de96cef382 | ||
|
|
a4957d17c1 | ||
|
|
bed795d261 | ||
|
|
434be13a33 | ||
|
|
2a1525e57c |
125
.github/workflows/binaries.yml
vendored
Normal file
125
.github/workflows/binaries.yml
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
# Builds the standalone executables (scripts/build-binary.mjs) and smoke-tests
|
||||
# each one on a runner of its own platform. Each archive holds
|
||||
# `claude-agent-acp` and the Claude CLI (`claude`) it runs from its directory.
|
||||
# CI calls this to check that every target still builds and starts; the
|
||||
# release calls it with `upload: true` and attaches the archives to the GitHub
|
||||
# release.
|
||||
name: Binaries
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
upload:
|
||||
description: Upload each archive as a workflow artifact for the release job.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Binary (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: bun-linux-x64
|
||||
runner: ubuntu-latest
|
||||
archive: claude-agent-acp-linux-x64.tar.gz
|
||||
- target: bun-linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
archive: claude-agent-acp-linux-arm64.tar.gz
|
||||
# npm skips the musl package on a glibc host, so these are
|
||||
# cross-built and smoke-tested in an Alpine container.
|
||||
- target: bun-linux-x64-musl
|
||||
runner: ubuntu-latest
|
||||
archive: claude-agent-acp-linux-x64-musl.tar.gz
|
||||
musl: true
|
||||
- target: bun-linux-arm64-musl
|
||||
runner: ubuntu-24.04-arm
|
||||
archive: claude-agent-acp-linux-arm64-musl.tar.gz
|
||||
musl: true
|
||||
- target: bun-darwin-x64
|
||||
runner: macos-15-intel
|
||||
archive: claude-agent-acp-darwin-x64.zip
|
||||
- target: bun-darwin-arm64
|
||||
runner: macos-latest
|
||||
archive: claude-agent-acp-darwin-arm64.zip
|
||||
- target: bun-windows-x64
|
||||
runner: windows-latest
|
||||
archive: claude-agent-acp-windows-x64.zip
|
||||
- target: bun-windows-arm64
|
||||
runner: windows-11-arm
|
||||
archive: claude-agent-acp-windows-arm64.zip
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
check-latest: true
|
||||
cache: "npm"
|
||||
|
||||
# Unpinned: each build is smoke-tested on its platform before it uploads,
|
||||
# so a Bun release that breaks the executable fails here instead of
|
||||
# shipping. Pin a version if one ever regresses past the smoke test.
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Install the musl Claude CLI package
|
||||
if: ${{ matrix.musl }}
|
||||
run: |
|
||||
version="$(node -p "require('./node_modules/@anthropic-ai/claude-agent-sdk/package.json').version")"
|
||||
npm install --no-save --force "@anthropic-ai/claude-agent-sdk-linux-${RUNNER_ARCH,,}-musl@$version"
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
echo "Built \`${{ matrix.target }}\` with Bun $(bun --version)" >> "$GITHUB_STEP_SUMMARY"
|
||||
node scripts/build-binary.mjs --target ${{ matrix.target }}
|
||||
|
||||
- name: Smoke test
|
||||
if: ${{ !matrix.musl }}
|
||||
run: node scripts/smoke-binary.mjs
|
||||
|
||||
# Claude Code's Alpine requirements: libgcc and libstdc++.
|
||||
- name: Smoke test (Alpine)
|
||||
if: ${{ matrix.musl }}
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/work" -w /work node:lts-alpine \
|
||||
sh -c 'apk add --no-cache libgcc libstdc++ >/dev/null && node scripts/smoke-binary.mjs'
|
||||
|
||||
- name: Archive
|
||||
if: ${{ inputs.upload }}
|
||||
env:
|
||||
ARCHIVE: ${{ matrix.archive }}
|
||||
run: |
|
||||
cd dist-bin
|
||||
if [[ "$ARCHIVE" == *.zip ]]; then
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
pwsh -Command "Compress-Archive -Path claude-agent-acp.exe,claude.exe -DestinationPath ../$ARCHIVE"
|
||||
else
|
||||
zip "../$ARCHIVE" claude-agent-acp claude
|
||||
fi
|
||||
else
|
||||
tar czf "../$ARCHIVE" claude-agent-acp claude
|
||||
fi
|
||||
|
||||
- name: Upload
|
||||
if: ${{ inputs.upload }}
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ${{ matrix.archive }}
|
||||
path: ${{ matrix.archive }}
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -44,3 +44,7 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:run
|
||||
|
||||
binaries:
|
||||
name: Binaries
|
||||
uses: ./.github/workflows/binaries.yml
|
||||
|
||||
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@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
- uses: amannn/action-semantic-pull-request@2252a14e810aa9b85c2d560bd7070c802206ecf6 # v6.1.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
38
.github/workflows/publish.yml
vendored
38
.github/workflows/publish.yml
vendored
@@ -88,6 +88,38 @@ jobs:
|
||||
npm publish
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Standalone executables for the GitHub release, stable releases only. They
|
||||
# don't gate the npm publish: a failed build leaves the release without
|
||||
# binaries, and re-running the failed jobs attaches them.
|
||||
build-binaries:
|
||||
name: Build binaries
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
# The called workflow only reads the repository; upload-binaries does the write.
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/binaries.yml
|
||||
with:
|
||||
upload: true
|
||||
|
||||
upload-binaries:
|
||||
name: Attach binaries to the release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please, build-binaries]
|
||||
permissions:
|
||||
contents: write
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Upload to the release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: gh release upload "$TAG" artifacts/* --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# Same shape as publish-npm. Tagging is a separate job, so nothing here needs
|
||||
# write access to the repository.
|
||||
publish-npm-preview:
|
||||
@@ -199,7 +231,11 @@ jobs:
|
||||
echo "Tagged \`v$VERSION\` at \`$SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
trigger-registry-update:
|
||||
needs: [publish-npm, publish-npm-preview]
|
||||
# upload-binaries too, so a stable release's archives are attached before the
|
||||
# registry reads the release: it takes each archive's sha256 from the release
|
||||
# assets when it bumps the version. It only waits; a failed or skipped upload
|
||||
# (every preview) doesn't stop the dispatch.
|
||||
needs: [publish-npm, publish-npm-preview, upload-binaries]
|
||||
# Both channels dispatch this: the registry has its own handling for preview
|
||||
# versions. The two never fire together — a release merge publishes stable and
|
||||
# skips the preview, and every other push does the reverse — so the registry sees
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,4 +3,5 @@ dist
|
||||
.DS_Store
|
||||
.claude/*.local.*
|
||||
CLAUDE.local.md
|
||||
.idea/
|
||||
.idea/
|
||||
dist-bin
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.96.0"
|
||||
".": "0.102.0"
|
||||
}
|
||||
|
||||
87
AGENTS.md
87
AGENTS.md
@@ -16,13 +16,88 @@ npm run format # prettier --write
|
||||
CI runs `format:check`, `lint`, `build` and `test:run`. Run `npm run check` before
|
||||
opening a PR.
|
||||
|
||||
## Code comments
|
||||
## Coding guidelines
|
||||
|
||||
Keep comments straightforward, concise, and concrete. Say what the code does and
|
||||
why, in plain terms: name the actual state, call, or failure involved instead of
|
||||
alluding to it. Prefer a short concrete example over an abstract description.
|
||||
Don't use vague phrases like "keeps it honest" or "the residual case" that make
|
||||
the reader reconstruct the meaning.
|
||||
- Keep edits consistent with the surrounding code's formatting, naming and idioms.
|
||||
- Keep comments concise and concrete. Name the actual state, call or failure
|
||||
instead of alluding to it, prefer a short example over an abstract
|
||||
description, and avoid vague phrases like "keeps it honest". A comment or doc
|
||||
must be true on every code path it describes, and a doc that lists what a
|
||||
result omits or rejects must name the actual cases.
|
||||
- When a request can't be served, throw a `RequestError` whose message says what
|
||||
happened and what to do (e.g. "Try again when the turn ends"). Don't drop the
|
||||
request silently.
|
||||
- Don't report success for work you start without awaiting it. If the detached
|
||||
call can still refuse and its `.catch` only logs, check the refusal condition
|
||||
first or report the failure to the client.
|
||||
- A result must not depend on what an earlier call happened to cache. A request
|
||||
right after a restart, with cold caches, must return the same thing as one
|
||||
after a warm-up.
|
||||
- If code relies on an ordering or call sequence that nothing enforces (e.g. an
|
||||
echo arriving before the result), say so in a short comment.
|
||||
|
||||
## Architecture and design fit
|
||||
|
||||
- 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 `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
|
||||
change.
|
||||
- Before writing new logic, check whether it already exists in a sibling handler
|
||||
of the same flow or in a helper that works with the same data, and reuse it.
|
||||
Paths that handle the same input must apply the same rules through the same
|
||||
code: the live stream and the `session/load` replay, the command list and
|
||||
command dispatch, `prompt` and `steer`. Two copies of one check drift apart.
|
||||
- Put guards, filters and cleanup in the module that owns the state, so every
|
||||
caller gets them. If guards, flags or fallbacks pile up in one place,
|
||||
restructure instead of adding another.
|
||||
- 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
|
||||
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.
|
||||
- Don't branch on a local copy of state that Claude Code or a remote service
|
||||
owns: it goes stale when the other side changes. Have a command set the state
|
||||
the user asks for instead of toggling it.
|
||||
- A command or name the adapter adds must not shadow one that Claude Code or the
|
||||
user's own commands and skills already provide.
|
||||
- New optional request fields and `_meta` keys must not reject requests that
|
||||
were valid before.
|
||||
- If a workaround or new path is unavoidable (e.g. a Claude Code or SDK
|
||||
limitation), say why in a short code comment and in the PR description.
|
||||
|
||||
## Testing guidelines
|
||||
|
||||
- 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.
|
||||
- Reach a state the shortest way (e.g. set `session.tornDown = true`) instead of
|
||||
driving a full flow with extra mocks, unless the flow is what you test.
|
||||
- Build test doubles from what the bundled Claude Code CLI really sends,
|
||||
including fields like `origin` it echoes back and fields it omits. A double
|
||||
simpler than the real message hides bugs. If unsure, check against the real
|
||||
CLI (`npm run test:integration`).
|
||||
- Cover both ACP v1 and v2 when a change affects what a client receives
|
||||
(updates, responses, replay). A change to internal logic only does not need
|
||||
its tests duplicated per version.
|
||||
- For each behavior the docs or comments promise, including what is omitted or
|
||||
rejected, have a test that would fail if it broke.
|
||||
|
||||
## Pull requests
|
||||
|
||||
|
||||
138
CHANGELOG.md
138
CHANGELOG.md
@@ -1,5 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* compact idle sessions before the prompt cache expires ([#1522](https://github.com/agentclientprotocol/claude-agent-acp/issues/1522)) ([a79d073](https://github.com/agentclientprotocol/claude-agent-acp/commit/a79d07324dc15808c94a889a3b60615cca5a9fbd)), closes [#1454](https://github.com/agentclientprotocol/claude-agent-acp/issues/1454) [#1518](https://github.com/agentclientprotocol/claude-agent-acp/issues/1518)
|
||||
* ship single-file executables built with bun ([#1524](https://github.com/agentclientprotocol/claude-agent-acp/issues/1524)) ([e3f0b6c](https://github.com/agentclientprotocol/claude-agent-acp/commit/e3f0b6c89c783939e6ca8c19c220c21b58263f46))
|
||||
* start sessions in Auto mode by default ([#1533](https://github.com/agentclientprotocol/claude-agent-acp/issues/1533)) ([7ea9eec](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ea9eec8392a43191b25a5c3636c181cc7eb42f3))
|
||||
* stream the output of v2 background commands ([#1512](https://github.com/agentclientprotocol/claude-agent-acp/issues/1512)) ([53e3eec](https://github.com/agentclientprotocol/claude-agent-acp/commit/53e3eec0134209654f53c46f319aaac851b8fff0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* honor configured fallback for unavailable restored modes ([#1520](https://github.com/agentclientprotocol/claude-agent-acp/issues/1520)) ([8bbdea6](https://github.com/agentclientprotocol/claude-agent-acp/commit/8bbdea6cc62a7ae080cb73ff476ed28ad7574018))
|
||||
* update to @anthropic-ai/claude-agent-sdk 0.3.318 ([#1525](https://github.com/agentclientprotocol/claude-agent-acp/issues/1525)) ([1d4418f](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d4418f80daf14c29cecc5b23286f52c7f0402ec))
|
||||
|
||||
## [0.100.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.1...v0.100.0) (2026-11-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* list v2 background tool calls ([#1511](https://github.com/agentclientprotocol/claude-agent-acp/issues/1511)) ([4d79b9f](https://github.com/agentclientprotocol/claude-agent-acp/commit/4d79b9ffbce95c23928a5dd771d6c87476d37160))
|
||||
* report subagent working directories ([#1496](https://github.com/agentclientprotocol/claude-agent-acp/issues/1496)) ([eb78afa](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb78afa8556b8fc4082495e48e3fd1762fb6a50f))
|
||||
* report v2 background work ([#1510](https://github.com/agentclientprotocol/claude-agent-acp/issues/1510)) ([5565259](https://github.com/agentclientprotocol/claude-agent-acp/commit/5565259771cb74e3a9bdbacfcbce43e061d81422))
|
||||
* report why Claude Code could not start a session ([#1508](https://github.com/agentclientprotocol/claude-agent-acp/issues/1508)) ([38cf656](https://github.com/agentclientprotocol/claude-agent-acp/commit/38cf656fc9d80fec2d1a16c472779803e2c802d2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align session/cancel, session/close, and v2 setup with the ACP v2 spec ([#1494](https://github.com/agentclientprotocol/claude-agent-acp/issues/1494)) ([f50598a](https://github.com/agentclientprotocol/claude-agent-acp/commit/f50598a39c5dc3908d4b98a7213e01d63ea867d5))
|
||||
* forward v2 elicitation values and read dismissed v2 permission prompts as a refusal ([#1493](https://github.com/agentclientprotocol/claude-agent-acp/issues/1493)) ([3bc6711](https://github.com/agentclientprotocol/claude-agent-acp/commit/3bc671193c6456516fe05a0eaa53c17bdfb6a190))
|
||||
* keep native Windows paths in v2 git patches ([#1492](https://github.com/agentclientprotocol/claude-agent-acp/issues/1492)) ([c3428de](https://github.com/agentclientprotocol/claude-agent-acp/commit/c3428de1bf411c513b0703ddd56bb6c5fdff583c))
|
||||
* preserve live session settings across provider refreshes ([#1474](https://github.com/agentclientprotocol/claude-agent-acp/issues/1474)) ([e01a288](https://github.com/agentclientprotocol/claude-agent-acp/commit/e01a28838499dd6dd7c31e35708090b938e852c6))
|
||||
* reject unreadable skill manifests ([#1482](https://github.com/agentclientprotocol/claude-agent-acp/issues/1482)) ([99a6e76](https://github.com/agentclientprotocol/claude-agent-acp/commit/99a6e76158b9164e51bd62e3b89113beadea5ce2))
|
||||
* report the fresh context's usage when ExitPlanMode clears context ([#1484](https://github.com/agentclientprotocol/claude-agent-acp/issues/1484)) ([2fd46c8](https://github.com/agentclientprotocol/claude-agent-acp/commit/2fd46c866d4ff2939fe1b1245b15a536d6de7c0b))
|
||||
* update to @agentclientprotocol/sdk 2.2.0 ([#1491](https://github.com/agentclientprotocol/claude-agent-acp/issues/1491)) ([1d2b92f](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d2b92feae5eeccde3ac738edca1f1d71250762e))
|
||||
* update to @agentclientprotocol/sdk 2.4.0 ([#1509](https://github.com/agentclientprotocol/claude-agent-acp/issues/1509)) ([720bbb3](https://github.com/agentclientprotocol/claude-agent-acp/commit/720bbb3e4c65874775a0bb9418799baf4b6e3680))
|
||||
* update to Claude Agent SDK 0.3.316 (Claude Code 2.1.316) ([#1507](https://github.com/agentclientprotocol/claude-agent-acp/issues/1507)) ([1d03064](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d0306468b632c6a56b4fb076ad7a19f23d9e7b5))
|
||||
* validate session, prompt, config, and auth requests per the latest ACP spec ([#1495](https://github.com/agentclientprotocol/claude-agent-acp/issues/1495)) ([d2ef490](https://github.com/agentclientprotocol/claude-agent-acp/commit/d2ef490a318033049af00cb9164a39ac2dd1eb95))
|
||||
|
||||
## [0.99.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.0...v0.99.1) (2026-10-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add approval id to plan ([#1469](https://github.com/agentclientprotocol/claude-agent-acp/issues/1469)) ([4b4b2c4](https://github.com/agentclientprotocol/claude-agent-acp/commit/4b4b2c4de7c492070f6260cacea5680b2e045a5a))
|
||||
* allow forking sessions into a different directory ([#1483](https://github.com/agentclientprotocol/claude-agent-acp/issues/1483)) ([8bd0bc9](https://github.com/agentclientprotocol/claude-agent-acp/commit/8bd0bc9b03fb4a30e0b1755128baad12a214a58d))
|
||||
* detect "Run /login" login-expired messages as authRequired ([#1481](https://github.com/agentclientprotocol/claude-agent-acp/issues/1481)) ([fccaebc](https://github.com/agentclientprotocol/claude-agent-acp/commit/fccaebcf87e8f37144ec1a64920c0c53997edabe))
|
||||
* mark plugin, bundled and nested Claude skills as skills for AIR ([#1473](https://github.com/agentclientprotocol/claude-agent-acp/issues/1473)) ([52c241e](https://github.com/agentclientprotocol/claude-agent-acp/commit/52c241e71512ec19c4dd79c95c51ce67f04d7aac))
|
||||
* title subagent sessions by their task ([#1479](https://github.com/agentclientprotocol/claude-agent-acp/issues/1479)) ([7dcb629](https://github.com/agentclientprotocol/claude-agent-acp/commit/7dcb62923d38ac24e386b0626fe123334341c445))
|
||||
|
||||
## [0.99.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.98.0...v0.99.0) (2026-10-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add thinking display configuration ([#1081](https://github.com/agentclientprotocol/claude-agent-acp/issues/1081)) ([2f1f4d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f1f4d144196c7518a7c072f9ae0e70ac9b91a44))
|
||||
* send embedded blob resources in prompts to Claude ([#1460](https://github.com/agentclientprotocol/claude-agent-acp/issues/1460)) ([d0ee308](https://github.com/agentclientprotocol/claude-agent-acp/commit/d0ee30813fbc79dbe122f7455c8e1746e09be458))
|
||||
* tell the client when an MCP server fails to start ([#1464](https://github.com/agentclientprotocol/claude-agent-acp/issues/1464)) ([54b35ee](https://github.com/agentclientprotocol/claude-agent-acp/commit/54b35ee0f4d765efc5bc39107a524891266c42ac))
|
||||
* update to Claude Agent SDK 0.3.312 ([#1471](https://github.com/agentclientprotocol/claude-agent-acp/issues/1471)) ([4cb8906](https://github.com/agentclientprotocol/claude-agent-acp/commit/4cb8906d32a618011cfeba3260e4eb1db2fd829f))
|
||||
* **v2:** send the current session info on session/resume ([#1462](https://github.com/agentclientprotocol/claude-agent-acp/issues/1462)) ([f6ed4a4](https://github.com/agentclientprotocol/claude-agent-acp/commit/f6ed4a4f7d9dd5b5e7ed4e9973d9661becb9f583))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* deny a background permission request that the client cancels ([#1466](https://github.com/agentclientprotocol/claude-agent-acp/issues/1466)) ([f646c82](https://github.com/agentclientprotocol/claude-agent-acp/commit/f646c821f1b05df471aac8f1018ea08e2a793f6e))
|
||||
* don't report decline when the client can't render an MCP elicitation ([#1465](https://github.com/agentclientprotocol/claude-agent-acp/issues/1465)) ([54b869c](https://github.com/agentclientprotocol/claude-agent-acp/commit/54b869c990ad3efa25c976e8eb3ff5a37808a6a3))
|
||||
* list the current model among the model config option's values ([#1463](https://github.com/agentclientprotocol/claude-agent-acp/issues/1463)) ([8e6fe92](https://github.com/agentclientprotocol/claude-agent-acp/commit/8e6fe92a98dab8cec868a8a782e0ded55c94e571))
|
||||
* report EnterPlanMode tool calls with the switch_mode kind ([#1467](https://github.com/agentclientprotocol/claude-agent-acp/issues/1467)) ([93c1f43](https://github.com/agentclientprotocol/claude-agent-acp/commit/93c1f43519a6a542dc55a39985d0abd12195129d))
|
||||
* return ACP error codes for unknown sessions and invalid config values ([#1458](https://github.com/agentclientprotocol/claude-agent-acp/issues/1458)) ([c2e85cb](https://github.com/agentclientprotocol/claude-agent-acp/commit/c2e85cb58067d9b00e6609ce9e90d4ec3a5d0feb))
|
||||
* update ACP SDK to 2.1.0 (schema v1.27.0 / v2.0.0-alpha.10) ([9f9aebf](https://github.com/agentclientprotocol/claude-agent-acp/commit/9f9aebf44bdecab887b03871fee8a2885ceac19a))
|
||||
* **v2:** keep clients in sync with config changes and replayed tool calls ([#1461](https://github.com/agentclientprotocol/claude-agent-acp/issues/1461)) ([2a0c188](https://github.com/agentclientprotocol/claude-agent-acp/commit/2a0c188a0aebd0ea03fb449f72dfa53f98c7622d))
|
||||
* **v2:** reject MCP transports and prompt content the agent doesn't advertise ([#1459](https://github.com/agentclientprotocol/claude-agent-acp/issues/1459)) ([2480888](https://github.com/agentclientprotocol/claude-agent-acp/commit/2480888b90498158054266bdb3bd7f22822e1b73))
|
||||
|
||||
## [0.98.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.97.0...v0.98.0) (2026-10-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add batch session metadata lookup ([#1408](https://github.com/agentclientprotocol/claude-agent-acp/issues/1408)) ([0ce42f2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0ce42f2c312d190f78024d8b4b1ae5f00d9e5939))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* publish Monitor async tasks as monitors ([#1452](https://github.com/agentclientprotocol/claude-agent-acp/issues/1452)) ([4324dce](https://github.com/agentclientprotocol/claude-agent-acp/commit/4324dce4be877c55385b3389cebac1c63d08f456))
|
||||
* settle the tool call of a SandboxNetworkAccess permission request ([#1448](https://github.com/agentclientprotocol/claude-agent-acp/issues/1448)) ([8f43ac8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8f43ac808931a3f14719bbed37b9b153c9933139))
|
||||
* **v2:** leave the tool call status out of permission subjects ([#1447](https://github.com/agentclientprotocol/claude-agent-acp/issues/1447)) ([c7c80f7](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7c80f7b26f8d90f7f1c828036db64bc46dddddf))
|
||||
|
||||
## [0.97.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.96.0...v0.97.0) (2026-10-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ultracode session config option ([#1438](https://github.com/agentclientprotocol/claude-agent-acp/issues/1438)) ([de96cef](https://github.com/agentclientprotocol/claude-agent-acp/commit/de96cef382d8daf27301f1f90ae4b5e7f665f73a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump body-parser from 2.3.0 to 2.4.0 ([#1436](https://github.com/agentclientprotocol/claude-agent-acp/issues/1436)) ([c26e640](https://github.com/agentclientprotocol/claude-agent-acp/commit/c26e640915739dc7a3cd0664f8f5879098e0fd83))
|
||||
* distinguish model options with duplicate labels ([#1286](https://github.com/agentclientprotocol/claude-agent-acp/issues/1286)) ([434be13](https://github.com/agentclientprotocol/claude-agent-acp/commit/434be13a33141af8655c6f7c30f574841bf2279c))
|
||||
* keep the usage-limit error when it arrives after compaction ([#1377](https://github.com/agentclientprotocol/claude-agent-acp/issues/1377)) ([2a1525e](https://github.com/agentclientprotocol/claude-agent-acp/commit/2a1525e57cd89246d1a50993fd6ba335c4e634ca))
|
||||
* pass --settings before the auth status subcommand ([#1392](https://github.com/agentclientprotocol/claude-agent-acp/issues/1392)) ([6a33ded](https://github.com/agentclientprotocol/claude-agent-acp/commit/6a33dedcbb90e7bf9db91207f0edbf19ca6d705e))
|
||||
* preserve foreground fork reports until launch completes ([#1431](https://github.com/agentclientprotocol/claude-agent-acp/issues/1431)) ([e369479](https://github.com/agentclientprotocol/claude-agent-acp/commit/e369479f93b34f84a11103323d097c44df42e02e))
|
||||
* settle advisor tool results before completing turns ([#1247](https://github.com/agentclientprotocol/claude-agent-acp/issues/1247)) ([bed795d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bed795d261620e6d5113e427a7196d94e66e7034))
|
||||
* settle live steers folded into autonomous cycles ([#1185](https://github.com/agentclientprotocol/claude-agent-acp/issues/1185)) ([a4957d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/a4957d17c1e1a925ba3b72b29c5dcde4faabbe17))
|
||||
|
||||
## [0.96.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.95.0...v0.96.0) (2026-10-26)
|
||||
|
||||
|
||||
|
||||
32
README.md
32
README.md
@@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- Interactive (and background) terminals
|
||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
||||
- Client MCP servers
|
||||
- An Ultracode session config option, which turns on Claude Code's dynamic workflows for every task. It appears when dynamic workflows are enabled and the model supports ultracode (any model with `xhigh` effort)
|
||||
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
|
||||
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)
|
||||
@@ -35,6 +36,23 @@ To try changes that have landed on `main` but are not released yet, install from
|
||||
npm install @agentclientprotocol/claude-agent-acp@preview
|
||||
```
|
||||
|
||||
### Standalone executables
|
||||
|
||||
Each release attaches archives for Linux (glibc and musl), macOS and Windows, on
|
||||
x64 and arm64, to its [GitHub release](https://github.com/agentclientprotocol/claude-agent-acp/releases).
|
||||
They need no Node.js install. Each holds two files: `claude-agent-acp`, and the
|
||||
Claude CLI (`claude`) it runs. Keep them in the same directory; a symlink to
|
||||
`claude-agent-acp` elsewhere works. To use a Claude CLI you already have
|
||||
instead, set `CLAUDE_CODE_EXECUTABLE`. On Alpine, install `libgcc` and
|
||||
`libstdc++` first.
|
||||
|
||||
To build one for your machine (needs [Bun](https://bun.sh)) and check it:
|
||||
|
||||
```sh
|
||||
npm run build:binary
|
||||
npm run smoke:binary # add `-- --full` to drive a real session; needs a Claude login
|
||||
```
|
||||
|
||||
### Subagent sessions
|
||||
|
||||
A client that declares `clientCapabilities.subagents`, other than JetBrains AIR, gets each Agent or
|
||||
@@ -47,6 +65,14 @@ Task subagent as its own child session, following the [subagents RFD](https://gi
|
||||
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
|
||||
stop reason and the SDK's error text as its `error`. The child keeps its session for every
|
||||
delegation to it.
|
||||
- A child that runs outside its parent's working directory, such as an Agent call with
|
||||
`isolation: "worktree"`, gets its `cwd` on `subagent_update` before its first tool call. The
|
||||
adapter takes it from the `cwd` of the child's own hooks: its SubagentStart hook runs before its
|
||||
first model request. It sends `cwd: null` when the child is back in its parent's directory. A
|
||||
child in its parent's directory gets no `cwd`, and no child gets `additionalDirectories`: Claude
|
||||
Code gives a subagent the session's additional directories, so it inherits them. A replay
|
||||
announces a child with the `cwd` that its transcript recorded, and clears it after the response
|
||||
when that directory no longer exists.
|
||||
- Session messages replace the Agent or Task call that launches the child, which its parent is
|
||||
not shown:
|
||||
- The launch's prompt is a `session_message` in both transcripts: the parent's outgoing view and
|
||||
@@ -116,7 +142,11 @@ 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 and the AIR extensions are v1 only for now.
|
||||
[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.
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ keeps a single release PR open, titled `chore(main): release X.Y.Z` and labelled
|
||||
`autorelease: pending`.
|
||||
|
||||
Merging that PR is what releases. It tags `vX.Y.Z`, creates the GitHub release,
|
||||
publishes to npm, and dispatches a version update to the agent registry.
|
||||
publishes to npm, dispatches a version update to the agent registry, and
|
||||
attaches the [standalone executables](../README.md#standalone-executables) to
|
||||
the GitHub release. Previews get no executables.
|
||||
|
||||
There is no manual release button, and versions are never typed in by hand: the
|
||||
version is an output of the commit history, not an input.
|
||||
@@ -45,6 +47,9 @@ gh release view "v<version>"
|
||||
npm view "@agentclientprotocol/claude-agent-acp@<version>"
|
||||
```
|
||||
|
||||
The release should list 8 `claude-agent-acp-*` archives. They are attached a
|
||||
few minutes after the release is created, once every platform has built.
|
||||
|
||||
## Preview releases
|
||||
|
||||
Every push to `main` that is not a release merge publishes a preview from the
|
||||
@@ -203,6 +208,13 @@ npm versions are immutable. If the package already published and only the
|
||||
registry update failed, pass `-f publish_npm=false` so the run skips publishing
|
||||
and only re-dispatches the registry update.
|
||||
|
||||
### The release has no executables
|
||||
|
||||
`build-binaries` and `upload-binaries` don't gate npm, so a failed build only
|
||||
leaves the release without archives. Re-run the failed jobs of that publish run
|
||||
(`gh run rerun <run-id> --failed`); the upload uses `--clobber`, so archives
|
||||
already attached are replaced rather than duplicated.
|
||||
|
||||
### A preview published but the commit was not tagged
|
||||
|
||||
Only the publish is irreversible, so re-run just the tag job:
|
||||
|
||||
@@ -141,10 +141,19 @@ The `initialize` response of an AIR client carries the agent side of the extensi
|
||||
}
|
||||
```
|
||||
|
||||
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`.
|
||||
An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
|
||||
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`.
|
||||
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 |
|
||||
@@ -158,6 +167,7 @@ The response to a client that is not AIR has no `_meta.jetbrains` key.
|
||||
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
|
||||
| `backgroundSubagents` | yes | Ends a prompt turn at its result while background subagents still run. | [Background subagents](#background-subagents) |
|
||||
| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) |
|
||||
| `sessionBatchGet` | with `sessionIndex` | Supports `_session/batch_get` for metadata of known session IDs. | [Batch get](#batch-get) |
|
||||
| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) |
|
||||
| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) |
|
||||
| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) |
|
||||
@@ -514,6 +524,17 @@ While the adapter does not know the path, a report carries no `planFilePath` key
|
||||
The adapter never sends a blank path, because AIR reads a present non-file value as a retraction.
|
||||
Without `rawInputRendering`, a `planFile` client gets no copy of the plan in `content` either.
|
||||
|
||||
### Approved plan identity
|
||||
|
||||
For an AIR client with plan operations, the approved plan ID is `plan-mode:<tool-call ID>`.
|
||||
Claude uses the SDK tool-call ID of ExitPlanMode.
|
||||
Each review keeps its own plan. Repeated updates of that review keep the same plan ID.
|
||||
AIR joins the plan to its review by ID, even when the text includes user edits.
|
||||
This link needs no extra metadata.
|
||||
The live result, history replay, and clear-context approval use the same identity.
|
||||
The identity does not depend on `planFile`: it also applies when the request carries inline plan text.
|
||||
Other clients keep the `plan-mode` ID.
|
||||
|
||||
### Fallback
|
||||
|
||||
The plan text goes out as before in these cases:
|
||||
@@ -707,7 +728,7 @@ A non-empty plan also gets one clear-context choice with the same priority.
|
||||
After that choice, the adapter stops the old private Claude query and hides that internal rejection.
|
||||
It starts a new private query under the same ACP session.
|
||||
It continues the turn with `Implement the following plan:` and the complete plan.
|
||||
The mode, model, agent, effort, Fast mode, and turn usage stay the same.
|
||||
The mode, model, agent, effort, Fast mode, ultracode, and turn usage stay the same.
|
||||
The SDK cannot apply `allowedPrompts` again, so those hints do not survive the handoff.
|
||||
|
||||
The adapter does not allow a callback only because the session mode is `bypassPermissions`.
|
||||
@@ -1050,7 +1071,8 @@ The adapter ignores a malformed request and a repeated request id.
|
||||
|
||||
### Report
|
||||
|
||||
The adapter sends one `session_info_update` before the `PromptResponse`:
|
||||
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)):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1282,8 +1304,8 @@ The session index makes `session/list` a bounded, ordered page with row metadata
|
||||
|
||||
AIR declares `sessionIndex` in `_meta.jetbrains.air.capabilities`.
|
||||
Unlike the other capabilities, the agent advertises `sessionIndex` only to a client that declared it, and never under ACP v2.
|
||||
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)).
|
||||
The client does not declare these four; declaring them without `sessionIndex` enables nothing.
|
||||
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionBatchGet` (it supports `_session/batch_get`), `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)).
|
||||
The client does not declare these five; declaring them without `sessionIndex` enables nothing.
|
||||
Everything in this section applies only to such a client, except where [Delete and close](#delete-and-close) says otherwise.
|
||||
A client without the capability gets the `session/list` of before: the SDK `listSessions` call, pages of 1000 by file mtime, `offset:N` cursors, and no row `_meta`.
|
||||
It gets no new notification, and the adapter starts no watcher for it.
|
||||
@@ -1442,6 +1464,71 @@ In all of the session index methods and in `session/delete`, a session id matche
|
||||
Loading an archived session does not unarchive it.
|
||||
The CLI cleanup still deletes old transcripts (`cleanupPeriodDays`), archived or not.
|
||||
|
||||
### Batch get
|
||||
|
||||
`_session/batch_get` refreshes metadata for retained session IDs, such as pinned
|
||||
sessions and folder members outside the recent list page. Check for
|
||||
`sessionBatchGet` in `initialize.result._meta.jetbrains.air.capabilities` before
|
||||
calling it.
|
||||
|
||||
The params object must contain only `sessionIds`: an array of distinct,
|
||||
non-blank strings. IDs match exactly, without trimming or case normalization.
|
||||
Malformed params, duplicates or extra fields return `Invalid params` (`-32602`).
|
||||
The agent sets no limit on the number of IDs; the client chooses the batch size
|
||||
for the latency it accepts. Archived sessions and sessions in other projects
|
||||
or worktrees are eligible.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "_session/batch_get",
|
||||
"params": { "sessionIds": ["oldA", "oldB", "oldC"] }
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"result": {
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "oldA",
|
||||
"cwd": "/project",
|
||||
"title": "Fix the build",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"_meta": { "jetbrains": { "air": { "version": 1, "archived": false } } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The example IDs are placeholders for stored Claude session UUIDs. `sessions`
|
||||
contains the same full rows as `session/list` without a project filter, including
|
||||
archived sessions and `_meta.jetbrains.air`. Copies use the same
|
||||
newest-readable-copy selection as the list. Returned sessions preserve their
|
||||
relative request order. Non-UUID IDs and IDs without a listable transcript are
|
||||
omitted: absent, empty, unreadable or malformed transcripts, sidechains, sessions
|
||||
whose continuation has history, and transcripts whose cwd cannot be recovered.
|
||||
A sidecar alone does not supply a row.
|
||||
|
||||
`{ "sessionIds": [] }` returns `{ "sessions": [] }` without reading the store.
|
||||
A store-wide failure, such as an unreadable projects root, rejects the request.
|
||||
Individual results reflect the store as each ID is read.
|
||||
|
||||
Lookup normally addresses only the requested transcript filenames in each project
|
||||
and reads their metadata. A transcript without a cwd can use a sibling's cwd,
|
||||
as the list does. This fallback enumerates and stats sibling transcripts only
|
||||
in the directories of rows still missing a cwd, then reads at most 64 siblings
|
||||
per directory, newest mtime first. A failed recovery is cached for 60 seconds
|
||||
unless the directory mtime changes. Other project histories are not enumerated.
|
||||
The agent looks up 100 IDs at a time; the client may request any number of IDs.
|
||||
Merge returned rows with the recent page by `sessionId`. A batch read does not
|
||||
expand an existing list subscription's scope; refresh retained IDs outside that
|
||||
scope explicitly.
|
||||
|
||||
### List subscription
|
||||
|
||||
`_session/list/subscribe { "cwd": "…" }` returns `{ "subscriptionId": "…" }`.
|
||||
@@ -1465,6 +1552,7 @@ From then on the agent sends `_session/list/changes` notifications for the sessi
|
||||
|
||||
- `cwd` is required and must be an absolute path; a missing, non-string or relative `cwd` is `-32602`. Other parameters are ignored.
|
||||
- The scope is the sessions of `cwd` and of the same subdirectory in every existing linked worktree of its repository, as a list with `includeWorktrees: true` resolves it, in any archive state. Sidechain and subagent transcripts are left out, as in the list.
|
||||
- There is no page or recent-session limit: changes to older pinned or folder sessions within that scope are reported even outside the page AIR loaded. Pinned IDs outside the subscribed project and worktrees are not tracked; `_session/batch_get` does not add them to the subscription. AIR must subscribe to their project scope or refresh them explicitly.
|
||||
- `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom`, `agentWorktree` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included.
|
||||
- After subscribe, the first change of each session sends its row in full, even when only `updatedAt` changed. From then on, a change of `updatedAt` alone sends nothing; the new `updatedAt` comes with the next change.
|
||||
- `removed` holds the ids of sessions that left the scope: deleted (the transcript is gone), or moved to a path outside it. It may name a session the client does not list (one it never listed, one the list hides, such as a session continued in another one when that one changes); the client ignores such an id.
|
||||
@@ -1491,7 +1579,7 @@ It is idempotent, and an unknown id also returns `{}`.
|
||||
`session/delete`:
|
||||
|
||||
- a `sessionIndex` client: deletes every transcript of the session (empty ones too) and every `<sessionId>/` directory, also one left without a transcript by an earlier failed delete. Anything left behind fails the request. A session that runs here is closed first. As with the CLI, the session is deleted also when another Claude Code process has it open. An unknown session is `-32002`.
|
||||
- an AIR client without `sessionIndex`: archives the session instead of deleting it (the title records of [Archive](#archive)), because AIR uses delete for "Done" and can reopen the session. A session that runs here is closed first. The list hides archived sessions from such a client too: those whose custom title, as the SDK list reports it, is archived. A session without a transcript fails with the error of the SDK delete, as before.
|
||||
- an AIR client without `sessionIndex`: archives the session instead of deleting it (the title records of [Archive](#archive)), because AIR uses delete for "Done" and can reopen the session. A session that runs here is closed first. The list hides archived sessions from such a client too: those whose custom title, as the SDK list reports it, is archived. A session without a transcript has nothing to archive, so the delete succeeds, as ACP asks of a session that does not exist.
|
||||
A `sessionIndex` client sees such a session as archived (`archived: true`), as AIR's native Claude path does.
|
||||
- any other client: the SDK delete. A session without a transcript (unknown, deleted before, or
|
||||
never prompted) succeeds, as ACP asks.
|
||||
@@ -1517,6 +1605,7 @@ Names and semantics follow them; the transport differs, and so do the `archived`
|
||||
| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` |
|
||||
| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) |
|
||||
| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) |
|
||||
| capability `sessionBatchGet`, `_session/batch_get` | none |
|
||||
| `_meta.jetbrains.air.list.limit` | `session/list` `limit` |
|
||||
| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` |
|
||||
| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there |
|
||||
@@ -1559,7 +1648,8 @@ 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. ACP v2 keeps the hold, and the agent does not advertise the capability to ACP v2.
|
||||
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 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.
|
||||
@@ -1600,14 +1690,14 @@ For AIR, both forms carry `_meta.jetbrains.air.contextCompaction`:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| ------------ | --------------------------------------------------------------------- |
|
||||
| `version` | Must equal `1`. |
|
||||
| `trigger` | `manual` or `automatic`. |
|
||||
| `preTokens` | The context size before the compaction. |
|
||||
| `postTokens` | The context size after the compaction. |
|
||||
| `durationMs` | The duration of the compaction. |
|
||||
| `error` | The error of a failed compaction. Only the tool call form carries it. |
|
||||
| Field | Meaning |
|
||||
| ------------ | -------------------------------------------------------------------------------------------------------- |
|
||||
| `version` | Must equal `1`. |
|
||||
| `trigger` | `manual`, `automatic`, or `idle`: the adapter compacted an idle session before its prompt cache expired. |
|
||||
| `preTokens` | The context size before the compaction. |
|
||||
| `postTokens` | The context size after the compaction. |
|
||||
| `durationMs` | The duration of the compaction. |
|
||||
| `error` | The error of a failed compaction. Only the tool call form carries it. |
|
||||
|
||||
The standard `toolCallId` or `compactionId` and the `status` own the identity and the phase.
|
||||
The `compaction_update` form puts the error in the standard `error` field.
|
||||
|
||||
588
package-lock.json
generated
588
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.96.0",
|
||||
"version": "0.102.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -21,12 +21,15 @@
|
||||
"files": [
|
||||
"dist/",
|
||||
"!dist/tests/",
|
||||
"!dist/binary-entry.*",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build:binary": "node scripts/build-binary.mjs",
|
||||
"smoke:binary": "node scripts/smoke-binary.mjs",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "npm run build && npm run start",
|
||||
"example:simple-client": "node --experimental-strip-types --disable-warning=ExperimentalWarning examples/simple-client.ts",
|
||||
@@ -65,25 +68,25 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.0.2",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.310",
|
||||
"@agentclientprotocol/sdk": "2.5.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.321",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "1.3.0",
|
||||
"@anthropic-ai/sdk": "1.7.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.72.0",
|
||||
"@typescript-eslint/parser": "8.72.0",
|
||||
"@types/node": "26.6.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.72.2",
|
||||
"@typescript-eslint/parser": "8.72.2",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint": "10.14.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.13.0",
|
||||
"globals": "17.14.0",
|
||||
"prettier": "3.9.10",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.5"
|
||||
"vitest": "5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
92
scripts/build-binary.mjs
Normal file
92
scripts/build-binary.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds an executable of the agent with `bun build --compile` and copies the
|
||||
// target platform's Claude CLI next to it. Both files go in the release
|
||||
// archive: the executable runs the CLI from its own directory.
|
||||
//
|
||||
// node scripts/build-binary.mjs [--target bun-linux-x64-musl] [--outfile path]
|
||||
//
|
||||
// --target defaults to the host. Cross-compiling needs the target's SDK
|
||||
// platform package installed; the script prints the install command when it
|
||||
// is missing.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, copyFileSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
function hostTarget() {
|
||||
const os = process.platform === "win32" ? "windows" : process.platform;
|
||||
// Node linked against glibc reports its version; musl builds don't.
|
||||
const musl =
|
||||
process.platform === "linux" && !process.report.getReport().header.glibcVersionRuntime;
|
||||
return `bun-${os}-${process.arch}${musl ? "-musl" : ""}`;
|
||||
}
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
target: { type: "string", default: hostTarget() },
|
||||
outfile: { type: "string" },
|
||||
},
|
||||
});
|
||||
const target = values.target;
|
||||
|
||||
// Bun targets look like `bun-<os>-<arch>` with optional `-musl`, `-baseline` or
|
||||
// `-modern` suffixes; the SDK names its packages `<platform>-<arch>[-musl]`.
|
||||
const match = /^bun-(linux|darwin|windows)-(x64|arm64|aarch64)(?:-.+)?$/.exec(target);
|
||||
if (!match) {
|
||||
console.error(
|
||||
`Unsupported target "${target}". Expected e.g. bun-linux-x64, bun-darwin-arm64, bun-windows-x64.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const [, os, rawArch] = match;
|
||||
const platform = os === "windows" ? "win32" : os;
|
||||
const arch = rawArch === "aarch64" ? "arm64" : rawArch;
|
||||
const musl = target.split("-").includes("musl");
|
||||
const exe = os === "windows" ? ".exe" : "";
|
||||
const cliPackage = `@anthropic-ai/claude-agent-sdk-${platform}-${arch}${musl ? "-musl" : ""}`;
|
||||
const cliSpecifier = `${cliPackage}/claude${exe}`;
|
||||
const outfile = resolve(values.outfile ?? join(repoRoot, "dist-bin", `claude-agent-acp${exe}`));
|
||||
|
||||
// Resolve from the SDK, the way claudeCliPath() does at runtime, so a nested
|
||||
// install is checked rather than only a hoisted one.
|
||||
const sdkRequire = createRequire(
|
||||
createRequire(join(repoRoot, "package.json")).resolve("@anthropic-ai/claude-agent-sdk"),
|
||||
);
|
||||
let cliPath;
|
||||
try {
|
||||
cliPath = sdkRequire.resolve(cliSpecifier);
|
||||
} catch {
|
||||
const sdkVersion = JSON.parse(
|
||||
readFileSync(
|
||||
join(dirname(sdkRequire.resolve("@anthropic-ai/claude-agent-sdk")), "package.json"),
|
||||
"utf8",
|
||||
),
|
||||
).version;
|
||||
console.error(
|
||||
`${cliSpecifier} is not installed. npm only installs the host's platform package; for ${target} run:\n\n` +
|
||||
` npm install --no-save --force ${cliPackage}@${sdkVersion}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bun = spawnSync(
|
||||
"bun",
|
||||
["build", "src/binary-entry.ts", "--compile", "--target", target, "--outfile", outfile],
|
||||
{ cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32" },
|
||||
);
|
||||
if (bun.error) {
|
||||
console.error(`Could not run bun: ${bun.error.message}. Install it from https://bun.sh.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (bun.status !== 0) process.exit(bun.status ?? 1);
|
||||
|
||||
// The executable runs the CLI from its own directory (src/binary-entry.ts).
|
||||
const cliOutfile = join(dirname(outfile), `claude${exe}`);
|
||||
copyFileSync(cliPath, cliOutfile);
|
||||
chmodSync(cliOutfile, 0o755);
|
||||
console.log(`Copied ${cliSpecifier} to ${cliOutfile}`);
|
||||
470
scripts/smoke-binary.mjs
Normal file
470
scripts/smoke-binary.mjs
Normal file
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env node
|
||||
// Smoke-tests a built agent over ACP. Defaults to the executable from
|
||||
// scripts/build-binary.mjs, with `claude` next to it. Pass another command
|
||||
// after `--` to compare, e.g.
|
||||
// `node scripts/smoke-binary.mjs --full -- node "$PWD/dist/index.js"` (the full
|
||||
// run starts the agent in a scratch directory, so paths must be absolute).
|
||||
//
|
||||
// node scripts/smoke-binary.mjs # no login needed
|
||||
// node scripts/smoke-binary.mjs --full # talks to the model; needs a logged-in Claude
|
||||
//
|
||||
// SMOKE_DEBUG=1 prints every tool call the full run saw.
|
||||
//
|
||||
// The basic run checks `--version`, `--cli --version`, `initialize` (v1 and
|
||||
// v2) and the `terminal-auth` login command. The
|
||||
// full run also has Claude use Bash, Read (text and image), Edit and a
|
||||
// subagent in a scratch directory, then cancels a turn, lists the session,
|
||||
// loads it in a fresh agent process, and forks and deletes it.
|
||||
//
|
||||
// Claude Code's native builds have no Grep or Glob tool: its Bash shell wraps
|
||||
// `grep` and `find` in functions that run the ugrep and bfs built into the
|
||||
// Claude binary at `$CLAUDE_CODE_EXECPATH`. The full run checks that those
|
||||
// run, since they are the helpers a packaged build could fail to reach.
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { deflateSync } from "node:zlib";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const { values, positionals } = parseArgs({
|
||||
options: { full: { type: "boolean", default: false } },
|
||||
allowPositionals: true,
|
||||
});
|
||||
const exe = process.platform === "win32" ? ".exe" : "";
|
||||
const [command, ...commandArgs] =
|
||||
positionals.length > 0 ? positionals : [join(repoRoot, "dist-bin", `claude-agent-acp${exe}`)];
|
||||
|
||||
let failures = 0;
|
||||
function check(ok, label, detail) {
|
||||
console.log(
|
||||
`${ok ? "ok " : "FAIL"} ${label}${!ok && detail !== undefined ? `: ${detail}` : ""}`,
|
||||
);
|
||||
if (!ok) failures++;
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- basic
|
||||
|
||||
function runSync(args, [cmd, ...cmdArgs] = [command, ...commandArgs]) {
|
||||
const result = spawnSync(cmd, [...cmdArgs, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
return { stdout: (result.stdout ?? "").trim(), error: result.error ?? result.stderr };
|
||||
}
|
||||
|
||||
const version = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version;
|
||||
const own = runSync(["--version"]);
|
||||
check(own.stdout === version, `--version prints ${version}`, own.stdout || own.error);
|
||||
const cli = runSync(["--cli", "--version"]);
|
||||
check(
|
||||
/\(Claude Code\)$/.test(cli.stdout),
|
||||
"--cli --version runs the bundled Claude CLI",
|
||||
cli.stdout || cli.error,
|
||||
);
|
||||
|
||||
// The release archive's layout: the executable finds `claude` in the directory
|
||||
// of its real path. Only for the default executable; a `--` command has no
|
||||
// such layout.
|
||||
if (positionals.length === 0) {
|
||||
const scratch = mkdtempSync(join(tmpdir(), "acp-layout-"));
|
||||
try {
|
||||
if (process.platform !== "win32") {
|
||||
const link = join(scratch, "claude-agent-acp");
|
||||
symlinkSync(command, link);
|
||||
const linked = runSync(["--cli", "--version"], [link]);
|
||||
check(
|
||||
/\(Claude Code\)$/.test(linked.stdout),
|
||||
"a symlink to the executable finds the bundled CLI",
|
||||
linked.stdout || linked.error,
|
||||
);
|
||||
rmSync(link);
|
||||
}
|
||||
const alone = join(scratch, `claude-agent-acp${exe}`);
|
||||
copyFileSync(command, alone);
|
||||
const missing = runSync(["--cli", "--version"], [alone]);
|
||||
check(
|
||||
String(missing.error).includes("The Claude CLI is missing"),
|
||||
"without claude next to it, says the CLI is missing",
|
||||
missing.stdout || missing.error,
|
||||
);
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the agent and speaks newline-delimited JSON-RPC to it. */
|
||||
function startAgent(cwd, onNotification) {
|
||||
const child = spawn(command, commandArgs, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
||||
const stderr = [];
|
||||
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
const write = (message) =>
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n");
|
||||
readline.createInterface({ input: child.stdout }).on("line", (line) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
check(false, "stdout carries only JSON-RPC", line.slice(0, 200));
|
||||
return;
|
||||
}
|
||||
if (message.method === undefined && pending.has(message.id)) {
|
||||
pending.get(message.id)(message);
|
||||
pending.delete(message.id);
|
||||
} else if (message.method === "session/request_permission") {
|
||||
const allow =
|
||||
message.params.options.find((o) => o.kind === "allow_once") ?? message.params.options[0];
|
||||
write({
|
||||
id: message.id,
|
||||
result: { outcome: { outcome: "selected", optionId: allow.optionId } },
|
||||
});
|
||||
} else if (message.id !== undefined && message.method !== undefined) {
|
||||
write({
|
||||
id: message.id,
|
||||
error: { code: -32601, message: `smoke client does not handle ${message.method}` },
|
||||
});
|
||||
} else {
|
||||
onNotification?.(message);
|
||||
}
|
||||
});
|
||||
const exited = new Promise((resolve) => child.on("exit", resolve));
|
||||
// Answer anything still pending if the agent dies, instead of hanging.
|
||||
let exitError;
|
||||
exited.then((code) => {
|
||||
exitError = { message: `agent exited with ${code}: ${Buffer.concat(stderr)}` };
|
||||
for (const resolve of pending.values()) resolve({ error: exitError });
|
||||
pending.clear();
|
||||
});
|
||||
return {
|
||||
request(method, params) {
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve) => {
|
||||
if (exitError) return resolve({ error: exitError });
|
||||
pending.set(id, resolve);
|
||||
write({ id, method, params });
|
||||
});
|
||||
},
|
||||
notify: (method, params) => write({ method, params }),
|
||||
async stop() {
|
||||
child.stdin.end();
|
||||
const code = await Promise.race([
|
||||
exited,
|
||||
new Promise((r) => setTimeout(() => r("timeout"), 10_000)),
|
||||
]);
|
||||
if (code === "timeout") child.kill();
|
||||
return { code, stderr: Buffer.concat(stderr).toString() };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const initializeParams = { protocolVersion: 1, clientCapabilities: {} };
|
||||
{
|
||||
const agent = startAgent(process.cwd());
|
||||
const init = await agent.request("initialize", initializeParams);
|
||||
check(
|
||||
init.result?.agentInfo?.version === version,
|
||||
"initialize answers",
|
||||
JSON.stringify(init.error),
|
||||
);
|
||||
const { code } = await agent.stop();
|
||||
check(code === 0, "exits 0 when stdin closes", code);
|
||||
}
|
||||
{
|
||||
// The `terminal-auth` login command reruns the agent; run it with
|
||||
// `--version` in place of `auth login --console` instead of logging in.
|
||||
// `--version` wins over a stray argument, so also check that no path inside
|
||||
// a compiled executable leaked into the arguments.
|
||||
const agent = startAgent(process.cwd());
|
||||
const init = await agent.request("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { "terminal-auth": true } },
|
||||
});
|
||||
await agent.stop();
|
||||
const login = init.result?.authMethods?.find((m) => m.id === "console-login")?._meta?.[
|
||||
"terminal-auth"
|
||||
];
|
||||
const cliArgs = login?.args?.slice(0, login.args.indexOf("--cli") + 1);
|
||||
const rerun = login
|
||||
? runSync([...cliArgs, "--version"], [login.command])
|
||||
: { stdout: "", error: "no terminal-auth console-login method" };
|
||||
check(
|
||||
/\(Claude Code\)$/.test(rerun.stdout) &&
|
||||
!login.args.some((arg) => arg.includes("$bunfs") || arg.includes("~BUN")),
|
||||
"the terminal-auth login command reruns the agent",
|
||||
`${JSON.stringify(login)} -> ${rerun.stdout || rerun.error}`,
|
||||
);
|
||||
}
|
||||
{
|
||||
const agent = startAgent(process.cwd());
|
||||
const init = await agent.request("initialize", {
|
||||
protocolVersion: 2,
|
||||
info: { name: "smoke-binary", version },
|
||||
capabilities: {},
|
||||
});
|
||||
check(
|
||||
init.result?.protocolVersion === 2,
|
||||
"initialize answers ACP v2",
|
||||
JSON.stringify(init.error),
|
||||
);
|
||||
await agent.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- full
|
||||
|
||||
if (values.full) {
|
||||
const work = mkdtempSync(join(tmpdir(), "acp-smoke-"));
|
||||
try {
|
||||
await fullSmoke(work);
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** A solid red 16x16 PNG, so the model has something to name. */
|
||||
function redPng() {
|
||||
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
const crc = (buf) => {
|
||||
let c = 0xffffffff;
|
||||
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
};
|
||||
const chunk = (type, data) => {
|
||||
const body = Buffer.concat([Buffer.from(type), data]);
|
||||
const out = Buffer.alloc(body.length + 8);
|
||||
out.writeUInt32BE(data.length, 0);
|
||||
body.copy(out, 4);
|
||||
out.writeUInt32BE(crc(body), body.length + 4);
|
||||
return out;
|
||||
};
|
||||
const size = 16;
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(size, 0);
|
||||
header.writeUInt32BE(size, 4);
|
||||
header[8] = 8; // bit depth
|
||||
header[9] = 2; // RGB
|
||||
const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(size * 3, Buffer.from([255, 0, 0]))]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk("IHDR", header),
|
||||
chunk("IDAT", deflateSync(Buffer.concat(Array(size).fill(row)))),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Writes its results to files so the checks don't depend on the model's reply. */
|
||||
function searchCommand(token) {
|
||||
return [
|
||||
`grep -rl "${token}" . > grep.out`,
|
||||
'find . -name "*.md" > find.out',
|
||||
'{ echo "$CLAUDE_CODE_EXECPATH"; grep --version | head -1; find --version | head -1; } > tools.out',
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function searchResults(work) {
|
||||
const read = (name) => {
|
||||
try {
|
||||
return readFileSync(join(work, name), "utf8");
|
||||
} catch (error) {
|
||||
return String(error);
|
||||
}
|
||||
};
|
||||
const [execPath = "", grepVersion = "", findVersion = ""] = read("tools.out").split("\n");
|
||||
return { grep: read("grep.out"), find: read("find.out"), execPath, grepVersion, findVersion };
|
||||
}
|
||||
|
||||
async function fullSmoke(work) {
|
||||
const token = `SMOKE_TOKEN_${process.pid}`;
|
||||
mkdirSync(join(work, "notes"));
|
||||
mkdirSync(join(work, "src"));
|
||||
writeFileSync(join(work, "notes", "alpha.md"), `# Alpha\n\nThe token is ${token}.\n`);
|
||||
writeFileSync(join(work, "notes", "beta.md"), "# Beta\n\nNothing here.\n");
|
||||
writeFileSync(join(work, "src", "gamma.ts"), `export const value = "${token}";\n`);
|
||||
writeFileSync(join(work, "pixel.png"), redPng());
|
||||
|
||||
const toolCalls = new Map();
|
||||
let text = "";
|
||||
const onNotification = (message) => {
|
||||
if (message.method !== "session/update") return;
|
||||
const update = message.params.update;
|
||||
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
||||
text += update.content.text;
|
||||
} else if (
|
||||
update.sessionUpdate === "tool_call" ||
|
||||
update.sessionUpdate === "tool_call_update"
|
||||
) {
|
||||
const call = toolCalls.get(update.toolCallId) ?? {};
|
||||
toolCalls.set(update.toolCallId, {
|
||||
...call,
|
||||
name: update._meta?.claudeCode?.toolName ?? call.name,
|
||||
status: update.status ?? call.status,
|
||||
title: update.title ?? call.title,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const agent = startAgent(work, onNotification);
|
||||
await agent.request("initialize", initializeParams);
|
||||
const created = await agent.request("session/new", { cwd: work, mcpServers: [] });
|
||||
if (!check(created.result?.sessionId, "session/new", JSON.stringify(created.error))) {
|
||||
console.error((await agent.stop()).stderr);
|
||||
return;
|
||||
}
|
||||
const sessionId = created.result.sessionId;
|
||||
|
||||
const started = Date.now();
|
||||
const prompt = await agent.request("session/prompt", {
|
||||
sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"This is an automated smoke test of your tools. Do each step with the named tool, in order, one tool call per step:",
|
||||
`1. Bash: run exactly \`${searchCommand(token)}\``,
|
||||
"2. Read: read notes/alpha.md.",
|
||||
"3. Read: read pixel.png and note its color.",
|
||||
`4. Edit: in notes/alpha.md replace "${token}" with "EDITED".`,
|
||||
"5. Bash: run `cat notes/alpha.md`.",
|
||||
'6. Agent: launch a subagent (subagent_type "Explore") that counts the .ts files under src/ and reports the number.',
|
||||
"Then reply with one line: SMOKE-DONE color=<the color of pixel.png> tsfiles=<the subagent's count>.",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
check(
|
||||
prompt.result?.stopReason === "end_turn",
|
||||
"prompt ends with end_turn",
|
||||
JSON.stringify(prompt.result ?? prompt.error),
|
||||
);
|
||||
console.log(` (turn took ${Math.round((Date.now() - started) / 1000)}s)`);
|
||||
|
||||
const calls = [...toolCalls.values()];
|
||||
if (process.env.SMOKE_DEBUG) console.log(JSON.stringify(calls, null, 1));
|
||||
const searched = searchResults(work);
|
||||
check(
|
||||
searched.grep.includes("alpha.md") && searched.grep.includes("gamma.ts"),
|
||||
"grep found the token",
|
||||
searched.grep,
|
||||
);
|
||||
check(
|
||||
searched.find.includes("alpha.md") && searched.find.includes("beta.md"),
|
||||
"find listed the .md files",
|
||||
searched.find,
|
||||
);
|
||||
check(
|
||||
/^ugrep /.test(searched.grepVersion),
|
||||
"grep ran Claude's embedded ugrep",
|
||||
searched.grepVersion,
|
||||
);
|
||||
check(/^bfs /.test(searched.findVersion), "find ran Claude's embedded bfs", searched.findVersion);
|
||||
console.log(` (CLAUDE_CODE_EXECPATH=${searched.execPath})`);
|
||||
for (const name of ["Read", "Edit", "Bash"]) {
|
||||
const used = calls.filter((c) => c.name === name);
|
||||
check(
|
||||
used.length > 0 && used.every((c) => c.status === "completed"),
|
||||
`${name} ran and completed`,
|
||||
JSON.stringify(used),
|
||||
);
|
||||
}
|
||||
const subagent = calls.filter((c) => c.name === "Agent" || c.name === "Task");
|
||||
check(
|
||||
subagent.some((c) => c.status === "completed"),
|
||||
"subagent ran and completed",
|
||||
JSON.stringify(subagent),
|
||||
);
|
||||
const failed = calls.filter((c) => c.status === "failed");
|
||||
check(failed.length === 0, "no tool call failed", JSON.stringify(failed));
|
||||
check(
|
||||
readFileSync(join(work, "notes", "alpha.md"), "utf8").includes("EDITED"),
|
||||
"Edit changed the file on disk",
|
||||
);
|
||||
check(
|
||||
/SMOKE-DONE/.test(text) && /color=\W*red/i.test(text),
|
||||
"image read as red",
|
||||
text.slice(-300),
|
||||
);
|
||||
check(/tsfiles=\W*1\b/.test(text), "subagent counted 1 .ts file", text.slice(-300));
|
||||
|
||||
// A cancel interrupts a running Bash command and ends the turn as cancelled.
|
||||
const cancelled = agent.request("session/prompt", {
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: "text", text: "Use the Bash tool to run `sleep 60`. Do not run anything else." },
|
||||
],
|
||||
});
|
||||
const deadline = Date.now() + 60_000;
|
||||
const bashRunning = () =>
|
||||
[...toolCalls.values()].some((c) => c.name === "Bash" && c.status !== "completed");
|
||||
while (!bashRunning() && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
agent.notify("session/cancel", { sessionId });
|
||||
const cancelResult = await cancelled;
|
||||
check(
|
||||
cancelResult.result?.stopReason === "cancelled",
|
||||
"session/cancel ends the turn as cancelled",
|
||||
JSON.stringify(cancelResult.result ?? cancelResult.error),
|
||||
);
|
||||
|
||||
const listed = await agent.request("session/list", { cwd: work });
|
||||
check(
|
||||
listed.result?.sessions?.some((s) => s.sessionId === sessionId),
|
||||
"session/list finds the session",
|
||||
JSON.stringify(listed.result ?? listed.error),
|
||||
);
|
||||
const { code, stderr } = await agent.stop();
|
||||
check(code === 0, "exits 0 when stdin closes", code);
|
||||
if (failures > 0) console.error(stderr);
|
||||
|
||||
// A fresh process, so the replay comes from the transcript on disk.
|
||||
let replayed = "";
|
||||
const reloaded = startAgent(work, (message) => {
|
||||
const update = message.params?.update;
|
||||
if (update?.sessionUpdate === "user_message_chunk" && update.content.type === "text")
|
||||
replayed += update.content.text;
|
||||
});
|
||||
await reloaded.request("initialize", initializeParams);
|
||||
const loaded = await reloaded.request("session/load", { sessionId, cwd: work, mcpServers: [] });
|
||||
check(!loaded.error, "session/load in a new process", JSON.stringify(loaded.error));
|
||||
check(replayed.includes(token), "session/load replays the first prompt", replayed.slice(0, 200));
|
||||
|
||||
// Fork and delete go through the SDK's transcript helpers, not the CLI.
|
||||
const forked = await reloaded.request("session/fork", { sessionId, cwd: work, mcpServers: [] });
|
||||
const forkId = forked.result?.sessionId;
|
||||
check(
|
||||
forkId && forkId !== sessionId,
|
||||
"session/fork",
|
||||
JSON.stringify(forked.result ?? forked.error),
|
||||
);
|
||||
if (forkId) {
|
||||
const deleted = await reloaded.request("session/delete", { sessionId: forkId });
|
||||
const after = await reloaded.request("session/list", { cwd: work });
|
||||
check(
|
||||
!deleted.error && !after.result?.sessions?.some((s) => s.sessionId === forkId),
|
||||
"session/delete removes the fork",
|
||||
JSON.stringify(deleted.error ?? after.result),
|
||||
);
|
||||
}
|
||||
await reloaded.stop();
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\nsmoke test passed" : `\n${failures} check(s) failed`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
5605
src/acp-agent.ts
5605
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
43
src/acp-client.ts
Normal file
43
src/acp-client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification } from "./acp-subagents.js";
|
||||
import type { AcpPermissionRequest } from "./permissions/presentation.js";
|
||||
|
||||
/**
|
||||
* 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` (`acp-agent.ts`) over the SDK's typed `AgentContext`.
|
||||
* The v2 session output passes one that only takes `sessionUpdate`
|
||||
* (`v2/stream.ts`).
|
||||
*/
|
||||
export interface AcpClient {
|
||||
sessionUpdate(params: AcpSessionNotification): Promise<void>;
|
||||
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
|
||||
* permission request so the client can dismiss its prompt, and rejects the
|
||||
* call at once: a client that ignores the cancel never answers. */
|
||||
requestPermission(
|
||||
params: AcpPermissionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestPermissionResponse>;
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
|
||||
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
|
||||
* elicitation so the client can dismiss its prompt, and rejects the call
|
||||
* at once, as {@link requestPermission} does. */
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse>;
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
@@ -17,8 +17,7 @@ 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. (An ACP v2 client gets it without declaring anything, since subagents
|
||||
* are part of v2's baseline.)
|
||||
* it.
|
||||
* - `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`).
|
||||
|
||||
19
src/agent-command.ts
Normal file
19
src/agent-command.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The command that starts this agent again with the flags it was started with,
|
||||
* for a client to run in a terminal (e.g. `terminal-auth` login).
|
||||
*
|
||||
* Under Node, `process.argv` is `[node, script, ...flags]`, so the command is
|
||||
* `node script ...flags`. In a `bun build --compile` executable it is
|
||||
* `["bun", "/$bunfs/root/<name>", ...flags]` (`B:\~BUN\root\<name>` on
|
||||
* Windows): `process.execPath` is the executable itself and `argv[1]` names a
|
||||
* file inside it, which must not be passed back as an argument.
|
||||
*/
|
||||
export function agentCommand(argv: readonly string[] = process.argv): {
|
||||
command: string;
|
||||
args: string[];
|
||||
} {
|
||||
const [, script, ...flags] = argv;
|
||||
if (script === undefined) return { command: process.execPath, args: [] };
|
||||
const compiled = script.includes("$bunfs") || script.includes("~BUN");
|
||||
return { command: process.execPath, args: compiled ? flags : [script, ...flags] };
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const AIR_PLAN_FILE_CAPABILITY = "planFile";
|
||||
* subscription. Advertised only to a client that declares it.
|
||||
*/
|
||||
export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex";
|
||||
/** Metadata lookup of known ids with `_session/batch_get`. Advertised
|
||||
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is negotiated. */
|
||||
export const AIR_SESSION_BATCH_GET_CAPABILITY = "sessionBatchGet";
|
||||
/**
|
||||
* The agent supports `_session/archive` and `_session/unarchive`. Advertised
|
||||
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not
|
||||
|
||||
@@ -15,6 +15,8 @@ type AsyncTask = {
|
||||
taskType: string;
|
||||
description: string;
|
||||
showInTranscript: boolean;
|
||||
/** A Monitor tool call or an SDK `local_monitor` task: published as a monitor the client cannot stop. */
|
||||
monitor: boolean;
|
||||
outputFilePath?: string;
|
||||
toolCallId?: string;
|
||||
/**
|
||||
@@ -103,6 +105,28 @@ 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;
|
||||
@@ -142,7 +166,8 @@ export class AsyncTaskRuntime {
|
||||
* the tasks that a tool call in another session started, for example in
|
||||
* a native subagent session. `toolNameOf`: the name of the tool of a tool
|
||||
* call, or `undefined` when the tool call is unknown. A task that a
|
||||
* `Monitor` tool call started is ignored. */
|
||||
* `Monitor` tool call started is published as a monitor: the SDK reports
|
||||
* it as `local_bash`, so the adapter names it a monitor. */
|
||||
private readonly options: {
|
||||
notices?: boolean;
|
||||
routeOf?: (toolCallId: string) => Route | undefined;
|
||||
@@ -155,7 +180,7 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
if (isSubagentTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
@@ -186,7 +211,7 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
if (isSubagentTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
@@ -264,15 +289,8 @@ 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 = {
|
||||
...(description ? { description } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(lastToolName ? { lastToolName } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...taskProgressFields(message),
|
||||
...(outputChanged && task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId !== previousToolCallId && task.toolCallId
|
||||
? { toolCallId: task.toolCallId }
|
||||
@@ -355,7 +373,7 @@ export class AsyncTaskRuntime {
|
||||
const task = this.task(taskId);
|
||||
if (task.ignored || isTerminal(task.state)) continue;
|
||||
const levelTaskType = field(item, "taskType", "task_type");
|
||||
if (levelTaskType === "local_agent" || levelTaskType === "local_monitor") {
|
||||
if (levelTaskType === "local_agent") {
|
||||
this.ignore(task);
|
||||
continue;
|
||||
}
|
||||
@@ -408,7 +426,13 @@ export class AsyncTaskRuntime {
|
||||
|
||||
canStop(taskId: string): boolean {
|
||||
const task = this.tasks.get(taskId);
|
||||
return task?.announced === true && !task.ignored && !task.stopping && !isTerminal(task.state);
|
||||
return (
|
||||
task?.announced === true &&
|
||||
!task.monitor &&
|
||||
!task.ignored &&
|
||||
!task.stopping &&
|
||||
!isTerminal(task.state)
|
||||
);
|
||||
}
|
||||
|
||||
claimStop(taskId: string): boolean {
|
||||
@@ -575,6 +599,7 @@ export class AsyncTaskRuntime {
|
||||
taskType: "task",
|
||||
description: "Background task",
|
||||
showInTranscript: true,
|
||||
monitor: false,
|
||||
announced: false,
|
||||
held: false,
|
||||
deferred: [],
|
||||
@@ -595,6 +620,7 @@ export class AsyncTaskRuntime {
|
||||
task.startedObserved = true;
|
||||
const rawTaskType = field(message, "taskType", "task_type");
|
||||
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
|
||||
if (rawTaskType === "local_monitor") task.monitor = true;
|
||||
const description = nonBlankString(message.description);
|
||||
if (description) task.description = description;
|
||||
else if (task.description === "Background task")
|
||||
@@ -652,6 +678,7 @@ export class AsyncTaskRuntime {
|
||||
private mergeLevel(task: AsyncTask, level: BackgroundTaskLevel): void {
|
||||
const rawTaskType = field(level, "taskType", "task_type");
|
||||
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
|
||||
if (rawTaskType === "local_monitor") task.monitor = true;
|
||||
const description = nonBlankString(level.description);
|
||||
if (description) {
|
||||
task.description = description;
|
||||
@@ -676,25 +703,29 @@ export class AsyncTaskRuntime {
|
||||
* ends first, or when the prompt result ends the model turn
|
||||
* ({@link releaseHeld}). No guess by the command text binds a task.
|
||||
*
|
||||
* A task that a `Monitor` tool call started is ignored: Monitor streams its
|
||||
* output to the model only, so the task has nothing for the client. The
|
||||
* tool_use streams before the tool runs, so the tool name is known when the
|
||||
* spawn has the tool call id.
|
||||
* A task that a `Monitor` tool call started is published as a monitor. The
|
||||
* SDK reports it as `local_bash`, so the adapter names it a monitor instead of
|
||||
* a shell. It has no transcript card because Monitor streams its output to
|
||||
* the model only, and no Stop because stopping it only ends the agent's wait
|
||||
* (the agent's own TaskStop still works). The tool_use streams before the
|
||||
* tool runs, so the tool name is known when the spawn has the tool call id.
|
||||
*/
|
||||
private async announce(
|
||||
task: AsyncTask,
|
||||
options: { withoutToolCall?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (task.announced || task.ignored) return;
|
||||
const toolName = task.toolCallId ? this.options.toolNameOf?.(task.toolCallId) : undefined;
|
||||
if (toolName !== undefined && HIDDEN_TASK_TOOLS.has(toolName)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
if (!task.toolCallId && !options.withoutToolCall && !isTerminal(task.state)) {
|
||||
task.held = true;
|
||||
return;
|
||||
}
|
||||
if (task.toolCallId && this.options.toolNameOf?.(task.toolCallId) === "Monitor") {
|
||||
task.monitor = true;
|
||||
}
|
||||
if (task.monitor) {
|
||||
task.taskType = "monitor";
|
||||
task.showInTranscript = false;
|
||||
}
|
||||
if (task.toolCallId) task.route ??= this.options.routeOf?.(task.toolCallId);
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_spawned",
|
||||
@@ -703,7 +734,7 @@ export class AsyncTaskRuntime {
|
||||
taskType: task.taskType,
|
||||
description: task.description,
|
||||
showInTranscript: task.showInTranscript,
|
||||
canStop: true,
|
||||
canStop: !task.monitor,
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
});
|
||||
@@ -847,18 +878,29 @@ function recordPublished(task: AsyncTask, fields: Record<string, unknown>): void
|
||||
* Recovers a background Bash task from the structured `backgroundTaskId` of
|
||||
* its tool result. The text of a tool result never gives a task id: a
|
||||
* foreground command can print any text.
|
||||
*
|
||||
* Claude Code sends no `tool_use_result` with a subagent's tool results. For
|
||||
* those, `startedTaskOf` gives the background `local_bash` task whose
|
||||
* `task_started` named the Bash call, which comes before the result.
|
||||
*/
|
||||
export function backgroundBashTaskFromToolResult(
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
startedTaskOf?: (toolUseId: string) => string | undefined,
|
||||
): (AsyncTaskStarted & { taskId: string; toolCallId: string }) | undefined {
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const toolResults = content.flatMap(toolResultBlock);
|
||||
const bashResults = toolResults.filter((result) => toolUses[result.toolUseId]?.name === "Bash");
|
||||
if (bashResults.length === 0) return undefined;
|
||||
|
||||
const backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
|
||||
let backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
|
||||
if (backgroundResults.length === 0 && startedTaskOf) {
|
||||
backgroundResults = bashResults.flatMap((result) => {
|
||||
const taskId = result.isError ? undefined : startedTaskOf(result.toolUseId);
|
||||
return taskId ? [{ taskId, toolUseId: result.toolUseId }] : [];
|
||||
});
|
||||
}
|
||||
const distinctTaskIds = new Set(backgroundResults.map((result) => result.taskId));
|
||||
if (distinctTaskIds.size !== 1) return undefined;
|
||||
const taskId = [...distinctTaskIds][0];
|
||||
@@ -1051,13 +1093,6 @@ function isBackgroundTask(isBackgrounded: unknown, taskType: unknown): boolean {
|
||||
return taskType !== "local_bash" && taskType !== "local_agent";
|
||||
}
|
||||
|
||||
/** The tools whose async tasks the client never sees. */
|
||||
const HIDDEN_TASK_TOOLS = new Set(["Monitor"]);
|
||||
|
||||
function isMonitorTask(message: AsyncTaskStarted): boolean {
|
||||
return field(message, "taskType", "task_type") === "local_monitor";
|
||||
}
|
||||
|
||||
function isSubagentTask(message: AsyncTaskStarted): boolean {
|
||||
return (
|
||||
field(message, "subagentType", "subagent_type") !== undefined ||
|
||||
|
||||
88
src/auth-methods.ts
Normal file
88
src/auth-methods.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
}
|
||||
309
src/auth-status-reporter.ts
Normal file
309
src/auth-status-reporter.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,15 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
|
||||
if (!externalBackend && !status.loggedIn && !status.apiKeySource) {
|
||||
return notLoggedInAuthStatus();
|
||||
}
|
||||
if (
|
||||
status.loggedIn &&
|
||||
status.authMethod === "oauth_token" &&
|
||||
status.apiProvider === "firstParty" &&
|
||||
!status.apiKeySource &&
|
||||
!status.subscriptionType
|
||||
) {
|
||||
return { kind: "account", label: "Claude subscription", detail: "OAuth token" };
|
||||
}
|
||||
return mapAuthFields({
|
||||
apiProvider: status.apiProvider,
|
||||
subscriptionType: status.subscriptionType,
|
||||
|
||||
131
src/background-output.ts
Normal file
131
src/background-output.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The output of a command that went to the background, read from the file
|
||||
* that Claude Code writes it to. Claude Code streams no output of such a
|
||||
* command, so its display terminal would show only the placeholder of the
|
||||
* Bash result ("Command running in background with ID …") until the command
|
||||
* exits. ACP v2 asks an Agent to keep a listed terminal's output current
|
||||
* whenever it can read it (the v2 prompt lifecycle, "Background work").
|
||||
*
|
||||
* The file is polled: watching a file is not reliable on every platform, and
|
||||
* a missing file only means that nothing was written yet.
|
||||
*/
|
||||
import { open } from "node:fs/promises";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
|
||||
/** How often a running command's output file is read. */
|
||||
export const BACKGROUND_OUTPUT_POLL_MS = 500;
|
||||
|
||||
/** The most bytes one read takes, so a burst of output does not block the loop. */
|
||||
export const BACKGROUND_OUTPUT_READ_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* The most unread bytes that are still sent. A command that writes faster
|
||||
* than the polls read falls behind. Past this, the reads skip to the file's
|
||||
* last bytes, as a snapshot: a terminal shows the latest output, and a
|
||||
* client keeps a bounded amount of it anyway.
|
||||
*/
|
||||
export const BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Text that the file adds. A snapshot replaces the terminal's output so far:
|
||||
* the first one replaces the placeholder of the Bash result, and a later one
|
||||
* follows a skip or a file that shrank. Other text appends to it.
|
||||
*/
|
||||
export type BackgroundOutput = { text: string; replaces: boolean };
|
||||
|
||||
/**
|
||||
* Follows the output file `path` of one background command and hands each
|
||||
* piece of new text to `publish`, in order. Reads never overlap. A read that
|
||||
* fails, for example because the file does not exist yet, is tried again at
|
||||
* the next poll. `publish` must not reject: the next read waits for it.
|
||||
*/
|
||||
export class BackgroundOutputTail {
|
||||
private offset = 0;
|
||||
private decoder = new StringDecoder("utf8");
|
||||
/** Whether the next text replaces the terminal's output. */
|
||||
private replaces = true;
|
||||
private stopped = false;
|
||||
private reading: Promise<void> = Promise.resolve();
|
||||
private readonly timer: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
private readonly publish: (output: BackgroundOutput) => Promise<void>,
|
||||
) {
|
||||
this.timer = setInterval(() => void this.read(false), BACKGROUND_OUTPUT_POLL_MS);
|
||||
// A command that runs on must not keep the agent process alive.
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the rest of the file and stops. The command ended, so what the file
|
||||
* holds is its whole output, and it goes out before the terminal's exit.
|
||||
*/
|
||||
async finish(): Promise<void> {
|
||||
clearInterval(this.timer);
|
||||
await this.read(true);
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/** Stops without reading more: the session ended. */
|
||||
stop(): void {
|
||||
clearInterval(this.timer);
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads what the file adds, one read of at most
|
||||
* {@link BACKGROUND_OUTPUT_READ_BYTES} at a time, and publishes each read's
|
||||
* text before the next. `toEnd` reads until the end of the file, and ends a
|
||||
* character that the command cut off with a replacement character.
|
||||
*/
|
||||
private read(toEnd: boolean): Promise<void> {
|
||||
this.reading = this.reading.then(async () => {
|
||||
if (this.stopped) return;
|
||||
let file;
|
||||
try {
|
||||
file = await open(this.path, "r");
|
||||
} catch {
|
||||
// Nothing written yet, or the file is gone: try again at the next
|
||||
// poll. At the end, the terminal keeps the output it has.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { size } = await file.stat();
|
||||
// A file that shrank is a new one: its output starts over. Past the
|
||||
// backlog, the reads skip to the file's last bytes.
|
||||
const start = size < this.offset ? 0 : this.offset;
|
||||
const skipTo = Math.max(start, size - BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES);
|
||||
if (skipTo !== this.offset) {
|
||||
this.offset = skipTo;
|
||||
this.decoder = new StringDecoder("utf8");
|
||||
this.replaces = true;
|
||||
}
|
||||
const buffer = Buffer.alloc(BACKGROUND_OUTPUT_READ_BYTES);
|
||||
let bytesRead: number;
|
||||
do {
|
||||
({ bytesRead } = await file.read(buffer, 0, buffer.length, this.offset));
|
||||
this.offset += bytesRead;
|
||||
const text = this.decoder.write(buffer.subarray(0, bytesRead));
|
||||
if (text !== "") await this.send(text);
|
||||
} while (toEnd && bytesRead > 0);
|
||||
if (toEnd) {
|
||||
const rest = this.decoder.end();
|
||||
// A command that wrote nothing still replaces the placeholder.
|
||||
if (rest !== "" || this.replaces) await this.send(rest);
|
||||
}
|
||||
} catch {
|
||||
// The read failed: try again at the next poll.
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
});
|
||||
return this.reading;
|
||||
}
|
||||
|
||||
private async send(text: string): Promise<void> {
|
||||
const replaces = this.replaces;
|
||||
this.replaces = false;
|
||||
await this.publish({ text, replaces });
|
||||
}
|
||||
}
|
||||
61
src/background-work.ts
Normal file
61
src/background-work.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The commands of a session that went to the background, for a client that
|
||||
* renders terminals from the terminal extension and is not AIR (AIR shows a
|
||||
* command through its async tasks): the display terminal of each command
|
||||
* runs from its Bash tool result until its task ends.
|
||||
*/
|
||||
import type { AcpSessionNotification } from "./acp-subagents.js";
|
||||
|
||||
/** The display terminal of a command that went to the background. */
|
||||
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 terminals of the commands that one root session, and its subagents, run in the background. */
|
||||
export class SessionBackgroundWork {
|
||||
/** The terminals of the commands that went to the background, by task id. */
|
||||
private readonly terminals = new Map<string, BackgroundTerminal>();
|
||||
|
||||
constructor(private readonly sessionId: string) {}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The task `taskId` ended: exits its command's terminal, with no exit code.
|
||||
* Claude Code reports no exit code for a command.
|
||||
*/
|
||||
async taskEnded(taskId: string, send: Send): Promise<void> {
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (!terminal) return;
|
||||
this.terminals.delete(taskId);
|
||||
await send(terminalExit(this.sessionId, terminal));
|
||||
}
|
||||
}
|
||||
|
||||
type Send = (notification: AcpSessionNotification) => Promise<void>;
|
||||
|
||||
/** 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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
12
src/binary-entry.ts
Normal file
12
src/binary-entry.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Entry of the release executable (`scripts/build-binary.mjs`). It registers the
|
||||
// Claude CLI shipped next to the executable before importing the agent:
|
||||
// index.ts starts serving (or, with --cli, spawns the CLI) while it evaluates,
|
||||
// and a static import would evaluate it before this module's body runs.
|
||||
import { realpathSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { useBundledClaudeCli } from "./claude-cli.js";
|
||||
|
||||
// realpath, so a symlink to the executable (e.g. in /usr/local/bin) still finds
|
||||
// the CLI next to the real file.
|
||||
useBundledClaudeCli(dirname(realpathSync(process.execPath)));
|
||||
await import("./index.js");
|
||||
125
src/blob-resources.ts
Normal file
125
src/blob-resources.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Embedded `resource` prompt blocks whose resource is a base64 `blob`, and the
|
||||
* Claude content each supported MIME type becomes.
|
||||
*/
|
||||
import type { BlobResourceContents } from "@agentclientprotocol/sdk/unstable";
|
||||
|
||||
/** Image types the Messages API accepts as a base64 `image` block. */
|
||||
const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const;
|
||||
type ImageMimeType = (typeof IMAGE_MIME_TYPES)[number];
|
||||
|
||||
/** Non-`text/*` types whose bytes are text, e.g. a `.json` or `.ts` file. */
|
||||
const TEXT_APPLICATION_MIME_TYPES = new Set([
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/javascript",
|
||||
"application/typescript",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"application/toml",
|
||||
"application/x-sh",
|
||||
"application/sql",
|
||||
"application/graphql",
|
||||
]);
|
||||
|
||||
export type BlobResourceContent =
|
||||
| {
|
||||
kind: "image";
|
||||
block: {
|
||||
type: "image";
|
||||
source: { type: "base64"; media_type: ImageMimeType; data: string };
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: "document";
|
||||
block: {
|
||||
type: "document";
|
||||
source: { type: "base64"; media_type: "application/pdf"; data: string };
|
||||
};
|
||||
}
|
||||
/** The decoded text, to send like a text resource. */
|
||||
| { kind: "text"; text: string };
|
||||
|
||||
/**
|
||||
* The MIME type without parameters or case, e.g. `text/plain; charset=utf-8` ->
|
||||
* `text/plain`. `image/jpg`, which some clients send, becomes `image/jpeg`.
|
||||
*/
|
||||
function baseMimeType(mimeType: string | null | undefined): string | undefined {
|
||||
const base = mimeType?.split(";")[0].trim().toLowerCase();
|
||||
if (base === "image/jpg") return "image/jpeg";
|
||||
return base || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a text blob with the `charset` parameter of its MIME type, e.g.
|
||||
* `text/csv; charset=iso-8859-1`, and UTF-8 when there is none or the label is
|
||||
* unknown. A leading byte order mark is dropped.
|
||||
*/
|
||||
function decodeText(blob: string, mimeType: string | null | undefined): string {
|
||||
const charset = /;\s*charset\s*=\s*"?([^";\s]+)/i.exec(mimeType ?? "")?.[1];
|
||||
let decoder: TextDecoder;
|
||||
try {
|
||||
decoder = new TextDecoder(charset ?? "utf-8");
|
||||
} catch {
|
||||
decoder = new TextDecoder("utf-8");
|
||||
}
|
||||
return decoder.decode(Buffer.from(blob, "base64"));
|
||||
}
|
||||
|
||||
function isImageMimeType(mimeType: string): mimeType is ImageMimeType {
|
||||
return (IMAGE_MIME_TYPES as readonly string[]).includes(mimeType);
|
||||
}
|
||||
|
||||
function isTextMimeType(mimeType: string): boolean {
|
||||
return (
|
||||
mimeType.startsWith("text/") ||
|
||||
TEXT_APPLICATION_MIME_TYPES.has(mimeType) ||
|
||||
mimeType.endsWith("+json") ||
|
||||
mimeType.endsWith("+xml")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link blobResourceToClaude} can send a blob of this MIME type. A blob
|
||||
* without a MIME type is not supported: its bytes could be anything.
|
||||
*/
|
||||
export function isSupportedBlobMimeType(mimeType: string | null | undefined): boolean {
|
||||
const base = baseMimeType(mimeType);
|
||||
if (!base) return false;
|
||||
return isImageMimeType(base) || base === "application/pdf" || isTextMimeType(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Claude content for a blob resource: an `image` block for PNG, JPEG, GIF
|
||||
* and WebP, a `document` block for PDF, and the decoded text for text
|
||||
* types such as `text/markdown` or `application/json`. Returns `undefined` for
|
||||
* any other type, e.g. `application/zip`.
|
||||
*/
|
||||
export function blobResourceToClaude(
|
||||
resource: BlobResourceContents,
|
||||
): BlobResourceContent | undefined {
|
||||
const mimeType = baseMimeType(resource.mimeType);
|
||||
if (!mimeType) return undefined;
|
||||
if (isImageMimeType(mimeType)) {
|
||||
return {
|
||||
kind: "image",
|
||||
block: {
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: mimeType, data: resource.blob },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (mimeType === "application/pdf") {
|
||||
return {
|
||||
kind: "document",
|
||||
block: {
|
||||
type: "document",
|
||||
source: { type: "base64", media_type: "application/pdf", data: resource.blob },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (isTextMimeType(mimeType)) {
|
||||
return { kind: "text", text: decodeText(resource.blob, resource.mimeType) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
88
src/claude-cli.ts
Normal file
88
src/claude-cli.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const CLI_FILE_NAME = process.platform === "win32" ? "claude.exe" : "claude";
|
||||
|
||||
let bundledCliDirectory: string | undefined;
|
||||
|
||||
/**
|
||||
* Called by the executable's entry (`src/binary-entry.ts`) before the agent
|
||||
* starts. A `bun build --compile` executable has no `node_modules` to resolve
|
||||
* the SDK's platform package from, so the release archive ships the Claude CLI
|
||||
* next to it. The CLI isn't embedded: a child process can't run a file inside
|
||||
* Bun's `$bunfs`, so an embedded copy would have to be extracted to disk first.
|
||||
*/
|
||||
export function useBundledClaudeCli(directory: string): void {
|
||||
bundledCliDirectory = directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Claude CLI binary to spawn: `CLAUDE_CODE_EXECUTABLE` if set, then the CLI
|
||||
* shipped next to the executable, then the SDK's platform-specific package.
|
||||
*/
|
||||
export async function claudeCliPath(): Promise<string> {
|
||||
if (process.env.CLAUDE_CODE_EXECUTABLE) {
|
||||
return process.env.CLAUDE_CODE_EXECUTABLE;
|
||||
}
|
||||
if (bundledCliDirectory !== undefined) {
|
||||
return bundledClaudeCliPath(bundledCliDirectory);
|
||||
}
|
||||
return installedClaudeCliPath();
|
||||
}
|
||||
|
||||
function bundledClaudeCliPath(directory: string): string {
|
||||
const path = join(directory, CLI_FILE_NAME);
|
||||
// Without this check, spawning a missing CLI fails with a bare ENOENT.
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(
|
||||
`The Claude CLI is missing: expected ${path}. Keep ${CLI_FILE_NAME} from the release archive ` +
|
||||
"in the same directory as claude-agent-acp, or set CLAUDE_CODE_EXECUTABLE to a Claude CLI binary.",
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
async function installedClaudeCliPath(): Promise<string> {
|
||||
// The SDK's CLI is a native binary shipped as a platform-specific optional
|
||||
// dependency of @anthropic-ai/claude-agent-sdk. Resolve via a require bound
|
||||
// to the SDK so nested installs are found even when npm doesn't hoist.
|
||||
const { createRequire } = await import("node:module");
|
||||
const req = createRequire(import.meta.resolve("@anthropic-ai/claude-agent-sdk"));
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
// On linux, both glibc and musl variants may be installed side-by-side
|
||||
// (e.g. bunx hydrates every optional dep), so picking one by trial is
|
||||
// unreliable: the wrong binary segfaults at runtime instead of failing to
|
||||
// spawn. Detect the runtime libc and prefer the matching variant, falling
|
||||
// back to the other only if the preferred one isn't installed.
|
||||
const candidates =
|
||||
process.platform === "linux"
|
||||
? isMuslLibc()
|
||||
? [
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
|
||||
]
|
||||
: [
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
|
||||
]
|
||||
: [`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude${ext}`];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return req.resolve(candidate);
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Claude native binary not found for ${process.platform}-${process.arch}. ` +
|
||||
`Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set CLAUDE_CODE_EXECUTABLE.`,
|
||||
);
|
||||
}
|
||||
|
||||
function isMuslLibc(): boolean {
|
||||
// process.report.getReport().header.glibcVersionRuntime is populated when
|
||||
// Node is dynamically linked against glibc, and absent on musl.
|
||||
const report = process.report?.getReport() as
|
||||
{ header?: { glibcVersionRuntime?: string } } | undefined;
|
||||
return !report?.header?.glibcVersionRuntime;
|
||||
}
|
||||
@@ -39,11 +39,13 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
|
||||
configOptions: SessionConfigOption[];
|
||||
currentAgent: string;
|
||||
fastModeEnabled: boolean;
|
||||
ultracodeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
turnQueue?: Turn[];
|
||||
pendingExitPlanContextReset?: ClearContextReset;
|
||||
contextUsedTokens?: number;
|
||||
contextWindowSize: number;
|
||||
sentUserUuids?: Set<string>;
|
||||
input: { push(message: SDKUserMessage): unknown };
|
||||
};
|
||||
@@ -59,10 +61,14 @@ export type ClearContextCoordinatorHost<
|
||||
options: { publicSessionId: string; permissionMode: PermissionMode },
|
||||
): Promise<Session>;
|
||||
applyFastMode(session: Session, enabled: boolean): Promise<void>;
|
||||
applyUltracode(session: Session, enabled: boolean): Promise<void>;
|
||||
/** Publishes the fresh context's mode, config options, and usage: the
|
||||
* context starts empty in a window of `contextWindowSize` tokens. */
|
||||
publishSessionState(
|
||||
sessionId: string,
|
||||
mode: PermissionMode,
|
||||
configOptions: SessionConfigOption[],
|
||||
contextWindowSize: number,
|
||||
): Promise<void>;
|
||||
continuationMessage(sessionId: string, plan: string, promptUuid: string): SDKUserMessage;
|
||||
ensureConsumer(session: Session, sessionId: string): void;
|
||||
@@ -146,6 +152,16 @@ export async function continuePlanInFreshContext<
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
}
|
||||
// The fresh query seeds ultracode from settings only, so a toggle made
|
||||
// during the old session would otherwise be lost.
|
||||
if (oldSession.ultracodeEnabled !== freshSession.ultracodeEnabled) {
|
||||
try {
|
||||
await host.applyUltracode(freshSession, oldSession.ultracodeEnabled);
|
||||
} catch (error) {
|
||||
host.logError("Failed to restore ultracode after clearing context:", error);
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
}
|
||||
|
||||
oldSession.activeTurn = null;
|
||||
oldSession.turnQueue = [];
|
||||
@@ -153,7 +169,12 @@ export async function continuePlanInFreshContext<
|
||||
freshSession.contextUsedTokens = 0;
|
||||
|
||||
try {
|
||||
await host.publishSessionState(sessionId, reset.mode, freshSession.configOptions);
|
||||
await host.publishSessionState(
|
||||
sessionId,
|
||||
reset.mode,
|
||||
freshSession.configOptions,
|
||||
freshSession.contextWindowSize,
|
||||
);
|
||||
} catch (error) {
|
||||
host.logError("Failed to publish clear-context session state:", error);
|
||||
}
|
||||
|
||||
@@ -72,3 +72,30 @@ 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 };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AIR_CONTEXT_COMPACTION_KEY, withAirMeta } from "./air-extension.js";
|
||||
|
||||
export const CONTEXT_COMPACTION_META_VERSION = 1;
|
||||
|
||||
export type ContextCompactionTrigger = "manual" | "automatic";
|
||||
export type ContextCompactionTrigger = "manual" | "automatic" | "idle";
|
||||
|
||||
export interface ContextCompactionMetadata {
|
||||
version: typeof CONTEXT_COMPACTION_META_VERSION;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
ContextCompactionMetadata,
|
||||
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";
|
||||
@@ -66,7 +68,7 @@ const ANALYSIS_BLOCKS = [
|
||||
* it as the post-compaction user message. */
|
||||
const CONTINUATION_PREAMBLE = /^\s*This session is being continued from a previous conversation/i;
|
||||
const PERSISTED_PREAMBLE =
|
||||
/^\s*This session is being continued from a previous conversation[^\n]*\n+Summary:[ \t]*\r?\n/;
|
||||
/^\s*This session is being continued from a previous conversation[^\n]*\n(?:[\s\S]*?\n)?Summary:[ \t]*\r?\n/;
|
||||
const PERSISTED_TRAILERS = [
|
||||
/\n+If you need specific details from before compaction[\s\S]*$/,
|
||||
/\n+(?:Please )?[Cc]ontinue the conversation from where it left (?:it )?off[\s\S]*$/,
|
||||
@@ -131,6 +133,36 @@ 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.
|
||||
*
|
||||
@@ -408,14 +440,29 @@ function withoutError({
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function contextCompactionMetadataFromBoundary(compactMetadata: {
|
||||
trigger: "manual" | "auto";
|
||||
pre_tokens: number;
|
||||
post_tokens?: number;
|
||||
duration_ms?: number;
|
||||
}): Omit<ContextCompactionMetadata, "version"> {
|
||||
function compactionTrigger(
|
||||
cliTrigger: "manual" | "auto",
|
||||
sentByAdapter: boolean,
|
||||
): ContextCompactionTrigger {
|
||||
// The CLI's own automatic compaction stays automatic, even inside the adapter's /compact turn.
|
||||
if (cliTrigger === "auto") return "automatic";
|
||||
// The CLI reports every /compact as manual; only the adapter knows it sent this one itself.
|
||||
if (sentByAdapter) return "idle";
|
||||
return "manual";
|
||||
}
|
||||
|
||||
/** `idleCompaction` marks a `/compact` that the adapter sent itself (see `IdleCompaction`). */
|
||||
export function contextCompactionMetadataFromBoundary(
|
||||
compactMetadata: {
|
||||
trigger: "manual" | "auto";
|
||||
pre_tokens: number;
|
||||
post_tokens?: number;
|
||||
duration_ms?: number;
|
||||
},
|
||||
{ idleCompaction = false }: { idleCompaction?: boolean } = {},
|
||||
): Omit<ContextCompactionMetadata, "version"> {
|
||||
return {
|
||||
trigger: compactMetadata.trigger === "auto" ? "automatic" : "manual",
|
||||
trigger: compactionTrigger(compactMetadata.trigger, idleCompaction),
|
||||
preTokens: compactMetadata.pre_tokens,
|
||||
...(compactMetadata.post_tokens !== undefined
|
||||
? { postTokens: compactMetadata.post_tokens }
|
||||
|
||||
166
src/context-window.ts
Normal file
166
src/context-window.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
14
src/diff.ts
14
src/diff.ts
@@ -89,7 +89,7 @@ export type PatchForm = "air" | "v2";
|
||||
/**
|
||||
* How a git patch names its file. `git` is git's own form: the leading slash
|
||||
* dropped and the `a/` and `b/` prefixes added. `absolute` is the form of an
|
||||
* ACP v2 `git_patch`, whose paths must be absolute: the path itself.
|
||||
* ACP v2 `git_patch`, whose paths must be absolute: the native path itself.
|
||||
*/
|
||||
type GitPatchPaths = "git" | "absolute";
|
||||
|
||||
@@ -438,11 +438,11 @@ export function gitPatchText(
|
||||
}
|
||||
|
||||
function gitPatchHeader(filePath: string, change: FileChange, paths: GitPatchPaths): string[] {
|
||||
// A Windows path gets forward slashes so the header names the same file on
|
||||
// every platform.
|
||||
const slashed = filePath.replaceAll("\\", "/");
|
||||
// git drops one leading slash of an absolute path.
|
||||
const name = paths === "git" ? slashed.replace(/^\/+/u, "") : slashed;
|
||||
// git's form uses forward slashes, so the header names the same file on
|
||||
// every platform, and drops one leading slash of an absolute path. An ACP v2
|
||||
// patch keeps the native path so that, once unquoted, it equals the `changes`
|
||||
// entry: `C:\work\App.ts` is written `"C:\\work\\App.ts"`.
|
||||
const name = paths === "git" ? filePath.replaceAll("\\", "/").replace(/^\/+/u, "") : filePath;
|
||||
const oldName = quoteGitPath(paths === "git" ? "a/" : "", name);
|
||||
const newName = quoteGitPath(paths === "git" ? "b/" : "", name);
|
||||
// git ends a ---/+++ name that contains a space with a tab, for GNU patch.
|
||||
@@ -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 surface sends it as it is
|
||||
* diff, and only a v2 client gets one: the v2 core sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
export function v2DiffContent(
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { CreateElicitationResponse } from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
ElicitationAcceptAction,
|
||||
CreateElicitationResponse,
|
||||
ElicitationPropertySchema,
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
ElicitationRequest,
|
||||
ElicitationResult,
|
||||
PermissionResult,
|
||||
UserDialogRequest,
|
||||
UserDialogResult,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
@@ -75,17 +80,22 @@ export function mcpElicitationToCreateRequest(
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of an accepted elicitation response.
|
||||
* Content of an accepted elicitation response, or `{}` when the response is
|
||||
* no accept or its `content` is no JSON object.
|
||||
*
|
||||
* Uses the SDK's validating guard rather than an `action === "accept"` check:
|
||||
* the guard both narrows past the union's custom/future variant and validates
|
||||
* the payload, so a malformed accept (right tag, ill-typed content) yields
|
||||
* empty content — the same classification the SDK's wire validators apply.
|
||||
* The values are not checked. v1 allows a string, number, boolean, or string
|
||||
* array; v2 also allows any other JSON value, such as an object. An MCP
|
||||
* server checks the content against its own `requestedSchema`, so it gets the
|
||||
* content as the client sent it: before, an accept whose content held an
|
||||
* object reached the server as `{ action: "accept", content: {} }`. Callers
|
||||
* that read a field must check its type.
|
||||
*/
|
||||
function acceptedElicitationContent(
|
||||
response: CreateElicitationResponse,
|
||||
): NonNullable<ElicitationAcceptAction["content"]> {
|
||||
return CreateElicitationResponse.isAccept(response) ? (response.content ?? {}) : {};
|
||||
function acceptedElicitationContent(response: CreateElicitationResponse): Record<string, unknown> {
|
||||
if (response.action !== "accept") return {};
|
||||
const content: unknown = (response as { content?: unknown }).content;
|
||||
return content !== null && typeof content === "object" && !Array.isArray(content)
|
||||
? (content as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +107,11 @@ export function createElicitationResponseToElicitResult(
|
||||
): ElicitationResult {
|
||||
switch (response.action) {
|
||||
case "accept":
|
||||
return { action: "accept", content: acceptedElicitationContent(response) };
|
||||
// `ElicitResult` types the values as v1 does. The MCP server checks them.
|
||||
return {
|
||||
action: "accept",
|
||||
content: acceptedElicitationContent(response) as ElicitationResult["content"],
|
||||
};
|
||||
case "decline":
|
||||
return { action: "decline" };
|
||||
case "cancel":
|
||||
@@ -280,14 +294,37 @@ function joinMultiSelectAnswer(items: string[]): string {
|
||||
* cannot make the selection disappear. Decline yields empty answers (the model
|
||||
* is told the user skipped rather than the turn aborting); cancel — and any
|
||||
* custom/future action we don't understand — aborts the tool call.
|
||||
*
|
||||
* With `reportUnanswered` set (the session's `system/init` listed
|
||||
* `ask_user_question_unanswered_v1`), each question left without an answer
|
||||
* also gets `annotations[question].unanswered`: `"dismissed"` for every
|
||||
* question when the user declined the form, `"skipped"` for a question left
|
||||
* blank in an accepted form. The model does not see this field; the tool
|
||||
* result carries it for clients and transcripts. A CLI without the capability
|
||||
* can refuse the whole answer over it, so it is left out there.
|
||||
*/
|
||||
export function applyAskElicitationResponse(
|
||||
response: CreateElicitationResponse,
|
||||
toolInput: Record<string, unknown>,
|
||||
questions: AskUserQuestion[],
|
||||
options: { reportUnanswered?: boolean } = {},
|
||||
): AskUserQuestionOutcome {
|
||||
// Typed against the output schema: as of SDK 0.3.319 only it lists
|
||||
// `unanswered`, though the tool reads the same annotations from its input.
|
||||
const annotations: NonNullable<AskUserQuestionOutput["annotations"]> = {};
|
||||
const withAnnotations = (answers: AskUserQuestionOutput["answers"]) => ({
|
||||
...toolInput,
|
||||
answers,
|
||||
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
||||
});
|
||||
|
||||
if (response.action === "decline") {
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
|
||||
if (options.reportUnanswered) {
|
||||
for (const question of questions) {
|
||||
annotations[question.question] = { unanswered: "dismissed" };
|
||||
}
|
||||
}
|
||||
return { action: "answered", updatedInput: withAnnotations({}) };
|
||||
}
|
||||
|
||||
if (response.action !== "accept") {
|
||||
@@ -298,18 +335,17 @@ export function applyAskElicitationResponse(
|
||||
// Typed against the tool's own output schema so the answer/response shapes
|
||||
// stay in sync with what the built-in tool's call() expects to read back.
|
||||
const answers: AskUserQuestionOutput["answers"] = {};
|
||||
const annotations: NonNullable<AskUserQuestionInput["annotations"]> = {};
|
||||
questions.forEach((question, index) => {
|
||||
const custom = content[questionCustomFieldKey(index)];
|
||||
const customText = typeof custom === "string" ? custom.trim() : "";
|
||||
|
||||
// A pick is a string, number, or boolean. Other values, such as an object
|
||||
// that a v2 client may send, are dropped instead of read as "[object Object]".
|
||||
const value = content[questionFieldKey(index)];
|
||||
const picks: string[] =
|
||||
value === undefined || value === null
|
||||
? []
|
||||
: Array.isArray(value)
|
||||
? value.filter((item) => item !== undefined && item !== null && item !== "").map(String)
|
||||
: [String(value)];
|
||||
const picks: string[] = (Array.isArray(value) ? value : [value])
|
||||
.filter(isElicitationScalar)
|
||||
.map(String)
|
||||
.filter((item) => item !== "");
|
||||
|
||||
// A multi-select is additive, and the form offers the selection and the
|
||||
// custom box as independent fields — a user who fills both means both, so
|
||||
@@ -341,20 +377,27 @@ export function applyAskElicitationResponse(
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
...toolInput,
|
||||
answers,
|
||||
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
||||
},
|
||||
};
|
||||
if (options.reportUnanswered) {
|
||||
for (const question of questions) {
|
||||
if (answers[question.question] === undefined) {
|
||||
annotations[question.question] = { unanswered: "skipped" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { action: "answered", updatedInput: withAnnotations(answers) };
|
||||
}
|
||||
|
||||
function isElicitationScalar(value: unknown): value is string | number | boolean {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
|
||||
* The two are structurally compatible JSON Schemas; we just guarantee the
|
||||
* `type: "object"` discriminator is present.
|
||||
* `type: "object"` discriminator and a `properties` map are present. The v2
|
||||
* schema requires both, so a client on SDK 2.2.0 or later rejects an MCP
|
||||
* schema such as `{ type: "object" }` that has no `properties`.
|
||||
*/
|
||||
function normalizeElicitationSchema(
|
||||
schema: Record<string, unknown> | undefined,
|
||||
@@ -362,7 +405,8 @@ function normalizeElicitationSchema(
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
return { ...(schema as ElicitationSchema), type: "object" };
|
||||
const { properties } = schema as Partial<ElicitationSchema>;
|
||||
return { ...(schema as ElicitationSchema), type: "object", properties: properties ?? {} };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,3 +518,156 @@ 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) };
|
||||
}
|
||||
|
||||
@@ -126,10 +126,9 @@ export function isExitPlanInterruptionResult(
|
||||
);
|
||||
}
|
||||
|
||||
/** The id of the plan that an approved ExitPlanMode publishes. Claude Code
|
||||
* keeps one plan file per session, which a later plan mode rewrites, so each
|
||||
* approval replaces the plan before it. The TodoWrite checklist is a
|
||||
* separate plan. */
|
||||
/** The base ID of an approved plan. AIR appends the review ID to keep each
|
||||
* approval in the transcript. Other clients replace one plan per session.
|
||||
* The TodoWrite checklist is a separate plan. */
|
||||
export const PLAN_MODE_PLAN_ID = "plan-mode";
|
||||
|
||||
/** The plan, trimmed, that a successful ExitPlanMode result approved: the plan
|
||||
@@ -150,11 +149,19 @@ export function approvedPlan(input: unknown, output: unknown): string | undefine
|
||||
}
|
||||
|
||||
/** The update that shows an approved plan to a client that supports plan
|
||||
* operations (the `plan` client capability, which every v2 client has). */
|
||||
export function planModeUpdate(plan: string): SessionNotification["update"] {
|
||||
* operations (the `plan` client capability, which every v2 client has).
|
||||
* For AIR, pass the review ID to join the plan to its approval card. */
|
||||
export function planModeUpdate(
|
||||
plan: string,
|
||||
reviewToolCallId?: string,
|
||||
): SessionNotification["update"] {
|
||||
return {
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "markdown", planId: PLAN_MODE_PLAN_ID, content: plan },
|
||||
plan: {
|
||||
type: "markdown",
|
||||
planId: reviewToolCallId ? `${PLAN_MODE_PLAN_ID}:${reviewToolCallId}` : PLAN_MODE_PLAN_ID,
|
||||
content: plan,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +185,7 @@ export type ExitPlanRestartHost<
|
||||
sessionUpdate(notification: SessionNotification): Promise<void>;
|
||||
/** Whether the client takes plan operations (see planModeUpdate). */
|
||||
supportsPlanOperations(): boolean;
|
||||
isAirClient(): boolean;
|
||||
destroyReplacement(sessionId: string, session: Session): void;
|
||||
settleCancelledTurn(oldSession: Session, turnSession: Session, turn: Turn): void;
|
||||
settleFailedTurn(turnSession: Session, turn: Turn, error: unknown): void;
|
||||
@@ -205,7 +213,7 @@ export class ExitPlanCoordinator<
|
||||
try {
|
||||
const clearContextHost: ClearContextCoordinatorHost<Session, Turn> = {
|
||||
...this.host,
|
||||
publishSessionState: async (id, mode, configOptions) => {
|
||||
publishSessionState: async (id, mode, configOptions, contextWindowSize) => {
|
||||
await this.host.sessionUpdate({
|
||||
sessionId: id,
|
||||
update: { sessionUpdate: "current_mode_update", currentModeId: mode },
|
||||
@@ -214,11 +222,23 @@ export class ExitPlanCoordinator<
|
||||
sessionId: id,
|
||||
update: { sessionUpdate: "config_option_update", configOptions },
|
||||
});
|
||||
// Otherwise the client shows the old context's usage until the fresh
|
||||
// context first reports its own.
|
||||
await this.host.sessionUpdate({
|
||||
sessionId: id,
|
||||
update: { sessionUpdate: "usage_update", used: 0, size: contextWindowSize },
|
||||
});
|
||||
// The approved plan becomes the session's plan once the fresh
|
||||
// context that carries it out exists. Its ExitPlanMode ended with
|
||||
// an error result, so toAcpNotifications did not publish it.
|
||||
if (this.host.supportsPlanOperations()) {
|
||||
await this.host.sessionUpdate({ sessionId: id, update: planModeUpdate(reset.plan) });
|
||||
await this.host.sessionUpdate({
|
||||
sessionId: id,
|
||||
update: planModeUpdate(
|
||||
reset.plan,
|
||||
this.host.isAirClient() ? reset.toolUseId : undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
continuationMessage: (id, plan, promptUuid) => ({
|
||||
|
||||
@@ -104,11 +104,12 @@ 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. Must run before `toAcpNotifications`
|
||||
* drops the tool use cache entries.
|
||||
* checkpoint already tracks their files. A turn that v1 marked `settled`
|
||||
* records nothing. 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>,
|
||||
|
||||
@@ -69,10 +69,7 @@ function forkPointMessageIdCandidates(messageId: string): string[] {
|
||||
return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId];
|
||||
}
|
||||
|
||||
async function loadFullSessionHistory(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
): Promise<SessionStoreEntry[]> {
|
||||
async function loadFullSessionHistory(sessionId: string): Promise<SessionStoreEntry[]> {
|
||||
// getSessionMessages returns only the active parentUuid chain. The import keeps inactive branches that AIR can still reference.
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
const store: SessionStore = {
|
||||
@@ -81,7 +78,7 @@ async function loadFullSessionHistory(
|
||||
},
|
||||
load: async () => null,
|
||||
};
|
||||
await importSessionToStore(sessionId, store, { dir: cwd, includeSubagents: false });
|
||||
await importSessionToStore(sessionId, store, { includeSubagents: false });
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -179,14 +176,44 @@ function resolveFromFullHistory(
|
||||
return occurrenceMatches.length === 1 ? occurrenceMatches[0]?.uuid : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `error` is the Claude SDK's report that `sessionId` has no
|
||||
* transcript: `forkSession` and `importSessionToStore` throw
|
||||
* `Error("Session <id> not found")`, and both throw
|
||||
* `"Invalid sessionId: <id>"` for an id that is not a UUID.
|
||||
*/
|
||||
function isUnknownSessionError(error: unknown, sessionId: string): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return (
|
||||
error.message === `Session ${sessionId} not found` ||
|
||||
error.message === `Invalid sessionId: ${sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function forkSession(
|
||||
params: ForkSessionRequest,
|
||||
dependencies: ForkSessionDependencies,
|
||||
): Promise<ForkSessionResponse> {
|
||||
try {
|
||||
return await forkTranscript(params, dependencies);
|
||||
} catch (error) {
|
||||
if (isUnknownSessionError(error, params.sessionId)) {
|
||||
throw RequestError.sessionNotFound(params.sessionId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function forkTranscript(
|
||||
params: ForkSessionRequest,
|
||||
dependencies: ForkSessionDependencies,
|
||||
): Promise<ForkSessionResponse> {
|
||||
const timing = new SessionTiming(dependencies.logger, "fork", params.sessionId);
|
||||
const point = forkPoint(params._meta);
|
||||
// SDK `dir` scopes source lookup, not the fork's working directory. Search
|
||||
// all projects: params.cwd can name a different workspace for the new session.
|
||||
if (!point) {
|
||||
const forked = await forkClaudeSession(params.sessionId, { dir: params.cwd });
|
||||
const forked = await forkClaudeSession(params.sessionId);
|
||||
timing.phase("sdk-fork", " resolution=latest");
|
||||
return { sessionId: forked.sessionId };
|
||||
}
|
||||
@@ -195,9 +222,7 @@ export async function forkSession(
|
||||
const liveUuid = candidateIds
|
||||
.map((candidateId) => dependencies.liveMessageIdToUuid?.get(candidateId))
|
||||
.find(Boolean);
|
||||
const history = liveUuid
|
||||
? undefined
|
||||
: await getSessionMessages(params.sessionId, { dir: params.cwd });
|
||||
const history = liveUuid ? undefined : await getSessionMessages(params.sessionId);
|
||||
timing.phase("active-history", ` resolution=${liveUuid ? "live" : "pending"}`);
|
||||
const messageUuid =
|
||||
liveUuid ??
|
||||
@@ -213,7 +238,7 @@ export async function forkSession(
|
||||
const fullHistoryUuid = messageUuid
|
||||
? undefined
|
||||
: resolveFromFullHistory(
|
||||
await loadFullSessionHistory(params.sessionId, params.cwd),
|
||||
await loadFullSessionHistory(params.sessionId),
|
||||
candidateIds,
|
||||
point,
|
||||
dependencies.messageIdForGrouping,
|
||||
@@ -231,7 +256,6 @@ export async function forkSession(
|
||||
}
|
||||
|
||||
const forked = await forkClaudeSession(params.sessionId, {
|
||||
dir: params.cwd,
|
||||
upToMessageId: messageUuid ?? fullHistoryUuid,
|
||||
});
|
||||
timing.phase("sdk-fork");
|
||||
|
||||
240
src/idle-compaction.ts
Normal file
240
src/idle-compaction.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
export type PromptCacheTtl = "5m" | "1h";
|
||||
|
||||
export type IdleCompactionHost = {
|
||||
/** Claude is working (`startTurn`'s check) or a permission request / user input is pending. */
|
||||
isBusy(): boolean;
|
||||
/** Context size after the last main-thread request, or undefined if unknown. */
|
||||
contextTokens(): number | undefined;
|
||||
/** Current merged Claude Code settings. */
|
||||
settings(): {
|
||||
idleCompaction?: boolean;
|
||||
autoCompactEnabled?: boolean;
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
/** Runs `/compact` as an adapter-originated turn; settles with that turn. */
|
||||
compact(): Promise<unknown>;
|
||||
/** Debug-level log. */
|
||||
log(message: string): void;
|
||||
};
|
||||
|
||||
export type IdleCompactionOptions = {
|
||||
env?: Record<string, string | undefined>;
|
||||
setTimeout?: (callback: () => void, ms: number) => unknown;
|
||||
};
|
||||
|
||||
/** The longest delay `setTimeout` takes; a longer one fires at once. */
|
||||
const MAX_TIMER_MS = 2_147_483_647;
|
||||
const HOUR_MS = 3_600_000;
|
||||
const DEFAULT_MIN_TOKENS = 200_000;
|
||||
const MIN_TOKENS_FLOOR = 100_000;
|
||||
// 54 min for a 1h cache: leaves a margin for the gap between the provider
|
||||
// writing the cache and the adapter receiving the response.
|
||||
const DEFAULT_DELAY_MS = 0.9 * HOUR_MS;
|
||||
// Node timers do not run while the machine sleeps, so one can fire hours after
|
||||
// its deadline, on a cache that expired meanwhile. Same threshold as Claude Code.
|
||||
const LAPSED_AFTER_MS = 60_000;
|
||||
// The values Claude Code reads as "on" for env flags such as DISABLE_COMPACT
|
||||
// (case-insensitive, trimmed).
|
||||
const TRUTHY_ENV = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
function isTruthyEnv(value: string | undefined): boolean {
|
||||
return value !== undefined && TRUTHY_ENV.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseCacheTtl(usage: unknown): PromptCacheTtl | undefined {
|
||||
const creation = (usage as { cache_creation?: unknown } | null | undefined)?.cache_creation;
|
||||
if (typeof creation !== "object" || creation === null) return undefined;
|
||||
const { ephemeral_1h_input_tokens: hour, ephemeral_5m_input_tokens: fiveMin } = creation as {
|
||||
ephemeral_1h_input_tokens?: unknown;
|
||||
ephemeral_5m_input_tokens?: unknown;
|
||||
};
|
||||
if (typeof hour === "number" && hour > 0) return "1h";
|
||||
if (typeof fiveMin === "number" && fiveMin > 0) return "5m";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compacts a session shortly before its one-hour prompt cache expires, so the
|
||||
* next prompt writes a short summary instead of the whole context. Claude Code
|
||||
* has this feature but arms it only for "attended" sessions, which SDK sessions
|
||||
* are not. One instance per session.
|
||||
*/
|
||||
export class IdleCompaction {
|
||||
private readonly env: Record<string, string | undefined>;
|
||||
private readonly setTimer: (callback: () => void, ms: number) => unknown;
|
||||
private ttl: PromptCacheTtl | undefined;
|
||||
private lastRequestAt: number | undefined;
|
||||
private lastMessageId: string | undefined;
|
||||
/** Set when `/compact` was sent, cleared by the next request: a compaction that failed,
|
||||
* or left the context large, would otherwise re-arm at once and loop until the user prompts. */
|
||||
private compactedSinceLastRequest = false;
|
||||
private timer: unknown;
|
||||
private compacting = false;
|
||||
private disposed = false;
|
||||
private warnedInvalidDelay = false;
|
||||
/** `status` of the last `rate_limit_event`; unknown until one arrives. */
|
||||
private rateLimitStatus: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly host: IdleCompactionHost,
|
||||
options: IdleCompactionOptions = {},
|
||||
) {
|
||||
this.env = options.env ?? process.env;
|
||||
this.setTimer = options.setTimeout ?? ((cb, ms) => setTimeout(cb, ms));
|
||||
}
|
||||
|
||||
/** 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
|
||||
* result, which nothing here enforces. */
|
||||
get isCompacting(): boolean {
|
||||
return this.compacting;
|
||||
}
|
||||
|
||||
/** A main-thread response arrived. Records its time and cache TTL, and drops any armed timer.
|
||||
* The provider writes the cache when the request starts, so the deadline is measured from the
|
||||
* response's first sighting: the `message_start` stream event, or its first `assistant` message
|
||||
* when it was not streamed. The CLI sends one `assistant` message per content block, and a
|
||||
* 7-minute response would otherwise put the deadline 7 minutes after the cache was written.
|
||||
* A later call with the same `messageId` only updates the TTL. */
|
||||
noteRequest(usage: unknown, messageId?: string): void {
|
||||
if (this.disposed) return;
|
||||
this.ttl = parseCacheTtl(usage) ?? this.ttl;
|
||||
if (messageId !== undefined && messageId === this.lastMessageId) return;
|
||||
this.lastMessageId = messageId;
|
||||
this.lastRequestAt = Date.now();
|
||||
this.compactedSinceLastRequest = false;
|
||||
this.disarm("new request");
|
||||
}
|
||||
|
||||
/** The session became idle. Arms the timer if every condition holds, else logs why not. */
|
||||
onIdle(): void {
|
||||
if (this.disposed) return;
|
||||
const skip = this.skipReason();
|
||||
if (skip) {
|
||||
this.host.log(`[idle-compaction] skip: ${skip}`);
|
||||
return;
|
||||
}
|
||||
this.disarm("rearm");
|
||||
const deadline = this.lastRequestAt! + this.delayMs();
|
||||
this.timer = this.setTimer(() => void this.fire(), Math.max(0, deadline - Date.now()));
|
||||
(this.timer as { unref?: () => void } | undefined)?.unref?.();
|
||||
this.host.log(`[idle-compaction] armed deadline=${new Date(deadline).toISOString()}`);
|
||||
}
|
||||
|
||||
/** A `rate_limit_event` arrived (`SDKRateLimitInfo`). Anything but `allowed` skips compaction,
|
||||
* as in Claude Code. This is account state, so `reset()` keeps it. */
|
||||
noteRateLimit(info: unknown): void {
|
||||
const status = (info as { status?: unknown } | null | undefined)?.status;
|
||||
if (typeof status === "string") this.rateLimitStatus = status;
|
||||
}
|
||||
|
||||
/** Claude Code reported its compaction as failed. Its turn still ends normally, so `compact()` does not reject. */
|
||||
noteCompactionFailed(error: string | undefined): void {
|
||||
if (!this.compacting) return;
|
||||
this.host.log(`[idle-compaction] compaction failed: ${error ?? "no reason given"}`);
|
||||
}
|
||||
|
||||
/** The conversation was replaced (`/clear`): forgets the recorded request and TTL, and drops any armed timer. */
|
||||
reset(): void {
|
||||
this.ttl = undefined;
|
||||
this.lastRequestAt = undefined;
|
||||
this.lastMessageId = undefined;
|
||||
this.compactedSinceLastRequest = false;
|
||||
this.disarm("conversation reset");
|
||||
}
|
||||
|
||||
/** Drops the armed timer, if any. `reason` is only logged. */
|
||||
disarm(reason: string): void {
|
||||
if (this.timer === undefined) return;
|
||||
clearTimeout(this.timer as ReturnType<typeof setTimeout>);
|
||||
this.timer = undefined;
|
||||
this.host.log(`[idle-compaction] disarmed: ${reason}`);
|
||||
}
|
||||
|
||||
/** Disarms and ignores every later call. */
|
||||
dispose(): void {
|
||||
this.disarm("disposed");
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
private async fire(): Promise<void> {
|
||||
this.timer = undefined;
|
||||
if (this.disposed) return;
|
||||
const skip = this.skipReason();
|
||||
if (skip) {
|
||||
this.host.log(`[idle-compaction] skip at fire: ${skip}`);
|
||||
return;
|
||||
}
|
||||
this.host.log("[idle-compaction] firing /compact");
|
||||
this.compacting = true;
|
||||
this.compactedSinceLastRequest = true;
|
||||
try {
|
||||
await this.host.compact();
|
||||
} catch (error) {
|
||||
this.host.log(`[idle-compaction] compaction failed: ${error}`);
|
||||
} finally {
|
||||
this.compacting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Why no compaction should be scheduled right now, or undefined if all conditions hold. */
|
||||
private skipReason(): string | undefined {
|
||||
if (this.compacting) return "compaction already running";
|
||||
const disabled = this.disabledBy();
|
||||
if (disabled) return `disabled by ${disabled}`;
|
||||
if (this.lastRequestAt === undefined) return "no request recorded in this process";
|
||||
if (Date.now() > this.lastRequestAt + this.delayMs() + LAPSED_AFTER_MS) {
|
||||
return "cache lapsed: more than 60 s past the deadline";
|
||||
}
|
||||
if (this.rateLimitStatus !== undefined && this.rateLimitStatus !== "allowed") {
|
||||
return `rate limit status is ${this.rateLimitStatus}`;
|
||||
}
|
||||
if (this.compactedSinceLastRequest) return "already compacted for this request";
|
||||
if (this.ttl !== "1h") return `cache TTL is ${this.ttl ?? "unknown"}, not 1h`;
|
||||
const tokens = this.host.contextTokens();
|
||||
const min = this.minTokens();
|
||||
if (tokens === undefined || tokens < min) {
|
||||
return `context ${tokens ?? "unknown"} tokens is below the ${min} minimum`;
|
||||
}
|
||||
if (this.host.isBusy()) return "session is busy";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private disabledBy(): string | undefined {
|
||||
const settings = this.host.settings();
|
||||
if (settings.idleCompaction === false) return "idleCompaction setting";
|
||||
if (settings.autoCompactEnabled === false) return "autoCompactEnabled setting";
|
||||
for (const name of ["DISABLE_COMPACT", "DISABLE_AUTO_COMPACT"]) {
|
||||
if (isTruthyEnv(this.claudeCodeEnv(name))) return name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The CLI applies the settings `env` block after the launch env, so it wins. */
|
||||
private claudeCodeEnv(name: string): string | undefined {
|
||||
return this.host.settings().env?.[name] ?? this.env[name];
|
||||
}
|
||||
|
||||
/** `0` and invalid values fall back to 200,000, as in the CLI, which declares the variable
|
||||
* with `min: 1` and computes `Math.max(100000, env ?? 200000)`. */
|
||||
private minTokens(): number {
|
||||
const raw = this.claudeCodeEnv("CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS");
|
||||
const parsed = raw !== undefined && /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : 0;
|
||||
return Math.max(MIN_TOKENS_FLOOR, parsed > 0 ? parsed : DEFAULT_MIN_TOKENS);
|
||||
}
|
||||
|
||||
private delayMs(): number {
|
||||
const raw = this.env.CLAUDE_AGENT_ACP_IDLE_COMPACT_DELAY_MS;
|
||||
if (raw === undefined) return DEFAULT_DELAY_MS;
|
||||
if (/^\d+$/.test(raw.trim()) && Number(raw.trim()) <= MAX_TIMER_MS) return Number(raw.trim());
|
||||
if (!this.warnedInvalidDelay) {
|
||||
this.warnedInvalidDelay = true;
|
||||
this.host.log(
|
||||
`[idle-compaction] ignoring invalid CLAUDE_AGENT_ACP_IDLE_COMPACT_DELAY_MS=${raw}`,
|
||||
);
|
||||
}
|
||||
return DEFAULT_DELAY_MS;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { claudeCliPath } from "./acp-agent.js";
|
||||
import { claudeCliPath } from "./claude-cli.js";
|
||||
import { applyManagedPolicyEnv } from "./managed-policy.js";
|
||||
import { acpProtocolsFromEnv, serveAcp } from "./serve.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
16
src/lib.ts
16
src/lib.ts
@@ -1,15 +1,9 @@
|
||||
// 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 { ClaudeAcpAgent, runAcp } from "./acp-agent.js";
|
||||
export { isLocalCommandMetadata, stripLocalCommandMetadata } from "./message-filters.js";
|
||||
export { toAcpNotifications, streamEventToAcpNotifications } from "./sdk-to-acp.js";
|
||||
export type { NewSessionMeta, SDKMessageFilter } from "./new-session-meta.js";
|
||||
export type { ToolUpdateMeta } from "./tool-calls/renderer.js";
|
||||
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
|
||||
9
src/logger.ts
Normal file
9
src/logger.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -111,8 +111,11 @@ function needsReconnect(status: McpServerStatus): boolean {
|
||||
function codeSpan(text: string): string {
|
||||
const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
||||
const fence = "`".repeat(longestRun + 1);
|
||||
// CommonMark strips one space from each end of a span, so a pad keeps the text unchanged.
|
||||
const pad = text.startsWith("`") || text.endsWith("`") || /^ .*[^ ].* $/.test(text) ? " " : "";
|
||||
// CommonMark strips one space from each end of a span that is not all spaces
|
||||
// (line endings count as spaces), so a pad keeps the text unchanged. Plain
|
||||
// string checks, not one regex: `^ .*[^ ].* $` backtracks quadratically.
|
||||
const strippedByCommonMark = text.startsWith(" ") && text.endsWith(" ") && /[^ \r\n]/.test(text);
|
||||
const pad = text.startsWith("`") || text.endsWith("`") || strippedByCommonMark ? " " : "";
|
||||
return `${fence}${pad}${text}${pad}${fence}`;
|
||||
}
|
||||
|
||||
|
||||
214
src/mcp-oauth.ts
Normal file
214
src/mcp-oauth.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
}
|
||||
33
src/mcp-servers.ts
Normal file
33
src/mcp-servers.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
217
src/message-filters.ts
Normal file
217
src/message-filters.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function isLocalCommandMetadata(content: unknown): boolean {
|
||||
return stripLocalCommandMetadata(content) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,12 @@ import {
|
||||
type SessionConfigOption,
|
||||
type SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { realpath } from "node:fs/promises";
|
||||
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. */
|
||||
@@ -25,11 +28,14 @@ export type NativeSubagent = {
|
||||
* form. It is absent when the adapter has no prompt.
|
||||
*/
|
||||
prompt?: string;
|
||||
/** The RFD form: the parent's label for the child, `subagent_update.title`. */
|
||||
/**
|
||||
* The RFD form: the parent's label for the child, `subagent_update.title`:
|
||||
* its name, else its task.
|
||||
*/
|
||||
title?: string;
|
||||
/** The RFD form: the child's role, its `subagent_type`, for the title. */
|
||||
/** The RFD form: the child's role, its `subagent_type`. */
|
||||
role?: string;
|
||||
/** The RFD form: the child's purpose, `subagent_update.description`. */
|
||||
/** The RFD form: `subagent_update.description`, the child's role. */
|
||||
description?: string;
|
||||
/**
|
||||
* The RFD form: the work state the client holds. The child is reusable, so
|
||||
@@ -53,14 +59,23 @@ export type NativeSubagent = {
|
||||
/** The RFD form: the calls that delegated to the child, whose runs handed back a report. */
|
||||
handedBack?: Set<string>;
|
||||
announced?: boolean;
|
||||
/**
|
||||
* The RFD form: the `cwd` that the client holds for the child. Undefined or
|
||||
* null while the child inherits its parent's working directory.
|
||||
*/
|
||||
reportedCwd?: string | null;
|
||||
terminalState?: SubagentState;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
announcePromise?: Promise<void>;
|
||||
/** Connection-local: serializes the child's `cwd` reports; never serialized on the wire. */
|
||||
cwdPublishing?: Promise<void>;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
terminalPromise?: Promise<void>;
|
||||
};
|
||||
|
||||
export type NativeSubagentSession = {
|
||||
/** The working directory of the root session, which a child inherits unless it reports its own. */
|
||||
cwd?: string;
|
||||
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
|
||||
nativeSubagentTaskIdByToolUseId?: Map<string, string>;
|
||||
nativeSubagentParentByToolUseId?: Map<string, string>;
|
||||
@@ -116,7 +131,7 @@ type TaskStarted = {
|
||||
prompt?: unknown;
|
||||
};
|
||||
|
||||
type SubagentIdentity = {
|
||||
export type SubagentIdentity = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
@@ -130,6 +145,8 @@ export type HistoricalSubagent = {
|
||||
launchId: string;
|
||||
/** The agent that made that call. None means the root session. */
|
||||
parentAgentId?: string;
|
||||
/** Its working directory, when Claude Code placed it elsewhere, e.g. in a worktree. */
|
||||
cwd?: string;
|
||||
name?: string;
|
||||
agentType?: string;
|
||||
description?: string;
|
||||
@@ -169,6 +186,11 @@ export class NativeSubagentRuntime {
|
||||
private readonly identityByToolUse = new Map<string, SubagentIdentity>();
|
||||
private readonly controlByToolUse = new Map<string, AcpSessionNotification>();
|
||||
private readonly configBySession = new Map<string, ChildConfiguration>();
|
||||
/**
|
||||
* The RFD form: the working directory of each child, by session id, as its
|
||||
* hooks last reported it. A hook can come before the child is announced.
|
||||
*/
|
||||
private readonly cwdBySession = new Map<string, string>();
|
||||
private readonly childByParentToolUse = new Map<string, NativeSubagent>();
|
||||
/**
|
||||
* The child session of each tool call that went to a child session. A later
|
||||
@@ -367,14 +389,7 @@ 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 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 configOptions = orderedSubagentConfiguration(config.options.values());
|
||||
const snapshot = JSON.stringify(configOptions);
|
||||
if (config.published === snapshot) return;
|
||||
await this.publish({
|
||||
@@ -397,6 +412,83 @@ export class NativeSubagentRuntime {
|
||||
await publishing;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: takes the working directory of the child `agentId`, the
|
||||
* `cwd` of one of its hooks. A child that Claude Code runs in its own
|
||||
* worktree (Agent `isolation: "worktree"`) reports the worktree. The client
|
||||
* gets the `cwd` only when it differs from the parent's, in the
|
||||
* announcement or in a `subagent_update` right after it. Claude Code runs a
|
||||
* child's SubagentStart hook before its first model request, so the `cwd`
|
||||
* reaches the client before the child's first tool call.
|
||||
*/
|
||||
async reportCwd(agentId: string, cwd: string): Promise<void> {
|
||||
if (this.form !== "rfd" || agentId === this.rootSessionId || !path.isAbsolute(cwd)) return;
|
||||
const known = this.children.get(agentId) ?? this.childOfSession(agentId);
|
||||
const sessionId = known?.sessionId ?? agentId;
|
||||
if (!known && !this.cwdBySession.has(sessionId)) {
|
||||
const pending = [...this.cwdBySession.keys()].filter((id) => !this.childOfSession(id)).length;
|
||||
if (pending >= MAX_PENDING_PARENTS) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: dropping cwd for unannounced subagent ${agentId}; pending buffer limit reached`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.cwdBySession.set(sessionId, cwd);
|
||||
if (!known) return;
|
||||
// An announcement in flight may have been built without this `cwd`.
|
||||
if (known.announcePromise) await known.announcePromise.catch(() => {});
|
||||
if (known.announced) await this.publishCwd(known);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `cwd` of `child` that the client does not hold yet: its working
|
||||
* directory when that differs from its parent's, `null` when it is back in
|
||||
* the parent's after it reported another, else undefined.
|
||||
*/
|
||||
private async unreportedCwd(child: NativeSubagent): Promise<string | null | undefined> {
|
||||
const cwd = this.cwdBySession.get(child.sessionId);
|
||||
if (cwd === undefined) return undefined;
|
||||
const inherited = this.effectiveCwd(child.parentSessionId);
|
||||
if (inherited !== undefined && (await sameDirectory(cwd, inherited))) {
|
||||
return child.reportedCwd ? null : undefined;
|
||||
}
|
||||
return cwd === child.reportedCwd ? undefined : cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* The working directory of the session `sessionId` as the client holds it:
|
||||
* the root's from session setup, a child's reported `cwd`, else its
|
||||
* parent's.
|
||||
*/
|
||||
private effectiveCwd(sessionId: string): string | undefined {
|
||||
const seen = new Set<string>();
|
||||
let child = this.childOfSession(sessionId);
|
||||
while (child && !seen.has(child.sessionId)) {
|
||||
if (child.reportedCwd) return child.reportedCwd;
|
||||
seen.add(child.sessionId);
|
||||
child = this.childOfSession(child.parentSessionId);
|
||||
}
|
||||
return this.session.cwd;
|
||||
}
|
||||
|
||||
/** The RFD form: sends the `cwd` of the announced `child` that the client does not hold. */
|
||||
private async publishCwd(child: NativeSubagent): Promise<void> {
|
||||
if (this.form !== "rfd") return;
|
||||
// Concurrent hooks of one child report the same `cwd` once.
|
||||
const publishing = (child.cwdPublishing ?? Promise.resolve()).then(async () => {
|
||||
const cwd = await this.unreportedCwd(child);
|
||||
if (cwd === undefined) return;
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, cwd },
|
||||
});
|
||||
child.reportedCwd = cwd;
|
||||
});
|
||||
child.cwdPublishing = publishing.catch(() => {});
|
||||
await publishing;
|
||||
}
|
||||
|
||||
private async routeUpdate(
|
||||
notification: AcpSessionNotification,
|
||||
deliver: Publish,
|
||||
@@ -920,9 +1012,14 @@ export class NativeSubagentRuntime {
|
||||
this.childByParentToolUse.set(entry.launchId, child);
|
||||
}
|
||||
if (child.announced) continue;
|
||||
// A hook of the resumed agent may have reported a newer `cwd`.
|
||||
if (entry.cwd && !this.cwdBySession.has(child.sessionId)) {
|
||||
this.cwdBySession.set(child.sessionId, entry.cwd);
|
||||
}
|
||||
if (entry.agentId === agentId) {
|
||||
await this.announce(child);
|
||||
} else {
|
||||
const cwd = await this.unreportedCwd(child);
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
@@ -930,8 +1027,10 @@ export class NativeSubagentRuntime {
|
||||
sessionId: child.sessionId,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...(child.description ? { description: child.description } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
},
|
||||
});
|
||||
if (cwd) child.reportedCwd = cwd;
|
||||
child.announced = true;
|
||||
}
|
||||
for (const pending of this.takePending(entry.launchId)) await deliver(pending);
|
||||
@@ -1100,6 +1199,7 @@ export class NativeSubagentRuntime {
|
||||
private withdraw(taskId: string, child: NativeSubagent): void {
|
||||
this.children.delete(taskId);
|
||||
this.configBySession.delete(child.sessionId);
|
||||
this.cwdBySession.delete(child.sessionId);
|
||||
if (child.parentToolUseId) {
|
||||
this.takePending(child.parentToolUseId);
|
||||
this.cleanupControl(child.parentToolUseId);
|
||||
@@ -1122,12 +1222,16 @@ export class NativeSubagentRuntime {
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces `child` in the client's form, then its configuration. A runtime
|
||||
* without a form never does.
|
||||
* Announces `child` in the client's form, with its `cwd` in the RFD form,
|
||||
* then its configuration. A runtime without a form never does.
|
||||
*/
|
||||
private async announce(child: NativeSubagent): Promise<void> {
|
||||
if (!this.form) return;
|
||||
await announceNativeSubagent(child, this.publish, this.form);
|
||||
const cwd =
|
||||
this.form === "rfd" && !child.announced ? await this.unreportedCwd(child) : undefined;
|
||||
await announceNativeSubagent(child, this.publish, this.form, cwd ?? undefined);
|
||||
// A hook that reported a `cwd` while the announcement was being built.
|
||||
await this.publishCwd(child);
|
||||
await this.publishConfiguration(child);
|
||||
}
|
||||
|
||||
@@ -1393,6 +1497,7 @@ export class NativeSubagentRuntime {
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.configBySession.clear();
|
||||
this.cwdBySession.clear();
|
||||
this.childByParentToolUse.clear();
|
||||
this.taskFinishPromises.clear();
|
||||
this.senderByToolUse.clear();
|
||||
@@ -1598,6 +1703,7 @@ export async function announceNativeSubagent(
|
||||
child: NativeSubagent,
|
||||
publish: Publish,
|
||||
form: SubagentForm,
|
||||
cwd?: string,
|
||||
): Promise<void> {
|
||||
if (child.announced) return;
|
||||
if (child.announcePromise) return child.announcePromise;
|
||||
@@ -1611,10 +1717,12 @@ export async function announceNativeSubagent(
|
||||
sessionId: child.sessionId,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...(child.description ? { description: child.description } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
capabilities: { cancel: {} },
|
||||
state: { state: "running" },
|
||||
},
|
||||
});
|
||||
if (cwd) child.reportedCwd = cwd;
|
||||
child.announced = true;
|
||||
// The immediate parent made the Agent/Task call that launched the child.
|
||||
if (child.prompt) {
|
||||
@@ -1746,11 +1854,11 @@ export function linkedSessionMessages(
|
||||
return [
|
||||
{
|
||||
sessionId: senderSessionId,
|
||||
update: { ...message, recipientMessageId: toolUseId } as AcpSessionNotification["update"],
|
||||
update: { ...message, recipientMessageId: toolUseId },
|
||||
},
|
||||
{
|
||||
sessionId: recipientSessionId,
|
||||
update: { ...message, senderMessageId: toolUseId } as AcpSessionNotification["update"],
|
||||
update: { ...message, senderMessageId: toolUseId },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1840,7 +1948,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 the v2 surface.
|
||||
* an internal error, as for a failed turn on v2.
|
||||
*
|
||||
* 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
|
||||
@@ -1868,26 +1976,22 @@ export function rfdEndState(state: SubagentState, end: TaskEnd | undefined): Wor
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: the parent's labels for a child. The title is the agent's
|
||||
* name, with its role after it when both are known, else the role. The
|
||||
* description is the task's short description, never the prompt.
|
||||
* The RFD form: the parent's labels for a child. The title is what tells
|
||||
* siblings apart: the agent's name when it has one, else the task's short
|
||||
* description (never the prompt), else its role. The description is the
|
||||
* role, its `subagent_type`, unless the role is already the title.
|
||||
*/
|
||||
export function rfdLabels(
|
||||
name: unknown,
|
||||
role: unknown,
|
||||
description: unknown,
|
||||
): { title?: string; role?: string; description?: string } {
|
||||
const agentName = nonBlankString(name);
|
||||
const agentRole = nonBlankString(role);
|
||||
const title =
|
||||
agentName && agentRole && agentName !== agentRole
|
||||
? `${agentName} (${agentRole})`
|
||||
: (agentName ?? agentRole);
|
||||
const text = nonBlankString(description);
|
||||
const title = nonBlankString(name) ?? nonBlankString(description) ?? agentRole;
|
||||
return {
|
||||
...(title ? { title } : {}),
|
||||
...(agentRole ? { role: agentRole } : {}),
|
||||
...(text ? { description: text } : {}),
|
||||
...(agentRole && agentRole !== title ? { description: agentRole } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1999,7 +2103,7 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole
|
||||
* one), a SendMessage call whose message reached no announced child, or a
|
||||
* failed SubagentHandback call.
|
||||
*/
|
||||
function unexposedControlFallback(
|
||||
export function unexposedControlFallback(
|
||||
initial: AcpSessionNotification | undefined,
|
||||
terminal: AcpSessionNotification,
|
||||
sessionId: string,
|
||||
@@ -2093,7 +2197,8 @@ function subagentDescription(prompt: unknown, description: unknown): string {
|
||||
return "Delegated task";
|
||||
}
|
||||
|
||||
function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
/** 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 {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return undefined;
|
||||
const value = input as Record<string, unknown>;
|
||||
const identity: SubagentIdentity = {
|
||||
@@ -2105,7 +2210,7 @@ function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
return Object.values(identity).some(Boolean) ? identity : undefined;
|
||||
}
|
||||
|
||||
function mergeSubagentIdentity(
|
||||
export function mergeSubagentIdentity(
|
||||
previous: SubagentIdentity | undefined,
|
||||
next: SubagentIdentity,
|
||||
): SubagentIdentity {
|
||||
@@ -2156,3 +2261,15 @@ function promptField(prompt: string | undefined): { prompt?: string } {
|
||||
function nonBlankString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `left` and `right` are the same directory, also when one is
|
||||
* spelled through a symlink, e.g. `/tmp/project` and `/private/tmp/project`
|
||||
* on macOS. A path that does not exist is compared as written.
|
||||
*/
|
||||
export async function sameDirectory(left: string, right: string): Promise<boolean> {
|
||||
if (path.resolve(left) === path.resolve(right)) return true;
|
||||
const real = (dir: string) => realpath(dir).catch(() => path.resolve(dir));
|
||||
const [realLeft, realRight] = await Promise.all([real(left), real(right)]);
|
||||
return realLeft === realRight;
|
||||
}
|
||||
|
||||
41
src/new-session-meta.ts
Normal file
41
src/new-session-meta.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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[];
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { PermissionMode, Settings } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
interface PermissionModeLogger {
|
||||
error: (...args: unknown[]) => void;
|
||||
@@ -58,3 +58,10 @@ export function resolvePermissionMode(
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/** Claude Code reads `disableAutoMode` both at the top level and under `permissions`. */
|
||||
export function autoModeDisabled(settings: Settings): boolean {
|
||||
return (
|
||||
settings.disableAutoMode === "disable" || settings.permissions?.disableAutoMode === "disable"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,18 +120,64 @@ export function buildClaudePermissionPresentation(
|
||||
}),
|
||||
// Only AIR gets the permission presentation.
|
||||
...(title && capabilities.air.client
|
||||
? {
|
||||
_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 } : {}),
|
||||
}),
|
||||
}
|
||||
? { _meta: airPermissionMeta({ title, description, defaultToNo: value.defaultToNo }) }
|
||||
: {}),
|
||||
...(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
Normal file
110
src/plans.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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,3 +42,51 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
129
src/prompt-to-claude.ts
Normal file
129
src/prompt-to-claude.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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
Normal file
421
src/providers.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 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 {};
|
||||
}
|
||||
129
src/remote-control.ts
Normal file
129
src/remote-control.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* `/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
|
||||
);
|
||||
}
|
||||
105
src/result-end.ts
Normal file
105
src/result-end.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 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
|
||||
* `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"
|
||||
);
|
||||
}
|
||||
853
src/sdk-to-acp.ts
Normal file
853
src/sdk-to-acp.ts
Normal file
@@ -0,0 +1,853 @@
|
||||
/**
|
||||
* 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,7 +1,8 @@
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { v1AgentApp, type ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
import { v2AgentApp, type AgentHandle } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* The ACP versions that the adapter serves: `v1` or `v2` alone, or both with
|
||||
@@ -32,13 +33,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: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
let agent: AgentHandle | undefined;
|
||||
const onAgent = (created: AgentHandle) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols).connect(
|
||||
v2.ndJsonStream(output, input),
|
||||
);
|
||||
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols, {
|
||||
onV2Agent: onAgent,
|
||||
}).connect(v2.ndJsonStream(output, input));
|
||||
return {
|
||||
closed,
|
||||
dispose: async () => {
|
||||
@@ -53,15 +54,20 @@ 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 the connection once the router has opened it.
|
||||
* 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.
|
||||
*/
|
||||
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, onAgent));
|
||||
if (protocols !== "v1") {
|
||||
router.withV2(v2AgentApp(logger, onV2Agent));
|
||||
}
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** 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";
|
||||
|
||||
244
src/session-config-options.ts
Normal file
244
src/session-config-options.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -14,14 +14,31 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
|
||||
|
||||
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
|
||||
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
|
||||
// ultracode on at any level, so carry a settings-requested ultracode along
|
||||
// with every effort apply.
|
||||
// ultracode on at any level, so send `ultracode: true` with every effort apply
|
||||
// while the session has ultracode on. When it is off, the key is omitted: an
|
||||
// effort change then leaves ultracode off, as it already was.
|
||||
export function effortFlagSettings(
|
||||
value: string | undefined,
|
||||
settings: Settings,
|
||||
ultracode: boolean,
|
||||
): { effortLevel: EffortLevel | null; ultracode?: true } {
|
||||
const effortLevel = toSdkEffortLevel(value);
|
||||
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
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 {
|
||||
@@ -132,3 +149,130 @@ 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
81
src/session-env.ts
Normal file
81
src/session-env.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
190
src/session-fast-mode.ts
Normal file
190
src/session-fast-mode.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
type ListSessionsResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, Logger, Session } from "../acp-agent.js";
|
||||
import type { Session } from "../acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import {
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_BATCH_GET_CAPABILITY,
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
@@ -46,6 +49,8 @@ import {
|
||||
renameTo,
|
||||
parseRenameSessionRequest,
|
||||
parseSessionIdRequest,
|
||||
parseBatchGetSessionsRequest,
|
||||
SESSION_BATCH_GET_METHOD,
|
||||
SESSION_ARCHIVE_METHOD,
|
||||
SESSION_RENAME_METHOD,
|
||||
SESSION_UNARCHIVE_METHOD,
|
||||
@@ -54,6 +59,8 @@ import {
|
||||
type RetitleOptions,
|
||||
type SessionIdRequest,
|
||||
type TitleChange,
|
||||
type BatchGetSessionsRequest,
|
||||
type BatchGetSessionsResponse,
|
||||
} from "./service.js";
|
||||
|
||||
/** What the session index keeps on each `Session`. */
|
||||
@@ -116,13 +123,11 @@ export class SessionIndexConnection {
|
||||
|
||||
constructor(private readonly host: SessionIndexHost) {}
|
||||
|
||||
/** 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 {
|
||||
/** Sets the index up for the client of `initialize`. */
|
||||
negotiate(request: InitializeRequest): void {
|
||||
this.service?.dispose();
|
||||
this.service = undefined;
|
||||
if (
|
||||
!options.v2 &&
|
||||
this.host.isAirClient() &&
|
||||
clientSupportsAirCapability(request.clientCapabilities, AIR_SESSION_INDEX_CAPABILITY)
|
||||
) {
|
||||
@@ -144,12 +149,13 @@ export class SessionIndexConnection {
|
||||
}
|
||||
|
||||
/** The AIR capabilities the agent advertises for the index: the index
|
||||
* itself, archive, rename, the list subscription and the `agentWorktree`
|
||||
* itself, batch get, archive, rename, the list subscription and the `agentWorktree`
|
||||
* row field, all only to a client that declared `sessionIndex`. */
|
||||
capabilities(): string[] {
|
||||
return this.service
|
||||
? [
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_BATCH_GET_CAPABILITY,
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
@@ -230,6 +236,12 @@ export class SessionIndexConnection {
|
||||
return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId));
|
||||
}
|
||||
|
||||
batchGet(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse> {
|
||||
return this.requireService(SESSION_BATCH_GET_METHOD).batchGet(params, (sessionId) =>
|
||||
this.ownSessionState(sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
/** An AIR client archives with session/delete (see {@link deleteSession}):
|
||||
* its archived sessions (an archived custom title) stay hidden from the
|
||||
* old list, as when the delete removed them. */
|
||||
@@ -448,6 +460,7 @@ export class SessionIndexConnection {
|
||||
|
||||
/** The agent methods behind the session index extension methods. */
|
||||
type SessionIndexMethods = {
|
||||
batchGetSessions(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse>;
|
||||
subscribeSessionList(params: ListSubscribeRequest): Promise<ListSubscribeResponse>;
|
||||
unsubscribeSessionList(params: ListUnsubscribeRequest): Promise<EmptyResponse>;
|
||||
renameSessionTitle(params: RenameSessionRequest): Promise<EmptyResponse>;
|
||||
@@ -455,10 +468,15 @@ type SessionIndexMethods = {
|
||||
unarchiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
|
||||
};
|
||||
|
||||
/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`,
|
||||
/** Routes `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`,
|
||||
* `_session/rename`, `_session/archive` and `_session/unarchive`. */
|
||||
export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp {
|
||||
return app
|
||||
.onRequest<BatchGetSessionsRequest, BatchGetSessionsResponse>(
|
||||
SESSION_BATCH_GET_METHOD,
|
||||
{ parse: parseBatchGetSessionsRequest },
|
||||
(ctx) => agent().batchGetSessions(ctx.params),
|
||||
)
|
||||
.onRequest<ListSubscribeRequest, ListSubscribeResponse>(
|
||||
LIST_SUBSCRIBE_METHOD,
|
||||
{ parse: parseListSubscribeRequest },
|
||||
|
||||
@@ -104,6 +104,17 @@ export async function canonicalPath(cwd: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the path exists, including a symlink whose target is missing. */
|
||||
export async function exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The names of the project directories of `cwd` among `rootEntries`. */
|
||||
export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] {
|
||||
const exact = encodeProjectPath(cwd);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RequestError,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
type SessionInfo,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { airExtensionMeta } from "../air-extension.js";
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
import { LiveSessionRegistry } from "./live-registry.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
errorCode,
|
||||
exists,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
projectDirMatches,
|
||||
@@ -65,6 +66,7 @@ import { sessionInfoOf } from "./session-info.js";
|
||||
export const SESSION_RENAME_METHOD = "_session/rename";
|
||||
export const SESSION_ARCHIVE_METHOD = "_session/archive";
|
||||
export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
|
||||
export const SESSION_BATCH_GET_METHOD = "_session/batch_get";
|
||||
|
||||
/** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
|
||||
const RESOURCE_NOT_FOUND = -32002;
|
||||
@@ -73,6 +75,27 @@ const CURSOR_VERSION = 5;
|
||||
|
||||
export type SessionIdRequest = { sessionId: string };
|
||||
export type RenameSessionRequest = { sessionId: string; title: string };
|
||||
export type BatchGetSessionsRequest = { sessionIds: string[] };
|
||||
export type BatchGetSessionsResponse = {
|
||||
sessions: SessionInfo[];
|
||||
};
|
||||
|
||||
export function parseBatchGetSessionsRequest(value: unknown): BatchGetSessionsRequest {
|
||||
const record = asRecord(value);
|
||||
const ids = record.sessionIds;
|
||||
if (
|
||||
Object.keys(record).length !== 1 ||
|
||||
!Array.isArray(ids) ||
|
||||
ids.some((id) => typeof id !== "string" || !id.trim()) ||
|
||||
new Set(ids).size !== ids.length
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"params require only sessionIds: distinct, non-blank strings",
|
||||
);
|
||||
}
|
||||
return { sessionIds: ids };
|
||||
}
|
||||
|
||||
export function sessionNotFound(sessionId: string): RequestError {
|
||||
return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
|
||||
@@ -364,26 +387,6 @@ function isSdkNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
|
||||
}
|
||||
|
||||
async function exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The error the SDK `deleteSession` gives for a session id it rejects or
|
||||
* does not find, which a client without `sessionIndex` got before. */
|
||||
function sdkDeleteError(sessionId: string): Error {
|
||||
return new Error(
|
||||
isSessionId(sessionId)
|
||||
? `Session ${sessionId} not found in any project directory`
|
||||
: `Invalid sessionId: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/delete` of a client that is neither AIR nor a `sessionIndex` client:
|
||||
* the SDK delete of the session's transcript. A session without one succeeds,
|
||||
@@ -409,16 +412,18 @@ export async function deleteSessionIfStored(
|
||||
/**
|
||||
* `session/delete` of an AIR client without `sessionIndex`, which uses delete
|
||||
* to mark a session done: archives it instead, so the transcript survives. A
|
||||
* session without a transcript fails as the SDK delete did.
|
||||
* session without a transcript has nothing to archive and succeeds, as ACP
|
||||
* asks of a delete of a session that never existed (see
|
||||
* {@link deleteSessionIfStored}).
|
||||
*/
|
||||
export async function archiveInsteadOfDelete(
|
||||
sessionId: string,
|
||||
service: SessionIndexService,
|
||||
): Promise<void> {
|
||||
if (!isSessionId(sessionId)) throw sdkDeleteError(sessionId);
|
||||
if (!isSessionId(sessionId)) return;
|
||||
// The SDK delete skips empty transcripts, so they do not count.
|
||||
const found = await service.index.findTranscripts(sessionId);
|
||||
if (found.length === 0) throw sdkDeleteError(sessionId);
|
||||
if (found.length === 0) return;
|
||||
await service.retitle(sessionId, archiveTo(true), { sidecar: "existing" });
|
||||
}
|
||||
|
||||
@@ -520,6 +525,22 @@ export class SessionIndexService {
|
||||
return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
|
||||
}
|
||||
|
||||
/** Metadata for known ids, independent of list pages and project filters. */
|
||||
async batchGet(
|
||||
{ sessionIds }: BatchGetSessionsRequest,
|
||||
own: (sessionId: string) => OwnSessionState | undefined,
|
||||
): Promise<BatchGetSessionsResponse> {
|
||||
if (sessionIds.length === 0) return { sessions: [] };
|
||||
const live = await this.registry.snapshot();
|
||||
const now = this.now();
|
||||
const rows = await this.index.getSessions(sessionIds);
|
||||
return {
|
||||
sessions: rows.map((row) =>
|
||||
sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `mutation` after the previous mutation of the session ended. */
|
||||
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
|
||||
|
||||
@@ -26,6 +26,8 @@ import { effectiveTitle, visibleTitle } from "./archive-title.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
encodeProjectPath,
|
||||
errorCode,
|
||||
exists,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
normalizePath,
|
||||
@@ -58,11 +60,15 @@ import { isInLinkedWorktree, worktreeCounterparts } from "./worktrees.js";
|
||||
|
||||
export const DEFAULT_LIST_LIMIT = 50;
|
||||
export const MAX_LIST_LIMIT = 200;
|
||||
/** Discovery could not establish which sessions the store contains. */
|
||||
export class SessionStoreUnavailableError extends Error {}
|
||||
/** Entries of the metadata cache: a few hundred bytes each, so the
|
||||
* transcripts of a large history (tens of thousands) stay cached and deep
|
||||
* pages do not read them again. */
|
||||
const METADATA_CACHE_SIZE = 50_000;
|
||||
const READ_BATCH_SIZE = 16;
|
||||
/** Session ids looked up together by {@link SessionIndex.getSessions}. */
|
||||
const LOOKUP_CHUNK_SIZE = 100;
|
||||
/** Transcripts read at most per directory to recover a sibling's cwd. */
|
||||
const MAX_CWD_PROBES_PER_DIR = 64;
|
||||
/** How long a directory that gave no cwd is not probed again. */
|
||||
@@ -97,6 +103,24 @@ export type TranscriptCandidate = {
|
||||
ino: number;
|
||||
};
|
||||
|
||||
type TranscriptFile = Pick<
|
||||
TranscriptCandidate,
|
||||
"sessionId" | "filePath" | "dirName" | "projectPath"
|
||||
>;
|
||||
|
||||
/** Non-empty transcript files, with bounded stats shared by lists and id lookups. */
|
||||
async function transcriptCandidates(
|
||||
files: readonly TranscriptFile[],
|
||||
): Promise<TranscriptCandidate[]> {
|
||||
const stats = await statFiles(files.map(({ filePath }) => filePath));
|
||||
return files.flatMap((file, i) => {
|
||||
const current = stats[i];
|
||||
return current?.isFile() && current.size > 0
|
||||
? [{ ...file, mtimeMs: current.mtimeMs, size: current.size, ino: current.ino }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/** The cached metadata of one transcript. */
|
||||
type TranscriptMetadata = {
|
||||
/** The visible title: without the archive prefix. */
|
||||
@@ -526,25 +550,16 @@ export class SessionIndex {
|
||||
const listed = await this.listings.namesOfAll(dirPaths);
|
||||
const files = dirs.flatMap(({ dirName, projectPath }, i) =>
|
||||
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))).map(
|
||||
(name) => ({ name, filePath: path.join(root, dirName, name), dirName, projectPath }),
|
||||
(name) => ({
|
||||
sessionId: name.slice(0, -6),
|
||||
filePath: path.join(root, dirName, name),
|
||||
dirName,
|
||||
projectPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const stats = await statFiles(files.map(({ filePath }) => filePath));
|
||||
const all: TranscriptCandidate[] = [];
|
||||
files.forEach(({ name, filePath, dirName, projectPath }, i) => {
|
||||
const fileStats = stats[i];
|
||||
if (!fileStats?.isFile() || fileStats.size === 0) return;
|
||||
all.push({
|
||||
sessionId: name.slice(0, -6),
|
||||
filePath,
|
||||
dirName,
|
||||
projectPath,
|
||||
mtimeMs: fileStats.mtimeMs,
|
||||
size: fileStats.size,
|
||||
ino: fileStats.ino,
|
||||
});
|
||||
});
|
||||
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates: all };
|
||||
const candidates = await transcriptCandidates(files);
|
||||
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -580,6 +595,90 @@ export class SessionIndex {
|
||||
};
|
||||
}
|
||||
|
||||
/** Known ids in request order, using the same rows as the list.
|
||||
* Addresses exact filenames; only rows without a cwd need sibling discovery. */
|
||||
async getSessions(sessionIds: readonly string[]): Promise<IndexRow[]> {
|
||||
if (sessionIds.length === 0) return [];
|
||||
const root = projectsRoot();
|
||||
let projectEntries: string[];
|
||||
try {
|
||||
projectEntries = await fs.readdir(root);
|
||||
} catch (cause) {
|
||||
if (errorCode(cause) === "ENOENT" && !(await exists(root))) {
|
||||
return [];
|
||||
}
|
||||
throw new SessionStoreUnavailableError("Session store could not be read", { cause });
|
||||
}
|
||||
const ids = sessionIds.filter(isSessionId);
|
||||
const byId = new Map<string, IndexRow>();
|
||||
// Paths are project directories × ids: bound them per chunk, not per request.
|
||||
for (let start = 0; start < ids.length; start += LOOKUP_CHUNK_SIZE) {
|
||||
const chunk = ids.slice(start, start + LOOKUP_CHUNK_SIZE);
|
||||
for (const row of await this.lookupRows(root, projectEntries, chunk)) {
|
||||
byId.set(row.sessionId, row);
|
||||
}
|
||||
}
|
||||
return sessionIds.flatMap((id) => {
|
||||
const row = byId.get(id);
|
||||
return row ? [row] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** The rows of `ids` found under `projectEntries`. All copies of an id are
|
||||
* in the same call, so copy selection is unchanged by chunking. */
|
||||
private async lookupRows(
|
||||
root: string,
|
||||
projectEntries: readonly string[],
|
||||
ids: readonly string[],
|
||||
): Promise<IndexRow[]> {
|
||||
let candidates = await transcriptCandidates(
|
||||
projectEntries.flatMap((dirName) =>
|
||||
ids.map((sessionId) => ({
|
||||
sessionId,
|
||||
filePath: path.join(root, dirName, `${sessionId}.jsonl`),
|
||||
dirName,
|
||||
})),
|
||||
),
|
||||
);
|
||||
if (CASE_INSENSITIVE_PATHS) {
|
||||
// On a case-insensitive file system, stat of `ABC.jsonl` also finds
|
||||
// `abc.jsonl`. Keep a file only when its real name is the requested one,
|
||||
// or differs by more than case (a symlink, as the list would read it).
|
||||
// A failed realpath means the file is unreadable, so the id is omitted.
|
||||
const exact = await Promise.all(
|
||||
candidates.map(async ({ filePath }) => {
|
||||
try {
|
||||
const actual = path.basename(await fs.realpath(filePath));
|
||||
const expected = path.basename(filePath);
|
||||
return actual === expected || actual.toLowerCase() !== expected.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
candidates = candidates.filter((_, i) => exact[i]);
|
||||
}
|
||||
return this.collectRows(candidates.sort(byMtimeDescending), {
|
||||
inScope: scopeOf(undefined),
|
||||
archived: "all",
|
||||
wanted: Infinity,
|
||||
unread: async (read, dirNames) => {
|
||||
const dirs = [...dirNames];
|
||||
const listed = await this.listings.namesOfAll(dirs.map((dir) => path.join(root, dir)));
|
||||
const siblings = dirs.flatMap((dirName, i) =>
|
||||
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6)))
|
||||
.map((name) => ({
|
||||
sessionId: name.slice(0, -6),
|
||||
filePath: path.join(root, dirName, name),
|
||||
dirName,
|
||||
}))
|
||||
.filter(({ filePath }) => !read.has(filePath)),
|
||||
);
|
||||
return (await transcriptCandidates(siblings)).sort(byMtimeDescending);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Every transcript file of `sessionId` (see {@link scanSession}). Empty
|
||||
* files only with `includeEmpty`. */
|
||||
async findTranscripts(
|
||||
@@ -778,9 +877,12 @@ export class SessionIndex {
|
||||
archived: ArchivedFilter;
|
||||
after?: ListCursor;
|
||||
wanted: number;
|
||||
/** The transcripts not read (by path), which may supply a sibling's
|
||||
* cwd. */
|
||||
unread: (read: ReadonlySet<string>) => readonly TranscriptCandidate[];
|
||||
/** The unread transcripts of the directories still eligible for cwd
|
||||
* recovery. Called only after checking the negative cache. */
|
||||
unread?: (
|
||||
read: ReadonlySet<string>,
|
||||
dirNames: ReadonlySet<string>,
|
||||
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>;
|
||||
restat?: boolean;
|
||||
},
|
||||
): Promise<IndexRow[]> {
|
||||
@@ -852,14 +954,15 @@ export class SessionIndex {
|
||||
}
|
||||
await settlePending();
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const unread = options.unread;
|
||||
if (pending.length > 0 && unread) {
|
||||
// A row recovered after the scan stopped still sorts into the page: it
|
||||
// was read before the stop, and more rows only raise the bound. A
|
||||
// sibling that the archive filter or the cursor leaves out may still
|
||||
// supply the cwd.
|
||||
await this.learnDirCwds(
|
||||
new Set(pending.map(({ candidate }) => candidate.dirName)),
|
||||
options.unread(read),
|
||||
(dirNames) => unread(read, dirNames),
|
||||
options.restat ?? false,
|
||||
);
|
||||
await settlePending();
|
||||
@@ -893,7 +996,9 @@ export class SessionIndex {
|
||||
* changes. `restat` as in {@link collectRows}. */
|
||||
private async learnDirCwds(
|
||||
dirNames: ReadonlySet<string>,
|
||||
unread: readonly TranscriptCandidate[],
|
||||
unread: (
|
||||
dirNames: ReadonlySet<string>,
|
||||
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>,
|
||||
restatFirst: boolean,
|
||||
): Promise<void> {
|
||||
const root = projectsRoot();
|
||||
@@ -909,8 +1014,9 @@ export class SessionIndex {
|
||||
if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS) continue;
|
||||
mtimes.set(dirName, mtimeMs);
|
||||
}
|
||||
if (mtimes.size === 0) return;
|
||||
const probes = new Map<string, number>();
|
||||
let remaining = unread.filter((candidate) => {
|
||||
let remaining = (await unread(new Set(mtimes.keys()))).filter((candidate) => {
|
||||
if (!mtimes.has(candidate.dirName)) return false;
|
||||
const count = probes.get(candidate.dirName) ?? 0;
|
||||
if (count >= MAX_CWD_PROBES_PER_DIR) return false;
|
||||
|
||||
@@ -66,29 +66,50 @@ type ModeConfigSession = SessionMode & {
|
||||
configOptions: SessionConfigOption[];
|
||||
};
|
||||
|
||||
type InitializeSessionModeParams = {
|
||||
query: Pick<Query, "setPermissionMode">;
|
||||
requestedMode: PermissionMode;
|
||||
currentModelInfo?: ModelInfo;
|
||||
currentModelId: string;
|
||||
/** Which optional modes the session's mode catalog offers. */
|
||||
export type ModeAvailability = {
|
||||
/** When false, bypassPermissions is omitted from the mode catalog. */
|
||||
allowBypass: boolean;
|
||||
/** When false (`disableAutoMode: "disable"` in settings), auto is omitted. */
|
||||
allowAuto: boolean;
|
||||
};
|
||||
|
||||
type InitializeSessionModeParams = ModeAvailability & {
|
||||
query: Pick<Query, "setPermissionMode">;
|
||||
requestedMode: PermissionMode;
|
||||
/** The mode is the built-in default: no request, transcript or setting chose it. */
|
||||
builtInDefault?: boolean;
|
||||
currentModelInfo?: ModelInfo;
|
||||
currentModelId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission mode a new Claude query starts in.
|
||||
*
|
||||
* An explicit mode wins. A resume or a load continues in the mode of the last
|
||||
* user record of the transcript. If that mode is unknown or not available now,
|
||||
* for example bypass, the session starts in `defaultMode` of the settings.
|
||||
* user record of the transcript. If that mode is unknown or absent from the
|
||||
* session's mode catalog, use an available `defaultMode` from settings, or
|
||||
* `default` when that setting is unavailable too.
|
||||
*
|
||||
* With no `defaultMode` set, the session starts in `auto` when the catalog
|
||||
* offers it and in `default` otherwise (`builtInDefault: true`), like an
|
||||
* interactive Claude Code session. Claude Code also starts organizations with
|
||||
* the HIPAA configuration in `default`; the SDK does not report that
|
||||
* configuration, so those sessions start in `auto` here.
|
||||
*/
|
||||
export function resolveInitialPermissionMode(
|
||||
sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown },
|
||||
logger: { error: (...args: unknown[]) => void },
|
||||
allowBypass: boolean,
|
||||
): PermissionMode {
|
||||
availability: ModeAvailability,
|
||||
): { mode: PermissionMode; builtInDefault: boolean } {
|
||||
const { allowBypass } = availability;
|
||||
const available = (mode: PermissionMode) =>
|
||||
buildAvailableModes(availability).some((entry) => entry.id === mode);
|
||||
if (sources.explicit !== undefined) {
|
||||
return resolvePermissionMode(sources.explicit, logger, allowBypass);
|
||||
return {
|
||||
mode: resolvePermissionMode(sources.explicit, logger, allowBypass),
|
||||
builtInDefault: false,
|
||||
};
|
||||
}
|
||||
if (sources.resumed !== undefined) {
|
||||
let rejected = false;
|
||||
@@ -97,13 +118,23 @@ export function resolveInitialPermissionMode(
|
||||
{ error: () => (rejected = true) },
|
||||
allowBypass,
|
||||
);
|
||||
if (!rejected) return mode;
|
||||
if (!rejected && available(mode)) {
|
||||
return { mode, builtInDefault: false };
|
||||
}
|
||||
logger.error(
|
||||
`Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` +
|
||||
`not available in this session.`,
|
||||
);
|
||||
}
|
||||
return resolvePermissionMode(sources.defaultMode, logger, allowBypass);
|
||||
if (sources.defaultMode === undefined) {
|
||||
return { mode: available("auto") ? "auto" : "default", builtInDefault: true };
|
||||
}
|
||||
const configuredMode = resolvePermissionMode(sources.defaultMode, logger, allowBypass);
|
||||
// A rejected transcript mode falls back only to a mode this session can offer.
|
||||
if (sources.resumed !== undefined && !available(configuredMode)) {
|
||||
return { mode: "default", builtInDefault: false };
|
||||
}
|
||||
return { mode: configuredMode, builtInDefault: false };
|
||||
}
|
||||
|
||||
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
|
||||
@@ -113,22 +144,40 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
async initialize({
|
||||
query,
|
||||
requestedMode,
|
||||
builtInDefault = false,
|
||||
currentModelInfo,
|
||||
currentModelId,
|
||||
allowBypass,
|
||||
allowAuto,
|
||||
}: InitializeSessionModeParams): Promise<{
|
||||
modes: SessionModeState;
|
||||
autoModeFallbackWarningPending: boolean;
|
||||
}> {
|
||||
const availableModes = this.buildAvailableModes(allowBypass);
|
||||
const availableModes = buildAvailableModes(
|
||||
{ allowBypass, allowAuto },
|
||||
this.options.airClient?.() ?? false,
|
||||
);
|
||||
let effectiveMode = requestedMode;
|
||||
let autoModeFallbackWarningPending = false;
|
||||
|
||||
if (
|
||||
const autoUnsupported =
|
||||
effectiveMode === "auto" &&
|
||||
currentModelInfo !== undefined &&
|
||||
currentModelInfo.supportsAutoMode !== true
|
||||
) {
|
||||
currentModelInfo.supportsAutoMode !== true;
|
||||
if (!this.isAvailable(availableModes, effectiveMode)) {
|
||||
this.options.logError(
|
||||
`permissions.defaultMode "${effectiveMode}" is not available in ` +
|
||||
`this session; falling back to "default".`,
|
||||
);
|
||||
effectiveMode = "default";
|
||||
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
|
||||
} else if (autoUnsupported && builtInDefault) {
|
||||
// Like Claude Code: a built-in Auto default the model can't serve starts
|
||||
// in Manual, without a warning. Nobody chose Auto, so don't escalate to
|
||||
// acceptEdits.
|
||||
effectiveMode = "default";
|
||||
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
|
||||
} else if (autoUnsupported) {
|
||||
this.options.logError(
|
||||
`permissions.defaultMode "auto" is not available for model ` +
|
||||
`"${currentModelId}"; falling back to "${AUTO_MODE_FALLBACK}".`,
|
||||
@@ -136,13 +185,6 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
effectiveMode = AUTO_MODE_FALLBACK;
|
||||
autoModeFallbackWarningPending = true;
|
||||
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
|
||||
} else if (!this.isAvailable(availableModes, effectiveMode)) {
|
||||
this.options.logError(
|
||||
`permissions.defaultMode "${effectiveMode}" is not available in ` +
|
||||
`this session; falling back to "default".`,
|
||||
);
|
||||
effectiveMode = "default";
|
||||
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -247,7 +289,10 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
const session = this.requireOpenSession(sessionId);
|
||||
const requestedMode = this.parseMode(modeId);
|
||||
if (!this.isAvailable(session.modes.availableModes, requestedMode)) {
|
||||
throw new Error(`Mode ${modeId} is not available in this session`);
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Mode ${modeId} is not available in this session`,
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveMode = this.effectiveMode(session, requestedMode);
|
||||
@@ -314,7 +359,7 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
|
||||
private requireOpenSession(sessionId: string): S {
|
||||
const session = this.options.getSession(sessionId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (!session) throw RequestError.sessionNotFound(sessionId);
|
||||
if (session.queryClosed) {
|
||||
throw RequestError.internalError(undefined, this.options.sessionEndedMessage);
|
||||
}
|
||||
@@ -343,54 +388,10 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
case "plan":
|
||||
return modeId;
|
||||
default:
|
||||
throw new Error("Invalid Mode");
|
||||
throw RequestError.invalidParams(undefined, `Invalid Mode: ${modeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildAvailableModes(allowBypass: boolean): SessionModeState["availableModes"] {
|
||||
// Only AIR gets the mode kind.
|
||||
const air = this.options.airClient?.() ?? false;
|
||||
const kind = (value: string) => {
|
||||
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
|
||||
return meta ? { _meta: meta } : {};
|
||||
};
|
||||
const modes: SessionModeState["availableModes"] = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
...kind("plan"),
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
...kind("auto_review"),
|
||||
},
|
||||
];
|
||||
if (allowBypass) {
|
||||
modes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
...kind("full_access"),
|
||||
});
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
private async trySyncMode(
|
||||
query: Pick<Query, "setPermissionMode">,
|
||||
mode: PermissionMode,
|
||||
@@ -403,3 +404,51 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAvailableModes(
|
||||
{ allowBypass, allowAuto }: ModeAvailability,
|
||||
air = false,
|
||||
): SessionModeState["availableModes"] {
|
||||
// Only AIR gets the mode kind.
|
||||
const kind = (value: string) => {
|
||||
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
|
||||
return meta ? { _meta: meta } : {};
|
||||
};
|
||||
const modes: SessionModeState["availableModes"] = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
...kind("plan"),
|
||||
},
|
||||
];
|
||||
if (allowAuto) {
|
||||
modes.push({
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
...kind("auto_review"),
|
||||
});
|
||||
}
|
||||
if (allowBypass) {
|
||||
modes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
...kind("full_access"),
|
||||
});
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
@@ -84,39 +84,73 @@ function versionedModelDisplayName(model: ModelInfo): string {
|
||||
return `${withoutContext} ${version}${contextSuffix[0]}`;
|
||||
}
|
||||
|
||||
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 ")
|
||||
function modelWords(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.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 };
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
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;
|
||||
/** 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;
|
||||
}
|
||||
return contextHint && !nonHintMatched ? 0 : score;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `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) =>
|
||||
@@ -134,28 +168,26 @@ 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;
|
||||
const value = model.value.toLowerCase();
|
||||
const display = model.displayName.toLowerCase();
|
||||
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
||||
return (
|
||||
containsWords(model.value, trimmed) ||
|
||||
containsWords(model.displayName, trimmed) ||
|
||||
(containsWords(trimmed, model.value) && modelCoversTokens(model, tokens))
|
||||
);
|
||||
});
|
||||
if (includesMatch) return includesMatch;
|
||||
|
||||
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;
|
||||
const candidates = models.filter(
|
||||
(model) => modelVersionsCompatible(trimmed, model) && modelCoversTokens(model, tokens),
|
||||
);
|
||||
return (
|
||||
candidates.find((model) => contextHintOf(model.value) === preferenceHint) ??
|
||||
candidates[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {
|
||||
@@ -210,22 +242,12 @@ export function buildModelConfigOption(
|
||||
? models.availableModels.filter((model) => model.modelId !== "default")
|
||||
: models.availableModels;
|
||||
|
||||
return {
|
||||
id: MODEL_CONFIG_ID,
|
||||
name: "Model",
|
||||
description: "AI model to use",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue:
|
||||
concreteRecommendation && models.currentModelId === "default"
|
||||
? recommended.value
|
||||
: models.currentModelId,
|
||||
...(concreteRecommendation
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommended.value),
|
||||
}
|
||||
: {}),
|
||||
options: available.map((model) => {
|
||||
const currentValue =
|
||||
concreteRecommendation && models.currentModelId === "default"
|
||||
? recommended.value
|
||||
: models.currentModelId;
|
||||
const options: Array<{ value: string; name: string; description?: string }> = available.map(
|
||||
(model) => {
|
||||
if (model.modelId === "default" && defaultInfo?.resolvedModel) {
|
||||
const named = modelInfos.find(
|
||||
(info) => info.value !== "default" && info.resolvedModel === defaultInfo.resolvedModel,
|
||||
@@ -241,7 +263,35 @@ export function buildModelConfigOption(
|
||||
name: model.name,
|
||||
description: model.description ?? undefined,
|
||||
};
|
||||
}),
|
||||
},
|
||||
);
|
||||
// ACP requires `currentValue` to be one of `options`. A session can run a
|
||||
// model the picker does not list: resumed onto a model excluded by the
|
||||
// `availableModels` allowlist, or switched by a refusal fallback. List that
|
||||
// model as its own entry. It is not added to `models.availableModels`, so the
|
||||
// next rebuild after switching to a listed model drops it.
|
||||
if (!options.some((option) => option.value === currentValue)) {
|
||||
const info = modelInfos.find((model) => model.value === currentValue);
|
||||
options.push({
|
||||
value: currentValue,
|
||||
name: info?.displayName || currentValue,
|
||||
description: info?.description || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: MODEL_CONFIG_ID,
|
||||
name: "Model",
|
||||
description: "AI model to use",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue,
|
||||
...(concreteRecommendation
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommended.value),
|
||||
}
|
||||
: {}),
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,6 +313,67 @@ 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
|
||||
@@ -438,6 +549,61 @@ 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:
|
||||
@@ -595,12 +761,20 @@ export async function getAvailableModels(
|
||||
for (const name of displayNames) {
|
||||
displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
const names = models.map((model, index) =>
|
||||
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
|
||||
);
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const name of names) {
|
||||
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
availableModels: models.map((model, index) => ({
|
||||
modelId: model.value,
|
||||
name:
|
||||
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
|
||||
// Allowlisted aliases can inherit the same SDK label. Keep every ID
|
||||
// selectable, but distinguish the rows for clients that show only names.
|
||||
name: nameCounts.get(names[index]) === 1 ? names[index] : `${names[index]} (${model.value})`,
|
||||
description: model.description,
|
||||
})),
|
||||
currentModelId: currentModel.value,
|
||||
|
||||
@@ -50,11 +50,35 @@ export function noticeOrTranscriptUpdate(
|
||||
if (supportsNotices) return noticeUpdate(notice);
|
||||
return {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: transcriptText },
|
||||
content: { type: "text", text: transcriptBlock(transcriptText) },
|
||||
...(transcriptMeta ? { _meta: transcriptMeta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Clients render consecutive agent_message_chunks as one markdown stream, so
|
||||
* a bare `**Notice:** …` chunk lands mid-sentence: "…all done.**Notice:** Stop
|
||||
* says: …". Put it in its own blockquote paragraph, blank lines on both sides,
|
||||
* so it starts on a new line and Claude's next chunk does not continue the
|
||||
* quote. */
|
||||
export function transcriptBlock(text: string): string {
|
||||
const lines = text.trim().split(/\r?\n/);
|
||||
return `\n\n${lines.map((line) => (line ? `> ${line}` : ">")).join("\n")}\n\n`;
|
||||
}
|
||||
|
||||
const HOOK_SPEAKER = /^[^\n:]{1,80} says: /;
|
||||
|
||||
/** Claude Code prefixes every line of a hook's `systemMessage` with
|
||||
* "<HookEvent> says: ", so a two-line message reads "Stop says: a\nStop says:
|
||||
* b". Keep the prefix on the first line only when every line repeats it. */
|
||||
export function collapseHookSpeaker(text: string): string {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const prefix = lines[0].match(HOOK_SPEAKER)?.[0];
|
||||
if (!prefix || lines.length < 2 || !lines.every((line) => line.startsWith(prefix))) {
|
||||
return text;
|
||||
}
|
||||
return [lines[0], ...lines.slice(1).map((line) => line.slice(prefix.length))].join("\n");
|
||||
}
|
||||
|
||||
/** Fragments that read on after a bold label stand alone as a notice
|
||||
* description, so start them with a capital. */
|
||||
export function sentenceCase(text: string): string {
|
||||
@@ -94,3 +118,98 @@ 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,19 +11,42 @@
|
||||
* `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 `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`.
|
||||
* {@link SessionTitles} holds the per-session state and is owned by the
|
||||
* session, in the v1 core (`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`.
|
||||
*/
|
||||
|
||||
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
ContentBlock,
|
||||
PromptRequest,
|
||||
SessionNotification,
|
||||
} 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
|
||||
@@ -137,7 +160,7 @@ export class SessionTitles {
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly agent: ClaudeAcpAgent,
|
||||
private readonly agent: TitleOwner,
|
||||
private readonly sessionId: string,
|
||||
) {}
|
||||
|
||||
@@ -158,7 +181,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: Session, prompt: PromptRequest["prompt"]): void {
|
||||
onPromptQueued(session: TitledSession, 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.
|
||||
@@ -209,7 +232,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: Session): Promise<void> {
|
||||
async onTurnEnd(session: TitledSession): 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.
|
||||
@@ -296,6 +319,32 @@ export class SessionTitles {
|
||||
});
|
||||
}
|
||||
|
||||
/** Sends the session's current title and `updatedAt`, even when the client
|
||||
* was already sent that title: a client that resumes the session may not
|
||||
* 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> {
|
||||
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.
|
||||
const stored =
|
||||
info?.customTitle || (this.settled && this.lastTitle ? undefined : info?.summary);
|
||||
const title = stored
|
||||
? sanitizeTitle(this.agent.clientTitle?.(stored) ?? stored)
|
||||
: this.lastTitle;
|
||||
if (!title) return;
|
||||
this.lastTitle = title;
|
||||
await this.agent.client.sessionUpdate({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
title,
|
||||
...(info ? { updatedAt: new Date(info.lastModified).toISOString() } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Records a title the CLI took, as soon as it took it. */
|
||||
rememberStoredTitle(title: string): void {
|
||||
this.persistedTitle = title;
|
||||
@@ -303,7 +352,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: Session): Promise<SDKSessionInfo | undefined> {
|
||||
private async readSessionInfo(session: TitledSession): Promise<SDKSessionInfo | undefined> {
|
||||
try {
|
||||
return await getSessionInfo(this.sessionId, { dir: session.cwd });
|
||||
} catch (error) {
|
||||
@@ -331,7 +380,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: Session): boolean {
|
||||
private canRequest(session: TitledSession): boolean {
|
||||
if (this.settled || session.queryClosed || session.cancelled) {
|
||||
return false;
|
||||
}
|
||||
@@ -357,7 +406,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: Session, description: string): Promise<void> {
|
||||
private async titleFromPrompt(session: TitledSession, description: string): Promise<void> {
|
||||
const info = await this.readSessionInfo(session);
|
||||
if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
|
||||
return;
|
||||
@@ -379,7 +428,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: Session,
|
||||
session: TitledSession,
|
||||
description: string,
|
||||
fallback: TitleFallback | undefined,
|
||||
): Promise<void> {
|
||||
|
||||
140
src/session-ultracode.ts
Normal file
140
src/session-ultracode.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
SessionConfigOption,
|
||||
SetSessionConfigOptionRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
export { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
/** Select-fallback values for clients without boolean config options. */
|
||||
export const ULTRACODE_ON = "on";
|
||||
export const ULTRACODE_OFF = "off";
|
||||
const ULTRACODE_DESCRIPTION = "Dynamic workflows on every task, at any effort level";
|
||||
|
||||
/** Ultracode state from the CLI's `get_settings` control response
|
||||
* (`applied.ultracodeAvailable` / `applied.ultracodeRequested`). Each field is
|
||||
* undefined when the CLI does not report it. */
|
||||
export type UltracodeSettingsReport = {
|
||||
/** Workflows are enabled and the current model supports ultracode. */
|
||||
available?: boolean;
|
||||
/** Ultracode is turned on for the session, whether or not it can take
|
||||
* effect on the current model. */
|
||||
requested?: boolean;
|
||||
};
|
||||
|
||||
// `Query.getSettings()` exists at runtime (sdk.mjs) but is missing from the
|
||||
// SDK's type declarations as of 0.3.319.
|
||||
type GetSettingsQuery = Query & {
|
||||
getSettings?: () => Promise<{
|
||||
applied?: { ultracodeAvailable?: unknown; ultracodeRequested?: unknown };
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Ask the CLI whether ultracode is available and requested for the session.
|
||||
* Returns undefined when the SDK has no `getSettings` or the request fails;
|
||||
* callers then fall back to {@link modelSupportsUltracode}. */
|
||||
export async function readUltracodeSettings(
|
||||
query: Query,
|
||||
): Promise<UltracodeSettingsReport | undefined> {
|
||||
const getSettings = (query as GetSettingsQuery).getSettings;
|
||||
if (typeof getSettings !== "function") return undefined;
|
||||
try {
|
||||
const applied = (await getSettings.call(query))?.applied;
|
||||
return {
|
||||
available:
|
||||
typeof applied?.ultracodeAvailable === "boolean" ? applied.ultracodeAvailable : undefined,
|
||||
requested:
|
||||
typeof applied?.ultracodeRequested === "boolean" ? applied.ultracodeRequested : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The CLI supports ultracode on exactly the models that support `xhigh`
|
||||
* effort (both use the same capability check), and `supportedEffortLevels`
|
||||
* lists `xhigh` only for those models. E.g. Opus 4.7+, Sonnet 5+ and Haiku
|
||||
* 5.5 qualify; Opus 4.6 and Haiku 4.5 do not. */
|
||||
export function modelSupportsUltracode(modelInfo: ModelInfo | undefined): boolean {
|
||||
return (
|
||||
modelInfo?.supportsEffort === true &&
|
||||
modelInfo.supportedEffortLevels?.includes("xhigh") === true
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether dynamic workflows are enabled for the session, inferred from the
|
||||
* CLI's `ultracodeAvailable` for the model the session started on. This is
|
||||
* the part of availability that does not change with the model: the plan
|
||||
* default (off on Pro), `enableWorkflows`/`disableWorkflows` and org policy.
|
||||
* Undefined when the report can't tell, e.g. `ultracodeAvailable: false` on a
|
||||
* model that lacks ultracode support anyway. */
|
||||
export function ultracodeWorkflowsEnabled(
|
||||
report: UltracodeSettingsReport | undefined,
|
||||
modelInfo: ModelInfo | undefined,
|
||||
): boolean | undefined {
|
||||
if (report?.available === true) return true;
|
||||
if (report?.available === false && modelSupportsUltracode(modelInfo)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether to offer the ultracode toggle for `modelInfo`. An unknown workflows
|
||||
* state shows the toggle: the CLI keeps an unavailable request without
|
||||
* error, so the toggle is merely inert there. */
|
||||
export function ultracodeSupported(
|
||||
workflowsEnabled: boolean | undefined,
|
||||
modelInfo: ModelInfo | undefined,
|
||||
): boolean {
|
||||
return workflowsEnabled !== false && modelSupportsUltracode(modelInfo);
|
||||
}
|
||||
|
||||
/** Ultracode state threaded into `buildConfigOptions`. The option is only
|
||||
* surfaced while `supported`. */
|
||||
export type UltracodeOptionState = {
|
||||
supported: boolean;
|
||||
enabled: boolean;
|
||||
/** Whether the client opted into boolean config options. */
|
||||
useBooleanOption: boolean;
|
||||
};
|
||||
|
||||
/** Build the ultracode toggle: a native boolean for clients that opted into
|
||||
* boolean config options, otherwise an "on"/"off" select. */
|
||||
export function createUltracodeConfigOption(
|
||||
enabled: boolean,
|
||||
useBooleanOption: boolean,
|
||||
): SessionConfigOption {
|
||||
const base = {
|
||||
id: ULTRACODE_CONFIG_ID,
|
||||
name: "Ultracode",
|
||||
description: ULTRACODE_DESCRIPTION,
|
||||
category: "model_config",
|
||||
} as const;
|
||||
|
||||
if (useBooleanOption) {
|
||||
return { ...base, type: "boolean", currentValue: enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
type: "select",
|
||||
currentValue: enabled ? ULTRACODE_ON : ULTRACODE_OFF,
|
||||
options: [
|
||||
{ value: ULTRACODE_ON, name: "On" },
|
||||
{ value: ULTRACODE_OFF, name: "Off" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the requested ultracode value from a `session/set_config_option`
|
||||
* request: a boolean, or the "on"/"off" select-fallback strings. */
|
||||
export function resolveUltracodeEnabled(params: SetSessionConfigOptionRequest): boolean {
|
||||
const value = params.value;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === ULTRACODE_ON) return true;
|
||||
if (value === ULTRACODE_OFF) return false;
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Invalid value for config option ${ULTRACODE_CONFIG_ID}: ${value}`,
|
||||
);
|
||||
}
|
||||
259
src/session-usage.ts
Normal file
259
src/session-usage.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 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 `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] };
|
||||
}
|
||||
}
|
||||
124
src/slash-commands.ts
Normal file
124
src/slash-commands.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
42
src/startup-failure.ts
Normal file
42
src/startup-failure.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SDKStartupFailureReason } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Makes the CLI write an `error_during_execution` result with a
|
||||
* `startup_failure_reason` before it exits on a known startup failure. Without
|
||||
* it, some failures (e.g. `proxy_invalid`) end with stderr and an exit code
|
||||
* alone. The SDK then rejects `initializationResult()` with an error that
|
||||
* carries the reason as `startupFailureReason`. */
|
||||
export const STARTUP_FAILURE_RESULTS_ENV = { CLAUDE_CODE_STARTUP_FAILURE_RESULTS: "1" };
|
||||
|
||||
/** The SDK's prefix on the message of an error built from an error result. */
|
||||
const ERROR_RESULT_PREFIX = "Claude Code returned an error result: ";
|
||||
|
||||
/** Reasons that signing in again, or with another account, can fix. */
|
||||
const AUTH_REQUIRED_REASONS: ReadonlySet<SDKStartupFailureReason> = new Set([
|
||||
"gateway_signin_required",
|
||||
"org_pin_mismatch",
|
||||
"org_config_refused",
|
||||
]);
|
||||
|
||||
/** The ACP error for a CLI that refused to start, or `undefined` when `error`
|
||||
* carries no `startupFailureReason` (older CLIs, failures without a known
|
||||
* cause). The message is the CLI's own, which says what to fix, e.g.
|
||||
* `Invalid proxy URL in HTTPS_PROXY: … Fix or unset HTTPS_PROXY and restart
|
||||
* Claude Code.`; `data.startupFailureReason` carries the reason. */
|
||||
export function startupFailureError(error: unknown): RequestError | undefined {
|
||||
if (!(error instanceof Error)) return undefined;
|
||||
const reason = (error as { startupFailureReason?: unknown }).startupFailureReason;
|
||||
if (typeof reason !== "string" || reason === "") return undefined;
|
||||
|
||||
const message = error.message.startsWith(ERROR_RESULT_PREFIX)
|
||||
? error.message.slice(ERROR_RESULT_PREFIX.length)
|
||||
: error.message;
|
||||
const data = { startupFailureReason: reason };
|
||||
if (AUTH_REQUIRED_REASONS.has(reason as SDKStartupFailureReason)) {
|
||||
return RequestError.authRequired(data, message);
|
||||
}
|
||||
if (reason === "cwd_unavailable") {
|
||||
return RequestError.invalidParams(data, message);
|
||||
}
|
||||
return RequestError.internalError(data, message);
|
||||
}
|
||||
86
src/streamed-text.ts
Normal file
86
src/streamed-text.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
96
src/subagent-configuration.ts
Normal file
96
src/subagent-configuration.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@@ -24,6 +24,11 @@ export interface SubagentRecord {
|
||||
background: boolean;
|
||||
/** Whether the user stopped it. */
|
||||
stoppedByUser: boolean;
|
||||
/**
|
||||
* Its working directory, when Claude Code placed it outside its parent's:
|
||||
* in a worktree of its own or of an ancestor, or in a `cwd` of its own.
|
||||
*/
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/** Where the subagent transcripts of a session are. */
|
||||
@@ -43,7 +48,9 @@ export interface SubagentHistory {
|
||||
* Claude keeps the history of a subagent in its own transcript,
|
||||
* `<session>/subagents/agent-<id>.jsonl`, next to the session transcript.
|
||||
* The `agent-<id>.meta.json` file beside it names the launching tool use.
|
||||
* The ids are empty for a session without a local transcript.
|
||||
* For a child that Claude Code placed elsewhere, it has `worktreePath`,
|
||||
* `inheritedWorktreePath` or `cwd`; then the child's working directory is the
|
||||
* `cwd` of its transcript's records. The ids are empty for a session without a local transcript.
|
||||
*/
|
||||
export async function subagentHistory(sessionId: string): Promise<SubagentHistory> {
|
||||
const agents = new Map<string, SubagentRecord>();
|
||||
@@ -67,6 +74,12 @@ export async function subagentHistory(sessionId: string): Promise<SubagentHistor
|
||||
> | null;
|
||||
if (typeof meta?.toolUseId !== "string") return;
|
||||
const text = (value: unknown) => (typeof value === "string" ? value : undefined);
|
||||
const placement = [meta.cwd, meta.worktreePath, meta.inheritedWorktreePath]
|
||||
.map(text)
|
||||
.find((value) => value && path.isAbsolute(value));
|
||||
const cwd = placement
|
||||
? ((await recordedCwd(path.join(directory, `agent-${match[1]!}.jsonl`))) ?? placement)
|
||||
: undefined;
|
||||
agents.set(meta.toolUseId, {
|
||||
agentId: match[1]!,
|
||||
name: text(meta.name),
|
||||
@@ -74,6 +87,7 @@ export async function subagentHistory(sessionId: string): Promise<SubagentHistor
|
||||
description: text(meta.description),
|
||||
background: meta.requestShape === "background",
|
||||
stoppedByUser: meta.stoppedByUser === true,
|
||||
...(cwd ? { cwd } : {}),
|
||||
});
|
||||
} catch {
|
||||
// A damaged meta file leaves its subagent without history.
|
||||
@@ -87,6 +101,31 @@ export async function subagentHistory(sessionId: string): Promise<SubagentHistor
|
||||
return dir ? { agents, dir } : { agents };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `cwd` of the first record of the transcript `file` that has one, or
|
||||
* undefined when the file cannot be read. Claude Code writes the working
|
||||
* directory of the session or subagent on each record.
|
||||
*/
|
||||
async function recordedCwd(file: string): Promise<string | undefined> {
|
||||
const stream = createReadStream(file, { encoding: "utf8" });
|
||||
try {
|
||||
for await (const line of readline.createInterface({ input: stream, crlfDelay: Infinity })) {
|
||||
if (!line.includes('"cwd"')) continue;
|
||||
try {
|
||||
const cwd = (JSON.parse(line) as { cwd?: unknown } | null)?.cwd;
|
||||
if (typeof cwd === "string" && path.isAbsolute(cwd)) return cwd;
|
||||
} catch {
|
||||
// A damaged record; a later one can have the `cwd`.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// An unreadable transcript; the caller falls back to the metadata's path.
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The folder of the subagent transcripts, next to the session transcript `transcript`. */
|
||||
function subagentsDirectory(transcript: string, sessionId: string): string {
|
||||
return path.join(path.dirname(transcript), sessionId, "subagents");
|
||||
@@ -162,6 +201,7 @@ export async function subagentLineage(
|
||||
agentId: current,
|
||||
launchId: launch.launchId,
|
||||
...(launcher !== null ? { parentAgentId: launcher } : {}),
|
||||
...(launch.record.cwd ? { cwd: launch.record.cwd } : {}),
|
||||
...(launch.record.name ? { name: launch.record.name } : {}),
|
||||
...(launch.record.agentType ? { agentType: launch.record.agentType } : {}),
|
||||
...(launch.record.description ? { description: launch.record.description } : {}),
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
*/
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { statSync } from "node:fs";
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
import {
|
||||
childModelOption,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
responseModelEntry,
|
||||
rfdEndState,
|
||||
rfdLabels,
|
||||
sameDirectory,
|
||||
type SubagentModels,
|
||||
type WorkState,
|
||||
} from "./native-subagents.js";
|
||||
@@ -71,6 +73,8 @@ export type ReplayChild = {
|
||||
/** The state of the child's last persisted task notification. */
|
||||
notified?: SubagentState;
|
||||
announced: boolean;
|
||||
/** The `cwd` that the replay reported for it; undefined while it inherits its parent's. */
|
||||
cwd?: string;
|
||||
promptSent: boolean;
|
||||
/** Whether it handed a report to its parent with a SubagentHandback call. */
|
||||
handedBack?: boolean;
|
||||
@@ -118,6 +122,8 @@ export class RfdSubagentReplay {
|
||||
* {@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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -166,8 +172,9 @@ export class RfdSubagentReplay {
|
||||
|
||||
/**
|
||||
* Announces `child` on its parent, as live: running, with the parent's
|
||||
* labels. Then it sends `firstModel`, the model of the child's first
|
||||
* response, when the transcript has one.
|
||||
* labels, and with its recorded `cwd` when that differs from its parent's.
|
||||
* Then it sends `firstModel`, the model of the child's first response, when
|
||||
* the transcript has one.
|
||||
*/
|
||||
async announce(child: ReplayChild, firstModel?: string): Promise<void> {
|
||||
if (child.announced) return;
|
||||
@@ -176,6 +183,12 @@ export class RfdSubagentReplay {
|
||||
child.record.agentType ?? child.input.subagent_type,
|
||||
child.record.description ?? child.input.description,
|
||||
);
|
||||
const inherited = this.effectiveCwd(child.parentSessionId);
|
||||
const cwd =
|
||||
child.record.cwd &&
|
||||
(inherited === undefined || !(await sameDirectory(child.record.cwd, inherited)))
|
||||
? child.record.cwd
|
||||
: undefined;
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
@@ -183,13 +196,30 @@ export class RfdSubagentReplay {
|
||||
sessionId: child.sessionId,
|
||||
...(labels.title ? { title: labels.title } : {}),
|
||||
...(labels.description ? { description: labels.description } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
state: { state: "running" },
|
||||
},
|
||||
});
|
||||
child.cwd = cwd;
|
||||
child.announced = true;
|
||||
await this.responseModel(child, firstModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* The working directory of the session `sessionId` as the client holds it:
|
||||
* a child's reported `cwd`, else its parent's, up to the root's.
|
||||
*/
|
||||
private effectiveCwd(sessionId: string): string | undefined {
|
||||
const seen = new Set<string>();
|
||||
let child = this.byAgentId.get(sessionId);
|
||||
while (child && !seen.has(child.sessionId)) {
|
||||
if (child.cwd) return child.cwd;
|
||||
seen.add(child.sessionId);
|
||||
child = this.byAgentId.get(child.parentSessionId);
|
||||
}
|
||||
return this.rootCwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the model of a replayed response of the child `owner`. A child
|
||||
* whose model changes gets its `model` option again.
|
||||
@@ -346,17 +376,7 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
const missing = this.omitted.size + this.withoutHistory;
|
||||
if (missing > 0 && supportsNotices) {
|
||||
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.`,
|
||||
}),
|
||||
});
|
||||
await this.publish({ sessionId: rootSessionId, update: missingHistoryNotice(missing) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +390,10 @@ export class RfdSubagentReplay {
|
||||
* when a live session is loaded again. Such a child reports that state and
|
||||
* can be cancelled, as live. Any other child cannot be running, because the
|
||||
* process that ran it is gone, so its replayed end is its current state.
|
||||
*
|
||||
* A replayed `cwd` whose directory is gone, such as a worktree that Claude
|
||||
* Code removed when the child finished, is cleared with `cwd: null`, as the
|
||||
* RFD requires after a reconnection.
|
||||
*/
|
||||
currentStates(
|
||||
live: (sessionId: string) => WorkState["state"] | undefined,
|
||||
@@ -395,6 +419,7 @@ export class RfdSubagentReplay {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(liveState !== undefined ? { capabilities: { cancel: {} } } : {}),
|
||||
...(child.cwd && !isDirectory(child.cwd) ? { cwd: null } : {}),
|
||||
state,
|
||||
},
|
||||
},
|
||||
@@ -463,12 +488,10 @@ export class RfdSubagentReplay {
|
||||
message: SessionMessage,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
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()),
|
||||
const call = deliveredCall(
|
||||
this.sendMessages,
|
||||
[child.sessionId, child.record.name, child.input.name],
|
||||
text,
|
||||
);
|
||||
if (call) {
|
||||
call.recipientSessionId = child.sessionId;
|
||||
@@ -574,7 +597,48 @@ function textOf(content: unknown): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Whether `dir` is an existing directory. */
|
||||
function isDirectory(dir: string): boolean {
|
||||
try {
|
||||
return statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A message that a SendMessage call delivered to the child. */
|
||||
function isDelivery(message: SessionMessage): boolean {
|
||||
export 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" }>;
|
||||
}
|
||||
|
||||
@@ -2,7 +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 { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({
|
||||
@@ -30,21 +31,22 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
function mockQuery() {
|
||||
function mockQuery(
|
||||
models: unknown[] = [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
) {
|
||||
let capturedOptions: any;
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
},
|
||||
],
|
||||
}),
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
}) as any;
|
||||
});
|
||||
@@ -284,7 +286,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
it("drops escalating defaultMode when it comes from project-tier settings", async () => {
|
||||
// bypassPermissions in .claude/settings.json (a repo-committed tier) is
|
||||
// filtered by the SDK's trust policy and clamps to 'default'.
|
||||
// filtered by the SDK's trust policy, so the built-in default applies.
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(path.join(projectDir, ".claude"), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
@@ -303,8 +305,8 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
expect(getCapturedOptions().permissionMode).toBe("auto");
|
||||
expect(response.modes.currentModeId).toBe("auto");
|
||||
});
|
||||
|
||||
it("honors the host opt-out from bypassPermissions", async () => {
|
||||
@@ -380,7 +382,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
|
||||
it("defaults to 'auto' when no permissions.defaultMode is set", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
@@ -395,14 +397,43 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(getCapturedOptions().permissionMode).toBe("auto");
|
||||
expect(response.modes.currentModeId).toBe("auto");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "top-level disableAutoMode", settings: { disableAutoMode: "disable" } },
|
||||
{
|
||||
name: "permissions.disableAutoMode with defaultMode 'auto'",
|
||||
settings: { permissions: { disableAutoMode: "disable", defaultMode: "auto" } },
|
||||
},
|
||||
])("starts in 'default' without Auto in the catalog for $name", async ({ settings }) => {
|
||||
await fs.promises.writeFile(path.join(tempDir, "settings.json"), JSON.stringify(settings));
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
expect(response.modes.availableModes.map((mode: { id: string }) => mode.id)).not.toContain(
|
||||
"auto",
|
||||
);
|
||||
});
|
||||
|
||||
describe("on resume", () => {
|
||||
const sessionId = "resumed-mode-session";
|
||||
|
||||
async function setup(records: unknown[]) {
|
||||
async function setup(records: unknown[], models?: unknown[]) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "plan" } }),
|
||||
@@ -416,11 +447,11 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
const { getCapturedOptions, setModelSpy } = mockQuery(models);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
return { agent, projectDir, getCapturedOptions };
|
||||
return { agent, projectDir, getCapturedOptions, setModelSpy };
|
||||
}
|
||||
|
||||
async function resume(records: unknown[], meta: object = {}) {
|
||||
@@ -449,6 +480,26 @@ 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")]);
|
||||
@@ -464,6 +515,16 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("uses the available configured mode when the restored mode is not in the catalog", async () => {
|
||||
const { permissionMode, response } = await resume([user("dontAsk")]);
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
expect(response.configOptions.find((option: any) => option.id === "mode").currentValue).toBe(
|
||||
"plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when bypass is not available now", async () => {
|
||||
const { permissionMode, response } = await resume([user("bypassPermissions")], {
|
||||
claudeCode: { options: { allowDangerouslySkipPermissions: false } },
|
||||
@@ -473,6 +534,41 @@ 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"),
|
||||
@@ -521,21 +617,22 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
(agent as any).createSession = async (params: object, options: { resume?: string }) => {
|
||||
creations.push(options);
|
||||
// The CLI never wrote the conversation, so the resume fails.
|
||||
if (options.resume) throw RequestError.resourceNotFound(sessionId);
|
||||
if (options.resume) throw RequestError.sessionNotFound(sessionId);
|
||||
return createSession(params, options);
|
||||
};
|
||||
|
||||
await (agent as any).recreateSignedOutQuery(sessionId, (agent as any).sessions[sessionId]);
|
||||
const husk = (agent as any).sessions[sessionId];
|
||||
await (agent as any).recreateSignedOutQuery(sessionId, husk);
|
||||
|
||||
expect(creations).toEqual([
|
||||
{ resume: sessionId, permissionMode: "default" },
|
||||
{ reuseSessionId: sessionId, permissionMode: "default" },
|
||||
{ resume: sessionId, permissionMode: "default", replacingSession: husk },
|
||||
{ reuseSessionId: sessionId, permissionMode: "default", replacingSession: husk },
|
||||
]);
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'default' when permissions.defaultMode is invalid", async () => {
|
||||
it("falls back to the built-in default when permissions.defaultMode is invalid", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
@@ -559,9 +656,9 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
// Bad mode is ignored at the usage site; session creation must not throw.
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
// Settings resolution drops the bad mode; session creation must not throw.
|
||||
expect(getCapturedOptions().permissionMode).toBe("auto");
|
||||
expect(response.modes.currentModeId).toBe("auto");
|
||||
});
|
||||
|
||||
it("ignores model from settings when it is not a string", async () => {
|
||||
@@ -703,6 +800,30 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(modeIds).not.toContain("dontAsk");
|
||||
});
|
||||
|
||||
it("starts the built-in Auto default in Manual on an unsupported model", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { setPermissionModeSpy } = mockQueryWithModels([
|
||||
{ value: "claude-haiku-4-5", displayName: "Claude Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
expect((agent as any).sessions[response.sessionId].autoModeFallbackWarningPending).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back permissions.defaultMode='auto' to Accept edits on an unsupported model", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2
|
||||
* surface: through the protocol router, with the SDK's v2 client.
|
||||
* core (`src/v2/`): 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,15 +12,15 @@
|
||||
* `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. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u`
|
||||
* to update them.
|
||||
* passing scenario, every message of the run. 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 { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { AgentHandle } from "../v2/agent.js";
|
||||
import {
|
||||
resetIds,
|
||||
runScenarioV2,
|
||||
@@ -37,6 +38,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
getSessionInfo: harness.mockedSessionInfo,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -60,8 +62,8 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PASSES = "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.
|
||||
* The scenarios that stop on the v2 surface, each with the reason. Every
|
||||
* other scenario passes.
|
||||
*/
|
||||
const V2_STOPS: Record<string, string> = {};
|
||||
|
||||
@@ -85,12 +87,15 @@ beforeAll(async () => {
|
||||
const { acpProtocolRouter } = await import("../serve.js");
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
const agents: { dispose(): Promise<void> }[] = [];
|
||||
const quiet = { log: () => {}, error: () => {} };
|
||||
runs.set(
|
||||
scenario.name,
|
||||
await runScenarioV2(
|
||||
(stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream),
|
||||
(stream) =>
|
||||
acpProtocolRouter(quiet, (agent) => agents.push(agent), "auto", {
|
||||
onV2Agent: (agent: AgentHandle) => agents.push(agent),
|
||||
}).connect(stream),
|
||||
scenario,
|
||||
),
|
||||
);
|
||||
@@ -162,10 +167,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(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`),
|
||||
);
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(golden(scenario));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"SandboxNetworkAccess"}},"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Reason: Sandbox blocks network","title":"example.com","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"host":"example.com"},"title":"example.com","toolCallId":"toolu_net"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_net"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_r_task"}},"content":{"text":"Subagent text.","type":"text"},"messageId":"msg_r_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"<session>:replay-subagent:toolu_r_task"}}}
|
||||
{"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"auto","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"auto"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile","backgroundSubagents"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"newSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"newSession","payload":{"configOptions":[{"category":"mode","currentValue":"auto","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"auto"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"AskUserQuestion","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Which database?","type":"text"},"type":"content"}],"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","title":"Which database?","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"elicitation/create","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"rawOutput":"User has answered your questions: \"Which database?\"=\"Postgres\".","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
|
||||
@@ -15,4 +15,4 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
@@ -14,4 +14,4 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
@@ -10,9 +10,9 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"ls -la"},"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls -la","description":"List files"},"status":"pending","title":"ls -la","toolCallId":"toolu_bash"},"type":"tool_call"},"title":"ls -la"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls -la","description":"List files"},"title":"ls -la","toolCallId":"toolu_bash"},"type":"tool_call"},"title":"ls -la"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"YS50cwpiLnRz"},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}}
|
||||
@@ -20,4 +20,4 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"ne.","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","error":"Context too small","sessionUpdate":"compaction_update","status":"failed"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
@@ -10,9 +10,9 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\nThe user doesn't want to proceed with this tool use. The tool use was rejected.\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use. The tool use was rejected.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
@@ -10,10 +10,10 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"User has approved your plan. You can now start coding.\n\n## Approved Plan:\n# Plan\n1. Do it","sessionUpdate":"tool_call_update","status":"completed","title":"Exited Plan Mode","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"plan":{"content":"# Plan\n1. Do it","planId":"plan-mode","type":"markdown"},"sessionUpdate":"plan_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"background":[],"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\n```\nThe user doesn't want to proceed with this tool use.\n```\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"background":[],"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user