mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 19:32:21 +00:00
Compare commits
66 Commits
fix/slash-
...
v0.104.2-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d79167a6 | ||
|
|
a4a8534c23 | ||
|
|
6bc2e058cb | ||
|
|
99cc1f3245 | ||
|
|
bce309f482 | ||
|
|
6b86f7b46a | ||
|
|
b89e4497da | ||
|
|
7d95d30ec3 | ||
|
|
3d6eb7b5b6 | ||
|
|
de521df295 | ||
|
|
c09543d85a | ||
|
|
d202748976 | ||
|
|
a1d83c6031 | ||
|
|
966aee0f04 | ||
|
|
66adc1a4c9 | ||
|
|
bc031f362c | ||
|
|
566e2652a9 | ||
|
|
0e7e74d424 | ||
|
|
f115b90161 | ||
|
|
617d5351ed | ||
|
|
cb285cd285 | ||
|
|
2f7e386f36 | ||
|
|
8e18eb7c75 | ||
|
|
855447c7cd | ||
|
|
d098fb08d5 | ||
|
|
766fd142f6 | ||
|
|
176ae0634c | ||
|
|
fcb7e175ee | ||
|
|
e9e0d414fe | ||
|
|
30600ba348 | ||
|
|
dd9eed4e46 | ||
|
|
4cceb06b31 | ||
|
|
ef65ab31b4 | ||
|
|
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 |
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.99.1"
|
||||
".": "0.104.1"
|
||||
}
|
||||
|
||||
97
AGENTS.md
97
AGENTS.md
@@ -1,7 +1,11 @@
|
||||
# Agent guide
|
||||
|
||||
An ACP-compatible coding agent powered by the Claude Agent SDK. Source in `src/`,
|
||||
TypeScript, ESM, Node >= 22.
|
||||
TypeScript, ESM, Node >= 22. `src/v1/` holds the v1 agent (`ClaudeAcpAgent` in
|
||||
`src/v1/acp-agent.ts`), `src/v2/` the v2 agent, and the rest of `src/` the modules
|
||||
both share. The package exports only `src/lib.ts`. ESLint rejects imports between `src/v1/` and
|
||||
`src/v2/`, and from shared modules into either agent. The entry points (`index.ts`, `lib.ts`,
|
||||
`serve.ts`, `binary-entry.ts`) are exempt.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -16,13 +20,92 @@ 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 `src/v1/acp-agent.ts`. If no existing module fits
|
||||
the new code cleanly, create a dedicated one that owns the feature's state and logic,
|
||||
and keep the change in `src/v1/acp-agent.ts` to the calls that wire it in.
|
||||
- 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
|
||||
|
||||
- Tests live in `src/tests/`: `v1/` holds the tests that drive the v1 agent,
|
||||
`v2/` the tests of the v2 agent, and the top level the tests of shared
|
||||
modules, with `helpers.ts`, the scenario harness (`acp-scenarios/`) and
|
||||
`fixtures/`. Before adding a test, search `src/tests/` for existing tests of
|
||||
the same API and scenario, and add to their file and `describe` block. Use
|
||||
the helpers in `helpers.ts` and `v1/session-doubles.ts` instead of copying
|
||||
setup.
|
||||
- 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
|
||||
|
||||
|
||||
114
CHANGELOG.md
114
CHANGELOG.md
@@ -1,5 +1,119 @@
|
||||
# Changelog
|
||||
|
||||
## [0.104.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.104.0...v0.104.1) (2026-11-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* carry the auth detail in the ACP account label ([#1573](https://github.com/agentclientprotocol/claude-agent-acp/issues/1573)) ([3d6eb7b](https://github.com/agentclientprotocol/claude-agent-acp/commit/3d6eb7b5b650a1f9e5d2e869e92e313c32dbdc86))
|
||||
* distinguish Ultra from Max in auth status ([#1574](https://github.com/agentclientprotocol/claude-agent-acp/issues/1574)) ([7d95d30](https://github.com/agentclientprotocol/claude-agent-acp/commit/7d95d30ec3a4813d580b3bfdedb08f03d93a3571))
|
||||
* update to @anthropic-ai/claude-agent-sdk 0.3.322 ([#1577](https://github.com/agentclientprotocol/claude-agent-acp/issues/1577)) ([b89e449](https://github.com/agentclientprotocol/claude-agent-acp/commit/b89e4497da9184072e7f3c4bb28de79801659222))
|
||||
|
||||
## [0.104.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.103.0...v0.104.0) (2026-11-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement the ACP auth/status RFD next to _auth/status_update ([#1570](https://github.com/agentclientprotocol/claude-agent-acp/issues/1570)) ([d202748](https://github.com/agentclientprotocol/claude-agent-acp/commit/d20274897684e8813477389b83a268f2b4f4ad11))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* offer Anthropic Console login in remote environments ([#1572](https://github.com/agentclientprotocol/claude-agent-acp/issues/1572)) ([c09543d](https://github.com/agentclientprotocol/claude-agent-acp/commit/c09543d85ae1067e8df315f1cd3179527701ea16))
|
||||
|
||||
## [0.103.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.102.0...v0.103.0) (2026-11-10)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* deep imports of `dist/` files fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`, and `lib.ts` exports only `serveAcp`, `AUTH_STATUS_UPDATE_METHOD` and their types.
|
||||
|
||||
### Features
|
||||
|
||||
* serve ACP v2 with a direct v2 agent and remove the v1 bridge ([#1560](https://github.com/agentclientprotocol/claude-agent-acp/issues/1560)) ([cb285cd](https://github.com/agentclientprotocol/claude-agent-acp/commit/cb285cd2857924c07038c129cc836da007e2737f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* give subagent checklists their own plan ID ([#1558](https://github.com/agentclientprotocol/claude-agent-acp/issues/1558)) ([2f7e386](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f7e386f3600658d905906f3b37301120b07605f))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* export only lib.ts, with serveAcp and the authStatus wire names ([#1566](https://github.com/agentclientprotocol/claude-agent-acp/issues/1566)) ([566e265](https://github.com/agentclientprotocol/claude-agent-acp/commit/566e2652a9da5a31dab57a23ea1b06e9fff2ead8))
|
||||
|
||||
## [0.102.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.1...v0.102.0) (2026-11-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* list workflow phases and agents on the workflow card ([#1552](https://github.com/agentclientprotocol/claude-agent-acp/issues/1552)) ([176ae06](https://github.com/agentclientprotocol/claude-agent-acp/commit/176ae0634c2ef7cb0549339f4b9824884574b2f0))
|
||||
* send the checklist as a plan operation and remove plans on reset ([#1554](https://github.com/agentclientprotocol/claude-agent-acp/issues/1554)) ([766fd14](https://github.com/agentclientprotocol/claude-agent-acp/commit/766fd142f60bda2f434d55baaa8e72939f215800))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* report v2 background tasks without a tool call as their own tool call ([#1556](https://github.com/agentclientprotocol/claude-agent-acp/issues/1556)) ([d098fb0](https://github.com/agentclientprotocol/claude-agent-acp/commit/d098fb08d54f521d5056d920ebcc5a2656334083))
|
||||
* update to @anthropic-ai/claude-agent-sdk 0.3.321 ([#1557](https://github.com/agentclientprotocol/claude-agent-acp/issues/1557)) ([855447c](https://github.com/agentclientprotocol/claude-agent-acp/commit/855447c7cd41aa566bdf361d6d59e6a5b038fe6e))
|
||||
|
||||
## [0.101.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.0...v0.101.1) (2026-11-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* keep autonomous turn tasks open across owed trailing idles ([#1455](https://github.com/agentclientprotocol/claude-agent-acp/issues/1455)) ([1ee2422](https://github.com/agentclientprotocol/claude-agent-acp/commit/1ee242293401372aa71ea5765e8dfd0679146dc9))
|
||||
* list a resumed Fable 5.1 session's model as `fable` ([#1548](https://github.com/agentclientprotocol/claude-agent-acp/issues/1548)) ([4cceb06](https://github.com/agentclientprotocol/claude-agent-acp/commit/4cceb06b31525ccaaecfd999ff9b21d401b2659a))
|
||||
* list a subagent's background Bash commands ([#1540](https://github.com/agentclientprotocol/claude-agent-acp/issues/1540)) ([6597626](https://github.com/agentclientprotocol/claude-agent-acp/commit/6597626d86ddb8c10ea9f1091d921806752d045a))
|
||||
* list Monitor calls in the session's background work ([#1538](https://github.com/agentclientprotocol/claude-agent-acp/issues/1538)) ([ba16234](https://github.com/agentclientprotocol/claude-agent-acp/commit/ba162346c9ccb4be51f13a3271198351d7822e78))
|
||||
* relay a rate_limit_event that arrives before the turn's first assistant frame ([#1534](https://github.com/agentclientprotocol/claude-agent-acp/issues/1534)) ([54392e1](https://github.com/agentclientprotocol/claude-agent-acp/commit/54392e144d307d5dd5b7732ae14634d4b2678bd5))
|
||||
* render workflow tool cards and progress correctly in ACP v2 ([#1550](https://github.com/agentclientprotocol/claude-agent-acp/issues/1550)) ([30600ba](https://github.com/agentclientprotocol/claude-agent-acp/commit/30600ba348e5f5c3755a50f5797fd24645e0ff48))
|
||||
* replay a compaction summary by its flag, not its framing ([#1536](https://github.com/agentclientprotocol/claude-agent-acp/issues/1536)) ([aa0c123](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa0c1234b5c52a63d96c46c9826631bd0f428062))
|
||||
* report setup-token logins as logged in ([#1376](https://github.com/agentclientprotocol/claude-agent-acp/issues/1376)) ([7d1ef76](https://github.com/agentclientprotocol/claude-agent-acp/commit/7d1ef76c1d32e390f8ff8824087df3003825a170))
|
||||
* start transcript notices on their own line for clients without session.notices ([#1362](https://github.com/agentclientprotocol/claude-agent-acp/issues/1362)) ([47b64c0](https://github.com/agentclientprotocol/claude-agent-acp/commit/47b64c04de83145c4d42033c4408c811736c9c40))
|
||||
* **test:** assert the blockquoted MCP startup transcript line ([#1543](https://github.com/agentclientprotocol/claude-agent-acp/issues/1543)) ([3b22586](https://github.com/agentclientprotocol/claude-agent-acp/commit/3b22586cf63cef06f1029a8418a9abb3b2cc3236))
|
||||
* update to @agentclientprotocol/sdk 2.5.0 ([#1549](https://github.com/agentclientprotocol/claude-agent-acp/issues/1549)) ([ef65ab3](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef65ab31b405a5d339133c980eb606a70a2d3624))
|
||||
* update to @anthropic-ai/claude-agent-sdk 0.3.319 ([#1545](https://github.com/agentclientprotocol/claude-agent-acp/issues/1545)) ([a58293f](https://github.com/agentclientprotocol/claude-agent-acp/commit/a58293f4c9aa559a500d61834ef97d2a23b3722b))
|
||||
|
||||
## [0.101.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.100.0...v0.101.0) (2026-11-05)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
|
||||
33
README.md
33
README.md
@@ -36,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
|
||||
@@ -48,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
|
||||
@@ -115,9 +140,13 @@ for v2 is negotiated down to v1. With `v2`, the adapter rejects the `initialize`
|
||||
|
||||
On v2 the adapter serves the session baseline (`session/new`, `session/list`, `session/resume`
|
||||
with replay, `session/close`, `session/prompt`, `session/cancel`), `session/delete`,
|
||||
`session/fork`, additional directories, config options (the mode is one), plans, notices,
|
||||
`session/fork`, `session/status`, `session/set_title`, 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:
|
||||
|
||||
@@ -78,7 +78,7 @@ A page with more sessions after it has a `nextCursor`, as ACP defines.
|
||||
A client that does not follow `nextCursor` sees only the newest 1000 sessions of the directory.
|
||||
An unknown cursor gives an `invalidParams` error.
|
||||
|
||||
`src/tests/acp-scenarios.test.ts` compares the traffic of a plain client and of Zed with recordings of origin/main.
|
||||
`src/tests/v1/acp-scenarios.test.ts` compares the traffic of a plain client and of Zed with recordings of origin/main.
|
||||
|
||||
## Negotiation
|
||||
|
||||
@@ -145,6 +145,15 @@ The agent list does not depend on the capabilities that AIR declares, except `se
|
||||
An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
|
||||
The response to a client that is not AIR has no `_meta.jetbrains` key.
|
||||
|
||||
### ACP v2
|
||||
|
||||
The v2 core (`src/v2/`) serves an AIR client only the extensions that ACP v2 does not cover.
|
||||
Its `initialize` response lists `agentFileChangeReport` and `recommendedValue`.
|
||||
It also serves the [permission presentation](#permission-presentation), the [question custom answers](#question-custom-answers), the [predicted follow-up prompt](#predicted-follow-up-prompt), and [load replay end](#load-replay-end).
|
||||
The [agent file-change report](#agent-file-change-report) goes out at the end of the turn that serves the prompt, before the turn's idle `state_update`, because a v2 `session/prompt` is answered when Claude Code takes the prompt in.
|
||||
ACP v2 covers subagents, background work, file plans, patches, compaction, the system prompt, and turn failures itself, so the v2 core serves no `nativeSubagentSessions`, `backgroundSubagents`, `asyncTasks`, `planFile`, `diffPatch`, `contextCompaction`, `customInstructions`, or `sessionFailure`.
|
||||
It does not serve the [tool call contract](#tool-call-contract) yet.
|
||||
|
||||
### Capabilities
|
||||
|
||||
| Capability | Advertised | What the adapter does when the client declares it | Section |
|
||||
@@ -219,7 +228,7 @@ The adapter keeps them where they are.
|
||||
|
||||
## Auth source display
|
||||
|
||||
The connection-scoped `_auth/status_update` continues to report agent-owned authentication, not client-owned `providers/set` routing. When the active key source is `apiKeyHelper`, the adapter reads the effective helper command through SDK `resolveSettings` / the session's `SettingsManager`, including programmatic settings overrides.
|
||||
The connection-scoped `_auth/status_update` continues to report agent-owned authentication, not client-owned `providers/set` routing. The adapter also reports the same identity through the ACP `auth/status` method and `auth/status_update` notification (advertised as `auth.status`), with `kind`, `detail` and `plan` under the account's `_meta.claudeCode`. The account's `label` also carries a `detail` in parentheses, e.g. `Anthropic API key (ANTHROPIC_API_KEY)`. Each notification is sent when its own payload changed. `auth/status` answers with the identity last reported and starts a new read, whose change arrives as a notification. When the active key source is `apiKeyHelper`, the adapter reads the effective helper command through SDK `resolveSettings` / the session's `SettingsManager`, including programmatic settings overrides.
|
||||
|
||||
A command containing the whole word `central` and the word sequence `proxy start` is displayed as:
|
||||
|
||||
@@ -231,6 +240,8 @@ Matching is case-insensitive and accepts multiple whitespace characters between
|
||||
|
||||
Other active helpers use `label: "Anthropic API key"` and `detail: "API key helper"`; unknown key-source identifiers are not exposed as display text. CLI and SDK reads share this presentation, retain stale-probe protection, and suppress unchanged notifications.
|
||||
|
||||
For Max subscriptions, the existing `claude auth status --json` probe's optional `planTier` distinguishes `max_20x` (displayed as `Claude Ultra`) from `max_5x` (`Claude Max`). Missing, unrecognized, or malformed tiers fall back to the subscription's usual label. Other subscriptions and auth sources keep their existing classification. The raw `account.plan` and ACP `_meta.claudeCode.plan` remain unchanged. SDK account reads lack the tier, so they preserve the known label for the same account and plan; a fresh CLI read replaces it, including when the tier is absent. This is display metadata, not an entitlement check, and requires no additional probe or direct credential-file reads.
|
||||
|
||||
## Zed conventions
|
||||
|
||||
The adapter keeps these Zed and upstream conventions unchanged for every client.
|
||||
@@ -548,7 +559,7 @@ AIR puts the hooks into `_meta.claudeCode.options.settings.hooks` of `session/ne
|
||||
```
|
||||
|
||||
- The settings go to the flag settings tier of that session only. Other sessions and the settings files do not change.
|
||||
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/session-hooks.test.ts` checks this with the Claude Code binary.
|
||||
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/v1/session-hooks.test.ts` checks this with the Claude Code binary.
|
||||
- `CLAUDE_MODEL_CONFIG` stays in effect (see [Model configuration](model-configuration.md#precedence)).
|
||||
- A `session/load` with different settings recreates the query, so it applies the new hooks.
|
||||
- Claude asks for no hook trust step.
|
||||
@@ -1062,7 +1073,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
|
||||
{
|
||||
@@ -1396,6 +1408,8 @@ Each row carries `_meta.jetbrains.air` with flat row fields of the RFDs. `archiv
|
||||
|
||||
`_session/rename { "sessionId": "…", "title": "…" }` returns `{}`.
|
||||
|
||||
ACP's `session/set_title` renames the same way for every client, on v1 and v2, without `sessionIndex`, and returns `{ "title": "…" }`: the title as `session/list` shows it to the client. On v1 an AIR client gets it without the archive prefix, and every other client, and every v2 client, with it.
|
||||
|
||||
- A blank title is `-32602`. The adapter collapses whitespace and keeps the first 200 characters, trimmed, as the CLI does. An `[archived] ` prefix of the requested title is dropped: a rename never changes the archive state.
|
||||
- The title is stored as the title records of [Archive](#archive): a `custom-title` and an `agent-name` record. An archived session keeps its `[archived] ` prefix (and the title is cut to 200 characters with it), so a rename does not unarchive it.
|
||||
- A session that runs here on a CLI without the `rename_session` control request is refused with `-32600`: its CLI holds the title and would write it back.
|
||||
@@ -1569,7 +1583,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.
|
||||
@@ -1638,7 +1652,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.
|
||||
@@ -1679,14 +1694,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.
|
||||
|
||||
@@ -4,6 +4,23 @@ import tsparser from "@typescript-eslint/parser";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import globals from "globals";
|
||||
|
||||
/**
|
||||
* Rules that reject a module specifier matching `regex` in static imports and
|
||||
* re-exports (type-only ones too), `import()` calls and `import("...")` types.
|
||||
*/
|
||||
function protocolBoundary(regex, message) {
|
||||
// esquery regex literals end at the first `/`, so spell it `\x2F`.
|
||||
const literal = `/${regex.replaceAll("/", "\\x2F")}/`;
|
||||
return {
|
||||
"no-restricted-imports": ["error", { patterns: [{ regex, message }] }],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{ selector: `ImportExpression[source.value=${literal}]`, message },
|
||||
{ selector: `TSImportType[argument.literal.value=${literal}]`, message },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
|
||||
@@ -44,5 +61,38 @@ export default [
|
||||
curly: ["error", "all"],
|
||||
},
|
||||
},
|
||||
// `src/v2/` must not import the v1 agent.
|
||||
{
|
||||
files: ["src/v2/**/*.ts"],
|
||||
rules: protocolBoundary(
|
||||
"^(\\.\\./)+v1/",
|
||||
"src/v2/ must not import the v1 agent. Move what both versions need into a shared module in src/.",
|
||||
),
|
||||
},
|
||||
// `src/v1/` must not import the v2 agent.
|
||||
{
|
||||
files: ["src/v1/**/*.ts"],
|
||||
rules: protocolBoundary(
|
||||
"^(\\.\\./)+v2/",
|
||||
"src/v1/ must not import the v2 agent. Move what both versions need into a shared module in src/.",
|
||||
),
|
||||
},
|
||||
// Shared modules must not import either agent. The entry points wire the agents up.
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
ignores: [
|
||||
"src/v1/**",
|
||||
"src/v2/**",
|
||||
"src/tests/**",
|
||||
"src/index.ts",
|
||||
"src/lib.ts",
|
||||
"src/serve.ts",
|
||||
"src/binary-entry.ts",
|
||||
],
|
||||
rules: protocolBoundary(
|
||||
"^(\\./|(\\.\\./)+)(v1|v2)/",
|
||||
"Shared modules must not import src/v1/ or src/v2/. Move the code into the shared module, or pass it in from the agent.",
|
||||
),
|
||||
},
|
||||
prettierConfig,
|
||||
];
|
||||
|
||||
492
package-lock.json
generated
492
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.99.1",
|
||||
"version": "0.104.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.99.1",
|
||||
"version": "0.104.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.1.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.312",
|
||||
"@agentclientprotocol/sdk": "2.5.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.322",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
@@ -18,29 +18,29 @@
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"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.1",
|
||||
"@typescript-eslint/parser": "8.72.1",
|
||||
"@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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.1.0.tgz",
|
||||
"integrity": "sha512-IuKjJmHLPzbmbg26eK9Jopd8AVxgLuu8fX114YzYAU6tLYclCo2DUzLL4vbCpYGr9mzvs2T0kigE3ZCueL06tg==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.5.0.tgz",
|
||||
"integrity": "sha512-MFlXeIuwN6nZSK45SAsSnVjbLgQ2r4fnthoF1lQoGJP3PdaPRpOZeCF2Dds8S4cN8yZP5QbxB1bkJccGskmcYw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/server": "^2.4.0",
|
||||
@@ -53,22 +53,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.312",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.312.tgz",
|
||||
"integrity": "sha512-Ilj5Agx3ak/Z0UPZNLS7+CTnYMa+Ijf8+FKelh9MKGRwHVYyfvmJgL0fsmooI78SdkZwqjI5a4CwOAsJurdjJA==",
|
||||
"version": "0.3.322",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.322.tgz",
|
||||
"integrity": "sha512-r88ij7R3SPV2j3BtFuavgrbWZNE8SVVS42AOQ1KxlS+QnSwNNnUhmVXV3Es2dniZySBYX8ENebL43ffhiB0azw==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.14.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.312",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.312",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.312",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.312",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.312",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.312",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.312",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.312"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.322",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.322",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.322",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.322",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.322",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.322",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.322",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.322"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -77,9 +77,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.312",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.312.tgz",
|
||||
"integrity": "sha512-AVmdMOuoeE3Y9f4wAIPcEAAS0OFaZw1L6l11YuyqfS0B/+DYxkWxS/ZYZ1NbUy57eaIW/vdEgz9Q5M99Pl6ZJw==",
|
||||
"version": "0.3.322",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.322.tgz",
|
||||
"integrity": "sha512-LoKkldW2zkLfaOLQD44mL1Ot4Aup8dBIo356LEeO3v2vF7JVNhMllQIXhhGpbYRz57QaYsGvOe0Kb4SbssHwcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -90,9 +90,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.312",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.312.tgz",
|
||||
"integrity": "sha512-Dmxyil+GPzF1WENoFOi1Cx0roSdJA6HewGAfAFvsH5vYrzb6G0REGuoQQ0n3u8i8qFgtl1m/WSl4u5t+MdiHWA==",
|
||||
"version": "0.3.322",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.322.tgz",
|
||||
"integrity": "sha512-rTvLS9G1OzjMIaSXrJglQtmRtVcaRmMbJQC+ND02+atCA463ac7Ty/Wc6tyzBGk8zFFGKWnfAhKtLZTNg91eMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -103,9 +103,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.312",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.312.tgz",
|
||||
"integrity": "sha512-jebsXksKh7g7u1x+RxjYQXM50TCN0e7GthebXa+n5IebF3pPqtmyWGVIiEEjvpajn3P6Zh8BWUVuufUpXn8ccA==",
|
||||
"version": "0.3.322",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.322.tgz",
|
||||
"integrity": "sha512-H1v33+MxB5JMPlnJLZffiOQ+6muxyA7tgHPyYQ+So4TH7uRhaPcjv2X03CUo5U9gXleO6u+X8ngNNDRFtfUaSg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -119,9 +119,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.312",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.312.tgz",
|
||||
"integrity": "sha512-9GT0JzbfhODwsCO3MCg79wPH47qARCuKECcryeAJnVczplAlXSIiBw1+6UcqL5+0wSYFu6D/tvw/AiVy+PkHsg==",
|
||||
"version": "0.3.322",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.322.tgz",
|
||||
"integrity": "sha512-zvgvVA/Mxpu1QttICZ1DGDlmgGo62HdVgKg6WbDGDQ3dgxtRC/kpCIepjtGEL8O1iO5NRxQ4mv8okLwCFcyrLQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -135,9 +135,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.312",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.312.tgz",
|
||||
"integrity": "sha512-UOsqza2uo5gqlX2OU72LhkQvIaNJKUQMJIi1poX2NtYqaWACqNo/TZPWfOBzxhyWmX8BLWaOlcuZGPLgcCJOTg==",
|
||||
"version": "0.3.322",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.322.tgz",
|
||||
"integrity": "sha512-M/i7uV5wQf1kvjiPJr2KF5PeA9BpftUpMTtwE+DpQ0Tj+scthQXSMsEhDvau/Rh/MfFOc+UHED4tltignmA5+Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -151,9 +151,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.312",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.312.tgz",
|
||||
"integrity": "sha512-zM+PB6qVybdwtlqElXQ4+wCwk2v/jjT3Xau4foydzy/g5pGRTuGT3PTcfDLeG0j9zF8IxLaCQ/+Bl9MABPQD8g==",
|
||||
"version": "0.3.322",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.322.tgz",
|
||||
"integrity": "sha512-jprUZMcQ3XcpoxwKRQ0fi3qAM1zYcytmNGhlD0gWDknbbSu8di5jLU7231ihJrCr4gbbtDu3FiFUd6w9EBEjfw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -167,9 +167,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.312",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.312.tgz",
|
||||
"integrity": "sha512-Li9CyzsC2mzGmF1wtt/R59ChBLww6nc2dLn6tb+bgTDcd6kqvW44x5rIH+lRmBcpTNHR/8VO/k+GlmnkxqeaQQ==",
|
||||
"version": "0.3.322",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.322.tgz",
|
||||
"integrity": "sha512-o5sPmBKT4NOglODC45wSTFmjD1AQIboC9NdGFQFOTa8kMF3bok+YqpB0DF81RmMDf3iR5tieonZvOXdYm1TDjQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -180,9 +180,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.312",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.312.tgz",
|
||||
"integrity": "sha512-9GgGHHFEQnXx6EPhkPplOw1ndqmys8+Z+uOR68EIQlXGZm+wE+BmfypyHAoZhjs4151iWdQgNzjDT7wet3crdw==",
|
||||
"version": "0.3.322",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.322.tgz",
|
||||
"integrity": "sha512-gONOkYx76of3p73opgWAT9gqpV7PRL8+az3vKoWXLc4xaDW6vhZF7fixpCnLDeUmPWxjXhnQxf30WGpKneTzNw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -193,9 +193,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.3.0.tgz",
|
||||
"integrity": "sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.7.0.tgz",
|
||||
"integrity": "sha512-Z+0c1iHQozlt4ymMHapPXEXfFnSbPEWGnSKbkavt+qeyaMQ3Sd6QNxfQj7vyhHYqiqYadC9shrXub8ISKf7xjQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
@@ -215,9 +215,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||
"version": "7.29.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.13.tgz",
|
||||
"integrity": "sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -267,9 +267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/regexpp": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
||||
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.3.tgz",
|
||||
"integrity": "sha512-R76UKbvsqI68DJJVTVyqFKqU98CN4+B+acxNfEA2IFarSzP0SjtKf/3+fFiXzTyv5Fj3PxLkGHe2wnVWWMxobQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -363,9 +363,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz",
|
||||
"integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==",
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.6.tgz",
|
||||
"integrity": "sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -544,9 +544,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"version": "0.156.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
|
||||
"integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -555,9 +555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.18.tgz",
|
||||
"integrity": "sha512-ubTG+4KlidvioxjOFdsdEa2F6JVGZb8m2ByJUqQ7fYzUeT1cEn194POcsQ10PKH4p//q0YQn4SB3YpSuzcA1Yg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -573,9 +573,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.18.tgz",
|
||||
"integrity": "sha512-2Xa1t/JeFGTZn5TbzgPppcCftF8sCVnTNvrIvoSdcZhoFzAuVy1L3xxQUQ4sfWDtwVzg1u6Yi1R3SlJugRNV2Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -591,9 +591,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.18.tgz",
|
||||
"integrity": "sha512-iUzw9HshVvrWci9gVXjxWS7/M/IcLvNo67vu2c/w9zKBz4hIvPRixdUpIW/iCfhldLaq9ER2hecsI9UY5z0/yA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -609,9 +609,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.18.tgz",
|
||||
"integrity": "sha512-XuDHFzbFEtZl0QGJi92+fmG67tfnp1iZ9VOeDkwasGyTq2VG3/5PnJnvAr+MTzFh/rNScJ3VhN+XpZyyGsN7Jw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -627,9 +627,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.18.tgz",
|
||||
"integrity": "sha512-MM8opLfsLK15oEXDz6iBc0t3ahu+ew9jsAmha+ovZ33hSwYgqo7YmpRITqFTfW7xA71BLEQ3jJdTsaCmhB1c9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -645,9 +645,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.18.tgz",
|
||||
"integrity": "sha512-J6EoCWiy5RnFQzHrKyHXXevZ9lukQfGpnchX/zG/k/WC2nc9O9XyS1JgA8kI9VxMFggMbCPjpv7LcikriXfehw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -663,9 +663,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.18.tgz",
|
||||
"integrity": "sha512-HIkFuLF7CdDeki+1aCZLLbLq0hk/PHntb+z4/YCTo47JbksTD6jJEELIz92tYROmDmvMrNqUMqBi71BkJLDR+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -684,9 +684,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.18.tgz",
|
||||
"integrity": "sha512-4oqWItLLYt7IceOnElCct8T2ek5p7PmD7LN04TRJRXGo5f3HtKTgGH6PhYRFK4C2ICkq/LMUU0BVPvvlAKhm8g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -705,9 +705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.18.tgz",
|
||||
"integrity": "sha512-+AkRly5SrCEgnPhGRfub7pucASYEC2fTsxZg7VCcnozpA2+Wn/gCYy1t7LuJS67/xyJu/7noYBJncDUkbwgw2A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -726,9 +726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.18.tgz",
|
||||
"integrity": "sha512-Ttg1r9vVykMnQrE/GwYO9icKtXiuiMfRe/w74xJkeoVuvhRG+WtT6LEMOf58Yl63yd77lhHzO8Y55R8CC3XALA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -747,9 +747,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.18.tgz",
|
||||
"integrity": "sha512-xX1Tt4o5jUMma0vJsXp5eRZA9nOuyigb2dKJMMzZRmzMxLYGE9PaKKSqHPzPvSQgM8ZOEacSTQwDQcwWfh0xmQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -768,9 +768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.18.tgz",
|
||||
"integrity": "sha512-4VVlGI5K2kociZlW3lSN8VjG3TW5yQIlAcfCdBXMgGjJxZll15Fa4ocRHXHvIVVnwlibCw/NQajJrNDiqqpiiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -789,9 +789,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.18.tgz",
|
||||
"integrity": "sha512-PApTsgH1jLYuyy3tSCz9De82Gq5qXwi7vImVZhEzTgX1C4150Cp5M2ZfL3pLvbCvbEHL0oJeC6pd6podtv8Y5w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -807,9 +807,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.18.tgz",
|
||||
"integrity": "sha512-J2XjWjx/SP+4x0H4r6dz3Vzb5WVkg7re40Do9K7HJ3sK2/xcmZrWwakXlSwaMvOkvruLJCD4CL/3wqsIR/thgg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -825,9 +825,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.18.tgz",
|
||||
"integrity": "sha512-XwRJxRQvkuEtS0hIIt29QrqPJ+JzMJSNYHJw4ae/mc6uEarfRG1ceyg4HmmWiBdL/YCb6g7N5BLbSvmRJRYZHw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -843,9 +843,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.1.0.tgz",
|
||||
"integrity": "sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
@@ -889,9 +889,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz",
|
||||
"integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -903,9 +903,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
|
||||
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
|
||||
"version": "26.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
||||
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -913,17 +913,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz",
|
||||
"integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.2.tgz",
|
||||
"integrity": "sha512-222PKFXKihmFpi/pS5njoBgdAqsPhj2/GBmFbrNw8Tq8gEG9Sv+kSlKFy4u2q+mJv9ZzJgM2Gkv/bUzRZwZw5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.1",
|
||||
"@typescript-eslint/type-utils": "8.72.1",
|
||||
"@typescript-eslint/utils": "8.72.1",
|
||||
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||
"@typescript-eslint/scope-manager": "8.72.2",
|
||||
"@typescript-eslint/type-utils": "8.72.2",
|
||||
"@typescript-eslint/utils": "8.72.2",
|
||||
"@typescript-eslint/visitor-keys": "8.72.2",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -936,22 +936,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.72.1",
|
||||
"@typescript-eslint/parser": "^8.72.2",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz",
|
||||
"integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.2.tgz",
|
||||
"integrity": "sha512-llP+T1YuJOLlxEciP6OyQGtz5SMxyn+9LA2BvZw5RmToGVa1y4beEDVQL6Gje7T5WFYh19WREjDCN9Ev4gm5AA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.72.1",
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/typescript-estree": "8.72.1",
|
||||
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||
"@typescript-eslint/scope-manager": "8.72.2",
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/typescript-estree": "8.72.2",
|
||||
"@typescript-eslint/visitor-keys": "8.72.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -967,14 +967,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
|
||||
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.2.tgz",
|
||||
"integrity": "sha512-6y5dLTZdHhI8Eh3yIQ/bfQhRKwn5e+TpJj64o18Cngc0iW/BCrVvrr6zGYqXPnYH31u6YXAYiFh0+qxX5mybJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.72.1",
|
||||
"@typescript-eslint/types": "^8.72.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.72.2",
|
||||
"@typescript-eslint/types": "^8.72.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -989,14 +989,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
|
||||
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.2.tgz",
|
||||
"integrity": "sha512-iIoMJ6/8c1/QpFNyFjU0d1YzPco4MsZjQw0k3V+vfzNWAFmUmvhlyMoYEoYYDQZXc+J6Gi1X5XcxlzW2taIIGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/visitor-keys": "8.72.1"
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/visitor-keys": "8.72.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1007,9 +1007,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
|
||||
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.2.tgz",
|
||||
"integrity": "sha512-+h3iCgZXuTNtoLXcnnjP12ZUHLMqoUcIHPfQrFEXTUqvb9dF8QK9b5nv5VtSo5fLkdHuks8ehbTzZj3rImAm9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1024,15 +1024,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz",
|
||||
"integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.2.tgz",
|
||||
"integrity": "sha512-STfkR38qIdhfkLpgtXnUjAzsNy4xouqiBO0lrVc/Wpk9Kem5/aHXUx1J8dGC16zpdYN1+yc2ILQ28er1kXX7ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/typescript-estree": "8.72.1",
|
||||
"@typescript-eslint/utils": "8.72.1",
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/typescript-estree": "8.72.2",
|
||||
"@typescript-eslint/utils": "8.72.2",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -1049,9 +1049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
|
||||
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.2.tgz",
|
||||
"integrity": "sha512-Xfn37OLZx03+PCU7qWx9DTv2FCRh1E6fk2+wONeNY15HXIwtcwuveaSwVpGAUptkNmPQegRxW5JyRel9T7wrog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1063,16 +1063,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
|
||||
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.2.tgz",
|
||||
"integrity": "sha512-pQEGqIK/cqEvKivVWaFLYpe7XYejFxfuAOi4mu1oAWFvIo36aRHI9esRFTUxcIy/z8N482RJyH+LpZB0ZIU7Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.72.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.72.1",
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||
"@typescript-eslint/project-service": "8.72.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.72.2",
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/visitor-keys": "8.72.2",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -1091,16 +1091,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
|
||||
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.2.tgz",
|
||||
"integrity": "sha512-hv3eRYbpPbbpezuax1EfGrzpa3C4ySgNZkPUkLOWOAjU8HsOKowEDAr8C7nsNl1C+SBSKDpvno6VKHnlhvp9iA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.72.1",
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/typescript-estree": "8.72.1"
|
||||
"@typescript-eslint/scope-manager": "8.72.2",
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"@typescript-eslint/typescript-estree": "8.72.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1115,13 +1115,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
|
||||
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
|
||||
"version": "8.72.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.2.tgz",
|
||||
"integrity": "sha512-NAGrjn0Pitjqzq/GkgMC4kBYm233PQ8GffyQ9+kTA3AlNQYEzOf25EIM02BAHZv5FRJSpHpJk+OGUxVkOB93GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.72.1",
|
||||
"@typescript-eslint/types": "8.72.2",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1146,14 +1146,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
|
||||
"integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz",
|
||||
"integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.5",
|
||||
"@vitest/spy": "5.1.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^1.4.3"
|
||||
},
|
||||
@@ -1174,9 +1174,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
|
||||
"integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz",
|
||||
"integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1543,13 +1543,17 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
|
||||
"integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
@@ -1613,9 +1617,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.13.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
|
||||
"integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
|
||||
"version": "10.14.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
|
||||
"integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -1628,7 +1632,7 @@
|
||||
"@eslint/config-helpers": "^0.7.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.3",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanfs/node": "^0.16.8",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
@@ -1649,7 +1653,7 @@
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": "^10.2.5",
|
||||
"minimatch": "^10.2.6",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
@@ -1941,9 +1945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.7.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
|
||||
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
|
||||
"version": "8.7.4",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.4.tgz",
|
||||
"integrity": "sha512-LKBXzX700wz3zAGNNmVnjRI5mEbYkLv7UbMI4+9Xa+JPcg2UZh6AgY/2CZh9RrJjFM5ttC/N1lD+KzyFoZITCw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -2201,9 +2205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.13.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
||||
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
||||
"version": "17.14.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz",
|
||||
"integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2266,9 +2270,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.14.tgz",
|
||||
"integrity": "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==",
|
||||
"version": "4.13.17",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.17.tgz",
|
||||
"integrity": "sha512-vZIcbkvjOR5hHRWERbGkr8fGfF0GGM1ooYkuH448iO3b2AdVEr3AXZSw4Z3Kn+1L5z0CVoVeyV29VGYmFh05kQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2348,9 +2352,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.5",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
|
||||
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
|
||||
"version": "10.7.6",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
|
||||
"integrity": "sha512-00LYkM6byILa9eQIx6qoaXCM40xTLd56AZ4vyaPW3YTyb49VZYPo5xsiAq4tuQ54GH4R7gVdYScJ1B7B77PuTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2404,9 +2408,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.13",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz",
|
||||
"integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==",
|
||||
"version": "6.2.17",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.17.tgz",
|
||||
"integrity": "sha512-wOuK9V1yujQFH5S0ZWmLcDZN7+IKh9bUCC2ZPCf2HDlol3WWHy+PPyNjEI75FXRLMp3vDKQcupP59xhVU95hcg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
@@ -3215,9 +3219,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz",
|
||||
"integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==",
|
||||
"version": "6.17.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz",
|
||||
"integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -3271,14 +3275,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.18.tgz",
|
||||
"integrity": "sha512-C1uuT3UR60SShuCaPQFAAZyefJCWZlAKB02BV3BuPE2Rs4fevRQltEKoFO2tV5W9/eJhU5RieV6vJj3euulfNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@oxc-project/types": "=0.156.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3288,21 +3292,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.18",
|
||||
"@rolldown/binding-android-arm64": "1.2.18",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.18",
|
||||
"@rolldown/binding-darwin-x64": "1.2.18",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.18",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.18",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.18",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.18",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.18",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.18",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.18",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.18",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.18",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.18",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.18"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -3330,9 +3334,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"version": "7.8.6",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
|
||||
"integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -3532,9 +3536,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
|
||||
"integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz",
|
||||
"integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3695,17 +3699,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"version": "8.3.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.7.tgz",
|
||||
"integrity": "sha512-oxh0dMxrayBr33ScJvvBJ2HBQ2jc1TBrXKR2NyL/MJ9WPuX19mN2DXWUmywKdyw5RFVQBo8hmtb2FMC0LHrnng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"postcss": "^8.5.32",
|
||||
"rolldown": "~1.2.18",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3774,14 +3778,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
|
||||
"integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz",
|
||||
"integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.5",
|
||||
"@vitest/mocker": "5.1.0",
|
||||
"chai": "^6.3.0",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
@@ -3807,12 +3811,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.5",
|
||||
"@vitest/browser-preview": "5.0.5",
|
||||
"@vitest/browser-playwright": "5.1.0",
|
||||
"@vitest/browser-preview": "5.1.0",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/ui": "5.0.5",
|
||||
"@vitest/coverage-istanbul": "5.1.0",
|
||||
"@vitest/coverage-v8": "5.1.0",
|
||||
"@vitest/ui": "5.1.0",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
25
package.json
25
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.99.1",
|
||||
"version": "0.104.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -16,17 +16,20 @@
|
||||
"types": "./dist/lib.d.ts",
|
||||
"import": "./dist/lib.js"
|
||||
},
|
||||
"./*": "./*"
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"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.1.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.312",
|
||||
"@agentclientprotocol/sdk": "2.5.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.322",
|
||||
"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.1",
|
||||
"@typescript-eslint/parser": "8.72.1",
|
||||
"@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);
|
||||
46
src/acp-client.ts
Normal file
46
src/acp-client.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
AuthStatusUpdateNotification,
|
||||
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` (`v1/client-connection.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>;
|
||||
/** Sends `auth/status_update`: the connection's auth state changed. */
|
||||
authStatusUpdate(params: AuthStatusUpdateNotification): 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`).
|
||||
|
||||
33
src/agent-command.ts
Normal file
33
src/agent-command.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { HIDE_CLAUDE_AUTH_FLAG } from "./hide-claude-auth.js";
|
||||
|
||||
/**
|
||||
* 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] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The arguments for the Claude CLI under `--cli`: the flags this process was
|
||||
* started with, minus the ones the adapter owns. The terminal-auth command
|
||||
* reruns the agent with its own flags (`--hide-claude-auth --cli auth login`),
|
||||
* and the CLI rejects the unknown option `--hide-claude-auth`. `argv.slice(2)`
|
||||
* is the flags under both Node and a compiled executable (see
|
||||
* {@link agentCommand}).
|
||||
*/
|
||||
export function cliArgs(argv: readonly string[] = process.argv): string[] {
|
||||
return argv.slice(2).filter((arg) => arg !== "--cli" && arg !== HIDE_CLAUDE_AUTH_FLAG);
|
||||
}
|
||||
@@ -105,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;
|
||||
@@ -267,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 }
|
||||
@@ -863,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];
|
||||
|
||||
83
src/auth-methods.ts
Normal file
83
src/auth-methods.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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 subscription login runs the TUI's `claude /login`:
|
||||
* clients such as Tidewave set `NO_BROWSER` to get it. The Anthropic Console
|
||||
* login is offered there too, because since Claude Code 2.1.126 `auth login`
|
||||
* accepts the OAuth code pasted into the terminal when the localhost callback
|
||||
* can't be reached. 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
|
||||
);
|
||||
const subscriptionLogin = isRemote
|
||||
? withMeta(
|
||||
{
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
type: "terminal",
|
||||
args: ["--cli"],
|
||||
},
|
||||
"Claude Login",
|
||||
)
|
||||
: withMeta(
|
||||
{
|
||||
description: "Use Claude subscription ",
|
||||
name: "Claude Subscription",
|
||||
id: "claude-ai-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
},
|
||||
"Claude Login",
|
||||
);
|
||||
const methods: AuthMethod[] = [];
|
||||
if (!shouldHideClaudeAuth()) methods.push(subscriptionLogin);
|
||||
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;
|
||||
}
|
||||
367
src/auth-status-reporter.ts
Normal file
367
src/auth-status-reporter.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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,
|
||||
* the `_auth/status_update` and `auth/status_update` pushes, and the
|
||||
* `auth/status` answer (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 {
|
||||
type AcpAuthStatus,
|
||||
AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
type AuthStatus,
|
||||
type AuthStatusKind,
|
||||
fromAccountInfo,
|
||||
fromCliStatus,
|
||||
mergeAuthStatus,
|
||||
sameAuthStatus,
|
||||
toAcpAuthStatus,
|
||||
} 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>;
|
||||
/** Sends the ACP `auth/status_update`. */
|
||||
pushAcp: (status: AcpAuthStatus) => 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;
|
||||
/** Numbers the probe runs in start order. */
|
||||
private probeRuns = 0;
|
||||
/** The latest-started probe run that published its read. */
|
||||
private publishedRun = 0;
|
||||
/** The last `auth/status_update` payload sent, as JSON. */
|
||||
private sentAcp?: string;
|
||||
|
||||
constructor(private readonly options: AuthStatusReporterOptions) {}
|
||||
|
||||
/**
|
||||
* Stores `next` and pushes it to the client, as `_auth/status_update` and
|
||||
* as the ACP `auth/status_update`.
|
||||
*
|
||||
* 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);
|
||||
});
|
||||
// Compared on its own payload: two identities can map to the same one.
|
||||
const acp = toAcpAuthStatus(next);
|
||||
const sent = JSON.stringify(acp);
|
||||
if (sent === this.sentAcp) {
|
||||
return;
|
||||
}
|
||||
this.sentAcp = sent;
|
||||
void Promise.resolve()
|
||||
.then(() => this.options.pushAcp(acp))
|
||||
.catch((error) => {
|
||||
this.options.logger.error("Failed to send auth/status_update:", error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `auth/status` answer: the identity the connection holds, which is
|
||||
* also what the last `auth/status_update` said.
|
||||
*
|
||||
* - No identity yet: it waits for a probe, joining the one `initialize`
|
||||
* started, so a query right after `initialize` gets the identity. The
|
||||
* probe is bounded by {@link AUTH_STATUS_PROBE_TIMEOUT_MS}. The answer is
|
||||
* `unknown` when that probe publishes nothing: the CLI failed, printed
|
||||
* no JSON, or timed out, or a login or logout overtook the read.
|
||||
* - An identity is known: it answers at once and starts a new probe that
|
||||
* nothing waits for. A login or logout made outside the client then
|
||||
* arrives as `auth/status_update`.
|
||||
*
|
||||
* The probe is an ordinary one: it runs under the settings of the session
|
||||
* that last started or prompted, pushes a change, and reports its kind to
|
||||
* `onProbe`. Under an ACP gateway login it reads nothing.
|
||||
*/
|
||||
async status(): Promise<AcpAuthStatus> {
|
||||
if (this.current) {
|
||||
void this.probe({ fresh: true });
|
||||
} else {
|
||||
await this.probe();
|
||||
}
|
||||
return toAcpAuthStatus(this.current);
|
||||
}
|
||||
|
||||
/** 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` describes what the live query authenticates with, but lacks
|
||||
* the CLI's plan tier. 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(mergeAuthStatus(this.current, fromSession, "session"));
|
||||
} 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 run = ++this.probeRuns;
|
||||
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;
|
||||
}
|
||||
if (run < this.publishedRun) {
|
||||
// Probes can overlap (`fresh`) and land out of order. A read that
|
||||
// started before one already published is the older of the two.
|
||||
logger.log("[authStatus] discarding a probe that a newer one overtook");
|
||||
return this.current;
|
||||
}
|
||||
this.publishedRun = run;
|
||||
// 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, "cli");
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
* identity the agent process itself uses (Claude subscription, API key,
|
||||
* gateway, external cloud credentials, or nothing).
|
||||
*
|
||||
* Push only. One carrier, connection-scoped (never per session): the
|
||||
* notification `_auth/status_update` → `{authStatus}`. The client never asks;
|
||||
* there is no request method. The empty `agentCapabilities._meta.authStatus`
|
||||
* marker means "this agent pushes its identity", nothing more.
|
||||
* Two carriers of the one identity, both connection-scoped (never per
|
||||
* session):
|
||||
* - the extension: the notification `_auth/status_update` → `{authStatus}`,
|
||||
* advertised by the empty `agentCapabilities._meta.authStatus` marker. It
|
||||
* has no request method;
|
||||
* - the ACP `auth/status` RFD: the `auth/status` request and the
|
||||
* `auth/status_update` notification, advertised as `auth.status`.
|
||||
* {@link toAcpAuthStatus} maps the identity to their payload.
|
||||
*
|
||||
* The agent reads the identity at these moments:
|
||||
* - `initialize`, with an asynchronous CLI probe. Nothing waits for it, and its
|
||||
@@ -20,7 +24,10 @@
|
||||
* login or a logout done in another terminal, including the sign-in a client
|
||||
* performs after a refusal, because the retried prompt is a new prompt.
|
||||
* Nothing waits for it and the push goes out mid-turn if that is when the
|
||||
* read lands.
|
||||
* read lands;
|
||||
* - each `auth/status` request, with an asynchronous CLI probe. The request
|
||||
* waits for it only while no identity is known yet (see
|
||||
* `AuthStatusReporter.status`).
|
||||
*
|
||||
* A probe whose kind differs from a live session's account marks that session
|
||||
* for recreation under `--hide-claude-auth`. The mark is a flag, consumed at
|
||||
@@ -29,18 +36,18 @@
|
||||
* guard decides on the account it reports.
|
||||
*
|
||||
* A read does not always cause a push. The agent sends `_auth/status_update`
|
||||
* only when the payload is different from the last one it sent.
|
||||
* only when its payload is different from the last one it sent, and
|
||||
* `auth/status_update` likewise.
|
||||
*
|
||||
* "Cannot determine" is expressed by silence: an agent that cannot read its own
|
||||
* identity pushes nothing, and the client shows "not reported". A known
|
||||
* logged-out state is a payload of its own (`kind: "none"`), which is what
|
||||
* distinguishes the two.
|
||||
* identity pushes nothing, and `auth/status` answers `unknown`. A known
|
||||
* logged-out state is a payload of its own (`kind: "none"`, `unauthenticated`),
|
||||
* which is what distinguishes the two.
|
||||
*
|
||||
* Reporting only — there is no write path in v1. When the upstream RFD lands,
|
||||
* the same payload moves from `_meta` to first-class fields and this module
|
||||
* is retired.
|
||||
* Reporting only — there is no write path in v1.
|
||||
*/
|
||||
|
||||
import type { AuthAccount, AuthStatusResponse } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Change notification, agent → client. Fire and forget: clients that do not
|
||||
@@ -85,6 +92,47 @@ export function authStatusCapability(): AuthStatusCapability {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** The fields of an `auth/status` response and of an `auth/status_update`
|
||||
* notification, which are the same on ACP v1 and v2. */
|
||||
export type AcpAuthStatus = Pick<AuthStatusResponse, "state" | "accounts">;
|
||||
|
||||
/**
|
||||
* Maps the identity to the ACP `auth/status` payload. The identity is the
|
||||
* agent's own login, so `state` does not count a route that the client set
|
||||
* with `providers/set`: a logged-out CLI is `unauthenticated` under one too.
|
||||
*
|
||||
* - No identity read yet (`undefined`): `unknown`, no accounts.
|
||||
* - `kind: "none"`: `unauthenticated`, no accounts.
|
||||
* - Any other kind: `authenticated`, with the identity as the one account,
|
||||
* marked `active`. The account has no field for `detail`, so its label
|
||||
* carries it in parentheses, e.g. "Anthropic API key (ANTHROPIC_API_KEY)",
|
||||
* for clients that show only the standard fields. `_meta.claudeCode`
|
||||
* carries the `kind`, `detail` and `plan` on their own.
|
||||
*
|
||||
* `--hide-claude-auth` does not change the payload: it reports the login the
|
||||
* CLI holds, also when the subscription guard refuses to use it.
|
||||
*/
|
||||
export function toAcpAuthStatus(status: AuthStatus | undefined): AcpAuthStatus {
|
||||
if (!status) {
|
||||
return { state: "unknown" };
|
||||
}
|
||||
if (status.kind === "none") {
|
||||
return { state: "unauthenticated" };
|
||||
}
|
||||
const label = status.detail ? `${status.label} (${status.detail})` : status.label;
|
||||
const account: AuthAccount = { label, active: true };
|
||||
if (status.account?.email) account.email = status.account.email;
|
||||
if (status.account?.organization) account.organization = status.account.organization;
|
||||
account._meta = {
|
||||
claudeCode: {
|
||||
kind: status.kind,
|
||||
...(status.detail !== undefined ? { detail: status.detail } : {}),
|
||||
...(status.account?.plan !== undefined ? { plan: status.account.plan } : {}),
|
||||
},
|
||||
};
|
||||
return { state: "authenticated", accounts: [account] };
|
||||
}
|
||||
|
||||
/** Shape of `claude auth status --json`. Absent fields are omitted, not null,
|
||||
* and the logged-out case exits 1 while still printing valid JSON. */
|
||||
export type CliAuthStatus = {
|
||||
@@ -96,6 +144,8 @@ export type CliAuthStatus = {
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
subscriptionType?: string;
|
||||
/** CLI-only tier, e.g. "max_5x" or "max_20x"; not in SDK AccountInfo. */
|
||||
planTier?: string;
|
||||
};
|
||||
|
||||
const NOT_LOGGED_IN: AuthStatus = {
|
||||
@@ -210,9 +260,19 @@ 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,
|
||||
planTier: status.planTier,
|
||||
apiKeySource: status.apiKeySource,
|
||||
email: status.email,
|
||||
organization: status.orgName,
|
||||
@@ -233,6 +293,7 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
|
||||
function mapAuthFields(fields: {
|
||||
apiProvider?: string;
|
||||
subscriptionType?: string;
|
||||
planTier?: string;
|
||||
apiKeySource?: string;
|
||||
email?: string;
|
||||
organization?: string;
|
||||
@@ -272,7 +333,7 @@ function mapAuthFields(fields: {
|
||||
if (organization) account.organization = organization;
|
||||
return {
|
||||
kind: "account",
|
||||
label: planLabel(subscriptionType),
|
||||
label: planLabel(subscriptionType, fields.planTier),
|
||||
// No detail: the client falls back to `account.email` for line 2.
|
||||
account,
|
||||
};
|
||||
@@ -280,12 +341,14 @@ function mapAuthFields(fields: {
|
||||
return notLoggedInAuthStatus();
|
||||
}
|
||||
|
||||
/** Names the plan with exactly one "Claude". A newer CLI already reports
|
||||
* "Claude Max", an older one reports "max", and both must read "Claude Max".
|
||||
* Each word is title-cased; a word that already carries capitals is kept. */
|
||||
function planLabel(plan: string): string {
|
||||
/** Names the plan with exactly one "Claude". Without a tier, "Claude Max"
|
||||
* from a newer CLI and "max" from an older one both read "Claude Max".
|
||||
* Each word is title-cased; a word that already carries capitals is kept.
|
||||
* The CLI's max_20x tier identifies Ultra; other tiers keep the plan label. */
|
||||
function planLabel(plan: string, planTier?: string): string {
|
||||
const titled = plan.replace(/\S+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1));
|
||||
return /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`;
|
||||
const label = /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`;
|
||||
return label.toLowerCase() === "claude max" && planTier === "max_20x" ? "Claude Ultra" : label;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,8 +376,14 @@ export function sameIdentity(a: AuthStatus, b: AuthStatus): boolean {
|
||||
* organization the CLI probe omits. So when the read describes the same
|
||||
* identity, its values win field by field but the fields it lacks are kept;
|
||||
* when it describes a different identity, it replaces the old one wholesale.
|
||||
* Session reads lack the CLI's plan tier, so keep the label for the same plan.
|
||||
* A CLI read always replaces the label, including when its tier is absent.
|
||||
*/
|
||||
export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStatus): AuthStatus {
|
||||
export function mergeAuthStatus(
|
||||
previous: AuthStatus | undefined,
|
||||
next: AuthStatus,
|
||||
source: "cli" | "session",
|
||||
): AuthStatus {
|
||||
if (!previous || !sameIdentity(previous, next)) {
|
||||
return next;
|
||||
}
|
||||
@@ -325,6 +394,14 @@ export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStat
|
||||
if (previous.account || next.account) {
|
||||
merged.account = { ...previous.account, ...next.account };
|
||||
}
|
||||
if (
|
||||
source === "session" &&
|
||||
previous.account?.plan &&
|
||||
next.account?.plan &&
|
||||
planLabel(previous.account.plan).toLowerCase() === planLabel(next.account.plan).toLowerCase()
|
||||
) {
|
||||
merged.label = previous.label;
|
||||
}
|
||||
if (merged.vendor === undefined && previous.vendor !== undefined) {
|
||||
merged.vendor = previous.vendor;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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");
|
||||
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;
|
||||
}
|
||||
@@ -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":
|
||||
@@ -295,7 +309,7 @@ export function applyAskElicitationResponse(
|
||||
questions: AskUserQuestion[],
|
||||
options: { reportUnanswered?: boolean } = {},
|
||||
): AskUserQuestionOutcome {
|
||||
// Typed against the output schema: as of SDK 0.3.312 only it lists
|
||||
// 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"]) => ({
|
||||
@@ -325,13 +339,13 @@ export function applyAskElicitationResponse(
|
||||
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
|
||||
@@ -374,10 +388,16 @@ export function applyAskElicitationResponse(
|
||||
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,
|
||||
@@ -385,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 ?? {} };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -497,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) };
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -176,9 +176,37 @@ 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);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* `--hide-claude-auth`: this integration must never bill a claude.ai
|
||||
* subscription. Under the flag the agent hides the claude.ai login method from
|
||||
* `initialize`, and refuses with `authRequired` every session and every turn
|
||||
* that a subscription would pay for. `acp-agent.ts` decides when the guard
|
||||
* that a subscription would pay for. `v1/acp-agent.ts` decides when the guard
|
||||
* applies (flag on, no provider override); this module decides what it refuses.
|
||||
*
|
||||
* The guard judges the `AccountInfo` that the SDK caches at `initialize`. That
|
||||
@@ -35,8 +35,10 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const HIDE_CLAUDE_AUTH_FLAG = "--hide-claude-auth";
|
||||
|
||||
export function shouldHideClaudeAuth(): boolean {
|
||||
return process.argv.includes("--hide-claude-auth");
|
||||
return process.argv.includes(HIDE_CLAUDE_AUTH_FLAG);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,14 +210,14 @@ type GuardOptions = {
|
||||
* covers a session created under a provider override that `providers/disable`
|
||||
* later removed, so the create-time check never ran for the routing in force
|
||||
* now. A sign-out ends the query (see `markSessionForSignOutRespawn` in
|
||||
* `acp-agent.ts`), so the next turn recreates it and `initialize` reports the
|
||||
* `v1/acp-agent.ts`), so the next turn recreates it and `initialize` reports the
|
||||
* real account. This guard does not see the residual case the module header
|
||||
* names either.
|
||||
*
|
||||
* A turn is refused when the account bills a subscription, and also when it
|
||||
* holds nothing this integration accepts, which is the user signing out in
|
||||
* mid-session. The refusal mirrors the signed-out path exactly (see
|
||||
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
|
||||
* `failActiveWithSessionFailure` for `auth_required` in `v1/acp-agent.ts`): every
|
||||
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
|
||||
* flow. The guard sends no separate access message.
|
||||
*
|
||||
|
||||
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. `v1/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,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { claudeCliPath } from "./acp-agent.js";
|
||||
import { cliArgs } from "./agent-command.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" };
|
||||
@@ -12,7 +13,7 @@ import { join } from "node:path";
|
||||
// handler below. Our version flag only applies when not delegating.
|
||||
if (process.argv.includes("--cli")) {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const args = process.argv.slice(2).filter((arg) => arg !== "--cli");
|
||||
const args = cliArgs();
|
||||
const child = spawn(await claudeCliPath(), args, { stdio: "inherit" });
|
||||
|
||||
const signals =
|
||||
|
||||
29
src/lib.ts
29
src/lib.ts
@@ -1,25 +1,7 @@
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
isLocalCommandMetadata,
|
||||
stripLocalCommandMetadata,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
|
||||
// The `authStatus` extension's wire surface: the notification's method name and
|
||||
// the payload types needed to read the traffic. The mappers stay internal.
|
||||
// The package's public API: serve ACP (v1 and v2) on stdio, and the wire
|
||||
// names of the `authStatus` extension that a client needs to read its traffic.
|
||||
export { serveAcp, type AcpProtocols } from "./serve.js";
|
||||
export type { Logger } from "./logger.js";
|
||||
export {
|
||||
AUTH_STATUS_UPDATE_METHOD,
|
||||
type AuthStatus,
|
||||
@@ -27,6 +9,3 @@ export {
|
||||
type AuthStatusKind,
|
||||
type AuthStatusUpdateNotification,
|
||||
} from "./auth-status.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
|
||||
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;
|
||||
}
|
||||
229
src/message-filters.ts
Normal file
229
src/message-filters.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the content of a user message is only the output of a local
|
||||
* command, such as the `<local-command-stdout>Set model to …</local-command-stdout>`
|
||||
* that Claude Code echoes when the model changes.
|
||||
*/
|
||||
export function isLocalCommandOutput(content: unknown): boolean {
|
||||
if (typeof content !== "string") return false;
|
||||
if (
|
||||
!hasMarkerTag(content, "local-command-stdout") &&
|
||||
!hasMarkerTag(content, "local-command-stderr")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return stripMarkerTags(content).trim() === "";
|
||||
}
|
||||
|
||||
function commandInvocationFromMarkerOnlyText(text: string): string | null {
|
||||
if (hasMarkerTag(text, "local-command-stdout") || hasMarkerTag(text, "local-command-stderr")) {
|
||||
return null;
|
||||
}
|
||||
const commandName = markerTagText(text, "command-name")?.trim();
|
||||
if (!commandName?.startsWith("/")) return null;
|
||||
if (REPLAY_HIDDEN_COMMANDS.has(commandName.split(" ", 1)[0])) return null;
|
||||
|
||||
const commandArgs = markerTagText(text, "command-args")?.trim();
|
||||
return commandArgs ? `${commandName} ${commandArgs}` : commandName;
|
||||
}
|
||||
|
||||
function stripLocalCommandMetadataText(text: string): string | null {
|
||||
const stripped = stripMarkerTags(text);
|
||||
if (stripped.trim() !== "") return stripped;
|
||||
return commandInvocationFromMarkerOnlyText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return user-message content with local-command and injected-context marker
|
||||
* tags removed, or `null` if nothing meaningful remains (caller should skip
|
||||
* the message). Preserves real prose that's mixed in alongside the markers —
|
||||
* e.g. a message like `<command-name>…</command-name>hi` becomes `hi`, and
|
||||
* `hi<system-reminder>…</system-reminder>` becomes `hi`.
|
||||
*/
|
||||
export function stripLocalCommandMetadata(content: unknown): unknown | null {
|
||||
if (typeof content === "string") {
|
||||
return stripLocalCommandMetadataText(content);
|
||||
}
|
||||
if (!Array.isArray(content)) return content;
|
||||
|
||||
const kept: unknown[] = [];
|
||||
for (const block of content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
(block as { type: unknown }).type === "text" &&
|
||||
"text" in block &&
|
||||
typeof (block as { text: unknown }).text === "string"
|
||||
) {
|
||||
const stripped = stripLocalCommandMetadataText((block as { text: string }).text);
|
||||
if (stripped === null) continue;
|
||||
kept.push({ ...(block as object), text: stripped });
|
||||
} else {
|
||||
kept.push(block);
|
||||
}
|
||||
}
|
||||
if (kept.length === 0) return null;
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the synthetic assistant message the CLI injects into the transcript
|
||||
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
|
||||
* "Session expired. Please run /login to sign in again."). The `/login`
|
||||
* instruction is Claude Code TUI-specific and meaningless to ACP clients
|
||||
* (issue #863). The live prompt loop suppresses the text and fails the turn
|
||||
* with `authRequired` so the client can run its own auth flow; replay must
|
||||
* skip it too — both for parity with what the client saw live and because the
|
||||
* message stays in the transcript forever, so it would resurface on every
|
||||
* session/load even after the user has logged back in.
|
||||
*
|
||||
* Takes the API message (`message.message`), which replay only knows as
|
||||
* `unknown`. The persisted record's structured `error` marker is stripped by
|
||||
* `getSessionMessages`, and the CLI does not always set it to
|
||||
* `authentication_failed` anyway: an expired login with no refresh token
|
||||
* ("Login expired · Run /login to sign in again, …") arrives as
|
||||
* `invalid_request`. So the synthetic model + text is what both paths match on.
|
||||
* Usage-limit banners are excluded because some of them also mention `/login`
|
||||
* (to switch accounts) and have their own handling.
|
||||
*/
|
||||
export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
|
||||
if (!apiMessage || typeof apiMessage !== "object") {
|
||||
return false;
|
||||
}
|
||||
const { model, content } = apiMessage as { model?: unknown; content?: unknown };
|
||||
if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const block = content[0] as { type?: unknown; text?: unknown } | null;
|
||||
return (
|
||||
!!block &&
|
||||
block.type === "text" &&
|
||||
typeof block.text === "string" &&
|
||||
isLoginRequiredText(block.text) &&
|
||||
!isSyntheticUsageLimitMessage(apiMessage)
|
||||
);
|
||||
}
|
||||
|
||||
/** Origin kinds of the meta user messages `getSessionMessages` returns since
|
||||
* SDK 0.3.284 (messages from other agents, sessions and channels). */
|
||||
const REPLAY_HIDDEN_META_ORIGIN_KINDS = new Set([
|
||||
"peer",
|
||||
"channel",
|
||||
"observer",
|
||||
"observer-activity",
|
||||
"slack-ping",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True for a transcript message delivered to the model from another agent,
|
||||
* session or channel. Since SDK 0.3.284 `getSessionMessages` returns these as
|
||||
* `is_meta` user messages whose content is the full harness framing (envelope
|
||||
* XML plus the "not typed by your user" preamble). The live prompt loop never
|
||||
* renders them, so replay skips them too rather than presenting them as text
|
||||
* the user typed. Other `is_meta` messages (compact summaries) are kept.
|
||||
*
|
||||
* `is_meta` and `origin` are runtime fields not declared on `SessionMessage`.
|
||||
*/
|
||||
export function isReplayHiddenMetaMessage(message: unknown): boolean {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const { type, is_meta, origin } = message as {
|
||||
type?: unknown;
|
||||
is_meta?: unknown;
|
||||
origin?: { kind?: unknown } | null;
|
||||
};
|
||||
return (
|
||||
type === "user" &&
|
||||
is_meta === true &&
|
||||
typeof origin?.kind === "string" &&
|
||||
REPLAY_HIDDEN_META_ORIGIN_KINDS.has(origin.kind)
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
@@ -56,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>;
|
||||
@@ -119,7 +131,7 @@ type TaskStarted = {
|
||||
prompt?: unknown;
|
||||
};
|
||||
|
||||
type SubagentIdentity = {
|
||||
export type SubagentIdentity = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
@@ -133,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;
|
||||
@@ -172,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
|
||||
@@ -370,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({
|
||||
@@ -400,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,
|
||||
@@ -923,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: {
|
||||
@@ -933,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);
|
||||
@@ -1103,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);
|
||||
@@ -1125,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);
|
||||
}
|
||||
|
||||
@@ -1396,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();
|
||||
@@ -1601,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;
|
||||
@@ -1614,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) {
|
||||
@@ -1843,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
|
||||
@@ -1998,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,
|
||||
@@ -2092,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 = {
|
||||
@@ -2104,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 {
|
||||
@@ -2155,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[];
|
||||
};
|
||||
13
src/paths.ts
13
src/paths.ts
@@ -5,3 +5,16 @@ import * as path from "node:path";
|
||||
export function claudeConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
}
|
||||
|
||||
/** `<config>/projects`, normalized like the SDK does. */
|
||||
export function projectsRoot(): string {
|
||||
return path.join(claudeConfigDir(), "projects").normalize("NFC");
|
||||
}
|
||||
|
||||
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Whether `value` is a Claude session id (a UUID). Only such ids name a
|
||||
* transcript, and only such ids are safe to use as a file name. */
|
||||
export function isSessionId(value: unknown): value is string {
|
||||
return typeof value === "string" && SESSION_ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* `v1/acp-agent.ts` and `v2/`). Each core adds its own side effects, such as v1's
|
||||
* session failure category.
|
||||
*/
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKMessageOrigin,
|
||||
SDKResultMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export type ResultEnd =
|
||||
| { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" }
|
||||
/**
|
||||
* The result failed the work, with Claude Code's error text. `loginRequired`:
|
||||
* the text of a `success` result tells the user to run `/login`.
|
||||
*/
|
||||
| { error: string; loginRequired: boolean };
|
||||
|
||||
/**
|
||||
* True when CLI error text tells the user to sign in with `/login`. The CLI
|
||||
* words this several ways — "Not logged in · Please run /login", "Not logged
|
||||
* in · Run /login", "Login expired · Run /login to sign in again, or
|
||||
* re-authenticate your Anthropic profile" — so match the "run /login"
|
||||
* instruction rather than one sentence.
|
||||
*/
|
||||
export function isLoginRequiredText(text: string): boolean {
|
||||
return /\brun \/login\b/i.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* - Any subtype with `stop_reason: "refusal"`: `refusal`, also on an error.
|
||||
* - `success`: an error with the `/login` text fails the work, then
|
||||
* `stop_reason: "max_tokens"` is `max_tokens`, then any other error fails
|
||||
* the work. Otherwise `end_turn`.
|
||||
* - `error_during_execution`: `max_tokens` as for `success`, then an error
|
||||
* fails the work. Otherwise `end_turn`.
|
||||
* - `error_max_turns`, `error_max_budget_usd` and
|
||||
* `error_max_structured_output_retries`: an error fails the work, whatever
|
||||
* the stop reason. Otherwise `max_turn_requests`.
|
||||
*/
|
||||
export function resultEnd(result: SDKResultMessage): ResultEnd {
|
||||
if (result.stop_reason === "refusal") return { stopReason: "refusal" };
|
||||
switch (result.subtype) {
|
||||
case "success":
|
||||
if (result.is_error && isLoginRequiredText(result.result)) {
|
||||
return { error: result.result, loginRequired: true };
|
||||
}
|
||||
if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" };
|
||||
if (result.is_error) return { error: result.result, loginRequired: false };
|
||||
return { stopReason: "end_turn" };
|
||||
case "error_during_execution":
|
||||
if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" };
|
||||
if (result.is_error) return failed(result);
|
||||
return { stopReason: "end_turn" };
|
||||
default:
|
||||
// `error_max_turns`, `error_max_budget_usd`, `error_max_structured_output_retries`.
|
||||
if (result.is_error) return failed(result);
|
||||
return { stopReason: "max_turn_requests" };
|
||||
}
|
||||
}
|
||||
|
||||
function failed(result: Exclude<SDKResultMessage, { subtype: "success" }>): ResultEnd {
|
||||
return { error: result.errors.join(", ") || result.subtype, loginRequired: false };
|
||||
}
|
||||
|
||||
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
|
||||
* model did on its own (a task-notification followup, a peer/coordinator/
|
||||
* observer message it handled) rather than the user's prompt. Absent,
|
||||
* `human`, and `channel` origins are the user's own turn (this adapter's
|
||||
* prompts arrive as the ACP channel on some CLI configurations — ALL
|
||||
* channel servers are treated as user, so a foreign channel integration's
|
||||
* autonomously-handled result is misclassified as the user's; accepted,
|
||||
* see below), and `auto-continuation` continues the user's turn, so its
|
||||
* result is the turn's real terminal.
|
||||
*
|
||||
* Deliberately fail-OPEN: an unknown future kind defaults to the user
|
||||
* lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
|
||||
* attribute this" marker, which gets the same safe default. Misrouting a
|
||||
* USER result into the autonomous lane hangs the prompt un-detectably
|
||||
* (the result is skipped, its trailing idle absorbed as owed, so the
|
||||
* #825 detector can't fire); misrouting an autonomous result into the
|
||||
* user lane is the bounded misattribution class this set exists to
|
||||
* reduce.
|
||||
*
|
||||
* Exception: a result from one of these origins that names a pending prompt
|
||||
* in `user_message_uuids` consumed that folded prompt and takes the user
|
||||
* lane (see the result handler of each core). */
|
||||
export const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet<SDKMessageOrigin["kind"]> = new Set([
|
||||
"task-notification",
|
||||
"peer",
|
||||
"coordinator",
|
||||
"observer",
|
||||
"observer-activity",
|
||||
]);
|
||||
|
||||
/** Whether Claude Code ended the work of `result` because an interrupt
|
||||
* stopped it, while the model streamed or while a tool ran. */
|
||||
export function isAbortedResult(result: Extract<SDKMessage, { type: "result" }>): boolean {
|
||||
return (
|
||||
result.terminal_reason === "aborted_streaming" || result.terminal_reason === "aborted_tools"
|
||||
);
|
||||
}
|
||||
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 "./v1/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,5 +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 };
|
||||
}
|
||||
@@ -25,6 +25,22 @@ export function effortFlagSettings(
|
||||
return ultracode ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
/**
|
||||
* `params` without `_meta.claudeCode.options.effort`, for a restart of the
|
||||
* session's query: the restart restores the current effort pin instead of
|
||||
* the one the session was created with.
|
||||
*/
|
||||
export function withoutEffort<T extends { _meta?: unknown }>(params: T): T {
|
||||
const meta = params._meta as
|
||||
{ claudeCode?: { options?: { effort?: unknown } } } | null | undefined;
|
||||
if (meta?.claudeCode?.options?.effort === undefined) {
|
||||
return params;
|
||||
}
|
||||
const options = { ...meta.claudeCode.options };
|
||||
delete options.effort;
|
||||
return { ...params, _meta: { ...meta, claudeCode: { ...meta.claudeCode, options } } };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
@@ -133,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;
|
||||
};
|
||||
@@ -49,7 +49,8 @@ import {
|
||||
type LiveSessionRegistry,
|
||||
type LiveSnapshot,
|
||||
} from "./live-registry.js";
|
||||
import { isExactProjectDir, isSessionId, projectDirMatches, projectsRoot } from "./project-dirs.js";
|
||||
import { isSessionId, projectsRoot } from "../paths.js";
|
||||
import { isExactProjectDir, projectDirMatches } from "./project-dirs.js";
|
||||
import { type IndexRow, type SessionIndex, type TranscriptCandidate } from "./session-index.js";
|
||||
import { changeSignature, sessionInfoOf } from "./session-info.js";
|
||||
|
||||
|
||||
@@ -14,24 +14,10 @@
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { claudeConfigDir } from "../paths.js";
|
||||
|
||||
/** The SDK cuts an encoded path at this length and appends a hash. */
|
||||
const MAX_SANITIZED_LENGTH = 200;
|
||||
|
||||
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Whether `value` is a Claude session id (a UUID). Only such ids name a
|
||||
* transcript, and only such ids are safe to use as a file name. */
|
||||
export function isSessionId(value: unknown): value is string {
|
||||
return typeof value === "string" && SESSION_ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/** `<config>/projects`, normalized like the SDK does. */
|
||||
export function projectsRoot(): string {
|
||||
return path.join(claudeConfigDir(), "projects").normalize("NFC");
|
||||
}
|
||||
|
||||
function sanitize(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
115
src/session-index/rename.ts
Normal file
115
src/session-index/rename.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Client renames of a session: `session/set_title` (v1 and v2) and AIR's
|
||||
* `_session/rename` (v1). Both cores call {@link renameSession}, which stores
|
||||
* the title in every transcript of the session (see
|
||||
* {@link SessionIndexService.retitle}) and, for a session loaded on the
|
||||
* connection, makes it the client title that no generated title replaces.
|
||||
*/
|
||||
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionTitles, TitledSession } from "../session-titles.js";
|
||||
import { storedTitle } from "./archive-title.js";
|
||||
import {
|
||||
renameTo,
|
||||
type RenameSessionRequest,
|
||||
type RetitleOptions,
|
||||
type SessionIndexService,
|
||||
type TitleChange,
|
||||
} from "./service.js";
|
||||
|
||||
/** A session loaded on the connection, and its title state. */
|
||||
export interface RetitledSession {
|
||||
readonly session: TitledSession;
|
||||
readonly titles: Pick<
|
||||
SessionTitles,
|
||||
"setExplicitTitle" | "storedTitle" | "rememberStoredTitle" | "shownTitle"
|
||||
>;
|
||||
}
|
||||
|
||||
type RenamingQuery = Query & {
|
||||
renameSession?: (title: string, sessionId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Names a session; no generated title replaces it. An archived session stays
|
||||
* archived. `loaded` is the session loaded on this connection, if any;
|
||||
* `clientTitle` turns a stored title into the one the client shows (an AIR
|
||||
* client's drops the archive prefix). Returns that title, which the loaded
|
||||
* session also publishes as a `session_info_update`.
|
||||
*
|
||||
* A session that is not loaded and has no transcript is unknown (`-32002`).
|
||||
*/
|
||||
export async function renameSession(
|
||||
index: SessionIndexService,
|
||||
request: RenameSessionRequest,
|
||||
loaded: RetitledSession | undefined,
|
||||
clientTitle: (stored: string) => string = (stored) => stored,
|
||||
): Promise<string> {
|
||||
const { sessionId } = request;
|
||||
// As the CLI keeps it, and never with the archive prefix: a rename does
|
||||
// not change the archive state.
|
||||
const title = storedTitle(request.title, false, sessionId);
|
||||
// The title as stored: an archived one is cut with its prefix.
|
||||
const shown = (stored: string | undefined) => clientTitle(stored ?? title);
|
||||
const stored = await retitleSession(index, sessionId, renameTo(title), shown, loaded, {
|
||||
mayBeUnwritten: true,
|
||||
sidecar: "always",
|
||||
});
|
||||
return shown(stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retitles a session (see {@link SessionIndexService.retitle}) and returns
|
||||
* the title stored. A session that runs here is titled through its CLI
|
||||
* (`rename_session`). A loaded session's title state takes the change as a
|
||||
* client title, so no generated title replaces it; `publish` is the title it
|
||||
* then publishes.
|
||||
*/
|
||||
export async function retitleSession(
|
||||
index: SessionIndexService,
|
||||
sessionId: string,
|
||||
change: TitleChange,
|
||||
publish: Parameters<SessionTitles["setExplicitTitle"]>[0],
|
||||
loaded: RetitledSession | undefined,
|
||||
options: Pick<RetitleOptions, "mayBeUnwritten" | "sidecar">,
|
||||
): Promise<string | undefined> {
|
||||
let stored: string | undefined;
|
||||
// Decided once a title generation in flight has ended: the query may
|
||||
// have closed meanwhile.
|
||||
const persist = async () => {
|
||||
const session = loaded?.session;
|
||||
const query = session?.query as RenamingQuery | undefined;
|
||||
// A closed session's CLI is gone here: only a running query titles it.
|
||||
const running = session !== undefined && !session.queryClosed;
|
||||
if (running && typeof query?.renameSession !== "function") {
|
||||
// Its CLI holds the title and would write it back over ours.
|
||||
throw RequestError.invalidRequest(
|
||||
{ sessionId },
|
||||
"The Claude Code CLI of this session cannot change its title while it runs.",
|
||||
);
|
||||
}
|
||||
const live = running
|
||||
? {
|
||||
cwd: session.cwd,
|
||||
rename: (title: string) => query!.renameSession!(title, sessionId),
|
||||
stored: () => loaded!.titles.storedTitle,
|
||||
remember: (title: string) => loaded!.titles.rememberStoredTitle(title),
|
||||
shown: loaded!.titles.shownTitle,
|
||||
}
|
||||
: undefined;
|
||||
stored = await index.retitle(sessionId, change, {
|
||||
...options,
|
||||
// Without its CLI, a session needs its transcript.
|
||||
mayBeUnwritten: live !== undefined && options.mayBeUnwritten,
|
||||
live,
|
||||
});
|
||||
return stored;
|
||||
};
|
||||
if (loaded) {
|
||||
await loaded.titles.setExplicitTitle(publish, persist);
|
||||
} else {
|
||||
await persist();
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
@@ -36,11 +36,11 @@ import {
|
||||
type ListSubscriptionDeps,
|
||||
} from "./list-subscriptions.js";
|
||||
import { LiveSessionRegistry } from "./live-registry.js";
|
||||
import { isSessionId } from "../paths.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
exists,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
projectDirMatches,
|
||||
sameProjectPath,
|
||||
} from "./project-dirs.js";
|
||||
@@ -387,16 +387,6 @@ function isSdkNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -422,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" });
|
||||
}
|
||||
|
||||
@@ -474,6 +466,18 @@ export type SessionIndexDeps = {
|
||||
logError: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
/** The transcript reads and writes of the session index, without its list
|
||||
* or watcher: what a client without `sessionIndex` uses for
|
||||
* `session/set_title`, AIR's archive on delete, and delete's ordering
|
||||
* after them (see {@link SessionIndexService.exclusive}). */
|
||||
export function transcriptWriter(logger: {
|
||||
error: (message: string, error: unknown) => void;
|
||||
}): SessionIndexService {
|
||||
return new SessionIndexService({
|
||||
logError: (message, error) => logger.error(`[session-index] ${message}:`, error),
|
||||
});
|
||||
}
|
||||
|
||||
export class SessionIndexService {
|
||||
readonly index: SessionIndex;
|
||||
readonly registry: LiveSessionRegistry;
|
||||
@@ -549,8 +553,10 @@ export class SessionIndexService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `mutation` after the previous mutation of the session ended. */
|
||||
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
/** Runs `mutation` after the previous mutation of the session ended, such
|
||||
* as a delete that does not go through {@link delete}, so that a retitle
|
||||
* cannot append to a transcript the delete is removing. */
|
||||
exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(mutation);
|
||||
const settled = next.catch(() => undefined);
|
||||
|
||||
@@ -23,18 +23,17 @@ import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { sanitizeTitle } from "../session-titles.js";
|
||||
import { effectiveTitle, visibleTitle } from "./archive-title.js";
|
||||
import { isSessionId, projectsRoot } from "../paths.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
encodeProjectPath,
|
||||
errorCode,
|
||||
exists,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
normalizePath,
|
||||
pathAndAncestors,
|
||||
projectDirMatches,
|
||||
projectDirsOf,
|
||||
projectsRoot,
|
||||
sameProjectPath,
|
||||
} from "./project-dirs.js";
|
||||
import {
|
||||
|
||||
@@ -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 {
|
||||
@@ -350,50 +392,6 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -406,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 {
|
||||
@@ -281,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
|
||||
@@ -456,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:
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
68
src/session-status.ts
Normal file
68
src/session-status.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* `session/status` (the unstable "Session status" RFD) of a session that the
|
||||
* connection does not hold: `saved` or unknown. Each core answers `open`
|
||||
* itself for the sessions it holds.
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import { isSessionId, projectsRoot } from "./paths.js";
|
||||
|
||||
/** What a core advertises as `status` in its session capabilities. */
|
||||
export const SESSION_STATUS_CAPABILITY = {};
|
||||
|
||||
/**
|
||||
* `saved` when a project directory holds a transcript file of `sessionId`
|
||||
* that is not empty. Its content is not read, so a transcript without a
|
||||
* conversation is `saved` too, and its resume fails with `-32002`. Fails with
|
||||
* `-32002` and `data.sessionId` otherwise:
|
||||
*
|
||||
* - an id that is not a UUID, such as a subagent's session id;
|
||||
* - an id without a transcript: one never used, a session never prompted
|
||||
* (Claude Code writes the transcript at the first prompt), and a session
|
||||
* whose transcripts were removed.
|
||||
*
|
||||
* The request names no `cwd`, so a transcript of any project counts: a
|
||||
* resume with another `cwd` can still fail with `-32002`.
|
||||
*
|
||||
* It lists the project directories and stats the one file name in each. No
|
||||
* query starts and no session is loaded. A directory or file that cannot be
|
||||
* read for another reason than being absent fails the request: it does not
|
||||
* make the session unknown.
|
||||
*/
|
||||
export async function savedSessionStatus(sessionId: string): Promise<{ status: "saved" }> {
|
||||
if (!isSessionId(sessionId) || !(await hasTranscript(sessionId))) {
|
||||
throw RequestError.sessionNotFound(sessionId);
|
||||
}
|
||||
return { status: "saved" };
|
||||
}
|
||||
|
||||
async function hasTranscript(sessionId: string): Promise<boolean> {
|
||||
const root = projectsRoot();
|
||||
let projects: string[];
|
||||
try {
|
||||
projects = await fs.readdir(root);
|
||||
} catch (error) {
|
||||
// No session was ever stored.
|
||||
if (isAbsent(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
const found = await Promise.all(
|
||||
projects.map((project) =>
|
||||
fs.stat(path.join(root, project, `${sessionId}.jsonl`)).then(
|
||||
(stats) => stats.isFile() && stats.size > 0,
|
||||
(error: unknown) => {
|
||||
// No such transcript, or `project` is a file.
|
||||
if (isAbsent(error)) return false;
|
||||
throw error;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
return found.includes(true);
|
||||
}
|
||||
|
||||
function isAbsent(error: unknown): boolean {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
}
|
||||
@@ -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 (`v1/acp-agent.ts`) and the v2 core
|
||||
* (`v2/session.ts`). Each drives it from the same places:
|
||||
* {@link SessionTitles.onPrompt} and {@link SessionTitles.onAssistantText} to
|
||||
* collect text, {@link SessionTitles.onPromptQueued} once a prompt is sent to
|
||||
* the CLI, {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`,
|
||||
* and, in v1, {@link SessionTitles.reset} on `conversation_reset`.
|
||||
*/
|
||||
|
||||
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.
|
||||
@@ -301,7 +324,7 @@ export class SessionTitles {
|
||||
* have it. Both come from the session file, as `session/list` reports
|
||||
* them; a session with no file yet sends the last title published, if any,
|
||||
* without `updatedAt`. */
|
||||
async publishCurrent(session: Session): Promise<void> {
|
||||
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.
|
||||
@@ -329,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) {
|
||||
@@ -357,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;
|
||||
}
|
||||
@@ -383,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;
|
||||
@@ -405,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> {
|
||||
|
||||
@@ -25,7 +25,7 @@ export type UltracodeSettingsReport = {
|
||||
};
|
||||
|
||||
// `Query.getSettings()` exists at runtime (sdk.mjs) but is missing from the
|
||||
// SDK's type declarations as of 0.3.312.
|
||||
// SDK's type declarations as of 0.3.319.
|
||||
type GetSettingsQuery = Query & {
|
||||
getSettings?: () => Promise<{
|
||||
applied?: { ultracodeAvailable?: unknown; ultracodeRequested?: unknown };
|
||||
|
||||
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 `v1/acp-agent.ts`). */
|
||||
export function quotaMeta(usage: AccumulatedUsage, modelUsage: ModelTokenTally | undefined) {
|
||||
return {
|
||||
quota: {
|
||||
token_count: quotaTokenCount(usage),
|
||||
model_usage: Object.entries(modelUsage ?? {}).map(([model, usage]) => ({
|
||||
model,
|
||||
token_count: quotaTokenCount(usage),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One `token_count` object. `cachedInputTokens` is cache reads, matching
|
||||
* codex's field; Claude also reports cache writes, which codex has no slot
|
||||
* for, so those ride along in an extra sibling (the same name the ACP `usage`
|
||||
* field already uses) and are counted in `totalTokens`. `reasoningOutputTokens`
|
||||
* is always 0: Claude bills thinking inside its output tokens and never breaks
|
||||
* it out — the key is kept so the shape stays uniform across agents. */
|
||||
function quotaTokenCount(usage: AccumulatedUsage) {
|
||||
return {
|
||||
totalTokens: tallyTotal(usage),
|
||||
inputTokens: usage.inputTokens,
|
||||
cachedInputTokens: usage.cachedReadTokens,
|
||||
cachedWriteTokens: usage.cachedWriteTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
reasoningOutputTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum a tally's four counters. Per the Anthropic API `inputTokens` excludes
|
||||
* the cache counters, so this is not double-counting (see `totalTokens`). */
|
||||
export function tallyTotal(usage: AccumulatedUsage): number {
|
||||
return usage.inputTokens + usage.outputTokens + usage.cachedReadTokens + usage.cachedWriteTokens;
|
||||
}
|
||||
|
||||
/** Project `result.modelUsage` into our tally shape. Counters are coerced the
|
||||
* way `snapshotFromUsage` coerces the stream's: third-party backends have been
|
||||
* observed omitting fields, and a missing or NaN counter must not reach the
|
||||
* wire as NaN (which `JSON.stringify` writes as `null`). */
|
||||
export function normalizeModelUsage(
|
||||
modelUsage: Record<string, ModelUsage> | undefined,
|
||||
): ModelTokenTally {
|
||||
const tally: ModelTokenTally = {};
|
||||
for (const [model, usage] of Object.entries(modelUsage ?? {})) {
|
||||
tally[model] = {
|
||||
inputTokens: finiteCount(usage?.inputTokens),
|
||||
outputTokens: finiteCount(usage?.outputTokens),
|
||||
cachedReadTokens: finiteCount(usage?.cacheReadInputTokens),
|
||||
cachedWriteTokens: finiteCount(usage?.cacheCreationInputTokens),
|
||||
};
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
function finiteCount(value: number | null | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
/** `current - previous` per model, dropping models with nothing to report so a
|
||||
* turn only lists the models it actually ran on. A reading that fell BELOW the
|
||||
* previous one means the running total restarted under us (a mid-session
|
||||
* /clear, a zeroed crash result): there is no usable reference left to
|
||||
* subtract, so the reading itself is the increment. */
|
||||
export function modelUsageIncrement(
|
||||
current: ModelTokenTally,
|
||||
previous: ModelTokenTally,
|
||||
): ModelTokenTally {
|
||||
const increment: ModelTokenTally = {};
|
||||
for (const [model, usage] of Object.entries(current)) {
|
||||
const base = previous[model];
|
||||
const subtracted: AccumulatedUsage = base
|
||||
? {
|
||||
inputTokens: usage.inputTokens - base.inputTokens,
|
||||
outputTokens: usage.outputTokens - base.outputTokens,
|
||||
cachedReadTokens: usage.cachedReadTokens - base.cachedReadTokens,
|
||||
cachedWriteTokens: usage.cachedWriteTokens - base.cachedWriteTokens,
|
||||
}
|
||||
: usage;
|
||||
const rewound = Object.values(subtracted).some((count) => count < 0);
|
||||
const resolved = rewound ? usage : subtracted;
|
||||
if (tallyTotal(resolved) > 0) {
|
||||
increment[model] = resolved;
|
||||
}
|
||||
}
|
||||
return increment;
|
||||
}
|
||||
|
||||
/** The per-model rows for the first result of a resumed session. Its
|
||||
* `modelUsage` reading continues from the totals the transcript saved (SDK
|
||||
* 0.3.277+; older transcripts may hold none), so there is no earlier reading
|
||||
* to subtract from, and taking the reading itself would charge the whole
|
||||
* pre-resume history to this one turn. The result's own `usage` is per-turn
|
||||
* (main agent loop only, like the response's `usage`), so it stands in under
|
||||
* the turn's top-level model — `key` is that model's `modelUsage` spelling
|
||||
* when the reading has one, else the assistant message's own. Any subagent
|
||||
* spend on this one turn goes unlisted rather than over-listed. */
|
||||
export function resumedFirstResultModelUsage(
|
||||
usage: {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
cache_creation_input_tokens?: number | null;
|
||||
},
|
||||
key: string | null,
|
||||
): ModelTokenTally {
|
||||
if (key === null) {
|
||||
return {};
|
||||
}
|
||||
const row: AccumulatedUsage = {
|
||||
inputTokens: finiteCount(usage.input_tokens),
|
||||
outputTokens: finiteCount(usage.output_tokens),
|
||||
cachedReadTokens: finiteCount(usage.cache_read_input_tokens),
|
||||
cachedWriteTokens: finiteCount(usage.cache_creation_input_tokens),
|
||||
};
|
||||
return tallyTotal(row) > 0 ? { [key]: row } : {};
|
||||
}
|
||||
|
||||
/** Fold `increment` into `base` per model — the per-model counterpart of the
|
||||
* `+=` the turn's flat accumulator uses. */
|
||||
export function addModelUsage(base: ModelTokenTally, increment: ModelTokenTally): ModelTokenTally {
|
||||
const merged: ModelTokenTally = { ...base };
|
||||
for (const [model, usage] of Object.entries(increment)) {
|
||||
const existing = merged[model];
|
||||
merged[model] = existing
|
||||
? {
|
||||
inputTokens: existing.inputTokens + usage.inputTokens,
|
||||
outputTokens: existing.outputTokens + usage.outputTokens,
|
||||
cachedReadTokens: existing.cachedReadTokens + usage.cachedReadTokens,
|
||||
cachedWriteTokens: existing.cachedWriteTokens + usage.cachedWriteTokens,
|
||||
}
|
||||
: usage;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Sum all four fields as a proxy for post-turn context occupancy: the current
|
||||
* turn's output becomes next turn's input. Per the Anthropic API, input_tokens
|
||||
* excludes cache tokens — cache_read and cache_creation are reported
|
||||
* separately — so summing all four is not double-counting. */
|
||||
export function totalTokens(usage: UsageSnapshot): number {
|
||||
return (
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_read_input_tokens +
|
||||
usage.cache_creation_input_tokens
|
||||
);
|
||||
}
|
||||
|
||||
/** Project a nullable API usage object into our non-null snapshot shape.
|
||||
* Both SDK message_start and assistant message `usage` have `number | null`
|
||||
* cache fields; we coerce absent values to 0 so `totalTokens` never hits
|
||||
* NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but
|
||||
* synthetic or third-party-backend stream events have been observed emitting
|
||||
* them as null/undefined — coerce those too so a malformed upstream event
|
||||
* can't leak NaN into the wire `used` field. Delta events have different
|
||||
* semantics (cumulative + prev fallback) and are handled inline. */
|
||||
export function snapshotFromUsage(usage: {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
cache_creation_input_tokens?: number | null;
|
||||
}): UsageSnapshot {
|
||||
return {
|
||||
input_tokens: usage.input_tokens ?? 0,
|
||||
output_tokens: usage.output_tokens ?? 0,
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
||||
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function commonPrefixLength(a: string, b: string) {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Best-effort first guess of a model's context window, used to seed the
|
||||
* window synchronously (via `immediateContextWindow`) until a `result` message
|
||||
* arrives with the authoritative `modelUsage` value.
|
||||
*
|
||||
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
||||
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
||||
* matching things like "10m" or embedded substrings. Semantic aliases like
|
||||
* `default` carry no such token in the ID, but their `resolvedModel` and the
|
||||
* SDK's human-facing `displayName`/`description` can (e.g.
|
||||
* "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
|
||||
* This text scan can't catch every model — some resolve to extended-context
|
||||
* models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
|
||||
* ~1M). Such a miss falls back to the default window, is refined by the
|
||||
* background `getContextUsage` (`refreshContextWindowInBackground`), and is
|
||||
* corrected by `result.modelUsage` (and cached) within one turn. */
|
||||
export function inferContextWindowFromModel(...texts: Array<string | undefined>): number | null {
|
||||
if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMatchingModelUsage(
|
||||
modelUsage: Record<string, ModelUsage>,
|
||||
currentModel: string,
|
||||
) {
|
||||
let bestKey: string | null = null;
|
||||
let bestLen = 0;
|
||||
|
||||
for (const key of Object.keys(modelUsage)) {
|
||||
const len = commonPrefixLength(key, currentModel);
|
||||
if (len > bestLen) {
|
||||
bestLen = len;
|
||||
bestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestKey) {
|
||||
// `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"),
|
||||
// the same spelling as ModelInfo.resolvedModel — the primary key the
|
||||
// window is cached under. `currentModel` (the assistant message's
|
||||
// `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result
|
||||
// handler caches under that spelling too, for seed-time reads that fall
|
||||
// back to a bare id (rows without `resolvedModel`).
|
||||
return { key: bestKey, usage: modelUsage[bestKey] };
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -80,7 +84,7 @@ export type ReplayChild = {
|
||||
responseModel?: string;
|
||||
};
|
||||
|
||||
type SendMessageCall = {
|
||||
export type SendMessageCall = {
|
||||
toolUseId: string;
|
||||
to: string;
|
||||
message: string;
|
||||
@@ -93,32 +97,27 @@ type SendMessageCall = {
|
||||
outgoingSent: boolean;
|
||||
};
|
||||
|
||||
export class RfdSubagentReplay {
|
||||
/** The children by the id of the tool use that launched each. */
|
||||
private readonly children = new Map<string, ReplayChild>();
|
||||
private readonly byAgentId = new Map<string, ReplayChild>();
|
||||
private readonly sendMessages: SendMessageCall[] = [];
|
||||
/** Launches without metadata, which the replay leaves out. */
|
||||
private readonly omitted = new Set<string>();
|
||||
/** Children whose transcript could not be read: only their association is replayed. */
|
||||
private withoutHistory = 0;
|
||||
/** The SubagentHandback calls, with their report and whether their result failed. */
|
||||
private readonly handbacks = new Map<
|
||||
string,
|
||||
{ message: string; failed: boolean; sent: boolean }
|
||||
>();
|
||||
/** A SubagentHandback call: its report, whether its result failed, and whether the replay sent it. */
|
||||
type ReplayHandback = { message: string; failed: boolean; sent: boolean };
|
||||
|
||||
constructor(
|
||||
private readonly history: SubagentHistory,
|
||||
private readonly publish: Publish,
|
||||
private readonly models?: SubagentModels,
|
||||
/**
|
||||
* Whether the replay maps a model to the session's model list as it goes.
|
||||
* A load lacks the list until the CLI started, so it maps only in
|
||||
* {@link currentStates}, after the load response.
|
||||
*/
|
||||
private readonly mapsDuringReplay = true,
|
||||
) {}
|
||||
/**
|
||||
* What both replays read from the transcripts before they send anything: the
|
||||
* children that launches with metadata started, the launches without metadata
|
||||
* (left out), the SendMessage calls, and the SubagentHandback calls. It sends
|
||||
* nothing; `RfdSubagentReplay` (v1) and `src/v2/subagent-replay.ts` send the
|
||||
* replay from it.
|
||||
*/
|
||||
export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
|
||||
/** The children by the id of the tool use that launched each. */
|
||||
readonly children = new Map<string, Child>();
|
||||
readonly byAgentId = new Map<string, Child>();
|
||||
readonly sendMessages: SendMessageCall[] = [];
|
||||
/** Launches without metadata, which the replay leaves out. */
|
||||
readonly omitted = new Set<string>();
|
||||
/** The SubagentHandback calls, by tool use id. */
|
||||
readonly handbacks = new Map<string, ReplayHandback>();
|
||||
|
||||
constructor(private readonly history: SubagentHistory) {}
|
||||
|
||||
/**
|
||||
* Registers the launches, the launch results, and the SendMessage calls in
|
||||
@@ -159,15 +158,71 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
toolUseId: string,
|
||||
input: Record<string, unknown>,
|
||||
parentSessionId: string,
|
||||
): void {
|
||||
if (this.children.has(toolUseId)) return;
|
||||
const record = this.history.agents.get(toolUseId);
|
||||
if (!record) {
|
||||
this.omitted.add(toolUseId);
|
||||
return;
|
||||
}
|
||||
// A child type adds only optional fields, which a new child leaves unset.
|
||||
const child = {
|
||||
launchId: toolUseId,
|
||||
sessionId: record.agentId,
|
||||
record,
|
||||
parentSessionId,
|
||||
input,
|
||||
announced: false,
|
||||
promptSent: false,
|
||||
} as Child;
|
||||
this.children.set(toolUseId, child);
|
||||
this.byAgentId.set(record.agentId, child);
|
||||
}
|
||||
}
|
||||
|
||||
export class RfdSubagentReplay {
|
||||
private readonly transcript: ReplayTranscript;
|
||||
/** Children whose transcript could not be read: only their association is replayed. */
|
||||
private withoutHistory = 0;
|
||||
|
||||
constructor(
|
||||
private readonly history: SubagentHistory,
|
||||
private readonly publish: Publish,
|
||||
private readonly models?: SubagentModels,
|
||||
/**
|
||||
* Whether the replay maps a model to the session's model list as it goes.
|
||||
* A load lacks the list until the CLI started, so it maps only in
|
||||
* {@link currentStates}, after the load response.
|
||||
*/
|
||||
private readonly mapsDuringReplay = true,
|
||||
/** The working directory of the root session, which a child inherits unless it reports its own. */
|
||||
private readonly rootCwd?: string,
|
||||
) {
|
||||
this.transcript = new ReplayTranscript(history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the launches, the launch results, and the SendMessage calls in
|
||||
* `messages`, the transcript of the session `ownerSessionId`.
|
||||
*/
|
||||
scan(messages: readonly SessionMessage[], ownerSessionId: string): void {
|
||||
this.transcript.scan(messages, ownerSessionId);
|
||||
}
|
||||
|
||||
/** The child that the tool use `toolUseId` launched, when the replay shows it. */
|
||||
childOfLaunch(toolUseId: string): ReplayChild | undefined {
|
||||
return this.children.get(toolUseId);
|
||||
return this.transcript.children.get(toolUseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +231,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 +244,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.transcript.byAgentId.get(sessionId);
|
||||
while (child && !seen.has(child.sessionId)) {
|
||||
if (child.cwd) return child.cwd;
|
||||
seen.add(child.sessionId);
|
||||
child = this.transcript.byAgentId.get(child.parentSessionId);
|
||||
}
|
||||
return this.rootCwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the model of a replayed response of the child `owner`. A child
|
||||
* whose model changes gets its `model` option again.
|
||||
@@ -269,7 +347,9 @@ export class RfdSubagentReplay {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return false;
|
||||
}
|
||||
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
|
||||
const sendMessage = this.transcript.sendMessages.find(
|
||||
(call) => call.toolUseId === update.toolCallId,
|
||||
);
|
||||
if (sendMessage) {
|
||||
if (sendMessage.recipientSessionId === undefined) {
|
||||
sendMessage.shownAsToolCall = true;
|
||||
@@ -287,7 +367,7 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
|
||||
const handback = owner ? this.transcript.handbacks.get(update.toolCallId) : undefined;
|
||||
if (owner && handback && !handback.failed) {
|
||||
if (!handback.sent) {
|
||||
handback.sent = true;
|
||||
@@ -303,7 +383,7 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const child = this.children.get(update.toolCallId);
|
||||
const child = this.transcript.children.get(update.toolCallId);
|
||||
if (!child) return false;
|
||||
if (
|
||||
update.status === "completed" &&
|
||||
@@ -322,41 +402,31 @@ export class RfdSubagentReplay {
|
||||
* its task id is the agent id.
|
||||
*/
|
||||
taskNotification(taskId: string, status: string): boolean {
|
||||
const child = this.byAgentId.get(taskId);
|
||||
const child = this.transcript.byAgentId.get(taskId);
|
||||
if (!child) return false;
|
||||
child.notified = nativeSubagentState(status) ?? child.notified;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the end of every announced child (see `endState`), and a notice when
|
||||
* Sends the end of every announced child (see `replayEndState`), and a notice when
|
||||
* subagent history is missing.
|
||||
*/
|
||||
async finish(supportsNotices: boolean, rootSessionId: string): Promise<void> {
|
||||
for (const child of [...this.children.values()].reverse()) {
|
||||
for (const child of [...this.transcript.children.values()].reverse()) {
|
||||
if (!child.announced) continue;
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
state: endState(child),
|
||||
state: replayEndState(child),
|
||||
},
|
||||
});
|
||||
}
|
||||
const missing = this.omitted.size + this.withoutHistory;
|
||||
const missing = this.transcript.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,17 +440,23 @@ 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,
|
||||
liveConfiguration?: (sessionId: string) => SessionConfigOption[] | undefined,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.children.values()]
|
||||
return [...this.transcript.children.values()]
|
||||
.filter((child) => child.announced)
|
||||
.flatMap((child): AcpSessionNotification[] => {
|
||||
const liveState = live(child.sessionId);
|
||||
const state: WorkState =
|
||||
liveState === undefined || liveState === "idle" ? endState(child) : { state: liveState };
|
||||
liveState === undefined || liveState === "idle"
|
||||
? replayEndState(child)
|
||||
: { state: liveState };
|
||||
// The session's model list may be known only now, after the load response.
|
||||
const entry = responseModelEntry(child.responseModel, this.models);
|
||||
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
|
||||
@@ -395,6 +471,7 @@ export class RfdSubagentReplay {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(liveState !== undefined ? { capabilities: { cancel: {} } } : {}),
|
||||
...(child.cwd && !isDirectory(child.cwd) ? { cwd: null } : {}),
|
||||
state,
|
||||
},
|
||||
},
|
||||
@@ -410,30 +487,6 @@ export class RfdSubagentReplay {
|
||||
});
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
toolUseId: string,
|
||||
input: Record<string, unknown>,
|
||||
parentSessionId: string,
|
||||
): void {
|
||||
if (this.children.has(toolUseId)) return;
|
||||
const record = this.history.agents.get(toolUseId);
|
||||
if (!record) {
|
||||
this.omitted.add(toolUseId);
|
||||
return;
|
||||
}
|
||||
const child: ReplayChild = {
|
||||
launchId: toolUseId,
|
||||
sessionId: record.agentId,
|
||||
record,
|
||||
parentSessionId,
|
||||
input,
|
||||
announced: false,
|
||||
promptSent: false,
|
||||
};
|
||||
this.children.set(toolUseId, child);
|
||||
this.byAgentId.set(record.agentId, child);
|
||||
}
|
||||
|
||||
/** The prompt of the launch, with the launch's id, to the parent and the child, as live. */
|
||||
private async sendPrompt(child: ReplayChild, text: string): Promise<void> {
|
||||
child.promptSent = true;
|
||||
@@ -463,12 +516,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.transcript.sendMessages,
|
||||
[child.sessionId, child.record.name, child.input.name],
|
||||
text,
|
||||
);
|
||||
if (call) {
|
||||
call.recipientSessionId = child.sessionId;
|
||||
@@ -530,7 +581,7 @@ export function replaySubagentTerminalState(
|
||||
* `cancelled` for a child the user stopped, else the result of its foreground
|
||||
* launch, else `idle` with no stop reason.
|
||||
*/
|
||||
function endState(child: ReplayChild): WorkState {
|
||||
export function replayEndState(child: ReplayChild): WorkState {
|
||||
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
|
||||
return end ? rfdEndState(end, undefined) : { state: "idle" };
|
||||
}
|
||||
@@ -563,7 +614,7 @@ function inputOf(block: Record<string, unknown>): Record<string, unknown> {
|
||||
}
|
||||
|
||||
/** The text of user message content: a string, or its text blocks. */
|
||||
function textOf(content: unknown): string {
|
||||
export function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
@@ -574,7 +625,48 @@ function textOf(content: unknown): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Whether `dir` is an existing directory. */
|
||||
export 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" }>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `<task-notification>` inputs that Claude Code writes to a transcript
|
||||
* when a background task stops. The replay of a session (`acp-agent.ts`) and
|
||||
* when a background task stops. The replay of a session (`v1/acp-agent.ts`) and
|
||||
* the session index (`session-index/background-tasks.ts`) read them here, so
|
||||
* both read the same fields.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"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":"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":{},"status":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"status":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}}
|
||||
@@ -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":"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":{},"status":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"status":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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":"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":{"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":{"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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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":"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":{"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":"```\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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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":"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":{"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":{"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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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":"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":{"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":{"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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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":"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":{"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":"```\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}}}}
|
||||
|
||||
@@ -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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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":"/goal Ship the feature","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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep \"value\"","toolCallId":"toolu_grep"}}}
|
||||
@@ -16,4 +16,4 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"pattern":"**/*.ts"},"sessionUpdate":"tool_call_update","title":"Find `**/*.ts`","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"src/app.ts\nsrc/b.ts","type":"text"},"type":"content"}],"rawOutput":"src/app.ts\nsrc/b.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_glob"}}}
|
||||
{"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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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":"mcp__docs__search","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
|
||||
{"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":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}}
|
||||
{"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":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{"q":"acp"},"title":"mcp__docs__search","toolCallId":"toolu_mcp"},"type":"tool_call"},"title":"mcp__docs__search"}}
|
||||
{"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":"Found 2 documents.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Found 2 documents.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_mcp"}}}
|
||||
{"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","type":"command"}],"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":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"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","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":{"claudeCode":{"toolResponse":{"mode":"select"}}},"kind":"read","locations":[{"path":"/memory/a.md"},{"path":"/memory/b.md"}],"name":"memory_recall","sessionUpdate":"tool_call_update","status":"completed","title":"Recalled 2 memories","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"mode":"synthesize"}}},"content":[{"content":{"text":"The user prefers tabs.","type":"text"},"type":"content"}],"kind":"read","name":"memory_recall","sessionUpdate":"tool_call_update","status":"completed","title":"Recalled synthesized memory","toolCallId":"00000000-0000-4000-8000-000000000002"}}}
|
||||
{"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