mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca1fe79e6f | ||
|
|
079614ab05 | ||
|
|
2ecfa83b26 | ||
|
|
dd67450fd9 | ||
|
|
d6a069f9fe | ||
|
|
85cd70c9f3 | ||
|
|
860c0441c7 | ||
|
|
3c98d76197 | ||
|
|
07a0fbc2f6 | ||
|
|
a0da4277e3 | ||
|
|
e3b7d1b9b3 | ||
|
|
32b16c1ad9 | ||
|
|
9fb631f5d7 | ||
|
|
5c81e99fe5 | ||
|
|
de37577eaf | ||
|
|
3c570c7673 | ||
|
|
96abf29334 | ||
|
|
93f58c0d2f | ||
|
|
6811943f57 | ||
|
|
d627b8c5e9 | ||
|
|
403a668078 | ||
|
|
fb9aced315 | ||
|
|
0a501434c0 | ||
|
|
c073131488 | ||
|
|
fcf5aaf06d | ||
|
|
cc73e37b41 | ||
|
|
41f04ec31c | ||
|
|
8acd0bd27f | ||
|
|
4b82236c1f | ||
|
|
0111786af2 | ||
|
|
aa82193330 | ||
|
|
26f3e8a521 | ||
|
|
769255c9a9 | ||
|
|
bea1a40e9b | ||
|
|
f9b15ba1c5 | ||
|
|
0990a8e520 | ||
|
|
15e91fb5c3 | ||
|
|
f422c7c954 | ||
|
|
063cd35380 | ||
|
|
a16145396f | ||
|
|
65a2230385 | ||
|
|
1d36b58cfb | ||
|
|
ee324b54dd | ||
|
|
d07799d7b3 | ||
|
|
7506223cff | ||
|
|
ae6c388314 | ||
|
|
320f4d70a1 | ||
|
|
e00a43901f | ||
|
|
93d87c2e71 | ||
|
|
fe94156852 | ||
|
|
3c6cb3208b | ||
|
|
cf16fabcc4 | ||
|
|
23b3073025 | ||
|
|
2bf75c1a9f | ||
|
|
27395a196d | ||
|
|
a7b3e3c97d | ||
|
|
886b9b3a75 | ||
|
|
f919d615eb | ||
|
|
f51f3a7937 | ||
|
|
b409782992 | ||
|
|
ce4a67ae33 | ||
|
|
5268661b7f | ||
|
|
9583f97536 | ||
|
|
95721e4e39 | ||
|
|
7f1c6c2078 | ||
|
|
3500b6c16e | ||
|
|
07db59ef0d | ||
|
|
34c847a9e3 | ||
|
|
d2428a49ef | ||
|
|
91d629d6c6 | ||
|
|
5fd7873c7c | ||
|
|
83496544b2 | ||
|
|
c5d5c11f40 | ||
|
|
521d1f766d | ||
|
|
f11435df13 |
18
.github/dependabot.yml
vendored
Normal file
18
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every week
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
minor:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -21,12 +21,12 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: "lts/*"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
74
.github/workflows/publish.yml
vendored
74
.github/workflows/publish.yml
vendored
@@ -1,14 +1,45 @@
|
||||
name: Publish and Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Generating a GitHub token, so that PRs and tags created by
|
||||
# the release-please-action can trigger actions workflows.
|
||||
- name: Generate GitHub token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3
|
||||
id: generate-token
|
||||
with:
|
||||
# GitHub App ID secret name
|
||||
app-id: ${{ secrets.RELEASE_PLZ_APP_ID }}
|
||||
# GitHub App private key secret name
|
||||
private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071
|
||||
id: release
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
release-type: node
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
|
||||
build-binaries:
|
||||
name: Build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
# TODO: Re-enable once Bun SDK compatibility is fixed
|
||||
if: false
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 15
|
||||
@@ -43,9 +74,9 @@ jobs:
|
||||
archive: claude-agent-acp-windows-arm64.zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
@@ -69,7 +100,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: ${{ matrix.archive }}
|
||||
path: ${{ matrix.archive }}
|
||||
@@ -77,16 +108,16 @@ jobs:
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -99,22 +130,27 @@ jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, publish-npm]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [publish-npm, release-please]
|
||||
# TODO: Re-enable once Bun SDK compatibility is fixed
|
||||
if: false
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: artifacts/*
|
||||
- name: Upload artifacts
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload \
|
||||
"${{ needs.release-please.outputs.tag_name }}" \
|
||||
artifacts/* \
|
||||
--clobber
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.agent-shell
|
||||
|
||||
.release-please-manifest.json
|
||||
release-please-config.json
|
||||
CHANGELOG.md
|
||||
|
||||
3
.release-please-manifest.json
Normal file
3
.release-please-manifest.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.29.1"
|
||||
}
|
||||
131
CHANGELOG.md
131
CHANGELOG.md
@@ -1,5 +1,136 @@
|
||||
# Changelog
|
||||
|
||||
## [0.29.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.29.0...v0.29.1) (2026-04-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* emit usage updates from Claude stream events ([#506](https://github.com/agentclientprotocol/claude-agent-acp/issues/506)) ([dd67450](https://github.com/agentclientprotocol/claude-agent-acp/commit/dd67450fd9bddc82db3e71273b535e07b2672804))
|
||||
* Remove dot from auto mode description ([#561](https://github.com/agentclientprotocol/claude-agent-acp/issues/561)) ([2ecfa83](https://github.com/agentclientprotocol/claude-agent-acp/commit/2ecfa83b26db58deaded210463fa6ff21d0dff70))
|
||||
* Update to claude-agent-sdk 0.2.112 to fix Auto bug with Opus 4.7 ([#562](https://github.com/agentclientprotocol/claude-agent-acp/issues/562)) ([079614a](https://github.com/agentclientprotocol/claude-agent-acp/commit/079614ab05afba17e2cce0d3d238df7b90e17389))
|
||||
|
||||
## [0.29.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.28.0...v0.29.0) (2026-04-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.2.111 (Opus 4.7) ([#557](https://github.com/agentclientprotocol/claude-agent-acp/issues/557)) ([85cd70c](https://github.com/agentclientprotocol/claude-agent-acp/commit/85cd70c9f3be47c9c404958547c4046d866db1c9))
|
||||
|
||||
## [0.28.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.27.0...v0.28.0) (2026-04-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.2.109 ([#549](https://github.com/agentclientprotocol/claude-agent-acp/issues/549)) ([07a0fbc](https://github.com/agentclientprotocol/claude-agent-acp/commit/07a0fbc2f6bc388541d064a436412bdd850772cb))
|
||||
|
||||
## [0.27.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.26.0...v0.27.0) (2026-04-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow clients to opt into receiving raw SDK messages ([#527](https://github.com/agentclientprotocol/claude-agent-acp/issues/527)) ([403a668](https://github.com/agentclientprotocol/claude-agent-acp/commit/403a668078c067868062ed26cd4d3e36665b66b6))
|
||||
* Update to claude-agent-sdk 0.2.104 ([#537](https://github.com/agentclientprotocol/claude-agent-acp/issues/537)) ([6811943](https://github.com/agentclientprotocol/claude-agent-acp/commit/6811943f57ef08616be633a8197223d4072663cf))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Allow auto mode after plan mode and send description for auto mode ([#528](https://github.com/agentclientprotocol/claude-agent-acp/issues/528)) ([fb9aced](https://github.com/agentclientprotocol/claude-agent-acp/commit/fb9aced3151c40694f1f01fd95665c4f5d90eb67))
|
||||
* Better remote check for auth methods ([#538](https://github.com/agentclientprotocol/claude-agent-acp/issues/538)) ([93f58c0](https://github.com/agentclientprotocol/claude-agent-acp/commit/93f58c0d2fcf7365c7ca5a6e52f56663e2065ddb))
|
||||
* better shutdown logic ([#543](https://github.com/agentclientprotocol/claude-agent-acp/issues/543)) ([9fb631f](https://github.com/agentclientprotocol/claude-agent-acp/commit/9fb631f5d76a5c5f6f0a1b61bdef05fe368c754c))
|
||||
* exit process when ACP connection closes ([#530](https://github.com/agentclientprotocol/claude-agent-acp/issues/530)) ([5c81e99](https://github.com/agentclientprotocol/claude-agent-acp/commit/5c81e99fe5ccdf774819b7ff3a2bc78a6519d730))
|
||||
* guard tool info rendering when tool_use input is undefined ([#536](https://github.com/agentclientprotocol/claude-agent-acp/issues/536)) ([d627b8c](https://github.com/agentclientprotocol/claude-agent-acp/commit/d627b8c5e95be31ac03923ef67d91748ec8564c5))
|
||||
* Remove backup auth check from new session ([#544](https://github.com/agentclientprotocol/claude-agent-acp/issues/544)) ([32b16c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/32b16c1ad99394238b0e5cd6c2f761c12debe142))
|
||||
|
||||
## [0.26.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.3...v0.26.0) (2026-04-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update claude-agent-sdk to 0.2.96 ([#526](https://github.com/agentclientprotocol/claude-agent-acp/issues/526)) ([c073131](https://github.com/agentclientprotocol/claude-agent-acp/commit/c07313148808a55f27f385c10babbaf7511a7f12))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Remove bun builds from release ([#525](https://github.com/agentclientprotocol/claude-agent-acp/issues/525)) ([fcf5aaf](https://github.com/agentclientprotocol/claude-agent-acp/commit/fcf5aaf06dfe9f7d1b285b976eeb2d1e20ea8dec))
|
||||
* Use TUI login for remote environments ([#523](https://github.com/agentclientprotocol/claude-agent-acp/issues/523)) ([cc73e37](https://github.com/agentclientprotocol/claude-agent-acp/commit/cc73e37b41678aa67813c4fbef66ba33ca538743))
|
||||
|
||||
## [0.25.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.2...v0.25.3) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Drop claude-agent-sdk back to 0.2.91 to fix broken import ([#513](https://github.com/agentclientprotocol/claude-agent-acp/issues/513)) ([26f3e8a](https://github.com/agentclientprotocol/claude-agent-acp/commit/26f3e8a5216295985fadb80fb3b977045c0c1b2c))
|
||||
* Recreate resumed sessions when params change ([#515](https://github.com/agentclientprotocol/claude-agent-acp/issues/515)) ([aa82193](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa82193330026bae132fed8391c47dde777dcf5a))
|
||||
|
||||
## [0.25.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.1...v0.25.2) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prioritize ANTHROPIC_MODEL env var over settings.model in model … ([#505](https://github.com/agentclientprotocol/claude-agent-acp/issues/505)) ([bea1a40](https://github.com/agentclientprotocol/claude-agent-acp/commit/bea1a40e9bfe1e06672b118a727f9339def3be23))
|
||||
|
||||
## [0.25.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.0...v0.25.1) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add `auto` to valid modes in applySessionMode to fix mode cycling ([#507](https://github.com/agentclientprotocol/claude-agent-acp/issues/507)) ([15e91fb](https://github.com/agentclientprotocol/claude-agent-acp/commit/15e91fb5c3449de2600b583cb7a8d36b5b510443))
|
||||
|
||||
## [0.25.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.2...v0.25.0) (2026-04-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add auto permission mode support ([#501](https://github.com/agentclientprotocol/claude-agent-acp/issues/501)) ([a161453](https://github.com/agentclientprotocol/claude-agent-acp/commit/a16145396fe2e6ead8734478961b2de65707e335))
|
||||
* Add separate Claude and Console terminal logins ([#502](https://github.com/agentclientprotocol/claude-agent-acp/issues/502)) ([063cd35](https://github.com/agentclientprotocol/claude-agent-acp/commit/063cd353809df8f3dda9e57d8ea848c0a6d44257))
|
||||
* Update to claude-agent-sdk 0.2.91 ([#500](https://github.com/agentclientprotocol/claude-agent-acp/issues/500)) ([65a2230](https://github.com/agentclientprotocol/claude-agent-acp/commit/65a223038576d72b74e1483fed10e982a1f842bd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* log warnings for malformed settings files instead of silent fallback ([#486](https://github.com/agentclientprotocol/claude-agent-acp/issues/486)) ([ae6c388](https://github.com/agentclientprotocol/claude-agent-acp/commit/ae6c38831415f9fc1de2d3dd1d4a247becbbd32f))
|
||||
* prevent race conditions in SettingsManager setCwd and debounce ([#485](https://github.com/agentclientprotocol/claude-agent-acp/issues/485)) ([7506223](https://github.com/agentclientprotocol/claude-agent-acp/commit/7506223cffb1aba4b4560feda11f69a1395a8c9d))
|
||||
* use current model's context window for usage_update size ([#412](https://github.com/agentclientprotocol/claude-agent-acp/issues/412)) ([d07799d](https://github.com/agentclientprotocol/claude-agent-acp/commit/d07799d7b3b4e438c8b158266c79723a0b592c07))
|
||||
|
||||
## [0.24.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.1...v0.24.2) (2026-03-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add explicit type checks for MCP servers (http/sse) ([#487](https://github.com/agentclientprotocol/claude-agent-acp/issues/487)) ([e00a439](https://github.com/agentclientprotocol/claude-agent-acp/commit/e00a43901fa2b4fd7d582e1de57277be88233007))
|
||||
|
||||
## [0.24.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.0...v0.24.1) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cleanup based on new idle state [#463](https://github.com/agentclientprotocol/claude-agent-acp/issues/463) ([#480](https://github.com/agentclientprotocol/claude-agent-acp/issues/480)) ([23b3073](https://github.com/agentclientprotocol/claude-agent-acp/commit/23b30730253752f0bc4e30b619a6236f16fafdb9))
|
||||
|
||||
## 0.24.0
|
||||
|
||||
Rename from `@zed-industries/claude-agent-acp` to `@agentclientprotocol/claude-agent-acp`.
|
||||
|
||||
We are moving this to the main ACP org to better allow multiple teams to contribute and maintain this adapter.
|
||||
|
||||
## 0.23.1
|
||||
|
||||
- Add back error_during_execution break point (#469)
|
||||
|
||||
## 0.23.0
|
||||
|
||||
- Use idle session state as end of turn (#463)
|
||||
- Update claude-agent-sdk to 0.2.83 (#462)
|
||||
- Fix handling of local-only slash commands (#432)
|
||||
- fix: correct null check for gatewayAuthMeta in subscription validation (#455)
|
||||
- fix: include both stdout and stderr in Bash tool output (#456)
|
||||
- fix: prevent prompt loop hang when cancel races with first result (#458)
|
||||
- fix: dispose SettingsManager on session close to prevent resource leaks (#454)
|
||||
- fix: restore plan content in ExitPlanMode tool call (#451)
|
||||
|
||||
## 0.22.2
|
||||
|
||||
- Add experimental meta param for testing additional directories
|
||||
|
||||
## 0.22.1
|
||||
|
||||
- Fix: invalid auth required state in gateway mode
|
||||
|
||||
61
LICENSE
61
LICENSE
@@ -1,42 +1,18 @@
|
||||
Copyright 2022 - 2025 Zed Industries, Inc.
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
|
||||
|
||||
Apache License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
|
||||
1. Definitions.
|
||||
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
@@ -45,28 +21,23 @@ Apache License
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
@@ -75,7 +46,6 @@ Apache License
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
@@ -90,12 +60,10 @@ Apache License
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
@@ -103,7 +71,6 @@ Apache License
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
@@ -120,28 +87,23 @@ Apache License
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
@@ -159,7 +121,6 @@ Apache License
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
@@ -167,7 +128,6 @@ Apache License
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
@@ -176,13 +136,11 @@ Apache License
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
@@ -193,7 +151,6 @@ Apache License
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
@@ -206,7 +163,6 @@ Apache License
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
@@ -218,5 +174,18 @@ Apache License
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025 Zed Industries, Inc. and contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
43
README.md
43
README.md
@@ -1,8 +1,8 @@
|
||||
# ACP adapter for the Claude Agent SDK
|
||||
|
||||
[](https://www.npmjs.com/package/@zed-industries/claude-agent-acp)
|
||||
[](https://www.npmjs.com/package/@agentclientprotocol/claude-agent-acp)
|
||||
|
||||
Use [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
|
||||
Use [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines) from [ACP-compatible](https://agentclientprotocol.com) clients!
|
||||
|
||||
This tool implements an ACP agent by using the official [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting:
|
||||
|
||||
@@ -18,41 +18,8 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
|
||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
## How to use
|
||||
## Contribution Policy
|
||||
|
||||
### Zed
|
||||
This project does not require a Contributor License Agreement (CLA). Instead, contributions are accepted under the following terms:
|
||||
|
||||
The latest version of Zed can already use this adapter out of the box.
|
||||
|
||||
To use Claude Agent, open the Agent Panel and click "New Claude Agent Thread" from the `+` button menu in the top-right:
|
||||
|
||||
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
|
||||
|
||||
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
|
||||
|
||||
### Other clients
|
||||
|
||||
Or try it with any of the other [ACP compatible clients](https://agentclientprotocol.com/overview/clients)!
|
||||
|
||||
#### Installation
|
||||
|
||||
Install the adapter from `npm`:
|
||||
|
||||
```bash
|
||||
npm install -g @zed-industries/claude-agent-acp
|
||||
```
|
||||
|
||||
You can then use `claude-agent-acp` as a regular ACP agent:
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY=sk-... claude-agent-acp
|
||||
```
|
||||
|
||||
#### Single-file executable
|
||||
|
||||
Pre-built single-file binaries are available on the [Releases](https://github.com/zed-industries/claude-agent-acp/releases) page for Linux, macOS, and Windows.
|
||||
These binaries bundle everything needed and don't require Node.js.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
> By contributing to this project, you agree that your contributions will be licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). You affirm that you have the legal right to submit your work, that you are not including code you do not have rights to, and that you understand contributions are made without requiring a Contributor License Agreement (CLA).
|
||||
|
||||
1773
package-lock.json
generated
1773
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@zed-industries/claude-agent-acp",
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.22.1",
|
||||
"version": "0.29.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -49,33 +49,33 @@
|
||||
"sdk",
|
||||
"code"
|
||||
],
|
||||
"homepage": "https://github.com/zed-industries/claude-agent-acp#readme",
|
||||
"homepage": "https://github.com/agentclientprotocol/claude-agent-acp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/zed-industries/claude-agent-acp/issues"
|
||||
"url": "https://github.com/agentclientprotocol/claude-agent-acp/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zed-industries/claude-agent-acp.git"
|
||||
"url": "git+https://github.com/agentclientprotocol/claude-agent-acp.git"
|
||||
},
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.76",
|
||||
"@agentclientprotocol/sdk": "0.19.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.112",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.78.0",
|
||||
"@anthropic-ai/sdk": "0.89.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"eslint": "10.0.3",
|
||||
"@types/node": "25.6.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"eslint": "10.2.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.4.0",
|
||||
"prettier": "3.8.1",
|
||||
"globals": "17.5.0",
|
||||
"prettier": "3.8.3",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.0"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
13
release-please-config.json
Normal file
13
release-please-config.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true,
|
||||
"packages": {
|
||||
".": {
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"release-type": "node",
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true
|
||||
}
|
||||
},
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
||||
}
|
||||
596
src/acp-agent.ts
596
src/acp-agent.ts
@@ -40,6 +40,7 @@ import {
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
StopReason,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
CanUseTool,
|
||||
@@ -47,19 +48,18 @@ import {
|
||||
listSessions,
|
||||
McpServerConfig,
|
||||
ModelInfo,
|
||||
ModelUsage,
|
||||
Options,
|
||||
PermissionMode,
|
||||
Query,
|
||||
query,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKResultMessage,
|
||||
SDKUserMessage,
|
||||
SlashCommand,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
@@ -107,11 +107,30 @@ type AccumulatedUsage = {
|
||||
cachedWriteTokens: number;
|
||||
};
|
||||
|
||||
type UsageSnapshot = {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
};
|
||||
|
||||
const ZERO_USAGE = Object.freeze({
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
});
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
|
||||
type Session = {
|
||||
query: Query;
|
||||
input: Pushable<SDKUserMessage>;
|
||||
cancelled: boolean;
|
||||
cwd: string;
|
||||
/** Serialized snapshot of session-defining params (cwd, mcpServers) used to
|
||||
* detect when loadSession/resumeSession is called with changed values. */
|
||||
sessionFingerprint: string;
|
||||
settingsManager: SettingsManager;
|
||||
accumulatedUsage: AccumulatedUsage;
|
||||
modes: SessionModeState;
|
||||
@@ -121,8 +140,27 @@ type Session = {
|
||||
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
|
||||
nextPendingOrder: number;
|
||||
abortController: AbortController;
|
||||
emitRawSDKMessages: boolean | SDKMessageFilter[];
|
||||
/** Context window size of the last top-level assistant model, carried across
|
||||
* prompts so mid-stream usage_update notifications report a correct `size`
|
||||
* before the turn's first result message arrives. Defaults to
|
||||
* DEFAULT_CONTEXT_WINDOW, refreshed from each result's modelUsage, and
|
||||
* invalidated when the user switches the session's model. */
|
||||
contextWindowSize: number;
|
||||
};
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
* detect when a loadSession/resumeSession call requires tearing down and
|
||||
* recreating the underlying Query process. MCP servers are sorted by name
|
||||
* so that ordering differences don't trigger unnecessary recreations. */
|
||||
function computeSessionFingerprint(params: {
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
}): string {
|
||||
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
||||
}
|
||||
|
||||
type BackgroundTerminal =
|
||||
| {
|
||||
handle: TerminalHandle;
|
||||
@@ -134,6 +172,11 @@ type BackgroundTerminal =
|
||||
pendingOutput: TerminalOutputResponse;
|
||||
};
|
||||
|
||||
export type SDKMessageFilter = {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra metadata that can be given when creating a new session.
|
||||
*/
|
||||
@@ -155,7 +198,16 @@ export type NewSessionMeta = {
|
||||
* - tools (passed through; defaults to claude_code preset if not provided)
|
||||
*/
|
||||
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[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -226,7 +278,12 @@ function shouldHideClaudeAuth(): boolean {
|
||||
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
||||
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
||||
|
||||
// Slash commands that the SDK handles locally without replaying the user
|
||||
// message and without invoking the model.
|
||||
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
||||
|
||||
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
acceptedits: "acceptEdits",
|
||||
dontask: "dontAsk",
|
||||
@@ -300,25 +357,85 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
};
|
||||
|
||||
const terminalAuthMethod: any = {
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
type: "terminal",
|
||||
args: ["--cli"],
|
||||
};
|
||||
const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
|
||||
|
||||
// If client supports terminal-auth capability, use that instead.
|
||||
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
||||
if (supportsMetaTerminalAuth) {
|
||||
terminalAuthMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...process.argv.slice(1), "--cli"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
|
||||
// Detect remote environments where the OAuth browser redirect to localhost
|
||||
// won't work. This matches the SDK's internal isRemote check. In these cases,
|
||||
// the `auth login` subcommand would fall back to a device-code-like manual
|
||||
// flow, which doesn't work well over ACP, so we offer the TUI login instead.
|
||||
const isRemote = !!(
|
||||
process.env.NO_BROWSER ||
|
||||
process.env.SSH_CONNECTION ||
|
||||
process.env.SSH_CLIENT ||
|
||||
process.env.SSH_TTY ||
|
||||
process.env.CLAUDE_CODE_REMOTE
|
||||
);
|
||||
const terminalAuthMethods: AuthMethod[] = [];
|
||||
|
||||
if (isRemote) {
|
||||
const remoteLoginMethod: AuthMethod = {
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
type: "terminal",
|
||||
args: ["--cli"],
|
||||
};
|
||||
|
||||
if (supportsMetaTerminalAuth) {
|
||||
remoteLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...process.argv.slice(1), "--cli"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
|
||||
terminalAuthMethods.push(remoteLoginMethod);
|
||||
}
|
||||
} else {
|
||||
const claudeLoginMethod: AuthMethod = {
|
||||
description: "Use Claude subscription ",
|
||||
name: "Claude Subscription",
|
||||
id: "claude-ai-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
};
|
||||
|
||||
const consoleLoginMethod: AuthMethod = {
|
||||
description: "Use Anthropic Console (API usage billing)",
|
||||
name: "Anthropic Console",
|
||||
id: "console-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
};
|
||||
|
||||
if (supportsMetaTerminalAuth) {
|
||||
const baseArgs = process.argv.slice(1);
|
||||
claudeLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--claudeai"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
};
|
||||
consoleLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--console"],
|
||||
label: "Anthropic Console Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
|
||||
terminalAuthMethods.push(claudeLoginMethod);
|
||||
}
|
||||
if (supportsTerminalAuth || supportsMetaTerminalAuth) {
|
||||
terminalAuthMethods.push(consoleLoginMethod);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -350,24 +467,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
title: "Claude Agent",
|
||||
version: packageJson.version,
|
||||
},
|
||||
authMethods: [
|
||||
...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
|
||||
? [terminalAuthMethod]
|
||||
: []),
|
||||
...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
|
||||
],
|
||||
authMethods: [...terminalAuthMethods, ...(supportsGatewayAuth ? [gatewayAuthMethod] : [])],
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
if (
|
||||
!this.gatewayAuthMeta &&
|
||||
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
|
||||
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
|
||||
) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
const response = await this.createSession(params, {
|
||||
// Revisit these meta values once we support resume
|
||||
resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.resume,
|
||||
@@ -462,13 +566,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
|
||||
let lastAssistantTotalUsage: number | null = null;
|
||||
let lastAssistantUsage: UsageSnapshot | null = null;
|
||||
let lastAssistantModel: string | null = null;
|
||||
|
||||
const userMessage = promptToClaude(params);
|
||||
|
||||
const promptUuid = randomUUID();
|
||||
userMessage.uuid = promptUuid;
|
||||
|
||||
let promptReplayed = false;
|
||||
// These local-only commands return a result without replaying the user
|
||||
// message. Mark promptReplayed=true so their result isn't consumed as a
|
||||
// background task result.
|
||||
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
||||
const isLocalOnlyCommand =
|
||||
firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
||||
|
||||
if (session.promptRunning) {
|
||||
session.input.push(userMessage);
|
||||
@@ -479,15 +590,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (cancelled) {
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
// The replay resolved the promise, mark in this loop too,
|
||||
// so we don't treat the next result as a background task's result.
|
||||
promptReplayed = true;
|
||||
} else {
|
||||
session.input.push(userMessage);
|
||||
}
|
||||
|
||||
session.promptRunning = true;
|
||||
let handedOff = false;
|
||||
let stopReason: StopReason = "end_turn";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -500,6 +609,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
session.emitRawSDKMessages &&
|
||||
shouldEmitRawMessage(session.emitRawSDKMessages, message)
|
||||
) {
|
||||
await this.client.extNotification("_claude/sdkMessage", {
|
||||
sessionId: params.sessionId,
|
||||
message: message as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
switch (message.subtype) {
|
||||
@@ -518,9 +637,27 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
case "compact_boundary": {
|
||||
// We don't know the exact size, but since we compacted,
|
||||
// we set it to zero. The client gets the exact size on the next message.
|
||||
// Send used:0 immediately so the client doesn't keep showing
|
||||
// the stale pre-compaction context size until the next turn.
|
||||
//
|
||||
// This is a deliberate approximation: we don't know the exact
|
||||
// post-compaction token count (only the SDK's next API call
|
||||
// reveals that). But used:0 is directionally correct — context
|
||||
// just dropped dramatically — and the real value replaces it
|
||||
// within seconds when the next result message arrives.
|
||||
// The alternative (no update) leaves the client showing e.g.
|
||||
// "944k/1m" right after the user sees "Compacting completed",
|
||||
// which is confusing and wrong.
|
||||
lastAssistantTotalUsage = 0;
|
||||
lastAssistantUsage = null;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 0,
|
||||
size: session.contextWindowSize,
|
||||
},
|
||||
});
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
@@ -528,7 +665,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
content: { type: "text", text: "\n\nCompacting completed." },
|
||||
},
|
||||
});
|
||||
promptReplayed = true;
|
||||
break;
|
||||
}
|
||||
case "local_command_output": {
|
||||
@@ -539,7 +675,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
content: { type: "text", text: message.content },
|
||||
},
|
||||
});
|
||||
promptReplayed = true;
|
||||
break;
|
||||
}
|
||||
case "session_state_changed": {
|
||||
if (message.state === "idle") {
|
||||
return { stopReason, usage: sessionUsage(session) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "hook_started":
|
||||
@@ -549,7 +690,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "task_started":
|
||||
case "task_notification":
|
||||
case "task_progress":
|
||||
case "task_updated":
|
||||
case "elicitation_complete":
|
||||
case "plugin_install":
|
||||
case "memory_recall":
|
||||
case "notification":
|
||||
case "api_retry":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
default:
|
||||
@@ -564,10 +710,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
|
||||
session.accumulatedUsage.cachedWriteTokens += message.usage.cache_creation_input_tokens;
|
||||
|
||||
// Calculate context window size from modelUsage (minimum across all models used)
|
||||
const contextWindows = Object.values(message.modelUsage).map((m) => m.contextWindow);
|
||||
const contextWindowSize =
|
||||
contextWindows.length > 0 ? Math.min(...contextWindows) : 200000;
|
||||
const matchingModelUsage = lastAssistantModel
|
||||
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
||||
: null;
|
||||
// Only overwrite when we have an authoritative value — a miss
|
||||
// (e.g. a turn with no top-level assistant message) would
|
||||
// otherwise discard the window learned on a prior turn and
|
||||
// leave the next prompt's mid-stream updates reporting 200k.
|
||||
if (matchingModelUsage) {
|
||||
session.contextWindowSize = matchingModelUsage.contextWindow;
|
||||
}
|
||||
|
||||
// Send usage_update notification
|
||||
if (lastAssistantTotalUsage !== null) {
|
||||
@@ -576,7 +728,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: lastAssistantTotalUsage,
|
||||
size: contextWindowSize,
|
||||
size: session.contextWindowSize,
|
||||
cost: {
|
||||
amount: message.total_cost_usd,
|
||||
currency: "USD",
|
||||
@@ -585,47 +737,43 @@ export class ClaudeAcpAgent implements Agent {
|
||||
});
|
||||
}
|
||||
|
||||
if (!promptReplayed) {
|
||||
// This result is from a background task that finished after
|
||||
// the previous prompt loop ended. Consume it and continue
|
||||
// waiting for our own prompt's result.
|
||||
this.logger.log(`Session ${params.sessionId}: consuming background task result`);
|
||||
if (session.cancelled) {
|
||||
stopReason = "cancelled";
|
||||
break;
|
||||
}
|
||||
|
||||
if (session.cancelled) {
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
|
||||
// Build the usage response
|
||||
const usage: PromptResponse["usage"] = {
|
||||
inputTokens: session.accumulatedUsage.inputTokens,
|
||||
outputTokens: session.accumulatedUsage.outputTokens,
|
||||
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
|
||||
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
|
||||
totalTokens:
|
||||
session.accumulatedUsage.inputTokens +
|
||||
session.accumulatedUsage.outputTokens +
|
||||
session.accumulatedUsage.cachedReadTokens +
|
||||
session.accumulatedUsage.cachedWriteTokens,
|
||||
};
|
||||
|
||||
switch (message.subtype) {
|
||||
case "success": {
|
||||
if (message.result.includes("Please run /login")) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
if (message.stop_reason === "max_tokens") {
|
||||
return { stopReason: "max_tokens", usage };
|
||||
stopReason = "max_tokens";
|
||||
break;
|
||||
}
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(undefined, message.result);
|
||||
}
|
||||
return { stopReason: "end_turn", usage };
|
||||
// For local-only commands (no model invocation), the result
|
||||
// text is the command output — forward it to the client.
|
||||
if (isLocalOnlyCommand) {
|
||||
for (const notification of toAcpNotifications(
|
||||
message.result,
|
||||
"assistant",
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error_during_execution":
|
||||
case "error_during_execution": {
|
||||
if (message.stop_reason === "max_tokens") {
|
||||
return { stopReason: "max_tokens", usage };
|
||||
stopReason = "max_tokens";
|
||||
break;
|
||||
}
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(
|
||||
@@ -633,7 +781,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message.errors.join(", ") || message.subtype,
|
||||
);
|
||||
}
|
||||
return { stopReason: "end_turn", usage };
|
||||
stopReason = "end_turn";
|
||||
break;
|
||||
}
|
||||
case "error_max_budget_usd":
|
||||
case "error_max_turns":
|
||||
case "error_max_structured_output_retries":
|
||||
@@ -643,7 +793,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message.errors.join(", ") || message.subtype,
|
||||
);
|
||||
}
|
||||
return { stopReason: "max_turn_requests", usage };
|
||||
stopReason = "max_turn_requests";
|
||||
break;
|
||||
default:
|
||||
unreachable(message, this.logger);
|
||||
break;
|
||||
@@ -651,6 +802,57 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
case "stream_event": {
|
||||
if (
|
||||
message.parent_tool_use_id === null &&
|
||||
(message.event.type === "message_start" || message.event.type === "message_delta")
|
||||
) {
|
||||
if (message.event.type === "message_start") {
|
||||
lastAssistantUsage = snapshotFromUsage(message.event.message.usage);
|
||||
const model = message.event.message.model;
|
||||
if (model && model !== "<synthetic>") {
|
||||
lastAssistantModel = model;
|
||||
// Only upgrade from the default — once a `result` has given
|
||||
// us an authoritative window, trust it over the heuristic.
|
||||
// Model switches invalidate the cached window via
|
||||
// `syncSessionConfigState`, which resets us back to the
|
||||
// default so this branch runs again for the new model.
|
||||
if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
|
||||
const inferred = inferContextWindowFromModel(model);
|
||||
if (inferred !== null) {
|
||||
session.contextWindowSize = inferred;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const usage = message.event.usage;
|
||||
const prev: Readonly<UsageSnapshot> = lastAssistantUsage ?? ZERO_USAGE;
|
||||
// Per Anthropic API, message_delta usage fields are *cumulative*;
|
||||
// nullable fields (input_tokens and the cache fields) fall back
|
||||
// to the prior snapshot when the server omits them from this
|
||||
// delta. Only output_tokens is guaranteed non-null.
|
||||
lastAssistantUsage = {
|
||||
input_tokens: usage.input_tokens ?? prev.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
cache_read_input_tokens:
|
||||
usage.cache_read_input_tokens ?? prev.cache_read_input_tokens,
|
||||
cache_creation_input_tokens:
|
||||
usage.cache_creation_input_tokens ?? prev.cache_creation_input_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
const nextUsage = totalTokens(lastAssistantUsage);
|
||||
if (nextUsage !== lastAssistantTotalUsage) {
|
||||
lastAssistantTotalUsage = nextUsage;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: nextUsage,
|
||||
size: session.contextWindowSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const notification of streamEventToAcpNotifications(
|
||||
message,
|
||||
params.sessionId,
|
||||
@@ -675,11 +877,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// Check for prompt replay
|
||||
if (message.type === "user" && "uuid" in message && message.uuid) {
|
||||
if (message.uuid === promptUuid) {
|
||||
// Our own prompt was replayed back — we're now processing
|
||||
// our prompt's response (not a background task's).
|
||||
promptReplayed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const pending = session.pendingMessages.get(message.uuid as string);
|
||||
if (pending) {
|
||||
pending.resolve(false);
|
||||
@@ -687,7 +887,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
handedOff = true;
|
||||
// the current loop stops with end_turn,
|
||||
// the loop of the next prompt continues running
|
||||
return { stopReason: "end_turn" };
|
||||
return { stopReason: "end_turn", usage: sessionUsage(session) };
|
||||
}
|
||||
if ("isReplay" in message && message.isReplay) {
|
||||
// not pending or unrelated replay message
|
||||
@@ -695,14 +895,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// Store latest assistant usage (excluding subagents)
|
||||
if ((message.message as any).usage && message.parent_tool_use_id === null) {
|
||||
const messageWithUsage = message.message as unknown as SDKResultMessage;
|
||||
lastAssistantTotalUsage =
|
||||
messageWithUsage.usage.input_tokens +
|
||||
messageWithUsage.usage.output_tokens +
|
||||
messageWithUsage.usage.cache_read_input_tokens +
|
||||
messageWithUsage.usage.cache_creation_input_tokens;
|
||||
// Snapshot the latest top-level assistant usage and model so the
|
||||
// next `result` can emit a usage_update tied to the right context
|
||||
// window. Subagent messages are excluded to keep the snapshot
|
||||
// aligned with what the user's current selection is producing.
|
||||
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
||||
lastAssistantUsage = snapshotFromUsage(message.message.usage);
|
||||
lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
|
||||
if (message.message.model && message.message.model !== "<synthetic>") {
|
||||
lastAssistantModel = message.message.model;
|
||||
}
|
||||
}
|
||||
|
||||
// Slash commands like /compact can generate invalid output... doesn't match
|
||||
@@ -711,22 +913,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
typeof message.message.content === "string" &&
|
||||
message.message.content.includes("<local-command-stdout>")
|
||||
) {
|
||||
// Handle /context by sending its reply as regular agent message.
|
||||
if (message.message.content.includes("Context Usage")) {
|
||||
for (const notification of toAcpNotifications(
|
||||
message.message.content
|
||||
.replace("<local-command-stdout>", "")
|
||||
.replace("</local-command-stdout>", ""),
|
||||
"assistant",
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{ clientCapabilities: this.clientCapabilities },
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
}
|
||||
this.logger.log(message.message.content);
|
||||
break;
|
||||
}
|
||||
@@ -810,6 +996,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message.includes("Failed to write to process stdin")
|
||||
) {
|
||||
this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
|
||||
session.settingsManager.dispose();
|
||||
session.input.end();
|
||||
delete this.sessions[params.sessionId];
|
||||
throw RequestError.internalError(
|
||||
@@ -840,7 +1027,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
const session = this.sessions[params.sessionId];
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
return;
|
||||
}
|
||||
session.cancelled = true;
|
||||
for (const [, pending] of session.pendingMessages) {
|
||||
@@ -850,16 +1037,30 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await session.query.interrupt();
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
const session = this.sessions[params.sessionId];
|
||||
/** Cleanly tear down a session: cancel in-flight work, dispose resources,
|
||||
* and remove it from the session map. */
|
||||
private async teardownSession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
await this.cancel({ sessionId });
|
||||
session.settingsManager.dispose();
|
||||
session.abortController.abort();
|
||||
session.query.close();
|
||||
delete this.sessions[sessionId];
|
||||
}
|
||||
|
||||
/** Tear down all active sessions. Called when the ACP connection closes. */
|
||||
async dispose(): Promise<void> {
|
||||
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
await this.cancel({ sessionId: params.sessionId });
|
||||
|
||||
session.abortController.abort();
|
||||
delete this.sessions[params.sessionId];
|
||||
|
||||
await this.teardownSession(params.sessionId);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -954,6 +1155,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
private async applySessionMode(sessionId: string, modeId: string): Promise<void> {
|
||||
switch (modeId) {
|
||||
case "auto":
|
||||
case "default":
|
||||
case "acceptEdits":
|
||||
case "bypassPermissions":
|
||||
@@ -1026,6 +1228,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const options = [
|
||||
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Yes, and auto-accept edits",
|
||||
@@ -1063,6 +1266,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "default" ||
|
||||
response.outcome.optionId === "acceptEdits" ||
|
||||
response.outcome.optionId === "auto" ||
|
||||
response.outcome.optionId === "bypassPermissions")
|
||||
) {
|
||||
await this.client.sessionUpdate({
|
||||
@@ -1195,6 +1399,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (configId === "mode") {
|
||||
session.modes = { ...session.modes, currentModeId: value };
|
||||
} else if (configId === "model") {
|
||||
if (session.models.currentModelId !== value) {
|
||||
// The cached context window was learned for the previous model; reset
|
||||
// to the new model's heuristic so mid-stream updates between now and
|
||||
// the next `result` reflect the user's selection instead of the old
|
||||
// model's window.
|
||||
session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW;
|
||||
}
|
||||
session.models = { ...session.models, currentModelId: value };
|
||||
}
|
||||
}
|
||||
@@ -1207,12 +1418,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}): Promise<NewSessionResponse> {
|
||||
const existingSession = this.sessions[params.sessionId];
|
||||
if (existingSession) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
};
|
||||
const fingerprint = computeSessionFingerprint(params);
|
||||
if (fingerprint === existingSession.sessionFingerprint) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
// Session-defining params changed (e.g. cwd pointed at a git worktree,
|
||||
// or MCP servers reconfigured). Tear down the existing session and
|
||||
// recreate it so the underlying Query process picks up the new values.
|
||||
await this.teardownSession(params.sessionId);
|
||||
}
|
||||
|
||||
const response = await this.createSession(
|
||||
@@ -1259,7 +1478,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const mcpServers: Record<string, McpServerConfig> = {};
|
||||
if (Array.isArray(params.mcpServers)) {
|
||||
for (const server of params.mcpServers) {
|
||||
if ("type" in server) {
|
||||
if ("type" in server && (server.type === "http" || server.type === "sse")) {
|
||||
// HTTP or SSE type MCP server
|
||||
mcpServers[server.name] = {
|
||||
type: server.type,
|
||||
url: server.url,
|
||||
@@ -1268,6 +1488,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
// Stdio type MCP server (with or without explicit type field)
|
||||
mcpServers[server.name] = {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
@@ -1299,7 +1520,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
);
|
||||
|
||||
// Extract options from _meta if provided
|
||||
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
|
||||
const sessionMeta = params._meta as NewSessionMeta | undefined;
|
||||
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
||||
|
||||
// Configure thinking tokens from environment variable
|
||||
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
|
||||
@@ -1328,6 +1550,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
...process.env,
|
||||
...userProvidedOptions?.env,
|
||||
...createEnvForGateway(this.gatewayAuthMeta),
|
||||
// Opt-in to session state events like when the agent is idle
|
||||
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
||||
},
|
||||
// Override certain fields that must be controlled by ACP
|
||||
cwd: params.cwd,
|
||||
@@ -1378,6 +1602,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
abortController,
|
||||
};
|
||||
|
||||
options.additionalDirectories = [
|
||||
...(userProvidedOptions?.additionalDirectories ?? []),
|
||||
...(sessionMeta?.additionalRoots ?? []),
|
||||
];
|
||||
|
||||
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
||||
// Set our own session id if not resuming an existing session.
|
||||
options.sessionId = sessionId;
|
||||
@@ -1400,7 +1629,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (
|
||||
creationOpts.resume &&
|
||||
error instanceof Error &&
|
||||
error.message === "Query closed before response received"
|
||||
(error.message === "Query closed before response received" ||
|
||||
error.message.includes("No conversation found with session ID"))
|
||||
) {
|
||||
throw RequestError.resourceNotFound(sessionId);
|
||||
}
|
||||
@@ -1410,7 +1640,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (
|
||||
shouldHideClaudeAuth() &&
|
||||
initializationResult.account.subscriptionType &&
|
||||
this.gatewayAuthMeta === null
|
||||
!this.gatewayAuthMeta
|
||||
) {
|
||||
throw RequestError.authRequired(
|
||||
undefined,
|
||||
@@ -1421,6 +1651,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
|
||||
|
||||
const availableModes = [
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Use a model classifier to approve/deny permission prompts",
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
@@ -1463,6 +1698,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
input: input,
|
||||
cancelled: false,
|
||||
cwd: params.cwd,
|
||||
sessionFingerprint: computeSessionFingerprint(params),
|
||||
settingsManager,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -1477,6 +1713,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController,
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
contextWindowSize:
|
||||
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1488,6 +1727,63 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldEmitRawMessage(
|
||||
config: boolean | SDKMessageFilter[],
|
||||
message: { type: string; subtype?: string },
|
||||
): boolean {
|
||||
if (config === true) return true;
|
||||
if (config === false) return false;
|
||||
return config.some(
|
||||
(f) => f.type === message.type && (f.subtype === undefined || f.subtype === message.subtype),
|
||||
);
|
||||
}
|
||||
|
||||
function sessionUsage(session: Session) {
|
||||
return {
|
||||
inputTokens: session.accumulatedUsage.inputTokens,
|
||||
outputTokens: session.accumulatedUsage.outputTokens,
|
||||
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
|
||||
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
|
||||
totalTokens:
|
||||
session.accumulatedUsage.inputTokens +
|
||||
session.accumulatedUsage.outputTokens +
|
||||
session.accumulatedUsage.cachedReadTokens +
|
||||
session.accumulatedUsage.cachedWriteTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
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. Delta events have different semantics (cumulative + prev fallback)
|
||||
* and are handled inline. */
|
||||
function snapshotFromUsage(usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens: number | null;
|
||||
cache_creation_input_tokens: number | null;
|
||||
}): UsageSnapshot {
|
||||
return {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
||||
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) {
|
||||
if (!gatewayMeta) {
|
||||
return {};
|
||||
@@ -1617,7 +1913,16 @@ async function getAvailableModels(
|
||||
|
||||
let currentModel = models[0];
|
||||
|
||||
if (settings.model) {
|
||||
// Model priority (highest to lowest):
|
||||
// 1. ANTHROPIC_MODEL environment variable
|
||||
// 2. settings.model (user configuration)
|
||||
// 3. models[0] (default first model)
|
||||
if (process.env.ANTHROPIC_MODEL) {
|
||||
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
}
|
||||
} else if (settings.model) {
|
||||
const match = resolveModelPreference(models, settings.model);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
@@ -1695,10 +2000,10 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
case "text": {
|
||||
let text = chunk.text;
|
||||
// change /mcp:server:command args -> /server:command (MCP) args
|
||||
const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
|
||||
const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s(.*))?$/);
|
||||
if (mcpMatch) {
|
||||
const [, server, command, args] = mcpMatch;
|
||||
text = `/${server}:${command} (MCP)${args || ""}`;
|
||||
text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
|
||||
}
|
||||
content.push({ type: "text", text });
|
||||
break;
|
||||
@@ -1849,8 +2154,8 @@ export function toAcpNotifications(
|
||||
const alreadyCached = chunk.id in toolUseCache;
|
||||
toolUseCache[chunk.id] = chunk;
|
||||
if (chunk.name === "TodoWrite") {
|
||||
// @ts-expect-error - sometimes input is empty object
|
||||
if (Array.isArray(chunk.input.todos)) {
|
||||
// @ts-expect-error - sometimes input is empty object or undefined
|
||||
if (Array.isArray(chunk.input?.todos)) {
|
||||
update = {
|
||||
sessionUpdate: "plan",
|
||||
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
|
||||
@@ -2004,6 +2309,7 @@ export function toAcpNotifications(
|
||||
case "container_upload":
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
case "advisor_tool_result":
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -2086,5 +2392,45 @@ export function runAcp() {
|
||||
const output = nodeToWebReadable(process.stdin);
|
||||
|
||||
const stream = ndJsonStream(input, output);
|
||||
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
|
||||
let agent!: ClaudeAcpAgent;
|
||||
const connection = new AgentSideConnection((client) => {
|
||||
agent = new ClaudeAcpAgent(client);
|
||||
return agent;
|
||||
}, stream);
|
||||
return { connection, agent };
|
||||
}
|
||||
|
||||
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 from its ID, used only
|
||||
* 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. */
|
||||
function inferContextWindowFromModel(model: string): number | null {
|
||||
if (/\b1m\b/i.test(model)) return 1_000_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
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) {
|
||||
return modelUsage[bestKey];
|
||||
}
|
||||
}
|
||||
|
||||
19
src/index.ts
19
src/index.ts
@@ -24,8 +24,23 @@ if (process.argv.includes("--cli")) {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
runAcp();
|
||||
const { connection, agent } = runAcp();
|
||||
|
||||
// Keep process alive
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
console.error("Error during cleanup:", err);
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
|
||||
// error). Without this, `process.stdin.resume()` keeps the event loop
|
||||
// alive indefinitely, causing orphan process accumulation in oneshot mode.
|
||||
connection.closed.then(shutdown);
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
// Keep process alive while connection is open
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
|
||||
@@ -25,9 +25,14 @@ export interface ClaudeCodeSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid.
|
||||
* Silently ignores missing files (ENOENT) but logs warnings for other errors
|
||||
* (malformed JSON, permission errors, etc.) to aid debugging.
|
||||
*/
|
||||
async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSettings> {
|
||||
async function loadSettingsFile(
|
||||
filePath: string | null,
|
||||
logger?: { error: (...args: any[]) => void },
|
||||
): Promise<ClaudeCodeSettings> {
|
||||
if (!filePath) {
|
||||
return {};
|
||||
}
|
||||
@@ -35,7 +40,11 @@ async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSett
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
logger?.error(`Failed to load settings from ${filePath}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -83,7 +92,9 @@ export class SettingsManager {
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
private initialized = false;
|
||||
private disposed = false;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(cwd: string, options?: SettingsManagerOptions) {
|
||||
this.cwd = cwd;
|
||||
@@ -98,10 +109,19 @@ export class SettingsManager {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
await this.loadAllSettings();
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
this.disposed = false;
|
||||
this.initPromise = this.loadAllSettings().then(() => {
|
||||
if (!this.disposed) {
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
}
|
||||
this.initPromise = null;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,10 +150,10 @@ export class SettingsManager {
|
||||
*/
|
||||
private async loadAllSettings(): Promise<void> {
|
||||
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
|
||||
loadSettingsFile(this.getUserSettingsPath()),
|
||||
loadSettingsFile(this.getProjectSettingsPath()),
|
||||
loadSettingsFile(this.getLocalSettingsPath()),
|
||||
loadSettingsFile(getManagedSettingsPath()),
|
||||
loadSettingsFile(this.getUserSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getProjectSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getLocalSettingsPath(), this.logger),
|
||||
loadSettingsFile(getManagedSettingsPath(), this.logger),
|
||||
]);
|
||||
|
||||
this.userSettings = userSettings;
|
||||
@@ -226,9 +246,14 @@ export class SettingsManager {
|
||||
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
this.debounceTimer = null;
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.loadAllSettings();
|
||||
this.onChange?.();
|
||||
if (!this.disposed) {
|
||||
this.onChange?.();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to reload settings:", error);
|
||||
}
|
||||
@@ -259,7 +284,6 @@ export class SettingsManager {
|
||||
|
||||
this.dispose();
|
||||
this.cwd = cwd;
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
@@ -267,6 +291,10 @@ export class SettingsManager {
|
||||
* Disposes of file watchers and cleans up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.initialized = false;
|
||||
this.initPromise = null;
|
||||
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
@@ -276,6 +304,5 @@ export class SettingsManager {
|
||||
watcher.close();
|
||||
}
|
||||
this.watchers = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
71
src/tests/additional-roots.test.ts
Normal file
71
src/tests/additional-roots.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: ({ options }: { options: Options }) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" }],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("additionalRoots", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
const tempDirs: string[] = [];
|
||||
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
|
||||
agent.newSession({ cwd, mcpServers: [], _meta: meta });
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedOptions = undefined;
|
||||
tempDirs.length = 0;
|
||||
vi.resetModules();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection);
|
||||
});
|
||||
|
||||
afterEach(
|
||||
async () =>
|
||||
void (await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))),
|
||||
);
|
||||
|
||||
it("passes through relative roots as provided", async () => {
|
||||
const projectRoot = await mkdtemp(path.join(os.tmpdir(), "claude-project-"));
|
||||
tempDirs.push(projectRoot);
|
||||
await newSession({ additionalRoots: ["."] }, projectRoot);
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["."]);
|
||||
});
|
||||
|
||||
it("merges additionalRoots with user additionalDirectories without normalization", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "claude-root-"));
|
||||
tempDirs.push(root);
|
||||
await newSession({
|
||||
additionalRoots: ["", root],
|
||||
claudeCode: { options: { additionalDirectories: ["/workspace/shared"] } },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,20 @@ import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
initializationResult: vi.fn().mockResolvedValue({
|
||||
models: [{ value: "id", displayName: "name", description: "description" }],
|
||||
}),
|
||||
setModel: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
describe("authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -17,20 +31,6 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
initializationResult: vi.fn().mockResolvedValue({
|
||||
models: [{ value: "id", displayName: "name", description: "description" }],
|
||||
}),
|
||||
setModel: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
const connectionMock = {
|
||||
sessionUpdate: async (_: any) => {},
|
||||
} as AgentSideConnection;
|
||||
@@ -123,14 +123,17 @@ describe("authorization", () => {
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide terminal auth even when terminal-auth is set", async () => {
|
||||
it("hide claude auth but still show console login when terminal-auth is set", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
@@ -140,7 +143,88 @@ describe("authorization", () => {
|
||||
_meta: { "terminal-auth": true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).toStrictEqual([]);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude auth but still show console login with terminal capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { terminal: true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("SSH session falls back to single legacy login method", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, env: { ...process.env, SSH_TTY: "/dev/pts/0" } });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("CLAUDE_CODE_REMOTE falls back to single legacy login method", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, env: { ...process.env, CLAUDE_CODE_REMOTE: "1" } });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("remote environment respects hide-claude-auth", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", {
|
||||
...process,
|
||||
argv: ["--hide-claude-auth"],
|
||||
env: { ...process.env, SSH_CONNECTION: "192.168.1.1 12345 192.168.1.2 22" },
|
||||
});
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("show claude authentication", async () => {
|
||||
@@ -152,7 +236,10 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,7 +93,10 @@ describe("session config options", () => {
|
||||
cancelled: false,
|
||||
permissionMode: "default",
|
||||
settingsManager: {},
|
||||
modes: structuredClone(MOCK_MODES),
|
||||
models: structuredClone(MOCK_MODELS),
|
||||
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
|
||||
contextWindowSize: 200000,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
148
src/tests/session-load.test.ts
Normal file
148
src/tests/session-load.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecycle", () => {
|
||||
it("SDK: session created but never prompted has no messages and is not resumable", async () => {
|
||||
// Create a session via the SDK, initialize it, but never send a prompt
|
||||
const sessionId = randomUUID();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
// initializationResult() works without needing a prompt pushed
|
||||
const initResult = await q.initializationResult();
|
||||
expect(initResult).toBeDefined();
|
||||
|
||||
// Close without ever prompting
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
|
||||
// Verify no messages were stored
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
expect(messages).toEqual([]);
|
||||
|
||||
// Verify the session is not resumable
|
||||
const input2 = new Pushable<SDKUserMessage>();
|
||||
const q2 = query({
|
||||
prompt: input2,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
resume: sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(q2.initializationResult()).rejects.toThrow(
|
||||
/No conversation found with session ID/,
|
||||
);
|
||||
|
||||
input2.end();
|
||||
q2.return(undefined);
|
||||
}, 30000);
|
||||
|
||||
it("ACP: loadSession throws resourceNotFound for a non-existent session", async () => {
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
const bogusSessionId = randomUUID();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agent.loadSession({
|
||||
sessionId: bogusSessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toThrow(RequestError);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("ACP: resumeSession throws resourceNotFound for a non-existent session", async () => {
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
const bogusSessionId = randomUUID();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agent.unstable_resumeSession({
|
||||
sessionId: bogusSessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toThrow(RequestError);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("ACP: newSession without prompt, then loadSession on fresh agent throws resourceNotFound", async () => {
|
||||
// Step 1: Create a real session via ACP, never prompt, dispose
|
||||
const agentA = new ClaudeAcpAgent(createMockClient());
|
||||
const { sessionId } = await agentA.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
expect(sessionId).toBeDefined();
|
||||
await agentA.dispose();
|
||||
|
||||
// Step 2: Fresh agent tries to load that session
|
||||
const agentB = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agentB.loadSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toThrow(RequestError);
|
||||
} finally {
|
||||
await agentB.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("ACP: newSession without prompt, then resumeSession on fresh agent throws resourceNotFound", async () => {
|
||||
const agentA = new ClaudeAcpAgent(createMockClient());
|
||||
const { sessionId } = await agentA.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
await agentA.dispose();
|
||||
|
||||
const agentB = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agentB.unstable_resumeSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toThrow(RequestError);
|
||||
} finally {
|
||||
await agentB.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import { toolUpdateFromToolResult, createPostToolUseHook } from "../tools.js";
|
||||
import {
|
||||
toolUpdateFromToolResult,
|
||||
createPostToolUseHook,
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
} from "../tools.js";
|
||||
|
||||
describe("rawOutput in tool call updates", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
@@ -1235,3 +1240,148 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInfoFromToolUse - ExitPlanMode", () => {
|
||||
it("should include plan text in content when input.plan is provided", () => {
|
||||
const toolUse = {
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_1",
|
||||
input: {
|
||||
plan: "# My Plan\n\n## Step 1\nDo something",
|
||||
planFilePath: "/tmp/plan.md",
|
||||
},
|
||||
};
|
||||
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
|
||||
expect(info.kind).toBe("switch_mode");
|
||||
expect(info.title).toBe("Ready to code?");
|
||||
expect(info.content).toHaveLength(1);
|
||||
expect(info.content![0]).toEqual({
|
||||
type: "content",
|
||||
content: { type: "text", text: "# My Plan\n\n## Step 1\nDo something" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty content when input.plan is not provided", () => {
|
||||
const toolUse = {
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_2",
|
||||
input: {},
|
||||
};
|
||||
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
|
||||
expect(info.kind).toBe("switch_mode");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInfoFromToolUse - undefined input regression", () => {
|
||||
it("Read with undefined input should not throw", () => {
|
||||
const toolUse = { name: "Read", id: "toolu_read_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Read File");
|
||||
expect(info.locations).toEqual([]);
|
||||
});
|
||||
|
||||
it("Grep with undefined input should not throw", () => {
|
||||
const toolUse = { name: "Grep", id: "toolu_grep_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("grep");
|
||||
});
|
||||
|
||||
it("Glob with undefined input should not throw", () => {
|
||||
const toolUse = { name: "Glob", id: "toolu_glob_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Find");
|
||||
expect(info.locations).toEqual([]);
|
||||
});
|
||||
|
||||
it("WebSearch with undefined input should not throw", () => {
|
||||
const toolUse = { name: "WebSearch", id: "toolu_ws_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Web search");
|
||||
});
|
||||
|
||||
it("TodoWrite with undefined input should not throw", () => {
|
||||
const toolUse = { name: "TodoWrite", id: "toolu_todo_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Update TODOs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planEntries - undefined input regression", () => {
|
||||
it("should return empty array when input is undefined", () => {
|
||||
expect(planEntries(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when input has no todos", () => {
|
||||
expect(planEntries({} as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should still map valid todos correctly", () => {
|
||||
const result = planEntries({
|
||||
todos: [
|
||||
{ content: "Task 1", status: "pending", activeForm: "" },
|
||||
{ content: "Task 2", status: "completed", activeForm: "" },
|
||||
],
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ content: "Task 1", status: "pending", priority: "medium" },
|
||||
{ content: "Task 2", status: "completed", priority: "medium" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications - TodoWrite with undefined input regression", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
it("should not throw when TodoWrite tool_use has undefined input", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_todo_undef",
|
||||
name: "TodoWrite",
|
||||
input: undefined as any,
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
// TodoWrite with undefined input should not crash, and should not emit plan update
|
||||
const planUpdates = notifications.filter((n) => (n.update as any).sessionUpdate === "plan");
|
||||
expect(planUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should still emit plan update when TodoWrite has valid input", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_todo_valid",
|
||||
name: "TodoWrite",
|
||||
input: { todos: [{ content: "Do X", status: "pending" }] },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
const planUpdates = notifications.filter((n) => (n.update as any).sessionUpdate === "plan");
|
||||
expect(planUpdates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
77
src/tools.ts
77
src/tools.ts
@@ -128,7 +128,7 @@ export function toolInfoFromToolUse(
|
||||
switch (name) {
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
const input = toolUse.input as AgentInput | BashInput;
|
||||
const input = toolUse.input as AgentInput | BashInput | undefined;
|
||||
return {
|
||||
title: input?.description ? input.description : "Task",
|
||||
kind: "think",
|
||||
@@ -145,7 +145,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Bash": {
|
||||
const input = toolUse.input as BashInput;
|
||||
const input = toolUse.input as BashInput | undefined;
|
||||
return {
|
||||
title: input?.command ? input.command : "Terminal",
|
||||
kind: "execute",
|
||||
@@ -163,18 +163,18 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Read": {
|
||||
const input = toolUse.input as FileReadInput;
|
||||
const input = toolUse.input as FileReadInput | undefined;
|
||||
let limit = "";
|
||||
if (input.limit && input.limit > 0) {
|
||||
if (input?.limit && input.limit > 0) {
|
||||
limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")";
|
||||
} else if (input.offset) {
|
||||
} else if (input?.offset) {
|
||||
limit = " (from line " + input.offset + ")";
|
||||
}
|
||||
const displayPath = input.file_path ? toDisplayPath(input.file_path, cwd) : "File";
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : "File";
|
||||
return {
|
||||
title: "Read " + displayPath + limit,
|
||||
kind: "read",
|
||||
locations: input.file_path
|
||||
locations: input?.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
@@ -187,7 +187,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Write": {
|
||||
const input = toolUse.input as FileWriteInput;
|
||||
const input = toolUse.input as FileWriteInput | undefined;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path) {
|
||||
content = [
|
||||
@@ -216,7 +216,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Edit": {
|
||||
const input = toolUse.input as FileEditInput;
|
||||
const input = toolUse.input as FileEditInput | undefined;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path && (input.old_string || input.new_string)) {
|
||||
content = [
|
||||
@@ -238,44 +238,44 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "Glob": {
|
||||
const input = toolUse.input as GlobInput;
|
||||
const input = toolUse.input as GlobInput | undefined;
|
||||
let label = "Find";
|
||||
if (input.path) {
|
||||
if (input?.path) {
|
||||
label += ` \`${input.path}\``;
|
||||
}
|
||||
if (input.pattern) {
|
||||
if (input?.pattern) {
|
||||
label += ` \`${input.pattern}\``;
|
||||
}
|
||||
return {
|
||||
title: label,
|
||||
kind: "search",
|
||||
content: [],
|
||||
locations: input.path ? [{ path: input.path }] : [],
|
||||
locations: input?.path ? [{ path: input.path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Grep": {
|
||||
const input = toolUse.input as GrepInput;
|
||||
const input = toolUse.input as GrepInput | undefined;
|
||||
let label = "grep";
|
||||
|
||||
if (input["-i"]) {
|
||||
if (input?.["-i"]) {
|
||||
label += " -i";
|
||||
}
|
||||
if (input["-n"]) {
|
||||
if (input?.["-n"]) {
|
||||
label += " -n";
|
||||
}
|
||||
|
||||
if (input["-A"] !== undefined) {
|
||||
if (input?.["-A"] !== undefined) {
|
||||
label += ` -A ${input["-A"]}`;
|
||||
}
|
||||
if (input["-B"] !== undefined) {
|
||||
if (input?.["-B"] !== undefined) {
|
||||
label += ` -B ${input["-B"]}`;
|
||||
}
|
||||
if (input["-C"] !== undefined) {
|
||||
if (input?.["-C"] !== undefined) {
|
||||
label += ` -C ${input["-C"]}`;
|
||||
}
|
||||
|
||||
if (input.output_mode) {
|
||||
if (input?.output_mode) {
|
||||
switch (input.output_mode) {
|
||||
case "files_with_matches":
|
||||
label += " -l";
|
||||
@@ -289,27 +289,27 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
}
|
||||
|
||||
if (input.head_limit !== undefined) {
|
||||
if (input?.head_limit !== undefined) {
|
||||
label += ` | head -${input.head_limit}`;
|
||||
}
|
||||
|
||||
if (input.glob) {
|
||||
if (input?.glob) {
|
||||
label += ` --include="${input.glob}"`;
|
||||
}
|
||||
|
||||
if (input.type) {
|
||||
if (input?.type) {
|
||||
label += ` --type=${input.type}`;
|
||||
}
|
||||
|
||||
if (input.multiline) {
|
||||
if (input?.multiline) {
|
||||
label += " -P";
|
||||
}
|
||||
|
||||
if (input.pattern) {
|
||||
if (input?.pattern) {
|
||||
label += ` "${input.pattern}"`;
|
||||
}
|
||||
|
||||
if (input.path) {
|
||||
if (input?.path) {
|
||||
label += ` ${input.path}`;
|
||||
}
|
||||
|
||||
@@ -338,14 +338,14 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "WebSearch": {
|
||||
const input = toolUse.input as WebSearchInput;
|
||||
let label = `"${input.query}"`;
|
||||
const input = toolUse.input as WebSearchInput | undefined;
|
||||
let label = input?.query ? `"${input.query}"` : "Web search";
|
||||
|
||||
if (input.allowed_domains && input.allowed_domains.length > 0) {
|
||||
if (input?.allowed_domains && input.allowed_domains.length > 0) {
|
||||
label += ` (allowed: ${input.allowed_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
if (input.blocked_domains && input.blocked_domains.length > 0) {
|
||||
if (input?.blocked_domains && input.blocked_domains.length > 0) {
|
||||
label += ` (blocked: ${input.blocked_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "TodoWrite": {
|
||||
const input = toolUse.input as TodoWriteInput;
|
||||
const input = toolUse.input as TodoWriteInput | undefined;
|
||||
return {
|
||||
title: Array.isArray(input?.todos)
|
||||
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
||||
@@ -368,10 +368,13 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string } | undefined;
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content: [],
|
||||
content: planInput?.plan
|
||||
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -481,7 +484,7 @@ export function toolUpdateFromToolResult(
|
||||
result.type === "bash_code_execution_result"
|
||||
) {
|
||||
const bashResult = result as BetaBashCodeExecutionResultBlock;
|
||||
output = bashResult.stdout || bashResult.stderr || "";
|
||||
output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n");
|
||||
exitCode = bashResult.return_code;
|
||||
} else if (typeof result === "string") {
|
||||
output = result;
|
||||
@@ -658,10 +661,10 @@ export type ClaudePlanEntry = {
|
||||
activeForm: string;
|
||||
};
|
||||
|
||||
export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
|
||||
return input.todos.map((input) => ({
|
||||
content: input.content,
|
||||
status: input.status,
|
||||
export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): PlanEntry[] {
|
||||
return (input?.todos ?? []).map((todo) => ({
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user