mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
1 Commits
v0.89.2-pr
...
code-block
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abae47de80 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
||||
src/tests/acp-scenarios/__snapshots__/** linguist-generated=true
|
||||
src/tests/acp-scenarios/origin-main/** linguist-generated=true
|
||||
18
.github/dependabot.yml
vendored
18
.github/dependabot.yml
vendored
@@ -1,18 +0,0 @@
|
||||
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: "daily"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
minor:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -1,45 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: "ci-${{ github.ref }}"
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:run
|
||||
48
.github/workflows/conventional-prs.yml
vendored
48
.github/workflows/conventional-prs.yml
vendored
@@ -1,48 +0,0 @@
|
||||
name: Conventional PRs
|
||||
|
||||
# Squash merges use the PR title as the commit subject, which is what
|
||||
# release-please parses to compute the next version and CHANGELOG entry.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# labeled/unlabeled so validation re-runs when `dependencies` is applied.
|
||||
types: [opened, reopened, edited, synchronize, labeled, unlabeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: "conventional-prs-${{ github.event.pull_request.number }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
# Config reference: https://github.com/amannn/action-semantic-pull-request#configuration
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
# Exactly the types release-please understands. feat/fix/perf/revert/docs
|
||||
# appear in the changelog; the rest are valid but hidden. Any other type
|
||||
# would be dropped silently, so it is rejected here instead.
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
perf
|
||||
revert
|
||||
docs
|
||||
style
|
||||
chore
|
||||
refactor
|
||||
test
|
||||
build
|
||||
ci
|
||||
# Dependabot titles itself conventionally already; don't block bot PRs.
|
||||
ignoreLabels: |
|
||||
dependencies
|
||||
273
.github/workflows/publish.yml
vendored
273
.github/workflows/publish.yml
vendored
@@ -1,273 +0,0 @@
|
||||
name: Publish and Release
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Which channel to publish
|
||||
required: true
|
||||
default: stable
|
||||
type: choice
|
||||
options:
|
||||
- stable
|
||||
- preview
|
||||
ref:
|
||||
description: Tag or commit to publish
|
||||
required: true
|
||||
type: string
|
||||
publish_npm:
|
||||
description: Publish the package before updating the registry (stable only)
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
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@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
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@45996ed1f6d02564a971a2fa1b5860e934307cf7
|
||||
id: release
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
# Do not add release-type here. The action ignores config-file whenever
|
||||
# release-type is set, which silently disables the pre-1.0 versioning
|
||||
# rules in release-please-config.json and lets a breaking change jump
|
||||
# straight to 1.0.0. The node release type is declared in that config
|
||||
# instead, so nothing is lost by leaving this out.
|
||||
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 }}
|
||||
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }}
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
version: ${{ steps.publish.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Publish
|
||||
id: publish
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
npm publish
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Same shape as publish-npm. Tagging is a separate job, so nothing here needs
|
||||
# write access to the repository.
|
||||
publish-npm-preview:
|
||||
name: Publish preview to npm
|
||||
runs-on: ubuntu-latest
|
||||
# Every push to main gets a preview without waiting for CI or release-please,
|
||||
# except release-please's own release merge, which publishes a stable version.
|
||||
# Check the commit author and the generated release subject to avoid publishing
|
||||
# the same tree twice; either match is enough to skip the preview.
|
||||
if: >-
|
||||
${{
|
||||
(github.event_name == 'workflow_dispatch' && inputs.channel == 'preview') ||
|
||||
(github.event_name == 'push'
|
||||
&& github.event.head_commit.author.name != 'acp-release-bot[bot]'
|
||||
&& !startsWith(github.event.head_commit.message, 'chore(main): release '))
|
||||
}}
|
||||
# Same environment as the stable job, so the npm trusted-publisher binding holds.
|
||||
# It carries no required reviewers, so previews never wait for an approval.
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm OIDC trusted publishing
|
||||
timeout-minutes: 15
|
||||
# Serialise previews so two pushes cannot read the same registry state and compute
|
||||
# the same -preview.N. Job-level rather than workflow-level: a workflow-level group
|
||||
# would also serialise release-please, and cancelling a queued release-please run
|
||||
# means a release PR that silently stops updating. The trade-off is that GitHub
|
||||
# keeps only one run pending per group, so a third push landing while one preview
|
||||
# runs and another waits drops the waiting one — that commit gets no preview, but a
|
||||
# version is never reused.
|
||||
concurrency:
|
||||
group: publish-npm-preview
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
# Publish the exact pushed commit, or the explicitly requested manual ref.
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
# Brings the tags the version calculation reads as its floor. The repo is a
|
||||
# few megabytes packed, so a full fetch is cheap and sidesteps every
|
||||
# shallow-clone tag caveat.
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
# Ahead of the version bump, so this still validates the committed lockfile.
|
||||
- run: npm ci
|
||||
- name: Compute the preview version
|
||||
id: preview
|
||||
run: |
|
||||
version="$(node scripts/next-preview-version.mjs)"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Preview \`$version\` from \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Apply the version to the working tree only
|
||||
# Never committed: package.json and .release-please-manifest.json on main stay
|
||||
# release-please's to own, and this only changes what goes into the tarball.
|
||||
# `npm version` keeps package-lock.json's version fields in step, and
|
||||
# --no-git-tag-version skips every git operation.
|
||||
run: npm version "${{ steps.preview.outputs.version }}" --no-git-tag-version
|
||||
- run: npm run build
|
||||
- name: Publish
|
||||
id: publish
|
||||
# --tag is mandatory: npm publish defaults to `latest` even for a semver
|
||||
# prerelease, which would point every plain `npm install` at a preview.
|
||||
run: |
|
||||
npm publish --tag preview
|
||||
echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
outputs:
|
||||
published: ${{ steps.publish.outputs.published }}
|
||||
version: ${{ steps.preview.outputs.version }}
|
||||
sha: ${{ steps.preview.outputs.sha }}
|
||||
|
||||
publish-tag-preview:
|
||||
name: Tag the published preview
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-npm-preview
|
||||
if: ${{ needs.publish-npm-preview.outputs.published == 'true' }}
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write # create refs/tags/v<version>
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# A re-run carries over the outputs of jobs it did not re-run. If that ever stops
|
||||
# holding, fail with the manual recipe rather than tagging the wrong commit.
|
||||
- name: Check the publish handed over a version and a commit
|
||||
env:
|
||||
VERSION: ${{ needs.publish-npm-preview.outputs.version }}
|
||||
SHA: ${{ needs.publish-npm-preview.outputs.sha }}
|
||||
run: |
|
||||
if [ -z "$VERSION" ] || [ -z "$SHA" ]; then
|
||||
echo "::error::publish-npm-preview reported version='$VERSION' sha='$SHA'." \
|
||||
"Tag it by hand — see docs/RELEASES.md, 'A preview published but the" \
|
||||
"commit was not tagged'."
|
||||
exit 1
|
||||
fi
|
||||
- name: Create the tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.publish-npm-preview.outputs.version }}
|
||||
SHA: ${{ needs.publish-npm-preview.outputs.sha }}
|
||||
run: |
|
||||
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
||||
-f ref="refs/tags/v$VERSION" \
|
||||
-f sha="$SHA"
|
||||
echo "Tagged \`v$VERSION\` at \`$SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
trigger-registry-update:
|
||||
needs: [publish-npm, publish-npm-preview]
|
||||
# Both channels dispatch this: the registry has its own handling for preview
|
||||
# versions. The two never fire together — a release merge publishes stable and
|
||||
# skips the preview, and every other push does the reverse — so the registry sees
|
||||
# exactly one dispatch per published version. The payload is deliberately
|
||||
# unchanged: it names no version, and the registry resolves what it needs itself.
|
||||
if: >-
|
||||
${{
|
||||
always() && (
|
||||
needs.publish-npm.result == 'success'
|
||||
|| needs.publish-npm-preview.outputs.published == 'true'
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.publish_npm)
|
||||
)
|
||||
}}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions: {}
|
||||
# npm metadata is normally visible within about five minutes. Leave 2.5x that
|
||||
# window for propagation, plus enough time to mint the token and dispatch.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Wait for package to become available
|
||||
if: ${{ needs.publish-npm.result == 'success' || needs.publish-npm-preview.outputs.published == 'true' }}
|
||||
env:
|
||||
PACKAGE: "@agentclientprotocol/claude-agent-acp"
|
||||
VERSION: ${{ needs.publish-npm-preview.outputs.version || needs.publish-npm.outputs.version }}
|
||||
#language=bash
|
||||
run: |
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::The publish job did not report a package version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
deadline=$((SECONDS + 750))
|
||||
while (( SECONDS < deadline )); do
|
||||
if timeout 10s npm pack "$PACKAGE@$VERSION" \
|
||||
--dry-run \
|
||||
--ignore-scripts \
|
||||
--silent \
|
||||
--registry=https://registry.npmjs.org \
|
||||
--prefer-online \
|
||||
--fetch-retries=0 >/dev/null 2>&1; then
|
||||
echo "$PACKAGE@$VERSION is available from npm."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
remaining=$((deadline - SECONDS))
|
||||
if (( remaining > 0 )); then
|
||||
echo "Waiting for $PACKAGE@$VERSION to become available (${remaining}s remaining)..."
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
|
||||
echo "::error::$PACKAGE@$VERSION was not available from npm after 12.5 minutes."
|
||||
exit 1
|
||||
|
||||
- name: Generate token scoped to the registry repo
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
id: registry-token
|
||||
with:
|
||||
app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }}
|
||||
private-key: ${{ secrets.REGISTRY_UPDATER_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: registry
|
||||
|
||||
- name: Dispatch registry version update
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.registry-token.outputs.token }}
|
||||
run: |
|
||||
gh workflow run update-versions.yml \
|
||||
--repo ${{ github.repository_owner }}/registry \
|
||||
--ref main \
|
||||
-f apply=true \
|
||||
-f agents=claude-acp
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.claude/*.local.*
|
||||
CLAUDE.local.md
|
||||
.idea/
|
||||
@@ -1,11 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.agent-shell
|
||||
|
||||
.release-please-manifest.json
|
||||
release-please-config.json
|
||||
CHANGELOG.md
|
||||
|
||||
# Recorded ACP traffic, written by src/tests/acp-scenarios.test.ts.
|
||||
src/tests/acp-scenarios/__snapshots__
|
||||
src/tests/acp-scenarios/origin-main
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
".": "0.89.1"
|
||||
}
|
||||
56
AGENTS.md
56
AGENTS.md
@@ -1,56 +0,0 @@
|
||||
# Agent guide
|
||||
|
||||
An ACP-compatible coding agent powered by the Claude Agent SDK. Source in `src/`,
|
||||
TypeScript, ESM, Node >= 22.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm run build # tsc
|
||||
npm run test:run # vitest, single pass
|
||||
npm run check # eslint + prettier --check
|
||||
npm run lint:fix # eslint --fix
|
||||
npm run format # prettier --write
|
||||
```
|
||||
|
||||
CI runs `format:check`, `lint`, `build` and `test:run`. Run `npm run check` before
|
||||
opening a PR.
|
||||
|
||||
## Code comments
|
||||
|
||||
Keep comments straightforward, concise, and concrete. Say what the code does and
|
||||
why, in plain terms: name the actual state, call, or failure involved instead of
|
||||
alluding to it. Prefer a short concrete example over an abstract description.
|
||||
Don't use vague phrases like "keeps it honest" or "the residual case" that make
|
||||
the reader reconstruct the meaning.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Squash merges use the PR title as the commit subject, and release-please parses it
|
||||
to compute the next version. Titles must be conventional commits using one of:
|
||||
`feat`, `fix`, `perf`, `revert`, `docs`, `style`, `chore`, `refactor`, `test`,
|
||||
`build`, `ci`. `conventional-prs.yml` rejects anything else.
|
||||
|
||||
The title also decides the release: `feat:` bumps the minor, `fix:`/`perf:` the
|
||||
patch, and `chore:`/`ci:`/`test:` and friends do not release at all.
|
||||
|
||||
## Releasing
|
||||
|
||||
Stable releases are fully automated by release-please. There is no manual release
|
||||
workflow, and the version is never chosen by hand — it follows from the commit
|
||||
history.
|
||||
|
||||
```sh
|
||||
npm run release:preflight # verifies it is safe to release, prints the PR and version
|
||||
gh pr merge <pr-number> --squash
|
||||
```
|
||||
|
||||
The preflight is the guard-list as code; if it exits non-zero, follow what it
|
||||
prints rather than merging.
|
||||
|
||||
Every _other_ push to `main` publishes a preview to npm — `0.73.1-preview.4` and
|
||||
so on — under the `preview` dist-tag, tags the commit it came from, and updates
|
||||
the agent registry the same way a release does. Only `latest` is reserved for
|
||||
real releases. So anything merged to `main` is published within minutes; there is
|
||||
no staging branch. Full runbook, including how to recover a stalled release:
|
||||
[`docs/RELEASES.md`](docs/RELEASES.md).
|
||||
1356
CHANGELOG.md
1356
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
61
LICENSE
61
LICENSE
@@ -1,18 +1,42 @@
|
||||
Copyright 2022 - 2025 Zed Industries, Inc.
|
||||
|
||||
Apache License
|
||||
|
||||
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
|
||||
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,
|
||||
@@ -21,23 +45,28 @@
|
||||
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
|
||||
@@ -46,6 +75,7 @@
|
||||
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
|
||||
@@ -60,10 +90,12 @@
|
||||
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
|
||||
@@ -71,6 +103,7 @@
|
||||
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
|
||||
@@ -87,23 +120,28 @@
|
||||
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
|
||||
@@ -121,6 +159,7 @@
|
||||
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
|
||||
@@ -128,6 +167,7 @@
|
||||
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
|
||||
@@ -136,11 +176,13 @@
|
||||
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,
|
||||
@@ -151,6 +193,7 @@
|
||||
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
|
||||
@@ -163,6 +206,7 @@
|
||||
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,
|
||||
@@ -174,18 +218,5 @@
|
||||
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.
|
||||
|
||||
54
README.md
54
README.md
@@ -1,53 +1,3 @@
|
||||
# ACP adapter for the Claude Agent SDK
|
||||
An ACP-compatible coding agent powered by the Claude Code SDK
|
||||
|
||||
[](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!
|
||||
|
||||
This tool implements an ACP agent by using the official [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting:
|
||||
|
||||
- Context @-mentions
|
||||
- Images
|
||||
- Tool calls (with permission requests)
|
||||
- Compact file changes through the negotiated [AIR diff patch extension](docs/air-extensions.md#diff-patch)
|
||||
- Following
|
||||
- Edit review
|
||||
- TODO lists
|
||||
- Nested subagent transcripts
|
||||
- Interactive (and background) terminals
|
||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
||||
- Client MCP servers
|
||||
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
|
||||
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)
|
||||
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/air-extensions.md#recommended-config-values)
|
||||
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/air-extensions.md#permission-presentation)
|
||||
- One fact per field in every tool call report, as the [ACP tool call contract](docs/air-extensions.md#tool-call-contract) defines
|
||||
- All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md)
|
||||
|
||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
To try changes that have landed on `main` but are not released yet, install from the
|
||||
`preview` channel — every push to `main` publishes one. See
|
||||
[`docs/RELEASES.md`](docs/RELEASES.md#preview-releases).
|
||||
|
||||
```sh
|
||||
npm install @agentclientprotocol/claude-agent-acp@preview
|
||||
```
|
||||
|
||||
### Subagent sessions
|
||||
|
||||
Subagents are exposed only after bilateral capability negotiation. Until the released ACP SDKs
|
||||
preserve the draft `clientCapabilities.subagents` field, a supporting client may advertise
|
||||
`nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; the adapter mirrors the capability
|
||||
in its initialize response. The canonical field remains supported and takes precedence once it is
|
||||
available. Without either client signal, Agent/Task lifecycle keeps its legacy ordinary ACP
|
||||
tool-call representation and child interactions stay on the root session. Clients that use the
|
||||
historical `_meta["subagent-transcript"]` capability or `forwardSubagentText` session option retain
|
||||
the flattened child transcript behavior. See [AIR extensions](docs/air-extensions.md#native-subagent-sessions).
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
This project does not require a Contributor License Agreement (CLA). Instead, contributions are accepted under the following terms:
|
||||
|
||||
> 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).
|
||||
This wrapper lets you use the real version of Claude Code from the IDE of your choice.
|
||||
|
||||
245
docs/RELEASES.md
245
docs/RELEASES.md
@@ -1,245 +0,0 @@
|
||||
# Releases
|
||||
|
||||
Releases are automated by [release-please](https://github.com/googleapis/release-please)
|
||||
in [`.github/workflows/publish.yml`](../.github/workflows/publish.yml). Every push
|
||||
to `main` re-computes the next version from the conventional commit messages and
|
||||
keeps a single release PR open, titled `chore(main): release X.Y.Z` and labelled
|
||||
`autorelease: pending`.
|
||||
|
||||
Merging that PR is what releases. It tags `vX.Y.Z`, creates the GitHub release,
|
||||
publishes to npm, and dispatches a version update to the agent registry.
|
||||
|
||||
There is no manual release button, and versions are never typed in by hand: the
|
||||
version is an output of the commit history, not an input.
|
||||
|
||||
Every _other_ push to `main` publishes a preview instead — see
|
||||
[Preview releases](#preview-releases). Anything merged to `main` is on npm within
|
||||
minutes; there is no staging branch.
|
||||
|
||||
## Releasing
|
||||
|
||||
```sh
|
||||
npm run release:preflight
|
||||
```
|
||||
|
||||
This reports the open release PR, the version it will ship, and checks that the
|
||||
repository is in a state where merging is safe. Nothing has to be remembered —
|
||||
if it exits non-zero, follow what it prints instead of merging.
|
||||
|
||||
Then merge it, using the PR number the preflight printed:
|
||||
|
||||
```sh
|
||||
gh pr merge <pr-number> --squash
|
||||
gh run watch "$(gh run list --workflow=publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
|
||||
```
|
||||
|
||||
The run is looked up rather than picked interactively, so this is safe to script.
|
||||
If the workflow has already finished, `gh run list --workflow=publish.yml` shows
|
||||
the outcome instead.
|
||||
|
||||
Merging main requires no review, so a green preflight and `Build` are the only
|
||||
gates. Once the workflow finishes, confirm both outputs landed:
|
||||
|
||||
```sh
|
||||
gh release view "v<version>"
|
||||
npm view "@agentclientprotocol/claude-agent-acp@<version>"
|
||||
```
|
||||
|
||||
## Preview releases
|
||||
|
||||
Every push to `main` that is not a release merge publishes a preview from the
|
||||
same workflow. There is no GitHub release — only an npm publish under the
|
||||
`preview` dist-tag, a `v<version>` tag on the commit it came from, and the same
|
||||
agent registry update a stable release dispatches, since the registry has its own
|
||||
handling for preview versions.
|
||||
|
||||
Those are three jobs, in that order: `publish-npm-preview` mirrors `publish-npm`
|
||||
and does nothing but publish; `publish-tag-preview` creates the tag; and
|
||||
`trigger-registry-update` is shared with the stable path. Before dispatching,
|
||||
the registry job polls npm for the exact published version for up to 12.5 minutes,
|
||||
including downloading its tarball, so the registry never checks while npm is
|
||||
still propagating the package. The tag is a separate job so that a tag failure
|
||||
can be retried on its own with **Re-run failed jobs** — re-running the publish is
|
||||
not an option, because npm versions are immutable and publishing the same one
|
||||
twice fails outright.
|
||||
|
||||
Both downstream jobs are gated on a `published` output that the publish step
|
||||
sets only after `npm publish --tag preview` succeeds. This ensures tagging runs
|
||||
only after publication and the registry update runs only after that exact version
|
||||
can also be downloaded from npm.
|
||||
|
||||
A stable and a preview dispatch can never collide — a release merge publishes
|
||||
stable and skips the preview, every other push does the reverse — so the registry
|
||||
sees exactly one dispatch per published version.
|
||||
|
||||
```sh
|
||||
npm install @agentclientprotocol/claude-agent-acp@preview
|
||||
npm view @agentclientprotocol/claude-agent-acp dist-tags
|
||||
git ls-remote --tags origin 'refs/tags/*preview*'
|
||||
```
|
||||
|
||||
The version is the `package.json` version with the patch incremented, plus
|
||||
`-preview.N`: with `main` at 0.73.0 the previews are `0.73.1-preview.1`,
|
||||
`0.73.1-preview.2`, and so on. `N` restarts at 1 whenever release-please moves
|
||||
`package.json`, which keeps the sequence monotonic whichever way the next release
|
||||
goes — a patch release makes the next base 0.73.2, a minor makes it 0.74.1, and
|
||||
both sort above every `0.73.1-preview.*`.
|
||||
|
||||
`0.73.1-preview.4` is **not** a promise that 0.73.1 will ship. The base is a
|
||||
patch bump because that is the only choice depending solely on `package.json`,
|
||||
which release-please only ever increases. Using release-please's predicted next
|
||||
version would read better but that prediction moves mid-flight: a `fix:` opens a
|
||||
0.73.1 release PR, a later `feat:` moves it to 0.74.0, and `N` would reset under
|
||||
previews that were already published.
|
||||
|
||||
`N` comes from [`scripts/next-preview-version.mjs`](../scripts/next-preview-version.mjs),
|
||||
which takes the larger of two sources. The npm registry says what is taken — npm
|
||||
versions are immutable and stay reserved even after `npm unpublish`, so reusing
|
||||
one is a hard failure — but it is CDN-served and can lag a publish by minutes.
|
||||
The git tags this job writes are strongly consistent and cover that window. The
|
||||
job publishes before it tags, so a version can exist on npm without a tag but
|
||||
never the reverse; that is why a registry read failure aborts the run rather than
|
||||
falling back to the tags alone.
|
||||
|
||||
Two pushes landing together cannot collide, because the job takes a concurrency
|
||||
group. GitHub keeps only one run pending per group, so a third push arriving
|
||||
while one preview runs and another waits drops the waiting one — that commit
|
||||
simply gets no preview.
|
||||
|
||||
`latest` stays put because the job passes `npm publish --tag preview`. Without
|
||||
it npm would move `latest` onto the preview: `--tag` defaults to `latest` even
|
||||
for a semver prerelease. Right after a release the `preview` dist-tag can name a
|
||||
version _below_ `latest` until the next push lands; that is cosmetic.
|
||||
|
||||
Release merges are excluded by checking the head commit's author and the subject
|
||||
release-please generates. Both are checked, either is enough, and the cost of a
|
||||
miss is one wasted version number plus a `preview` tag briefly pointing at
|
||||
already-released code — `latest` is untouched. Previews start directly on pushes
|
||||
to `main`, without waiting for CI or the `release-please` job. The commit checks
|
||||
let previews run independently of release-please's outputs.
|
||||
|
||||
To publish a preview by hand from any commit:
|
||||
|
||||
```sh
|
||||
gh workflow run publish.yml --ref main \
|
||||
-f channel=preview -f ref=<commit-or-branch> -f publish_npm=false
|
||||
```
|
||||
|
||||
`--ref main` is required: the `release` environment only accepts protected
|
||||
branches, so a dispatch from anywhere else is rejected before the job starts.
|
||||
|
||||
## How the version is chosen
|
||||
|
||||
Squash merges use the PR title as the commit subject, so the PR title decides the
|
||||
next version. [`conventional-prs.yml`](../.github/workflows/conventional-prs.yml)
|
||||
rejects titles release-please would not understand.
|
||||
|
||||
| PR title prefix | Effect |
|
||||
| --------------------------------------------------------- | --------------------------- |
|
||||
| `fix:`, `perf:`, `revert:`, `docs:` | patch, e.g. 0.66.0 → 0.66.1 |
|
||||
| `feat:` | minor, e.g. 0.66.0 → 0.67.0 |
|
||||
| any of the above with `!`, or BREAKING CHANGE | minor while below 1.0.0 |
|
||||
| `chore:`, `ci:`, `build:`, `test:`, `refactor:`, `style:` | no release on their own |
|
||||
|
||||
Breaking changes bump the minor rather than the major because
|
||||
`bump-minor-pre-major` is set in
|
||||
[`release-please-config.json`](../release-please-config.json) and the package is
|
||||
still below 1.0.0. That is deliberate: it keeps a single `!` in a PR title from
|
||||
shipping 1.0.0 by accident.
|
||||
|
||||
Note that `config-file` only takes effect while the workflow does **not** pass a
|
||||
`release-type` input to the action — with `release-type` set, the action ignores
|
||||
the config entirely. The release type is declared inside the config instead.
|
||||
|
||||
`release-type` also switches release-please from `Manifest.fromManifest` to
|
||||
`Manifest.fromConfig`, which is a second and sharper reason never to set it. On
|
||||
the manifest path the previous release is found by an exact string match against
|
||||
the version in [`.release-please-manifest.json`](../.release-please-manifest.json),
|
||||
which is why the `v<x>-preview.<n>` tags are invisible to it. On the config path
|
||||
release-please instead sorts every candidate tag and release descending and takes
|
||||
the highest — and there the preview tags _would_ be candidates.
|
||||
|
||||
Because the config is what is read, it also has to say
|
||||
`"include-component-in-tag": false`. Left at its default, release-please derives a
|
||||
component from the package name and tags `claude-agent-acp-vX.Y.Z` instead of
|
||||
`vX.Y.Z`. That renames the tag every step here looks up, and because no tag under
|
||||
the new scheme exists, it also walks the entire commit history into the changelog
|
||||
rather than just what landed since the last release. The preflight checks the tag
|
||||
release-please is going to use, so this cannot reach a published release.
|
||||
|
||||
Releasing 1.0.0 is therefore an explicit act: add `"release-as": "1.0.0"` to
|
||||
`release-please-config.json` in its own PR, release, then remove it again.
|
||||
|
||||
## Recovering a stalled release
|
||||
|
||||
### The release PR merged but nothing was tagged
|
||||
|
||||
The preflight fails with `release-please is jammed`. While a merged release PR
|
||||
still carries `autorelease: pending`, release-please refuses to open any new
|
||||
release PR at all, so every later release stalls silently until this is cleared.
|
||||
|
||||
Take the release notes release-please already wrote into the changelog, create
|
||||
the missing release, then move the label the way release-please would have:
|
||||
|
||||
```sh
|
||||
awk '/^## \[<version>\]/{f=1;print;next} /^## \[/{f=0} f' CHANGELOG.md > notes.md
|
||||
gh release create "v<version>" --target <merge-commit-sha> --notes-file notes.md
|
||||
gh pr edit <pr-number> --remove-label "autorelease: pending" \
|
||||
--add-label "autorelease: tagged"
|
||||
```
|
||||
|
||||
Then publish the tag as described below.
|
||||
|
||||
### The tag exists but npm or the registry is missing
|
||||
|
||||
npm publishes through OIDC from inside the workflow, so this cannot be done from
|
||||
a laptop. Re-run the publish workflow against the existing tag:
|
||||
|
||||
```sh
|
||||
gh workflow run publish.yml -f ref="v<version>" -f publish_npm=true
|
||||
```
|
||||
|
||||
npm versions are immutable. If the package already published and only the
|
||||
registry update failed, pass `-f publish_npm=false` so the run skips publishing
|
||||
and only re-dispatches the registry update.
|
||||
|
||||
### A preview published but the commit was not tagged
|
||||
|
||||
Only the publish is irreversible, so re-run just the tag job:
|
||||
|
||||
```sh
|
||||
gh run rerun <run-id> --failed
|
||||
```
|
||||
|
||||
Or **Re-run failed jobs** on the run in the web or mobile UI. This re-runs
|
||||
`publish-tag-preview` alone and leaves the successful publish untouched, which
|
||||
matters because re-publishing an immutable npm version would fail.
|
||||
|
||||
If the re-run reports that it received no version or commit, the run's carried
|
||||
over job outputs are gone and it cannot tag anything safely. Do it by hand
|
||||
instead, taking the version from the publish job's log:
|
||||
|
||||
```sh
|
||||
gh api "repos/$(gh repo view --json nameWithOwner --jq .nameWithOwner)/git/refs" \
|
||||
-f ref="refs/tags/v<version>" -f sha="<commit-sha>"
|
||||
```
|
||||
|
||||
Either way nothing is broken in the meantime: the next preview still picks the
|
||||
right `N` once the registry CDN catches up. The tag is how that number is known
|
||||
immediately.
|
||||
|
||||
## Credentials
|
||||
|
||||
| Secret | Used for |
|
||||
| ------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `RELEASE_PLZ_APP_ID`, `RELEASE_PLZ_APP_PRIVATE_KEY` | App token for release PRs and tags, so they can trigger workflows |
|
||||
| `REGISTRY_UPDATER_APP_ID`, `REGISTRY_UPDATER_APP_PRIVATE_KEY` | App token scoped to the `registry` repository |
|
||||
|
||||
Publishing to npm uses OIDC trusted publishing, so there is no npm token. All
|
||||
release jobs run in the `release` environment.
|
||||
|
||||
npm binds a trusted publisher to one repository, one **workflow filename** and
|
||||
one environment, and a package may only have one such binding. That is why
|
||||
preview publishing is another job inside `publish.yml` rather than a workflow of
|
||||
its own: a separate file would fail to authenticate, and registering it would
|
||||
cost the stable path its publisher.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
||||
# Model Configuration
|
||||
|
||||
When using claude-agent-acp with alternative providers (e.g. AWS Bedrock), model IDs differ from the direct Anthropic API. The `CLAUDE_MODEL_CONFIG` environment variable lets you configure model overrides and availability at the deployment level.
|
||||
|
||||
## `CLAUDE_MODEL_CONFIG`
|
||||
|
||||
A JSON string with two optional fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `modelOverrides` | `Record<string, string>` | Maps Anthropic model IDs to provider-specific model IDs (e.g. Bedrock model IDs or ARNs) |
|
||||
| `availableModels` | `string[]` | Restricts which models are offered to users. Accepts aliases (`"opus"`), prefixes (`"opus-4-5"`), or full IDs |
|
||||
|
||||
### Examples
|
||||
|
||||
**Bedrock model overrides:**
|
||||
|
||||
```bash
|
||||
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"}}'
|
||||
```
|
||||
|
||||
**Restrict available models:**
|
||||
|
||||
```bash
|
||||
CLAUDE_MODEL_CONFIG='{"availableModels":["opus","sonnet"]}'
|
||||
```
|
||||
|
||||
**Both together:**
|
||||
|
||||
```bash
|
||||
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"},"availableModels":["opus","sonnet"]}'
|
||||
```
|
||||
|
||||
**Full Bedrock example:**
|
||||
|
||||
```bash
|
||||
CLAUDE_CODE_USE_BEDROCK=1 \
|
||||
AWS_REGION=us-west-2 \
|
||||
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1"}}' \
|
||||
node dist/index.js
|
||||
```
|
||||
|
||||
## Precedence
|
||||
|
||||
An ACP caller can provide `settings` via `_meta.claudeCode.options.settings` in `session/new` or `session/load`, as an object or a path to a settings file. The adapter merges `CLAUDE_MODEL_CONFIG` into those settings, so a caller that only adds hooks keeps the model settings. When both set the same key, the caller wins; the merge is shallow, so a caller's `modelOverrides` replaces the env var's map.
|
||||
|
||||
| Source | Priority |
|
||||
| -------------------------------------------- | ---------------------------------------------- |
|
||||
| `_meta.claudeCode.options.settings` (caller) | Highest — wins for each key it sets |
|
||||
| `CLAUDE_MODEL_CONFIG` (env var) | Used for the keys that the caller does not set |
|
||||
|
||||
## Format details
|
||||
|
||||
- The value must be valid JSON. Invalid JSON will cause session creation to fail with a parse error.
|
||||
- Only `modelOverrides` and `availableModels` keys are read; other keys in the JSON are ignored.
|
||||
- Both fields map directly to the Claude Agent SDK's `Settings` type.
|
||||
@@ -1,48 +0,0 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsparser from "@typescript-eslint/parser";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "off",
|
||||
"no-constant-condition": "off",
|
||||
"default-case": "error",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "smart"],
|
||||
curly: ["error", "all"],
|
||||
},
|
||||
},
|
||||
prettierConfig,
|
||||
];
|
||||
@@ -1,706 +0,0 @@
|
||||
/**
|
||||
* Example: a dead-simple interactive ACP client for this agent.
|
||||
*
|
||||
* This is the "no editor required" way to exercise the agent: it launches
|
||||
* `dist/index.js` as a subprocess, speaks ACP over its stdio using the SDK, and
|
||||
* prints every JSON-RPC message that crosses the wire in both directions. Each
|
||||
* line you type becomes a `session/prompt`; the agent's answer streams back as
|
||||
* `session/update` notifications.
|
||||
*
|
||||
* Every message is preceded by a header line naming its direction:
|
||||
*
|
||||
* [agent] ◂ inbound — everything the agent sends us
|
||||
* [user] ▸ outbound — everything we send the agent, including our own
|
||||
* permission answers and `session/cancel`
|
||||
*
|
||||
* The label and the arrow carry the direction, so it survives NO_COLOR and
|
||||
* redirection to a file (`2> wire.log`). The JSON itself is printed in full,
|
||||
* flush left and undecorated — select it and you have valid JSON on the
|
||||
* clipboard. This exists to show you the protocol, not to summarize it.
|
||||
*
|
||||
* There are four ways for a client to get a message to this agent, and all four
|
||||
* are reachable from the prompt so you can watch what each one looks like on
|
||||
* the wire. Plain text is the first; the rest are `!`-prefixed commands, chosen
|
||||
* so they cannot be confused with the agent's own slash commands (`/compact`
|
||||
* and friends are ordinary prompt text here). `!!` escapes a literal `!`.
|
||||
*
|
||||
* <text> `session/prompt`. Typed mid-turn it is *buffered* by this
|
||||
* client and sent once the running turn responds.
|
||||
* !steer <text> `_session/steering` — injected into the turn that is
|
||||
* already running, so it can change course between tool
|
||||
* calls. Answers "injected", or "startedNewTurn" if the turn
|
||||
* beat us to the finish. An injected reply streams as
|
||||
* `session/update` notifications inside that turn, whose
|
||||
* `stopReason` waits for it. "startedNewTurn" output belongs
|
||||
* to a detached turn no `session/prompt` of ours tracks.
|
||||
* See ./steering.ts.
|
||||
* !queue <text> `session/prompt` sent mid-turn anyway. The agent advertises
|
||||
* `promptQueueing`, so it takes the backlog instead of us —
|
||||
* same ordering, different owner.
|
||||
* !cancel `session/cancel` for the running turn, as Ctrl-C does.
|
||||
*
|
||||
* Run (build the agent first so `dist/index.js` exists):
|
||||
*
|
||||
* npm run build
|
||||
* node examples/simple-client.ts
|
||||
*
|
||||
* or, equivalently, `npm run example:simple-client`.
|
||||
*
|
||||
* To debug the agent itself, pass a port. The agent is then rebuilt with source
|
||||
* maps and started under `--inspect-brk=<port>`, i.e. paused before its first
|
||||
* line, so you can attach (IntelliJ: Run → Attach to Node.js/Chrome; VS Code:
|
||||
* "Attach to Node Process"), set breakpoints in `src/*.ts`, and resume:
|
||||
*
|
||||
* node examples/simple-client.ts --debug-port=9229
|
||||
*
|
||||
* The client will sit waiting for `initialize` until you attach and resume —
|
||||
* that is expected, ACP has no request timeout.
|
||||
*
|
||||
* (Node < 22.18 needs `node --experimental-strip-types examples/simple-client.ts`.)
|
||||
*
|
||||
* Keys: Enter sends (or buffers), Ctrl-C cancels the running turn (again to
|
||||
* quit), Ctrl-D drains anything buffered and exits. `!help` lists the commands.
|
||||
*
|
||||
* Flags: --debug-port=<port>, --no-build, --help.
|
||||
* Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()).
|
||||
*
|
||||
* Requires the agent to be authenticated, since it talks to the real model.
|
||||
*
|
||||
* Note: this client does not advertise the `elicitation` capability, so the
|
||||
* agent disables its AskUserQuestion tool instead of asking us to render a
|
||||
* form. That is the one intentional behaviour difference from a full client.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
client as acpClient,
|
||||
methods,
|
||||
ndJsonStream,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
// Type-only: Node's type stripping does not elide value imports, and these two
|
||||
// exist only in the .d.ts — importing them normally is a runtime SyntaxError.
|
||||
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
|
||||
|
||||
// ---------------------------------------------------------------- steering
|
||||
|
||||
/**
|
||||
* The steering extension (../steering_protocol.md, and ./steering.ts for a
|
||||
* scripted walkthrough). It is not part of ACP proper, so it has no entry in
|
||||
* `methods` and is sent as a raw method name with hand-written types.
|
||||
*/
|
||||
const STEERING_METHOD = "_session/steering";
|
||||
|
||||
type SteeringRequest = {
|
||||
sessionId: string;
|
||||
prompt: Array<{ type: "text"; text: string }>;
|
||||
};
|
||||
|
||||
/** Both outcomes are successes; they say where the message landed, not whether
|
||||
* it worked. "startedNewTurn" means the turn finished before we got there. */
|
||||
type SteeringResponse = {
|
||||
outcome: "injected" | "startedNewTurn";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- arguments
|
||||
|
||||
const ARGV = process.argv.slice(2);
|
||||
|
||||
/** Reads `--name=value` or `--name value`; returns "" for a valueless flag. */
|
||||
function flag(name: string): string | undefined {
|
||||
const index = ARGV.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
|
||||
if (index === -1) return undefined;
|
||||
const [, ...rest] = ARGV[index].split("=");
|
||||
return rest.length > 0 ? rest.join("=") : (ARGV[index + 1] ?? "");
|
||||
}
|
||||
|
||||
if (ARGV.includes("--help") || ARGV.includes("-h")) {
|
||||
process.stdout.write(
|
||||
[
|
||||
"Usage: node examples/simple-client.ts [--debug-port=<port>] [--no-build]",
|
||||
"",
|
||||
" --debug-port=<port> rebuild with source maps and start the agent under",
|
||||
" --inspect-brk=<port>, paused until a debugger attaches",
|
||||
" --no-build skip the tsc rebuild and use dist/ as-is",
|
||||
"",
|
||||
"Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()).",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// The built agent entry. Rebuilt below unless --no-build is passed.
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const AGENT_ENTRY = process.env.AGENT_ENTRY ?? path.join(repoRoot, "dist", "index.js");
|
||||
const CWD = process.env.CWD ?? process.cwd();
|
||||
const BUILD = !ARGV.includes("--no-build");
|
||||
|
||||
const debugPortArg = flag("debug-port");
|
||||
const DEBUG_PORT = debugPortArg === undefined ? undefined : Number(debugPortArg);
|
||||
if (
|
||||
DEBUG_PORT !== undefined &&
|
||||
(!Number.isInteger(DEBUG_PORT) || DEBUG_PORT < 1 || DEBUG_PORT > 65535)
|
||||
) {
|
||||
process.stderr.write(`--debug-port expects a port between 1 and 65535, got "${debugPortArg}"\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Shown by `!help`, and once at startup. */
|
||||
const HELP = [
|
||||
"<text> send it — as a new turn, or buffered until this turn ends",
|
||||
"!steer <text> inject into the running turn (_session/steering)",
|
||||
"!queue <text> send session/prompt now and let the agent queue it",
|
||||
"!cancel session/cancel the running turn (same as Ctrl-C)",
|
||||
"!help this list; !! sends a line that really starts with '!'",
|
||||
];
|
||||
|
||||
// How long to wait for a cancelled turn to settle, and for the agent to exit
|
||||
// after we close its stdin, before escalating during shutdown.
|
||||
const CANCEL_TIMEOUT_MS = 5_000;
|
||||
const EXIT_TIMEOUT_MS = 5_000;
|
||||
|
||||
// ---------------------------------------------------------------- output
|
||||
|
||||
const COLOR = process.stderr.isTTY === true && !process.env.NO_COLOR;
|
||||
const paint = (code: string, text: string) => (COLOR ? `\x1b[${code}m${text}\x1b[0m` : text);
|
||||
const dim = (text: string) => paint("2", text);
|
||||
|
||||
const TTY = process.stdin.isTTY === true && process.stderr.isTTY === true;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let rl: readline.Interface | null = null;
|
||||
let shuttingDown = false;
|
||||
let queue: string[] = [];
|
||||
// How many `session/prompt` requests are awaiting a response. Usually 0 or 1;
|
||||
// `!queue` deliberately drives it higher to exercise the agent's own queueing.
|
||||
let inFlight = 0;
|
||||
const turnRunning = () => inFlight > 0;
|
||||
let cancelRequested = false;
|
||||
let lastUpdateKind = "";
|
||||
// False until `initialize` returns — an agent still paused under --inspect-brk
|
||||
// will never act on stdin EOF, so shutdown skips straight to signalling it.
|
||||
let agentReady = false;
|
||||
|
||||
/**
|
||||
* The single writer for everything this process prints. It wipes the half-typed
|
||||
* input line, writes the block, then asks readline to redraw its prompt with the
|
||||
* buffer intact — without this, streamed output and typing shred each other.
|
||||
* (One caveat: a soft-wrapped input line leaves a stale fragment behind, since
|
||||
* clearLine only clears the row the cursor is on.)
|
||||
*/
|
||||
function writeBlock(text: string) {
|
||||
if (TTY) {
|
||||
readline.cursorTo(process.stderr, 0);
|
||||
readline.clearLine(process.stderr, 0);
|
||||
}
|
||||
process.stderr.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
if (TTY) rl?.prompt(true);
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
writeBlock(`${dim("[client]")} ${message}`);
|
||||
}
|
||||
|
||||
function refreshPrompt() {
|
||||
if (!rl) return;
|
||||
const queued = queue.length > 0 ? `[${queue.length} queued] ` : "";
|
||||
const running = inFlight > 1 ? `[${inFlight} turns] ` : "";
|
||||
const busy = turnRunning() ? dim(`⋯ ${lastUpdateKind} `) : "";
|
||||
rl.setPrompt(`${busy}${running}${queued}› `);
|
||||
if (TTY) rl.prompt(true);
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---------------------------------------------------------------- wire log
|
||||
|
||||
/** A permissive view of the JSON-RPC union, purely for the summary line. */
|
||||
type WireMessage = {
|
||||
id?: string | number | null;
|
||||
method?: string;
|
||||
params?: { update?: { sessionUpdate?: string } };
|
||||
result?: { stopReason?: string };
|
||||
error?: { code: number; message: string };
|
||||
};
|
||||
|
||||
/** One-line summary: method (plus session update kind), or which reply this is. */
|
||||
function describe(message: AnyMessage): string {
|
||||
const wire = message as WireMessage;
|
||||
const id = wire.id === undefined || wire.id === null ? "" : ` #${wire.id}`;
|
||||
if (wire.method !== undefined) {
|
||||
const kind = wire.params?.update?.sessionUpdate;
|
||||
return `${wire.method}${kind ? ` · ${kind}` : ""}${id}`;
|
||||
}
|
||||
if (wire.error !== undefined) {
|
||||
return `error${id} · ${wire.error.code} ${wire.error.message}`;
|
||||
}
|
||||
const stopReason = wire.result?.stopReason;
|
||||
return `result${id}${stopReason ? ` · ${stopReason}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints one JSON-RPC message. The SDK exposes no logging hook, so this is
|
||||
* called from a stream tap (see `tapStream`) that sits between the SDK and the
|
||||
* transport — what you see is exactly what is written to / read from the pipe.
|
||||
*/
|
||||
function logMessage(direction: "agent" | "user", message: AnyMessage) {
|
||||
const inbound = direction === "agent";
|
||||
const color = inbound ? "36" : "35";
|
||||
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(3);
|
||||
const header = [
|
||||
dim(`+${elapsed}s`),
|
||||
paint(`1;${color}`, inbound ? "[agent]" : "[user] "),
|
||||
paint(color, inbound ? "◂" : "▸"),
|
||||
dim(describe(message)),
|
||||
].join(" ");
|
||||
// The body is printed flush left and unadorned so that selecting it in the
|
||||
// terminal yields valid JSON you can paste straight into a tool.
|
||||
writeBlock(`${header}\n${JSON.stringify(message, null, 2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a transport stream so every message is logged post-parse/pre-stringify.
|
||||
* A TransformStream per direction is the only complete interposition point: the
|
||||
* SDK reads inbound with a single reader and takes a fresh writer per outbound
|
||||
* message, and both survive being handed a transform.
|
||||
*/
|
||||
function tapStream(inner: Stream): Stream {
|
||||
const inbound = new TransformStream<AnyMessage, AnyMessage>({
|
||||
transform(message, controller) {
|
||||
logMessage("agent", message);
|
||||
controller.enqueue(message);
|
||||
},
|
||||
});
|
||||
const outbound = new TransformStream<AnyMessage, AnyMessage>({
|
||||
transform(message, controller) {
|
||||
logMessage("user", message);
|
||||
controller.enqueue(message);
|
||||
},
|
||||
});
|
||||
// The SDK never closes its writable, so this pipe only settles on failure —
|
||||
// typically the agent died and its stdin is gone. Teardown handles the rest;
|
||||
// without the catch this would be an unhandled rejection.
|
||||
outbound.readable.pipeTo(inner.writable).catch((err) => {
|
||||
if (!shuttingDown) log(`transport write failed: ${err}`);
|
||||
});
|
||||
return { readable: inner.readable.pipeThrough(inbound), writable: outbound.writable };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- build
|
||||
|
||||
/** Runs the repo's tsc, sending its diagnostics to our stderr. */
|
||||
async function build(sourceMaps: boolean) {
|
||||
const localTsc = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc");
|
||||
const useLocal = existsSync(localTsc);
|
||||
const args = sourceMaps ? ["--sourceMap"] : [];
|
||||
log(`building agent (tsc${sourceMaps ? " --sourceMap" : ""}) …`);
|
||||
|
||||
const child = useLocal
|
||||
? spawn(process.execPath, [localTsc, ...args], { cwd: repoRoot, stdio: ["ignore", 2, 2] })
|
||||
: spawn(process.platform === "win32" ? "npx.cmd" : "npx", ["tsc", ...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", 2, 2],
|
||||
});
|
||||
|
||||
const [code] = (await once(child, "exit")) as [number | null];
|
||||
if (code !== 0) {
|
||||
log(`build failed (tsc exit ${code}) — fix the errors above, or pass --no-build`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
|
||||
async function main() {
|
||||
if (BUILD) {
|
||||
await build(DEBUG_PORT !== undefined);
|
||||
} else if (!existsSync(AGENT_ENTRY)) {
|
||||
log(`${AGENT_ENTRY} not found — run \`npm run build\` first, or unset --no-build`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// An ACP client launches the agent as a subprocess and speaks JSON-RPC over
|
||||
// its stdin/stdout. Its stderr is piped rather than inherited so that agent
|
||||
// logs go through writeBlock() and don't corrupt the readline input line.
|
||||
const nodeArgs =
|
||||
DEBUG_PORT === undefined ? [AGENT_ENTRY] : [`--inspect-brk=${DEBUG_PORT}`, AGENT_ENTRY];
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
cwd: repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
log(`failed to spawn agent (${AGENT_ENTRY}): ${err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
// A write racing the agent's exit surfaces as an unhandled 'error' event that
|
||||
// would take this client down with it.
|
||||
child.stdin?.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code !== "EPIPE") log(`agent stdin error: ${err}`);
|
||||
});
|
||||
child.stdout?.on("error", () => {});
|
||||
process.stderr.on("error", () => {});
|
||||
|
||||
pumpAgentStderr(child);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!shuttingDown) log(`agent exited (code ${code}, signal ${signal})`);
|
||||
});
|
||||
|
||||
const stream = tapStream(
|
||||
ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
|
||||
),
|
||||
);
|
||||
|
||||
const connection = acpClient({ name: "simple-client" })
|
||||
// The tap already prints every update; this handler exists so the prompt line
|
||||
// can show what the agent is currently doing.
|
||||
.onNotification(methods.client.session.update, (ctx) => {
|
||||
lastUpdateKind = ctx.params.update.sessionUpdate;
|
||||
})
|
||||
// Auto-approve, chosen by `kind` and never by a hard-coded optionId: for
|
||||
// ExitPlanMode this agent uses permission-*mode* names as option ids
|
||||
// (auto / acceptEdits / default / plan / bypassPermissions).
|
||||
.onRequest(methods.client.session.requestPermission, (ctx) => {
|
||||
const options = ctx.params.options;
|
||||
const option = options.find((o) => o.kind === "allow_once") ?? options[0];
|
||||
if (!option) return { outcome: { outcome: "cancelled" } };
|
||||
log(`auto-approving permission: ${option.name} (${option.kind}/${option.optionId})`);
|
||||
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
||||
})
|
||||
// Minimal file-system stubs. This agent performs its own file I/O inside the
|
||||
// Claude Agent SDK subprocess and never calls these today.
|
||||
.onRequest(methods.client.fs.readTextFile, () => ({ content: "" }))
|
||||
.onRequest(methods.client.fs.writeTextFile, () => ({}))
|
||||
.connect(stream);
|
||||
|
||||
const agent = connection.agent;
|
||||
|
||||
// Signals are wired up *before* the first request. Under --inspect-brk the
|
||||
// wait below is unbounded, and a client that dies without running shutdown()
|
||||
// orphans a paused agent still holding the debug port.
|
||||
const onInterrupt = () => {
|
||||
if (turnRunning() && !cancelRequested) {
|
||||
cancelRequested = true;
|
||||
const dropped = queue.length;
|
||||
queue = [];
|
||||
// A notification, not a request: every turn in flight settles through its
|
||||
// own session/prompt response, normally with stopReason "cancelled".
|
||||
void agent.notify(methods.agent.session.cancel, { sessionId });
|
||||
log(`sent session/cancel${dropped > 0 ? `, dropped ${dropped} queued line(s)` : ""}`);
|
||||
refreshPrompt();
|
||||
return;
|
||||
}
|
||||
void shutdown(turnRunning() ? "^C while cancelling" : "^C");
|
||||
};
|
||||
// In terminal mode readline puts stdin in raw mode and owns ^C, so this only
|
||||
// ever fires for an explicit `kill -INT`; with piped stdin it is the ^C path.
|
||||
process.on("SIGINT", onInterrupt);
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// 1. Initialize. Under --inspect-brk the agent is paused before it reads a
|
||||
// single byte, so this request stays unanswered until a debugger attaches
|
||||
// and resumes it. ACP has no request timeout, so waiting is safe; the
|
||||
// interval below just keeps the terminal from looking dead.
|
||||
let nudge: NodeJS.Timeout | undefined;
|
||||
if (DEBUG_PORT !== undefined) {
|
||||
log(`agent is PAUSED before its first line (--inspect-brk=${DEBUG_PORT})`);
|
||||
log(`attach a debugger to port ${DEBUG_PORT} and resume it — breakpoints go in src/*.ts`);
|
||||
nudge = setInterval(
|
||||
() => log(`still waiting for a debugger on port ${DEBUG_PORT} … (Ctrl-C to give up)`),
|
||||
10_000,
|
||||
);
|
||||
}
|
||||
let init;
|
||||
try {
|
||||
init = await agent.request(methods.agent.initialize, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
terminal: false,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (nudge) clearInterval(nudge);
|
||||
}
|
||||
agentReady = true;
|
||||
log(`connected to ${init.agentInfo?.name ?? "agent"} ${init.agentInfo?.version ?? ""}`.trim());
|
||||
|
||||
// Steering is advertised at the TOP-LEVEL `_meta` of the initialize result —
|
||||
// a sibling of `agentCapabilities`, not nested inside it. Prompt queueing is
|
||||
// a Claude-specific capability flag and does live inside.
|
||||
const initMeta = init._meta as { steering?: { supported?: boolean } } | null | undefined;
|
||||
const steeringSupported = initMeta?.steering?.supported === true;
|
||||
const queueMeta = init.agentCapabilities?._meta as
|
||||
{ claudeCode?: { promptQueueing?: boolean } } | null | undefined;
|
||||
log(
|
||||
`agent supports: steering=${steeringSupported} ` +
|
||||
`promptQueueing=${queueMeta?.claudeCode?.promptQueueing === true}`,
|
||||
);
|
||||
|
||||
// 2. Open a session.
|
||||
let sessionId: string;
|
||||
try {
|
||||
const session = await agent.request(methods.agent.session.new, { cwd: CWD, mcpServers: [] });
|
||||
sessionId = session.sessionId;
|
||||
} catch (err) {
|
||||
log(`session/new failed: ${err}`);
|
||||
log("if this is an auth error, log in first: node dist/index.js --cli auth login");
|
||||
await shutdown("session/new failed", 1);
|
||||
return;
|
||||
}
|
||||
log(`session ${sessionId} in ${CWD}`);
|
||||
|
||||
// 3. Turns. There are four ways to get a message to the agent, and the client
|
||||
// can drive all of them (see `handleCommand`); plain text uses the first.
|
||||
//
|
||||
// session/prompt a new turn, or — for text typed mid-turn — one
|
||||
// buffered here and sent when the turn responds
|
||||
// session/prompt sent mid-turn anyway, so the *agent* queues it
|
||||
// (!queue) (it advertises `_meta.claudeCode.promptQueueing`)
|
||||
// _session/steering injected into the turn that is already running
|
||||
// (!steer)
|
||||
// session/cancel abandon the running turn
|
||||
// (!cancel, ^C)
|
||||
let draining = false;
|
||||
|
||||
/** One `session/prompt` round-trip. Several may be in flight at once when
|
||||
* `!queue` is used; the agent runs them in order. */
|
||||
async function sendPrompt(text: string) {
|
||||
inFlight += 1;
|
||||
cancelRequested = false;
|
||||
refreshPrompt();
|
||||
try {
|
||||
const result = await agent.request(methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text }],
|
||||
});
|
||||
log(`turn ended: ${result.stopReason}`);
|
||||
} catch (err) {
|
||||
// The error response itself was already printed by the tap; this is
|
||||
// the human-readable "your prompt did not run".
|
||||
log(`prompt failed: ${err}`);
|
||||
if (connection.signal.aborted) queue = [];
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
refreshPrompt();
|
||||
exitWhenIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the buffered lines one turn at a time, oldest first. */
|
||||
async function drain() {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
try {
|
||||
while (queue.length > 0 && !shuttingDown) {
|
||||
await sendPrompt(queue.shift()!);
|
||||
if (connection.signal.aborted) break;
|
||||
}
|
||||
} finally {
|
||||
draining = false;
|
||||
refreshPrompt();
|
||||
exitWhenIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** A piped stdin ends; exiting has to wait for the work already handed out. */
|
||||
function exitWhenIdle() {
|
||||
if (inputClosed && !draining && queue.length === 0 && inFlight === 0) {
|
||||
void shutdown("input closed, queue drained");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side commands, prefixed with `!` so they cannot collide with the
|
||||
* agent's own slash commands (`/compact` and friends, which are just prompt
|
||||
* text as far as this client is concerned). `!!` escapes a literal `!`.
|
||||
*/
|
||||
function handleCommand(line: string) {
|
||||
const word = line.split(/\s+/)[0];
|
||||
const text = line.slice(word.length).trim();
|
||||
switch (word) {
|
||||
case "!steer":
|
||||
if (text.length === 0) {
|
||||
log("usage: !steer <message> — injects into the turn that is running");
|
||||
break;
|
||||
}
|
||||
if (!steeringSupported) log("agent did not advertise steering; trying anyway");
|
||||
void steer(text);
|
||||
break;
|
||||
case "!queue":
|
||||
if (text.length === 0) {
|
||||
log("usage: !queue <message> — sends session/prompt now, agent queues it");
|
||||
break;
|
||||
}
|
||||
void sendPrompt(text);
|
||||
break;
|
||||
case "!cancel":
|
||||
if (!turnRunning()) {
|
||||
log("nothing to cancel");
|
||||
break;
|
||||
}
|
||||
onInterrupt();
|
||||
break;
|
||||
case "!help":
|
||||
for (const entry of HELP) log(entry);
|
||||
break;
|
||||
default:
|
||||
log(`unknown command ${word} — try !help`);
|
||||
break;
|
||||
}
|
||||
refreshPrompt();
|
||||
}
|
||||
|
||||
/** `_session/steering`: deliver a message to the turn already in progress. */
|
||||
async function steer(text: string) {
|
||||
const params: SteeringRequest = { sessionId, prompt: [{ type: "text", text }] };
|
||||
try {
|
||||
const result = await agent.request<SteeringResponse>(STEERING_METHOD, params);
|
||||
// Either way the steered message's own output arrives as `session/update`
|
||||
// notifications, not in this response. When injected, it lands inside the
|
||||
// turn we steered, whose `stopReason` waits for it.
|
||||
log(
|
||||
result.outcome === "injected"
|
||||
? "steer outcome: injected into the running turn"
|
||||
: "steer outcome: startedNewTurn — the turn had already finished, so the " +
|
||||
"agent began a new one that no session/prompt of ours is tracking",
|
||||
);
|
||||
} catch (err) {
|
||||
log(`steer rejected: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
let inputClosed = false;
|
||||
let steerHinted = false;
|
||||
|
||||
rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: TTY,
|
||||
crlfDelay: Infinity,
|
||||
historySize: 200,
|
||||
});
|
||||
|
||||
rl.on("line", (raw) => {
|
||||
const line = raw.trim();
|
||||
if (line.length === 0 || shuttingDown) {
|
||||
refreshPrompt();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith("!") && !line.startsWith("!!")) {
|
||||
handleCommand(line);
|
||||
return;
|
||||
}
|
||||
// "!!steer …" is how you send text that begins with the command prefix.
|
||||
queue.push(line.startsWith("!!") ? line.slice(1) : line);
|
||||
if (turnRunning()) {
|
||||
log(`buffered — will send when this turn ends (${queue.length} queued)`);
|
||||
if (!steerHinted) {
|
||||
steerHinted = true;
|
||||
log("…or use !steer <text> to inject into the running turn instead");
|
||||
}
|
||||
}
|
||||
refreshPrompt();
|
||||
void drain();
|
||||
});
|
||||
|
||||
rl.on("SIGINT", onInterrupt);
|
||||
|
||||
rl.on("close", () => {
|
||||
inputClosed = true;
|
||||
exitWhenIdle();
|
||||
});
|
||||
|
||||
void connection.closed.then(() => {
|
||||
if (!shuttingDown) void shutdown("connection closed");
|
||||
});
|
||||
|
||||
for (const entry of HELP) log(entry);
|
||||
log("Enter sends, Ctrl-C cancels a turn, Ctrl-D exits");
|
||||
refreshPrompt();
|
||||
|
||||
/**
|
||||
* Order matters here. The agent exits cleanly on stdin EOF (src/index.ts wires
|
||||
* `connection.closed` to `agent.dispose()`), whereas killing it skips disposal
|
||||
* and can orphan the Claude Agent SDK subprocess. Closing our connection first
|
||||
* would cancel the reader on the agent's stdout and make it die of EPIPE
|
||||
* mid-write, so that comes last.
|
||||
*/
|
||||
async function shutdown(reason: string, exitCode = 0) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log(`shutting down (${reason})`);
|
||||
queue = [];
|
||||
rl?.removeAllListeners("close");
|
||||
rl?.close();
|
||||
|
||||
if (turnRunning()) {
|
||||
if (!cancelRequested) {
|
||||
try {
|
||||
await agent.notify(methods.agent.session.cancel, { sessionId });
|
||||
} catch {
|
||||
// The connection may already be gone; teardown continues regardless.
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + CANCEL_TIMEOUT_MS;
|
||||
while (turnRunning() && Date.now() < deadline) await delay(50);
|
||||
}
|
||||
|
||||
child.stdin?.end();
|
||||
const exited = await Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
delay(agentReady ? EXIT_TIMEOUT_MS : 250).then(() => false),
|
||||
]);
|
||||
if (!exited) {
|
||||
log("agent did not exit on stdin EOF; sending SIGTERM");
|
||||
child.kill("SIGTERM");
|
||||
const stopped = await Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
delay(2_000).then(() => false),
|
||||
]);
|
||||
if (!stopped) child.kill("SIGKILL");
|
||||
}
|
||||
|
||||
connection.close();
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Streams the agent's stderr through writeBlock, flagging inspector messages. */
|
||||
function pumpAgentStderr(child: ReturnType<typeof spawn>) {
|
||||
let buffered = "";
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stderr?.on("data", (chunk: string) => {
|
||||
buffered += chunk;
|
||||
const lines = buffered.split("\n");
|
||||
buffered = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) continue;
|
||||
if (line.includes("Debugger listening on")) {
|
||||
writeBlock(paint("1;33", `[agent] ${line.trim()}`));
|
||||
} else if (line.includes("address already in use")) {
|
||||
writeBlock(paint("1;31", `[agent] ${line.trim()}`));
|
||||
log("that inspector port is taken — pass a different --debug-port");
|
||||
} else {
|
||||
writeBlock(dim(`[agent:stderr] ${line}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Tearing down rejects whatever request main() is awaiting; that is the
|
||||
// teardown finishing its job, not a failure, and shutdown() owns the exit.
|
||||
if (shuttingDown) return;
|
||||
log(`fatal: ${err?.stack ?? err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* Example: mid-turn steering over ACP with the `_session/steering` extension.
|
||||
*
|
||||
* "Steering" lets a client deliver a follow-up message to a turn that is still
|
||||
* running, instead of waiting for it to finish and sending a fresh
|
||||
* `session/prompt`. This is what powers "the user typed something while the
|
||||
* agent was still working" — the new message joins the in-flight turn so the
|
||||
* agent can adapt immediately (it shines in multi-step / tool-using turns,
|
||||
* where the message slots in between tool calls).
|
||||
*
|
||||
* The wire protocol has three moving parts:
|
||||
*
|
||||
* 1. The agent advertises support in its `initialize` response, at the
|
||||
* top-level `_meta.steering` (a sibling of `agentCapabilities`).
|
||||
* 2. The client calls the `_session/steering` request with `{ sessionId,
|
||||
* prompt }` while a turn is running.
|
||||
* 3. The agent replies with an `outcome`:
|
||||
* - "injected" the message joined the running turn;
|
||||
* - "startedNewTurn" the turn had already finished and the legacy
|
||||
* detached fallback was used;
|
||||
* - "promptRequired" the turn had already finished, but this request
|
||||
* explicitly opted into Host-owned prompt delivery.
|
||||
*
|
||||
* This example launches the agent as a subprocess, starts a deliberately
|
||||
* long-running prompt, and — as soon as the agent begins streaming — injects a
|
||||
* steering message and prints the outcome. All agent output is streamed to
|
||||
* stdout so you can watch the turn change course.
|
||||
*
|
||||
* Run (build the agent first so `dist/index.js` exists):
|
||||
*
|
||||
* npm run build
|
||||
* node examples/steering.ts
|
||||
*
|
||||
* (Node < 22.18 needs `node --experimental-strip-types examples/steering.ts`.)
|
||||
*
|
||||
* Override the prompts with the PROMPT / STEER env vars. Requires the agent to
|
||||
* be authenticated, since it talks to the real model.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import {
|
||||
client as acpClient,
|
||||
methods,
|
||||
ndJsonStream,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
|
||||
/** The steering extension method, per the ACP steering wire protocol. */
|
||||
const STEERING_METHOD = "_session/steering";
|
||||
|
||||
/** Params for a `_session/steering` request — the same shape as the relevant
|
||||
* subset of a `session/prompt`. */
|
||||
type SteeringRequest = {
|
||||
sessionId: string;
|
||||
prompt: Array<{ type: "text"; text: string }>;
|
||||
_meta?: { steering?: { idleBehavior?: "promptRequired" } };
|
||||
};
|
||||
|
||||
/** Result of a `_session/steering` request. `injected` means the message joined
|
||||
* the running turn; `promptRequired` means the turn had already settled, so the
|
||||
* message was NOT consumed and must be (re)sent through a normal
|
||||
* `session/prompt`. Both are successes. */
|
||||
type SteeringResponse =
|
||||
| { outcome: "injected" }
|
||||
| { outcome: "startedNewTurn" }
|
||||
| { outcome: "promptRequired"; reason: "noRunningTurn" };
|
||||
|
||||
/** The existing steering capability advertised at the top-level `_meta.steering`
|
||||
* of the `initialize` result. The idle behavior is selected per request. */
|
||||
type SteeringCapability = {
|
||||
supported?: boolean;
|
||||
};
|
||||
|
||||
// The built agent entry. Run `npm run build` first so this exists.
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const AGENT_ENTRY = process.env.AGENT_ENTRY ?? path.join(repoRoot, "dist", "index.js");
|
||||
const CWD = process.env.CWD ?? process.cwd();
|
||||
|
||||
// A deliberately long-running first prompt, and the follow-up injected while it
|
||||
// is still streaming. Override either via env vars to experiment.
|
||||
const PROMPT =
|
||||
process.env.PROMPT ??
|
||||
"Count slowly from 1 to 30, one number per line, with a short sentence of " +
|
||||
"commentary after each. Do not stop early.";
|
||||
const STEER =
|
||||
process.env.STEER ?? "Actually stop counting and instead reply with exactly one line: STEERED-OK";
|
||||
|
||||
function log(msg: string) {
|
||||
process.stderr.write(`\x1b[2m[client]\x1b[0m ${msg}\n`);
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function main() {
|
||||
// An ACP client launches the agent as a subprocess and speaks JSON-RPC over
|
||||
// its stdin/stdout. stderr is inherited so the agent's own logs stay visible.
|
||||
const child = spawn(process.execPath, [AGENT_ENTRY], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: process.env,
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
log(`failed to spawn agent (${AGENT_ENTRY}): ${err}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
|
||||
);
|
||||
|
||||
// Resolves the first time the agent streams assistant text — our signal that
|
||||
// the turn is genuinely underway and therefore steerable.
|
||||
let signalFirstOutput = () => {};
|
||||
const firstOutput = new Promise<void>((resolve) => (signalFirstOutput = resolve));
|
||||
|
||||
const connection = acpClient({ name: "steering-example" })
|
||||
.onNotification(methods.client.session.update, (ctx) => {
|
||||
const update = ctx.params.update;
|
||||
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
||||
process.stdout.write(update.content.text);
|
||||
signalFirstOutput();
|
||||
}
|
||||
})
|
||||
// Auto-approve permission prompts so the turn is never blocked on us.
|
||||
.onRequest(methods.client.session.requestPermission, (ctx) => {
|
||||
const options = ctx.params.options;
|
||||
const option = options.find((o) => o.kind === "allow_once") ?? options[0];
|
||||
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
||||
})
|
||||
// Minimal file-system stubs; the example prompts don't touch files.
|
||||
.onRequest(methods.client.fs.readTextFile, () => ({ content: "" }))
|
||||
.onRequest(methods.client.fs.writeTextFile, () => ({}))
|
||||
.connect(stream);
|
||||
|
||||
try {
|
||||
const agent = connection.agent;
|
||||
|
||||
// 1. Initialize and confirm the agent advertises steering. Per the wire
|
||||
// protocol the capability lives at the TOP-LEVEL `_meta` of the initialize
|
||||
// result — a sibling of `agentCapabilities`, not nested inside it.
|
||||
const init = await agent.request(methods.agent.initialize, {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
const steering = (init._meta as { steering?: SteeringCapability } | null | undefined)
|
||||
?.steering;
|
||||
if (steering?.supported !== true) {
|
||||
throw new Error("agent does not advertise steering support");
|
||||
}
|
||||
log("agent advertises steering support");
|
||||
|
||||
// 2. Open a session.
|
||||
const { sessionId } = await agent.request(methods.agent.session.new, {
|
||||
cwd: CWD,
|
||||
mcpServers: [],
|
||||
});
|
||||
log(`session: ${sessionId}`);
|
||||
|
||||
// 3. Start a long turn, but DON'T await it yet — we need it in flight so we
|
||||
// can steer it. Its output streams through the notification handler above.
|
||||
log(`prompt: ${PROMPT}`);
|
||||
process.stdout.write("\n----- agent output -----\n");
|
||||
const turn = agent.request(methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: PROMPT }],
|
||||
});
|
||||
|
||||
// 4. Once the turn is producing output, inject the follow-up. Wait for the
|
||||
// first streamed chunk (with a fallback) plus a beat, so the steer clearly
|
||||
// lands mid-turn.
|
||||
await Promise.race([firstOutput, delay(5000)]);
|
||||
await delay(1000);
|
||||
|
||||
process.stdout.write("\n");
|
||||
log(`steer: ${STEER}`);
|
||||
const steerRequest: SteeringRequest = {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: STEER }],
|
||||
// Opt into the host-owned idle fallback. Without this request metadata,
|
||||
// the Adapter preserves its legacy `startedNewTurn` behavior.
|
||||
_meta: { steering: { idleBehavior: "promptRequired" } },
|
||||
};
|
||||
const result = await agent.request<SteeringResponse>(STEERING_METHOD, steerRequest);
|
||||
log(`steer outcome: ${result.outcome}`);
|
||||
|
||||
if (result.outcome === "promptRequired") {
|
||||
// The target turn already settled, so steering did not consume the message.
|
||||
// Start a normal session/prompt on the same session to deliver it — that
|
||||
// request owns the continuation's updates and terminal response.
|
||||
log(`steer fallback: ${result.reason}; starting a normal session/prompt`);
|
||||
const continuationRequest: PromptRequest = {
|
||||
sessionId: steerRequest.sessionId,
|
||||
prompt: steerRequest.prompt,
|
||||
};
|
||||
const continuation = await agent.request<PromptResponse, PromptRequest>(
|
||||
methods.agent.session.prompt,
|
||||
continuationRequest,
|
||||
);
|
||||
log(`continuation stopReason: ${continuation.stopReason}`);
|
||||
}
|
||||
|
||||
// 5. Await the original turn. With outcome "injected" the steer already
|
||||
// reshaped the output above; the promptRequired branch owns its own turn.
|
||||
const response = await turn;
|
||||
log(`original turn stopReason: ${response.stopReason}`);
|
||||
process.stdout.write("\n----- end of agent output -----\n");
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log(`fatal: ${err?.stack ?? err}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
4163
package-lock.json
generated
4163
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
80
package.json
80
package.json
@@ -1,46 +1,21 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"name": "@zed-industries/claude-code-acp",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.89.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
"bin": {
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/acp-agent.js",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib.d.ts",
|
||||
"import": "./dist/lib.js"
|
||||
},
|
||||
"./*": "./*"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"!dist/tests/",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "npm run build && npm run start",
|
||||
"example:simple-client": "node --experimental-strip-types --disable-warning=ExperimentalWarning examples/simple-client.ts",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"lint:fix": "eslint src --ext .ts --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"check": "npm run lint && npm run format:check",
|
||||
"release:preflight": "bash scripts/release-preflight.sh",
|
||||
"test": "vitest",
|
||||
"test:integration": "RUN_INTEGRATION_TESTS=true vitest run",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepublishOnly": "npm run build"
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -51,40 +26,25 @@
|
||||
"sdk",
|
||||
"code"
|
||||
],
|
||||
"homepage": "https://github.com/agentclientprotocol/claude-agent-acp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/agentclientprotocol/claude-agent-acp/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/agentclientprotocol/claude-agent-acp.git"
|
||||
},
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.8.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.293",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
"@anthropic-ai/claude-code": "^1.0.96",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/express": "^5.0.3",
|
||||
"@zed-industries/agent-client-protocol": "0.1.2",
|
||||
"express": "^5.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
"uuid": "11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.132.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.1",
|
||||
"@typescript-eslint/parser": "8.71.1",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.3"
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^8.56.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"bump-minor-pre-major": true,
|
||||
"include-component-in-tag": false,
|
||||
"packages": {
|
||||
".": {
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"release-type": "node",
|
||||
"bump-minor-pre-major": true,
|
||||
"include-component-in-tag": false
|
||||
}
|
||||
},
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Prints the next preview version for this package, e.g. `0.73.1-preview.4`.
|
||||
//
|
||||
// The base is package.json's version with the patch incremented, so a preview always
|
||||
// sorts above the last release and below whatever release follows it. `-preview.N`
|
||||
// counts from 1 per base and restarts whenever release-please moves package.json on.
|
||||
//
|
||||
// Basing the preview on release-please's *predicted* next version would be more
|
||||
// truthful — it would give `0.74.0-preview.N` when a `feat:` is queued — but the
|
||||
// prediction moves mid-flight: a `fix:` opens a 0.73.1 release PR, a later `feat:`
|
||||
// moves it to 0.74.0, and N resets under previews that were already published. If the
|
||||
// prediction ever moved down, the next preview would sort below the previous one.
|
||||
// package.json only ever increases, so patch+1 is monotonic in every case:
|
||||
// 0.73.1-preview.9 < 0.73.2-preview.1 and < 0.74.1-preview.1, whichever way the next
|
||||
// release goes. The cost is that `0.73.1-preview.4` is not a promise that 0.73.1 will
|
||||
// ship.
|
||||
//
|
||||
// N has to be a number nobody has used, and two sources are consulted because either
|
||||
// alone is unsafe:
|
||||
//
|
||||
// * the npm registry, authoritative for what is taken — npm versions are immutable
|
||||
// and stay reserved even after `npm unpublish`, so a taken N is a hard E403 at
|
||||
// publish time — but served from a CDN, so it can lag a publish by minutes;
|
||||
// * git tags, written by the workflow after each publish and strongly consistent,
|
||||
// which cover that window and any unpublish.
|
||||
//
|
||||
// The workflow publishes before it tags, so a version can exist on npm without a tag
|
||||
// but never the other way round. That asymmetry is why the registry is the authority
|
||||
// and the tags are only a floor, and why a registry read failure is fatal here rather
|
||||
// than falling back to the tags alone.
|
||||
//
|
||||
// See docs/RELEASES.md.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
||||
const PREVIEW_ID = "preview";
|
||||
const FETCH_ATTEMPTS = 3;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const BACKOFF_MS = 500;
|
||||
|
||||
/** X.Y.Z: no prerelease, no build metadata, no leading zeros. */
|
||||
const RELEASE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
|
||||
/** The base a preview anticipates: package.json's version with the patch bumped. */
|
||||
export function bumpPatch(version) {
|
||||
const match = RELEASE_VERSION.exec(String(version ?? "").trim());
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`expected a plain X.Y.Z release version, got ${JSON.stringify(version)}. ` +
|
||||
`release-please owns package.json's version; a prerelease or a range here means ` +
|
||||
`something else has edited it.`,
|
||||
);
|
||||
}
|
||||
const [, major, minor, patch] = match;
|
||||
return `${major}.${minor}.${Number(patch) + 1}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(literal) {
|
||||
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Matcher for `<base>-preview.<n>`. `0731-preview.1` would match a base of `0.73.1`. `n`
|
||||
* is a semver-legal numeric identifier, so leading zeros are rejected; npm would never
|
||||
* have accepted `preview.01` in the first place.
|
||||
*/
|
||||
export function previewPattern(base) {
|
||||
return new RegExp(`^${escapeRegExp(base)}-${PREVIEW_ID}\\.(0|[1-9]\\d*)$`);
|
||||
}
|
||||
|
||||
/** The N in `<base>-preview.N`, or null for anything else. */
|
||||
export function previewNumber(candidate, base) {
|
||||
const match = previewPattern(base).exec(String(candidate ?? "").trim());
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/** `refs/tags/v1.2.3`, `v1.2.3` and `1.2.3` all become `1.2.3`. */
|
||||
export function versionFromTag(tag) {
|
||||
return String(tag ?? "")
|
||||
.trim()
|
||||
.replace(/^refs\/tags\//, "")
|
||||
.replace(/^v/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest N among the candidates, or 0 when none match.
|
||||
*/
|
||||
export function highestPreviewNumber(candidates, base) {
|
||||
let highest = 0;
|
||||
for (const candidate of candidates) {
|
||||
const n = previewNumber(candidate, base);
|
||||
if (n !== null && n > highest) {
|
||||
highest = n;
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
export function nextPreviewVersion({ base, registryVersions = [], tags = [] }) {
|
||||
const taken = Math.max(
|
||||
highestPreviewNumber(registryVersions, base),
|
||||
highestPreviewNumber(tags.map(versionFromTag), base),
|
||||
);
|
||||
return `${base}-${PREVIEW_ID}.${taken + 1}`;
|
||||
}
|
||||
|
||||
/** Every version this package has ever published, per the registry. */
|
||||
export async function fetchPublishedVersions(name, options = {}) {
|
||||
const {
|
||||
registry = DEFAULT_REGISTRY,
|
||||
fetchImpl = fetch,
|
||||
attempts = FETCH_ATTEMPTS,
|
||||
backoffMs = BACKOFF_MS,
|
||||
} = options;
|
||||
const url = `${registry}/${name.replace("/", "%2f")}`;
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
// The abbreviated packument is a fraction of the full one — tens of kilobytes
|
||||
// rather than megabytes — and still carries every version key.
|
||||
headers: { accept: "application/vnd.npm.install-v1+json" },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
// Never published. A definite answer, not a failure.
|
||||
return [];
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} answered ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const packument = await response.json();
|
||||
// `versions` on a packument is always an object keyed by version, so there is
|
||||
// none of the "a single version comes back as a bare string" shape that
|
||||
// `npm view <pkg> versions --json` has.
|
||||
return Object.keys(packument.versions ?? {});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs * 2 ** (attempt - 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`could not read published versions for ${name} after ${attempts} attempts: ${lastError}. ` +
|
||||
`Refusing to guess a preview number: the registry is the only record of versions ` +
|
||||
`that were published and then unpublished, and those can never be reused.`,
|
||||
{ cause: lastError },
|
||||
);
|
||||
}
|
||||
|
||||
function localPreviewTags(base) {
|
||||
try {
|
||||
return execFileSync("git", ["tag", "--list", `v${base}-${PREVIEW_ID}.*`], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
// No git, no repository, or a clone without tags. Tags are only a floor under the
|
||||
// registry, so carry on rather than fail.
|
||||
process.stderr.write("warning: could not list git tags; using the registry alone\n");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readFlag(argv, name) {
|
||||
const index = argv.indexOf(name);
|
||||
return index === -1 ? undefined : argv[index + 1];
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
const packageJsonPath = readFlag(argv, "--package-json") ?? "package.json";
|
||||
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
const base = bumpPatch(manifest.version);
|
||||
const registryVersions = await fetchPublishedVersions(manifest.name, {
|
||||
registry: readFlag(argv, "--registry") ?? DEFAULT_REGISTRY,
|
||||
});
|
||||
const tags = argv.includes("--no-git-tags") ? [] : localPreviewTags(base);
|
||||
|
||||
// Diagnostics on stderr so stdout stays a single machine-readable line.
|
||||
process.stderr.write(
|
||||
`${manifest.name} ${manifest.version} -> base ${base}; registry high-water ` +
|
||||
`${highestPreviewNumber(registryVersions, base)}, tag high-water ` +
|
||||
`${highestPreviewNumber(tags.map(versionFromTag), base)}\n`,
|
||||
);
|
||||
process.stdout.write(`${nextPreviewVersion({ base, registryVersions, tags })}\n`);
|
||||
}
|
||||
|
||||
// Only when invoked as a program, so the helpers above can be imported by the test
|
||||
// without doing any I/O.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main(process.argv.slice(2)).catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
bumpPatch,
|
||||
previewNumber,
|
||||
versionFromTag,
|
||||
highestPreviewNumber,
|
||||
nextPreviewVersion,
|
||||
fetchPublishedVersions,
|
||||
} from "./next-preview-version.mjs";
|
||||
|
||||
const PACKAGE = "@agentclientprotocol/claude-agent-acp";
|
||||
|
||||
/** A packument response, abbreviated the way the registry returns it. */
|
||||
function packument(versions) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ name: PACKAGE, versions }),
|
||||
};
|
||||
}
|
||||
|
||||
function failure(status, statusText = "Internal Server Error") {
|
||||
return { ok: false, status, statusText, json: async () => ({}) };
|
||||
}
|
||||
|
||||
describe("bumpPatch", () => {
|
||||
it("increments the patch", () => {
|
||||
expect(bumpPatch("0.73.0")).toBe("0.73.1");
|
||||
expect(bumpPatch("1.0.0")).toBe("1.0.1");
|
||||
expect(bumpPatch("0.0.0")).toBe("0.0.1");
|
||||
});
|
||||
|
||||
it("increments numerically rather than by concatenation", () => {
|
||||
expect(bumpPatch("0.73.9")).toBe("0.73.10");
|
||||
expect(bumpPatch("0.73.19")).toBe("0.73.20");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(bumpPatch(" 0.73.0 ")).toBe("0.73.1");
|
||||
});
|
||||
|
||||
// release-please owns package.json's version, so anything that is not a plain
|
||||
// release means something else has edited it. Failing loudly beats guessing.
|
||||
it.each([
|
||||
["a two-part version", "0.73"],
|
||||
["a four-part version", "0.73.0.1"],
|
||||
["a prerelease", "0.73.0-preview.1"],
|
||||
["build metadata", "0.73.0+build.1"],
|
||||
["a v prefix", "v0.73.0"],
|
||||
["a range", "^0.73.0"],
|
||||
["leading zeros", "01.2.3"],
|
||||
["the empty string", ""],
|
||||
["undefined", undefined],
|
||||
])("throws on %s", (_label, version) => {
|
||||
expect(() => bumpPatch(version)).toThrow(/plain X\.Y\.Z release version/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewNumber", () => {
|
||||
it("reads N out of a matching version", () => {
|
||||
expect(previewNumber("0.73.1-preview.1", "0.73.1")).toBe(1);
|
||||
expect(previewNumber("0.73.1-preview.10", "0.73.1")).toBe(10);
|
||||
});
|
||||
|
||||
it("treats zero as a number rather than as no match", () => {
|
||||
expect(previewNumber("0.73.1-preview.0", "0.73.1")).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a plain release", "0.73.1"],
|
||||
["a different base", "0.73.2-preview.1"],
|
||||
["a missing number", "0.73.1-preview"],
|
||||
["leading zeros in N", "0.73.1-preview.01"],
|
||||
["a dotted N", "0.73.1-preview.1.2"],
|
||||
["build metadata", "0.73.1-preview.1+build.5"],
|
||||
["another prerelease id", "0.73.1-alpha.1"],
|
||||
// Proves the base's dots are escaped rather than matching any character.
|
||||
["a base whose dots are elided", "0731-preview.1"],
|
||||
// Proves the pattern is anchored at the start.
|
||||
["a base with a numeric prefix", "10.73.1-preview.1"],
|
||||
])("returns null for %s", (_label, candidate) => {
|
||||
expect(previewNumber(candidate, "0.73.1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("versionFromTag", () => {
|
||||
it("strips a refs/tags/ prefix and a v", () => {
|
||||
expect(versionFromTag("refs/tags/v0.73.1-preview.3")).toBe("0.73.1-preview.3");
|
||||
expect(versionFromTag("v0.73.1")).toBe("0.73.1");
|
||||
expect(versionFromTag("0.73.1")).toBe("0.73.1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("highestPreviewNumber", () => {
|
||||
it("is 0 when nothing matches", () => {
|
||||
expect(highestPreviewNumber([], "0.73.1")).toBe(0);
|
||||
expect(highestPreviewNumber(["0.73.1", "0.72.1-preview.4"], "0.73.1")).toBe(0);
|
||||
});
|
||||
|
||||
// Sorted as strings, `preview.9` would beat `preview.10`.
|
||||
it("compares numerically, not lexicographically", () => {
|
||||
expect(highestPreviewNumber(["0.73.1-preview.9", "0.73.1-preview.10"], "0.73.1")).toBe(10);
|
||||
expect(highestPreviewNumber(["0.73.1-preview.10", "0.73.1-preview.9"], "0.73.1")).toBe(10);
|
||||
});
|
||||
|
||||
it("ignores order and duplicates", () => {
|
||||
const candidates = [
|
||||
"0.73.1-preview.2",
|
||||
"0.73.1-preview.7",
|
||||
"0.73.1-preview.2",
|
||||
"0.73.1-preview.5",
|
||||
];
|
||||
expect(highestPreviewNumber(candidates, "0.73.1")).toBe(7);
|
||||
});
|
||||
|
||||
it("counts only the requested base", () => {
|
||||
const candidates = ["0.72.1-preview.99", "0.73.1-preview.3", "0.74.1-preview.42"];
|
||||
expect(highestPreviewNumber(candidates, "0.73.1")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextPreviewVersion", () => {
|
||||
it("starts at 1 with no history", () => {
|
||||
expect(nextPreviewVersion({ base: "0.73.1" })).toBe("0.73.1-preview.1");
|
||||
});
|
||||
|
||||
it("continues from the registry", () => {
|
||||
const registryVersions = ["0.73.0", "0.73.1-preview.1", "0.73.1-preview.2", "0.73.1-preview.3"];
|
||||
expect(nextPreviewVersion({ base: "0.73.1", registryVersions })).toBe("0.73.1-preview.4");
|
||||
});
|
||||
|
||||
// The registry is CDN-served and can lag a publish by minutes; the tag the previous
|
||||
// run wrote is what closes that window.
|
||||
it("uses the tags as a floor when the registry is behind", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: "0.73.1",
|
||||
registryVersions: ["0.73.1-preview.1"],
|
||||
tags: ["refs/tags/v0.73.1-preview.3"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.4");
|
||||
});
|
||||
|
||||
// The converse: publish succeeded but the tag step did not, so the registry is
|
||||
// ahead. It stays authoritative.
|
||||
it("keeps the registry authoritative when the tags are behind", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: "0.73.1",
|
||||
registryVersions: ["0.73.1-preview.1", "0.73.1-preview.2", "0.73.1-preview.3"],
|
||||
tags: ["v0.73.1-preview.1"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.4");
|
||||
});
|
||||
|
||||
it("restarts at 1 once a release moves the base", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: "0.73.1",
|
||||
registryVersions: ["0.72.1-preview.7", "0.73.0"],
|
||||
tags: ["v0.72.1-preview.7"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.1");
|
||||
});
|
||||
|
||||
it("composes with bumpPatch", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: bumpPatch("0.73.0"),
|
||||
registryVersions: ["0.73.0"],
|
||||
tags: ["v0.73.0"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchPublishedVersions", () => {
|
||||
const options = (fetchImpl) => ({ fetchImpl, backoffMs: 0 });
|
||||
|
||||
it("returns the version keys of the packument", async () => {
|
||||
const fetchImpl = vi.fn(async () => packument({ "0.73.0": {}, "0.73.1-preview.1": {} }));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([
|
||||
"0.73.0",
|
||||
"0.73.1-preview.1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests the abbreviated packument at the scope-encoded URL", async () => {
|
||||
const fetchImpl = vi.fn(async () => packument({}));
|
||||
await fetchPublishedVersions(PACKAGE, options(fetchImpl));
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe("https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp");
|
||||
expect(init.headers.accept).toBe("application/vnd.npm.install-v1+json");
|
||||
});
|
||||
|
||||
it("honours a custom registry", async () => {
|
||||
const fetchImpl = vi.fn(async () => packument({}));
|
||||
await fetchPublishedVersions(PACKAGE, {
|
||||
...options(fetchImpl),
|
||||
registry: "http://127.0.0.1:4873",
|
||||
});
|
||||
expect(fetchImpl.mock.calls[0][0]).toBe(
|
||||
"http://127.0.0.1:4873/@agentclientprotocol%2fclaude-agent-acp",
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates a packument with no versions", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ name: PACKAGE }),
|
||||
}));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
// A 404 is a definite answer — the package has never been published — so it must not
|
||||
// be retried or turned into a failure.
|
||||
it("treats 404 as an empty history without retrying", async () => {
|
||||
const fetchImpl = vi.fn(async () => failure(404, "Not Found"));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries a server error and resolves", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(failure(500))
|
||||
.mockResolvedValueOnce(packument({ "0.73.0": {} }));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["0.73.0"]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries a network failure and resolves", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
||||
.mockResolvedValueOnce(packument({ "0.73.0": {} }));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["0.73.0"]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Guessing N after a failed read risks reusing a version that was published and then
|
||||
// unpublished, which npm reserves forever.
|
||||
it("gives up rather than guessing when every attempt fails", async () => {
|
||||
const fetchImpl = vi.fn(async () => failure(500));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).rejects.toThrow(
|
||||
/could not read published versions for @agentclientprotocol\/claude-agent-acp after 3 attempts/,
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Preflight checks for a release.
|
||||
#
|
||||
# release-please decides what the next version is; this script only verifies the
|
||||
# repository is in a state where merging the open release PR produces a correct
|
||||
# release. Merging main needs no review, so these checks are the only thing
|
||||
# standing between a mistake and a published package — keep them as code rather
|
||||
# than as a list someone has to remember. See docs/RELEASES.md.
|
||||
#
|
||||
# Requires an authenticated GitHub CLI. Nothing else: gh has a jq engine builtin.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PENDING_LABEL="autorelease: pending"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
pass() {
|
||||
printf 'ok %s\n' "$1"
|
||||
}
|
||||
|
||||
command -v gh >/dev/null 2>&1 ||
|
||||
fail "GitHub CLI (gh) is not installed."
|
||||
gh auth status >/dev/null 2>&1 ||
|
||||
fail "GitHub CLI is not authenticated. Run 'gh auth login'."
|
||||
|
||||
repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) ||
|
||||
fail "cannot work out which GitHub repository this is. Run from a checkout."
|
||||
|
||||
# A merged release PR that still carries the pending label means release-please
|
||||
# never tagged it. In that state it refuses to open any new release PR at all,
|
||||
# so every later release stalls silently until it is recovered by hand.
|
||||
stuck=$(gh pr list --repo "$repo" --state merged --label "$PENDING_LABEL" \
|
||||
--json number --jq 'map("#\(.number)") | join(", ")')
|
||||
[ -z "$stuck" ] ||
|
||||
fail "merged release PR still labelled '$PENDING_LABEL': $stuck.
|
||||
release-please is jammed and will not open new release PRs.
|
||||
See docs/RELEASES.md, 'Recovering a stalled release'."
|
||||
pass "no untagged merged release PR"
|
||||
|
||||
rows=$(gh pr list --repo "$repo" --state open --label "$PENDING_LABEL" \
|
||||
--json number,title,headRefName --jq '.[] | [.number, .headRefName, .title] | @tsv')
|
||||
count=$(printf '%s\n' "$rows" | grep -c . || true)
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
fail "no open release PR.
|
||||
Nothing has landed since the last tag that release-please puts in a
|
||||
changelog, so there is nothing to release. Land a feat/fix/perf/revert/docs
|
||||
change first, or check that the Publish and Release workflow is running."
|
||||
fi
|
||||
if [ "$count" -gt 1 ]; then
|
||||
fail "$count open release PRs, expected exactly one:
|
||||
$rows"
|
||||
fi
|
||||
IFS=$(printf '\t') read -r pr_number head_ref pr_title <<EOF
|
||||
$rows
|
||||
EOF
|
||||
pass "one open release PR: #$pr_number ($pr_title)"
|
||||
|
||||
# release-please reads the version to tag from the PR title, while npm publishes
|
||||
# whatever package.json says. If those disagree the tag and the published package
|
||||
# describe different releases, so check all three sources against each other.
|
||||
# The version is the last field, not everything after 'release': release-please
|
||||
# writes the component into the title as 'release <component> X.Y.Z' whenever it
|
||||
# is configured to tag with one.
|
||||
title_version=${pr_title##* }
|
||||
case $title_version in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) fail "cannot read a version from PR title '$pr_title'." ;;
|
||||
esac
|
||||
pkg_version=$(gh api "repos/$repo/contents/package.json?ref=$head_ref" \
|
||||
-H "Accept: application/vnd.github.raw" --jq .version)
|
||||
manifest_version=$(gh api "repos/$repo/contents/.release-please-manifest.json?ref=$head_ref" \
|
||||
-H "Accept: application/vnd.github.raw" --jq '."."')
|
||||
|
||||
if [ "$title_version" != "$pkg_version" ] || [ "$title_version" != "$manifest_version" ]; then
|
||||
fail "version mismatch on #$pr_number:
|
||||
PR title $title_version
|
||||
package.json $pkg_version
|
||||
.release-please-manifest.json $manifest_version"
|
||||
fi
|
||||
pass "version agrees across PR title, package.json and manifest: $title_version"
|
||||
|
||||
# The tag comes from the config, not from the PR title. With
|
||||
# include-component-in-tag left on, release-please tags <component>-vX.Y.Z, which
|
||||
# breaks the vX.Y.Z scheme the rest of this script and docs/RELEASES.md rely on,
|
||||
# and makes it rewrite the whole history into the changelog because no tag under
|
||||
# that scheme exists yet. The compare link it writes into the PR body names the
|
||||
# tags it is going to use, so check those rather than trusting the config.
|
||||
compare_tag=$(gh pr view "$pr_number" --repo "$repo" --json body --jq '
|
||||
.body | capture("/compare/[^)]*[.][.][.](?<to>[^)\\s]+)").to // ""' 2>/dev/null || true)
|
||||
case $compare_tag in
|
||||
"" | "v$title_version") ;;
|
||||
*) fail "release-please is going to tag '$compare_tag', not 'v$title_version'.
|
||||
Every previous release is tagged vX.Y.Z, and the changelog for #$pr_number
|
||||
covers the whole history rather than just this release, because no tag
|
||||
under that scheme exists. Set 'include-component-in-tag': false in
|
||||
release-please-config.json, then let it rewrite the release PR." ;;
|
||||
esac
|
||||
|
||||
if gh release view "v$title_version" --repo "$repo" >/dev/null 2>&1; then
|
||||
fail "tag v$title_version already exists. Merging would try to release it twice."
|
||||
fi
|
||||
pass "tag v$title_version does not exist yet"
|
||||
|
||||
build=$(gh pr view "$pr_number" --repo "$repo" --json statusCheckRollup --jq '
|
||||
[.statusCheckRollup[]? | select(.name == "Build")]
|
||||
| if length == 0 then "MISSING" else (.[0].conclusion // .[0].status // "PENDING") end')
|
||||
[ "$build" = "SUCCESS" ] ||
|
||||
fail "required check 'Build' is $build on #$pr_number, expected SUCCESS."
|
||||
pass "required check 'Build' passed"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Ready to release $title_version.
|
||||
|
||||
gh pr merge $pr_number --squash
|
||||
|
||||
Merging tags v$title_version, publishes to npm and updates the agent registry.
|
||||
EOF
|
||||
12109
src/acp-agent.ts
12109
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
SessionCapabilities,
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY,
|
||||
clientSupportsAirCapability,
|
||||
} from "./air-extension.js";
|
||||
|
||||
export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Temporary typed surface for agentclientprotocol/agent-client-protocol#1992.
|
||||
*
|
||||
* The wire contract is already defined by the ACP draft, but the published
|
||||
* TypeScript SDK does not contain it yet. Keep the compatibility boundary in
|
||||
* this file so it can be replaced by SDK exports without changing lifecycle
|
||||
* code when the draft ships.
|
||||
*/
|
||||
export type SubagentSessionCapabilities = {
|
||||
cancel?: boolean;
|
||||
close?: boolean;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type SubagentSpawnedUpdate = {
|
||||
sessionUpdate: "subagent_spawned";
|
||||
subagentSessionId: string;
|
||||
name: string;
|
||||
task: string;
|
||||
/**
|
||||
* Adapter extension: the exact prompt of the subagent. A client can show it
|
||||
* as the first user message of the subagent session. It is absent when the
|
||||
* adapter does not know the prompt.
|
||||
*/
|
||||
prompt?: string;
|
||||
capabilities: SubagentSessionCapabilities;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type SubagentState = "completed" | "failed" | "cancelled" | "disconnected";
|
||||
|
||||
export type SubagentStateUpdate = {
|
||||
sessionUpdate: "subagent_state_update";
|
||||
subagentSessionId: string;
|
||||
state: SubagentState;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AsyncTaskState = "running" | "paused" | "completed" | "failed" | "stopped";
|
||||
|
||||
export type AsyncTaskSpawnedUpdate = {
|
||||
sessionUpdate: "async_task_spawned";
|
||||
asyncTaskId: string;
|
||||
name: string;
|
||||
taskType: string;
|
||||
description: string;
|
||||
showInTranscript: boolean;
|
||||
canStop: boolean;
|
||||
outputFilePath?: string;
|
||||
toolCallId?: string;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AsyncTaskProgressUpdate = {
|
||||
sessionUpdate: "async_task_progress";
|
||||
asyncTaskId: string;
|
||||
description?: string;
|
||||
summary?: string;
|
||||
lastToolName?: string;
|
||||
usage?: { totalTokens: number; toolUses: number; durationMs: number };
|
||||
/** Latest durable task log path. May arrive after spawn. */
|
||||
outputFilePath?: string;
|
||||
/** Originating tool call, when correlation becomes known after spawn. */
|
||||
toolCallId?: string;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AsyncTaskStateUpdate = {
|
||||
sessionUpdate: "async_task_state_update";
|
||||
asyncTaskId: string;
|
||||
state: AsyncTaskState;
|
||||
summary?: string;
|
||||
/** Latest durable task log path, including terminal-only SDK reports. */
|
||||
outputFilePath?: string;
|
||||
/** Originating tool call, including terminal-only late correlation. */
|
||||
toolCallId?: string;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AcpSessionUpdate =
|
||||
| SessionNotification["update"]
|
||||
| SubagentSpawnedUpdate
|
||||
| SubagentStateUpdate
|
||||
| AsyncTaskSpawnedUpdate
|
||||
| AsyncTaskProgressUpdate
|
||||
| AsyncTaskStateUpdate;
|
||||
|
||||
export type AcpSessionNotification = Omit<SessionNotification, "update"> & {
|
||||
update: AcpSessionUpdate;
|
||||
};
|
||||
|
||||
export type SubagentAwareSessionCapabilities = SessionCapabilities & {
|
||||
subagents?: Record<string, never>;
|
||||
};
|
||||
|
||||
export function clientSupportsSubagents(capabilities?: ClientCapabilities | null): boolean {
|
||||
const subagents = (
|
||||
capabilities as (ClientCapabilities & { subagents?: unknown }) | null | undefined
|
||||
)?.subagents;
|
||||
if (typeof subagents === "object" && subagents !== null && !Array.isArray(subagents)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return clientSupportsAirCapability(capabilities, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY);
|
||||
}
|
||||
|
||||
/** The only cast needed until the TypeScript SDK publishes PR #1992. */
|
||||
export function asSdkSessionNotification(
|
||||
notification: AcpSessionNotification,
|
||||
): SessionNotification {
|
||||
return notification as SessionNotification;
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions";
|
||||
export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks";
|
||||
export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure";
|
||||
export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue";
|
||||
export const AIR_CUSTOM_INSTRUCTIONS_CAPABILITY = "customInstructions";
|
||||
export const AIR_DIFF_PATCH_CAPABILITY = "diffPatch";
|
||||
/** AIR renders `rawInput` itself and needs no display copy of the input. */
|
||||
export const AIR_RAW_INPUT_RENDERING_CAPABILITY = "rawInputRendering";
|
||||
/**
|
||||
* The plan of an ExitPlanMode is a file. `rawInput.planFilePath` names the
|
||||
* file, and AIR reads the plan from it.
|
||||
*/
|
||||
export const AIR_PLAN_FILE_CAPABILITY = "planFile";
|
||||
/**
|
||||
* The session index: a bounded, ordered `session/list` with row metadata, and
|
||||
* `_session/rename`, `_session/archive`, `_session/unarchive` and the list
|
||||
* subscription. Advertised only to a client that declares it.
|
||||
*/
|
||||
export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex";
|
||||
/**
|
||||
* The agent supports `_session/archive` and `_session/unarchive`. Advertised
|
||||
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not
|
||||
* declare it.
|
||||
*/
|
||||
export const AIR_SESSION_ARCHIVE_CAPABILITY = "sessionArchive";
|
||||
/**
|
||||
* The agent supports `_session/rename`. Advertised exactly when
|
||||
* {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it.
|
||||
*/
|
||||
export const AIR_SESSION_RENAME_CAPABILITY = "sessionRename";
|
||||
/**
|
||||
* The agent supports `_session/list/subscribe`, `_session/list/unsubscribe`
|
||||
* and `_session/list/changes`. Advertised exactly when
|
||||
* {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it.
|
||||
*/
|
||||
export const AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY = "sessionListSubscribe";
|
||||
|
||||
/** The `_meta.jetbrains.air` keys that the ACP tool call contract defines. */
|
||||
export const AIR_COMMAND_TITLE_KEY = "commandTitle";
|
||||
export const AIR_SUBAGENT_KEY = "subagent";
|
||||
export const AIR_SKILL_KEY = "skill";
|
||||
export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction";
|
||||
export const AIR_GOAL_KEY = "goal";
|
||||
export const AIR_KIND_KEY = "kind";
|
||||
/** The absolute path of the `SKILL.md` of a skill command in `available_commands_update`. */
|
||||
export const AIR_SKILL_PATH_KEY = "skillPath";
|
||||
export const AIR_PERMISSION_KEY = "permission";
|
||||
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
|
||||
/**
|
||||
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released
|
||||
* AIR versions read. Remove it when the ACP registry no longer serves AIR
|
||||
* versions that read only this spelling.
|
||||
*/
|
||||
export const LEGACY_AIR_CUSTOM_ANSWER_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
const AIR_EXTENSION_VERSION_KEY = "version";
|
||||
const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities";
|
||||
const AIR_EXTENSION_VERSION = 1;
|
||||
|
||||
/** The capability list this side advertises, as its own `_meta` object. */
|
||||
export function airCapabilityMeta(...capabilities: string[]) {
|
||||
return withAirMeta(undefined, AIR_EXTENSION_CAPABILITIES_KEY, capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges one AIR extension payload into an existing `_meta`.
|
||||
*
|
||||
* Every other namespace is preserved: an update can carry both agent-native
|
||||
* `claudeCode` metadata and an AIR payload, and two AIR payloads can share the
|
||||
* same `air` object. Pass `undefined` to build a fresh `_meta`.
|
||||
*/
|
||||
export function withAirMeta(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
capability: string,
|
||||
payload: unknown,
|
||||
): Record<string, unknown> {
|
||||
const jetbrains = asRecord(meta?.[JETBRAINS_META_KEY]);
|
||||
const air = asRecord(jetbrains[AIR_META_KEY]);
|
||||
return {
|
||||
...meta,
|
||||
[JETBRAINS_META_KEY]: {
|
||||
...jetbrains,
|
||||
[AIR_META_KEY]: {
|
||||
...air,
|
||||
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
|
||||
[capability]: payload,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client is JetBrains AIR: it declared `_meta.jetbrains.air` in
|
||||
* its capabilities.
|
||||
*
|
||||
* Only AIR gets the AIR extensions of `docs/air-extensions.md`. Every other
|
||||
* client, Zed too, gets the fields and the upstream `_meta` keys of the
|
||||
* upstream adapter, and no key that exists only for AIR.
|
||||
*/
|
||||
export function isAirClient(capabilities: unknown): boolean {
|
||||
return airExtensionMeta(asRecord(capabilities)._meta) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta` of an AIR client with one more AIR payload, or undefined for
|
||||
* every other client: a client that is not AIR gets no AIR key.
|
||||
*/
|
||||
export function airOnlyMeta(
|
||||
airClient: boolean,
|
||||
capability: string,
|
||||
payload: unknown,
|
||||
meta?: Record<string, unknown> | null,
|
||||
): Record<string, unknown> | undefined {
|
||||
return airClient ? withAirMeta(meta, capability, payload) : (meta ?? undefined);
|
||||
}
|
||||
|
||||
/** The `air` object inside a `_meta`, or undefined when the peer sent no AIR extension. */
|
||||
export function airExtensionMeta(meta: unknown): Record<string, unknown> | undefined {
|
||||
const air = asRecord(asRecord(meta)[JETBRAINS_META_KEY])[AIR_META_KEY];
|
||||
return air && typeof air === "object" && !Array.isArray(air)
|
||||
? (air as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the peer advertised `capability`.
|
||||
*
|
||||
* Takes `unknown` because every caller is reading wire data: an ACP
|
||||
* `ClientCapabilities`, or a bag whose `_meta` was never validated.
|
||||
*/
|
||||
export function clientSupportsAirCapability(capabilities: unknown, capability: string): boolean {
|
||||
const air = airExtensionMeta(asRecord(capabilities)._meta);
|
||||
const version = air?.[AIR_EXTENSION_VERSION_KEY];
|
||||
const advertised = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
|
||||
return (
|
||||
typeof version === "number" &&
|
||||
Number.isFinite(version) &&
|
||||
Number.isInteger(version) &&
|
||||
version >= AIR_EXTENSION_VERSION &&
|
||||
Array.isArray(advertised) &&
|
||||
advertised.includes(capability)
|
||||
);
|
||||
}
|
||||
|
||||
/** Read custom instructions from `session/new` metadata. */
|
||||
export function airCustomInstructions(meta: unknown): string | undefined {
|
||||
const air = airExtensionMeta(meta);
|
||||
const instructions = air?.[AIR_CUSTOM_INSTRUCTIONS_CAPABILITY];
|
||||
return typeof instructions === "string" ? instructions : undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
1051
src/async-tasks.ts
1051
src/async-tasks.ts
File diff suppressed because it is too large
Load Diff
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* `authStatus` — interim `_meta`-based ACP extension that reports which auth
|
||||
* identity the agent process itself uses (Claude subscription, API key,
|
||||
* gateway, external cloud credentials, or nothing).
|
||||
*
|
||||
* Push only. One carrier, connection-scoped (never per session): the
|
||||
* notification `_auth/status_update` → `{authStatus}`. The client never asks;
|
||||
* there is no request method. The empty `agentCapabilities._meta.authStatus`
|
||||
* marker means "this agent pushes its identity", nothing more.
|
||||
*
|
||||
* The agent reads the identity at these moments:
|
||||
* - `initialize`, with an asynchronous CLI probe. Nothing waits for it, and its
|
||||
* result is the first push of the connection;
|
||||
* - a completed `authenticate` or a `logout`;
|
||||
* - each `createSession`, from the session account. A refused session reports
|
||||
* too: the client must know which account was refused;
|
||||
* - each turn the `--hide-claude-auth` guard checks. The guard reads the
|
||||
* account anyway, so this costs nothing;
|
||||
* - the START of each user prompt, with an asynchronous CLI probe. This finds a
|
||||
* login or a logout done in another terminal, including the sign-in a client
|
||||
* performs after a refusal, because the retried prompt is a new prompt.
|
||||
* Nothing waits for it and the push goes out mid-turn if that is when the
|
||||
* read lands.
|
||||
*
|
||||
* A probe whose kind differs from a live session's account marks that session
|
||||
* for recreation under `--hide-claude-auth`. The mark is a flag, consumed at
|
||||
* the next turn boundary: a running turn is never interrupted. The probe judges
|
||||
* nothing itself — the next turn runs a new `initialize`, and the creation
|
||||
* guard decides on the account it reports.
|
||||
*
|
||||
* A read does not always cause a push. The agent sends `_auth/status_update`
|
||||
* only when the payload is different from the last one it sent.
|
||||
*
|
||||
* "Cannot determine" is expressed by silence: an agent that cannot read its own
|
||||
* identity pushes nothing, and the client shows "not reported". A known
|
||||
* logged-out state is a payload of its own (`kind: "none"`), which is what
|
||||
* distinguishes the two.
|
||||
*
|
||||
* Reporting only — there is no write path in v1. When the upstream RFD lands,
|
||||
* the same payload moves from `_meta` to first-class fields and this module
|
||||
* is retired.
|
||||
*/
|
||||
|
||||
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Change notification, agent → client. Fire and forget: clients that do not
|
||||
* know the method drop it silently, so it is sent unconditionally. */
|
||||
export const AUTH_STATUS_UPDATE_METHOD = "_auth/status_update";
|
||||
|
||||
/** Upper bound on one `claude auth status --json` run, so a hung CLI cannot
|
||||
* wedge every later probe that joins it. */
|
||||
export const AUTH_STATUS_PROBE_TIMEOUT_MS = 5000;
|
||||
|
||||
export type AuthStatusKind = "account" | "api_key" | "gateway" | "external" | "none";
|
||||
|
||||
export type AuthStatusAccount = {
|
||||
email?: string;
|
||||
organization?: string;
|
||||
/** Vendor plan/license string, not normalized. */
|
||||
plan?: string;
|
||||
};
|
||||
|
||||
export type AuthStatus = {
|
||||
kind: AuthStatusKind;
|
||||
/** Human-readable and usable as a UI string on its own. The "type" line:
|
||||
* "Claude Max", "Anthropic API key", "AWS Bedrock". */
|
||||
label: string;
|
||||
/** Optional second line carrying the specifics (key source, gateway host, …).
|
||||
* Clients render it under `label`, falling back to `account.email`. */
|
||||
detail?: string;
|
||||
account?: AuthStatusAccount;
|
||||
/** Vendor-namespaced extras, e.g. `{claudeCode: {...}}`. */
|
||||
vendor?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AuthStatusUpdateNotification = { authStatus: AuthStatus };
|
||||
|
||||
/** Advertisement carried in `agentCapabilities._meta.authStatus`, never a
|
||||
* status payload: its mere presence means "this agent pushes its identity",
|
||||
* and it stays empty. Informational — a client that never saw it still
|
||||
* accepts `_auth/status_update`, because there is nothing to ask for. */
|
||||
export type AuthStatusCapability = Record<string, never>;
|
||||
|
||||
export function authStatusCapability(): AuthStatusCapability {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Shape of `claude auth status --json`. Absent fields are omitted, not null,
|
||||
* and the logged-out case exits 1 while still printing valid JSON. */
|
||||
export type CliAuthStatus = {
|
||||
loggedIn?: boolean;
|
||||
authMethod?: string;
|
||||
apiProvider?: string;
|
||||
apiKeySource?: string;
|
||||
email?: string;
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
subscriptionType?: string;
|
||||
};
|
||||
|
||||
const NOT_LOGGED_IN: AuthStatus = {
|
||||
kind: "none",
|
||||
label: "Not logged in",
|
||||
};
|
||||
|
||||
/** Display names for the non-firstParty backends, where auth lives outside the
|
||||
* agent (AWS credentials, gcloud ADC, …). */
|
||||
const EXTERNAL_PROVIDER_LABELS: Record<string, string> = {
|
||||
bedrock: "AWS Bedrock",
|
||||
vertex: "Google Vertex AI",
|
||||
foundry: "Azure AI Foundry",
|
||||
anthropicAws: "Anthropic on AWS",
|
||||
anthropicGoogleCloud: "Anthropic on Google Cloud",
|
||||
mantle: "Mantle",
|
||||
};
|
||||
|
||||
export function notLoggedInAuthStatus(): AuthStatus {
|
||||
return { ...NOT_LOGGED_IN };
|
||||
}
|
||||
|
||||
/** ACP-level gateway auth (`authenticate` with `gateway`/`gateway-bedrock`).
|
||||
* The gateway owns the credentials, so it wins over anything the CLI reports. */
|
||||
export function gatewayAuthStatus(baseUrl?: string): AuthStatus {
|
||||
let host: string | undefined;
|
||||
if (baseUrl) {
|
||||
try {
|
||||
host = new URL(baseUrl).host;
|
||||
} catch {
|
||||
host = baseUrl;
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "gateway",
|
||||
label: "Custom model gateway",
|
||||
...(host ? { detail: host } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this `AccountInfo` say anything about the identity?
|
||||
*
|
||||
* The SDK always fills `apiProvider`, but the identity fields come from the
|
||||
* claude.ai profile: with an `apiKeyHelper` and no subscription login the
|
||||
* session reports `{apiProvider: "firstParty"}` and nothing else. That is "no
|
||||
* information", not "logged out" — an empty read must never be mapped to
|
||||
* `none`, or it destroys what the CLI probe already established.
|
||||
*
|
||||
* `tokenSource` is deliberately not a signal: the CLI sets it to the OAuth
|
||||
* token's origin (`CLAUDE_CODE_OAUTH_TOKEN`,
|
||||
* `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`, …), never to a key source, so it
|
||||
* cannot attribute a key and carries no plan of its own.
|
||||
*/
|
||||
export function accountInfoHasIdentitySignal(account: AccountInfo | undefined): boolean {
|
||||
if (!account) return false;
|
||||
return Boolean(
|
||||
account.apiKeySource ||
|
||||
account.subscriptionType ||
|
||||
(account.apiProvider && account.apiProvider !== "firstParty"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Session-time mapping from the SDK's `AccountInfo`, the richest source when
|
||||
* it is populated. Returns `undefined` when the account carries no identity
|
||||
* signal, so callers keep the status they already know. */
|
||||
export function fromAccountInfo(account: AccountInfo | undefined): AuthStatus | undefined {
|
||||
if (!account || !accountInfoHasIdentitySignal(account)) {
|
||||
return undefined;
|
||||
}
|
||||
return mapAuthFields({
|
||||
apiProvider: account.apiProvider,
|
||||
subscriptionType: account.subscriptionType,
|
||||
apiKeySource: account.apiKeySource,
|
||||
email: account.email,
|
||||
organization: account.organization,
|
||||
});
|
||||
}
|
||||
|
||||
/** Pre-session mapping from `claude auth status --json` stdout. Returns
|
||||
* `undefined` when the output is not the expected JSON object, so callers can
|
||||
* report "not known" instead of guessing. */
|
||||
export function fromCliStatus(stdout: string): AuthStatus | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const status = parsed as CliAuthStatus;
|
||||
if (typeof status.loggedIn !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
// `loggedIn` tracks the claude.ai login only. On a 3P backend (Bedrock,
|
||||
// Vertex, …) it is false while the credentials live outside the agent, so the
|
||||
// backend must decide the kind before the logged-out shortcut runs.
|
||||
const externalBackend = Boolean(status.apiProvider && status.apiProvider !== "firstParty");
|
||||
if (!externalBackend && !status.loggedIn && !status.apiKeySource) {
|
||||
return notLoggedInAuthStatus();
|
||||
}
|
||||
return mapAuthFields({
|
||||
apiProvider: status.apiProvider,
|
||||
subscriptionType: status.subscriptionType,
|
||||
apiKeySource: status.apiKeySource,
|
||||
email: status.email,
|
||||
organization: status.orgName,
|
||||
});
|
||||
}
|
||||
|
||||
/** Single mapping table shared by both sources; see the `kind`/`label` table in
|
||||
* `extension-authstatus-meta.md`. Order matters: a 3P backend or a gateway
|
||||
* decides the kind before any firstParty identity fields are considered, and
|
||||
* an API key outranks a stored subscription login.
|
||||
*
|
||||
* Both can be reported at once — an `apiKeyHelper` in settings.json next to a
|
||||
* claude.ai OAuth login yields `apiKeySource` AND `subscriptionType`. Claude
|
||||
* Code's authentication precedence puts `apiKeyHelper` above the `/login`
|
||||
* subscription, so the key is what actually pays for the turn and is what we
|
||||
* report. */
|
||||
function mapAuthFields(fields: {
|
||||
apiProvider?: string;
|
||||
subscriptionType?: string;
|
||||
apiKeySource?: string;
|
||||
email?: string;
|
||||
organization?: string;
|
||||
}): AuthStatus {
|
||||
const { apiProvider, subscriptionType, apiKeySource, email, organization } = fields;
|
||||
|
||||
if (apiProvider === "gateway") {
|
||||
return gatewayAuthStatus();
|
||||
}
|
||||
if (apiProvider && apiProvider !== "firstParty") {
|
||||
return {
|
||||
kind: "external",
|
||||
label: EXTERNAL_PROVIDER_LABELS[apiProvider] ?? apiProvider,
|
||||
};
|
||||
}
|
||||
if (apiKeySource) {
|
||||
return {
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
// The source ("apiKeyHelper", "env", …) is the specifics line.
|
||||
detail: apiKeySource,
|
||||
};
|
||||
}
|
||||
if (subscriptionType) {
|
||||
// `account.plan` keeps the raw vendor string; only the label is titled.
|
||||
const account: AuthStatusAccount = { plan: subscriptionType };
|
||||
if (email) account.email = email;
|
||||
if (organization) account.organization = organization;
|
||||
return {
|
||||
kind: "account",
|
||||
label: planLabel(subscriptionType),
|
||||
// No detail: the client falls back to `account.email` for line 2.
|
||||
account,
|
||||
};
|
||||
}
|
||||
return notLoggedInAuthStatus();
|
||||
}
|
||||
|
||||
/** Names the plan with exactly one "Claude". A newer CLI already reports
|
||||
* "Claude Max", an older one reports "max", and both must read "Claude Max".
|
||||
* Each word is title-cased; a word that already carries capitals is kept. */
|
||||
function planLabel(plan: string): string {
|
||||
const titled = plan.replace(/\S+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1));
|
||||
return /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two payloads describe the same login? Compared on the field that
|
||||
* identifies each kind — the key source for `api_key`, the email for
|
||||
* `account`, the kind alone for the rest, which carry no per-identity key.
|
||||
*/
|
||||
export function sameIdentity(a: AuthStatus, b: AuthStatus): boolean {
|
||||
if (a.kind !== b.kind) {
|
||||
return false;
|
||||
}
|
||||
switch (a.kind) {
|
||||
case "api_key":
|
||||
return a.detail === b.detail;
|
||||
case "account":
|
||||
return (a.account?.email ?? "") === (b.account?.email ?? "");
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds a fresh read into what is already known. Sources differ in richness for
|
||||
* the very same login: the SDK's session `AccountInfo` can carry an
|
||||
* organization the CLI probe omits. So when the read describes the same
|
||||
* identity, its values win field by field but the fields it lacks are kept;
|
||||
* when it describes a different identity, it replaces the old one wholesale.
|
||||
*/
|
||||
export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStatus): AuthStatus {
|
||||
if (!previous || !sameIdentity(previous, next)) {
|
||||
return next;
|
||||
}
|
||||
const merged: AuthStatus = { ...next };
|
||||
if (merged.detail === undefined && previous.detail !== undefined) {
|
||||
merged.detail = previous.detail;
|
||||
}
|
||||
if (previous.account || next.account) {
|
||||
merged.account = { ...previous.account, ...next.account };
|
||||
}
|
||||
if (merged.vendor === undefined && previous.vendor !== undefined) {
|
||||
merged.vendor = previous.vendor;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two payloads carry the same information? Unlike {@link sameIdentity},
|
||||
* which asks "is this the same login", this compares every rendered field, so
|
||||
* a richer read of one login is NOT the same. Callers use it to suppress a
|
||||
* push that would tell the client nothing new.
|
||||
*
|
||||
* `a` undefined means "nothing reported yet", which never equals a payload.
|
||||
*/
|
||||
export function sameAuthStatus(a: AuthStatus | undefined, b: AuthStatus): boolean {
|
||||
if (!a) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
a.kind === b.kind &&
|
||||
a.label === b.label &&
|
||||
a.detail === b.detail &&
|
||||
a.account?.email === b.account?.email &&
|
||||
a.account?.organization === b.account?.organization &&
|
||||
a.account?.plan === b.account?.plan &&
|
||||
// Vendor extras are an open bag, so compare them structurally. Both sides
|
||||
// are built by this module from the same key order, so a string compare is
|
||||
// enough and costs nothing on the usual `undefined`/`undefined` pair.
|
||||
JSON.stringify(a.vendor) === JSON.stringify(b.vendor)
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import type { NewSessionRequest, SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
EffortLevel,
|
||||
Options,
|
||||
PermissionMode,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
|
||||
export type ClearContextReset = {
|
||||
toolUseId: string;
|
||||
plan: string;
|
||||
mode: PermissionMode;
|
||||
};
|
||||
|
||||
type Usage = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedReadTokens: number;
|
||||
cachedWriteTokens: number;
|
||||
};
|
||||
|
||||
export type ClearContextTurn = {
|
||||
settled: boolean;
|
||||
promptUuid: string;
|
||||
carriedUsage?: Usage;
|
||||
carriedModelUsage?: Record<string, Usage>;
|
||||
};
|
||||
|
||||
/** The session state needed to replace Claude's private conversation without
|
||||
* replacing the public ACP turn. Keeping this projection here makes the
|
||||
* restart workflow independent from the agent's much larger Session record. */
|
||||
export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn> = {
|
||||
cwd: string;
|
||||
creationParams?: NewSessionRequest;
|
||||
accumulatedUsage: Usage;
|
||||
accumulatedModelUsage?: Record<string, Usage>;
|
||||
models: { currentModelId: string };
|
||||
configOptions: SessionConfigOption[];
|
||||
currentAgent: string;
|
||||
fastModeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
turnQueue?: Turn[];
|
||||
pendingExitPlanContextReset?: ClearContextReset;
|
||||
contextUsedTokens?: number;
|
||||
input: { push(message: SDKUserMessage): unknown };
|
||||
};
|
||||
|
||||
export type ClearContextCoordinatorHost<
|
||||
Session extends ClearContextSession<Turn>,
|
||||
Turn extends ClearContextTurn,
|
||||
> = {
|
||||
currentSession(sessionId: string): Session | undefined;
|
||||
closeQueryStream(session: Session): void;
|
||||
restartSession(
|
||||
params: NewSessionRequest,
|
||||
options: { publicSessionId: string; permissionMode: PermissionMode },
|
||||
): Promise<Session>;
|
||||
applyFastMode(session: Session, enabled: boolean): Promise<void>;
|
||||
publishSessionState(
|
||||
sessionId: string,
|
||||
mode: PermissionMode,
|
||||
configOptions: SessionConfigOption[],
|
||||
): Promise<void>;
|
||||
continuationMessage(sessionId: string, plan: string, promptUuid: string): SDKUserMessage;
|
||||
ensureConsumer(session: Session, sessionId: string): void;
|
||||
logError(message: string, error: unknown): void;
|
||||
};
|
||||
|
||||
function restartParams<Session extends ClearContextSession>(session: Session): NewSessionRequest {
|
||||
const originalParams = session.creationParams ?? { cwd: session.cwd, mcpServers: [] };
|
||||
const originalMeta = originalParams._meta as
|
||||
({ claudeCode?: { options?: Options } } & Record<string, unknown>) | undefined;
|
||||
const originalOptions = originalMeta?.claudeCode?.options;
|
||||
const unmanagedOptions = { ...(originalOptions ?? {}) };
|
||||
delete unmanagedOptions.model;
|
||||
delete unmanagedOptions.agent;
|
||||
delete unmanagedOptions.effort;
|
||||
|
||||
return {
|
||||
...originalParams,
|
||||
_meta: {
|
||||
...(originalMeta ?? {}),
|
||||
claudeCode: {
|
||||
...(originalMeta?.claudeCode ?? {}),
|
||||
options: {
|
||||
...unmanagedOptions,
|
||||
...(session.models.currentModelId !== DEFAULT_MODEL_ID
|
||||
? { model: session.models.currentModelId }
|
||||
: {}),
|
||||
...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}),
|
||||
...(session.effortPinnedLevel !== undefined
|
||||
? { effort: session.effortPinnedLevel as EffortLevel }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace Claude's private conversation and attach the still-pending ACP turn
|
||||
* to it. The host owns provider-specific creation and stream mechanics; this
|
||||
* coordinator owns the ordering and state transfer invariants. */
|
||||
export async function continuePlanInFreshContext<
|
||||
Turn extends ClearContextTurn,
|
||||
Session extends ClearContextSession<Turn>,
|
||||
>(
|
||||
sessionId: string,
|
||||
oldSession: Session,
|
||||
reset: ClearContextReset,
|
||||
host: ClearContextCoordinatorHost<Session, Turn>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const assertRestartActive = (session?: Session): void => {
|
||||
if (signal?.aborted || (session && host.currentSession(sessionId) !== session)) {
|
||||
throw new Error("Clear-context restart aborted");
|
||||
}
|
||||
};
|
||||
assertRestartActive();
|
||||
const turn = oldSession.activeTurn;
|
||||
if (!turn || turn.settled || host.currentSession(sessionId) !== oldSession) {
|
||||
throw new Error("Cannot clear context without an active ACP turn");
|
||||
}
|
||||
|
||||
const params = restartParams(oldSession);
|
||||
host.closeQueryStream(oldSession);
|
||||
|
||||
const freshSession = await host.restartSession(params, {
|
||||
publicSessionId: sessionId,
|
||||
permissionMode: reset.mode,
|
||||
});
|
||||
assertRestartActive(freshSession);
|
||||
|
||||
// Do not consume the reset or mutate the turn until a replacement exists.
|
||||
// A failed restart must remain distinguishable from a lost query transport.
|
||||
turn.carriedUsage = { ...oldSession.accumulatedUsage };
|
||||
turn.carriedModelUsage = { ...oldSession.accumulatedModelUsage };
|
||||
oldSession.pendingExitPlanContextReset = undefined;
|
||||
if (oldSession.fastModeEnabled !== freshSession.fastModeEnabled) {
|
||||
try {
|
||||
await host.applyFastMode(freshSession, oldSession.fastModeEnabled);
|
||||
} catch (error) {
|
||||
host.logError("Failed to restore Fast mode after clearing context:", error);
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
}
|
||||
|
||||
oldSession.activeTurn = null;
|
||||
oldSession.turnQueue = [];
|
||||
freshSession.turnQueue = [turn];
|
||||
freshSession.contextUsedTokens = 0;
|
||||
|
||||
try {
|
||||
await host.publishSessionState(sessionId, reset.mode, freshSession.configOptions);
|
||||
} catch (error) {
|
||||
host.logError("Failed to publish clear-context session state:", error);
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
|
||||
freshSession.input.push(host.continuationMessage(sessionId, reset.plan, turn.promptUuid));
|
||||
assertRestartActive(freshSession);
|
||||
host.ensureConsumer(freshSession, sessionId);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { AIR_CONTEXT_COMPACTION_KEY, withAirMeta } from "./air-extension.js";
|
||||
|
||||
export const CONTEXT_COMPACTION_META_VERSION = 1;
|
||||
|
||||
export type ContextCompactionTrigger = "manual" | "automatic";
|
||||
|
||||
export interface ContextCompactionMetadata {
|
||||
version: typeof CONTEXT_COMPACTION_META_VERSION;
|
||||
trigger?: ContextCompactionTrigger;
|
||||
preTokens?: number;
|
||||
postTokens?: number;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta` of a context compaction report:
|
||||
* `_meta.jetbrains.air.contextCompaction`, as `docs/air-extensions.md`
|
||||
* defines. The standard toolCallId and status fields own lifecycle identity
|
||||
* and phase; this extension carries only compaction-specific facts. Only AIR
|
||||
* gets it.
|
||||
*/
|
||||
export function createContextCompactionMeta(
|
||||
metadata: Omit<ContextCompactionMetadata, "version"> = {},
|
||||
): Record<string, unknown> {
|
||||
return withAirMeta(undefined, AIR_CONTEXT_COMPACTION_KEY, {
|
||||
version: CONTEXT_COMPACTION_META_VERSION,
|
||||
...metadata,
|
||||
} satisfies ContextCompactionMetadata);
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
import { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
ContextCompactionMetadata,
|
||||
createContextCompactionMeta,
|
||||
} from "./context-compaction-meta.js";
|
||||
import { compactionToolCall } from "./tool-calls/reporters/compaction.js";
|
||||
|
||||
type CompactionStatus = "completed" | "failed";
|
||||
type TerminalStatus = CompactionStatus | "cancelled";
|
||||
|
||||
/**
|
||||
* How compaction reaches the client.
|
||||
*
|
||||
* - `compaction_update`: the ACP session-compaction contract (ID-addressed
|
||||
* `compaction_update` / `compaction_summary_chunk`), used when the v1 client
|
||||
* advertises `clientCapabilities.session.compaction`.
|
||||
* - `tool_call`: the legacy synthetic "Compact conversation" tool call, kept
|
||||
* for clients that do not advertise the capability.
|
||||
*/
|
||||
export type CompactionPresentation = "tool_call" | "compaction_update";
|
||||
|
||||
type CompactionState = {
|
||||
/** Doubles as the toolCallId under the legacy presentation. */
|
||||
compactionId: string;
|
||||
terminalStatus?: TerminalStatus;
|
||||
heartbeatSent: boolean;
|
||||
/** The summary text that went out as chunks. */
|
||||
streamedSummary?: string;
|
||||
/** User-displayable summary reported by the PostCompact hook. While the
|
||||
* compaction is in progress it awaits the terminal update; on a `completed`
|
||||
* state it has been sent. */
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
type SendUpdate = (notification: SessionNotification) => Promise<void>;
|
||||
|
||||
export type ContextCompactionLifecycleOptions = {
|
||||
sessionId: string;
|
||||
presentation?: CompactionPresentation;
|
||||
/** Receives failures of the send that must not propagate: the turn-boundary
|
||||
* `cancelled` terminal. */
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
/** The client is AIR. Only AIR gets the compaction facts, under `_meta.jetbrains.air`. */
|
||||
airClient?: boolean;
|
||||
};
|
||||
|
||||
export function clientSupportsCompactionUpdates(capabilities?: ClientCapabilities | null): boolean {
|
||||
const compaction = capabilities?.session?.compaction;
|
||||
return typeof compaction === "object" && compaction !== null && !Array.isArray(compaction);
|
||||
}
|
||||
|
||||
/** Tags on their own line, as the model emits them; a summary that merely
|
||||
* quotes `<summary>` mid-sentence must not be truncated at the mention. */
|
||||
const SUMMARY_OPEN_LINE = /^[ \t]*<summary>[ \t]*\r?\n/gim;
|
||||
const SUMMARY_CLOSE_LINE = /^[ \t]*<\/summary>[ \t]*$/gim;
|
||||
/** The model's private reasoning block that precedes the summary; stripped
|
||||
* first so a `<summary>` it merely mentions cannot anchor the summary span,
|
||||
* and even when the summary tags themselves are missing. Own-line tags
|
||||
* anywhere; inline tags only as a leading block, since a summary body may
|
||||
* quote both tag names. */
|
||||
const ANALYSIS_BLOCKS = [
|
||||
/^[ \t]*<analysis>[ \t]*\r?\n[\s\S]*?\n[ \t]*<\/analysis>[ \t]*$/gim,
|
||||
/^\s*<analysis>[\s\S]*?<\/analysis>/i,
|
||||
];
|
||||
/** The framing Claude Code wraps around the retained summary when it persists
|
||||
* it as the post-compaction user message. */
|
||||
const CONTINUATION_PREAMBLE = /^\s*This session is being continued from a previous conversation/i;
|
||||
const PERSISTED_PREAMBLE =
|
||||
/^\s*This session is being continued from a previous conversation[^\n]*\n+Summary:[ \t]*\r?\n/;
|
||||
const PERSISTED_TRAILERS = [
|
||||
/\n+If you need specific details from before compaction[\s\S]*$/,
|
||||
/\n+(?:Please )?[Cc]ontinue the conversation from where it left (?:it )?off[\s\S]*$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Reduce Claude's compaction output to the user-displayable summary.
|
||||
*
|
||||
* The PostCompact hook reports the model's raw output, which wraps the
|
||||
* summary in `<summary>` tags (often preceded by an `<analysis>` block). The
|
||||
* persisted transcript instead frames the same summary with continuation
|
||||
* instructions for the model. Neither framing belongs in the ACP `summary`,
|
||||
* and a continuation prompt whose framing is not recognized yields no summary
|
||||
* at all rather than model-facing instructions.
|
||||
*/
|
||||
export function compactionSummaryText(raw: string): string | undefined {
|
||||
let text = raw;
|
||||
for (const block of ANALYSIS_BLOCKS) {
|
||||
text = text.replace(block, "");
|
||||
}
|
||||
const tagged = taggedSummary(text);
|
||||
if (tagged !== undefined) {
|
||||
text = tagged;
|
||||
} else if (CONTINUATION_PREAMBLE.test(text)) {
|
||||
const body = text.replace(PERSISTED_PREAMBLE, "");
|
||||
if (body === text) return undefined;
|
||||
text = body;
|
||||
for (const trailer of PERSISTED_TRAILERS) {
|
||||
text = text.replace(trailer, "");
|
||||
}
|
||||
}
|
||||
text = text.trim();
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function taggedSummary(raw: string): string | undefined {
|
||||
const close = [...raw.matchAll(SUMMARY_CLOSE_LINE)].at(-1);
|
||||
if (close?.index !== undefined) {
|
||||
const open = [...raw.slice(0, close.index).matchAll(SUMMARY_OPEN_LINE)].at(-1);
|
||||
if (open?.index !== undefined) {
|
||||
return raw.slice(open.index + open[0].length, close.index);
|
||||
}
|
||||
}
|
||||
// Inline tags: the widest span, so a quoted `</summary>` inside the body
|
||||
// does not end it early. Plain index scans keep an unterminated tag linear.
|
||||
const openInline = raw.search(/<summary>/i);
|
||||
const closeInline = raw.toLowerCase().lastIndexOf("</summary>");
|
||||
if (openInline >= 0 && closeInline > openInline) {
|
||||
return raw.slice(openInline + "<summary>".length, closeInline);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True for the post-compaction user message Claude Code persists (and
|
||||
* `getSessionMessages` returns) with the summary of the dropped history. */
|
||||
export function isCompactSummaryMessage(message: unknown): boolean {
|
||||
return (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
(message as { type?: unknown }).type === "user" &&
|
||||
(message as { isCompactSummary?: unknown }).isCompactSummary === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates Claude's compaction signals into one idempotent ACP lifecycle.
|
||||
*
|
||||
* The SDK can duplicate terminal compact_result messages and can omit the
|
||||
* opening status on replay. State therefore lives until the owning turn's
|
||||
* result (or abort), while a new compacting status after a terminal outcome
|
||||
* starts a fresh lifecycle.
|
||||
*/
|
||||
export class ContextCompactionLifecycle {
|
||||
private activeCompaction: CompactionState | undefined;
|
||||
private outputDelivered = false;
|
||||
private duplicateErrorOutput: string | undefined;
|
||||
/** After interruption, uncorrelated terminal frames and hooks may still
|
||||
* arrive from the abandoned work, including an opening we never consumed.
|
||||
* Wait for a known new turn before accepting them again. */
|
||||
private interrupted = false;
|
||||
/** Summary reported by PostCompact before the opening status was consumed.
|
||||
* Deliberately survives `reset()`: the CLI awaits the hook before emitting
|
||||
* the compaction's terminal frames, so a pending summary always belongs to
|
||||
* a compaction whose frames are still ahead of the consumer — possibly
|
||||
* queued behind a previous turn's result. */
|
||||
private pendingSummary: string | undefined;
|
||||
private readonly sessionId: string;
|
||||
readonly presentation: CompactionPresentation;
|
||||
private readonly logError: (message: string, error: unknown) => void;
|
||||
private readonly airClient: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly sendUpdate: SendUpdate,
|
||||
options: ContextCompactionLifecycleOptions,
|
||||
) {
|
||||
this.sessionId = options.sessionId;
|
||||
this.presentation = options.presentation ?? "tool_call";
|
||||
this.logError = options.logError ?? (() => {});
|
||||
this.airClient = options.airClient ?? false;
|
||||
}
|
||||
|
||||
get hasDeliveredOutput(): boolean {
|
||||
return this.outputDelivered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the lifecycle at a turn boundary (result, cancel, idle-abandon).
|
||||
*
|
||||
* A `compaction_update` entity still `in_progress` here never received its
|
||||
* terminal signal from the runtime and gets its one terminal status,
|
||||
* `cancelled`. The legacy tool call is deliberately left as it was (an
|
||||
* `in_progress` call): ACP `ToolCallStatus` has no cancelled state and that
|
||||
* presentation's behavior predates this lifecycle. Never throws — a failed
|
||||
* send is logged, so callers can settle the turn unconditionally.
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
const state = this.activeCompaction;
|
||||
this.activeCompaction = undefined;
|
||||
this.outputDelivered = false;
|
||||
this.duplicateErrorOutput = undefined;
|
||||
if (!state || state.terminalStatus || this.presentation !== "compaction_update") return;
|
||||
state.terminalStatus = "cancelled";
|
||||
await this.sendQuietly(
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: state.compactionId,
|
||||
status: "cancelled",
|
||||
},
|
||||
"cancelled compaction",
|
||||
);
|
||||
}
|
||||
|
||||
/** Abandon the current work before settling its prompt or tearing down the
|
||||
* stream. Unlike a normal result boundary, its pending hooks are not safe
|
||||
* to carry into the next compaction. A hook has no command ID, so even an
|
||||
* early hook for the next compaction must be omitted until the stream gives
|
||||
* us a new live turn boundary. Idempotent and best-effort. */
|
||||
async interrupt(): Promise<void> {
|
||||
if (this.presentation === "compaction_update") {
|
||||
this.interrupted = true;
|
||||
this.pendingSummary = undefined;
|
||||
}
|
||||
await this.reset();
|
||||
}
|
||||
|
||||
/** A live command's dispatch/echo proves the interrupted command's tail
|
||||
* has drained in the SDK's FIFO stream. Do not reset normal lifecycle
|
||||
* state: compaction may already have started before the user echo. */
|
||||
resume(): void {
|
||||
this.interrupted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude also emits a failed manual compaction's error as local-command
|
||||
* stdout. Consume that one duplicate after the lifecycle carried it,
|
||||
* without hiding unrelated command output.
|
||||
*/
|
||||
consumeDuplicateErrorOutput(content: string): boolean {
|
||||
if (
|
||||
this.duplicateErrorOutput === undefined ||
|
||||
content.trim() !== this.duplicateErrorOutput.trim()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.duplicateErrorOutput = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
async start(compactionId: string): Promise<void> {
|
||||
if (this.interrupted) return;
|
||||
if (this.activeCompaction && !this.activeCompaction.terminalStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.open(compactionId);
|
||||
if (this.presentation === "compaction_update") {
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "in_progress",
|
||||
...(this.airClient ? { _meta: createContextCompactionMeta() } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.send(compactionToolCall.started(compactionId, this.airClient));
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming progress from the API's compaction content block.
|
||||
*
|
||||
* Under `compaction_update` the block's text is the retained summary and
|
||||
* streams as `compaction_summary_chunk`s — but only into an entity the CLI
|
||||
* announced with its `compacting` status. The block alone carries no
|
||||
* terminal signal, so opening an entity from it would leave nothing but the
|
||||
* turn-boundary `cancelled` to close a compaction that succeeded. The legacy
|
||||
* tool call keeps its historical behavior: open on first sight, re-assert
|
||||
* `in_progress` once.
|
||||
*/
|
||||
async heartbeat(fallbackId: string, summaryChunk?: string): Promise<void> {
|
||||
if (this.presentation === "compaction_update") {
|
||||
const state = this.activeCompaction;
|
||||
if (!state || state.terminalStatus || !summaryChunk) return;
|
||||
state.streamedSummary = (state.streamedSummary ?? "") + summaryChunk;
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_summary_chunk",
|
||||
compactionId: state.compactionId,
|
||||
content: { type: "text", text: summaryChunk },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeCompaction) await this.start(fallbackId);
|
||||
const state = this.activeCompaction;
|
||||
if (!state || state.terminalStatus || state.heartbeatSent) return;
|
||||
state.heartbeatSent = true;
|
||||
await this.send(compactionToolCall.inProgress(state.compactionId, this.airClient));
|
||||
}
|
||||
|
||||
/**
|
||||
* The PostCompact hook's summary. The CLI awaits the hook before emitting
|
||||
* the compaction's terminal frames, so the summary lands while its
|
||||
* compaction is `in_progress` and rides on the terminal update. Anything
|
||||
* else — no entity yet, or the entity is a previous, already-terminal
|
||||
* compaction of the same turn whose status frame the consumer is still
|
||||
* catching up to — belongs to the compaction whose frames are ahead of the
|
||||
* consumer, and waits for it. Only the `compaction_update` presentation
|
||||
* carries the summary; the legacy tool call never exposed it. Never throws:
|
||||
* it runs detached from a hook callback. Returns whether a summary was
|
||||
* retained.
|
||||
*/
|
||||
recordSummary(rawSummary: unknown): boolean {
|
||||
if (this.interrupted) return false;
|
||||
if (this.presentation !== "compaction_update" || typeof rawSummary !== "string") return false;
|
||||
const summary = compactionSummaryText(rawSummary);
|
||||
if (!summary) return false;
|
||||
|
||||
const state = this.activeCompaction;
|
||||
if (state && !state.terminalStatus) {
|
||||
state.summary = summary;
|
||||
} else {
|
||||
this.pendingSummary = summary;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async finish(
|
||||
compactionId: string,
|
||||
status: CompactionStatus,
|
||||
metadata: Omit<ContextCompactionMetadata, "version"> = {},
|
||||
enrichTerminal = false,
|
||||
): Promise<void> {
|
||||
if (this.interrupted) return;
|
||||
// The opening status can be missed (replay, or a terminal-only runtime):
|
||||
// the first update for the ID is then already terminal.
|
||||
const opened = this.activeCompaction === undefined;
|
||||
const firstTerminal = !this.activeCompaction?.terminalStatus;
|
||||
const state = this.activeCompaction ?? this.open(compactionId, status);
|
||||
if (firstTerminal) {
|
||||
state.terminalStatus = status;
|
||||
} else if (!enrichTerminal) {
|
||||
return;
|
||||
}
|
||||
if (status === "failed" && metadata.error) {
|
||||
this.duplicateErrorOutput = metadata.error;
|
||||
}
|
||||
const terminalStatus = state.terminalStatus ?? status;
|
||||
const hasMetadata = Object.keys(metadata).length > 0;
|
||||
|
||||
if (this.presentation === "compaction_update") {
|
||||
// A summary that went out as chunks is not sent again in full. The
|
||||
// chunks are the raw API text and the summary is the cleaned hook text,
|
||||
// so the summary is left out only when both hold the same text.
|
||||
const summaryStreamed = state.streamedSummary === state.summary;
|
||||
const summary =
|
||||
firstTerminal && terminalStatus === "completed" && !summaryStreamed
|
||||
? state.summary
|
||||
: undefined;
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: state.compactionId,
|
||||
status: terminalStatus,
|
||||
...(summary !== undefined ? { summary: [{ type: "text", text: summary }] } : {}),
|
||||
...(terminalStatus === "failed" && metadata.error ? { error: metadata.error } : {}),
|
||||
// `_meta` is a replace-patch: seed it with the first terminal, then
|
||||
// only re-send it when the boundary adds facts, so a status-only
|
||||
// duplicate can't wipe the token counts.
|
||||
// The standard `error` field carries the error, so `_meta` does not.
|
||||
// Only AIR gets the compaction facts.
|
||||
...(this.airClient && (firstTerminal || hasMetadata)
|
||||
? { _meta: createContextCompactionMeta(withoutError(metadata)) }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.send(
|
||||
compactionToolCall.finished(
|
||||
state.compactionId,
|
||||
opened || firstTerminal ? status : undefined,
|
||||
metadata,
|
||||
opened,
|
||||
this.airClient,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Make `compactionId` the active entity, moving a pending hook summary onto
|
||||
* it, and mark compaction output as delivered for the owning turn. */
|
||||
private open(compactionId: string, terminalStatus?: TerminalStatus): CompactionState {
|
||||
const state: CompactionState = {
|
||||
compactionId,
|
||||
heartbeatSent: false,
|
||||
...(terminalStatus ? { terminalStatus } : {}),
|
||||
...(this.pendingSummary !== undefined ? { summary: this.pendingSummary } : {}),
|
||||
};
|
||||
this.pendingSummary = undefined;
|
||||
this.activeCompaction = state;
|
||||
this.outputDelivered = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
private send(update: SessionNotification["update"]): Promise<void> {
|
||||
return this.sendUpdate({ sessionId: this.sessionId, update });
|
||||
}
|
||||
|
||||
private async sendQuietly(update: SessionNotification["update"], what: string): Promise<void> {
|
||||
try {
|
||||
await this.send(update);
|
||||
} catch (error) {
|
||||
this.logError(`Failed to send the ${what} update for session ${this.sessionId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withoutError({
|
||||
error: _error,
|
||||
...facts
|
||||
}: Omit<ContextCompactionMetadata, "version">): Omit<ContextCompactionMetadata, "version"> {
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function contextCompactionMetadataFromBoundary(compactMetadata: {
|
||||
trigger: "manual" | "auto";
|
||||
pre_tokens: number;
|
||||
post_tokens?: number;
|
||||
duration_ms?: number;
|
||||
}): Omit<ContextCompactionMetadata, "version"> {
|
||||
return {
|
||||
trigger: compactMetadata.trigger === "auto" ? "automatic" : "manual",
|
||||
preTokens: compactMetadata.pre_tokens,
|
||||
...(compactMetadata.post_tokens !== undefined
|
||||
? { postTokens: compactMetadata.post_tokens }
|
||||
: {}),
|
||||
...(compactMetadata.duration_ms !== undefined
|
||||
? { durationMs: compactMetadata.duration_ms }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
748
src/diff.ts
748
src/diff.ts
@@ -1,748 +0,0 @@
|
||||
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { structuredPatch } from "diff";
|
||||
import { constants } from "node:fs";
|
||||
import { type FileHandle, open } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { AIR_DIFF_PATCH_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
import { normalizeWriteInput } from "./tool-calls/reporters/file-edit.js";
|
||||
|
||||
/**
|
||||
* The largest file, in bytes, that the adapter turns into a git patch.
|
||||
*
|
||||
* A larger file, a larger new text, or an Edit whose result can be larger
|
||||
* gets no patch. The tool call then keeps its standard ACP content. The limit
|
||||
* bounds the file read, the replacement, and the line diff that run while
|
||||
* Claude waits for an approval.
|
||||
*/
|
||||
export const MAX_PATCH_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The wall-clock budget, in milliseconds, of one line diff for a patch.
|
||||
*
|
||||
* The diff runs synchronously, so the budget is also the longest block of the
|
||||
* event loop.
|
||||
*/
|
||||
const PATCH_DIFF_TIMEOUT_MS = 100;
|
||||
|
||||
/** The number of context lines around a change in a hunk. */
|
||||
const PATCH_CONTEXT_LINES = 3;
|
||||
|
||||
/** Git reads this many leading bytes to decide that a file is binary. */
|
||||
const BINARY_SNIFF_BYTES = 8000;
|
||||
|
||||
const NO_NEWLINE_MARKER = "\\ No newline at end of file";
|
||||
|
||||
/**
|
||||
* One unified-diff hunk in the `diff` package convention.
|
||||
*
|
||||
* A side with zero lines stores the number of the line after the change as its
|
||||
* start. {@link hunkHeader} converts that start to the git convention.
|
||||
*/
|
||||
interface PatchHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface DiffToolResponse {
|
||||
filePath?: string;
|
||||
structuredPatch?: PatchHunk[];
|
||||
/** FileWriteOutput only (FileEditOutput carries no `type`): whether the
|
||||
* write created the file or overwrote an existing one. */
|
||||
type?: "create" | "update";
|
||||
/** FileWriteOutput only: the content that was written. */
|
||||
content?: string;
|
||||
/** The pre-change content. It is null on a Write create, or on a Write
|
||||
* update whose previous content was too large to include. An Edit that
|
||||
* creates a file reports an empty string, as for an existing empty file. */
|
||||
originalFile?: string | null;
|
||||
/** FileEditOutput only: the text that the Edit replaced. It is empty when
|
||||
* the Edit created the file or filled an existing empty file. */
|
||||
oldString?: string;
|
||||
}
|
||||
|
||||
interface EditPreviewInput {
|
||||
file_path?: unknown;
|
||||
old_string?: unknown;
|
||||
new_string?: unknown;
|
||||
replace_all?: unknown;
|
||||
}
|
||||
|
||||
/** The kind of file change that a git patch describes. */
|
||||
type FileChange = "create" | "update";
|
||||
|
||||
/** A file change and the hunks of its git patch. */
|
||||
interface FilePatch {
|
||||
change: FileChange;
|
||||
hunks: PatchHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff that carries an exact patch: AIR's `diffPatch` extension of a v1
|
||||
* diff, or an ACP v2 diff.
|
||||
*/
|
||||
export type PatchForm = "air" | "v2";
|
||||
|
||||
/**
|
||||
* How a git patch names its file. `git` is git's own form: the leading slash
|
||||
* dropped and the `a/` and `b/` prefixes added. `absolute` is the form of an
|
||||
* ACP v2 `git_patch`, whose paths must be absolute: the path itself.
|
||||
*/
|
||||
type GitPatchPaths = "git" | "absolute";
|
||||
|
||||
/** An ACP v2 diff, as tool call content. */
|
||||
type V2DiffContent = v2.Diff & { type: "diff" };
|
||||
|
||||
/**
|
||||
* Builds the exact patch shown before Claude runs an Edit or Write tool.
|
||||
*
|
||||
* Returns undefined when the adapter cannot predict the file change exactly.
|
||||
* The caller then keeps the standard tool-call content. The adapter declines
|
||||
* a file that is missing for an Edit, too large, binary, or that has CR line
|
||||
* endings. Claude converts the line endings of an edited file, so an in-memory
|
||||
* replacement would not match the bytes that Claude writes. It also declines
|
||||
* an `old_string` that does not match exactly once, because Claude then
|
||||
* normalizes quotes or fails.
|
||||
*
|
||||
* A Write always gets content that shows the change: the patch, the standard
|
||||
* diff (in v2, the change alone) of a text that cannot have an exact patch, or
|
||||
* a notice that the Write overwrites a file that the adapter cannot show.
|
||||
*
|
||||
* The preview uses the tool input as it is. Claude can still remove the
|
||||
* trailing whitespace of a line before it writes. The PostToolUse hook then
|
||||
* sends the patch of the written file.
|
||||
*/
|
||||
export async function previewPatchContent(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
cwd?: string,
|
||||
form: PatchForm = "air",
|
||||
): Promise<ToolCallContent[] | undefined> {
|
||||
if (toolName === "Edit") {
|
||||
const edit = input as EditPreviewInput;
|
||||
if (
|
||||
typeof edit.file_path !== "string" ||
|
||||
typeof edit.old_string !== "string" ||
|
||||
typeof edit.new_string !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const oldString = edit.old_string;
|
||||
const filePath = resolveToolPath(edit.file_path, cwd);
|
||||
const oldText = await readPatchSource(filePath);
|
||||
if (oldText === undefined) return undefined;
|
||||
const newString = edit.new_string;
|
||||
if (oldString === newString || !isPatchableText(newString)) return undefined;
|
||||
if (oldString.length === 0) {
|
||||
// An empty old_string creates the file, or fills an existing file that
|
||||
// holds only whitespace. `trim` also removes a byte order mark, whose
|
||||
// handling the adapter does not predict, so such a file is declined.
|
||||
if (oldText !== null && (oldText.trim() !== "" || oldText.includes("\uFEFF"))) {
|
||||
return undefined;
|
||||
}
|
||||
return exactPatch(filePath, oldText, newString, form);
|
||||
}
|
||||
if (oldText === null) return undefined;
|
||||
const occurrences = oldText.split(oldString).length - 1;
|
||||
if (occurrences === 0 || (edit.replace_all !== true && occurrences !== 1)) return undefined;
|
||||
if (replacedTextBytesBound(oldText, oldString, newString, occurrences) > MAX_PATCH_FILE_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
const newText = replacedText(oldText, oldString, newString, edit.replace_all === true);
|
||||
return exactPatch(filePath, oldText, newText, form);
|
||||
}
|
||||
|
||||
if (toolName === "Write") {
|
||||
const write = normalizeWriteInput(input);
|
||||
if (typeof write?.file_path !== "string" || typeof write.content !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
// The Write tool call shows no diff, so the approval is the only place that
|
||||
// shows what the Write changes.
|
||||
const content = write.content;
|
||||
const filePath = resolveToolPath(write.file_path, cwd);
|
||||
const oldText = await readPatchSource(filePath);
|
||||
if (oldText === undefined) {
|
||||
return [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Overwrites the existing file \`${write.file_path}\`. The adapter cannot show its current content.`,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (oldText === content) return undefined;
|
||||
const standard: ToolCallContent[] = [
|
||||
form === "v2"
|
||||
? v2DiffContent(filePath, oldText === null ? "create" : "update")
|
||||
: { type: "diff", path: write.file_path, oldText, newText: content },
|
||||
];
|
||||
if (!isPatchableText(content)) return standard;
|
||||
// A dense change can exceed the diff budget. The approval then shows the standard diff.
|
||||
return exactPatch(filePath, oldText, content, form) ?? standard;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* An upper bound of the UTF-8 size, in bytes, of {@link replacedText}.
|
||||
*
|
||||
* The caller checks the bound before it builds the text. A `replace_all` of
|
||||
* many short matches with a long `newString` can otherwise build a text of
|
||||
* gigabytes before the timed line diff starts.
|
||||
*/
|
||||
function replacedTextBytesBound(
|
||||
fileText: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
occurrences: number,
|
||||
): number {
|
||||
const growth = Buffer.byteLength(newString, "utf8") - Buffer.byteLength(oldString, "utf8");
|
||||
return Buffer.byteLength(fileText, "utf8") + occurrences * Math.max(0, growth);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file text after Claude replaces `oldString` with `newString`.
|
||||
*
|
||||
* An empty `newString` deletes a line: when `oldString` does not end with a
|
||||
* line break and the file holds `oldString` and a line break, Claude also
|
||||
* removes that line break. With `replaceAll`, it then replaces only the
|
||||
* occurrences that a line break follows.
|
||||
*/
|
||||
function replacedText(
|
||||
fileText: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
): string {
|
||||
const target =
|
||||
newString === "" && !oldString.endsWith("\n") && fileText.includes(`${oldString}\n`)
|
||||
? `${oldString}\n`
|
||||
: oldString;
|
||||
return replaceAll
|
||||
? fileText.split(target).join(newString)
|
||||
: fileText.replace(target, () => newString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the git patch for a finished Edit or Write from the file on disk.
|
||||
*
|
||||
* The Claude SDK `structuredPatch` is display data: Claude converts leading
|
||||
* tabs to spaces and CRLF line endings to LF before it computes the hunks. A
|
||||
* patch built from those hunks does not match the file. This function diffs
|
||||
* `originalFile` against the written file instead. It returns undefined when
|
||||
* it cannot build an exact patch, and the caller then sends the standard diff.
|
||||
* A written file that contains a CR or a byte order mark is declined, because
|
||||
* Claude removed those bytes from `originalFile`.
|
||||
*/
|
||||
export async function patchUpdateFromDiffToolResponse(
|
||||
toolResponse: unknown,
|
||||
form: PatchForm = "air",
|
||||
): Promise<{ content: ToolCallContent[]; locations: ToolCallLocation[] } | undefined> {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return undefined;
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
if (typeof response.filePath !== "string") return undefined;
|
||||
// An Edit with an empty old_string reports "" as the original of a created
|
||||
// file and of an existing empty file. The response does not tell the two
|
||||
// apart, so the caller keeps the standard diff.
|
||||
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
|
||||
return undefined;
|
||||
}
|
||||
// A Write whose content is the previous text changed nothing. The file on
|
||||
// disk can hold a later change, and that change is not the change of this Write.
|
||||
if (
|
||||
response.type === "update" &&
|
||||
typeof response.content === "string" &&
|
||||
response.originalFile === response.content
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const oldText =
|
||||
response.type === "create"
|
||||
? null
|
||||
: typeof response.originalFile === "string"
|
||||
? response.originalFile
|
||||
: undefined;
|
||||
if (oldText === undefined) return undefined;
|
||||
// The same size limit as the new side: a larger text gets the standard diff.
|
||||
if (oldText !== null && Buffer.byteLength(oldText, "utf8") > MAX_PATCH_FILE_BYTES)
|
||||
return undefined;
|
||||
const newText = await readPatchSource(response.filePath);
|
||||
if (typeof newText !== "string" || newText.startsWith("\uFEFF")) return undefined;
|
||||
if (response.type === "create" && newText !== response.content) return undefined;
|
||||
const patch = filePatch(oldText, newText);
|
||||
if (!patch) return undefined;
|
||||
return {
|
||||
content: [patchContent(response.filePath, patch, form)],
|
||||
// A created file keeps the location of its Write tool call.
|
||||
locations:
|
||||
oldText === null
|
||||
? [{ path: response.filePath }]
|
||||
: patch.hunks.map(({ newStart }) => ({ path: response.filePath!, line: newStart })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds standard ACP diff content from the structured toolResponse provided
|
||||
* by the PostToolUse hook for diff-producing tools (Edit, Write). Unlike
|
||||
* parsing the plain unified diff string, this uses the pre-parsed
|
||||
* structuredPatch which supports multiple replacement sites (replaceAll) and
|
||||
* always includes context lines for better readability.
|
||||
*/
|
||||
export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
} {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
|
||||
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
for (const { lines, newStart } of response.structuredPatch) {
|
||||
const oldText: string[] = [];
|
||||
const newText: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("-")) {
|
||||
oldText.push(line.slice(1));
|
||||
} else if (line.startsWith("+")) {
|
||||
newText.push(line.slice(1));
|
||||
} else if (line === NO_NEWLINE_MARKER) {
|
||||
continue;
|
||||
} else {
|
||||
oldText.push(line.slice(1));
|
||||
newText.push(line.slice(1));
|
||||
}
|
||||
}
|
||||
if (oldText.length > 0 || newText.length > 0) {
|
||||
locations.push({ path: response.filePath, line: newStart });
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: response.filePath,
|
||||
oldText: oldText.join("\n") || null,
|
||||
newText: newText.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A Write `update` can arrive with an empty structuredPatch — nothing
|
||||
// changed, the diff timed out, or the previous content was too large to
|
||||
// diff (originalFile null; SDK 0.3.252 documents the lane). Returning `{}`
|
||||
// would leave Write's optimistic tool_use-time content standing, and that
|
||||
// was built with `oldText: null` — "creation" semantics — so an overwrite
|
||||
// of a large existing file would render as creating it. Emit a truthful
|
||||
// replacement instead. Gated on `type` so Edit (whose output carries no
|
||||
// `type` and whose optimistic old/new diff is already truthful) keeps the
|
||||
// empty-return behavior. A `create` needs nothing here: the
|
||||
// tool_use-time diff shows the created file, and a client with diffPatch
|
||||
// gets it from the Write reporter.
|
||||
if (content.length === 0 && response.type === "update" && typeof response.content === "string") {
|
||||
locations.push({ path: response.filePath });
|
||||
content.push(
|
||||
typeof response.originalFile === "string"
|
||||
? {
|
||||
type: "diff",
|
||||
path: response.filePath,
|
||||
oldText: response.originalFile,
|
||||
newText: response.content,
|
||||
}
|
||||
: {
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Updated \`${response.filePath}\` (previous content too large to diff)`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {};
|
||||
if (content.length > 0) result.content = content;
|
||||
if (locations.length > 0) result.locations = locations;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a finished Edit or Write: the exact patch of
|
||||
* {@link patchUpdateFromDiffToolResponse} when it can be built, else the
|
||||
* change with a patch from what the toolResponse holds.
|
||||
*
|
||||
* The fallback patch of an update comes from the `structuredPatch`, which is
|
||||
* display data (see {@link patchUpdateFromDiffToolResponse}). That is enough
|
||||
* for a v2 patch, which is renderable text, not a patch to apply. An update
|
||||
* without hunks, such as one whose previous content was too large to diff,
|
||||
* gets the change alone. An Edit with an empty `old_string` reports the same
|
||||
* response for a created file and a filled empty one, so it gets no diff.
|
||||
*/
|
||||
export async function v2UpdateFromDiffToolResponse(toolResponse: unknown): Promise<{
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
}> {
|
||||
const exact = await patchUpdateFromDiffToolResponse(toolResponse, "v2");
|
||||
if (exact) return exact;
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
const filePath = response.filePath;
|
||||
if (typeof filePath !== "string") return {};
|
||||
if (response.type === "create") {
|
||||
const hunk =
|
||||
typeof response.content === "string" && isPatchableText(response.content)
|
||||
? wholeFileHunk(response.content)
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "create", hunk && [hunk])],
|
||||
locations: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
|
||||
return {};
|
||||
}
|
||||
// A Write whose content is the previous text changed nothing.
|
||||
if (response.type === "update" && response.content === response.originalFile) return {};
|
||||
const hunks =
|
||||
Array.isArray(response.structuredPatch) && response.structuredPatch.length > 0
|
||||
? response.structuredPatch
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "update", hunks)],
|
||||
locations: hunks
|
||||
? hunks.map(({ newStart }) => ({ path: filePath, line: newStart }))
|
||||
: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of one git patch for `filePath`.
|
||||
*
|
||||
* The headers follow `git diff`: the path is quoted when git would quote it,
|
||||
* and a created file gets its mode line and a `/dev/null` side. With `git`
|
||||
* paths, the path loses its leading slash and gets the `a/` and `b/` prefixes.
|
||||
*/
|
||||
export function gitPatchText(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks: PatchHunk[],
|
||||
paths: GitPatchPaths = "git",
|
||||
): string {
|
||||
return [
|
||||
...gitPatchHeader(filePath, change, paths),
|
||||
...hunks.flatMap((hunk) => [hunkHeader(hunk), ...hunk.lines]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function gitPatchHeader(filePath: string, change: FileChange, paths: GitPatchPaths): string[] {
|
||||
// A Windows path gets forward slashes so the header names the same file on
|
||||
// every platform.
|
||||
const slashed = filePath.replaceAll("\\", "/");
|
||||
// git drops one leading slash of an absolute path.
|
||||
const name = paths === "git" ? slashed.replace(/^\/+/u, "") : slashed;
|
||||
const oldName = quoteGitPath(paths === "git" ? "a/" : "", name);
|
||||
const newName = quoteGitPath(paths === "git" ? "b/" : "", name);
|
||||
// git ends a ---/+++ name that contains a space with a tab, for GNU patch.
|
||||
const tab = name.includes(" ") ? "\t" : "";
|
||||
return [
|
||||
`diff --git ${oldName} ${newName}`,
|
||||
...(change === "create" ? ["new file mode 100644"] : []),
|
||||
`--- ${change === "create" ? "/dev/null" : `${oldName}${tab}`}`,
|
||||
`+++ ${newName}${tab}`,
|
||||
];
|
||||
}
|
||||
|
||||
const GIT_PATH_ESCAPES: Record<number, string> = {
|
||||
0x07: "a",
|
||||
0x08: "b",
|
||||
0x09: "t",
|
||||
0x0a: "n",
|
||||
0x0b: "v",
|
||||
0x0c: "f",
|
||||
0x0d: "r",
|
||||
0x22: '"',
|
||||
0x5c: "\\",
|
||||
};
|
||||
|
||||
/**
|
||||
* Quotes `prefix + name` like git with the default `core.quotePath`.
|
||||
*
|
||||
* A double quote, a backslash, a control byte, or a non-ASCII byte makes git
|
||||
* put the whole name in double quotes. git then writes a C escape or a
|
||||
* three-digit octal escape for each such UTF-8 byte.
|
||||
*/
|
||||
function quoteGitPath(prefix: string, name: string): string {
|
||||
const full = `${prefix}${name}`;
|
||||
let quoted = "";
|
||||
let needsQuotes = false;
|
||||
for (const byte of Buffer.from(full, "utf8")) {
|
||||
const escape = GIT_PATH_ESCAPES[byte];
|
||||
if (escape !== undefined) {
|
||||
quoted += `\\${escape}`;
|
||||
needsQuotes = true;
|
||||
} else if (byte < 0x20 || byte >= 0x7f) {
|
||||
quoted += `\\${byte.toString(8).padStart(3, "0")}`;
|
||||
needsQuotes = true;
|
||||
} else {
|
||||
quoted += String.fromCharCode(byte);
|
||||
}
|
||||
}
|
||||
return needsQuotes ? `"${quoted}"` : full;
|
||||
}
|
||||
|
||||
function hunkHeader({ oldStart, oldLines, newStart, newLines }: PatchHunk): string {
|
||||
return `@@ -${hunkRange(oldStart, oldLines)} +${hunkRange(newStart, newLines)} @@`;
|
||||
}
|
||||
|
||||
/** A git hunk range. A side with zero lines names the line before the change. */
|
||||
function hunkRange(start: number, count: number): string {
|
||||
if (count === 0) return `${Math.max(0, start - 1)},0`;
|
||||
return count === 1 ? String(start) : `${start},${count}`;
|
||||
}
|
||||
|
||||
/** The hunk that adds every line of `text` to an empty file. */
|
||||
function wholeFileHunk(text: string): PatchHunk | undefined {
|
||||
if (text.length === 0) return undefined;
|
||||
const terminated = text.endsWith("\n");
|
||||
const lines = (terminated ? text.slice(0, -1) : text).split("\n");
|
||||
return {
|
||||
oldStart: 1,
|
||||
oldLines: 0,
|
||||
newStart: 1,
|
||||
newLines: lines.length,
|
||||
lines: [...lines.map((line) => `+${line}`), ...(terminated ? [] : [NO_NEWLINE_MARKER])],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The line-diff hunks between two texts. Returns undefined when the diff runs
|
||||
* out of its time budget.
|
||||
*/
|
||||
function diffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
|
||||
if (oldText === null) {
|
||||
const hunk = wholeFileHunk(newText);
|
||||
return hunk ? [hunk] : [];
|
||||
}
|
||||
const oldLines = textLines(oldText);
|
||||
const newLines = textLines(newText);
|
||||
// The diff gets only the changed middle and its context lines. An edit
|
||||
// usually changes a small part of a large file.
|
||||
const common = Math.min(oldLines.length, newLines.length);
|
||||
let prefix = 0;
|
||||
while (prefix < common && oldLines[prefix] === newLines[prefix]) prefix++;
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < common - prefix &&
|
||||
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
||||
) {
|
||||
suffix++;
|
||||
}
|
||||
const skipped = Math.max(0, prefix - PATCH_CONTEXT_LINES);
|
||||
const skippedEnd = Math.max(0, suffix - PATCH_CONTEXT_LINES);
|
||||
const patch = structuredPatch(
|
||||
"",
|
||||
"",
|
||||
oldLines.slice(skipped, oldLines.length - skippedEnd).join(""),
|
||||
newLines.slice(skipped, newLines.length - skippedEnd).join(""),
|
||||
"",
|
||||
"",
|
||||
{ context: PATCH_CONTEXT_LINES, timeout: PATCH_DIFF_TIMEOUT_MS },
|
||||
);
|
||||
return patch?.hunks.map((hunk) => ({
|
||||
...hunk,
|
||||
oldStart: hunk.oldStart + skipped,
|
||||
newStart: hunk.newStart + skipped,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The lines of a text, each with its line break. The last line can have none. */
|
||||
function textLines(text: string): string[] {
|
||||
return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch of a change from `oldText`, null for a missing file, to
|
||||
* `newText`. Returns undefined when nothing changed or the diff runs out of
|
||||
* its time budget.
|
||||
*/
|
||||
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
|
||||
const hunks = memoizedDiffHunks(oldText, newText);
|
||||
if (!hunks || hunks.length === 0) return undefined;
|
||||
return { change: oldText === null ? "create" : "update", hunks };
|
||||
}
|
||||
|
||||
/** The number of recent line diffs that {@link memoizedDiffHunks} keeps. */
|
||||
const DIFF_MEMO_SIZE = 4;
|
||||
|
||||
/** Recent line diffs not read again yet, the most recent last. A text of at
|
||||
* most {@link MAX_PATCH_FILE_BYTES} is held until it is read again or later
|
||||
* diffs push it out. */
|
||||
const diffMemo: { oldText: string | null; newText: string; hunks: PatchHunk[] }[] = [];
|
||||
|
||||
/**
|
||||
* {@link diffHunks} of a recent pair of texts again, without a second diff.
|
||||
*
|
||||
* The approval of an Edit or Write diffs the file against the predicted text,
|
||||
* and the PostToolUse hook diffs the same texts once Claude wrote them. On a
|
||||
* large file each diff can block the event loop for its whole time budget.
|
||||
* Only a diff that completed is remembered: one that ran out of its budget
|
||||
* says nothing about the texts, so a later call diffs them again. The hunks
|
||||
* are shared, and no caller changes them.
|
||||
*/
|
||||
function memoizedDiffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
|
||||
const index = diffMemo.findIndex(
|
||||
(entry) => entry.oldText === oldText && entry.newText === newText,
|
||||
);
|
||||
if (index >= 0) {
|
||||
// The hook is normally the last reader of a pair, so the entry goes: the
|
||||
// memo does not hold two large texts longer than needed.
|
||||
const [entry] = diffMemo.splice(index, 1);
|
||||
return entry.hunks;
|
||||
}
|
||||
const hunks = diffHunks(oldText, newText);
|
||||
if (hunks) {
|
||||
diffMemo.push({ oldText, newText, hunks });
|
||||
if (diffMemo.length > DIFF_MEMO_SIZE) diffMemo.shift();
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** Forget the remembered line diffs. For tests. */
|
||||
export function clearDiffMemo(): void {
|
||||
diffMemo.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file as patch input.
|
||||
*
|
||||
* Returns null when the file does not exist. Returns undefined when the file
|
||||
* cannot be read, is not a regular file, is larger than
|
||||
* {@link MAX_PATCH_FILE_BYTES}, is binary, is not valid UTF-8, or contains a
|
||||
* CR.
|
||||
*/
|
||||
async function readPatchSource(filePath: string): Promise<string | null | undefined> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
// O_NONBLOCK keeps the open of a FIFO from waiting for a writer. It does not change a regular file.
|
||||
handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
return isMissingFileError(error) ? null : undefined;
|
||||
}
|
||||
try {
|
||||
// The checks and the read use one open file, so a swap of the path between them has no effect.
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.size > MAX_PATCH_FILE_BYTES) return undefined;
|
||||
// One byte more than the checked size shows a file that grew after the check.
|
||||
const buffer = Buffer.alloc(stats.size + 1);
|
||||
let length = 0;
|
||||
while (length < buffer.length) {
|
||||
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
||||
if (bytesRead === 0) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > stats.size) return undefined;
|
||||
const text = decodeFileText(buffer.subarray(0, length));
|
||||
return text?.includes("\r") ? undefined : text;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The text of a file of at most {@link MAX_PATCH_FILE_BYTES} that is not binary and is valid UTF-8. */
|
||||
function decodeFileText(bytes: Uint8Array): string | undefined {
|
||||
if (bytes.length > MAX_PATCH_FILE_BYTES) return undefined;
|
||||
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return undefined;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tool input text can be the new side of an exact patch. */
|
||||
function isPatchableText(text: string): boolean {
|
||||
return (
|
||||
Buffer.byteLength(text, "utf8") <= MAX_PATCH_FILE_BYTES &&
|
||||
!text.includes("\0") &&
|
||||
!text.includes("\r")
|
||||
);
|
||||
}
|
||||
|
||||
/** The exact patch content of a change, or undefined when it has no patch. */
|
||||
function exactPatch(
|
||||
filePath: string,
|
||||
oldText: string | null,
|
||||
newText: string,
|
||||
form: PatchForm,
|
||||
): ToolCallContent[] | undefined {
|
||||
const patch = filePatch(oldText, newText);
|
||||
return patch && [patchContent(filePath, patch, form)];
|
||||
}
|
||||
|
||||
/** The content that carries a patch, in the given form. */
|
||||
function patchContent(filePath: string, patch: FilePatch, form: PatchForm): ToolCallContent {
|
||||
return form === "v2"
|
||||
? v2DiffContent(filePath, patch.change, patch.hunks)
|
||||
: airPatchContent(filePath, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* A diff block in the AIR patch form.
|
||||
*
|
||||
* `oldText: null` and `newText: ""` only satisfy the ACP schema. The adapter
|
||||
* sends this form only to a client that advertised `diffPatch`.
|
||||
*/
|
||||
function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCallContent {
|
||||
return {
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText: null,
|
||||
newText: "",
|
||||
_meta: withAirMeta(undefined, AIR_DIFF_PATCH_CAPABILITY, {
|
||||
version: 1,
|
||||
format: "git_patch",
|
||||
text: gitPatchText(filePath, change, hunks),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a change to the text file `filePath`: the change and,
|
||||
* given hunks, its git patch.
|
||||
*
|
||||
* The agent builds tool call content in v1 types, and v1 cannot express a v2
|
||||
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
|
||||
* diff, and only a v2 client gets one: the v2 surface sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
export function v2DiffContent(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks?: PatchHunk[],
|
||||
): ToolCallContent {
|
||||
const fileChange: v2.DiffChange =
|
||||
change === "create"
|
||||
? { operation: "add", path: filePath, fileType: "text" }
|
||||
: { operation: "modify", path: filePath, fileType: "text" };
|
||||
const diff: V2DiffContent = {
|
||||
type: "diff",
|
||||
changes: [fileChange],
|
||||
...(hunks?.length
|
||||
? { patch: { format: "git_patch", text: gitPatchText(filePath, change, hunks, "absolute") } }
|
||||
: {}),
|
||||
};
|
||||
return diff as unknown as ToolCallContent;
|
||||
}
|
||||
|
||||
function resolveToolPath(filePath: string, cwd?: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd ?? process.cwd(), filePath);
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { CreateElicitationResponse } from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
ElicitationAcceptAction,
|
||||
ElicitationPropertySchema,
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
* (unstable) elicitation protocol.
|
||||
*
|
||||
* Two distinct SDK surfaces flow through here:
|
||||
*
|
||||
* 1. `onElicitation` — fired when an MCP server requests user input. These map
|
||||
* directly onto ACP `session/create_elicitation` (form or url mode).
|
||||
* 2. The built-in AskUserQuestion tool — when a `canUseTool` callback is
|
||||
* registered the SDK routes its permission check through `canUseTool`
|
||||
* (not the interactive `permission_ask_user_question` dialog). We render
|
||||
* its questions as an ACP form elicitation and feed the user's selections
|
||||
* back as the tool's `updatedInput`, which the tool's own `call()` reads.
|
||||
*/
|
||||
|
||||
/** Modes the connected client advertised support for. */
|
||||
export type ElicitationSupport = {
|
||||
form: boolean;
|
||||
url: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an MCP elicitation request (from the SDK's `onElicitation` callback)
|
||||
* into an ACP `CreateElicitationRequest`. Returns `null` when the request can't
|
||||
* be represented (e.g. a url-mode request with no url).
|
||||
*/
|
||||
export function mcpElicitationToCreateRequest(
|
||||
request: ElicitationRequest,
|
||||
sessionId: string,
|
||||
): CreateElicitationRequest | null {
|
||||
if (request.mode === "url") {
|
||||
if (!request.url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mode: "url",
|
||||
sessionId,
|
||||
message: request.message,
|
||||
url: request.url,
|
||||
// URL elicitations need a stable id so the client can correlate the
|
||||
// later `session/complete_elicitation` notification. MCP servers usually
|
||||
// provide one; fall back to a generated id if not.
|
||||
elicitationId: request.elicitationId ?? randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
// Form mode (the default). The MCP `requestedSchema` is already a JSON Schema
|
||||
// with primitive-typed properties, which is structurally what ACP expects.
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
message: request.message,
|
||||
requestedSchema: normalizeElicitationSchema(request.requestedSchema),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of an accepted elicitation response.
|
||||
*
|
||||
* Uses the SDK's validating guard rather than an `action === "accept"` check:
|
||||
* the guard both narrows past the union's custom/future variant and validates
|
||||
* the payload, so a malformed accept (right tag, ill-typed content) yields
|
||||
* empty content — the same classification the SDK's wire validators apply.
|
||||
*/
|
||||
function acceptedElicitationContent(
|
||||
response: CreateElicitationResponse,
|
||||
): NonNullable<ElicitationAcceptAction["content"]> {
|
||||
return CreateElicitationResponse.isAccept(response) ? (response.content ?? {}) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an ACP elicitation response back to the MCP `ElicitResult` the SDK expects
|
||||
* to hand back to the requesting server.
|
||||
*/
|
||||
export function createElicitationResponseToElicitResult(
|
||||
response: CreateElicitationResponse,
|
||||
): ElicitationResult {
|
||||
switch (response.action) {
|
||||
case "accept":
|
||||
return { action: "accept", content: acceptedElicitationContent(response) };
|
||||
case "decline":
|
||||
return { action: "decline" };
|
||||
case "cancel":
|
||||
default:
|
||||
return { action: "cancel" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single question as supplied by the AskUserQuestion tool. Derived from the
|
||||
* SDK's input type so the shape stays in sync; the SDK validates the model's
|
||||
* tool call against this schema before it reaches us.
|
||||
*/
|
||||
export type AskUserQuestion = AskUserQuestionInput["questions"][number];
|
||||
|
||||
/**
|
||||
* Pull the well-formed questions out of an AskUserQuestion tool input. Returns
|
||||
* `null` when there are no usable questions — including the case where every
|
||||
* entry is malformed and filtering leaves an empty list — so callers can treat
|
||||
* "nothing to ask" uniformly.
|
||||
*/
|
||||
export function extractAskUserQuestions(input: Record<string, unknown>): AskUserQuestion[] | null {
|
||||
const questions = (input as { questions?: unknown }).questions;
|
||||
if (!Array.isArray(questions)) {
|
||||
return null;
|
||||
}
|
||||
const valid = questions.filter(
|
||||
(q): q is AskUserQuestion =>
|
||||
!!q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length > 0,
|
||||
);
|
||||
return valid.length > 0 ? valid : null;
|
||||
}
|
||||
|
||||
/** Stable form-field key for the question at the given index. */
|
||||
function questionFieldKey(index: number): string {
|
||||
return `question_${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form-field key for the per-question free-text "custom answer" field that sits
|
||||
* alongside `question_<n>`. Mirrors the first-party clients, where every
|
||||
* question carries its own "Other" box rather than one form-level field.
|
||||
*/
|
||||
function questionCustomFieldKey(index: number): string {
|
||||
return `question_${index}_custom`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_meta` key under which a bridged enum option carries its `preview`, the one
|
||||
* option field ACP's `EnumOption` still has no slot for (descriptions are
|
||||
* first-class as of schema 1.19). Namespaced like the agent's other `_meta`
|
||||
* extensions (`_claude/...`).
|
||||
*/
|
||||
const OPTION_META_KEY = "_claude/askUserQuestionOption";
|
||||
|
||||
/**
|
||||
* Render the AskUserQuestion tool's questions as an ACP form elicitation.
|
||||
*
|
||||
* Fields are keyed by a short stable id (`question_<n>`) rather than the full
|
||||
* question text, so the question text appears in exactly one place per field.
|
||||
* Single-select questions use a titled `oneOf` enum; multi-select questions use
|
||||
* an array with a titled `anyOf` item enum. The enum `const` is always the
|
||||
* option label, since that is what the tool records as the answer; an option's
|
||||
* secondary text travels in the enum option's own `description` field.
|
||||
*
|
||||
* Each question is followed by its own optional free-text "custom answer" field
|
||||
* (`question_<n>_custom`), mirroring the CLI's per-question "Other" box: the
|
||||
* user can type their own answer instead of picking an option, add it to a
|
||||
* multi-select's picks, or attach it as a note to a single-select's pick (see
|
||||
* `applyAskElicitationResponse`), scoped to that specific question. Nothing is
|
||||
* marked required, so the user can also just skip — matching the built-in tool,
|
||||
* which always offers Skip + a free-text box.
|
||||
*/
|
||||
export function askUserQuestionsToCreateRequest(
|
||||
questions: AskUserQuestion[],
|
||||
sessionId: string,
|
||||
toolCallId: string | undefined,
|
||||
airClient = false,
|
||||
): CreateElicitationRequest {
|
||||
const single = questions.length === 1;
|
||||
const properties: Record<string, ElicitationPropertySchema> = {};
|
||||
|
||||
questions.forEach((question, index) => {
|
||||
const options: EnumOption[] = question.options.map((option) => {
|
||||
const enumOption: EnumOption = {
|
||||
const: option.label,
|
||||
title: option.label,
|
||||
};
|
||||
if (option.description) {
|
||||
enumOption.description = option.description;
|
||||
}
|
||||
// The SDK option's `preview` (mockups, code snippets, comparisons shown
|
||||
// on focus) still has no structural slot in `EnumOption`, so forward it
|
||||
// under ACP's reserved `_meta` extension point for clients that render it.
|
||||
if (option.preview) {
|
||||
enumOption._meta = { [OPTION_META_KEY]: { preview: option.preview } };
|
||||
}
|
||||
return enumOption;
|
||||
});
|
||||
|
||||
// For a single question the prompt is carried by `message`, so we don't
|
||||
// repeat it in the field description. With multiple questions each field
|
||||
// needs its own question text.
|
||||
const description = single ? undefined : question.question;
|
||||
const title = question.header || undefined;
|
||||
|
||||
properties[questionFieldKey(index)] = question.multiSelect
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
|
||||
const customAnswer = { questionId: questionFieldKey(index), isCustomAnswer: true };
|
||||
properties[questionCustomFieldKey(index)] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
description: question.multiSelect
|
||||
? "Type your own answer to add to your selection above (optional)."
|
||||
: "Type your own answer, or add a note to the option you chose above (optional).",
|
||||
// Marks the field as the custom answer companion of a select question,
|
||||
// under `_meta.jetbrains.air.customAnswer` and under the root key that
|
||||
// released AIR versions read. Only AIR gets the marker.
|
||||
...(airClient
|
||||
? {
|
||||
_meta: withAirMeta(
|
||||
{ [LEGACY_AIR_CUSTOM_ANSWER_KEY]: customAnswer },
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
customAnswer,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
const requestedSchema: ElicitationSchema = {
|
||||
type: "object",
|
||||
properties,
|
||||
};
|
||||
|
||||
const message = single ? questions[0].question : "Please answer the following questions.";
|
||||
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
message,
|
||||
requestedSchema,
|
||||
};
|
||||
}
|
||||
|
||||
/** Outcome of an AskUserQuestion elicitation, decoupled from any transport. */
|
||||
export type AskUserQuestionOutcome =
|
||||
{ action: "answered"; updatedInput: Record<string, unknown> } | { action: "cancel" };
|
||||
|
||||
/**
|
||||
* Serialize a multi-select answer the way the CLI's own AskUserQuestion UI
|
||||
* does: comma-joined, with any item that itself contains the separator (or a
|
||||
* double quote) JSON-quoted. The tool's `call()` splits the string back on the
|
||||
* same rule, so a free-text answer like `Redis, not Memcached` stays one item
|
||||
* instead of reading as two more picks.
|
||||
*/
|
||||
function joinMultiSelectAnswer(items: string[]): string {
|
||||
return items
|
||||
.map((item) => (item.includes(", ") || item.includes('"') ? JSON.stringify(item) : item))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold an ACP elicitation response into the AskUserQuestion tool's input.
|
||||
*
|
||||
* Selected labels are read back from the indexed form fields and written into
|
||||
* `answers` as a `{ [questionText]: label }` map — the key shape the tool's own
|
||||
* `call()` reads — with multi-selects comma-joined in the CLI's own quoted form
|
||||
* (see `joinMultiSelectAnswer`). A non-empty per-question custom-answer field
|
||||
* (`question_<n>_custom`) joins the selection of a multi-select question, where
|
||||
* the two fields are independent and filling both means both. For a
|
||||
* single-select question it is the answer when nothing was picked (the user
|
||||
* typed their own instead), and otherwise travels beside the pick as the
|
||||
* tool's own per-question `annotations[question].notes` — the slot the CLI
|
||||
* uses for free text attached to a selection and renders to the model as
|
||||
* `"Q"="A" notes: ...` — so a client that presents the box as a notes field
|
||||
* cannot make the selection disappear. Decline yields empty answers (the model
|
||||
* is told the user skipped rather than the turn aborting); cancel — and any
|
||||
* custom/future action we don't understand — aborts the tool call.
|
||||
*/
|
||||
export function applyAskElicitationResponse(
|
||||
response: CreateElicitationResponse,
|
||||
toolInput: Record<string, unknown>,
|
||||
questions: AskUserQuestion[],
|
||||
): AskUserQuestionOutcome {
|
||||
if (response.action === "decline") {
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
|
||||
}
|
||||
|
||||
if (response.action !== "accept") {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
const content = acceptedElicitationContent(response);
|
||||
// Typed against the tool's own output schema so the answer/response shapes
|
||||
// stay in sync with what the built-in tool's call() expects to read back.
|
||||
const answers: AskUserQuestionOutput["answers"] = {};
|
||||
const annotations: NonNullable<AskUserQuestionInput["annotations"]> = {};
|
||||
questions.forEach((question, index) => {
|
||||
const custom = content[questionCustomFieldKey(index)];
|
||||
const customText = typeof custom === "string" ? custom.trim() : "";
|
||||
|
||||
const value = content[questionFieldKey(index)];
|
||||
const picks: string[] =
|
||||
value === undefined || value === null
|
||||
? []
|
||||
: Array.isArray(value)
|
||||
? value.filter((item) => item !== undefined && item !== null && item !== "").map(String)
|
||||
: [String(value)];
|
||||
|
||||
// A multi-select is additive, and the form offers the selection and the
|
||||
// custom box as independent fields — a user who fills both means both, so
|
||||
// the typed answer joins the checked options.
|
||||
if (question.multiSelect) {
|
||||
const text = joinMultiSelectAnswer(customText === "" ? picks : [...picks, customText]);
|
||||
if (text !== "") {
|
||||
answers[question.question] = text;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A single-select question is answered by exactly one thing. With no option
|
||||
// picked, the typed text is that answer (the CLI's "Other"). With an option
|
||||
// picked as well, the pick stays the answer and the text rides along as the
|
||||
// tool's per-question `notes` annotation, so neither is lost. A single-select
|
||||
// normally holds one item; the plain join only matters if a client hands
|
||||
// back an array for it, and then mirrors the old behavior.
|
||||
const picked = picks.join(", ");
|
||||
if (picked === "") {
|
||||
if (customText !== "") {
|
||||
answers[question.question] = customText;
|
||||
}
|
||||
return;
|
||||
}
|
||||
answers[question.question] = picked;
|
||||
if (customText !== "") {
|
||||
annotations[question.question] = { notes: customText };
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
...toolInput,
|
||||
answers,
|
||||
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
|
||||
* The two are structurally compatible JSON Schemas; we just guarantee the
|
||||
* `type: "object"` discriminator is present.
|
||||
*/
|
||||
function normalizeElicitationSchema(
|
||||
schema: Record<string, unknown> | undefined,
|
||||
): ElicitationSchema {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
return { ...(schema as ElicitationSchema), type: "object" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `request_user_dialog` kind the CLI emits when a model refusal has a
|
||||
* fallback available but needs user consent before retrying (e.g. Claude Fable
|
||||
* declining a request with Opus available as the fallback). Declaring this
|
||||
* kind in `supportedDialogKinds` is the opt-in: the CLI fails closed and never
|
||||
* emits an undeclared kind — the flow degrades to the classic refusal error
|
||||
* ending the turn.
|
||||
*/
|
||||
export const REFUSAL_FALLBACK_DIALOG_KIND = "refusal_fallback_prompt";
|
||||
|
||||
/**
|
||||
* Payload of the `refusal_fallback_prompt` dialog. The dialog protocol
|
||||
* transports payloads opaquely, so this shape is recovered from the CLI's own
|
||||
* schema (v2.1.177): `originalModel`/`fallbackModel` are required strings;
|
||||
* `apiRefusalCategory` (nullable), `guidanceText`, and
|
||||
* `retractedMessageUuids` are optional. We ignore `retractedMessageUuids` —
|
||||
* ACP has no way to retract already-streamed chunks.
|
||||
*/
|
||||
export type RefusalFallbackPrompt = {
|
||||
originalModel: string;
|
||||
fallbackModel: string;
|
||||
apiRefusalCategory: string | null;
|
||||
guidanceText?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the opaque dialog payload into a {@link RefusalFallbackPrompt}.
|
||||
* Returns `null` when the required fields are missing or mistyped (a newer CLI
|
||||
* may reshape the payload), so the caller can cancel the dialog and let the
|
||||
* CLI apply its default behavior instead of rendering something misleading.
|
||||
*/
|
||||
export function extractRefusalFallbackPrompt(
|
||||
payload: Record<string, unknown>,
|
||||
): RefusalFallbackPrompt | null {
|
||||
const { originalModel, fallbackModel, apiRefusalCategory, guidanceText } = payload;
|
||||
if (typeof originalModel !== "string" || typeof fallbackModel !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
originalModel,
|
||||
fallbackModel,
|
||||
apiRefusalCategory: typeof apiRefusalCategory === "string" ? apiRefusalCategory : null,
|
||||
...(typeof guidanceText === "string" && guidanceText ? { guidanceText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Form-field key carrying the user's choice in the refusal-fallback form. */
|
||||
const REFUSAL_FALLBACK_CHOICE_KEY = "choice";
|
||||
|
||||
/** Wire values of the dialog's result enum (CLI schema). `edit_prompt` is
|
||||
* deliberately not offered: in the CLI it prefills the composer with the
|
||||
* refused prompt for edit-and-retry, and ACP has no composer-prefill surface
|
||||
* — the user can simply edit and resend on their own. */
|
||||
const RETRY_FALLBACK_RESULT = "retry_fallback";
|
||||
const KEEP_REFUSAL_RESULT = "cancelled";
|
||||
|
||||
/**
|
||||
* Render the refusal-fallback consent prompt as an ACP form elicitation: a
|
||||
* single-select between retrying on the fallback model and keeping the
|
||||
* refusal. The enum `const`s are the dialog's wire result values, so the
|
||||
* response maps back without a translation table.
|
||||
*/
|
||||
export function refusalFallbackToCreateRequest(
|
||||
prompt: RefusalFallbackPrompt,
|
||||
sessionId: string,
|
||||
): CreateElicitationRequest {
|
||||
const category = prompt.apiRefusalCategory ? ` (${prompt.apiRefusalCategory})` : "";
|
||||
const guidance = prompt.guidanceText ? `\n\n${prompt.guidanceText}` : "";
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
message:
|
||||
`${prompt.originalModel} declined this request${category}. ` +
|
||||
`Retry with ${prompt.fallbackModel}?` +
|
||||
guidance,
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
[REFUSAL_FALLBACK_CHOICE_KEY]: {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{
|
||||
const: RETRY_FALLBACK_RESULT,
|
||||
title: `Retry with ${prompt.fallbackModel}`,
|
||||
description: `The session continues on ${prompt.fallbackModel}.`,
|
||||
},
|
||||
{
|
||||
const: KEEP_REFUSAL_RESULT,
|
||||
title: "Keep the refusal",
|
||||
description: "You can send a new message.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the elicitation response back to the dialog's result enum. Only an
|
||||
* explicit accept-with-retry resolves to `retry_fallback`; decline, cancel, a
|
||||
* skipped field, or an unrecognized value all keep the refusal — the dialog's
|
||||
* own default — so a dismissed or half-filled form can never trigger a model
|
||||
* switch the user didn't ask for.
|
||||
*/
|
||||
export function refusalFallbackResultFromResponse(response: CreateElicitationResponse): string {
|
||||
const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY];
|
||||
return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
|
||||
}
|
||||
232
src/exit-plan.ts
232
src/exit-plan.ts
@@ -1,232 +0,0 @@
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
type ClearContextCoordinatorHost,
|
||||
type ClearContextReset,
|
||||
type ClearContextSession,
|
||||
type ClearContextTurn,
|
||||
continuePlanInFreshContext,
|
||||
} from "./clear-context-coordinator.js";
|
||||
import { parseToolResultMeta } from "./tool-result-meta.js";
|
||||
|
||||
export function acceptedPlanToolResult(
|
||||
notification: SessionNotification,
|
||||
toolUseId: string | undefined,
|
||||
): SessionNotification {
|
||||
const update = notification.update;
|
||||
if (
|
||||
!toolUseId ||
|
||||
update.sessionUpdate !== "tool_call_update" ||
|
||||
update.toolCallId !== toolUseId
|
||||
) {
|
||||
return notification;
|
||||
}
|
||||
const completed = { ...update };
|
||||
delete completed.rawOutput;
|
||||
delete completed.content;
|
||||
return { ...notification, update: { ...completed, status: "completed" } };
|
||||
}
|
||||
|
||||
function containsToolResultFor(content: unknown, toolUseId: string): boolean {
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.some(
|
||||
(block) =>
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
(block as { type?: unknown }).type === "tool_result" &&
|
||||
(block as { tool_use_id?: unknown }).tool_use_id === toolUseId,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type ExitPlanState = {
|
||||
toolUseCache: Record<string, { name: string } | undefined>;
|
||||
pendingExitPlanModeInterruption?: { toolUseId: string; toolResultSeen: boolean };
|
||||
pendingExitPlanContextReset?: ClearContextReset;
|
||||
};
|
||||
|
||||
function rejectedExitPlanToolUseId(
|
||||
content: unknown,
|
||||
toolUseCache: ExitPlanState["toolUseCache"],
|
||||
rawToolResultMeta: unknown,
|
||||
): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const toolResultMeta = parseToolResultMeta(rawToolResultMeta);
|
||||
if (!toolResultMeta) return undefined;
|
||||
for (const block of content) {
|
||||
if (typeof block !== "object" || block === null) continue;
|
||||
const { type, tool_use_id: toolUseId, is_error: isError } = block as Record<string, unknown>;
|
||||
if (
|
||||
type === "tool_result" &&
|
||||
typeof toolUseId === "string" &&
|
||||
isError === true &&
|
||||
toolResultMeta.get(toolUseId)?.nonExecutionKind === "user-rejected" &&
|
||||
toolUseCache[toolUseId]?.name === "ExitPlanMode"
|
||||
) {
|
||||
return toolUseId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Reconcile an SDK user message with the two pending ExitPlanMode lanes and
|
||||
* return the accepted plan tool id whose rendered update must be completed. */
|
||||
export function observeExitPlanToolResults(
|
||||
message: { type: string; tool_result_meta?: unknown },
|
||||
content: unknown,
|
||||
state: ExitPlanState,
|
||||
): string | undefined {
|
||||
if (message.type !== "user") return undefined;
|
||||
|
||||
const rejectedToolUseId = rejectedExitPlanToolUseId(
|
||||
content,
|
||||
state.toolUseCache,
|
||||
message.tool_result_meta,
|
||||
);
|
||||
if (rejectedToolUseId) {
|
||||
// The stream is authoritative: resumed queries can lose the short-lived
|
||||
// marker installed by canUseTool, while metadata preserves correlation.
|
||||
state.pendingExitPlanModeInterruption = {
|
||||
toolUseId: rejectedToolUseId,
|
||||
toolResultSeen: true,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingInterruption = state.pendingExitPlanModeInterruption;
|
||||
if (pendingInterruption && containsToolResultFor(content, pendingInterruption.toolUseId)) {
|
||||
pendingInterruption.toolResultSeen = true;
|
||||
}
|
||||
|
||||
const pendingReset = state.pendingExitPlanContextReset;
|
||||
return pendingReset && containsToolResultFor(content, pendingReset.toolUseId)
|
||||
? pendingReset.toolUseId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function executionDiagnostic(message: SDKResultMessage): string | undefined {
|
||||
if (message.subtype === "success") {
|
||||
return message.result.startsWith("[ede_diagnostic]") ? message.result : undefined;
|
||||
}
|
||||
return message.errors.find((error) => error.startsWith("[ede_diagnostic]"));
|
||||
}
|
||||
|
||||
/** Whether `message` is the error-shaped diagnostic Claude emits for the cycle
|
||||
* a recorded ExitPlanMode `interrupt: true` answer stopped. */
|
||||
export function isExitPlanInterruptionResult(
|
||||
message: SDKResultMessage,
|
||||
pending: ExitPlanState["pendingExitPlanModeInterruption"],
|
||||
): boolean {
|
||||
if (!pending?.toolResultSeen || !message.is_error) return false;
|
||||
const diagnostic = executionDiagnostic(message);
|
||||
return (
|
||||
!!diagnostic &&
|
||||
/(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
|
||||
/(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(diagnostic)
|
||||
);
|
||||
}
|
||||
|
||||
/** Claude wraps a rejected ExitPlanMode explanation in a Markdown code fence.
|
||||
* Strip exactly one complete outer fence for that tool only. */
|
||||
export function exitPlanModeRawOutput(toolName: string, content: unknown): unknown {
|
||||
if (toolName !== "ExitPlanMode" || typeof content !== "string") {
|
||||
return content;
|
||||
}
|
||||
const fenced = /^\s*```[^\r\n]*\r?\n([\s\S]*?)\r?\n```\s*$/.exec(content);
|
||||
return fenced?.[1] ?? content;
|
||||
}
|
||||
|
||||
export type ExitPlanRestartHost<
|
||||
Session extends ClearContextSession<Turn>,
|
||||
Turn extends ClearContextTurn,
|
||||
> = Omit<
|
||||
ClearContextCoordinatorHost<Session, Turn>,
|
||||
"publishSessionState" | "continuationMessage"
|
||||
> & {
|
||||
sessionUpdate(notification: SessionNotification): Promise<void>;
|
||||
destroyReplacement(sessionId: string, session: Session): void;
|
||||
settleCancelledTurn(oldSession: Session, turnSession: Session, turn: Turn): void;
|
||||
settleFailedTurn(turnSession: Session, turn: Turn, error: unknown): void;
|
||||
};
|
||||
|
||||
/** Owns the lifetime of accepted-plan context replacements. In particular, a
|
||||
* session cancellation invalidates an in-progress async restart so a late
|
||||
* restartSession result cannot recreate a closed public session. */
|
||||
export class ExitPlanCoordinator<
|
||||
Session extends ClearContextSession<Turn>,
|
||||
Turn extends ClearContextTurn,
|
||||
> {
|
||||
private readonly restarts = new Map<string, AbortController>();
|
||||
|
||||
constructor(private readonly host: ExitPlanRestartHost<Session, Turn>) {}
|
||||
|
||||
cancel(sessionId: string): void {
|
||||
this.restarts.get(sessionId)?.abort();
|
||||
}
|
||||
|
||||
async restart(sessionId: string, oldSession: Session, reset: ClearContextReset): Promise<void> {
|
||||
this.cancel(sessionId);
|
||||
const controller = new AbortController();
|
||||
this.restarts.set(sessionId, controller);
|
||||
try {
|
||||
const clearContextHost: ClearContextCoordinatorHost<Session, Turn> = {
|
||||
...this.host,
|
||||
publishSessionState: async (id, mode, configOptions) => {
|
||||
await this.host.sessionUpdate({
|
||||
sessionId: id,
|
||||
update: { sessionUpdate: "current_mode_update", currentModeId: mode },
|
||||
});
|
||||
await this.host.sessionUpdate({
|
||||
sessionId: id,
|
||||
update: { sessionUpdate: "config_option_update", configOptions },
|
||||
});
|
||||
},
|
||||
continuationMessage: (id, plan, promptUuid) => ({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `Implement the following plan:\n\n${plan}` }],
|
||||
},
|
||||
session_id: id,
|
||||
parent_tool_use_id: null,
|
||||
origin: { kind: "human" },
|
||||
uuid: promptUuid as `${string}-${string}-${string}-${string}-${string}`,
|
||||
}),
|
||||
};
|
||||
await continuePlanInFreshContext(
|
||||
sessionId,
|
||||
oldSession,
|
||||
reset,
|
||||
clearContextHost,
|
||||
controller.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
const currentSession = this.host.currentSession(sessionId);
|
||||
const replacement = currentSession !== oldSession ? currentSession : undefined;
|
||||
if (replacement) this.host.destroyReplacement(sessionId, replacement);
|
||||
|
||||
const turn = replacement?.activeTurn ?? oldSession.activeTurn;
|
||||
if (turn && !turn.settled) {
|
||||
const turnSession = replacement?.activeTurn === turn ? replacement : oldSession;
|
||||
if (controller.signal.aborted) {
|
||||
this.host.settleCancelledTurn(oldSession, turnSession, turn);
|
||||
} else {
|
||||
// A provider/session-creation failure is turn-scoped. Settling it
|
||||
// here keeps it out of the query consumer's transport-loss catch.
|
||||
this.host.settleFailedTurn(turnSession, turn, error);
|
||||
}
|
||||
oldSession.activeTurn = null;
|
||||
oldSession.turnQueue = (oldSession.turnQueue ?? []).filter((queued) => queued !== turn);
|
||||
if (replacement) {
|
||||
replacement.activeTurn = null;
|
||||
replacement.turnQueue = (replacement.turnQueue ?? []).filter((queued) => queued !== turn);
|
||||
}
|
||||
}
|
||||
oldSession.pendingExitPlanContextReset = undefined;
|
||||
} finally {
|
||||
if (this.restarts.get(sessionId) === controller) {
|
||||
this.restarts.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import type { Query, RewindFilesResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { airExtensionMeta, clientSupportsAirCapability, withAirMeta } from "./air-extension.js";
|
||||
|
||||
export const AGENT_FILE_CHANGE_REPORT_CAPABILITY = "agentFileChangeReport";
|
||||
|
||||
const MAX_REPORTED_PATHS = 1024;
|
||||
const MAX_REPORTED_PATH_LENGTH = 4096;
|
||||
export const AGENT_FILE_CHANGE_REPORT_MAX_BYTES = 256 * 1024;
|
||||
export const AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS = 2_000;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
|
||||
export type FileChangeReportTurnState = {
|
||||
requestId: string;
|
||||
phase: "requested" | "collecting" | "finished";
|
||||
};
|
||||
|
||||
export type NativeFileChangeReportTurn = {
|
||||
promptUuid: string;
|
||||
fileChangeReport?: FileChangeReportTurnState;
|
||||
};
|
||||
|
||||
export type FileChangeReportWorkspace = {
|
||||
cwd: string;
|
||||
additionalDirectories: string[];
|
||||
};
|
||||
|
||||
export type AgentFileChangeReportResult = {
|
||||
version: 1;
|
||||
requestId: string;
|
||||
} & (
|
||||
| {
|
||||
status: "reported";
|
||||
paths: string[];
|
||||
declaredComplete: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
| {
|
||||
status: "unavailable";
|
||||
reason: FileChangeReportUnavailableReason;
|
||||
}
|
||||
);
|
||||
|
||||
export type FileChangeReportUnavailableReason =
|
||||
"cancelled" | "timeout" | "invalidOutput" | "notReported" | "providerError";
|
||||
|
||||
type NativeFileChangeReporterOptions = {
|
||||
cwd: string;
|
||||
additionalDirectories: string[];
|
||||
publish: (result: AgentFileChangeReportResult) => Promise<void>;
|
||||
logError: (message: string) => void;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type NativeFileChangeReporter = {
|
||||
request(meta: unknown): FileChangeReportTurnState | undefined;
|
||||
report(
|
||||
turn: NativeFileChangeReportTurn | null | undefined,
|
||||
query: Pick<Query, "rewindFiles">,
|
||||
): Promise<void>;
|
||||
finish(
|
||||
state: FileChangeReportTurnState | undefined,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
): void;
|
||||
};
|
||||
|
||||
export function agentFileChangeReportRequestId(meta: unknown): string | undefined {
|
||||
const air = airExtensionMeta(meta);
|
||||
const request = air?.agentFileChangeReportRequest;
|
||||
if (!request || typeof request !== "object" || Array.isArray(request)) return undefined;
|
||||
const requestRecord = request as Record<string, unknown>;
|
||||
const requestKeys = Object.keys(requestRecord);
|
||||
if (
|
||||
requestKeys.length !== 2 ||
|
||||
!requestKeys.includes("version") ||
|
||||
!requestKeys.includes("requestId") ||
|
||||
requestRecord.version !== 1
|
||||
)
|
||||
return undefined;
|
||||
const requestId = requestRecord.requestId;
|
||||
return typeof requestId === "string" && REQUEST_ID_PATTERN.test(requestId)
|
||||
? requestId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function supportsAgentFileChangeReport(capabilities: unknown): boolean {
|
||||
return clientSupportsAirCapability(capabilities, AGENT_FILE_CHANGE_REPORT_CAPABILITY);
|
||||
}
|
||||
|
||||
export function agentFileChangeReportMeta(
|
||||
result: AgentFileChangeReportResult,
|
||||
): Record<string, unknown> {
|
||||
return withAirMeta(undefined, AGENT_FILE_CHANGE_REPORT_CAPABILITY, result);
|
||||
}
|
||||
|
||||
export function createNativeFileChangeReporter(
|
||||
options: NativeFileChangeReporterOptions,
|
||||
): NativeFileChangeReporter {
|
||||
const workspace = normalizeWorkspace(options.cwd, options.additionalDirectories);
|
||||
const timeoutMs = options.timeoutMs ?? AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS;
|
||||
const requestIds = new Set<string>();
|
||||
|
||||
const publish = async (state: FileChangeReportTurnState, result: AgentFileChangeReportResult) => {
|
||||
if (state.phase === "finished") return;
|
||||
state.phase = "finished";
|
||||
try {
|
||||
await options.publish(result);
|
||||
} catch (error) {
|
||||
options.logError(`Failed to publish file-change report ${state.requestId}: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const finishUnavailable = async (
|
||||
state: FileChangeReportTurnState,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
) => {
|
||||
await publish(state, {
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "unavailable",
|
||||
reason,
|
||||
});
|
||||
};
|
||||
|
||||
const report = async (
|
||||
turn: NativeFileChangeReportTurn | null | undefined,
|
||||
query: Pick<Query, "rewindFiles">,
|
||||
) => {
|
||||
const state = turn?.fileChangeReport;
|
||||
if (!state || state.phase !== "requested") return;
|
||||
state.phase = "collecting";
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const previewPromise = query.rewindFiles(turn.promptUuid, { dryRun: true });
|
||||
// A timed-out control request may still settle later. Observe it so a late
|
||||
// rejection cannot become unhandled, but never let it publish a second terminal.
|
||||
void previewPromise.catch(() => {});
|
||||
try {
|
||||
const preview = await Promise.race<RewindFilesResult | null>([
|
||||
previewPromise,
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), timeoutMs);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
if (preview === null) {
|
||||
await finishUnavailable(state, "timeout");
|
||||
return;
|
||||
}
|
||||
if (!preview.canRewind || !Array.isArray(preview.filesChanged)) {
|
||||
await finishUnavailable(state, "invalidOutput");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeReportedPaths(preview.filesChanged, workspace);
|
||||
await publish(
|
||||
state,
|
||||
fitReportedAgentFileChangeReport({
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "reported",
|
||||
paths: normalized.paths,
|
||||
// Checkpoints cover Claude file tools, but not every mutation source
|
||||
// (notably Bash and most subagents), so this list is never exhaustive.
|
||||
declaredComplete: false,
|
||||
truncated: normalized.truncated,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
options.logError(`Failed to inspect Claude file checkpoint ${turn.promptUuid}: ${error}`);
|
||||
await finishUnavailable(state, "providerError");
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
request(meta) {
|
||||
const requestId = agentFileChangeReportRequestId(meta);
|
||||
if (!requestId || requestIds.has(requestId)) return undefined;
|
||||
requestIds.add(requestId);
|
||||
return { requestId, phase: "requested" };
|
||||
},
|
||||
report,
|
||||
finish(state, reason) {
|
||||
if (!state) return;
|
||||
void finishUnavailable(state, reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkspace(
|
||||
cwd: string,
|
||||
additionalDirectories: string[],
|
||||
): FileChangeReportWorkspace {
|
||||
const normalizedCwd = canonicalizeWorkspaceRoot(path.resolve(cwd));
|
||||
const seen = new Set([pathKey(normalizedCwd)]);
|
||||
const normalizedAdditionalDirectories: string[] = [];
|
||||
for (const directory of additionalDirectories) {
|
||||
const normalized = canonicalizeWorkspaceRoot(path.resolve(normalizedCwd, directory));
|
||||
const key = pathKey(normalized);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalizedAdditionalDirectories.push(normalized);
|
||||
}
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
additionalDirectories: normalizedAdditionalDirectories,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReportedPaths(
|
||||
reportedPaths: string[],
|
||||
workspace: FileChangeReportWorkspace,
|
||||
): { paths: string[]; truncated: boolean } {
|
||||
const roots = [workspace.cwd, ...workspace.additionalDirectories];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
for (const reportedPath of reportedPaths) {
|
||||
if (
|
||||
typeof reportedPath !== "string" ||
|
||||
reportedPath.trim().length === 0 ||
|
||||
reportedPath.length > MAX_REPORTED_PATH_LENGTH ||
|
||||
hasControlCharacter(reportedPath)
|
||||
) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = canonicalizeReportedPath(path.resolve(workspace.cwd, reportedPath));
|
||||
} catch {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (normalized.length > MAX_REPORTED_PATH_LENGTH || hasControlCharacter(normalized)) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (!roots.some((root) => isWithinRoot(normalized, root))) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
const key = pathKey(normalized);
|
||||
if (roots.some((root) => pathKey(root) === key)) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) continue;
|
||||
const pathBytes = Buffer.byteLength(normalized, "utf8");
|
||||
if (
|
||||
result.length >= MAX_REPORTED_PATHS ||
|
||||
totalBytes + pathBytes > AGENT_FILE_CHANGE_REPORT_MAX_BYTES
|
||||
) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(normalized);
|
||||
totalBytes += pathBytes;
|
||||
}
|
||||
return { paths: result, truncated };
|
||||
}
|
||||
|
||||
function fitReportedAgentFileChangeReport(
|
||||
report: Extract<AgentFileChangeReportResult, { status: "reported" }>,
|
||||
): Extract<AgentFileChangeReportResult, { status: "reported" }> {
|
||||
const paths = [...report.paths];
|
||||
let fitted = report;
|
||||
while (Buffer.byteLength(JSON.stringify(fitted), "utf8") > AGENT_FILE_CHANGE_REPORT_MAX_BYTES) {
|
||||
if (paths.length === 0) {
|
||||
throw new Error("Agent file-change report exceeds the wire limit without paths");
|
||||
}
|
||||
paths.pop();
|
||||
fitted = {
|
||||
...report,
|
||||
paths: [...paths],
|
||||
declaredComplete: false,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
return fitted;
|
||||
}
|
||||
|
||||
function isWithinRoot(candidate: string, root: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return !path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..";
|
||||
}
|
||||
|
||||
function canonicalizeWorkspaceRoot(value: string): string {
|
||||
return canonicalizeFromExistingAncestor(value);
|
||||
}
|
||||
|
||||
function canonicalizeReportedPath(value: string): string {
|
||||
const parent = canonicalizeFromExistingAncestor(path.dirname(value));
|
||||
return path.resolve(parent, path.basename(value));
|
||||
}
|
||||
|
||||
function canonicalizeFromExistingAncestor(value: string): string {
|
||||
const original = path.resolve(value);
|
||||
let current = original;
|
||||
const missingSegments: string[] = [];
|
||||
while (true) {
|
||||
try {
|
||||
const canonical = fs.realpathSync.native(current);
|
||||
return path.resolve(canonical, ...missingSegments.reverse());
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) return original;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return original;
|
||||
missingSegments.push(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
||||
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
||||
}
|
||||
|
||||
function pathKey(value: string): string {
|
||||
return process.platform === "win32" ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { ForkSessionRequest, ForkSessionResponse, RequestError } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
forkSession as forkClaudeSession,
|
||||
getSessionMessages,
|
||||
importSessionToStore,
|
||||
type SessionStore,
|
||||
type SessionStoreEntry,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createHash } from "node:crypto";
|
||||
import { assistantMessageText } from "./session-failure-extension.js";
|
||||
import { SessionTiming } from "./session-timing.js";
|
||||
|
||||
type ForkSessionMeta = {
|
||||
[key: string]: unknown;
|
||||
jetbrains?: {
|
||||
air?: {
|
||||
fork?: {
|
||||
version?: number;
|
||||
messageId?: string;
|
||||
messageFingerprint?: string;
|
||||
messageOccurrence?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ForkSessionDependencies = {
|
||||
liveMessageIdToUuid?: ReadonlyMap<string, string>;
|
||||
logger?: { log: (...args: unknown[]) => void };
|
||||
messageIdForGrouping: (message: {
|
||||
type?: string;
|
||||
uuid?: string | null;
|
||||
message?: unknown;
|
||||
}) => string | undefined;
|
||||
};
|
||||
|
||||
type ForkPoint = {
|
||||
messageId: string;
|
||||
messageFingerprint?: string;
|
||||
messageOccurrence?: number;
|
||||
};
|
||||
|
||||
function forkPoint(meta: unknown): ForkPoint | undefined {
|
||||
const fork = (meta as ForkSessionMeta | null | undefined)?.jetbrains?.air?.fork;
|
||||
if (fork?.version !== 1) return undefined;
|
||||
const messageId = fork.messageId?.trim();
|
||||
if (!messageId) return undefined;
|
||||
const messageFingerprint = fork.messageFingerprint?.trim();
|
||||
const messageOccurrence = fork.messageOccurrence;
|
||||
return {
|
||||
messageId,
|
||||
...(messageFingerprint ? { messageFingerprint } : {}),
|
||||
// AIR computes this with count(...) over the visible prefix, so it is 1-based.
|
||||
...(typeof messageOccurrence === "number" &&
|
||||
Number.isSafeInteger(messageOccurrence) &&
|
||||
messageOccurrence > 0
|
||||
? { messageOccurrence }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function forkPointMessageIdCandidates(messageId: string): string[] {
|
||||
// Older AIR builds sent their visible segment id. Prefer the exact id before its ACP source id.
|
||||
const protocolMessageId = messageId.replace(/:segment:\d+$/, "");
|
||||
return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId];
|
||||
}
|
||||
|
||||
async function loadFullSessionHistory(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
): Promise<SessionStoreEntry[]> {
|
||||
// getSessionMessages returns only the active parentUuid chain. The import keeps inactive branches that AIR can still reference.
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
const store: SessionStore = {
|
||||
append: async (_key, batch) => {
|
||||
entries.push(...batch);
|
||||
},
|
||||
load: async () => null,
|
||||
};
|
||||
await importSessionToStore(sessionId, store, { dir: cwd, includeSubagents: false });
|
||||
return entries;
|
||||
}
|
||||
|
||||
function assistantGroups(
|
||||
entries: SessionStoreEntry[],
|
||||
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
|
||||
) {
|
||||
const groups = new Map<string, { uuid?: string; text: string }>();
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.type !== "assistant" ||
|
||||
entry.isSidechain === true ||
|
||||
entry.parent_tool_use_id != null ||
|
||||
entry.parent_agent_id != null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const messageId = messageIdForGrouping(entry);
|
||||
if (!messageId) continue;
|
||||
const group = groups.get(messageId) ?? { text: "" };
|
||||
if (typeof entry.uuid === "string" && entry.uuid.length > 0) group.uuid = entry.uuid;
|
||||
group.text += assistantMessageText(entry.message) ?? "";
|
||||
groups.set(messageId, group);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function fingerprint(text: string): string {
|
||||
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
||||
}
|
||||
|
||||
function fingerprintOccurrenceOnBranch(
|
||||
targetUuid: string,
|
||||
expectedFingerprint: string,
|
||||
entries: SessionStoreEntry[],
|
||||
groups: ReturnType<typeof assistantGroups>,
|
||||
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
|
||||
): number {
|
||||
const entriesByUuid = new Map(
|
||||
entries.flatMap((entry) => (typeof entry.uuid === "string" ? [[entry.uuid, entry]] : [])),
|
||||
);
|
||||
const seenGroups = new Set<string>();
|
||||
let occurrence = 0;
|
||||
let cursor: string | undefined = targetUuid;
|
||||
while (cursor) {
|
||||
const entry = entriesByUuid.get(cursor);
|
||||
if (!entry) break;
|
||||
if (
|
||||
entry.type === "assistant" &&
|
||||
entry.isSidechain !== true &&
|
||||
entry.parent_tool_use_id == null &&
|
||||
entry.parent_agent_id == null
|
||||
) {
|
||||
const messageId = messageIdForGrouping(entry);
|
||||
if (messageId && !seenGroups.has(messageId)) {
|
||||
seenGroups.add(messageId);
|
||||
const group = groups.get(messageId);
|
||||
if (group && fingerprint(group.text) === expectedFingerprint) occurrence++;
|
||||
}
|
||||
}
|
||||
cursor = typeof entry.parentUuid === "string" ? entry.parentUuid : undefined;
|
||||
}
|
||||
return occurrence;
|
||||
}
|
||||
|
||||
function resolveFromFullHistory(
|
||||
entries: SessionStoreEntry[],
|
||||
candidates: string[],
|
||||
point: ForkPoint,
|
||||
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
|
||||
): string | undefined {
|
||||
const groups = assistantGroups(entries, messageIdForGrouping);
|
||||
const exact = candidates.map((candidate) => groups.get(candidate)?.uuid).find(Boolean);
|
||||
if (exact) return exact;
|
||||
if (!point.messageFingerprint || !point.messageOccurrence) return undefined;
|
||||
|
||||
const fingerprintMatches = [...groups.values()].filter(
|
||||
(group) => group.uuid && fingerprint(group.text) === point.messageFingerprint,
|
||||
);
|
||||
// If only one persisted message has this content, the fingerprint already
|
||||
// identifies it even when an older client supplied an incompatible index.
|
||||
if (fingerprintMatches.length === 1) return fingerprintMatches[0]?.uuid;
|
||||
|
||||
const occurrenceMatches = fingerprintMatches.filter(
|
||||
(group) =>
|
||||
group.uuid &&
|
||||
fingerprintOccurrenceOnBranch(
|
||||
group.uuid,
|
||||
point.messageFingerprint!,
|
||||
entries,
|
||||
groups,
|
||||
messageIdForGrouping,
|
||||
) === point.messageOccurrence,
|
||||
);
|
||||
return occurrenceMatches.length === 1 ? occurrenceMatches[0]?.uuid : undefined;
|
||||
}
|
||||
|
||||
export async function forkSession(
|
||||
params: ForkSessionRequest,
|
||||
dependencies: ForkSessionDependencies,
|
||||
): Promise<ForkSessionResponse> {
|
||||
const timing = new SessionTiming(dependencies.logger, "fork", params.sessionId);
|
||||
const point = forkPoint(params._meta);
|
||||
if (!point) {
|
||||
const forked = await forkClaudeSession(params.sessionId, { dir: params.cwd });
|
||||
timing.phase("sdk-fork", " resolution=latest");
|
||||
return { sessionId: forked.sessionId };
|
||||
}
|
||||
|
||||
const candidateIds = forkPointMessageIdCandidates(point.messageId);
|
||||
const liveUuid = candidateIds
|
||||
.map((candidateId) => dependencies.liveMessageIdToUuid?.get(candidateId))
|
||||
.find(Boolean);
|
||||
const history = liveUuid
|
||||
? undefined
|
||||
: await getSessionMessages(params.sessionId, { dir: params.cwd });
|
||||
timing.phase("active-history", ` resolution=${liveUuid ? "live" : "pending"}`);
|
||||
const messageUuid =
|
||||
liveUuid ??
|
||||
candidateIds
|
||||
.map(
|
||||
(candidateId) =>
|
||||
history
|
||||
?.slice()
|
||||
.reverse()
|
||||
.find((message) => dependencies.messageIdForGrouping(message) === candidateId)?.uuid,
|
||||
)
|
||||
.find(Boolean);
|
||||
const fullHistoryUuid = messageUuid
|
||||
? undefined
|
||||
: resolveFromFullHistory(
|
||||
await loadFullSessionHistory(params.sessionId, params.cwd),
|
||||
candidateIds,
|
||||
point,
|
||||
dependencies.messageIdForGrouping,
|
||||
);
|
||||
timing.phase(
|
||||
"full-history",
|
||||
` resolution=${liveUuid ? "live" : messageUuid ? "active" : fullHistoryUuid ? "full" : "missing"}`,
|
||||
);
|
||||
|
||||
if (!messageUuid && !fullHistoryUuid) {
|
||||
throw RequestError.invalidParams(
|
||||
{ messageId: point.messageId },
|
||||
`Fork point message ${point.messageId} was not found in session ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const forked = await forkClaudeSession(params.sessionId, {
|
||||
dir: params.cwd,
|
||||
upToMessageId: messageUuid ?? fullHistoryUuid,
|
||||
});
|
||||
timing.phase("sdk-fork");
|
||||
return { sessionId: forked.sessionId };
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk";
|
||||
import type { SDKActiveGoalMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const GOAL_EXTENSION_VERSION = 1 as const;
|
||||
export const GOAL_CONTROL_METHOD = "_session/goal";
|
||||
|
||||
export const GOAL_ACTIONS = ["set", "clear"] as const;
|
||||
export type GoalAction = (typeof GOAL_ACTIONS)[number];
|
||||
|
||||
export type GoalCapability = {
|
||||
version: typeof GOAL_EXTENSION_VERSION;
|
||||
controlMethod: typeof GOAL_CONTROL_METHOD;
|
||||
actions: GoalAction[];
|
||||
};
|
||||
|
||||
export type GoalStatus = "active" | "paused" | "blocked" | "limited" | "complete";
|
||||
|
||||
export type GoalSnapshot = {
|
||||
objective: string;
|
||||
status: GoalStatus;
|
||||
iterations?: number;
|
||||
lastReason?: string | null;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
tokenBudget?: number | null;
|
||||
tokensUsed?: number;
|
||||
timeUsedSeconds?: number;
|
||||
controlMethod: typeof GOAL_CONTROL_METHOD;
|
||||
};
|
||||
|
||||
export type GoalRequest =
|
||||
{ sessionId: string; action: "clear" } | { sessionId: string; action: "set"; objective: string };
|
||||
|
||||
export type GoalControlResponse = Record<string, never>;
|
||||
|
||||
export function goalUpdateFromPrompt(prompt: string): GoalSnapshot | null | undefined {
|
||||
const match = /^\/goal(?:\s+([\s\S]*))?$/.exec(prompt);
|
||||
const argument = match?.[1]?.trim();
|
||||
if (!argument) {
|
||||
return undefined;
|
||||
}
|
||||
if (argument === "clear") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
objective: argument,
|
||||
status: "active",
|
||||
controlMethod: GOAL_CONTROL_METHOD,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGoalRequest(params: unknown): GoalRequest {
|
||||
if (!params || typeof params !== "object") {
|
||||
throw RequestError.invalidParams(undefined, "goal params must be an object");
|
||||
}
|
||||
const { sessionId, action, objective } = params as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "goal params require a non-empty sessionId");
|
||||
}
|
||||
if (!GOAL_ACTIONS.includes(action as GoalAction)) {
|
||||
throw RequestError.invalidParams(undefined, 'goal action must be "set" or "clear"');
|
||||
}
|
||||
if (action === "set" && (typeof objective !== "string" || objective.trim().length === 0)) {
|
||||
throw RequestError.invalidParams(undefined, 'goal action "set" requires a non-empty objective');
|
||||
}
|
||||
return action === "set"
|
||||
? { sessionId, action, objective: (objective as string).trim() }
|
||||
: { sessionId, action: "clear" };
|
||||
}
|
||||
|
||||
export function toGoalSnapshot(message: SDKActiveGoalMessage): GoalSnapshot | null {
|
||||
if (message.value === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
objective: message.value.condition.trim(),
|
||||
status: "active",
|
||||
iterations: message.value.iterations,
|
||||
lastReason: message.value.last_reason ?? null,
|
||||
createdAt: message.value.set_at,
|
||||
controlMethod: GOAL_CONTROL_METHOD,
|
||||
};
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* `--hide-claude-auth`: this integration must never bill a claude.ai
|
||||
* subscription. Under the flag the agent hides the claude.ai login method from
|
||||
* `initialize`, and refuses with `authRequired` every session and every turn
|
||||
* that a subscription would pay for. `acp-agent.ts` decides when the guard
|
||||
* applies (flag on, no provider override); this module decides what it refuses.
|
||||
*
|
||||
* The guard judges the `AccountInfo` that the SDK caches at `initialize`. That
|
||||
* cache never changes during the life of a query, but the CLI itself picks up
|
||||
* a login or logout made elsewhere. So the cached account can go stale: a
|
||||
* session started on an API key keeps passing the guard after the user swaps
|
||||
* the key for a claude.ai login, and the subscription pays.
|
||||
*
|
||||
* `authStatus` cannot fix this: it only reports to the client, and the guard
|
||||
* does not read it. The agent therefore recreates the query whenever the
|
||||
* account may have changed, and the new `initialize` gives the guard a current
|
||||
* account. Two signals trigger it:
|
||||
*
|
||||
* - A turn fails with `auth_required`, which means a sign-out. The query is
|
||||
* closed at once and recreated on the next turn.
|
||||
* - The `claude auth status` check that runs at the start of every prompt (the
|
||||
* same check that feeds `authStatus`) reports a different kind of identity
|
||||
* than the session started with. The session is flagged and recreated at the
|
||||
* next turn boundary, so a running turn is never interrupted.
|
||||
*
|
||||
* The recreation resumes the conversation, or starts a new one under the same
|
||||
* session id when the CLI never saved it. Session creation also refuses an
|
||||
* account with no credential this integration accepts. So a signed-out session
|
||||
* never exists, and a sign-in always leads to a fresh `initialize`.
|
||||
*
|
||||
* The check is not awaited, so a credential swapped after it ran stays unseen
|
||||
* until the next prompt. Awaiting it would add a CLI run to every turn.
|
||||
*/
|
||||
|
||||
import { RequestError } from "@agentclientprotocol/sdk";
|
||||
import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export function shouldHideClaudeAuth(): boolean {
|
||||
return process.argv.includes("--hide-claude-auth");
|
||||
}
|
||||
|
||||
/**
|
||||
* `data.reason` carried by the `authRequired` JSON-RPC error when the flag
|
||||
* blocks a turn. From the client's point of view the agent is logged out (same
|
||||
* error code and flow as a signed-out CLI); the reason lets it render a
|
||||
* different hint. A string enum so further reasons can be added without a
|
||||
* shape change.
|
||||
*/
|
||||
export const CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON = "claude_subscription_not_supported";
|
||||
export type AuthRequiredReason = typeof CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON;
|
||||
|
||||
export const CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE =
|
||||
"This integration does not support using claude.ai subscriptions.";
|
||||
|
||||
/** The message of the plain sign-out refusal. It carries no `reason`: the
|
||||
* account holds nothing this integration can bill, which is the ordinary
|
||||
* signed-out state every ACP client already knows how to answer. */
|
||||
export const CLAUDE_LOGIN_REQUIRED_MESSAGE =
|
||||
"Sign in with an API key or a Console account to use this integration.";
|
||||
|
||||
/**
|
||||
* The `apiKeySource` values that outrank a stored subscription.
|
||||
*
|
||||
* The SDK documents its `ApiKeySource` type as the origin of the credential
|
||||
* used for API requests. `ANTHROPIC_API_KEY` is the environment variable,
|
||||
* `apiKeyHelper` is the configured helper command, and `/login managed key` is
|
||||
* a key that `/login` created with an Anthropic Console account. Each of the
|
||||
* three is an API key that pays instead of the subscription. Every other
|
||||
* member means no API key is in use: `none`, and the legacy `user`, `project`,
|
||||
* `org`, `temporary` and `oauth` that current CLIs never emit.
|
||||
*/
|
||||
const API_KEY_SOURCES_ABOVE_SUBSCRIPTION: ReadonlySet<string> = new Set([
|
||||
"ANTHROPIC_API_KEY",
|
||||
"apiKeyHelper",
|
||||
"/login managed key",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when a turn on this account is paid by a claude.ai subscription.
|
||||
*
|
||||
* The SDK types `apiKeySource` and `tokenSource` as an open `string`, so this
|
||||
* is an allowlist. An account bills the subscription only when every field
|
||||
* proves it, and an unknown value keeps the guard on.
|
||||
*
|
||||
* - `subscriptionType` must be set. Without it the CLI reports no subscription.
|
||||
* - `tokenSource` must be absent. The CLI sets it in place of the subscription
|
||||
* when a bearer or OAuth environment token pays for the turn.
|
||||
* - `apiProvider` must be absent or `firstParty`. The SDK documents that the
|
||||
* Anthropic OAuth login applies only to `firstParty`; for a third-party
|
||||
* backend such as `bedrock`, `vertex`, `foundry` or `gateway` the other
|
||||
* fields are absent and the authentication is external.
|
||||
* - `apiKeySource` must not be one of {@link API_KEY_SOURCES_ABOVE_SUBSCRIPTION}.
|
||||
* Claude Code's credential precedence puts those keys above the `/login`
|
||||
* subscription, so the key pays and the stored subscription is inert.
|
||||
*/
|
||||
export function billsClaudeSubscription(account: AccountInfo | undefined): boolean {
|
||||
if (!account?.subscriptionType) {
|
||||
return false;
|
||||
}
|
||||
if (account.tokenSource) {
|
||||
return false;
|
||||
}
|
||||
if (account.apiProvider !== undefined && account.apiProvider !== "firstParty") {
|
||||
return false;
|
||||
}
|
||||
return !API_KEY_SOURCES_ABOVE_SUBSCRIPTION.has(account.apiKeySource ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the account already holds a credential this integration accepts:
|
||||
* a third-party backend, a bearer or OAuth environment token, or one of the
|
||||
* API key sources in {@link API_KEY_SOURCES_ABOVE_SUBSCRIPTION}.
|
||||
*
|
||||
* False therefore covers both the signed-out CLI and the account whose only
|
||||
* credential is a claude.ai subscription. {@link billsClaudeSubscription} runs
|
||||
* first and separates the two, so a caller that reaches a `false` here is
|
||||
* looking at a session with no way to pay.
|
||||
*/
|
||||
export function holdsNonSubscriptionCredential(account: AccountInfo | undefined): boolean {
|
||||
if (!account) {
|
||||
return false;
|
||||
}
|
||||
if (account.apiProvider !== undefined && account.apiProvider !== "firstParty") {
|
||||
return true;
|
||||
}
|
||||
if (account.tokenSource) {
|
||||
return true;
|
||||
}
|
||||
return API_KEY_SOURCES_ABOVE_SUBSCRIPTION.has(account.apiKeySource ?? "");
|
||||
}
|
||||
|
||||
export function claudeSubscriptionNotSupportedError(): RequestError {
|
||||
return RequestError.authRequired(
|
||||
{ reason: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON satisfies AuthRequiredReason },
|
||||
CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
|
||||
);
|
||||
}
|
||||
|
||||
/** The signed-out refusal. Deliberately without `data`, so a client that reads
|
||||
* `data.reason` sees the plain ACP sign-out it already handles. */
|
||||
export function claudeLoginRequiredError(): RequestError {
|
||||
return RequestError.authRequired(undefined, CLAUDE_LOGIN_REQUIRED_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The state the guard keeps for one session. It lives on the session so that a
|
||||
* concurrent pair of turns shares one account read, and so
|
||||
* that each degraded-guard warning is logged once.
|
||||
*/
|
||||
export type ClaudeSubscriptionGuardState = {
|
||||
/** The in-flight guard run. Set before the first `await`. */
|
||||
pendingRun?: Promise<RequestError | undefined>;
|
||||
/** True after the "no account source" warning was logged for this session. */
|
||||
degradedWarningLogged?: boolean;
|
||||
};
|
||||
|
||||
export type GuardLogger = {
|
||||
error: (...args: unknown[]) => void;
|
||||
warn?: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
function warn(logger: GuardLogger, message: string): void {
|
||||
(logger.warn ?? logger.error).call(logger, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report once per session that the guard cannot read the account at all. The
|
||||
* guard is then absent, not passing. The CLI build decides this, not the user,
|
||||
* so a message on every turn would only add noise.
|
||||
*/
|
||||
export function warnClaudeSubscriptionGuardDegraded(options: {
|
||||
sessionId: string;
|
||||
guardState: ClaudeSubscriptionGuardState;
|
||||
logger: GuardLogger;
|
||||
cause: string;
|
||||
}): void {
|
||||
const { guardState } = options;
|
||||
if (guardState.degradedWarningLogged) {
|
||||
return;
|
||||
}
|
||||
guardState.degradedWarningLogged = true;
|
||||
warn(
|
||||
options.logger,
|
||||
`Session ${options.sessionId}: the --hide-claude-auth subscription guard is degraded ` +
|
||||
`(${options.cause}). This session cannot detect a claude.ai subscription.`,
|
||||
);
|
||||
}
|
||||
|
||||
type GuardOptions = {
|
||||
sessionId: string;
|
||||
query: Pick<Query, "accountInfo">;
|
||||
guardState: ClaudeSubscriptionGuardState;
|
||||
logger: GuardLogger;
|
||||
/** Display hook: called with the account the guard just read, before the
|
||||
* guard decides on it. It exists so a refused turn still reports which
|
||||
* account it was refused for. Purely observational — this module knows
|
||||
* nothing about what the caller does with it, and a throw from here never
|
||||
* changes the verdict. */
|
||||
onAccount?: (account: AccountInfo) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-turn guard for a live session.
|
||||
*
|
||||
* It decides on the account the SDK cached at `initialize`, which is this
|
||||
* integration's source of truth. That account is a constant for the life of
|
||||
* the query, so the check costs microseconds. It still earns its place: it
|
||||
* covers a session created under a provider override that `providers/disable`
|
||||
* later removed, so the create-time check never ran for the routing in force
|
||||
* now. A sign-out ends the query (see `markSessionForSignOutRespawn` in
|
||||
* `acp-agent.ts`), so the next turn recreates it and `initialize` reports the
|
||||
* real account. This guard does not see the residual case the module header
|
||||
* names either.
|
||||
*
|
||||
* A turn is refused when the account bills a subscription, and also when it
|
||||
* holds nothing this integration accepts, which is the user signing out in
|
||||
* mid-session. The refusal mirrors the signed-out path exactly (see
|
||||
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
|
||||
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
|
||||
* flow. The guard sends no separate access message.
|
||||
*
|
||||
* Fails open when no account can be read at all: session creation already
|
||||
* refused every account without a usable credential, and an unavailable probe
|
||||
* must not block a session that passed that check.
|
||||
*/
|
||||
export async function refuseClaudeSubscriptionTurn(options: GuardOptions): Promise<void> {
|
||||
// Concurrent turns must not each re-read the account and each publish a row.
|
||||
// Store the run before the first await, and let the others await the same one.
|
||||
const { guardState } = options;
|
||||
const run = (guardState.pendingRun ??= evaluateGuard(options).finally(() => {
|
||||
if (guardState.pendingRun === run) {
|
||||
guardState.pendingRun = undefined;
|
||||
}
|
||||
}));
|
||||
const refusal = await run;
|
||||
if (refusal) {
|
||||
throw refusal;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the account and return the error for the refused turn.
|
||||
* Never throws: an unreadable account fails open. */
|
||||
async function evaluateGuard(options: GuardOptions): Promise<RequestError | undefined> {
|
||||
const account = await readGuardAccount(options);
|
||||
if (!account) {
|
||||
return undefined;
|
||||
}
|
||||
// Before the decision, so the client learns the identity even when the next
|
||||
// lines refuse the turn. A display hook must never fail a guard.
|
||||
try {
|
||||
options.onAccount?.(account);
|
||||
} catch (error) {
|
||||
options.logger.error(
|
||||
`Session ${options.sessionId}: the guard's onAccount hook threw:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (billsClaudeSubscription(account)) {
|
||||
return claudeSubscriptionNotSupportedError();
|
||||
}
|
||||
if (!holdsNonSubscriptionCredential(account)) {
|
||||
return claudeLoginRequiredError();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The account the SDK cached at `initialize`. `undefined` when the query
|
||||
* cannot answer at all. */
|
||||
async function readGuardAccount(options: GuardOptions): Promise<AccountInfo | undefined> {
|
||||
const { query } = options;
|
||||
if (typeof query.accountInfo !== "function") {
|
||||
warnClaudeSubscriptionGuardDegraded({
|
||||
...options,
|
||||
cause: "the SDK query has no accountInfo method",
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await query.accountInfo();
|
||||
} catch (error) {
|
||||
options.logger.error(
|
||||
`Session ${options.sessionId}: accountInfo failed; skipping the subscription check for this turn:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
111
src/index.ts
111
src/index.ts
@@ -1,108 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { runAcp as runAcp } from "./acp-agent.js";
|
||||
runAcp();
|
||||
|
||||
import { claudeCliPath } from "./acp-agent.js";
|
||||
import { applyManagedPolicyEnv } from "./managed-policy.js";
|
||||
import { serveAcp } from "./serve.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// `--cli` is checked first so that `--version`/`-v` (and any other flags) are
|
||||
// forwarded to the wrapped native CLI rather than swallowed by our own version
|
||||
// handler below. Our version flag only applies when not delegating.
|
||||
if (process.argv.includes("--cli")) {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const args = process.argv.slice(2).filter((arg) => arg !== "--cli");
|
||||
const child = spawn(await claudeCliPath(), args, { stdio: "inherit" });
|
||||
|
||||
const signals =
|
||||
process.platform === "win32"
|
||||
? (["SIGINT", "SIGTERM"] as const)
|
||||
: (["SIGINT", "SIGTERM", "SIGHUP"] as const);
|
||||
for (const sig of signals) {
|
||||
process.on(sig, () => {
|
||||
if (!child.killed) child.kill(sig);
|
||||
});
|
||||
}
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal && process.platform !== "win32") {
|
||||
// Remove our listener so re-raising actually terminates instead of
|
||||
// re-entering the no-op handler, which would let us exit with code 0
|
||||
// instead of the signal's conventional 128+N.
|
||||
process.removeAllListeners(signal);
|
||||
process.kill(process.pid, signal);
|
||||
} else {
|
||||
process.exit(code ?? 1);
|
||||
}
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
||||
console.log(packageJson.version);
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Apply env vars from the managed-policy tier before any SDK call so the
|
||||
// SDK subprocess inherits them. Reading the tier is best-effort: a transient
|
||||
// failure leaves the agent running with no policy env instead of aborting
|
||||
// module evaluation here, before any ACP traffic.
|
||||
await applyManagedPolicyEnv();
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.warn = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
const logDirectory = process.env.CLAUDE_AGENT_LOGS;
|
||||
const logger = logDirectory
|
||||
? (() => {
|
||||
mkdirSync(logDirectory, { recursive: true });
|
||||
const logFile = join(logDirectory, "agent.log");
|
||||
const writeLog = (...args: unknown[]) => {
|
||||
const rendered = args
|
||||
.map((arg) => (arg instanceof Error ? (arg.stack ?? arg.message) : String(arg)))
|
||||
.join(" ");
|
||||
appendFileSync(logFile, `${new Date().toISOString()} pid=${process.pid} ${rendered}\n`);
|
||||
};
|
||||
return {
|
||||
log: writeLog,
|
||||
error: (...args: unknown[]) => {
|
||||
console.error(...args);
|
||||
writeLog(...args);
|
||||
},
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
logger?.log("Claude ACP started");
|
||||
|
||||
// The draft ACP v2 is opt-in while it is a draft.
|
||||
const acp = serveAcp(logger, {
|
||||
experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1",
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
await acp.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.
|
||||
acp.closed.then(shutdown);
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
// Keep process alive while connection is open
|
||||
process.stdin.resume();
|
||||
}
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
|
||||
32
src/lib.ts
32
src/lib.ts
@@ -1,32 +0,0 @@
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
isLocalCommandMetadata,
|
||||
stripLocalCommandMetadata,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
|
||||
// The `authStatus` extension's wire surface: the notification's method name and
|
||||
// the payload types needed to read the traffic. The mappers stay internal.
|
||||
export {
|
||||
AUTH_STATUS_UPDATE_METHOD,
|
||||
type AuthStatus,
|
||||
type AuthStatusAccount,
|
||||
type AuthStatusKind,
|
||||
type AuthStatusUpdateNotification,
|
||||
} from "./auth-status.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
@@ -1,34 +0,0 @@
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/**
|
||||
* Applies the managed-policy tier's env vars to `process.env` before any SDK
|
||||
* call, so the SDK subprocess inherits them. Going through `resolveSettings`
|
||||
* rather than a raw read of `managed-settings.json` also picks up MDM sources on
|
||||
* macOS and `HKLM`/`HKCU` on Windows.
|
||||
*
|
||||
* Reading the tier is best-effort by nature: on a host with no MDM and no
|
||||
* managed policy the result is empty and the agent behaves identically. So a
|
||||
* transient failure to read it — `EINTR`, `EMFILE`, `EAGAIN` — must leave the
|
||||
* agent running with no policy env rather than stop it from starting.
|
||||
*
|
||||
* That guard matters because the caller awaits this at module scope in an ESM
|
||||
* entry point, where a rejection aborts module evaluation and the process exits
|
||||
* before serving any ACP traffic. `SettingsManager.loadAllSettings` wraps the
|
||||
* same SDK call for the same reason.
|
||||
*/
|
||||
export async function applyManagedPolicyEnv(
|
||||
resolve: typeof resolveSettings = resolveSettings,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): Promise<void> {
|
||||
let policy: Awaited<ReturnType<typeof resolveSettings>>;
|
||||
try {
|
||||
policy = await resolve({ settingSources: [] });
|
||||
} catch (error) {
|
||||
// stderr, so the ACP stream on stdout stays clean.
|
||||
console.error("Failed to resolve managed policy settings:", error);
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
import type { AvailableCommand } from "@agentclientprotocol/sdk";
|
||||
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { escapeMarkdown } from "./usage-markdown.js";
|
||||
import { raceTimeoutAndAbort } from "./utils.js";
|
||||
|
||||
/** The `/mcp` entry of `available_commands_update`. The adapter shows the
|
||||
* servers as a list, and runs a reconnect, an enable, or a disable through
|
||||
* the SDK control API. */
|
||||
export const MCP_AVAILABLE_COMMAND: AvailableCommand = {
|
||||
name: "mcp",
|
||||
description: "Show the MCP servers and their status, or reconnect, enable, or disable a server",
|
||||
input: { hint: "[reconnect|enable|disable [<server>|all]]" },
|
||||
};
|
||||
|
||||
/** The time that the status read after a `/mcp` command can take. */
|
||||
const MCP_STATUS_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The time that one reconnect, enable, or disable can take. */
|
||||
const MCP_ACTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** A `/mcp` prompt that gets a replacement for its Claude Code text. `all` is
|
||||
* true for `all` and for a reconnect without a server name, as in Claude
|
||||
* Code. `server` is undefined for an enable or a disable without a name. */
|
||||
export type McpCommand =
|
||||
| { action: "status" }
|
||||
| { action: "reconnect" | "enable" | "disable"; all: boolean; server?: string };
|
||||
|
||||
/** Parse a prompt that is exactly `/mcp`, or `/mcp reconnect`, `/mcp enable`,
|
||||
* or `/mcp disable` with an optional server name or `all`. Any other `/mcp`
|
||||
* argument gives null, and the prompt gets no replacement. */
|
||||
export function parseMcpCommand(text: string): McpCommand | null {
|
||||
// The first word is `/mcp` only when the text starts with it after the
|
||||
// leading whitespace. Checking that first keeps a long prompt from being
|
||||
// split into words. `trimStart` removes the characters that `\s` matches.
|
||||
if (!text.trimStart().startsWith("/mcp")) return null;
|
||||
const words = text.trim().split(/\s+/);
|
||||
if (words[0] !== "/mcp") return null;
|
||||
if (words.length === 1) return { action: "status" };
|
||||
const action = words[1]?.toLowerCase();
|
||||
if (action !== "reconnect" && action !== "enable" && action !== "disable") return null;
|
||||
const server = words.slice(2).join(" ");
|
||||
if (server === "all" || (server === "" && action === "reconnect")) {
|
||||
return { action, all: true };
|
||||
}
|
||||
return server === "" ? { action, all: false } : { action, all: false, server };
|
||||
}
|
||||
|
||||
/** Read the MCP server status for the list after a `/mcp` command. Resolves
|
||||
* to null when the read fails, takes too long, or `signal` aborts. Then the
|
||||
* turn keeps the text of Claude Code. */
|
||||
export async function readMcpServerStatus(
|
||||
query: Pick<Query, "mcpServerStatus">,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpServerStatus[] | null> {
|
||||
if (signal.aborted) return null;
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(
|
||||
query.mcpServerStatus(),
|
||||
MCP_STATUS_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
if (outcome.type === "timeout") {
|
||||
logError("The MCP server status read timed out; keeping the Claude Code text of /mcp");
|
||||
}
|
||||
return outcome.type === "done" ? outcome.value : null;
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
logError(`The MCP server status read failed; keeping the Claude Code text of /mcp: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The known statuses in the order of the groups and of the summary counts.
|
||||
* A disabled server is not a group. It is on one line at the end. */
|
||||
const GROUP_ORDER: McpServerStatus["status"][] = ["failed", "needs-auth", "pending", "connected"];
|
||||
|
||||
const GROUP_TITLES: Partial<Record<McpServerStatus["status"], string>> = {
|
||||
failed: "Failed",
|
||||
"needs-auth": "Needs authentication",
|
||||
pending: "Connecting",
|
||||
connected: "Connected",
|
||||
};
|
||||
|
||||
const SUMMARY_LABELS: Record<McpServerStatus["status"], string> = {
|
||||
connected: "connected",
|
||||
failed: "failed",
|
||||
"needs-auth": "need authentication",
|
||||
pending: "connecting",
|
||||
disabled: "disabled",
|
||||
};
|
||||
|
||||
const KNOWN_STATUSES: string[] = [...GROUP_ORDER, "disabled"];
|
||||
|
||||
const MAX_ERROR_LENGTH = 160;
|
||||
|
||||
/** The longest segment that can be a wrapper. A longer segment is content. */
|
||||
const MAX_WRAPPER_LENGTH = 60;
|
||||
|
||||
/** True for a server that a `/mcp reconnect` without a name must retry. */
|
||||
function needsReconnect(status: McpServerStatus): boolean {
|
||||
return (
|
||||
status.status === "failed" || status.status === "pending" || status.status === "needs-auth"
|
||||
);
|
||||
}
|
||||
|
||||
/** The text as a Markdown code span. The fence is longer than the longest
|
||||
* backtick run in the text, so the span shows the text unchanged. The user
|
||||
* can copy a server name from it into `/mcp reconnect`. */
|
||||
function codeSpan(text: string): string {
|
||||
const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
||||
const fence = "`".repeat(longestRun + 1);
|
||||
// CommonMark strips one space from each end of a span, so a pad keeps the text unchanged.
|
||||
const pad = text.startsWith("`") || text.endsWith("`") || /^ .*[^ ].* $/.test(text) ? " " : "";
|
||||
return `${fence}${pad}${text}${pad}${fence}`;
|
||||
}
|
||||
|
||||
function isErrorCode(segment: string): boolean {
|
||||
return /^-?\d+$/.test(segment);
|
||||
}
|
||||
|
||||
/** True for a segment that only wraps the real error, such as
|
||||
* `MCP startup failed`, `McpError`, `MCP error -32001`, an error code, or a
|
||||
* Rust or TypeScript type path. */
|
||||
function isWrapperSegment(segment: string): boolean {
|
||||
return (
|
||||
isErrorCode(segment) ||
|
||||
segment.includes("::") ||
|
||||
(segment.length <= MAX_WRAPPER_LENGTH &&
|
||||
!/[.!?]/.test(segment) &&
|
||||
/(failed|error|exception)(\s+-?\d+)?$/i.test(segment))
|
||||
);
|
||||
}
|
||||
|
||||
/** Cut the text to `MAX_ERROR_LENGTH` code points, so a surrogate pair stays whole. */
|
||||
function truncate(text: string): string {
|
||||
const chars = Array.from(text);
|
||||
if (chars.length <= MAX_ERROR_LENGTH) return text;
|
||||
const cut = chars.slice(0, MAX_ERROR_LENGTH - 1).join("");
|
||||
return `${cut.trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** Make an MCP error short and readable for one list item. The result has
|
||||
* no leading wrapper segments, at most two sentences, and at most
|
||||
* `MAX_ERROR_LENGTH` code points. A wrapper before an error code stays, so
|
||||
* an error code is never alone. The result is empty for an error with only
|
||||
* whitespace. */
|
||||
export function cleanMcpError(error: string): string {
|
||||
const segments = error.replace(/\s+/g, " ").trim().split(": ");
|
||||
let start = 0;
|
||||
while (
|
||||
start < segments.length - 1 &&
|
||||
isWrapperSegment(segments[start]!) &&
|
||||
!segments.slice(start + 1).every(isErrorCode)
|
||||
) {
|
||||
start++;
|
||||
}
|
||||
const content = segments.slice(start).join(": ");
|
||||
return truncate(
|
||||
content
|
||||
.split(/(?<=[.!?]) /)
|
||||
.slice(0, 2)
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
function toolCount(status: McpServerStatus): string | undefined {
|
||||
if (!status.tools) return undefined;
|
||||
return status.tools.length === 1 ? "1 tool" : `${status.tools.length} tools`;
|
||||
}
|
||||
|
||||
/** The server name as inline code. Claude Code keys the servers by name, so
|
||||
* two servers have the same name only from different scopes. Then the
|
||||
* scope tells them apart. */
|
||||
function serverName(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const origin = status.scope ?? status.source;
|
||||
return duplicates.has(status.name) && origin
|
||||
? `${codeSpan(status.name)} (${escapeMarkdown(origin)})`
|
||||
: codeSpan(status.name);
|
||||
}
|
||||
|
||||
function serverItem(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const error = status.error ? cleanMcpError(status.error) : "";
|
||||
const details = [error === "" ? undefined : escapeMarkdown(error), toolCount(status)]
|
||||
.filter((detail) => detail !== undefined)
|
||||
.join("; ");
|
||||
const name = serverName(status, duplicates);
|
||||
return details === "" ? `- ${name}` : `- ${name}: ${details}`;
|
||||
}
|
||||
|
||||
function summaryLine(statuses: McpServerStatus[]): string {
|
||||
// The counts follow the group order. An unknown status also counts, so the counts add up to the total.
|
||||
const counts = [...GROUP_ORDER, ...unknownStatuses(statuses), "disabled"]
|
||||
.map((state) => ({
|
||||
state,
|
||||
count: statuses.filter((status) => status.status === state).length,
|
||||
}))
|
||||
.filter(({ count }) => count > 0);
|
||||
const parts = counts.map(({ state, count }) =>
|
||||
count === 1 && state === "needs-auth"
|
||||
? "1 needs authentication"
|
||||
: `${count} ${SUMMARY_LABELS[state as McpServerStatus["status"]] ?? escapeMarkdown(state)}`,
|
||||
);
|
||||
return `**MCP servers:** ${statuses.length} (${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
/** The statuses that this adapter does not know, in the SDK order. The SDK
|
||||
* can report a new status before the adapter knows it. */
|
||||
function unknownStatuses(statuses: McpServerStatus[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
statuses
|
||||
.map((status) => status.status as string)
|
||||
.filter((state) => !KNOWN_STATUSES.includes(state)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** The group title of a status that the adapter does not know. It starts with a capital letter, as a known title does. */
|
||||
function unknownTitle(state: string): string {
|
||||
return escapeMarkdown(state.charAt(0).toUpperCase() + state.slice(1));
|
||||
}
|
||||
|
||||
/** Markdown for `/mcp`: a summary line, one group of servers for each
|
||||
* status, and the disabled servers on one line. A list reads better than a
|
||||
* table in a narrow chat. */
|
||||
export function formatMcpStatus(statuses: McpServerStatus[]): string {
|
||||
if (statuses.length === 0) return "No MCP servers are configured.";
|
||||
const blocks: string[] = [];
|
||||
const names = statuses.map((status) => status.name);
|
||||
const duplicates = new Set(names.filter((name, index) => names.indexOf(name) !== index));
|
||||
blocks.push(summaryLine(statuses));
|
||||
const groups: [string, string][] = [
|
||||
...GROUP_ORDER.map((state): [string, string] => [state, GROUP_TITLES[state]!]),
|
||||
...unknownStatuses(statuses).map((state): [string, string] => [state, unknownTitle(state)]),
|
||||
];
|
||||
for (const [state, title] of groups) {
|
||||
const members = statuses.filter((status) => status.status === state);
|
||||
if (members.length === 0) continue;
|
||||
blocks.push(
|
||||
[`**${title}**`, ...members.map((status) => serverItem(status, duplicates))].join("\n"),
|
||||
);
|
||||
}
|
||||
const disabled = statuses.filter((status) => status.status === "disabled");
|
||||
if (disabled.length > 0) {
|
||||
blocks.push(
|
||||
`**Disabled:** ${disabled.map((status) => serverName(status, duplicates)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (statuses.some(needsReconnect)) {
|
||||
blocks.push(
|
||||
"Run `/mcp reconnect <server>` to reconnect one server, or `/mcp reconnect` to reconnect every server that is not connected and not disabled.",
|
||||
);
|
||||
}
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
/** The SDK query methods that `/mcp` uses. */
|
||||
export type McpControlQuery = Pick<
|
||||
Query,
|
||||
"mcpServerStatus" | "reconnectMcpServer" | "toggleMcpServer"
|
||||
>;
|
||||
|
||||
/** The result of {@link runMcpCommand}. `markdown` is null when the turn
|
||||
* keeps the text of Claude Code. `reconnected` holds the servers that the
|
||||
* adapter tried to reconnect. */
|
||||
export type McpCommandOutcome = { markdown: string | null; reconnected: string[] };
|
||||
|
||||
type CallResult = { type: "done" } | { type: "failed"; error: string } | { type: "aborted" };
|
||||
|
||||
/** Wait for one control call. The wait stops when `signal` aborts or the
|
||||
* call takes more than {@link MCP_ACTION_TIMEOUT_MS}. */
|
||||
async function awaitCall(call: () => Promise<void>, signal: AbortSignal): Promise<CallResult> {
|
||||
if (signal.aborted) return { type: "aborted" };
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(call(), MCP_ACTION_TIMEOUT_MS, signal);
|
||||
if (outcome.type === "timeout") return { type: "failed", error: "The request timed out." };
|
||||
return outcome.type === "done" ? { type: "done" } : outcome;
|
||||
} catch (error) {
|
||||
return signal.aborted
|
||||
? { type: "aborted" }
|
||||
: { type: "failed", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_DONE = { reconnect: "Reconnected", enable: "Enabled", disable: "Disabled" };
|
||||
|
||||
/** The Claude Code sentence that refuses an action in SDK mode. */
|
||||
const UNAVAILABLE_SENTENCE = /aren't available in this session/i;
|
||||
|
||||
/** A Claude Code sentence that points to a terminal, which an ACP client
|
||||
* does not have. */
|
||||
function isTerminalSentence(sentence: string): boolean {
|
||||
return /in the terminal/i.test(sentence) || sentence.includes("`/mcp reconnect all` here");
|
||||
}
|
||||
|
||||
function sentences(text: string): string[] {
|
||||
return text
|
||||
.trim()
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.filter((sentence) => sentence !== "");
|
||||
}
|
||||
|
||||
/** The servers that the action changes. A named server is always a target,
|
||||
* so the control API reports its own error. */
|
||||
function actionTargets(
|
||||
command: Extract<McpCommand, { all: boolean }>,
|
||||
statuses: McpServerStatus[],
|
||||
): string[] {
|
||||
if (!command.all) return command.server === undefined ? [] : [command.server];
|
||||
const names = statuses
|
||||
.filter((status) => {
|
||||
if (command.action === "reconnect") return needsReconnect(status);
|
||||
if (command.action === "enable") return status.status === "disabled";
|
||||
return status.status !== "disabled";
|
||||
})
|
||||
.map((status) => status.name);
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
function nothingToDoLine(action: McpCommand["action"]): string {
|
||||
if (action === "enable") return "Every MCP server is already enabled.";
|
||||
if (action === "disable") return "Every MCP server is already disabled.";
|
||||
return "Every MCP server is connected or disabled. There is nothing to reconnect.";
|
||||
}
|
||||
|
||||
/** Run a `/mcp` command after Claude Code sent `originalOutput`, its text for
|
||||
* the command. Claude Code refuses a reconnect, an enable, and a disable in
|
||||
* SDK mode, so the adapter runs them through the SDK control API. The result
|
||||
* of each server goes above the server list. An error of one server does
|
||||
* not stop the others. When the first status read fails or `signal`
|
||||
* aborts, the turn keeps the text of Claude Code. */
|
||||
export async function runMcpCommand(
|
||||
query: McpControlQuery,
|
||||
command: McpCommand,
|
||||
originalOutput: string,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpCommandOutcome> {
|
||||
const reconnected: string[] = [];
|
||||
const keep: McpCommandOutcome = { markdown: null, reconnected };
|
||||
if (command.action === "status") {
|
||||
// The list replaces the Claude Code summary line.
|
||||
const statuses = await readMcpServerStatus(query, signal, logError);
|
||||
return { markdown: statuses ? formatMcpStatus(statuses) : null, reconnected };
|
||||
}
|
||||
const before = await readMcpServerStatus(query, signal, logError);
|
||||
if (!before) return keep;
|
||||
|
||||
const cliSentences = sentences(originalOutput).filter(
|
||||
(sentence) => !isTerminalSentence(sentence),
|
||||
);
|
||||
const cliNamesUnknown = cliSentences.some((sentence) => /no MCP server named/i.test(sentence));
|
||||
const results: string[] = [];
|
||||
if (!command.all && command.server === undefined) {
|
||||
results.push(`Name a server or \`all\`, for example \`/mcp ${command.action} <server>\`.`);
|
||||
}
|
||||
const known = new Set(before.map((status) => status.name));
|
||||
const targets = actionTargets(command, before);
|
||||
if (command.all && targets.length === 0) results.push(nothingToDoLine(command.action));
|
||||
for (const server of targets) {
|
||||
if (!known.has(server)) {
|
||||
// Claude Code checks the name first. Its sentence then stays.
|
||||
if (!cliNamesUnknown) results.push(`There's no MCP server named ${codeSpan(server)}.`);
|
||||
continue;
|
||||
}
|
||||
if (command.action === "reconnect") reconnected.push(server);
|
||||
const call =
|
||||
command.action === "reconnect"
|
||||
? () => query.reconnectMcpServer(server)
|
||||
: () => query.toggleMcpServer(server, command.action === "enable");
|
||||
const result = await awaitCall(call, signal);
|
||||
if (result.type === "aborted") return keep;
|
||||
if (result.type === "done") {
|
||||
results.push(`- ${ACTION_DONE[command.action]} ${codeSpan(server)}`);
|
||||
} else {
|
||||
const error = cleanMcpError(result.error);
|
||||
const failure = `- Couldn't ${command.action} ${codeSpan(server)}`;
|
||||
results.push(error === "" ? failure : `${failure}: ${escapeMarkdown(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
const kept = cliSentences.filter((sentence) => !UNAVAILABLE_SENTENCE.test(sentence));
|
||||
if (kept.length > 0) blocks.push(kept.join(" "));
|
||||
if (results.length > 0) blocks.push(results.join("\n"));
|
||||
const after = await readMcpServerStatus(query, signal, logError);
|
||||
if (signal.aborted) return keep;
|
||||
if (after) blocks.push(formatMcpStatus(after));
|
||||
return { markdown: blocks.join("\n\n"), reconnected };
|
||||
}
|
||||
543
src/mcp-server.ts
Normal file
543
src/mcp-server.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import express from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
import { Server } from "node:http";
|
||||
import { ClaudeAcpAgent } from "./acp-agent.js";
|
||||
|
||||
export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
): Promise<Server> {
|
||||
// Create MCP server
|
||||
const server = new McpServer({
|
||||
name: "acp-mcp-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
"read",
|
||||
{
|
||||
title: "Read",
|
||||
description: `Reads the content of the given file in the project.
|
||||
|
||||
Never attempt to read a path that hasn't been previously mentioned.
|
||||
|
||||
In sessions with mcp__acp__read always use it instead of Read as it contains the most up-to-date contents.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to read."),
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Which line to start reading from. Omit to start from the beginning.",
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("How many lines to read. Omit for the whole file."),
|
||||
},
|
||||
annotations: {
|
||||
title: "Read file",
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
let content = await agent.client.readTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
limit: input.limit,
|
||||
line: input.offset,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
content.content +
|
||||
`
|
||||
|
||||
<system-reminder>
|
||||
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
|
||||
</system-reminder>`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Reading file failed: " + error.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"write",
|
||||
{
|
||||
title: "Write",
|
||||
description: `Writes content to the specified file in the project.
|
||||
|
||||
In sessions with mcp__acp__write always use it instead of Write as it will
|
||||
allow the user to conveniently review changes.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to write"),
|
||||
content: z.string().describe("The full content to write"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Write file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
let content = await agent.client.writeTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
content: input.content,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Writing file failed: " + error.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"edit",
|
||||
{
|
||||
title: "Edit",
|
||||
description: `Edit a file.
|
||||
|
||||
In sessions with mcp__acp__edit always use it instead of Edit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
File editing instructions:
|
||||
- The \`old_text\` param must match existing file content, including indentation.
|
||||
- The \`old_text\` param must come from the actual file, not an outline.
|
||||
- The \`old_text\` section must not be empty.
|
||||
- Be minimal with replacements:
|
||||
- For unique lines, include only those lines.
|
||||
- For non-unique lines, include enough context to identify them.
|
||||
- Do not escape quotes, newlines, or other characters.
|
||||
- Only edit the specified file.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to read."),
|
||||
old_text: z
|
||||
.string()
|
||||
.describe("The old text to replace (must be unique in the file)"),
|
||||
new_text: z.string().describe("The new text."),
|
||||
},
|
||||
annotations: {
|
||||
title: "Edit file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let { content } = await agent.client.readTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
});
|
||||
|
||||
const { newContent, lineNumbers } = replaceAndCalculateLocation(
|
||||
content,
|
||||
[
|
||||
{
|
||||
oldText: input.old_text,
|
||||
newText: input.new_text,
|
||||
replaceAll: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await agent.client.writeTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
content: newContent,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers }),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Editing file failed: " + error.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"multi-edit",
|
||||
{
|
||||
title: "Multi Edit",
|
||||
description: `Edit a file with multiple sequential edits.`,
|
||||
inputSchema: {
|
||||
file_path: z
|
||||
.string()
|
||||
.describe("The absolute path to the file to modify"),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
old_string: z.string().describe("The text to replace"),
|
||||
new_string: z.string().describe("The text to replace it with"),
|
||||
replace_all: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Replace all occurrences of old_string (default false)",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe(
|
||||
"Array of edit operations to perform sequentially on the file",
|
||||
),
|
||||
},
|
||||
annotations: {
|
||||
title: "Multi Edit file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let { content } = await agent.client.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
});
|
||||
|
||||
const { newContent, lineNumbers } = replaceAndCalculateLocation(
|
||||
content,
|
||||
input.edits.map((edit) => ({
|
||||
oldText: edit.old_string,
|
||||
newText: edit.new_string,
|
||||
replaceAll: edit.replace_all ?? false,
|
||||
})),
|
||||
);
|
||||
|
||||
await agent.client.writeTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
content: newContent,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers }),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
let alwaysAllowedTools: { [key: string]: boolean } = {};
|
||||
server.registerTool(
|
||||
"permission",
|
||||
{
|
||||
title: "Permission Tool",
|
||||
description: "Used to request tool permissions",
|
||||
inputSchema: {
|
||||
tool_name: z.string(),
|
||||
input: z.any(),
|
||||
tool_use_id: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (alwaysAllowedTools[input.tool_name]) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
let response = await agent.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
optionId: "allow_always",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: input.tool_use_id!,
|
||||
rawInput: input.input,
|
||||
},
|
||||
});
|
||||
if (
|
||||
response.outcome?.outcome == "selected" &&
|
||||
(response.outcome.optionId == "allow" ||
|
||||
response.outcome.optionId == "allow_always")
|
||||
) {
|
||||
if (response.outcome.optionId == "allow_always") {
|
||||
alwaysAllowedTools[input.tool_name] = true;
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/mcp", async (req, res) => {
|
||||
try {
|
||||
const transport: StreamableHTTPServerTransport =
|
||||
new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
server.close();
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32603,
|
||||
message: "Internal server error",
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const listener = app.listen(0, "127.0.0.1", (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(listener);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace text in a file and calculate the line numbers where the edits occurred.
|
||||
*
|
||||
* @param fileContent - The full file content
|
||||
* @param edits - Array of edit operations to apply sequentially
|
||||
* @returns the new content and the line numbers where replacements occurred in the final content
|
||||
*/
|
||||
export function replaceAndCalculateLocation(
|
||||
fileContent: string,
|
||||
edits: Array<{
|
||||
oldText: string;
|
||||
newText: string;
|
||||
replaceAll?: boolean;
|
||||
}>,
|
||||
): { newContent: string; lineNumbers: number[] } {
|
||||
let currentContent = fileContent;
|
||||
|
||||
// Use unique markers to track where replacements happen
|
||||
const markerPrefix = `__REPLACE_MARKER_${Math.random().toString(36).substr(2, 9)}_`;
|
||||
let markerCounter = 0;
|
||||
const markers: string[] = [];
|
||||
|
||||
// Apply edits sequentially, inserting markers at replacement positions
|
||||
for (const edit of edits) {
|
||||
// Skip empty oldText
|
||||
if (edit.oldText === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edit.replaceAll) {
|
||||
// Replace all occurrences with marker + newText
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let searchIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const index = currentContent.indexOf(edit.oldText, searchIndex);
|
||||
if (index === -1) break;
|
||||
|
||||
// Add content before the match
|
||||
parts.push(currentContent.substring(lastIndex, index));
|
||||
|
||||
// Add marker and replacement
|
||||
const marker = `${markerPrefix}${markerCounter++}__`;
|
||||
markers.push(marker);
|
||||
parts.push(marker + edit.newText);
|
||||
|
||||
lastIndex = index + edit.oldText.length;
|
||||
searchIndex = lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining content
|
||||
parts.push(currentContent.substring(lastIndex));
|
||||
currentContent = parts.join("");
|
||||
} else {
|
||||
// Replace first occurrence only
|
||||
const index = currentContent.indexOf(edit.oldText);
|
||||
if (index !== -1) {
|
||||
const marker = `${markerPrefix}${markerCounter++}__`;
|
||||
markers.push(marker);
|
||||
currentContent =
|
||||
currentContent.substring(0, index) +
|
||||
marker +
|
||||
edit.newText +
|
||||
currentContent.substring(index + edit.oldText.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find line numbers where markers appear in the content
|
||||
const lineNumbers: number[] = [];
|
||||
for (const marker of markers) {
|
||||
const index = currentContent.indexOf(marker);
|
||||
if (index !== -1) {
|
||||
const lineNumber = Math.max(
|
||||
0,
|
||||
currentContent.substring(0, index).split(/\r\n|\r|\n/).length - 1,
|
||||
);
|
||||
lineNumbers.push(lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all markers from the final content
|
||||
let finalContent = currentContent;
|
||||
for (const marker of markers) {
|
||||
finalContent = finalContent.replace(marker, "");
|
||||
}
|
||||
|
||||
// Dedupe and sort line numbers
|
||||
const uniqueLineNumbers = [...new Set(lineNumbers)].sort();
|
||||
|
||||
return { newContent: finalContent, lineNumbers: uniqueLineNumbers };
|
||||
}
|
||||
@@ -1,762 +0,0 @@
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
|
||||
export type NativeSubagent = {
|
||||
sessionId: string;
|
||||
parentSessionId: string;
|
||||
parentToolUseId?: string;
|
||||
name: string;
|
||||
task: string;
|
||||
/**
|
||||
* The exact prompt of this generation, sent as `prompt` in
|
||||
* `subagent_spawned`. It is absent when the adapter has no prompt.
|
||||
*/
|
||||
prompt?: string;
|
||||
announced?: boolean;
|
||||
terminalState?: SubagentState;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
announcePromise?: Promise<void>;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
terminalPromise?: Promise<void>;
|
||||
};
|
||||
|
||||
export type NativeSubagentSession = {
|
||||
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
|
||||
nativeSubagentTaskIdByToolUseId?: Map<string, string>;
|
||||
nativeSubagentParentByToolUseId?: Map<string, string>;
|
||||
};
|
||||
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
type Logger = { log(message: string): void };
|
||||
|
||||
type TaskStarted = {
|
||||
taskId: string;
|
||||
toolUseId?: string | null;
|
||||
subagentType?: unknown;
|
||||
description?: unknown;
|
||||
prompt?: unknown;
|
||||
};
|
||||
|
||||
type SubagentIdentity = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
subagentType?: string;
|
||||
};
|
||||
|
||||
const MAX_PENDING_PARENTS = 64;
|
||||
const MAX_PENDING_UPDATES = 256;
|
||||
const MAX_PENDING_UPDATES_PER_PARENT = 32;
|
||||
/** The number of child tool calls whose owning child session the runtime remembers. */
|
||||
const MAX_CHILD_TOOL_CALLS = 2048;
|
||||
|
||||
/**
|
||||
* Owns the connection-local native subagent registry and all ACP lifecycle
|
||||
* ordering. The main agent only supplies SDK facts and delivers routed output.
|
||||
*/
|
||||
export class NativeSubagentRuntime {
|
||||
readonly enabled: boolean;
|
||||
|
||||
private readonly children: Map<string, NativeSubagent>;
|
||||
private readonly taskByToolUse: Map<string, string>;
|
||||
private readonly parentByToolUse: Map<string, string>;
|
||||
private readonly identityByToolUse = new Map<string, SubagentIdentity>();
|
||||
private readonly controlByToolUse = new Map<string, AcpSessionNotification>();
|
||||
private readonly childByParentToolUse = new Map<string, NativeSubagent>();
|
||||
/**
|
||||
* The child session of each tool call that went to a child session. A later
|
||||
* update of that tool call can lose `parentToolUseId`, for example a progress
|
||||
* beat after the child finished. It still belongs to the child session.
|
||||
*/
|
||||
private readonly childByToolCall = new Map<string, NativeSubagent>();
|
||||
private readonly taskFinishPromises = new Map<string, Promise<void>>();
|
||||
private readonly generationByTaskId = new Map<string, number>();
|
||||
private readonly pending = new Map<string, AcpSessionNotification[]>();
|
||||
private pendingCount = 0;
|
||||
|
||||
constructor(
|
||||
enabled: boolean,
|
||||
private readonly rootSessionId: string,
|
||||
private readonly session: NativeSubagentSession,
|
||||
private readonly publish: Publish,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.enabled = enabled;
|
||||
this.children = session.nativeSubagentsByTaskId ??= new Map();
|
||||
this.taskByToolUse = session.nativeSubagentTaskIdByToolUseId ??= new Map();
|
||||
this.parentByToolUse = session.nativeSubagentParentByToolUseId ??= new Map();
|
||||
for (const child of this.children.values()) {
|
||||
if (child.parentToolUseId) {
|
||||
this.childByParentToolUse.set(child.parentToolUseId, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async route(
|
||||
notification: AcpSessionNotification,
|
||||
deliver: Publish,
|
||||
forcedSessionId?: string,
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const { update } = notification;
|
||||
const claudeMeta = update._meta?.claudeCode as
|
||||
{ parentToolUseId?: string | null; toolName?: string } | undefined;
|
||||
const isControl = isNativeSubagentControlUpdate(update);
|
||||
|
||||
if (!this.enabled) return notification;
|
||||
|
||||
if (this.enabled && isControl) {
|
||||
const toolCallId = update.toolCallId;
|
||||
if (update.sessionUpdate === "tool_call") {
|
||||
this.controlByToolUse.set(toolCallId, notification);
|
||||
}
|
||||
const identity = subagentIdentity(update.rawInput);
|
||||
if (identity) {
|
||||
this.identityByToolUse.set(
|
||||
toolCallId,
|
||||
mergeSubagentIdentity(this.identityByToolUse.get(toolCallId), identity),
|
||||
);
|
||||
}
|
||||
const parentSessionId = claudeMeta?.parentToolUseId
|
||||
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)?.sessionId
|
||||
: this.rootSessionId;
|
||||
this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
|
||||
|
||||
const child = this.childByParentToolUse.get(toolCallId);
|
||||
if (child && !child.announced) {
|
||||
child.parentSessionId = parentSessionId ?? this.rootSessionId;
|
||||
applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of this.takePending(toolCallId)) await deliver(pending);
|
||||
}
|
||||
if (!child && isFailedToolCallUpdate(update)) {
|
||||
this.takePending(toolCallId);
|
||||
const initial = this.controlByToolUse.get(toolCallId);
|
||||
this.cleanupControl(toolCallId);
|
||||
if (forcedSessionId) {
|
||||
return { ...notification, sessionId: forcedSessionId };
|
||||
}
|
||||
return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
|
||||
}
|
||||
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update"
|
||||
? update.toolCallId
|
||||
: undefined;
|
||||
|
||||
// A permission request may have had to create the tool call before native
|
||||
// child ownership was known. Keep every later update in that original ACP
|
||||
// session; moving a lifecycle after its initial call creates an orphan in
|
||||
// both transcripts.
|
||||
if (forcedSessionId) {
|
||||
const forcedChild = this.childOfSession(forcedSessionId);
|
||||
if (toolCallId && forcedChild) this.rememberToolCallOwner(toolCallId, forcedChild);
|
||||
return { ...notification, sessionId: forcedSessionId };
|
||||
}
|
||||
|
||||
const owner = toolCallId ? this.childByToolCall.get(toolCallId) : undefined;
|
||||
if (owner) return this.toChild(owner, notification, toolCallId);
|
||||
|
||||
if (this.enabled && claudeMeta?.parentToolUseId) {
|
||||
const child = this.childByParentToolUse.get(claudeMeta.parentToolUseId);
|
||||
if (!child || !child.announced) {
|
||||
this.buffer(claudeMeta.parentToolUseId, notification);
|
||||
return null;
|
||||
}
|
||||
return this.toChild(child, notification, toolCallId);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
async taskStarted(task: TaskStarted, deliver: Publish): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
if (!task.subagentType) {
|
||||
if (task.toolUseId) {
|
||||
this.takePending(task.toolUseId);
|
||||
this.cleanupControl(task.toolUseId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const previous = this.children.get(task.taskId);
|
||||
if (previous && previous.terminalState === undefined) return;
|
||||
|
||||
// A SendMessage resume reuses the finished generation's tool id, whose
|
||||
// control state is already cleaned up.
|
||||
const knownParentSessionId =
|
||||
(task.toolUseId ? this.parentByToolUse.get(task.toolUseId) : undefined) ??
|
||||
(previous && this.resumedParentSessionId(previous));
|
||||
const identity = task.toolUseId ? this.identityByToolUse.get(task.toolUseId) : undefined;
|
||||
// A nested child must wait for the spawning Agent/Task frame to establish
|
||||
// its immediate parent. Root children without a tool id can be announced.
|
||||
await this.openGeneration(
|
||||
task.taskId,
|
||||
previous,
|
||||
{
|
||||
parentSessionId: knownParentSessionId ?? this.rootSessionId,
|
||||
parentToolUseId: task.toolUseId ?? undefined,
|
||||
name: subagentDisplayName(
|
||||
identity?.name,
|
||||
identity?.description ?? task.description,
|
||||
identity?.subagentType ?? task.subagentType,
|
||||
task.taskId,
|
||||
),
|
||||
task: subagentDescription(
|
||||
identity?.prompt ?? task.prompt,
|
||||
identity?.description ?? task.description,
|
||||
),
|
||||
...promptField(promptText(task.prompt) ?? identity?.prompt),
|
||||
},
|
||||
!!knownParentSessionId || !task.toolUseId,
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a new generation of a finished child when the SDK resumes the same
|
||||
* agent id. The SDK can resume a child without a new `task_started`, so a
|
||||
* running `task_updated` patch or a SendMessage `resumedAgentId` is the
|
||||
* signal. A child that did not finish is not changed. The `prompt` is the
|
||||
* SendMessage text that resumed the child, when the adapter knows it.
|
||||
*/
|
||||
async taskResumed(taskId: string, deliver: Publish, prompt?: string): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const previous = this.children.get(taskId);
|
||||
if (!previous) return;
|
||||
const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
|
||||
if (finishing) await finishing.catch(() => {});
|
||||
if (this.children.get(taskId) !== previous || previous.terminalState === undefined) return;
|
||||
await this.openGeneration(
|
||||
taskId,
|
||||
previous,
|
||||
{
|
||||
parentSessionId: this.resumedParentSessionId(previous),
|
||||
parentToolUseId: previous.parentToolUseId,
|
||||
name: previous.name,
|
||||
task: previous.task,
|
||||
...promptField(promptText(prompt)),
|
||||
},
|
||||
true,
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
|
||||
async finishTask(
|
||||
taskId: string,
|
||||
status: unknown,
|
||||
deliver: Publish,
|
||||
toolUseId?: string | null,
|
||||
): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const state = nativeSubagentState(status);
|
||||
const child = toolUseId ? this.childByParentToolUse.get(toolUseId) : this.children.get(taskId);
|
||||
if (child && toolUseId && this.taskByToolUse.get(toolUseId) !== taskId) return;
|
||||
if (!state || !child || child.terminalState !== undefined) return;
|
||||
const existing = this.taskFinishPromises.get(taskId);
|
||||
if (existing) return existing;
|
||||
|
||||
const finish = Promise.resolve().then(async () => {
|
||||
try {
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
if (child.parentToolUseId) {
|
||||
for (const pending of this.takePending(child.parentToolUseId)) await deliver(pending);
|
||||
}
|
||||
await finishNativeSubagent(this.session, taskId, state, this.publish);
|
||||
} finally {
|
||||
if (child.parentToolUseId) {
|
||||
this.cleanupControl(child.parentToolUseId);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.taskFinishPromises.set(taskId, finish);
|
||||
try {
|
||||
await finish;
|
||||
} finally {
|
||||
if (this.taskFinishPromises.get(taskId) === finish) this.taskFinishPromises.delete(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
async finishAll(state: SubagentState, deliver: Publish): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
for (const taskId of [...this.children.keys()].reverse()) {
|
||||
try {
|
||||
await this.finishTask(taskId, state, deliver);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.pending.clear();
|
||||
this.pendingCount = 0;
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.parentByToolUse.clear();
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to finish native subagents");
|
||||
}
|
||||
|
||||
discardPending(parentToolUseId: string): void {
|
||||
this.takePending(parentToolUseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an update to the child session. An update of a finished child is
|
||||
* dropped: it never goes to the root session.
|
||||
*/
|
||||
private toChild(
|
||||
child: NativeSubagent,
|
||||
notification: AcpSessionNotification,
|
||||
toolCallId: string | undefined,
|
||||
): AcpSessionNotification | null {
|
||||
if (child.terminalState !== undefined || child.terminalPromise) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (toolCallId) this.rememberToolCallOwner(toolCallId, child);
|
||||
return { ...notification, sessionId: child.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* The route of the work that a child tool call started, for example an async
|
||||
* task. The route sends each update to the child generation that owned the
|
||||
* tool call when the work started. An async task can outlive that child: a
|
||||
* subagent can start a background command and end its turn. So the route
|
||||
* still sends `async_task_spawned`, `async_task_progress` and
|
||||
* `async_task_state_update` to the child generation after it finished. The route drops every other update
|
||||
* after the child finished. `undefined` means that the root session owns the
|
||||
* tool call. `eagerSessionId` is the session where a permission request
|
||||
* created the tool call before the stream routed it.
|
||||
*/
|
||||
routeOfToolCall(
|
||||
toolCallId: string,
|
||||
eagerSessionId?: string,
|
||||
): ((notification: AcpSessionNotification) => AcpSessionNotification | null) | undefined {
|
||||
if (!this.enabled) return undefined;
|
||||
const owner =
|
||||
this.childByToolCall.get(toolCallId) ??
|
||||
(eagerSessionId ? this.childOfSession(eagerSessionId) : undefined);
|
||||
return (
|
||||
owner &&
|
||||
((notification) =>
|
||||
isLateAsyncTaskUpdate(notification)
|
||||
? { ...notification, sessionId: owner.sessionId }
|
||||
: this.toChild(owner, notification, undefined))
|
||||
);
|
||||
}
|
||||
|
||||
private rememberToolCallOwner(toolCallId: string, child: NativeSubagent): void {
|
||||
this.childByToolCall.delete(toolCallId);
|
||||
this.childByToolCall.set(toolCallId, child);
|
||||
if (this.childByToolCall.size > MAX_CHILD_TOOL_CALLS) {
|
||||
const oldest = this.childByToolCall.keys().next().value;
|
||||
if (oldest !== undefined) this.childByToolCall.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** The child generation with the ACP session `sessionId`, if one exists. */
|
||||
private childOfSession(sessionId: string): NativeSubagent | undefined {
|
||||
if (sessionId === this.rootSessionId) return undefined;
|
||||
for (const child of this.children.values()) {
|
||||
if (child.sessionId === sessionId) return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.children.clear();
|
||||
this.childByToolCall.clear();
|
||||
this.taskByToolUse.clear();
|
||||
this.parentByToolUse.clear();
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.childByParentToolUse.clear();
|
||||
this.taskFinishPromises.clear();
|
||||
this.generationByTaskId.clear();
|
||||
this.pending.clear();
|
||||
this.pendingCount = 0;
|
||||
}
|
||||
|
||||
private takePending(parentToolUseId: string): AcpSessionNotification[] {
|
||||
const updates = this.pending.get(parentToolUseId) ?? [];
|
||||
if (updates.length > 0) {
|
||||
this.pending.delete(parentToolUseId);
|
||||
this.pendingCount -= updates.length;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
private buffer(parentToolUseId: string, notification: AcpSessionNotification): void {
|
||||
const updates = this.pending.get(parentToolUseId);
|
||||
if (
|
||||
this.pendingCount >= MAX_PENDING_UPDATES ||
|
||||
(updates === undefined && this.pending.size >= MAX_PENDING_PARENTS) ||
|
||||
(updates?.length ?? 0) >= MAX_PENDING_UPDATES_PER_PARENT
|
||||
) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: dropping unattributed subagent update for ${parentToolUseId}; pending buffer limit reached`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (updates) updates.push(notification);
|
||||
else this.pending.set(parentToolUseId, [notification]);
|
||||
this.pendingCount++;
|
||||
}
|
||||
|
||||
private cleanupControl(toolUseId: string): void {
|
||||
this.identityByToolUse.delete(toolUseId);
|
||||
this.controlByToolUse.delete(toolUseId);
|
||||
this.parentByToolUse.delete(toolUseId);
|
||||
}
|
||||
|
||||
/** The parent of a resumed generation: the old parent while it is live, else the root. */
|
||||
private resumedParentSessionId(previous: NativeSubagent): string {
|
||||
return this.isLiveSession(previous.parentSessionId)
|
||||
? previous.parentSessionId
|
||||
: this.rootSessionId;
|
||||
}
|
||||
|
||||
private isLiveSession(sessionId: string): boolean {
|
||||
if (sessionId === this.rootSessionId) return true;
|
||||
for (const child of this.children.values()) {
|
||||
if (child.sessionId === sessionId) return child.terminalState === undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new child session for the task and makes it the owner of its
|
||||
* parent tool call. With `announce`, it publishes `subagent_spawned` and
|
||||
* delivers the updates that waited for the child.
|
||||
*/
|
||||
private async openGeneration(
|
||||
taskId: string,
|
||||
previous: NativeSubagent | undefined,
|
||||
fields: Pick<
|
||||
NativeSubagent,
|
||||
"parentSessionId" | "parentToolUseId" | "name" | "task" | "prompt"
|
||||
>,
|
||||
announce: boolean,
|
||||
deliver: Publish,
|
||||
): Promise<void> {
|
||||
const child: NativeSubagent = {
|
||||
sessionId: this.nextChildSessionId(taskId, previous),
|
||||
...fields,
|
||||
};
|
||||
const toolUseId = child.parentToolUseId;
|
||||
this.children.set(taskId, child);
|
||||
if (toolUseId) {
|
||||
this.taskByToolUse.set(toolUseId, taskId);
|
||||
this.childByParentToolUse.set(toolUseId, child);
|
||||
this.controlByToolUse.delete(toolUseId);
|
||||
}
|
||||
if (!announce) return;
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
|
||||
}
|
||||
|
||||
private nextChildSessionId(taskId: string, previous: NativeSubagent | undefined): string {
|
||||
if (!previous) {
|
||||
this.generationByTaskId.set(taskId, 1);
|
||||
return taskId;
|
||||
}
|
||||
const generation = (this.generationByTaskId.get(taskId) ?? 1) + 1;
|
||||
this.generationByTaskId.set(taskId, generation);
|
||||
return `${taskId}:generation:${generation}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function announceNativeSubagent(
|
||||
child: NativeSubagent,
|
||||
publish: Publish,
|
||||
): Promise<void> {
|
||||
if (child.announced) return;
|
||||
if (child.announcePromise) return child.announcePromise;
|
||||
const announce = Promise.resolve().then(async () => {
|
||||
await publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_spawned",
|
||||
subagentSessionId: child.sessionId,
|
||||
name: child.name,
|
||||
task: child.task,
|
||||
...promptField(child.prompt),
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
child.announced = true;
|
||||
});
|
||||
child.announcePromise = announce;
|
||||
try {
|
||||
await announce;
|
||||
} finally {
|
||||
if (child.announcePromise === announce) child.announcePromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function finishNativeSubagent(
|
||||
session: NativeSubagentSession,
|
||||
taskId: string,
|
||||
state: SubagentState,
|
||||
publish: Publish,
|
||||
): Promise<void> {
|
||||
const child = session.nativeSubagentsByTaskId?.get(taskId);
|
||||
if (!child || child.terminalState !== undefined) return;
|
||||
if (child.terminalPromise) return child.terminalPromise;
|
||||
const finish = Promise.resolve().then(async () => {
|
||||
await announceNativeSubagent(child, publish);
|
||||
await publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_state_update",
|
||||
subagentSessionId: child.sessionId,
|
||||
state,
|
||||
},
|
||||
});
|
||||
child.terminalState = state;
|
||||
});
|
||||
child.terminalPromise = finish;
|
||||
try {
|
||||
await finish;
|
||||
} finally {
|
||||
if (child.terminalPromise === finish) child.terminalPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent id that a successful SendMessage result resumed. The SDK puts it
|
||||
* in `tool_use_result.resumedAgentId` when a finished agent runs again.
|
||||
*/
|
||||
export function resumedNativeSubagentId(toolUseResult: unknown): string | undefined {
|
||||
if (typeof toolUseResult !== "object" || toolUseResult === null) return undefined;
|
||||
const result = toolUseResult as { success?: unknown; resumedAgentId?: unknown };
|
||||
return result.success === true ? nonBlankString(result.resumedAgentId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SendMessage text that resumed the agent `agentId`. The tool uses of
|
||||
* `resultToolUseIds` come first: they are the SendMessage calls whose result
|
||||
* carried the resume. Otherwise the latest SendMessage call to `agentId` counts.
|
||||
*/
|
||||
export function sendMessageResumePrompt(
|
||||
toolUses: Record<string, { name: string; input: unknown } | undefined>,
|
||||
agentId: string,
|
||||
resultToolUseIds: readonly string[] = [],
|
||||
): string | undefined {
|
||||
for (const toolUseId of resultToolUseIds) {
|
||||
const toolUse = toolUses[toolUseId];
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const text = promptText((toolUse.input as { message?: unknown } | null)?.message);
|
||||
if (text) return text;
|
||||
}
|
||||
if (resultToolUseIds.length > 0) return undefined;
|
||||
for (const toolUse of Object.values(toolUses).reverse()) {
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const input = toolUse.input as { to?: unknown; message?: unknown } | null;
|
||||
if (input?.to === agentId) return promptText(input.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function nativeSubagentState(status: unknown): SubagentState | undefined {
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "disconnected") return "disconnected";
|
||||
if (status === "killed" || status === "cancelled" || status === "stopped") return "cancelled";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isNativeSubagentControlUpdate(
|
||||
update: AcpSessionNotification["update"],
|
||||
): update is Extract<
|
||||
AcpSessionNotification["update"],
|
||||
{ sessionUpdate: "tool_call" | "tool_call_update" }
|
||||
> {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return false;
|
||||
}
|
||||
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
|
||||
return (
|
||||
airExtensionMeta(update._meta)?.[AIR_SUBAGENT_KEY] === true ||
|
||||
isNativeSubagentControlTool(claudeMeta?.toolName)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNativeSubagentControlTool(toolName: unknown): boolean {
|
||||
return toolName === "Agent" || toolName === "Task";
|
||||
}
|
||||
|
||||
/**
|
||||
* An async task update that may reach a child generation after it finished.
|
||||
* The spawn is one of them: a held task can get its tool call id, and so its
|
||||
* spawn, after the child finished.
|
||||
*/
|
||||
function isLateAsyncTaskUpdate(notification: AcpSessionNotification): boolean {
|
||||
const kind = notification.update.sessionUpdate;
|
||||
return (
|
||||
kind === "async_task_spawned" ||
|
||||
kind === "async_task_progress" ||
|
||||
kind === "async_task_state_update"
|
||||
);
|
||||
}
|
||||
|
||||
function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boolean {
|
||||
return (
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
update.status === "failed"
|
||||
);
|
||||
}
|
||||
|
||||
function failedControlFallback(
|
||||
initial: AcpSessionNotification | undefined,
|
||||
terminal: AcpSessionNotification,
|
||||
sessionId: string,
|
||||
): AcpSessionNotification {
|
||||
if (
|
||||
terminal.update.sessionUpdate !== "tool_call" &&
|
||||
terminal.update.sessionUpdate !== "tool_call_update"
|
||||
) {
|
||||
return { ...terminal, sessionId };
|
||||
}
|
||||
if (!initial || initial.update.sessionUpdate !== "tool_call") {
|
||||
const claudeMeta = terminal.update._meta?.claudeCode as { toolName?: unknown } | undefined;
|
||||
return {
|
||||
...terminal,
|
||||
sessionId,
|
||||
update: {
|
||||
...terminal.update,
|
||||
sessionUpdate: "tool_call",
|
||||
status: "failed",
|
||||
// The synthesized tool_call is this call's first report, so give it
|
||||
// the standard `name` the initial one would have carried.
|
||||
...(typeof claudeMeta?.toolName === "string" ? { name: claudeMeta.toolName } : {}),
|
||||
title:
|
||||
typeof terminal.update.title === "string" && terminal.update.title.length > 0
|
||||
? terminal.update.title
|
||||
: claudeMeta?.toolName === "Task"
|
||||
? "Task"
|
||||
: "Agent",
|
||||
_meta: ordinaryToolMeta(terminal.update._meta),
|
||||
} as AcpSessionNotification["update"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...initial,
|
||||
sessionId,
|
||||
update: {
|
||||
...initial.update,
|
||||
...terminal.update,
|
||||
sessionUpdate: "tool_call",
|
||||
status: "failed",
|
||||
title:
|
||||
typeof terminal.update.title === "string" && terminal.update.title.trim().length > 0
|
||||
? terminal.update.title
|
||||
: initial.update.title,
|
||||
_meta: {
|
||||
...initial.update._meta,
|
||||
...terminal.update._meta,
|
||||
...ordinaryToolMeta(initial.update._meta, terminal.update._meta),
|
||||
},
|
||||
} as AcpSessionNotification["update"],
|
||||
};
|
||||
}
|
||||
|
||||
function ordinaryToolMeta(
|
||||
...values: Array<Record<string, unknown> | null | undefined>
|
||||
): Record<string, unknown> {
|
||||
const merged = Object.assign({}, ...values);
|
||||
const claudeCode = Object.assign(
|
||||
{},
|
||||
...values.map(
|
||||
(value) => (value?.claudeCode as Record<string, unknown> | null | undefined) ?? {},
|
||||
),
|
||||
);
|
||||
const result: Record<string, unknown> = { ...merged, claudeCode };
|
||||
const air = airExtensionMeta(merged);
|
||||
if (air && AIR_SUBAGENT_KEY in air) {
|
||||
const rest = { ...air };
|
||||
delete rest[AIR_SUBAGENT_KEY];
|
||||
result.jetbrains = { ...(merged.jetbrains as Record<string, unknown>), air: rest };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function subagentDisplayName(
|
||||
explicitName: unknown,
|
||||
description: unknown,
|
||||
type: unknown,
|
||||
taskId: string,
|
||||
): string {
|
||||
for (const value of [explicitName, description, type]) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
const suffix = taskId.length > 8 ? taskId.slice(-8) : taskId;
|
||||
return `Agent ${suffix}`;
|
||||
}
|
||||
|
||||
function subagentDescription(prompt: unknown, description: unknown): string {
|
||||
for (const value of [prompt, description]) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return "Delegated task";
|
||||
}
|
||||
|
||||
function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return undefined;
|
||||
const value = input as Record<string, unknown>;
|
||||
const identity: SubagentIdentity = {
|
||||
name: nonBlankString(value.name),
|
||||
description: nonBlankString(value.description),
|
||||
prompt: promptText(value.prompt),
|
||||
subagentType: nonBlankString(value.subagent_type),
|
||||
};
|
||||
return Object.values(identity).some(Boolean) ? identity : undefined;
|
||||
}
|
||||
|
||||
function mergeSubagentIdentity(
|
||||
previous: SubagentIdentity | undefined,
|
||||
next: SubagentIdentity,
|
||||
): SubagentIdentity {
|
||||
return {
|
||||
name: next.name ?? previous?.name,
|
||||
description: next.description ?? previous?.description,
|
||||
prompt: next.prompt ?? previous?.prompt,
|
||||
subagentType: next.subagentType ?? previous?.subagentType,
|
||||
};
|
||||
}
|
||||
|
||||
function applySubagentIdentity(
|
||||
child: NativeSubagent,
|
||||
identity: SubagentIdentity | undefined,
|
||||
): void {
|
||||
if (!identity) return;
|
||||
if (identity.name || identity.description) {
|
||||
child.name = subagentDisplayName(
|
||||
identity.name,
|
||||
identity.description,
|
||||
identity.subagentType,
|
||||
child.sessionId,
|
||||
);
|
||||
}
|
||||
if (identity.prompt || identity.description) {
|
||||
child.task = subagentDescription(identity.prompt, identity.description);
|
||||
}
|
||||
child.prompt ??= identity.prompt;
|
||||
}
|
||||
|
||||
/** The prompt text unchanged, or `undefined` when it is not a non-blank string. */
|
||||
function promptText(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function promptField(prompt: string | undefined): { prompt?: string } {
|
||||
return prompt === undefined ? {} : { prompt };
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** The Claude configuration directory: `CLAUDE_CONFIG_DIR`, or `~/.claude`. */
|
||||
export function claudeConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import type { RequestPermissionResponse } from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
PermissionUpdate,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { DurablePermissionChangeSet } from "./normalization.js";
|
||||
import { PERMISSION_OPTION_ID } from "./options.js";
|
||||
|
||||
export interface ClaudePermissionSelection {
|
||||
optionId: string;
|
||||
contextResetMode?: PermissionMode;
|
||||
}
|
||||
|
||||
export interface ClaudePermissionEffectContext {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
toolUseID: string;
|
||||
durableChangeSet?: DurablePermissionChangeSet;
|
||||
}
|
||||
|
||||
/** Parse the ACP envelope once before dispatching to a tool-specific effect. */
|
||||
export function parseClaudePermissionSelection(
|
||||
response: RequestPermissionResponse,
|
||||
toolName: string,
|
||||
): ClaudePermissionSelection {
|
||||
if (response.outcome?.outcome !== "selected") throw new Error("Tool use aborted");
|
||||
const optionId = response.outcome.optionId;
|
||||
const contextResetMode =
|
||||
toolName === "ExitPlanMode" ? exitPlanClearContextMode(optionId) : undefined;
|
||||
return { optionId, ...(contextResetMode ? { contextResetMode } : {}) };
|
||||
}
|
||||
|
||||
function allow(
|
||||
context: ClaudePermissionEffectContext,
|
||||
updatedPermissions?: PermissionUpdate[],
|
||||
permanent = false,
|
||||
): PermissionResult {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: context.input,
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
toolUseID: context.toolUseID,
|
||||
decisionClassification: permanent ? "user_permanent" : "user_temporary",
|
||||
};
|
||||
}
|
||||
|
||||
function deny(
|
||||
context: ClaudePermissionEffectContext,
|
||||
message = "User refused permission to run tool",
|
||||
interrupt = false,
|
||||
): PermissionResult {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message,
|
||||
...(interrupt ? { interrupt: true } : {}),
|
||||
toolUseID: context.toolUseID,
|
||||
decisionClassification: "user_reject",
|
||||
};
|
||||
}
|
||||
|
||||
function skillName(input: Record<string, unknown>): string {
|
||||
const value = typeof input.skill === "string" ? input.skill.trim() : "";
|
||||
if (!value || value.length > 1_000) throw new Error("Invalid Skill permission input");
|
||||
return value;
|
||||
}
|
||||
|
||||
function localAllowRuleUpdate(toolName: string, ruleContent?: string): PermissionUpdate[] {
|
||||
return [
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName, ...(ruleContent === undefined ? {} : { ruleContent }) }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function webFetchPermissionUpdate(input: Record<string, unknown>): PermissionUpdate[] {
|
||||
if (typeof input.url !== "string") throw new Error("Invalid WebFetch permission input");
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(input.url).hostname;
|
||||
} catch {
|
||||
throw new Error("Invalid WebFetch permission input");
|
||||
}
|
||||
if (!hostname) throw new Error("Invalid WebFetch permission input");
|
||||
return localAllowRuleUpdate("WebFetch", `domain:${hostname}`);
|
||||
}
|
||||
|
||||
function applySkillSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
): PermissionResult {
|
||||
switch (selection.optionId) {
|
||||
case PERMISSION_OPTION_ID.allowSkillExact: {
|
||||
return allow(context, localAllowRuleUpdate("Skill", skillName(context.input)), true);
|
||||
}
|
||||
case PERMISSION_OPTION_ID.allowSkillPrefix: {
|
||||
const skill = skillName(context.input);
|
||||
const spaceIndex = skill.indexOf(" ");
|
||||
if (spaceIndex <= 0) throw new Error("Skill prefix permission requires arguments");
|
||||
return allow(context, localAllowRuleUpdate("Skill", `${skill.slice(0, spaceIndex)}:*`), true);
|
||||
}
|
||||
default:
|
||||
return applyCommonSelection(selection, context);
|
||||
}
|
||||
}
|
||||
|
||||
function exitPlanMode(optionId: string): PermissionMode | undefined {
|
||||
switch (optionId) {
|
||||
case PERMISSION_OPTION_ID.exitPlanBypass:
|
||||
return "bypassPermissions";
|
||||
case PERMISSION_OPTION_ID.exitPlanAuto:
|
||||
return "auto";
|
||||
case PERMISSION_OPTION_ID.exitPlanAcceptEdits:
|
||||
return "acceptEdits";
|
||||
case PERMISSION_OPTION_ID.exitPlanDefault:
|
||||
return "default";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function exitPlanClearContextMode(optionId: string): PermissionMode | undefined {
|
||||
switch (optionId) {
|
||||
case PERMISSION_OPTION_ID.exitPlanClearAuto:
|
||||
return "auto";
|
||||
case PERMISSION_OPTION_ID.exitPlanClearBypass:
|
||||
return "bypassPermissions";
|
||||
case PERMISSION_OPTION_ID.exitPlanClearAcceptEdits:
|
||||
return "acceptEdits";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function applyExitPlanModeSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
): PermissionResult {
|
||||
if (selection.contextResetMode) {
|
||||
// The adapter consumes this interrupt and continues the same ACP turn on a
|
||||
// fresh Claude query. Returning allow here would execute ExitPlanMode in
|
||||
// the old context before the handoff.
|
||||
return deny(context, "User accepted the plan and requested a fresh context", true);
|
||||
}
|
||||
const mode = exitPlanMode(selection.optionId);
|
||||
if (mode) {
|
||||
return allow(context, [{ type: "setMode", mode, destination: "session" }], mode !== "default");
|
||||
}
|
||||
if (selection.optionId === PERMISSION_OPTION_ID.reject) {
|
||||
// A regular deny lets Claude continue planning and ask another question.
|
||||
// Interrupt stops this ACP turn; the adapter maps Claude's internal
|
||||
// diagnostic for that intentional stop back to cancellation.
|
||||
return deny(context, "User chose to keep planning", true);
|
||||
}
|
||||
return applyCommonSelection(selection, context);
|
||||
}
|
||||
|
||||
function applyCommonSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
): PermissionResult {
|
||||
switch (selection.optionId) {
|
||||
case PERMISSION_OPTION_ID.allowOnce:
|
||||
return allow(context);
|
||||
case PERMISSION_OPTION_ID.allowWithUpdates:
|
||||
if (!context.durableChangeSet) throw new Error("Invalid durable permission selection");
|
||||
return allow(context, context.durableChangeSet.updates, true);
|
||||
case PERMISSION_OPTION_ID.reject:
|
||||
return deny(context);
|
||||
default:
|
||||
throw new Error(`Unknown permission option: ${selection.optionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function applyGeneratedDurableSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
updates: () => PermissionUpdate[],
|
||||
): PermissionResult {
|
||||
if (selection.optionId !== PERMISSION_OPTION_ID.allowWithUpdates) {
|
||||
return applyCommonSelection(selection, context);
|
||||
}
|
||||
return allow(context, updates(), true);
|
||||
}
|
||||
|
||||
/** Apply a parsed selection using the semantics of the tool that produced it. */
|
||||
export function applyClaudePermissionSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
): PermissionResult {
|
||||
switch (context.toolName) {
|
||||
case "Skill":
|
||||
return applySkillSelection(selection, context);
|
||||
case "WebFetch":
|
||||
return applyGeneratedDurableSelection(selection, context, () =>
|
||||
webFetchPermissionUpdate(context.input),
|
||||
);
|
||||
case "EnterPlanMode":
|
||||
return applyCommonSelection(selection, context);
|
||||
case "ExitPlanMode":
|
||||
return applyExitPlanModeSelection(selection, context);
|
||||
case "Read":
|
||||
case "Bash":
|
||||
case "PowerShell":
|
||||
case "Glob":
|
||||
case "Grep":
|
||||
case "Edit":
|
||||
case "Write":
|
||||
case "NotebookEdit":
|
||||
case "SandboxNetworkAccess":
|
||||
return applyCommonSelection(selection, context);
|
||||
default:
|
||||
if (context.toolName.startsWith("mcp__")) {
|
||||
return applyCommonSelection(selection, context);
|
||||
}
|
||||
return applyGeneratedDurableSelection(selection, context, () =>
|
||||
localAllowRuleUpdate(context.toolName),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
interface PermissionModeLogger {
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
// Bypass Permissions doesn't work if we are a root/sudo user.
|
||||
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
||||
export const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
||||
|
||||
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
// Settings use user-facing, case-insensitive spellings while the SDK uses
|
||||
// camel-cased wire values. Keep legacy shorthand accepted by Claude Code too.
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
// Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts
|
||||
// `"defaultMode": "manual"` in settings.json; honor the same alias here.
|
||||
manual: "default",
|
||||
acceptedits: "acceptEdits",
|
||||
dontask: "dontAsk",
|
||||
plan: "plan",
|
||||
bypasspermissions: "bypassPermissions",
|
||||
bypass: "bypassPermissions",
|
||||
};
|
||||
|
||||
/** When `allowBypass` is false, `bypassPermissions` clamps to `default` so the SDK
|
||||
* is never spawned in a mode it would reject. */
|
||||
export function resolvePermissionMode(
|
||||
defaultMode?: unknown,
|
||||
logger: PermissionModeLogger = console,
|
||||
allowBypass: boolean = ALLOW_BYPASS,
|
||||
): PermissionMode {
|
||||
if (defaultMode === undefined) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (typeof defaultMode !== "string") {
|
||||
logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
|
||||
return "default";
|
||||
}
|
||||
|
||||
const normalized = defaultMode.trim().toLowerCase();
|
||||
if (normalized === "") {
|
||||
logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
|
||||
return "default";
|
||||
}
|
||||
|
||||
const mapped = PERMISSION_MODE_ALIASES[normalized];
|
||||
if (!mapped) {
|
||||
logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (mapped === "bypassPermissions" && !allowBypass) {
|
||||
logger.error("Ignoring permission mode bypassPermissions: not available in this session.");
|
||||
return "default";
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import type {
|
||||
PermissionBehavior,
|
||||
PermissionMode,
|
||||
PermissionUpdate,
|
||||
PermissionUpdateDestination,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export interface DurablePermissionChangeSet {
|
||||
updates: PermissionUpdate[];
|
||||
}
|
||||
|
||||
const MAX_UPDATES = 100;
|
||||
const MAX_RULES_PER_UPDATE = 100;
|
||||
const MAX_DIRECTORIES_PER_UPDATE = 100;
|
||||
const MAX_TOOL_NAME_LENGTH = 512;
|
||||
const MAX_RULE_CONTENT_LENGTH = 10_000;
|
||||
const MAX_DIRECTORY_LENGTH = 4_096;
|
||||
|
||||
const DESTINATIONS = new Set<PermissionUpdateDestination>([
|
||||
"session",
|
||||
"cliArg",
|
||||
"userSettings",
|
||||
"projectSettings",
|
||||
"localSettings",
|
||||
]);
|
||||
const BEHAVIORS = new Set<PermissionBehavior>(["allow", "deny", "ask"]);
|
||||
const MODES = new Set<PermissionMode>([
|
||||
"default",
|
||||
"acceptEdits",
|
||||
"bypassPermissions",
|
||||
"plan",
|
||||
"dontAsk",
|
||||
"auto",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isDestination(value: unknown): value is PermissionUpdateDestination {
|
||||
return typeof value === "string" && DESTINATIONS.has(value as PermissionUpdateDestination);
|
||||
}
|
||||
|
||||
function safePlainString(value: unknown, maxLength: number): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
const normalized = value.trim();
|
||||
return (
|
||||
normalized.length > 0 &&
|
||||
value.length <= maxLength &&
|
||||
!Array.from(normalized).some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return (
|
||||
code <= 0x08 ||
|
||||
code === 0x0b ||
|
||||
code === 0x0c ||
|
||||
(code >= 0x0e && code <= 0x1f) ||
|
||||
code === 0x7f
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isRule(value: unknown): value is { toolName: string; ruleContent?: string } {
|
||||
if (!isRecord(value) || !safePlainString(value.toolName, MAX_TOOL_NAME_LENGTH)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
value.ruleContent === undefined || safePlainString(value.ruleContent, MAX_RULE_CONTENT_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownUpdate(value: unknown): value is PermissionUpdate {
|
||||
if (!isRecord(value) || !isDestination(value.destination) || typeof value.type !== "string") {
|
||||
return false;
|
||||
}
|
||||
switch (value.type) {
|
||||
case "addRules":
|
||||
case "replaceRules":
|
||||
case "removeRules":
|
||||
return (
|
||||
Array.isArray(value.rules) &&
|
||||
value.rules.length <= MAX_RULES_PER_UPDATE &&
|
||||
(value.type === "replaceRules" || value.rules.length > 0) &&
|
||||
value.rules.every(isRule) &&
|
||||
typeof value.behavior === "string" &&
|
||||
BEHAVIORS.has(value.behavior as PermissionBehavior)
|
||||
);
|
||||
case "setMode":
|
||||
return typeof value.mode === "string" && MODES.has(value.mode as PermissionMode);
|
||||
case "addDirectories":
|
||||
case "removeDirectories":
|
||||
return (
|
||||
Array.isArray(value.directories) &&
|
||||
value.directories.length > 0 &&
|
||||
value.directories.length <= MAX_DIRECTORIES_PER_UPDATE &&
|
||||
value.directories.every((directory) => safePlainString(directory, MAX_DIRECTORY_LENGTH))
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDurablePermissionChangeSet(
|
||||
suggestions: unknown,
|
||||
forcedAsk = false,
|
||||
): DurablePermissionChangeSet | undefined {
|
||||
if (forcedAsk) return undefined;
|
||||
try {
|
||||
if (
|
||||
!Array.isArray(suggestions) ||
|
||||
suggestions.length === 0 ||
|
||||
suggestions.length > MAX_UPDATES ||
|
||||
!suggestions.every(isKnownUpdate)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
// The ACP prompt may stay open for an arbitrary amount of time. Snapshot the
|
||||
// provider payload so the label the user approved and the effect returned to
|
||||
// Claude cannot diverge through later mutation of the callback argument.
|
||||
updates: structuredClone(suggestions as PermissionUpdate[]),
|
||||
};
|
||||
} catch {
|
||||
// SDK data is expected to be plain structured-cloneable input. Treat an
|
||||
// accessor, function, proxy, or other non-wire value as an invalid bundle.
|
||||
// Validation belongs in this boundary too because merely reading one of those
|
||||
// values can throw before structuredClone gets a chance to reject it.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
buildEditPermissionOptions,
|
||||
buildGlobPermissionOptions,
|
||||
buildGrepPermissionOptions,
|
||||
buildNotebookEditPermissionOptions,
|
||||
buildReadPermissionOptions,
|
||||
buildWritePermissionOptions,
|
||||
} from "./options/filesystem.js";
|
||||
import { buildBashPermissionOptions, buildPowerShellPermissionOptions } from "./options/shell.js";
|
||||
import type { PermissionOptionContext } from "./options/shared.js";
|
||||
import {
|
||||
buildComputerUseMcpPermissionOptions,
|
||||
buildEnterPlanModePermissionOptions,
|
||||
buildExitPlanModePermissionOptions,
|
||||
buildFallbackPermissionOptions,
|
||||
buildSandboxNetworkPermissionOptions,
|
||||
buildSkillPermissionOptions,
|
||||
buildWebFetchPermissionOptions,
|
||||
isComputerUseMcpTool,
|
||||
} from "./options/tools.js";
|
||||
|
||||
export { PERMISSION_OPTION_ID } from "./options/shared.js";
|
||||
|
||||
export function buildClaudePermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
// ACP has no "pre-selected option" field, so option order is the only lever
|
||||
// for the CLI's `defaultToNo` hint ("open on the decline option"): lead with
|
||||
// the reject options instead of the approvals.
|
||||
const order = context.defaultToNo === true ? declineFirstOptionOrder : permissionOptionOrder;
|
||||
return buildUnsortedClaudePermissionOptions(context).sort(
|
||||
(left, right) => order(left) - order(right),
|
||||
);
|
||||
}
|
||||
|
||||
function buildUnsortedClaudePermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
if (isComputerUseMcpTool(context.toolName)) {
|
||||
return buildComputerUseMcpPermissionOptions(context);
|
||||
}
|
||||
switch (context.toolName) {
|
||||
case "AskUserQuestion":
|
||||
throw new Error("AskUserQuestion must be handled by ACP elicitation, not permission options");
|
||||
case "Bash":
|
||||
return buildBashPermissionOptions(context);
|
||||
case "PowerShell":
|
||||
return buildPowerShellPermissionOptions(context);
|
||||
case "Read":
|
||||
return buildReadPermissionOptions(context);
|
||||
case "Glob":
|
||||
return buildGlobPermissionOptions(context);
|
||||
case "Grep":
|
||||
return buildGrepPermissionOptions(context);
|
||||
case "Edit":
|
||||
return buildEditPermissionOptions(context);
|
||||
case "Write":
|
||||
return buildWritePermissionOptions(context);
|
||||
case "NotebookEdit":
|
||||
return buildNotebookEditPermissionOptions(context);
|
||||
case "WebFetch":
|
||||
return buildWebFetchPermissionOptions(context);
|
||||
case "Skill":
|
||||
return buildSkillPermissionOptions(context);
|
||||
case "EnterPlanMode":
|
||||
return buildEnterPlanModePermissionOptions();
|
||||
case "ExitPlanMode":
|
||||
return buildExitPlanModePermissionOptions(context);
|
||||
case "SandboxNetworkAccess":
|
||||
return buildSandboxNetworkPermissionOptions(context);
|
||||
case "WebSearch":
|
||||
case "Agent":
|
||||
case "Task":
|
||||
default:
|
||||
return buildFallbackPermissionOptions(context);
|
||||
}
|
||||
}
|
||||
|
||||
function declineFirstOptionOrder(option: PermissionOption): number {
|
||||
switch (option.kind) {
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return 0;
|
||||
default:
|
||||
return 1 + permissionOptionOrder(option);
|
||||
}
|
||||
}
|
||||
|
||||
function permissionOptionOrder(option: PermissionOption): number {
|
||||
switch (option.kind) {
|
||||
case "allow_once":
|
||||
return 0;
|
||||
case "allow_always":
|
||||
return 1;
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return 3;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DurablePermissionChangeSet } from "../normalization.js";
|
||||
import { plainString, type PermissionOptionContext, withOptionalUpdate } from "./shared.js";
|
||||
|
||||
function isFileSessionChangeSet(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
operation: "read" | "write",
|
||||
): boolean {
|
||||
return (
|
||||
!!changeSet &&
|
||||
changeSet.updates.every((update) => {
|
||||
if (update.destination !== "session") return false;
|
||||
if (update.type === "setMode") {
|
||||
return operation === "write" && update.mode === "acceptEdits";
|
||||
}
|
||||
if (update.type === "addDirectories") return operation === "write";
|
||||
if (update.type !== "addRules" || update.behavior !== "allow") return false;
|
||||
return update.rules.every((rule) =>
|
||||
operation === "read" ? rule.toolName === "Read" : rule.toolName === "Edit",
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function inputPath(context: PermissionOptionContext, key: string): string | undefined {
|
||||
return plainString(context.input[key]);
|
||||
}
|
||||
|
||||
function isInside(directory: string, candidate: string): boolean {
|
||||
const relative = path.relative(path.resolve(directory), path.resolve(candidate));
|
||||
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
|
||||
}
|
||||
|
||||
function hasSessionAllowRuleFor(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
toolName: string,
|
||||
): boolean {
|
||||
return !!changeSet?.updates.some(
|
||||
(update) =>
|
||||
update.type === "addRules" &&
|
||||
update.destination === "session" &&
|
||||
update.behavior === "allow" &&
|
||||
update.rules.some((rule) => rule.toolName === toolName),
|
||||
);
|
||||
}
|
||||
|
||||
function hasBroadSessionAllowRuleFor(
|
||||
changeSet: DurablePermissionChangeSet,
|
||||
toolName: string,
|
||||
): boolean {
|
||||
return changeSet.updates.some(
|
||||
(update) =>
|
||||
update.type === "addRules" &&
|
||||
update.destination === "session" &&
|
||||
update.behavior === "allow" &&
|
||||
update.rules.some((rule) => rule.toolName === toolName && rule.ruleContent === undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function hasAcceptEditsMode(changeSet: DurablePermissionChangeSet): boolean {
|
||||
return changeSet.updates.some(
|
||||
(update) =>
|
||||
update.type === "setMode" &&
|
||||
update.destination === "session" &&
|
||||
update.mode === "acceptEdits",
|
||||
);
|
||||
}
|
||||
|
||||
function permissionPath(ruleContent: string | undefined): string | undefined {
|
||||
const value = plainString(ruleContent);
|
||||
if (!value) return undefined;
|
||||
return value.replace(/[/\\]\*\*$/, "");
|
||||
}
|
||||
|
||||
function effectCoversFilePath(
|
||||
changeSet: DurablePermissionChangeSet,
|
||||
filePath: string,
|
||||
operation: "read" | "write",
|
||||
cwd: string,
|
||||
): boolean {
|
||||
const directory = path.dirname(filePath);
|
||||
const comparable = (candidate: string): boolean => {
|
||||
const resolved = path.resolve(cwd, candidate);
|
||||
return resolved === directory || isInside(resolved, filePath);
|
||||
};
|
||||
const paths = changeSet.updates.flatMap((update) => {
|
||||
if (update.type === "addDirectories") return update.directories;
|
||||
if (update.type !== "addRules" || update.behavior !== "allow") return [];
|
||||
return update.rules
|
||||
.filter((rule) =>
|
||||
operation === "read" ? rule.toolName === "Read" : rule.toolName === "Edit",
|
||||
)
|
||||
.map((rule) => permissionPath(rule.ruleContent))
|
||||
.filter((value): value is string => value !== undefined);
|
||||
});
|
||||
return paths.some(comparable);
|
||||
}
|
||||
|
||||
function fileSessionLabel(
|
||||
context: PermissionOptionContext,
|
||||
filePath: string | undefined,
|
||||
operation: "read" | "write",
|
||||
): string | undefined {
|
||||
if (!isFileSessionChangeSet(context.durableChangeSet, operation)) return undefined;
|
||||
const resolvedFilePath = filePath ? path.resolve(context.cwd, filePath) : undefined;
|
||||
const isClaudePath =
|
||||
operation === "write" &&
|
||||
!!resolvedFilePath &&
|
||||
(isInside(path.join(context.cwd, ".claude"), resolvedFilePath) ||
|
||||
isInside(path.join(os.homedir(), ".claude"), resolvedFilePath));
|
||||
const broadRule = hasBroadSessionAllowRuleFor(
|
||||
context.durableChangeSet!,
|
||||
operation === "read" ? "Read" : "Edit",
|
||||
);
|
||||
if (resolvedFilePath) {
|
||||
const coversCurrentPath =
|
||||
effectCoversFilePath(context.durableChangeSet!, resolvedFilePath, operation, context.cwd) ||
|
||||
broadRule ||
|
||||
(operation === "write" && !isClaudePath && hasAcceptEditsMode(context.durableChangeSet!));
|
||||
if (!coversCurrentPath) return undefined;
|
||||
} else if (
|
||||
!broadRule &&
|
||||
!(operation === "write" && hasAcceptEditsMode(context.durableChangeSet!))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
operation === "write" &&
|
||||
resolvedFilePath &&
|
||||
isClaudePath &&
|
||||
hasSessionAllowRuleFor(context.durableChangeSet, "Edit")
|
||||
) {
|
||||
return "Yes, and allow Claude to edit its own settings for this session";
|
||||
}
|
||||
if (!resolvedFilePath || isInside(context.cwd, resolvedFilePath)) {
|
||||
return operation === "read"
|
||||
? "Yes, during this session"
|
||||
: "Yes, allow all edits during this session";
|
||||
}
|
||||
const directory = path.dirname(resolvedFilePath);
|
||||
const directoryName = path.basename(directory) || "this directory";
|
||||
return operation === "read"
|
||||
? `Yes, allow reading from ${directoryName}${path.sep} during this session`
|
||||
: `Yes, allow all edits in ${directoryName}${path.sep} during this session`;
|
||||
}
|
||||
|
||||
function buildFilePermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
filePath: string | undefined,
|
||||
operation: "read" | "write",
|
||||
): PermissionOption[] {
|
||||
return withOptionalUpdate(
|
||||
context.durableChangeSet,
|
||||
fileSessionLabel(context, filePath, operation),
|
||||
"Yes",
|
||||
"No",
|
||||
);
|
||||
}
|
||||
|
||||
export function buildReadPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "file_path"), "read");
|
||||
}
|
||||
|
||||
export function buildGlobPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "path") ?? context.cwd, "read");
|
||||
}
|
||||
|
||||
export function buildGrepPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "path") ?? context.cwd, "read");
|
||||
}
|
||||
|
||||
export function buildEditPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "file_path"), "write");
|
||||
}
|
||||
|
||||
export function buildWritePermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "file_path"), "write");
|
||||
}
|
||||
|
||||
export function buildNotebookEditPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "notebook_path"), "write");
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import type { DurablePermissionChangeSet } from "../normalization.js";
|
||||
|
||||
export const PERMISSION_OPTION_ID = {
|
||||
allowOnce: "allow-once",
|
||||
allowWithUpdates: "allow-with-updates",
|
||||
allowSkillExact: "allow-skill-exact",
|
||||
allowSkillPrefix: "allow-skill-prefix",
|
||||
exitPlanBypass: "exit-plan-bypass",
|
||||
exitPlanAuto: "exit-plan-auto",
|
||||
exitPlanAcceptEdits: "exit-plan-accept-edits",
|
||||
exitPlanDefault: "exit-plan-default",
|
||||
exitPlanClearAuto: "exit-plan-clear-auto",
|
||||
exitPlanClearBypass: "exit-plan-clear-bypass",
|
||||
exitPlanClearAcceptEdits: "exit-plan-clear-accept-edits",
|
||||
reject: "reject",
|
||||
} as const;
|
||||
|
||||
export interface PermissionOptionContext {
|
||||
toolName: string;
|
||||
displayName?: string;
|
||||
input: Record<string, unknown>;
|
||||
cwd: string;
|
||||
durableChangeSet?: DurablePermissionChangeSet;
|
||||
allowPersistentOptions?: boolean;
|
||||
/** The CLI's `defaultToNo` hint (SDK 0.3.268+): the ask must not be
|
||||
* approvable by a stray keystroke, so the decline option leads. */
|
||||
defaultToNo?: boolean;
|
||||
availableModes?: readonly string[];
|
||||
/** The mode the session left when it entered plan mode. */
|
||||
prePlanMode?: string;
|
||||
contextUsedPercent?: number;
|
||||
}
|
||||
|
||||
export function allowOnce(name = "Yes"): PermissionOption {
|
||||
return { optionId: PERMISSION_OPTION_ID.allowOnce, name, kind: "allow_once" };
|
||||
}
|
||||
|
||||
export function allowWithUpdates(name: string): PermissionOption {
|
||||
return { optionId: PERMISSION_OPTION_ID.allowWithUpdates, name, kind: "allow_always" };
|
||||
}
|
||||
|
||||
export function reject(name = "No"): PermissionOption {
|
||||
return { optionId: PERMISSION_OPTION_ID.reject, name, kind: "reject_once" };
|
||||
}
|
||||
|
||||
export function withOptionalUpdate(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
updateName: string | undefined,
|
||||
allowName = "Yes",
|
||||
rejectName = "No",
|
||||
): PermissionOption[] {
|
||||
const options = [allowOnce(allowName)];
|
||||
if (changeSet && updateName) options.push(allowWithUpdates(updateName));
|
||||
options.push(reject(rejectName));
|
||||
return options;
|
||||
}
|
||||
|
||||
export function plainString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function exactLocalAllowRule(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
toolName: string,
|
||||
ruleContent?: string,
|
||||
): boolean {
|
||||
if (!changeSet || changeSet.updates.length !== 1) return false;
|
||||
const update = changeSet.updates[0];
|
||||
if (
|
||||
update?.type !== "addRules" ||
|
||||
update.behavior !== "allow" ||
|
||||
update.destination !== "localSettings" ||
|
||||
update.rules.length !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const rule = update.rules[0];
|
||||
return (
|
||||
rule?.toolName === toolName &&
|
||||
(ruleContent === undefined
|
||||
? rule.ruleContent === undefined
|
||||
: plainString(rule.ruleContent) === ruleContent)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An MCP "don't ask again" option is only truthful when every provider update
|
||||
* adds an unrestricted allow rule for the tool currently being prompted.
|
||||
*/
|
||||
export function isMcpAllowChangeSet(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
toolName: string,
|
||||
): boolean {
|
||||
return (
|
||||
!!changeSet &&
|
||||
changeSet.updates.length > 0 &&
|
||||
changeSet.updates.every(
|
||||
(update) =>
|
||||
update.type === "addRules" &&
|
||||
update.behavior === "allow" &&
|
||||
update.rules.length > 0 &&
|
||||
update.rules.every((rule) => rule.toolName === toolName && rule.ruleContent === undefined),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function withGeneratedUpdate(name: string, rejectName = "No"): PermissionOption[] {
|
||||
return [allowOnce(), allowWithUpdates(name), reject(rejectName)];
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import path from "node:path";
|
||||
import type { DurablePermissionChangeSet } from "../normalization.js";
|
||||
import { plainString, type PermissionOptionContext, withOptionalUpdate } from "./shared.js";
|
||||
|
||||
function permissionRulePrefix(value: string): string {
|
||||
return value.endsWith(":*") ? value.slice(0, -2) : value;
|
||||
}
|
||||
|
||||
function displayList(values: string[]): string {
|
||||
if (values.join(", ").length > 50) return "similar";
|
||||
if (values.length === 1) return values[0]!;
|
||||
if (values.length === 2) return `${values[0]} and ${values[1]}`;
|
||||
return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
|
||||
}
|
||||
|
||||
/** Shorten paths only as much as needed to distinguish them in prompt text. */
|
||||
function displayPaths(paths: string[]): string {
|
||||
const normalizedPaths = [...new Set(paths.map((value) => path.normalize(value)))];
|
||||
const segments = normalizedPaths.map((value) => value.split(path.sep).filter(Boolean));
|
||||
|
||||
// Compare equally deep suffixes so duplicate basenames gain the smallest
|
||||
// useful parent prefix (for example, codex-acp/src/ vs claude-acp/src/).
|
||||
const suffix = (index: number, depth: number): string =>
|
||||
segments[index]!.slice(-depth).join(path.sep) || normalizedPaths[index]!;
|
||||
const names = normalizedPaths.map((value, index) => {
|
||||
const parts = segments[index]!;
|
||||
let name = path.basename(value) || value;
|
||||
for (let depth = 1; depth <= parts.length; depth++) {
|
||||
const candidate = suffix(index, depth);
|
||||
if (
|
||||
normalizedPaths.every((_, other) => other === index || suffix(other, depth) !== candidate)
|
||||
) {
|
||||
name = candidate;
|
||||
break;
|
||||
}
|
||||
if (depth === parts.length) name = value;
|
||||
}
|
||||
return name.endsWith(path.sep) ? name : `${name}${path.sep}`;
|
||||
});
|
||||
|
||||
// Native Claude labels enumerate two paths and summarize larger sets.
|
||||
if (names.length <= 2) return displayList(names);
|
||||
return `${names[0]}, ${names[1]} and ${names.length - 2} more`;
|
||||
}
|
||||
|
||||
/** Plain-text port of Claude Code's generateShellSuggestionsLabel. */
|
||||
function shellSuggestionsLabel(
|
||||
toolName: "Bash" | "PowerShell",
|
||||
changeSet: DurablePermissionChangeSet,
|
||||
): string | undefined {
|
||||
const representable = changeSet.updates.every((update) => {
|
||||
if (update.type === "addDirectories") return update.destination === "session";
|
||||
if (update.type !== "addRules" || update.behavior !== "allow") return false;
|
||||
return update.rules.every(
|
||||
(rule) =>
|
||||
(rule.toolName === toolName || rule.toolName === "Read") &&
|
||||
plainString(rule.ruleContent) !== undefined,
|
||||
);
|
||||
});
|
||||
if (!representable) return undefined;
|
||||
|
||||
const rules = changeSet.updates
|
||||
.filter((update) => update.type === "addRules")
|
||||
.flatMap((update) => update.rules);
|
||||
const readPaths = rules
|
||||
.filter((rule) => rule.toolName === "Read")
|
||||
.map((rule) => rule.ruleContent?.replace(/\/\*\*$/, ""))
|
||||
.filter((value): value is string => !!value);
|
||||
const commands = [
|
||||
...new Set(
|
||||
rules
|
||||
.filter((rule) => rule.toolName === toolName && rule.ruleContent)
|
||||
.map((rule) => permissionRulePrefix(rule.ruleContent!)),
|
||||
),
|
||||
];
|
||||
const directories = changeSet.updates
|
||||
.filter((update) => update.type === "addDirectories")
|
||||
.flatMap((update) => update.directories);
|
||||
const hasPaths = readPaths.length > 0 || directories.length > 0;
|
||||
|
||||
if (readPaths.length > 0 && directories.length === 0 && commands.length === 0) {
|
||||
return `Yes, allow reading from ${displayPaths(readPaths)} from this project`;
|
||||
}
|
||||
if (directories.length > 0 && readPaths.length === 0 && commands.length === 0) {
|
||||
return `Yes, and always allow access to ${displayPaths(directories)} from this project`;
|
||||
}
|
||||
if (commands.length > 0 && !hasPaths) {
|
||||
return `Yes, and don't ask again for ${displayList(commands)} commands`;
|
||||
}
|
||||
if (hasPaths && commands.length === 0) {
|
||||
return `Yes, and always allow access to ${displayPaths([...directories, ...readPaths])} from this project`;
|
||||
}
|
||||
if (hasPaths && commands.length > 0) {
|
||||
const paths = [...directories, ...readPaths];
|
||||
return paths.length === 1 && commands.length === 1
|
||||
? `Yes, and allow access to ${displayPaths(paths)} and ${displayList(commands)} commands`
|
||||
: `Yes, and allow ${displayPaths(paths)} access and ${displayList(commands)} commands`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildShellPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
toolName: "Bash" | "PowerShell",
|
||||
): PermissionOption[] {
|
||||
const name = context.durableChangeSet
|
||||
? shellSuggestionsLabel(toolName, context.durableChangeSet)
|
||||
: undefined;
|
||||
return withOptionalUpdate(context.durableChangeSet, name, "Yes", "No");
|
||||
}
|
||||
|
||||
export function buildBashPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildShellPermissionOptions(context, "Bash");
|
||||
}
|
||||
|
||||
export function buildPowerShellPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
return buildShellPermissionOptions(context, "PowerShell");
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
allowOnce,
|
||||
exactLocalAllowRule,
|
||||
isMcpAllowChangeSet,
|
||||
PERMISSION_OPTION_ID,
|
||||
plainString,
|
||||
type PermissionOptionContext,
|
||||
reject,
|
||||
withGeneratedUpdate,
|
||||
withOptionalUpdate,
|
||||
} from "./shared.js";
|
||||
|
||||
export function buildWebFetchPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const url = plainString(context.input.url);
|
||||
if (url && context.allowPersistentOptions !== false) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
if (hostname) {
|
||||
return withGeneratedUpdate(`Yes, and don't ask again for ${hostname}`);
|
||||
}
|
||||
} catch {
|
||||
// Invalid input cannot produce Claude Code's domain-specific option.
|
||||
}
|
||||
}
|
||||
return [allowOnce(), reject("No")];
|
||||
}
|
||||
|
||||
export function buildSkillPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
const skill = plainString(context.input.skill);
|
||||
const options = [allowOnce()];
|
||||
if (skill && context.allowPersistentOptions !== false) {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.allowSkillExact,
|
||||
name: `Yes, and don't ask again for ${skill}`,
|
||||
kind: "allow_always",
|
||||
});
|
||||
const spaceIndex = skill.indexOf(" ");
|
||||
if (spaceIndex > 0) {
|
||||
const prefix = `${skill.slice(0, spaceIndex)}:*`;
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.allowSkillPrefix,
|
||||
name: `Yes, and don't ask again for ${prefix} commands`,
|
||||
kind: "allow_always",
|
||||
});
|
||||
}
|
||||
}
|
||||
options.push(reject("No"));
|
||||
return options;
|
||||
}
|
||||
|
||||
export function buildEnterPlanModePermissionOptions(): PermissionOption[] {
|
||||
return [allowOnce("Yes, enter plan mode"), reject("No, start implementing now")];
|
||||
}
|
||||
|
||||
export function buildExitPlanModePermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const modes = new Set(context.availableModes);
|
||||
const options: PermissionOption[] = [];
|
||||
// Claude Code offers bypass only when the user launched with it, so it can
|
||||
// prefer bypass outright. The adapter advertises bypass by default, so Auto
|
||||
// leads unless the session was in bypass before entering plan mode. Both stay
|
||||
// selectable: they have different permission semantics.
|
||||
const elevatedModes = (
|
||||
context.prePlanMode === "bypassPermissions"
|
||||
? (["bypassPermissions", "auto"] as const)
|
||||
: (["auto", "bypassPermissions"] as const)
|
||||
).filter((mode) => modes.has(mode));
|
||||
const preferredMode = elevatedModes[0] ?? "acceptEdits";
|
||||
if (plainString(context.input.plan)) {
|
||||
const usage =
|
||||
context.contextUsedPercent === undefined ? "" : ` (${context.contextUsedPercent}% used)`;
|
||||
options.push(
|
||||
preferredMode === "auto"
|
||||
? {
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearAuto,
|
||||
name: `Yes, clear context${usage} and use auto mode`,
|
||||
kind: "allow_always",
|
||||
}
|
||||
: preferredMode === "bypassPermissions"
|
||||
? {
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearBypass,
|
||||
name: `Yes, clear context${usage} and bypass permissions`,
|
||||
kind: "allow_always",
|
||||
}
|
||||
: {
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearAcceptEdits,
|
||||
name: `Yes, clear context${usage} and auto-accept edits`,
|
||||
kind: "allow_always",
|
||||
},
|
||||
);
|
||||
}
|
||||
for (const mode of elevatedModes) {
|
||||
options.push(
|
||||
mode === "auto"
|
||||
? {
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanAuto,
|
||||
name: "Yes, and use auto mode",
|
||||
kind: "allow_always",
|
||||
}
|
||||
: {
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanBypass,
|
||||
name: "Yes, and bypass permissions",
|
||||
kind: "allow_always",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (elevatedModes.length === 0) {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanAcceptEdits,
|
||||
name: "Yes, auto-accept edits",
|
||||
kind: "allow_always",
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanDefault,
|
||||
name: "Yes, manually approve edits",
|
||||
kind: "allow_once",
|
||||
});
|
||||
options.push(reject("No, keep planning"));
|
||||
return options;
|
||||
}
|
||||
|
||||
export function buildSandboxNetworkPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const host = plainString(context.input.host);
|
||||
const name =
|
||||
host && exactLocalAllowRule(context.durableChangeSet, context.toolName, host)
|
||||
? `Yes, and don't ask again for ${host}`
|
||||
: undefined;
|
||||
return withOptionalUpdate(context.durableChangeSet, name, "Yes", "No");
|
||||
}
|
||||
|
||||
export function buildFallbackPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const toolLabel = plainString(context.displayName) ?? context.toolName;
|
||||
if (context.toolName.startsWith("mcp__")) {
|
||||
const changeSet =
|
||||
context.allowPersistentOptions !== false &&
|
||||
isMcpAllowChangeSet(context.durableChangeSet, context.toolName)
|
||||
? context.durableChangeSet
|
||||
: undefined;
|
||||
return withOptionalUpdate(
|
||||
changeSet,
|
||||
changeSet ? `Yes, and don't ask again for ${toolLabel} commands` : undefined,
|
||||
);
|
||||
}
|
||||
if (context.allowPersistentOptions !== false) {
|
||||
return withGeneratedUpdate(`Yes, and don't ask again for ${toolLabel} commands`);
|
||||
}
|
||||
return [allowOnce(), reject("No")];
|
||||
}
|
||||
|
||||
const COMPUTER_USE_MCP_TOOL_PREFIX = "mcp__computer-use__";
|
||||
|
||||
export function isComputerUseMcpTool(toolName: string): boolean {
|
||||
return toolName.startsWith(COMPUTER_USE_MCP_TOOL_PREFIX);
|
||||
}
|
||||
|
||||
export function buildComputerUseMcpPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const toolLabel = plainString(context.displayName) ?? context.toolName;
|
||||
const changeSet =
|
||||
context.allowPersistentOptions !== false &&
|
||||
isMcpAllowChangeSet(context.durableChangeSet, context.toolName)
|
||||
? context.durableChangeSet
|
||||
: undefined;
|
||||
return withOptionalUpdate(
|
||||
changeSet,
|
||||
changeSet ? `Yes, and don't ask again for ${toolLabel}` : undefined,
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { RequestPermissionRequest, ToolCallContent } from "@agentclientprotocol/sdk";
|
||||
import { AIR_PERMISSION_KEY, withAirMeta } from "../air-extension.js";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
|
||||
|
||||
/**
|
||||
* A permission request as the agent builds it. For a v2 client it also
|
||||
* carries the heading and the explanation of the prompt, which ACP v2 sends as
|
||||
* the request's `title` and `description`. v1 has no field for them; AIR gets
|
||||
* them in `_meta`.
|
||||
*/
|
||||
export type AcpPermissionRequest = RequestPermissionRequest & {
|
||||
/** The heading of the prompt. It is not the title of the tool call. */
|
||||
title?: string;
|
||||
/** Why the permission is asked. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export interface ClaudePermissionPresentationInput {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
toolUseID: string;
|
||||
cwd?: string;
|
||||
capabilities?: ClientCapabilities;
|
||||
/** The exact patch of the change to approve, for a negotiated `diffPatch` client or a v2 client. */
|
||||
previewContent?: ToolCallContent[];
|
||||
blockedPath?: string;
|
||||
title?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
decisionReason?: string;
|
||||
defaultToNo?: boolean;
|
||||
}
|
||||
|
||||
function humanText(value: unknown, maxLength: number, singleLine = false): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const withoutControls = Array.from(value)
|
||||
.filter((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return !(
|
||||
code <= 0x08 ||
|
||||
code === 0x0b ||
|
||||
code === 0x0c ||
|
||||
(code >= 0x0e && code <= 0x1f) ||
|
||||
code === 0x7f
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
const normalized = singleLine
|
||||
? withoutControls.replace(/\s+/gu, " ").trim()
|
||||
: withoutControls.trim();
|
||||
return normalized && normalized.length <= maxLength ? normalized : undefined;
|
||||
}
|
||||
|
||||
function compactText(value: unknown): string | undefined {
|
||||
return humanText(value, 160, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission request presentation.
|
||||
*
|
||||
* The request `toolCall` carries `toolCallId`, `title`, and `rawInput`. The
|
||||
* adapter emits the `tool_call` before the request, so the client already
|
||||
* holds the rest. The request adds only what it shows new: the exact preview
|
||||
* patch, and the blocked path when the tool call has no such location.
|
||||
*
|
||||
* The heading and the explanation of the prompt go to AIR in `_meta`, and to a
|
||||
* v2 client as `title` and `description`.
|
||||
*/
|
||||
export function buildClaudePermissionPresentation(
|
||||
value: ClaudePermissionPresentationInput,
|
||||
): Pick<AcpPermissionRequest, "toolCall" | "_meta" | "title" | "description"> {
|
||||
const capabilities = value.capabilities ?? new ClientCapabilities();
|
||||
const renderer = new AcpToolCallRenderer(capabilities);
|
||||
const toolUse = { id: value.toolUseID, name: value.toolName, input: value.input };
|
||||
const facts = renderer.facts(toolUse, value.cwd);
|
||||
const host =
|
||||
value.toolName === "SandboxNetworkAccess" ? compactText(value.input.host) : undefined;
|
||||
const isComputerUse = value.toolName.startsWith("mcp__computer-use__");
|
||||
const subjectTitle = host ?? (isComputerUse ? compactText(value.displayName) : undefined);
|
||||
// Reuse the exact standard tool-call heading as the permission heading so
|
||||
// the approval never maintains a second, divergent name for the operation.
|
||||
// decisionReason is temporarily exposed as the permission description so
|
||||
// its actual SDK values can be inspected; it remains diagnostic policy text.
|
||||
const toolCallTitle = subjectTitle ?? facts.title;
|
||||
const permissionTitle = value.toolName === "ExitPlanMode" ? "Ready to code?" : toolCallTitle;
|
||||
// Shell titles are executable input: compacting whitespace changes quoted
|
||||
// arguments and comment boundaries, and length limits can hide the command.
|
||||
const title =
|
||||
value.toolName === "Bash" || value.toolName === "PowerShell"
|
||||
? permissionTitle
|
||||
: (humanText(permissionTitle, 4_000, true) ?? "Use tool?");
|
||||
const decisionReason = humanText(value.decisionReason, 4_000);
|
||||
const description = decisionReason ? `Reason: ${decisionReason}` : undefined;
|
||||
const blockedPath = humanText(value.blockedPath, 4_096, true);
|
||||
const extraLocations =
|
||||
blockedPath && !(facts.locations ?? []).some((location) => location.path === blockedPath)
|
||||
? [{ path: blockedPath }]
|
||||
: undefined;
|
||||
return {
|
||||
toolCall: renderer.permissionToolCall(toolUse, {
|
||||
cwd: value.cwd,
|
||||
title: toolCallTitle,
|
||||
previewContent: value.previewContent,
|
||||
extraLocations,
|
||||
// The upstream request shows the input of a network or computer-use
|
||||
// request that has no content of its own.
|
||||
fallbackContent:
|
||||
host || isComputerUse
|
||||
? [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `\`\`\`json\n${JSON.stringify(value.input, null, 2)}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
}),
|
||||
// Only AIR gets the permission presentation.
|
||||
...(title && capabilities.air.client
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_PERMISSION_KEY, {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// The CLI's own hint, forwarded so a client that can pre-select
|
||||
// an option keeps the decline focused; the option order already
|
||||
// leads with the reject options when this is set.
|
||||
...(value.defaultToNo === true ? { defaultToNo: true } : {}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { RequestPermissionResponse } from "@agentclientprotocol/sdk";
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import type { PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { DurablePermissionChangeSet } from "./normalization.js";
|
||||
import { applyClaudePermissionSelection, parseClaudePermissionSelection } from "./effects.js";
|
||||
|
||||
export interface ClaudePermissionDecision {
|
||||
permissionResult: PermissionResult;
|
||||
contextResetMode?: PermissionMode;
|
||||
}
|
||||
|
||||
/** Decode, validate, and interpret an ACP response exactly once. */
|
||||
export function decodeClaudePermissionResponse(
|
||||
response: RequestPermissionResponse,
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
toolUseID: string,
|
||||
offeredOptions: readonly PermissionOption[],
|
||||
durableChangeSet?: DurablePermissionChangeSet,
|
||||
): ClaudePermissionDecision {
|
||||
const selection = parseClaudePermissionSelection(response, toolName);
|
||||
const offeredOption = offeredOptions.find((option) => option.optionId === selection.optionId);
|
||||
if (!offeredOption) {
|
||||
throw new Error(`Permission option was not offered: ${selection.optionId}`);
|
||||
}
|
||||
const permissionResult = applyClaudePermissionSelection(selection, {
|
||||
toolName,
|
||||
input,
|
||||
toolUseID,
|
||||
durableChangeSet,
|
||||
});
|
||||
return {
|
||||
permissionResult,
|
||||
...(selection.contextResetMode ? { contextResetMode: selection.contextResetMode } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { getSessionMessages, type SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { access, open, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { claudeConfigDir } from "./paths.js";
|
||||
import { SessionTiming } from "./session-timing.js";
|
||||
|
||||
/** The size of one backward read of a transcript. */
|
||||
const TAIL_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
type ResumeLogger = {
|
||||
log: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
export type ResumedSessionSnapshot = {
|
||||
messages?: SessionMessage[];
|
||||
model?: string;
|
||||
};
|
||||
|
||||
/** Return the concrete model recorded by the last real assistant response.
|
||||
* Claude Code restores a resumed query from this same transcript field.
|
||||
* Synthetic assistant records use angle-bracket placeholders and do not
|
||||
* describe a model the resumed query can run. */
|
||||
export function resumedModelFromTranscript(messages: SessionMessage[]): string | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const entry = messages[index];
|
||||
if (
|
||||
entry?.type !== "assistant" ||
|
||||
entry.parent_tool_use_id != null ||
|
||||
entry.parent_agent_id != null ||
|
||||
!entry.message ||
|
||||
typeof entry.message !== "object"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const model = concreteModel((entry.message as { model?: unknown }).model);
|
||||
if (model) return model;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The model name, or undefined for a missing or a synthetic `<…>` model. */
|
||||
function concreteModel(model: unknown): string | undefined {
|
||||
if (typeof model !== "string") return undefined;
|
||||
const name = model.trim();
|
||||
return name.length > 0 && !/^<[^>]+>$/.test(name) ? name : undefined;
|
||||
}
|
||||
|
||||
/** Read the resume model from the local transcript without starting a Claude
|
||||
* control request. This is intentionally on the load critical path. */
|
||||
export async function readResumedSession(
|
||||
sessionId: string,
|
||||
logger?: ResumeLogger,
|
||||
): Promise<ResumedSessionSnapshot> {
|
||||
const timing = new SessionTiming(logger, "models", sessionId);
|
||||
try {
|
||||
// Deliberately search all project directories, matching replaySessionHistory.
|
||||
// A client may reopen a session from a worktree or normalized path that is
|
||||
// different from the directory under which Claude persisted the transcript.
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
const model = resumedModelFromTranscript(messages);
|
||||
timing.phase("read-transcript", ` messages=${messages.length} model=${model ?? "unknown"}`);
|
||||
return { messages, model };
|
||||
} catch (error) {
|
||||
timing.phase("read-transcript", " outcome=error");
|
||||
logger?.error(`Failed to read transcript for resumed session ${sessionId}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** What a resume restores from the end of the local transcript. */
|
||||
export type ResumedTail = {
|
||||
model?: string;
|
||||
/** The last permission mode of the main thread. See {@link TailScan}. */
|
||||
permissionMode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the resume model and the permission mode of a session from the end
|
||||
* of its local transcript.
|
||||
*
|
||||
* A resume needs only these two values, not the messages. The transcript file
|
||||
* is read backwards until both are known, so the cost does not grow with the
|
||||
* length of the session. A session without a local transcript file falls back
|
||||
* to {@link readResumedSession} for the model.
|
||||
*
|
||||
* A caller that already has the model passes `withModel: false`. The read then
|
||||
* looks for the permission mode only and has no fallback.
|
||||
*/
|
||||
export async function readResumedTail(
|
||||
sessionId: string,
|
||||
logger?: ResumeLogger,
|
||||
withModel = true,
|
||||
): Promise<ResumedTail> {
|
||||
const timing = new SessionTiming(logger, "models", sessionId);
|
||||
try {
|
||||
const filePath = await findTranscript(sessionId);
|
||||
if (filePath) {
|
||||
const tail = await lastTailRecords(filePath, withModel);
|
||||
timing.phase(
|
||||
"read-transcript-tail",
|
||||
` model=${tail.model ?? "unknown"} permissionMode=${tail.permissionMode ?? "unknown"}`,
|
||||
);
|
||||
return tail;
|
||||
}
|
||||
} catch (error) {
|
||||
timing.phase("read-transcript-tail", " outcome=error");
|
||||
logger?.error(`Failed to read the transcript tail of resumed session ${sessionId}:`, error);
|
||||
}
|
||||
return withModel ? { model: (await readResumedSession(sessionId, logger)).model } : {};
|
||||
}
|
||||
|
||||
/** The local transcript of a session in any project directory, as the SDK looks it up. */
|
||||
export async function findTranscript(sessionId: string): Promise<string | undefined> {
|
||||
const projects = path.join(claudeConfigDir(), "projects");
|
||||
let directories: string[];
|
||||
try {
|
||||
directories = await readdir(projects);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
for (const directory of directories) {
|
||||
const candidate = path.join(projects, directory, `${sessionId}.jsonl`);
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Not in this project directory.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The model of the last real assistant record and the last permission mode
|
||||
* of the main thread in a JSONL transcript. */
|
||||
async function lastTailRecords(filePath: string, withModel: boolean): Promise<ResumedTail> {
|
||||
const handle = await open(filePath, "r");
|
||||
const scan = new TailScan(withModel);
|
||||
try {
|
||||
let end = (await handle.stat()).size;
|
||||
// The bytes of the line whose start is not read yet, in file order.
|
||||
let pending: Buffer[] = [];
|
||||
while (end > 0) {
|
||||
const start = Math.max(0, end - TAIL_CHUNK_BYTES);
|
||||
let chunk = Buffer.alloc(end - start);
|
||||
await handle.read(chunk, 0, chunk.length, start);
|
||||
for (let at = chunk.lastIndexOf(0x0a); at >= 0; at = chunk.lastIndexOf(0x0a)) {
|
||||
if (scan.visit(Buffer.concat([chunk.subarray(at + 1), ...pending]))) return scan.tail;
|
||||
pending = [];
|
||||
chunk = chunk.subarray(0, at);
|
||||
}
|
||||
pending.unshift(chunk);
|
||||
end = start;
|
||||
}
|
||||
scan.visit(Buffer.concat(pending));
|
||||
return scan.tail;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
type TranscriptRecord = {
|
||||
type?: unknown;
|
||||
isSidechain?: unknown;
|
||||
message?: { model?: unknown };
|
||||
permissionMode?: unknown;
|
||||
origin?: { kind?: unknown };
|
||||
attachment?: { type?: unknown };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the lines of a transcript from the end and keeps what a resume needs.
|
||||
*
|
||||
* Claude Code records the permission mode on each prompt record of type `user`.
|
||||
* The `permission-mode` records of the CLI metadata block are not used: they
|
||||
* can disagree with the prompts, and the SDK does not write them.
|
||||
* The SDK writes no record when it leaves plan mode through ExitPlanMode.
|
||||
* It writes a `plan_mode_exit` attachment only, without the new mode. Thus a
|
||||
* `plan` mode before a later plan exit is not the current mode, and the scan
|
||||
* gives no mode.
|
||||
*
|
||||
* The mode search stops at the first mode record or at the last human prompt.
|
||||
* Each human prompt of a current Claude Code has the mode, so a prompt without
|
||||
* it means that the transcript does not record modes. A compaction does not
|
||||
* stop the search: the mode of the prompt before it is still current.
|
||||
*/
|
||||
class TailScan {
|
||||
readonly tail: ResumedTail = {};
|
||||
private modeDone = false;
|
||||
private planExited = false;
|
||||
|
||||
constructor(private readonly withModel: boolean) {}
|
||||
|
||||
/** Visit the previous line. Return true when the scan has all it needs. */
|
||||
visit(line: Buffer): boolean {
|
||||
let parsed: TranscriptRecord | null | undefined;
|
||||
// Most lines have no marker of interest. The check skips their JSON parse.
|
||||
const record = (marker: string): TranscriptRecord | undefined => {
|
||||
if (line.indexOf(marker) < 0) return undefined;
|
||||
if (parsed === undefined) parsed = parseMainThreadRecord(line);
|
||||
return parsed ?? undefined;
|
||||
};
|
||||
if (this.withModel && this.tail.model === undefined) {
|
||||
const entry = record('"assistant"');
|
||||
if (entry?.type === "assistant") this.tail.model = concreteModel(entry.message?.model);
|
||||
}
|
||||
if (!this.modeDone) this.visitForMode(record);
|
||||
return (!this.withModel || this.tail.model !== undefined) && this.modeDone;
|
||||
}
|
||||
|
||||
private visitForMode(record: (marker: string) => TranscriptRecord | undefined): void {
|
||||
const modeEntry = record('"permissionMode"');
|
||||
if (
|
||||
modeEntry?.type === "user" &&
|
||||
typeof modeEntry.permissionMode === "string" &&
|
||||
modeEntry.permissionMode.trim() !== ""
|
||||
) {
|
||||
const mode = modeEntry.permissionMode.trim();
|
||||
this.modeDone = true;
|
||||
if (!(this.planExited && mode === "plan")) this.tail.permissionMode = mode;
|
||||
return;
|
||||
}
|
||||
if (record('"plan_mode_exit"')?.attachment?.type === "plan_mode_exit") {
|
||||
this.planExited = true;
|
||||
return;
|
||||
}
|
||||
const human = record('"human"');
|
||||
if (human?.type === "user" && human.origin?.kind === "human") this.modeDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The parsed record of one line, or null for a subagent record or a bad line. */
|
||||
function parseMainThreadRecord(line: Buffer): TranscriptRecord | null {
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line.toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const record = entry as TranscriptRecord;
|
||||
return record.isSidechain === true ? null : record;
|
||||
}
|
||||
51
src/serve.ts
51
src/serve.ts
@@ -1,51 +0,0 @@
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* Serves ACP on stdio.
|
||||
*
|
||||
* With `experimentalV2`, the connection goes through {@link acpProtocolRouter}.
|
||||
* Without it, the adapter serves ACP v1 alone and never routes by protocol
|
||||
* version, exactly as before.
|
||||
*/
|
||||
export function serveAcp(
|
||||
logger: Logger | undefined,
|
||||
options: { experimentalV2: boolean },
|
||||
): { closed: Promise<void>; dispose(): Promise<void> } {
|
||||
const output = nodeToWebWritable(process.stdout);
|
||||
const input = nodeToWebReadable(process.stdin);
|
||||
// The router opens the connection of the negotiated version only when it
|
||||
// reads `initialize`, so a client that leaves before then leaves no agent.
|
||||
let agent: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = options.experimentalV2
|
||||
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
|
||||
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
|
||||
return {
|
||||
closed,
|
||||
dispose: async () => {
|
||||
await agent?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a connection to the protocol version that the client negotiates in
|
||||
* `initialize`: the experimental draft ACP v2 for a client that requests
|
||||
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
|
||||
* the connection once the router has opened it.
|
||||
*/
|
||||
export function acpProtocolRouter(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentProtocolRouter {
|
||||
return v2
|
||||
.agentProtocolRouter()
|
||||
.withV1(v1AgentApp(logger, onAgent))
|
||||
.withV2(v2AgentApp(logger, onAgent));
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/** Shared sentinels and ids for session preferences managed by the adapter. */
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import type { EffortLevel, ModelInfo, Settings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
import { EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
export { EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
// The SDK drops `undefined` during JSON transport and only clears a flag-layer
|
||||
// setting when it receives an explicit `null`. Map both an absent picker and
|
||||
// the legacy "default" row to null so a previously applied effort is cleared.
|
||||
export function toSdkEffortLevel(value: string | undefined): EffortLevel | null {
|
||||
return value === undefined || value === "default" ? null : (value as EffortLevel);
|
||||
}
|
||||
|
||||
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
|
||||
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
|
||||
// ultracode on at any level, so carry a settings-requested ultracode along
|
||||
// with every effort apply.
|
||||
export function effortFlagSettings(
|
||||
value: string | undefined,
|
||||
settings: Settings,
|
||||
): { effortLevel: EffortLevel | null; ultracode?: true } {
|
||||
const effortLevel = toSdkEffortLevel(value);
|
||||
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/-(\d+m)$/i, "[$1]");
|
||||
}
|
||||
|
||||
/** Resolve the effort the CLI will use: per-model settings first, then the
|
||||
* legacy top-level effort setting. Model settings are keyed by canonical
|
||||
* model name, so try the resolved SDK model before picker and raw IDs. */
|
||||
export function settingsEffortForModel(
|
||||
settings: Settings,
|
||||
modelInfo: ModelInfo | undefined,
|
||||
modelId?: string,
|
||||
): string | undefined {
|
||||
const modelSettings = settings.modelSettings;
|
||||
if (modelSettings) {
|
||||
for (const key of [modelInfo?.resolvedModel, modelInfo?.value, modelId]) {
|
||||
if (key === undefined) continue;
|
||||
const exact = modelSettings[key]?.effortLevel;
|
||||
if (typeof exact === "string") return exact;
|
||||
const canonicalKey = canonicalizeModelSettingsKey(key);
|
||||
const matchingEntry = Object.entries(modelSettings).find(
|
||||
([candidate]) => canonicalizeModelSettingsKey(candidate) === canonicalKey,
|
||||
);
|
||||
const perModel = matchingEntry?.[1]?.effortLevel;
|
||||
if (typeof perModel === "string") return perModel;
|
||||
}
|
||||
}
|
||||
return settings.effortLevel;
|
||||
}
|
||||
|
||||
/** Programmatic query settings have higher priority than file-backed settings,
|
||||
* matching the SDK. Keep unrelated per-model entries from lower tiers while
|
||||
* replacing entries supplied at the programmatic tier. */
|
||||
export function mergeEffortSettings(base: Settings, override: Settings | undefined): Settings {
|
||||
if (!override) return base;
|
||||
const overriddenModelKeys = new Set(
|
||||
Object.keys(override.modelSettings ?? {}).map(canonicalizeModelSettingsKey),
|
||||
);
|
||||
const unshadowedBaseModelSettings = Object.fromEntries(
|
||||
Object.entries(base.modelSettings ?? {}).filter(
|
||||
([key]) => !overriddenModelKeys.has(canonicalizeModelSettingsKey(key)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
modelSettings:
|
||||
base.modelSettings || override.modelSettings
|
||||
? { ...unshadowedBaseModelSettings, ...override.modelSettings }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEffortConfigOption(
|
||||
modelInfos: ModelInfo[],
|
||||
currentModelId: string,
|
||||
currentEffortLevel: string | undefined,
|
||||
useRecommendedValue: boolean,
|
||||
): SessionConfigOption | undefined {
|
||||
const currentModelInfo = modelInfos.find((model) => model.value === currentModelId);
|
||||
const supportedLevels = currentModelInfo?.supportsEffort
|
||||
? (currentModelInfo.supportedEffortLevels ?? [])
|
||||
: [];
|
||||
if (supportedLevels.length === 0) return undefined;
|
||||
|
||||
const recommendedEffort = (supportedLevels as string[]).includes("medium")
|
||||
? "medium"
|
||||
: supportedLevels[0];
|
||||
const options = [
|
||||
...(useRecommendedValue ? [] : [{ value: "default", name: "Default" }]),
|
||||
...supportedLevels.map((level) => ({
|
||||
value: level,
|
||||
name: level
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
})),
|
||||
];
|
||||
const includes = (level: string) =>
|
||||
(!useRecommendedValue && level === "default") || (supportedLevels as string[]).includes(level);
|
||||
const currentValue =
|
||||
currentEffortLevel && includes(currentEffortLevel)
|
||||
? currentEffortLevel
|
||||
: useRecommendedValue
|
||||
? recommendedEffort
|
||||
: "default";
|
||||
|
||||
return {
|
||||
id: EFFORT_CONFIG_ID,
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue,
|
||||
options,
|
||||
...(useRecommendedValue
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommendedEffort),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
getSessionMessages,
|
||||
type SDKAssistantMessageError,
|
||||
USAGE_LIMIT_ERROR_PREFIXES,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
AIR_SESSION_FAILURE_CAPABILITY,
|
||||
airCapabilityMeta,
|
||||
clientSupportsAirCapability,
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
|
||||
export function airSessionFailureCapabilityMeta(...additionalCapabilities: string[]) {
|
||||
return airCapabilityMeta(AIR_SESSION_FAILURE_CAPABILITY, ...additionalCapabilities);
|
||||
}
|
||||
|
||||
export type ClaudeFailureKind =
|
||||
| "access_denied"
|
||||
| "advisory"
|
||||
| "auth_required"
|
||||
| "bad_request"
|
||||
| "budget_exhausted"
|
||||
| "context_exhausted"
|
||||
| "internal_error"
|
||||
| "overloaded"
|
||||
| "provider_error"
|
||||
| "quota_exhausted"
|
||||
| "rate_limited"
|
||||
| "transport_lost"
|
||||
| "worker_shutdown";
|
||||
|
||||
type AirSessionFailureCategory =
|
||||
"connection" | "access" | "limit" | "request" | "service" | "unknown";
|
||||
|
||||
type AirSessionFailureSeverity = "warning" | "error";
|
||||
type AirSessionFailureAction = "retry" | "login" | "new_session";
|
||||
|
||||
type SessionFailureRecoveryPolicy =
|
||||
"never" | "next_attempt" | "real_model_success" | "auth_status" | "runtime_rebind";
|
||||
|
||||
export type PublishedSessionFailure = {
|
||||
id: string;
|
||||
revision: number;
|
||||
kind: ClaudeFailureKind;
|
||||
category: AirSessionFailureCategory;
|
||||
turnId?: string;
|
||||
severity: AirSessionFailureSeverity;
|
||||
title: string;
|
||||
details?: string;
|
||||
/** A machine-readable refinement of `kind`, e.g. the `--hide-claude-auth`
|
||||
* subscription guard's reason on an `auth_required` failure. Absent for a
|
||||
* plain sign-out. */
|
||||
reason?: string;
|
||||
actions: AirSessionFailureAction[];
|
||||
recoveryPolicy: SessionFailureRecoveryPolicy;
|
||||
};
|
||||
|
||||
type SessionFailureOptions = {
|
||||
turnId?: string;
|
||||
sessionScoped?: boolean;
|
||||
title?: string;
|
||||
details?: string;
|
||||
severity?: AirSessionFailureSeverity;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type SessionFailureState = {
|
||||
epoch: string;
|
||||
revisions: Map<string, number>;
|
||||
active: Map<string, PublishedSessionFailure>;
|
||||
nextIncident?: number;
|
||||
lastNotice?: { title: string; id: string };
|
||||
};
|
||||
|
||||
type Logger = {
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
export function createSessionFailureState(): SessionFailureState {
|
||||
return {
|
||||
epoch: randomUUID(),
|
||||
revisions: new Map(),
|
||||
active: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const AIR_FAILURE_POLICY: Record<
|
||||
ClaudeFailureKind,
|
||||
{
|
||||
category: AirSessionFailureCategory;
|
||||
actions: AirSessionFailureAction[];
|
||||
fallbackTitle: string;
|
||||
}
|
||||
> = {
|
||||
// Account or credential blocks that signing in again cannot fix: the
|
||||
// organization must complete verification, or the cloud provider's
|
||||
// credentials must be refreshed outside Claude. No `login` action, and not
|
||||
// sticky like `auth_required` (nothing clears at `auth_status`).
|
||||
access_denied: {
|
||||
category: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude could not access the account or provider credentials.",
|
||||
},
|
||||
advisory: {
|
||||
category: "unknown",
|
||||
actions: [],
|
||||
fallbackTitle: "Claude reported a notice.",
|
||||
},
|
||||
auth_required: {
|
||||
category: "access",
|
||||
actions: ["login"],
|
||||
fallbackTitle: "Sign in to continue using Claude.",
|
||||
},
|
||||
bad_request: {
|
||||
category: "request",
|
||||
actions: [],
|
||||
fallbackTitle: "Claude could not process this request.",
|
||||
},
|
||||
budget_exhausted: {
|
||||
category: "limit",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "This Claude session reached its configured budget.",
|
||||
},
|
||||
context_exhausted: {
|
||||
category: "limit",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "This Claude turn reached its configured limit.",
|
||||
},
|
||||
internal_error: {
|
||||
category: "service",
|
||||
actions: ["retry", "new_session"],
|
||||
fallbackTitle: "Claude Agent encountered an internal error.",
|
||||
},
|
||||
overloaded: {
|
||||
category: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude is temporarily overloaded.",
|
||||
},
|
||||
provider_error: {
|
||||
category: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "The model provider reported an error.",
|
||||
},
|
||||
quota_exhausted: {
|
||||
category: "limit",
|
||||
actions: [],
|
||||
fallbackTitle: "The Claude account has no available quota.",
|
||||
},
|
||||
rate_limited: {
|
||||
category: "limit",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude is temporarily rate limited.",
|
||||
},
|
||||
transport_lost: {
|
||||
category: "connection",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "The connection to Claude was lost.",
|
||||
},
|
||||
worker_shutdown: {
|
||||
category: "connection",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "The Claude worker is shutting down.",
|
||||
},
|
||||
};
|
||||
|
||||
export function sessionFailureMeta(failure: PublishedSessionFailure) {
|
||||
return withAirMeta(undefined, AIR_SESSION_FAILURE_CAPABILITY, {
|
||||
id: failure.id,
|
||||
revision: failure.revision,
|
||||
category: failure.category,
|
||||
severity: failure.severity,
|
||||
title: failure.title,
|
||||
...(failure.details ? { details: failure.details } : {}),
|
||||
...(failure.reason ? { reason: failure.reason } : {}),
|
||||
actions: failure.actions,
|
||||
});
|
||||
}
|
||||
|
||||
/** `getSessionMessages` deliberately exposes only the API message and strips
|
||||
* transcript-level `error` / `isApiErrorMessage` fields. The SDK exports the
|
||||
* exact stable prefixes used by its synthetic usage-limit errors, so replay
|
||||
* can recover this one typed failure without matching arbitrary model prose. */
|
||||
export function assistantMessageText(apiMessage: unknown): string | undefined {
|
||||
if (!apiMessage || typeof apiMessage !== "object") return undefined;
|
||||
const { content } = apiMessage as { content?: unknown };
|
||||
if (typeof content === "string") return content || undefined;
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const text = content
|
||||
.map((block) =>
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
block.type === "text" &&
|
||||
"text" in block &&
|
||||
typeof block.text === "string"
|
||||
? block.text
|
||||
: "",
|
||||
)
|
||||
.join("");
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
export function isSyntheticUsageLimitMessage(apiMessage: unknown): boolean {
|
||||
if (!apiMessage || typeof apiMessage !== "object") return false;
|
||||
const { model } = apiMessage as { model?: unknown };
|
||||
if (model !== "<synthetic>") return false;
|
||||
const text = assistantMessageText(apiMessage);
|
||||
if (!text) return false;
|
||||
return USAGE_LIMIT_ERROR_PREFIXES.some((prefix) => text.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function activeUsageLimitMessage(
|
||||
messages: Awaited<ReturnType<typeof getSessionMessages>>,
|
||||
): { uuid: string; title: string } | undefined {
|
||||
let active: { uuid: string; title: string } | undefined;
|
||||
for (const message of messages) {
|
||||
if (message.type !== "assistant" || message.parent_tool_use_id !== null) continue;
|
||||
if (isSyntheticUsageLimitMessage(message.message)) {
|
||||
const title = assistantMessageText(message.message);
|
||||
if (title) active = { uuid: message.uuid, title };
|
||||
continue;
|
||||
}
|
||||
const model =
|
||||
message.message && typeof message.message === "object" && "model" in message.message
|
||||
? message.message.model
|
||||
: undefined;
|
||||
// A later real model answer proves the account recovered. Synthetic
|
||||
// local-command/interruption messages do not: they can be appended while
|
||||
// the provider remains unavailable.
|
||||
if (model !== undefined && model !== "<synthetic>") active = undefined;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
export function supportsAirSessionFailures(capabilities?: ClientCapabilities): boolean {
|
||||
return clientSupportsAirCapability(capabilities, AIR_SESSION_FAILURE_CAPABILITY);
|
||||
}
|
||||
|
||||
function sessionFailureRecoveryPolicy(
|
||||
kind: ClaudeFailureKind,
|
||||
turnId?: string,
|
||||
): SessionFailureRecoveryPolicy {
|
||||
switch (kind) {
|
||||
case "advisory":
|
||||
return "never";
|
||||
case "auth_required":
|
||||
return "auth_status";
|
||||
case "transport_lost":
|
||||
case "worker_shutdown":
|
||||
return "runtime_rebind";
|
||||
case "quota_exhausted":
|
||||
return "real_model_success";
|
||||
default:
|
||||
return turnId ? "next_attempt" : "real_model_success";
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns the AIR failure lifecycle for both the live consumer and history replay.
|
||||
* Published revisions become active only after delivery succeeds; recovery only
|
||||
* removes internal active state because transcript records are historical. */
|
||||
export class SessionFailureController {
|
||||
private readonly sessionId: string;
|
||||
private readonly state: SessionFailureState;
|
||||
private readonly capabilities?: ClientCapabilities;
|
||||
private readonly isCurrent: () => boolean;
|
||||
private readonly sendUpdate: (notification: SessionNotification) => Promise<void>;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: {
|
||||
sessionId: string;
|
||||
state: SessionFailureState;
|
||||
capabilities?: ClientCapabilities;
|
||||
isCurrent: () => boolean;
|
||||
sendUpdate: (notification: SessionNotification) => Promise<void>;
|
||||
logger: Logger;
|
||||
}) {
|
||||
this.sessionId = options.sessionId;
|
||||
this.state = options.state;
|
||||
this.capabilities = options.capabilities;
|
||||
this.isCurrent = options.isCurrent;
|
||||
this.sendUpdate = options.sendUpdate;
|
||||
this.logger = options.logger;
|
||||
}
|
||||
|
||||
private isSupported(): boolean {
|
||||
return supportsAirSessionFailures(this.capabilities);
|
||||
}
|
||||
|
||||
private async emit(failure: PublishedSessionFailure): Promise<boolean> {
|
||||
if (!this.isCurrent()) {
|
||||
this.logger.error(
|
||||
`Session ${this.sessionId}: ignored AIR session failure from a stale consumer`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.sendUpdate({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: sessionFailureMeta(failure),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Session ${this.sessionId}: failed to publish AIR session failure: ${error}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a session-scoped error of `kind` is active. A turn-scoped
|
||||
* warning of the same kind (an `api_retry` notice) does not count: it
|
||||
* reports a retry in progress, not the state the error would publish.
|
||||
*
|
||||
* `reason` refines `kind`, so the active record must carry the same one,
|
||||
* and an omitted `reason` matches only a record with no reason. A plain
|
||||
* sign-out and the `--hide-claude-auth` subscription refusal are both
|
||||
* `auth_required`, but they tell the user different things. Neither may
|
||||
* suppress the other. */
|
||||
hasActiveSessionError(kind: ClaudeFailureKind, reason?: string): boolean {
|
||||
for (const failure of this.state.active.values()) {
|
||||
if (
|
||||
failure.kind === kind &&
|
||||
failure.severity === "error" &&
|
||||
failure.turnId === undefined &&
|
||||
failure.reason === reason
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
recordActive(failure: PublishedSessionFailure): void {
|
||||
this.state.revisions.set(failure.id, failure.revision);
|
||||
this.state.active.set(failure.id, failure);
|
||||
this.state.lastNotice =
|
||||
failure.kind === "advisory" ? { title: failure.title, id: failure.id } : undefined;
|
||||
}
|
||||
|
||||
async clear(
|
||||
shouldClear: (failure: PublishedSessionFailure) => boolean = () => true,
|
||||
): Promise<boolean> {
|
||||
if (!this.isSupported()) return true;
|
||||
this.state.lastNotice = undefined;
|
||||
for (const failure of [...this.state.active.values()]) {
|
||||
if (!shouldClear(failure)) continue;
|
||||
this.state.active.delete(failure.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async prepare(
|
||||
kind: ClaudeFailureKind,
|
||||
failureOptions: SessionFailureOptions = {},
|
||||
): Promise<PublishedSessionFailure | undefined> {
|
||||
if (!this.isSupported() || !this.isCurrent()) return undefined;
|
||||
const policy = AIR_FAILURE_POLICY[kind];
|
||||
const title = failureOptions.title || policy.fallbackTitle;
|
||||
if (kind === "advisory") {
|
||||
const id =
|
||||
this.state.lastNotice?.title === title
|
||||
? this.state.lastNotice.id
|
||||
: `${this.sessionId}:notice:${this.state.epoch}:${this.state.nextIncident ?? 1}`;
|
||||
if (this.state.lastNotice?.title !== title) {
|
||||
this.state.nextIncident = (this.state.nextIncident ?? 1) + 1;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
revision: (this.state.revisions.get(id) ?? 0) + 1,
|
||||
kind,
|
||||
category: policy.category,
|
||||
severity: "warning",
|
||||
title,
|
||||
...(failureOptions.details ? { details: failureOptions.details } : {}),
|
||||
actions: policy.actions,
|
||||
recoveryPolicy: sessionFailureRecoveryPolicy(kind),
|
||||
};
|
||||
}
|
||||
const id =
|
||||
failureOptions.turnId && !failureOptions.sessionScoped
|
||||
? `${failureOptions.turnId}:error`
|
||||
: `${this.sessionId}:session-error:${this.state.epoch}:${this.state.nextIncident ?? 1}`;
|
||||
if (!failureOptions.turnId || failureOptions.sessionScoped) {
|
||||
this.state.nextIncident = (this.state.nextIncident ?? 1) + 1;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
revision: (this.state.revisions.get(id) ?? 0) + 1,
|
||||
kind,
|
||||
category: policy.category,
|
||||
severity: failureOptions.severity ?? "error",
|
||||
title,
|
||||
...(failureOptions.details ? { details: failureOptions.details } : {}),
|
||||
...(failureOptions.reason ? { reason: failureOptions.reason } : {}),
|
||||
actions: failureOptions.severity === "warning" ? [] : policy.actions,
|
||||
recoveryPolicy: sessionFailureRecoveryPolicy(
|
||||
kind,
|
||||
failureOptions.turnId && !failureOptions.sessionScoped ? failureOptions.turnId : undefined,
|
||||
),
|
||||
...(failureOptions.turnId && !failureOptions.sessionScoped
|
||||
? { turnId: failureOptions.turnId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async publish(
|
||||
kind: ClaudeFailureKind,
|
||||
failureOptions: SessionFailureOptions = {},
|
||||
): Promise<void> {
|
||||
const failure = await this.prepare(kind, failureOptions);
|
||||
if (!failure) return;
|
||||
if (await this.emit(failure)) this.recordActive(failure);
|
||||
}
|
||||
|
||||
async restore(
|
||||
id: string,
|
||||
kind: ClaudeFailureKind,
|
||||
title: string,
|
||||
active = true,
|
||||
): Promise<boolean> {
|
||||
if (!this.isSupported() || !this.isCurrent()) return false;
|
||||
const policy = AIR_FAILURE_POLICY[kind];
|
||||
const failure: PublishedSessionFailure = {
|
||||
id,
|
||||
revision: (this.state.revisions.get(id) ?? 0) + 1,
|
||||
kind,
|
||||
category: policy.category,
|
||||
severity: "error",
|
||||
title,
|
||||
actions: policy.actions,
|
||||
recoveryPolicy: sessionFailureRecoveryPolicy(kind),
|
||||
};
|
||||
if (!(await this.emit(failure))) return false;
|
||||
this.state.revisions.set(failure.id, failure.revision);
|
||||
if (active) {
|
||||
this.state.active.set(failure.id, failure);
|
||||
} else {
|
||||
this.state.active.delete(failure.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function providerFailureCategory(
|
||||
errorKind?: SDKAssistantMessageError,
|
||||
isUsageLimit = false,
|
||||
): ClaudeFailureKind {
|
||||
if (isUsageLimit) return "quota_exhausted";
|
||||
switch (errorKind) {
|
||||
case "authentication_failed":
|
||||
case "oauth_org_not_allowed":
|
||||
return "auth_required";
|
||||
case "billing_error":
|
||||
case "account_on_hold":
|
||||
return "quota_exhausted";
|
||||
// 403 `permission_error` with error_code `verification_required` (the
|
||||
// organization must verify at the console), and a Bedrock/Vertex/Foundry
|
||||
// credential the CLI could not load (transient: it retries, and asks the
|
||||
// user to check or refresh the credentials). Both are access failures
|
||||
// `/login` does not repair, so they get their own lane rather than
|
||||
// `auth_required`'s login action and sticky auth_status recovery.
|
||||
case "verification_required":
|
||||
case "cloud_credential_error":
|
||||
return "access_denied";
|
||||
case "rate_limit":
|
||||
return "rate_limited";
|
||||
case "overloaded":
|
||||
return "overloaded";
|
||||
case "invalid_request":
|
||||
case "model_not_found":
|
||||
return "bad_request";
|
||||
case "max_output_tokens":
|
||||
return "context_exhausted";
|
||||
case "server_error":
|
||||
case "unknown":
|
||||
case undefined:
|
||||
return "provider_error";
|
||||
default:
|
||||
return "provider_error";
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* The `activity` and `cost` of a session list row.
|
||||
*
|
||||
* A session that this connection runs reports the SDK's own state, and
|
||||
* `error` when it is idle and its last turn failed with an error (not a
|
||||
* cancel). Another session reports what the CLI registry and the transcript
|
||||
* tell:
|
||||
*
|
||||
* - no live process holds it: `idle`, or `error` when its last turn ended
|
||||
* with an API error;
|
||||
* - an interactive CLI that is not driven by an SDK holds it, and its registry
|
||||
* status is newer than the transcript: the registry status (an idle one is
|
||||
* `error` after such a turn);
|
||||
* - otherwise the transcript tail: a finished turn is `idle` (`error` after
|
||||
* an API error), an unfinished turn written in the last 10 minutes is
|
||||
* `running`, and anything else has no known state.
|
||||
*
|
||||
* A user interrupt is no error. A new turn clears it.
|
||||
*
|
||||
* The SDK-driven CLIs (`entrypoint: sdk-*`, which includes the ones this
|
||||
* adapter starts) keep `busy` in the registry long after a turn ends, so their
|
||||
* registry status is not used.
|
||||
*/
|
||||
|
||||
import type { LiveRecord } from "./live-registry.js";
|
||||
import type { TranscriptFacts } from "./transcript-scan.js";
|
||||
|
||||
export type ActivityState = "running" | "idle" | "requires_action" | "error";
|
||||
|
||||
export type SessionActivity = {
|
||||
state?: ActivityState;
|
||||
lastTurnEndedAt?: string;
|
||||
};
|
||||
|
||||
/** What this connection knows about a session it runs. */
|
||||
export type OwnSessionState = {
|
||||
state?: ActivityState;
|
||||
/** Epoch ms of the end of the last turn. */
|
||||
lastTurnEndedAt?: number;
|
||||
/** `total_cost_usd` of the last result. */
|
||||
costUsd?: number;
|
||||
/** The last turn failed with an error (not a cancel). */
|
||||
lastTurnFailed?: boolean;
|
||||
/** The query of the session failed here at this time (epoch ms) and no
|
||||
* longer runs: the session is reported as any other one, `error` until
|
||||
* its transcript shows a prompt or a turn end after it. */
|
||||
queryFailedAt?: number;
|
||||
};
|
||||
|
||||
const RECENT_UNFINISHED_TURN_MS = 10 * 60 * 1000;
|
||||
|
||||
function registryState(status: string | undefined): ActivityState | undefined {
|
||||
switch (status) {
|
||||
case "busy":
|
||||
case "shell":
|
||||
return "running";
|
||||
case "waiting":
|
||||
return "requires_action";
|
||||
case "idle":
|
||||
return "idle";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function usesRegistryStatus(record: LiveRecord, transcriptMtimeMs: number): boolean {
|
||||
return (
|
||||
record.kind === "interactive" &&
|
||||
!(record.entrypoint ?? "").startsWith("sdk-") &&
|
||||
record.statusUpdatedAt !== undefined &&
|
||||
record.statusUpdatedAt > transcriptMtimeMs
|
||||
);
|
||||
}
|
||||
|
||||
function iso(ms: number | undefined): string | undefined {
|
||||
return ms === undefined || !Number.isFinite(ms) ? undefined : new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
export function deriveActivity(input: {
|
||||
own?: OwnSessionState;
|
||||
live?: LiveRecord;
|
||||
facts: TranscriptFacts;
|
||||
transcriptMtimeMs: number;
|
||||
now: number;
|
||||
}): SessionActivity | undefined {
|
||||
const { live, facts, transcriptMtimeMs, now } = input;
|
||||
const failedAt = input.own?.queryFailedAt;
|
||||
const own = failedAt === undefined ? input.own : undefined;
|
||||
let state: ActivityState | undefined;
|
||||
// A finished turn that ended with an API error, or this connection's query
|
||||
// of the session failed and nothing was written since.
|
||||
const failed =
|
||||
(facts.turnState === "finished" && facts.lastTurnError === true) ||
|
||||
(failedAt !== undefined &&
|
||||
!((facts.lastPromptAt ?? 0) > failedAt) &&
|
||||
!((facts.lastTurnEndedAt ?? 0) > failedAt));
|
||||
if (own) {
|
||||
state = own.state ?? "idle";
|
||||
if (state === "idle" && own.lastTurnFailed) state = "error";
|
||||
} else if (!live) {
|
||||
state = failed ? "error" : "idle";
|
||||
} else if (usesRegistryStatus(live, transcriptMtimeMs)) {
|
||||
// A busy or waiting CLI wins; an idle one shows how its turn ended.
|
||||
state = registryState(live.status);
|
||||
if (state === "idle" && failed) state = "error";
|
||||
} else if (failed) {
|
||||
state = "error";
|
||||
} else if (facts.turnState === "finished") {
|
||||
state = "idle";
|
||||
} else if (
|
||||
facts.turnState === "unfinished" &&
|
||||
now - transcriptMtimeMs < RECENT_UNFINISHED_TURN_MS
|
||||
) {
|
||||
state = "running";
|
||||
}
|
||||
const lastTurnEndedAt = iso(own?.lastTurnEndedAt ?? facts.lastTurnEndedAt);
|
||||
if (state === undefined && lastTurnEndedAt === undefined) return undefined;
|
||||
return {
|
||||
...(state !== undefined && { state }),
|
||||
...(lastTurnEndedAt !== undefined && { lastTurnEndedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The cost of a session this connection runs: the `total_cost_usd` the SDK
|
||||
* gave in its last result. Only a positive amount; none for any other
|
||||
* session. */
|
||||
export function selectCost(own: OwnSessionState | undefined): number | undefined {
|
||||
const amount = own?.costUsd;
|
||||
return amount !== undefined && Number.isFinite(amount) && amount > 0 ? amount : undefined;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* The session archive, in the on-disk format of JetBrains AIR's own Claude
|
||||
* integration: a session is archived when its title carries the
|
||||
* `[archived] ` prefix, and archive, unarchive and rename append the title as
|
||||
* a pair of records to the transcript:
|
||||
*
|
||||
* ```
|
||||
* {"type":"custom-title","customTitle":"[archived] Title","sessionId":"…"}
|
||||
* {"type":"agent-name","agentName":"[archived] Title","sessionId":"…"}
|
||||
* ```
|
||||
*
|
||||
* The archive state follows AIR's native Claude provider (see
|
||||
* {@link effectiveTitle}): the last agent name decides, else the custom title
|
||||
* the SDK reports (`SDKSessionInfo.customTitle`). Archive writes the prefix
|
||||
* and the current title without it; unarchive writes the title without the
|
||||
* prefix. Both are cut to the CLI's 200-character title limit. The records
|
||||
* are metadata: they move neither `updatedAt` nor the list order. The
|
||||
* `claude --resume` picker shows the prefix.
|
||||
*/
|
||||
|
||||
/** The title prefix of an archived session. */
|
||||
export const ARCHIVED_TITLE_PREFIX = "[archived] ";
|
||||
|
||||
/** What the CLI keeps of a stored title: `title.slice(0, 200).trim()`. */
|
||||
const CLI_TITLE_LIMIT = 200;
|
||||
|
||||
/** A title once normalized starts with the prefix: leading space (what a
|
||||
* trim removes), the marker, a run of white space, then a title character.
|
||||
* The white space AIR collapses is ASCII white space, as in Java's `\s`. */
|
||||
const ARCHIVED_PATTERN = /^\s*\[archived\][ \t\n\v\f\r]+(?=[^ \t\n\v\f\r])/;
|
||||
|
||||
/** Every leading archive prefix at once. */
|
||||
const ARCHIVED_PREFIXES = /^(?:\s*\[archived\][ \t\n\v\f\r]+(?=[^ \t\n\v\f\r]))+/;
|
||||
|
||||
const WHITESPACE_RUN = /[ \t\n\v\f\r]+/g;
|
||||
|
||||
/** Whitespace collapsed to single spaces and trimmed, as AIR stores a title. */
|
||||
export function normalizeStoredTitle(title: string): string {
|
||||
return title.replace(WHITESPACE_RUN, " ").trim();
|
||||
}
|
||||
|
||||
/** Cut to what the CLI keeps of a title. */
|
||||
function capTitle(title: string): string {
|
||||
return title.length <= CLI_TITLE_LIMIT ? title : title.slice(0, CLI_TITLE_LIMIT).trim();
|
||||
}
|
||||
|
||||
/** Whether a session name (see {@link effectiveTitle}) marks its session
|
||||
* archived. */
|
||||
export function isArchivedTitle(title: string | undefined): boolean {
|
||||
return title !== undefined && ARCHIVED_PATTERN.test(title);
|
||||
}
|
||||
|
||||
/** The title shown for a stored title: without the archive prefix,
|
||||
* removed once, as AIR shows it. */
|
||||
export function visibleTitle(title: string): string {
|
||||
return title.replace(ARCHIVED_PATTERN, "");
|
||||
}
|
||||
|
||||
/** The title of a session that has none at all, as AIR names it. */
|
||||
export function defaultSessionTitle(sessionId: string): string {
|
||||
return `Session ${sessionId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** The title that `title` is stored as in the given archive state: the
|
||||
* visible title, with the prefix when archived, normalized and cut to the
|
||||
* CLI limit. */
|
||||
export function storedTitle(title: string, archived: boolean, sessionId: string): string {
|
||||
// Every prefix: a title stored twice prefixed must not stay archived.
|
||||
const visible =
|
||||
normalizeStoredTitle(title.replace(ARCHIVED_PREFIXES, "")) || defaultSessionTitle(sessionId);
|
||||
return capTitle(archived ? ARCHIVED_TITLE_PREFIX + visible : visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* The title of a session and its archive state, by the rule of AIR's native
|
||||
* Claude provider: the session's name is its last agent name, else its custom
|
||||
* title, and the session is archived exactly when that name starts with the
|
||||
* prefix. The title is that agent name, else `summary`, the title the SDK
|
||||
* gives the session; it is stored, so it may carry the prefix.
|
||||
*/
|
||||
export function effectiveTitle(
|
||||
agentName: string | undefined,
|
||||
{ customTitle, summary }: { customTitle?: string; summary?: string },
|
||||
): { title: string | undefined; archived: boolean } {
|
||||
return { title: agentName ?? summary, archived: isArchivedTitle(agentName ?? customTitle) };
|
||||
}
|
||||
|
||||
/** The `custom-title` and `agent-name` records of `title`, one per line. */
|
||||
export function titleRecords(sessionId: string, title: string): string {
|
||||
return (
|
||||
JSON.stringify({ type: "custom-title", customTitle: title, sessionId }) +
|
||||
"\n" +
|
||||
JSON.stringify({ type: "agent-name", agentName: title, sessionId }) +
|
||||
"\n"
|
||||
);
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
/**
|
||||
* The session index of one ACP connection: what `ClaudeAcpAgent` does for a
|
||||
* client that declared `sessionIndex`, and the archive of an AIR client
|
||||
* without it. The agent keeps thin call sites; everything else
|
||||
* lives here.
|
||||
*
|
||||
* Wire contract: docs/air-extensions.md, "Session index".
|
||||
*/
|
||||
|
||||
import {
|
||||
RequestError,
|
||||
type AgentApp,
|
||||
type DeleteSessionRequest,
|
||||
type DeleteSessionResponse,
|
||||
type InitializeRequest,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, Logger, Session } from "../acp-agent.js";
|
||||
import {
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
clientSupportsAirCapability,
|
||||
withAirMeta,
|
||||
} from "../air-extension.js";
|
||||
import type { OwnSessionState } from "./activity.js";
|
||||
import { isArchivedTitle, storedTitle, visibleTitle } from "./archive-title.js";
|
||||
import {
|
||||
LIST_CHANGES_METHOD,
|
||||
LIST_SUBSCRIBE_METHOD,
|
||||
LIST_UNSUBSCRIBE_METHOD,
|
||||
parseListSubscribeRequest,
|
||||
parseListUnsubscribeRequest,
|
||||
type ListSubscribeRequest,
|
||||
type ListSubscribeResponse,
|
||||
type ListUnsubscribeRequest,
|
||||
} from "./list-subscriptions.js";
|
||||
import {
|
||||
archiveInsteadOfDelete,
|
||||
archiveTo,
|
||||
renameTo,
|
||||
parseRenameSessionRequest,
|
||||
parseSessionIdRequest,
|
||||
SESSION_ARCHIVE_METHOD,
|
||||
SESSION_RENAME_METHOD,
|
||||
SESSION_UNARCHIVE_METHOD,
|
||||
SessionIndexService,
|
||||
type RenameSessionRequest,
|
||||
type RetitleOptions,
|
||||
type SessionIdRequest,
|
||||
type TitleChange,
|
||||
} from "./service.js";
|
||||
|
||||
/** What the session index keeps on each `Session`. */
|
||||
export type SessionIndexFields = {
|
||||
/** When the last turn ended (the last `session_state_changed: idle`), epoch
|
||||
* ms. Reported as `lastTurnEndedAt` in the session index. */
|
||||
lastTurnEndedAt?: number;
|
||||
/** The last turn failed with an error, not a cancel; a new turn clears
|
||||
* it. Reported as the `error` state in the session index. */
|
||||
lastTurnFailed?: boolean;
|
||||
/** `total_cost_usd` of the last result, reported as the cost of the session
|
||||
* in the session index. */
|
||||
lastTotalCostUsd?: number;
|
||||
/** The query resumed a stored conversation (load, resume, fork): the
|
||||
* session has a transcript, unlike a new one before its first turn. */
|
||||
resumedFromHistory?: boolean;
|
||||
};
|
||||
|
||||
/** Records the end of a turn at `session_state_changed`. */
|
||||
export function noteSessionState(
|
||||
session: Session,
|
||||
previous: Session["lastSessionState"],
|
||||
state: Session["lastSessionState"],
|
||||
): void {
|
||||
if (state === "idle" && previous !== "idle") session.lastTurnEndedAt = Date.now();
|
||||
if (state === "running" && previous !== "running") session.lastTurnFailed = false;
|
||||
}
|
||||
|
||||
/** What the connection needs from the agent. */
|
||||
export type SessionIndexHost = {
|
||||
/** Read when used: the agent may still be under construction. */
|
||||
agent: {
|
||||
readonly sessions: { [key: string]: Session };
|
||||
readonly client: AcpClient;
|
||||
readonly logger: Logger;
|
||||
};
|
||||
/** Whether the client is an AIR client. */
|
||||
isAirClient(): boolean;
|
||||
/** Cancels the running turns of a session loaded here and sends its CLI
|
||||
* the interrupt, without awaiting the reply. */
|
||||
interruptSession(sessionId: string): Promise<void>;
|
||||
/** Closes a session loaded here, as `session/close` does. */
|
||||
teardownSession(sessionId: string): Promise<void>;
|
||||
};
|
||||
|
||||
type EmptyResponse = Record<string, never>;
|
||||
|
||||
export class SessionIndexConnection {
|
||||
/**
|
||||
* The session index of a client that declared `sessionIndex`. Undefined for
|
||||
* every other client, which keeps the session list, delete and the watchers
|
||||
* exactly as before.
|
||||
*/
|
||||
service?: SessionIndexService;
|
||||
/** The archive behind `session/delete` of an AIR client without
|
||||
* `sessionIndex`: the same writes, without a list or a watcher. */
|
||||
private archiver?: SessionIndexService;
|
||||
/** When the query of a session failed here, by session id. */
|
||||
private readonly failedQueries = new Map<string, number>();
|
||||
|
||||
constructor(private readonly host: SessionIndexHost) {}
|
||||
|
||||
/** Sets the index up for the client of `initialize`. ACP v2 does not route
|
||||
* the session index methods yet. */
|
||||
negotiate(request: InitializeRequest, options: { v2: boolean }): void {
|
||||
this.service?.dispose();
|
||||
this.service = undefined;
|
||||
if (
|
||||
!options.v2 &&
|
||||
this.host.isAirClient() &&
|
||||
clientSupportsAirCapability(request.clientCapabilities, AIR_SESSION_INDEX_CAPABILITY)
|
||||
) {
|
||||
this.service = new SessionIndexService({
|
||||
ownSessionState: (sessionId) => this.ownSessionState(sessionId),
|
||||
notifyListChanges: (changes) =>
|
||||
this.host.agent.client.extNotification(LIST_CHANGES_METHOD, changes),
|
||||
logError: (message, error) =>
|
||||
this.host.agent.logger.error(`[session-index] ${message}:`, error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A stored title as the client shows it: an AIR client gets the title
|
||||
* of an archived session without the archive prefix, as in its list;
|
||||
* every other client the title as stored. */
|
||||
clientTitle(title: string): string {
|
||||
return this.host.isAirClient() && isArchivedTitle(title) ? visibleTitle(title) : title;
|
||||
}
|
||||
|
||||
/** The AIR capabilities the agent advertises for the index: the index
|
||||
* itself, archive, rename and the list subscription, all only to a client
|
||||
* that declared `sessionIndex`. */
|
||||
capabilities(): string[] {
|
||||
return this.service
|
||||
? [
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.service?.dispose();
|
||||
}
|
||||
|
||||
/** The query of a session that ran here failed (not a cancel): its row
|
||||
* shows `error` until its transcript changes. */
|
||||
onQueryFailed(sessionId: string): void {
|
||||
this.failedQueries.set(sessionId, Date.now());
|
||||
if (this.failedQueries.size > 1024) {
|
||||
this.failedQueries.delete(this.failedQueries.keys().next().value as string);
|
||||
}
|
||||
this.service?.ownSessionChanged(sessionId);
|
||||
}
|
||||
|
||||
/** A turn of a session started here: an earlier failed query of it no
|
||||
* longer counts. */
|
||||
onTurnStarted(sessionId: string): void {
|
||||
this.failedQueries.delete(sessionId);
|
||||
}
|
||||
|
||||
onTeardown(sessionId: string): void {
|
||||
// Its row now shows what the registry and the transcript tell.
|
||||
this.service?.ownSessionChanged(sessionId);
|
||||
}
|
||||
|
||||
/** A session this connection runs changed its state, turn end or cost:
|
||||
* the list subscriptions send it at once. */
|
||||
onOwnSessionChanged(sessionId: string): void {
|
||||
this.service?.ownSessionChanged(sessionId);
|
||||
}
|
||||
|
||||
/** `_session/list/subscribe`. */
|
||||
subscribeList(params: ListSubscribeRequest): Promise<ListSubscribeResponse> {
|
||||
return this.requireService(LIST_SUBSCRIBE_METHOD).subscribeList(params.cwd);
|
||||
}
|
||||
|
||||
/** `_session/list/unsubscribe`. Idempotent. */
|
||||
unsubscribeList(params: ListUnsubscribeRequest): EmptyResponse {
|
||||
this.requireService(LIST_UNSUBSCRIBE_METHOD).unsubscribeList(params.subscriptionId);
|
||||
return {};
|
||||
}
|
||||
|
||||
/** `session/list` of a `sessionIndex` client; undefined for another one. */
|
||||
list(params: ListSessionsRequest): Promise<ListSessionsResponse> | undefined {
|
||||
return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId));
|
||||
}
|
||||
|
||||
/** An AIR client archives with session/delete (see {@link deleteSession}):
|
||||
* its archived sessions (an archived custom title) stay hidden from the
|
||||
* old list, as when the delete removed them. */
|
||||
hideArchived<T extends { customTitle?: string }>(sessions: T[]): T[] {
|
||||
if (!this.host.isAirClient()) return sessions;
|
||||
return sessions.filter((session) => !isArchivedTitle(session.customTitle));
|
||||
}
|
||||
|
||||
/** `_session/rename`: names a session; no generated title replaces it.
|
||||
* An archived session stays archived. */
|
||||
async rename(request: RenameSessionRequest): Promise<EmptyResponse> {
|
||||
const index = this.requireService(SESSION_RENAME_METHOD);
|
||||
const { sessionId } = request;
|
||||
// As the CLI keeps it, and never with the archive prefix: a rename does
|
||||
// not change the archive state.
|
||||
const title = storedTitle(request.title, false, sessionId);
|
||||
// The title as stored: an archived one is cut with its prefix.
|
||||
const shown = (stored: string | undefined) =>
|
||||
stored === undefined ? title : visibleTitle(stored);
|
||||
await this.retitle(index, sessionId, renameTo(title), shown, {
|
||||
mayBeUnwritten: true,
|
||||
sidecar: "always",
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
/** `_session/archive`: hides a session from the default list and stops
|
||||
* it. Idempotent; the session need not be loaded. A session loaded here
|
||||
* is interrupted, retitled through its CLI while that still runs, then
|
||||
* closed as `session/close` closes it. */
|
||||
async archive(params: SessionIdRequest): Promise<EmptyResponse> {
|
||||
const index = this.requireService(SESSION_ARCHIVE_METHOD);
|
||||
const { sessionId } = params;
|
||||
const loaded = this.host.agent.sessions[sessionId] !== undefined;
|
||||
// No turn goes on after the archive: the interrupt goes to the CLI ahead
|
||||
// of the rename.
|
||||
if (loaded) await this.host.interruptSession(sessionId);
|
||||
await this.retitle(index, sessionId, archiveTo(true), undefined, {
|
||||
mayBeUnwritten: this.isUnwrittenSession(sessionId),
|
||||
sidecar: "existing",
|
||||
});
|
||||
if (loaded) {
|
||||
await this.host.teardownSession(sessionId);
|
||||
// The closed CLI may have lost or overwritten the agent name it was
|
||||
// still to write: the transcripts are archived without it once more,
|
||||
// as a best effort, since the CLI took the archived title.
|
||||
try {
|
||||
await index.retitle(sessionId, archiveTo(true), {
|
||||
mayBeUnwritten: true,
|
||||
sidecar: "existing",
|
||||
});
|
||||
} catch (error) {
|
||||
this.host.agent.logger.error(
|
||||
`[session-index] archiving ${sessionId} after its close failed:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.reportArchived(sessionId, true, loaded);
|
||||
return {};
|
||||
}
|
||||
|
||||
/** `_session/unarchive`. Idempotent; it does not load the session. */
|
||||
async unarchive(params: SessionIdRequest): Promise<EmptyResponse> {
|
||||
const index = this.requireService(SESSION_UNARCHIVE_METHOD);
|
||||
const { sessionId } = params;
|
||||
await this.retitle(index, sessionId, archiveTo(false), undefined, {
|
||||
mayBeUnwritten: this.isUnwrittenSession(sessionId),
|
||||
sidecar: "existing",
|
||||
});
|
||||
await this.reportArchived(sessionId, false, this.host.agent.sessions[sessionId] !== undefined);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retitles a session (see {@link SessionIndexService.retitle}). A session
|
||||
* this connection runs is titled through its CLI (`rename_session`). A
|
||||
* loaded session's title state takes the change as a client title, so no
|
||||
* generated title replaces it; `publish` is the title it then publishes.
|
||||
*/
|
||||
private async retitle(
|
||||
index: SessionIndexService,
|
||||
sessionId: string,
|
||||
change: TitleChange,
|
||||
publish: Parameters<Session["titles"]["setExplicitTitle"]>[0],
|
||||
options: Pick<RetitleOptions, "mayBeUnwritten" | "sidecar">,
|
||||
): Promise<void> {
|
||||
const session = this.host.agent.sessions[sessionId];
|
||||
// Decided once a title generation in flight has ended: the query may
|
||||
// have closed meanwhile.
|
||||
const persist = () => {
|
||||
const query = session?.query as
|
||||
| (Query & { renameSession?: (title: string, sessionId?: string) => Promise<void> })
|
||||
| undefined;
|
||||
// A closed session's CLI is gone here: only a running query titles it.
|
||||
const running = session !== undefined && !session.queryClosed;
|
||||
if (running && typeof query?.renameSession !== "function") {
|
||||
// Its CLI holds the title and would write it back over ours.
|
||||
throw RequestError.invalidRequest(
|
||||
{ sessionId },
|
||||
"The Claude Code CLI of this session cannot change its title while it runs.",
|
||||
);
|
||||
}
|
||||
const live = running
|
||||
? {
|
||||
cwd: session.cwd,
|
||||
rename: (title: string) => query!.renameSession!(title, sessionId),
|
||||
stored: () => session.titles.storedTitle,
|
||||
remember: (title: string) => session.titles.rememberStoredTitle(title),
|
||||
shown: session.titles.shownTitle,
|
||||
}
|
||||
: undefined;
|
||||
return index.retitle(sessionId, change, {
|
||||
...options,
|
||||
// Without its CLI, a session needs its transcript.
|
||||
mayBeUnwritten: live !== undefined && options.mayBeUnwritten,
|
||||
live,
|
||||
});
|
||||
};
|
||||
if (session) {
|
||||
await session.titles.setExplicitTitle(publish, persist);
|
||||
} else {
|
||||
await persist();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/delete`:
|
||||
* - A `sessionIndex` client deletes for real: every transcript, whoever
|
||||
* else has the session open, as the CLI does.
|
||||
* - Another AIR client uses delete to mark a session done, and may reopen
|
||||
* it later: the adapter archives it instead (as `_session/archive` does,
|
||||
* after the CLI closed), so the transcript survives.
|
||||
* - Every other client: the SDK delete, as before.
|
||||
*/
|
||||
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
||||
const loaded = this.host.agent.sessions[params.sessionId] !== undefined;
|
||||
// Tear down any active in-memory state first so the on-disk file isn't
|
||||
// recreated by an outstanding query writing to it.
|
||||
if (loaded) {
|
||||
await this.host.teardownSession(params.sessionId);
|
||||
}
|
||||
if (this.service) {
|
||||
await this.service.delete(params.sessionId, loaded);
|
||||
} else if (this.host.isAirClient()) {
|
||||
await archiveInsteadOfDelete(params.sessionId, this.airArchiver());
|
||||
} else {
|
||||
await sdkDeleteSession(params.sessionId);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private airArchiver(): SessionIndexService {
|
||||
this.archiver ??= new SessionIndexService({
|
||||
logError: (message, error) =>
|
||||
this.host.agent.logger.error(`[session-index] ${message}:`, error),
|
||||
});
|
||||
return this.archiver;
|
||||
}
|
||||
|
||||
private requireService(method: string): SessionIndexService {
|
||||
if (!this.service) throw RequestError.methodNotFound(method);
|
||||
return this.service;
|
||||
}
|
||||
|
||||
/** What the session index reports for a session this connection runs. */
|
||||
private ownSessionState(sessionId: string): OwnSessionState | undefined {
|
||||
const session = this.host.agent.sessions[sessionId];
|
||||
if (!session || session.queryClosed) {
|
||||
const failedAt = this.failedQueries.get(sessionId);
|
||||
return failedAt === undefined ? undefined : { queryFailedAt: failedAt };
|
||||
}
|
||||
return {
|
||||
state: session.lastSessionState,
|
||||
lastTurnEndedAt: session.lastTurnEndedAt,
|
||||
// A session loaded again after its query failed here shows the failure
|
||||
// until a turn of it starts.
|
||||
lastTurnFailed: session.lastTurnFailed || this.failedQueries.has(sessionId),
|
||||
costUsd: session.lastTotalCostUsd,
|
||||
};
|
||||
}
|
||||
|
||||
/** A new session this connection runs that may have no transcript yet: it
|
||||
* resumed no stored conversation and no turn of it has ended. A session
|
||||
* whose query ended, or that has history, needs its transcript. */
|
||||
private isUnwrittenSession(sessionId: string): boolean {
|
||||
const session = this.host.agent.sessions[sessionId];
|
||||
return (
|
||||
session !== undefined &&
|
||||
!session.queryClosed &&
|
||||
!session.resumedFromHistory &&
|
||||
session.lastTurnEndedAt === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Tells the client the archive state of a session that was loaded on
|
||||
* this connection (`session_info_update` with
|
||||
* `_meta.jetbrains.air.archived`, RFD #2161's `archived` field), also
|
||||
* once the archive closed it. */
|
||||
private async reportArchived(
|
||||
sessionId: string,
|
||||
archived: boolean,
|
||||
loaded: boolean,
|
||||
): Promise<void> {
|
||||
if (!loaded) return;
|
||||
await this.host.agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: withAirMeta(undefined, "archived", archived),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The agent methods behind the session index extension methods. */
|
||||
type SessionIndexMethods = {
|
||||
subscribeSessionList(params: ListSubscribeRequest): Promise<ListSubscribeResponse>;
|
||||
unsubscribeSessionList(params: ListUnsubscribeRequest): Promise<EmptyResponse>;
|
||||
renameSessionTitle(params: RenameSessionRequest): Promise<EmptyResponse>;
|
||||
archiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
|
||||
unarchiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
|
||||
};
|
||||
|
||||
/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`,
|
||||
* `_session/rename`, `_session/archive` and `_session/unarchive`. */
|
||||
export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp {
|
||||
return app
|
||||
.onRequest<ListSubscribeRequest, ListSubscribeResponse>(
|
||||
LIST_SUBSCRIBE_METHOD,
|
||||
{ parse: parseListSubscribeRequest },
|
||||
(ctx) => agent().subscribeSessionList(ctx.params),
|
||||
)
|
||||
.onRequest<ListUnsubscribeRequest, EmptyResponse>(
|
||||
LIST_UNSUBSCRIBE_METHOD,
|
||||
{ parse: parseListUnsubscribeRequest },
|
||||
(ctx) => agent().unsubscribeSessionList(ctx.params),
|
||||
)
|
||||
.onRequest<RenameSessionRequest, EmptyResponse>(
|
||||
SESSION_RENAME_METHOD,
|
||||
{ parse: parseRenameSessionRequest },
|
||||
(ctx) => agent().renameSessionTitle(ctx.params),
|
||||
)
|
||||
.onRequest<SessionIdRequest, EmptyResponse>(
|
||||
SESSION_ARCHIVE_METHOD,
|
||||
{ parse: parseSessionIdRequest },
|
||||
(ctx) => agent().archiveSession(ctx.params),
|
||||
)
|
||||
.onRequest<SessionIdRequest, EmptyResponse>(
|
||||
SESSION_UNARCHIVE_METHOD,
|
||||
{ parse: parseSessionIdRequest },
|
||||
(ctx) => agent().unarchiveSession(ctx.params),
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* File system reads of the session index that stay cheap with thousands of
|
||||
* transcripts: bounded batches of stats, and directory listings that are
|
||||
* read again only when the directory changed.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
|
||||
/** Stats in flight at once, across all callers of {@link statFiles}. */
|
||||
const STAT_CONCURRENCY = 512;
|
||||
let statsInFlight = 0;
|
||||
/** Queued stats, a FIFO read from `waitingHead` (no `shift()`: the queue
|
||||
* can hold tens of thousands). */
|
||||
let waitingStats: (() => void)[] = [];
|
||||
let waitingHead = 0;
|
||||
|
||||
function nextWaitingStat(): (() => void) | undefined {
|
||||
if (waitingHead >= waitingStats.length) return undefined;
|
||||
const next = waitingStats[waitingHead++];
|
||||
if (waitingHead > 1024 && waitingHead * 2 > waitingStats.length) {
|
||||
waitingStats = waitingStats.slice(waitingHead);
|
||||
waitingHead = 0;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Runs `stat` once fewer than {@link STAT_CONCURRENCY} are in flight. */
|
||||
function statBounded(filePath: string, done: (stats: fs.Stats | undefined) => void): void {
|
||||
const run = () => {
|
||||
statsInFlight++;
|
||||
fs.stat(filePath, (error, stats) => {
|
||||
statsInFlight--;
|
||||
nextWaitingStat()?.();
|
||||
done(error ? undefined : stats);
|
||||
});
|
||||
};
|
||||
if (statsInFlight < STAT_CONCURRENCY) run();
|
||||
else waitingStats.push(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stats of `filePaths` (undefined for one that is gone). A project
|
||||
* directory holds thousands of transcripts: the callback API costs about
|
||||
* half of one promise per file, and a bounded number in flight, shared by
|
||||
* all callers, keeps each turn of the event loop short (dispatching
|
||||
* thousands at once holds it for 100 ms and more). Unlike synchronous
|
||||
* stats, a file system stall never holds the event loop.
|
||||
*/
|
||||
export function statFiles(filePaths: readonly string[]): Promise<(fs.Stats | undefined)[]> {
|
||||
const result: (fs.Stats | undefined)[] = new Array(filePaths.length);
|
||||
if (filePaths.length === 0) return Promise.resolve(result);
|
||||
return new Promise((resolve) => {
|
||||
let left = filePaths.length;
|
||||
filePaths.forEach((filePath, i) => {
|
||||
statBounded(filePath, (stats) => {
|
||||
result[i] = stats;
|
||||
if (--left === 0) resolve(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type Listing = {
|
||||
mtimeMs: number;
|
||||
/** A directory replaced by another one with the same mtime differs here. */
|
||||
ino: number;
|
||||
/** When the listing was read. */
|
||||
readAt: number;
|
||||
names: string[];
|
||||
};
|
||||
|
||||
/** A listing read this soon after the directory changed is not trusted:
|
||||
* some file systems keep the mtime in whole seconds, so another change in
|
||||
* the same second would leave it unchanged. */
|
||||
const MTIME_RESOLUTION_MS = 2000;
|
||||
|
||||
/**
|
||||
* Directory listings, read again only when the directory's mtime changed
|
||||
* (an entry was added, removed or renamed; an append to a file does not
|
||||
* change it). A project directory with thousands of transcripts is then one
|
||||
* stat instead of one `readdir` per list or mutation.
|
||||
*/
|
||||
export class DirListings {
|
||||
private readonly listings = new Map<string, Listing>();
|
||||
|
||||
/** The entry names of `dir`, empty when it cannot be read. */
|
||||
async names(dir: string): Promise<string[]> {
|
||||
return (await this.listing(dir, await statOrUndefined(dir))).names;
|
||||
}
|
||||
|
||||
/** The entry names of each of `dirs`, one bounded batch of stats. */
|
||||
async namesOfAll(dirs: readonly string[]): Promise<string[][]> {
|
||||
const stats = await statFiles(dirs);
|
||||
return Promise.all(dirs.map(async (dir, i) => (await this.listing(dir, stats[i])).names));
|
||||
}
|
||||
|
||||
private async listing(dir: string, stats: fs.Stats | undefined): Promise<Listing> {
|
||||
if (!stats?.isDirectory()) {
|
||||
this.listings.delete(dir);
|
||||
return { mtimeMs: 0, ino: 0, readAt: 0, names: [] };
|
||||
}
|
||||
const cached = this.listings.get(dir);
|
||||
if (
|
||||
cached &&
|
||||
cached.mtimeMs === stats.mtimeMs &&
|
||||
cached.ino === stats.ino &&
|
||||
cached.readAt - cached.mtimeMs > MTIME_RESOLUTION_MS
|
||||
) {
|
||||
return cached;
|
||||
}
|
||||
const readAt = Date.now();
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.promises.readdir(dir);
|
||||
} catch {
|
||||
return { mtimeMs: 0, ino: 0, readAt, names: [] };
|
||||
}
|
||||
const listing: Listing = { mtimeMs: stats.mtimeMs, ino: stats.ino, readAt, names };
|
||||
this.listings.set(dir, listing);
|
||||
return listing;
|
||||
}
|
||||
}
|
||||
|
||||
function statOrUndefined(target: string): Promise<fs.Stats | undefined> {
|
||||
return fs.promises.stat(target).then(
|
||||
(stats) => stats,
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* The client-set title of a session (`_session/rename`), as `SessionTitles`
|
||||
* guards it against generated titles: once a client named the session, or
|
||||
* while a rename is in flight, no generated title is adopted, and a rename
|
||||
* waits for a generation in flight, whose persisted title would otherwise
|
||||
* land after it.
|
||||
*/
|
||||
export class ExplicitTitle {
|
||||
/** Set once a client named the session. Released by {@link reset}. */
|
||||
private named = false;
|
||||
/** Renames in flight. */
|
||||
private pending = 0;
|
||||
/** Renames started so far, to tell that one ran between two reads. */
|
||||
private started = 0;
|
||||
/** The title generation in flight. */
|
||||
private generation?: Promise<void>;
|
||||
|
||||
/** Whether a generated title must not be adopted. */
|
||||
get active(): boolean {
|
||||
return this.named || this.pending > 0;
|
||||
}
|
||||
|
||||
/** Whether a client named the session and no rename is in flight: a title
|
||||
* stored since by someone else (a `/rename`) may still be adopted. */
|
||||
get settledByClient(): boolean {
|
||||
return this.named && this.pending === 0;
|
||||
}
|
||||
|
||||
/** Changes with every rename that starts. */
|
||||
get renames(): number {
|
||||
return this.started;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.named = false;
|
||||
}
|
||||
|
||||
/** Tracks a title generation until it ends. */
|
||||
track(generation: Promise<void>): void {
|
||||
this.generation = generation;
|
||||
void generation.finally(() => {
|
||||
if (this.generation === generation) this.generation = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** Persists a client title after the generation in flight. `persist`
|
||||
* tells whether the session is named now. When it fails or names nothing
|
||||
* (an archive of an archived session), and no other rename named the
|
||||
* session, `restore` puts the title state back, so a later turn may still
|
||||
* generate a title. */
|
||||
async apply(persist: () => Promise<boolean>, restore: () => void): Promise<void> {
|
||||
this.pending++;
|
||||
this.started++;
|
||||
try {
|
||||
await this.generation;
|
||||
if (await persist()) this.named = true;
|
||||
else if (!this.named && this.pending === 1) restore();
|
||||
} catch (error) {
|
||||
if (!this.named && this.pending === 1) restore();
|
||||
throw error;
|
||||
} finally {
|
||||
this.pending--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* The first prompt of a transcript, extracted the way the SDK does for a
|
||||
* session title (its `rf`, `ti` and `jP` in `@anthropic-ai/claude-agent-sdk`).
|
||||
* Part of the SDK's title rule that the session index applies itself (see
|
||||
* `sdkTitles`).
|
||||
*
|
||||
* - Only main-chain user records count: no tool result, meta or compact
|
||||
* summary record.
|
||||
* - Each text (the string content, or every text block) is expanded from its
|
||||
* `<pasted_content>` blocks and flattened to one line.
|
||||
* - A slash command (`<command-name>/init</command-name>`) is only a fallback
|
||||
* while a real prompt may follow; `<bash-input>x</bash-input>` is `! x`;
|
||||
* a text that starts with another tag or with an interrupt marker is
|
||||
* skipped.
|
||||
* - A prompt longer than 200 characters is cut and gets an ellipsis.
|
||||
* - Without any text, an image or a document prompt is `Image` or `Document`.
|
||||
*/
|
||||
|
||||
const MAX_PROMPT_LENGTH = 200;
|
||||
const COMMAND_NAME = /<command-name>(.*?)<\/command-name>/;
|
||||
const BASH_INPUT = /<bash-input>([\s\S]*?)<\/bash-input>/;
|
||||
const SKIPPED_TEXT = /^(?:\s*<[a-z][\w-]*[\s>]|\[Request interrupted by user[^\]]*\])/;
|
||||
const PASTED_OPEN = '<pasted_content id="';
|
||||
|
||||
type Entry = Record<string, unknown>;
|
||||
|
||||
function isPastedId(value: string): boolean {
|
||||
return /^[0-9a-f]{4}$/.test(value);
|
||||
}
|
||||
|
||||
/** `text` with each `<pasted_content id="xxxx">` block replaced by its body. */
|
||||
function expandPastedContent(text: string): string {
|
||||
const parts: string[] = [];
|
||||
let done = 0;
|
||||
let from = 0;
|
||||
let expanded = false;
|
||||
for (;;) {
|
||||
const open = text.indexOf(PASTED_OPEN, from);
|
||||
if (open === -1) break;
|
||||
const idStart = open + PASTED_OPEN.length;
|
||||
const id = text.slice(idStart, idStart + 4);
|
||||
if (!isPastedId(id) || !text.startsWith('">\n', idStart + 4)) {
|
||||
from = idStart;
|
||||
continue;
|
||||
}
|
||||
const bodyStart = idStart + 4 + 3;
|
||||
const close = `</pasted_content id="${id}">`;
|
||||
const closeAt = text.indexOf(`\n${close}`, bodyStart - 1) + 1;
|
||||
if (closeAt === 0) break;
|
||||
let start = open;
|
||||
for (let i = 0; i < 2 && start > done && text[start - 1] === "\n"; i++) start--;
|
||||
if (start > done) parts.push(text.slice(done, start));
|
||||
done = closeAt + close.length;
|
||||
for (let i = 0; i < 2 && text[done] === "\n"; i++) done++;
|
||||
parts.push(text.slice(bodyStart, closeAt - 1));
|
||||
expanded = true;
|
||||
from = done;
|
||||
}
|
||||
if (!expanded) return text;
|
||||
if (done < text.length) parts.push(text.slice(done));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/** At most `length` UTF-16 units, without a split surrogate pair. */
|
||||
function cut(text: string, length: number): string {
|
||||
if (text.length <= length) return text;
|
||||
const head = text.slice(0, length);
|
||||
const last = head.charCodeAt(length - 1);
|
||||
return last >= 0xd800 && last <= 0xdbff ? head.slice(0, -1) : head;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt of one user record, or undefined when it has none. A slash
|
||||
* command is recorded in `state.commandFallback` instead.
|
||||
*/
|
||||
export function promptOf(entry: Entry, state: { commandFallback: string }): string | undefined {
|
||||
if (entry.type !== "user") return undefined;
|
||||
if (entry.isMeta === true || entry.isCompactSummary === true) return undefined;
|
||||
const message = entry.message as { content?: unknown } | undefined;
|
||||
if (!message) return undefined;
|
||||
const content = message.content;
|
||||
const texts: string[] = [];
|
||||
if (typeof content === "string") {
|
||||
texts.push(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const { type, text } = block as { type?: unknown; text?: unknown };
|
||||
if (type === "tool_result") return undefined;
|
||||
if (type === "text" && typeof text === "string") texts.push(text);
|
||||
}
|
||||
}
|
||||
for (const raw of texts) {
|
||||
let text = expandPastedContent(raw).replaceAll("\n", " ").trim();
|
||||
if (!text) continue;
|
||||
const command = COMMAND_NAME.exec(text);
|
||||
if (command) {
|
||||
if (!state.commandFallback) state.commandFallback = command[1]!;
|
||||
continue;
|
||||
}
|
||||
const bash = BASH_INPUT.exec(text);
|
||||
if (bash) return `! ${bash[1]!.trim()}`;
|
||||
if (SKIPPED_TEXT.test(text)) continue;
|
||||
if (text.length > MAX_PROMPT_LENGTH) text = `${cut(text, MAX_PROMPT_LENGTH).trim()}…`;
|
||||
return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a user record is a prompt the user sent: one with prompt text
|
||||
* ({@link promptOf}), or one with an image or a document, which the SDK
|
||||
* titles `Image` or `Document` when it has no prompt text. A tool result, a meta or compact summary
|
||||
* record, an interrupt and a slash command are none.
|
||||
*/
|
||||
export function isUserPrompt(entry: Entry): boolean {
|
||||
if (promptOf(entry, { commandFallback: "" }) !== undefined) return true;
|
||||
if (entry.type !== "user" || entry.isMeta === true || entry.isCompactSummary === true) {
|
||||
return false;
|
||||
}
|
||||
const content = (entry.message as { content?: unknown } | undefined)?.content;
|
||||
if (!Array.isArray(content)) return false;
|
||||
let media = false;
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const { type } = block as { type?: unknown };
|
||||
if (type === "tool_result") return false;
|
||||
if (type === "image" || type === "document") media = true;
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
function isUserLine(line: string): boolean {
|
||||
return line.includes('"type":"user"') || line.includes('"type": "user"');
|
||||
}
|
||||
|
||||
/** The first prompt of the head, else the first slash command, else "". */
|
||||
export function firstPrompt(head: string): string {
|
||||
const state = { commandFallback: "" };
|
||||
for (const line of head.split("\n")) {
|
||||
if (!isUserLine(line)) continue;
|
||||
if (line.includes('"tool_result"')) continue;
|
||||
if (line.includes('"isMeta":true') || line.includes('"isMeta": true')) continue;
|
||||
if (line.includes('"isCompactSummary":true') || line.includes('"isCompactSummary": true')) {
|
||||
continue;
|
||||
}
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const prompt = promptOf(entry as Entry, state);
|
||||
if (prompt !== undefined) return prompt;
|
||||
}
|
||||
return state.commandFallback;
|
||||
}
|
||||
|
||||
/** `Image` or `Document` for a first user record that holds one, else "". */
|
||||
export function mediaPrompt(head: string): string {
|
||||
for (const line of head.split("\n")) {
|
||||
if (!isUserLine(line)) continue;
|
||||
if (line.includes('"tool_result"')) continue;
|
||||
if (line.includes('"isMeta":true') || line.includes('"isMeta": true')) continue;
|
||||
if (line.includes('"type":"image"') || line.includes('"type": "image"')) return "Image";
|
||||
if (line.includes('"type":"document"') || line.includes('"type": "document"')) {
|
||||
return "Document";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,288 +0,0 @@
|
||||
/**
|
||||
* The live Claude Code processes on this machine, from the CLI's registry.
|
||||
*
|
||||
* Each running CLI writes `<config>/sessions/<pid>.json` with its session id,
|
||||
* kind, entrypoint and status. The format is not documented; the reader keeps
|
||||
* to the fields it needs and applies the liveness rules of Claude Desktop:
|
||||
*
|
||||
* - only `<pid>.json` files are read; `.key` files and other names are never
|
||||
* opened, and no file is ever written or deleted;
|
||||
* - a record whose JSON does not parse is read once more, then skipped;
|
||||
* - a record of another pid domain (another machine or pid namespace) is
|
||||
* skipped;
|
||||
* - the process must be alive: `kill(pid, 0)` succeeds or fails with `EPERM`;
|
||||
* - a recorded `procStart` must match the start time of the live process,
|
||||
* so a reused pid does not count;
|
||||
* - a record older than 24 hours counts only when its `procStart` matches.
|
||||
*
|
||||
* A snapshot is cached for {@link SNAPSHOT_TTL_MS}. The records tell only the
|
||||
* `state` of a session list row; no operation is refused for them.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { claudeConfigDir } from "../paths.js";
|
||||
import { errorCode } from "./project-dirs.js";
|
||||
|
||||
const SNAPSHOT_TTL_MS = 5_000;
|
||||
const STALE_RECORD_MS = 24 * 60 * 60 * 1000;
|
||||
const PS_TIMEOUT_MS = 1_000;
|
||||
const RECORD_FILE_PATTERN = /^(\d+)\.json$/;
|
||||
/** How long a process start time read with `ps` serves a reader that asks
|
||||
* for recent ones (see {@link LiveSessionRegistry.readFiles}). */
|
||||
const RECENT_START_MS = 10_000;
|
||||
|
||||
export type LiveRecord = {
|
||||
pid: number;
|
||||
sessionId: string;
|
||||
cwd?: string;
|
||||
kind?: string;
|
||||
entrypoint?: string;
|
||||
status?: string;
|
||||
statusUpdatedAt?: number;
|
||||
};
|
||||
|
||||
export type LiveSnapshot = ReadonlyMap<string, LiveRecord>;
|
||||
|
||||
export type LiveRegistryDeps = {
|
||||
/** The registry directory. Defaults to `<config>/sessions`. */
|
||||
dir?: () => string;
|
||||
now?: () => number;
|
||||
/** Whether the process is alive. Defaults to `kill(pid, 0)`. */
|
||||
isAlive?: (pid: number) => boolean;
|
||||
/** The `ps -o lstart=` start time of each live pid. */
|
||||
processStarts?: (pids: number[]) => Promise<Map<number, string>>;
|
||||
/** This machine's pid domain, as the CLI computes it. */
|
||||
pidDomain?: () => Promise<string>;
|
||||
};
|
||||
|
||||
/** `<config>/sessions`, where each CLI process registers itself. */
|
||||
export function liveRegistryDir(): string {
|
||||
return path.join(claudeConfigDir(), "sessions");
|
||||
}
|
||||
|
||||
function defaultIsAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return errorCode(error) === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
/** The start times of `pids`, from one `ps` call, in the format the CLI
|
||||
* records (`LC_ALL=C TZ=UTC ps -o lstart=`). */
|
||||
function defaultProcessStarts(pids: number[]): Promise<Map<number, string>> {
|
||||
const result = new Map<number, string>();
|
||||
if (pids.length === 0 || process.platform === "win32") return Promise.resolve(result);
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"ps",
|
||||
["-o", "pid=,lstart=", "-p", pids.join(",")],
|
||||
{ timeout: PS_TIMEOUT_MS, env: { ...process.env, LC_ALL: "C", TZ: "UTC" } },
|
||||
(_error, stdout) => {
|
||||
// `ps` exits 1 when one of the pids is gone; its output still holds
|
||||
// the live ones.
|
||||
for (const line of String(stdout ?? "").split("\n")) {
|
||||
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
||||
if (match) result.set(Number(match[1]), normalizeStart(match[2]!));
|
||||
}
|
||||
resolve(result);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStart(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
let cachedPidDomain: Promise<string> | undefined;
|
||||
|
||||
/** The pid domain the CLI records: the platform on macOS, the machine id and
|
||||
* pid namespace on Linux, the host name on Windows. */
|
||||
function defaultPidDomain(): Promise<string> {
|
||||
cachedPidDomain ??= (async () => {
|
||||
if (process.platform === "win32") return `win32:${os.hostname().toLowerCase()}`;
|
||||
if (process.platform !== "linux") return process.platform;
|
||||
const [machineId, pidNamespace] = await Promise.all([
|
||||
fs.readFile("/etc/machine-id", "utf8").then(
|
||||
(value) => value.trim(),
|
||||
() => "",
|
||||
),
|
||||
fs.readlink("/proc/self/ns/pid").catch(() => ""),
|
||||
]);
|
||||
return `linux:${machineId}:${pidNamespace}`;
|
||||
})();
|
||||
return cachedPidDomain;
|
||||
}
|
||||
|
||||
type RawRecord = Record<string, unknown>;
|
||||
|
||||
async function readRecord(file: string): Promise<RawRecord | undefined> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const value: unknown = JSON.parse(text);
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as RawRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
// The CLI may be rewriting the file; read it once more.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export class LiveSessionRegistry {
|
||||
private cached?: { at: number; snapshot: Promise<LiveSnapshot> };
|
||||
private readonly dir: () => string;
|
||||
private readonly now: () => number;
|
||||
private readonly isAlive: (pid: number) => boolean;
|
||||
private readonly processStarts: (pids: number[]) => Promise<Map<number, string>>;
|
||||
private readonly pidDomain: () => Promise<string>;
|
||||
/** The start times `ps` gave, by pid, and when. */
|
||||
private readonly starts = new Map<number, { start: string; at: number }>();
|
||||
|
||||
constructor(deps: LiveRegistryDeps = {}) {
|
||||
this.dir = deps.dir ?? liveRegistryDir;
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.isAlive = deps.isAlive ?? defaultIsAlive;
|
||||
this.processStarts = deps.processStarts ?? defaultProcessStarts;
|
||||
this.pidDomain = deps.pidDomain ?? defaultPidDomain;
|
||||
}
|
||||
|
||||
/** The live records by session id. Cached for 5 s. */
|
||||
snapshot(): Promise<LiveSnapshot> {
|
||||
const now = this.now();
|
||||
if (this.cached && now - this.cached.at < SNAPSHOT_TTL_MS) {
|
||||
return this.cached.snapshot;
|
||||
}
|
||||
const snapshot = this.read()
|
||||
.then(bySession)
|
||||
.catch(() => new Map<string, LiveRecord>());
|
||||
this.cached = { at: now, snapshot };
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Every live record, several per session when several processes hold it. */
|
||||
private async read(): Promise<LiveRecord[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(this.dir());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return [...(await this.readFiles(names)).values()].filter(
|
||||
(record): record is LiveRecord => record !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live record of each of the registry files `names`, by name:
|
||||
* undefined for a file that is gone or holds no live record. A name that
|
||||
* is not `<pid>.json` (a `.key` file, a temporary file) is never opened and
|
||||
* not in the result. The rules of {@link read} apply. `recentStarts` takes
|
||||
* a start time that `ps` gave in the last 10 s when it matches the record,
|
||||
* so a record that changes often does not run `ps` each time.
|
||||
*/
|
||||
async readFiles(
|
||||
names: readonly string[],
|
||||
options: { recentStarts?: boolean } = {},
|
||||
): Promise<Map<string, LiveRecord | undefined>> {
|
||||
const dir = this.dir();
|
||||
const result = new Map<string, LiveRecord | undefined>();
|
||||
const domain = await this.pidDomain();
|
||||
const candidates: { name: string; pid: number; raw: RawRecord }[] = [];
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const match = RECORD_FILE_PATTERN.exec(name);
|
||||
if (!match) return;
|
||||
result.set(name, undefined);
|
||||
const pid = Number(match[1]);
|
||||
const raw = await readRecord(path.join(dir, name));
|
||||
if (!raw || raw.pid !== pid || typeof raw.sessionId !== "string") return;
|
||||
if (typeof raw.pidDomain === "string" && raw.pidDomain !== domain) return;
|
||||
if (!this.isAlive(pid)) return;
|
||||
candidates.push({ name, pid, raw });
|
||||
}),
|
||||
);
|
||||
const now = this.now();
|
||||
const starts = new Map<number, string>();
|
||||
const needStarts: number[] = [];
|
||||
for (const { pid, raw } of candidates) {
|
||||
if (typeof raw.procStart !== "string") continue;
|
||||
const recent = this.starts.get(pid);
|
||||
// A start that differs may be a new process under the same pid.
|
||||
if (
|
||||
options.recentStarts &&
|
||||
recent &&
|
||||
now - recent.at < RECENT_START_MS &&
|
||||
recent.start === normalizeStart(raw.procStart)
|
||||
) {
|
||||
starts.set(pid, recent.start);
|
||||
} else {
|
||||
needStarts.push(pid);
|
||||
}
|
||||
}
|
||||
if (needStarts.length > 0) {
|
||||
for (const [pid, start] of await this.processStarts(needStarts)) {
|
||||
starts.set(pid, start);
|
||||
this.starts.set(pid, { start, at: now });
|
||||
}
|
||||
}
|
||||
for (const [pid, { at }] of this.starts) {
|
||||
if (now - at >= RECENT_START_MS) this.starts.delete(pid);
|
||||
}
|
||||
for (const { name, pid, raw } of candidates) {
|
||||
const recorded =
|
||||
typeof raw.procStart === "string" ? normalizeStart(raw.procStart) : undefined;
|
||||
const actual = starts.get(pid);
|
||||
if (recorded !== undefined && actual !== undefined && recorded !== actual) continue;
|
||||
const procStartMatches = recorded !== undefined && actual === recorded;
|
||||
const lastSeen = Math.max(
|
||||
finiteNumber(raw.updatedAt) ?? 0,
|
||||
finiteNumber(raw.statusUpdatedAt) ?? 0,
|
||||
finiteNumber(raw.startedAt) ?? 0,
|
||||
);
|
||||
if (now - lastSeen > STALE_RECORD_MS && !procStartMatches) continue;
|
||||
result.set(name, {
|
||||
pid,
|
||||
sessionId: raw.sessionId as string,
|
||||
cwd: optionalString(raw.cwd),
|
||||
kind: optionalString(raw.kind),
|
||||
entrypoint: optionalString(raw.entrypoint),
|
||||
status: optionalString(raw.status),
|
||||
statusUpdatedAt: finiteNumber(raw.statusUpdatedAt),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** One record per session: the one with the newest status. */
|
||||
export function bySession(records: Iterable<LiveRecord>): LiveSnapshot {
|
||||
const result = new Map<string, LiveRecord>();
|
||||
for (const record of records) {
|
||||
const previous = result.get(record.sessionId);
|
||||
if (!previous || (record.statusUpdatedAt ?? 0) > (previous.statusUpdatedAt ?? 0)) {
|
||||
result.set(record.sessionId, record);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Where Claude Code keeps the transcripts of a working directory.
|
||||
*
|
||||
* The CLI stores the transcripts of `cwd` in `<config>/projects/<encoded cwd>/`.
|
||||
* The encoding replaces every character that is not ASCII alphanumeric with `-`.
|
||||
* A name longer than {@link MAX_SANITIZED_LENGTH} is cut and gets a hash
|
||||
* suffix; the CLI (Bun) and the SDK (Node) compute different hashes, so a long
|
||||
* name is matched by its prefix, as the SDK does.
|
||||
*
|
||||
* The encoding is lossy (`/a/b-c` and `/a/b/c` share a directory), so a
|
||||
* directory name is never decoded: a path belongs to a directory only when
|
||||
* the path encodes to the name.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { claudeConfigDir } from "../paths.js";
|
||||
|
||||
/** The SDK cuts an encoded path at this length and appends a hash. */
|
||||
const MAX_SANITIZED_LENGTH = 200;
|
||||
|
||||
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Whether `value` is a Claude session id (a UUID). Only such ids name a
|
||||
* transcript, and only such ids are safe to use as a file name. */
|
||||
export function isSessionId(value: unknown): value is string {
|
||||
return typeof value === "string" && SESSION_ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/** `<config>/projects`, normalized like the SDK does. */
|
||||
export function projectsRoot(): string {
|
||||
return path.join(claudeConfigDir(), "projects").normalize("NFC");
|
||||
}
|
||||
|
||||
function sanitize(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
/** The SDK's 32-bit string hash for long project names. */
|
||||
function hashCode(value: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/** The project directory name of `cwd`, exactly as the SDK computes it. */
|
||||
export function encodeProjectPath(cwd: string): string {
|
||||
const sanitized = sanitize(normalizePath(cwd));
|
||||
if (sanitized.length <= MAX_SANITIZED_LENGTH) return sanitized;
|
||||
return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${Math.abs(hashCode(normalizePath(cwd))).toString(36)}`;
|
||||
}
|
||||
|
||||
/** NFC on darwin, as the SDK normalizes paths there. */
|
||||
export function normalizePath(value: string): string {
|
||||
return process.platform === "darwin" ? value.normalize("NFC") : value;
|
||||
}
|
||||
|
||||
function sameName(a: string, b: string): boolean {
|
||||
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `dirName` is the project directory of `cwd`: the exact encoding, or,
|
||||
* for a long path, a directory with the same cut prefix and any hash suffix.
|
||||
*/
|
||||
export function projectDirMatches(dirName: string, cwd: string): boolean {
|
||||
if (sameName(dirName, encodeProjectPath(cwd))) return true;
|
||||
const sanitized = sanitize(normalizePath(cwd));
|
||||
if (sanitized.length <= MAX_SANITIZED_LENGTH) return false;
|
||||
const prefix = `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-`;
|
||||
const name = process.platform === "win32" ? dirName.toLowerCase() : dirName;
|
||||
return name.startsWith(process.platform === "win32" ? prefix.toLowerCase() : prefix);
|
||||
}
|
||||
|
||||
/** Whether a transcript's `cwd` belongs to `projectPath`, compared the way
|
||||
* the SDK does: the full sanitized forms, ignoring case where the file
|
||||
* system does. */
|
||||
export function sameProjectPath(cwd: string, projectPath: string): boolean {
|
||||
const a = sanitize(normalizePath(cwd));
|
||||
const b = sanitize(normalizePath(projectPath));
|
||||
return process.platform === "win32" || process.platform === "darwin"
|
||||
? a.toLowerCase() === b.toLowerCase()
|
||||
: a === b;
|
||||
}
|
||||
|
||||
/** Whether `dirName` is the exact project directory of `cwd`, as the SDK
|
||||
* finds it by `dir`: the exact encoding, on macOS in any case (the file
|
||||
* system ignores it). */
|
||||
export function isExactProjectDir(dirName: string, cwd: string): boolean {
|
||||
const exact = encodeProjectPath(cwd);
|
||||
if (sameName(dirName, exact)) return true;
|
||||
return process.platform === "darwin" && dirName.toLowerCase() === exact.toLowerCase();
|
||||
}
|
||||
|
||||
/** The real path of `cwd`, like the SDK resolves it before encoding. Falls
|
||||
* back to `cwd` when it does not exist. */
|
||||
export async function canonicalPath(cwd: string): Promise<string> {
|
||||
try {
|
||||
return normalizePath(await fs.realpath(cwd));
|
||||
} catch {
|
||||
return normalizePath(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
/** The names of the project directories of `cwd` among `rootEntries`. */
|
||||
export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] {
|
||||
const exact = encodeProjectPath(cwd);
|
||||
const sanitizedLength = sanitize(normalizePath(cwd)).length;
|
||||
if (sanitizedLength <= MAX_SANITIZED_LENGTH) {
|
||||
return rootEntries.filter((name) => sameName(name, exact));
|
||||
}
|
||||
return rootEntries.filter((name) => projectDirMatches(name, cwd));
|
||||
}
|
||||
|
||||
/** `value` and each of its ancestors, nearest first. */
|
||||
export function pathAndAncestors(value: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = value;
|
||||
for (;;) {
|
||||
result.push(current);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return result;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/** The `code` of a Node.js system error, if it has one. */
|
||||
export function errorCode(error: unknown): string | undefined {
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
@@ -1,710 +0,0 @@
|
||||
/**
|
||||
* The `sessionIndex` AIR extension: the session list, rename, archive and
|
||||
* delete of a client that declared the capability.
|
||||
*
|
||||
* Wire contract: docs/air-extensions.md, "Session index". Everything here is
|
||||
* reached only for a `sessionIndex` client, except the archive that an AIR
|
||||
* client without the capability gets in place of a delete (see
|
||||
* `ClaudeAcpAgent.deleteSession`).
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
RequestError,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { airExtensionMeta } from "../air-extension.js";
|
||||
import { sanitizeTitle } from "../session-titles.js";
|
||||
import type { OwnSessionState } from "./activity.js";
|
||||
import {
|
||||
effectiveTitle,
|
||||
isArchivedTitle,
|
||||
storedTitle,
|
||||
titleRecords,
|
||||
visibleTitle,
|
||||
} from "./archive-title.js";
|
||||
import {
|
||||
ListSubscriptions,
|
||||
type ListChanges,
|
||||
type ListSubscribeResponse,
|
||||
type ListSubscriptionDeps,
|
||||
} from "./list-subscriptions.js";
|
||||
import { LiveSessionRegistry } from "./live-registry.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
errorCode,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
projectDirMatches,
|
||||
sameProjectPath,
|
||||
} from "./project-dirs.js";
|
||||
import {
|
||||
readHeadTail,
|
||||
readHeadTailOf,
|
||||
sdkTitles,
|
||||
tailCustomTitle,
|
||||
transcriptAgentName,
|
||||
transcriptProjectCwd,
|
||||
} from "./transcript-scan.js";
|
||||
import {
|
||||
DEFAULT_LIST_LIMIT,
|
||||
MAX_LIST_LIMIT,
|
||||
ARCHIVED_FILTERS,
|
||||
readSidecarTitle,
|
||||
SessionIndex,
|
||||
type ArchivedFilter,
|
||||
type ListCursor,
|
||||
} from "./session-index.js";
|
||||
import { sessionInfoOf } from "./session-info.js";
|
||||
|
||||
export const SESSION_RENAME_METHOD = "_session/rename";
|
||||
export const SESSION_ARCHIVE_METHOD = "_session/archive";
|
||||
export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
|
||||
|
||||
/** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
|
||||
const RESOURCE_NOT_FOUND = -32002;
|
||||
/** The cursor format. A cursor of another version is rejected. */
|
||||
const CURSOR_VERSION = 5;
|
||||
|
||||
export type SessionIdRequest = { sessionId: string };
|
||||
export type RenameSessionRequest = { sessionId: string; title: string };
|
||||
|
||||
export function sessionNotFound(sessionId: string): RequestError {
|
||||
return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function parseSessionIdRequest(value: unknown): SessionIdRequest {
|
||||
const sessionId = asRecord(value).sessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId.trim()) {
|
||||
throw RequestError.invalidParams(undefined, "params require a non-empty sessionId");
|
||||
}
|
||||
return { sessionId: sessionId.trim() };
|
||||
}
|
||||
|
||||
export function parseRenameSessionRequest(value: unknown): RenameSessionRequest {
|
||||
const { sessionId } = parseSessionIdRequest(value);
|
||||
const raw = asRecord(value).title;
|
||||
const title = typeof raw === "string" ? sanitizeTitle(raw) : "";
|
||||
if (!title) throw RequestError.invalidParams(undefined, "title must be a non-empty string");
|
||||
return { sessionId, title };
|
||||
}
|
||||
|
||||
type ListOptions = { limit: number; archived: ArchivedFilter; includeWorktrees: boolean };
|
||||
|
||||
/** What a cursor is bound to: the request values that select the rows. */
|
||||
export type ListScope = {
|
||||
cwd: string | null;
|
||||
archived: ArchivedFilter;
|
||||
includeWorktrees: boolean;
|
||||
};
|
||||
|
||||
function archivedFilter(list: Record<string, unknown>): ArchivedFilter {
|
||||
// Omitted or null is `unarchived`.
|
||||
const value = list.archived ?? "unarchived";
|
||||
if (!ARCHIVED_FILTERS.includes(value as ArchivedFilter)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ archived: value },
|
||||
'`_meta.jetbrains.air.list.archived` must be "unarchived", "archived" or "all"',
|
||||
);
|
||||
}
|
||||
return value as ArchivedFilter;
|
||||
}
|
||||
|
||||
function optionalBoolean(list: Record<string, unknown>, key: string): boolean {
|
||||
// Omitted or null is false.
|
||||
const value = list[key] ?? false;
|
||||
if (typeof value !== "boolean") {
|
||||
throw RequestError.invalidParams(
|
||||
{ [key]: value },
|
||||
`\`_meta.jetbrains.air.list.${key}\` must be a boolean`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `_meta.jetbrains.air.list` of a list request. */
|
||||
export function parseListOptions(meta: unknown): ListOptions {
|
||||
const list = asRecord(airExtensionMeta(meta)?.list);
|
||||
// Omitted or null is the default; an integer of at least 1 is clamped;
|
||||
// anything else is invalid.
|
||||
const raw = list.limit ?? DEFAULT_LIST_LIMIT;
|
||||
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
|
||||
throw RequestError.invalidParams(
|
||||
{ limit: raw },
|
||||
"`_meta.jetbrains.air.list.limit` must be an integer of at least 1",
|
||||
);
|
||||
}
|
||||
return {
|
||||
limit: Math.min(MAX_LIST_LIMIT, raw),
|
||||
archived: archivedFilter(list),
|
||||
includeWorktrees: optionalBoolean(list, "includeWorktrees"),
|
||||
};
|
||||
}
|
||||
|
||||
type CursorPayload = {
|
||||
v: number;
|
||||
u: number;
|
||||
id: string;
|
||||
cwd: string | null;
|
||||
archived: ArchivedFilter;
|
||||
worktrees: boolean;
|
||||
};
|
||||
|
||||
export function encodeListCursor(cursor: ListCursor, scope: ListScope): string {
|
||||
const payload: CursorPayload = {
|
||||
v: CURSOR_VERSION,
|
||||
u: cursor.orderAtMs,
|
||||
id: cursor.sessionId,
|
||||
cwd: scope.cwd,
|
||||
archived: scope.archived,
|
||||
worktrees: scope.includeWorktrees,
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
}
|
||||
|
||||
/** The position a cursor names. A cursor of another cwd or filter, or one
|
||||
* this adapter did not issue, is rejected. */
|
||||
export function decodeListCursor(cursor: string, scope: ListScope): ListCursor {
|
||||
let payload: Partial<CursorPayload> | undefined;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as CursorPayload;
|
||||
} catch {
|
||||
payload = undefined;
|
||||
}
|
||||
if (
|
||||
!payload ||
|
||||
payload.v !== CURSOR_VERSION ||
|
||||
typeof payload.u !== "number" ||
|
||||
typeof payload.id !== "string"
|
||||
) {
|
||||
throw RequestError.invalidParams(undefined, `Unknown session/list cursor: ${cursor}`);
|
||||
}
|
||||
if (
|
||||
payload.cwd !== scope.cwd ||
|
||||
payload.archived !== scope.archived ||
|
||||
payload.worktrees !== scope.includeWorktrees
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"The session/list cursor belongs to another cwd or filter",
|
||||
);
|
||||
}
|
||||
return { orderAtMs: payload.u, sessionId: payload.id };
|
||||
}
|
||||
|
||||
/** `<projectDir>/<sessionId>/custom-title.json` of a transcript. */
|
||||
function sidecarPath(transcriptPath: string): string {
|
||||
const sessionId = path.basename(transcriptPath, ".jsonl");
|
||||
return path.join(path.dirname(transcriptPath), sessionId, "custom-title.json");
|
||||
}
|
||||
|
||||
/** Writes `<projectDir>/<sessionId>/custom-title.json` the way the CLI's
|
||||
* `/rename` does: file 0600 in a 0700 directory, replaced atomically. The
|
||||
* temporary file has a random name of the CLI's `custom-title.json.tmp.*`
|
||||
* pattern, and only a temporary file this call created is removed. */
|
||||
export async function writeCustomTitleSidecar(transcriptPath: string, title: string) {
|
||||
const target = sidecarPath(transcriptPath);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${target}.tmp.${randomBytes(8).toString("hex")}`;
|
||||
const handle = await fs.open(temporary, "wx", 0o600);
|
||||
try {
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify({ customTitle: title }));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fs.rename(temporary, target);
|
||||
} catch (error) {
|
||||
await fs.rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The last two non-empty lines of a file, from its last 64 KB, and
|
||||
* whether the file ends with a newline. */
|
||||
async function lastLines(
|
||||
filePath: string,
|
||||
): Promise<{ lines: string[]; last: string; endsWithNewline: boolean }> {
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
const length = Math.min(size, 64 * 1024);
|
||||
const buffer = Buffer.alloc(length);
|
||||
await handle.read(buffer, 0, length, size - length);
|
||||
const text = buffer.toString("utf8");
|
||||
const lines = text.split("\n").filter((line) => line.trim());
|
||||
return {
|
||||
lines: lines.slice(-2),
|
||||
last: lines.at(-1) ?? "",
|
||||
endsWithNewline: text.endsWith("\n"),
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends the title records of `title` (see archive-title.ts) to a
|
||||
* transcript whose last two records are not those already, whoever else
|
||||
* has the session open, as the CLI's `/rename` does. A torn last line is
|
||||
* closed first. */
|
||||
async function ensureTitleRecords(filePath: string, sessionId: string, title: string) {
|
||||
const records = titleRecords(sessionId, title);
|
||||
const { lines, last, endsWithNewline } = await lastLines(filePath);
|
||||
if (lines.join("\n") + "\n" === records) return;
|
||||
const complete = endsWithNewline || last === "";
|
||||
await fs.appendFile(filePath, `${complete ? "" : "\n"}${records}`);
|
||||
}
|
||||
|
||||
/** Appends the `agent-name` record of `title` when the copy's agent name
|
||||
* is another one, so its archive state follows `title`. Reads and appends
|
||||
* through one handle. */
|
||||
async function ensureAgentName(filePath: string, sessionId: string, title: string) {
|
||||
const handle = await fs.open(filePath, "a+");
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
const headTail = await readHeadTailOf(handle, size);
|
||||
const agentName = transcriptAgentName(headTail);
|
||||
if (agentName === undefined || agentName === title) return;
|
||||
const complete = size === 0 || headTail.tail.endsWith("\n");
|
||||
const record = JSON.stringify({ type: "agent-name", agentName: title, sessionId });
|
||||
await handle.appendFile(`${complete ? "" : "\n"}${record}\n`);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The title of one transcript copy as the list reads it (see
|
||||
* {@link effectiveTitle}): its agent name, else the title the SDK reports.
|
||||
* It carries the archive prefix only when the copy is archived: a generated
|
||||
* title never archives a session. */
|
||||
async function copyTitle(filePath: string, sessionId: string): Promise<string | undefined> {
|
||||
const { size } = await fs.stat(filePath);
|
||||
const headTail = await readHeadTail(filePath, size);
|
||||
const sidecar =
|
||||
tailCustomTitle(headTail) === undefined
|
||||
? await readSidecarTitle(filePath, sessionId)
|
||||
: undefined;
|
||||
const { title, archived } = effectiveTitle(
|
||||
transcriptAgentName(headTail),
|
||||
sdkTitles(headTail, sidecar),
|
||||
);
|
||||
return title !== undefined && !archived && isArchivedTitle(title) ? visibleTitle(title) : title;
|
||||
}
|
||||
|
||||
/** Rewrites the sidecar of a copy whose title is `current` when the
|
||||
* sidecar says the other archive state (a sidecar write that failed after
|
||||
* the records were appended). */
|
||||
async function alignSidecar(
|
||||
transcript: string,
|
||||
current: string | undefined,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (current === undefined) return;
|
||||
const sidecar = await readSidecarTitle(transcript, sessionId);
|
||||
if (sidecar === undefined || isArchivedTitle(sidecar) === isArchivedTitle(current)) return;
|
||||
await writeCustomTitleSidecar(
|
||||
transcript,
|
||||
storedTitle(current, isArchivedTitle(current), sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
/** The title a change stores for a copy whose title is `current`
|
||||
* (undefined: none yet), or undefined to leave the copy as it is. */
|
||||
export type TitleChange = (current: string | undefined, sessionId: string) => string | undefined;
|
||||
|
||||
/** `_session/rename`: the new title, with the archive prefix on a copy
|
||||
* that is archived, so a rename keeps the archive state. */
|
||||
export function renameTo(title: string): TitleChange {
|
||||
return (current, sessionId) =>
|
||||
isArchivedTitle(current) ? storedTitle(title, true, sessionId) : title;
|
||||
}
|
||||
|
||||
/** `_session/archive` and `_session/unarchive`: the current title with or
|
||||
* without the archive prefix; a copy already in that state is left alone. */
|
||||
export function archiveTo(archived: boolean): TitleChange {
|
||||
return (current, sessionId) =>
|
||||
isArchivedTitle(current) === archived
|
||||
? undefined
|
||||
: storedTitle(current ?? "", archived, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `transcript` lies in the project directory of one of `paths`, the
|
||||
* one the CLI of that cwd writes: the exact encoding, or, for a long path
|
||||
* whose name the CLI hashes differently, a directory with the cut prefix
|
||||
* whose transcript belongs to the path. Another long path that shares the
|
||||
* prefix does not count.
|
||||
*/
|
||||
async function isTranscriptOf(transcript: string, paths: readonly string[]): Promise<boolean> {
|
||||
const dirName = path.basename(path.dirname(transcript));
|
||||
if (paths.some((cwd) => isExactProjectDir(dirName, cwd))) return true;
|
||||
if (!paths.some((cwd) => projectDirMatches(dirName, cwd))) return false;
|
||||
try {
|
||||
const { size } = await fs.stat(transcript);
|
||||
const cwd = transcriptProjectCwd(await readHeadTail(transcript, size));
|
||||
return cwd !== undefined && paths.some((projectPath) => sameProjectPath(cwd, projectPath));
|
||||
} catch {
|
||||
// Unreadable: not known to be the CLI's own copy.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSdkNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
|
||||
}
|
||||
|
||||
async function exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The error the SDK `deleteSession` gives for a session id it rejects or
|
||||
* does not find, which a client without `sessionIndex` got before. */
|
||||
function sdkDeleteError(sessionId: string): Error {
|
||||
return new Error(
|
||||
isSessionId(sessionId)
|
||||
? `Session ${sessionId} not found in any project directory`
|
||||
: `Invalid sessionId: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/delete` of an AIR client without `sessionIndex`, which uses delete
|
||||
* to mark a session done: archives it instead, so the transcript survives. A
|
||||
* session without a transcript fails as the SDK delete did.
|
||||
*/
|
||||
export async function archiveInsteadOfDelete(
|
||||
sessionId: string,
|
||||
service: SessionIndexService,
|
||||
): Promise<void> {
|
||||
if (!isSessionId(sessionId)) throw sdkDeleteError(sessionId);
|
||||
// The SDK delete skips empty transcripts, so they do not count.
|
||||
const found = await service.index.findTranscripts(sessionId);
|
||||
if (found.length === 0) throw sdkDeleteError(sessionId);
|
||||
await service.retitle(sessionId, archiveTo(true), { sidecar: "existing" });
|
||||
}
|
||||
|
||||
/** How {@link SessionIndexService.retitle} reaches the session. */
|
||||
export type RetitleOptions = {
|
||||
/** The CLI of the session runs here: it stores the title of its own
|
||||
* transcript (`rename_session`). `stored`: the title last stored through
|
||||
* it, which it holds; `shown`: the title last shown for a session that has
|
||||
* neither that nor a transcript. */
|
||||
live?: {
|
||||
cwd: string;
|
||||
rename: (title: string) => Promise<void>;
|
||||
/** Read once the session's earlier title changes are done. */
|
||||
stored: () => string | undefined;
|
||||
/** Records a title the CLI took, before the next change starts. */
|
||||
remember: (title: string) => void;
|
||||
shown?: string;
|
||||
};
|
||||
/** A session without a transcript is not unknown: a new session this
|
||||
* connection runs, or a live one being renamed. */
|
||||
mayBeUnwritten?: boolean;
|
||||
/** `always` writes the CLI title sidecar next to every titled copy;
|
||||
* `existing` rewrites only one that is there already. */
|
||||
sidecar: "always" | "existing";
|
||||
};
|
||||
|
||||
export type SessionIndexDeps = {
|
||||
deleteSession?: (sessionId: string) => Promise<void>;
|
||||
registry?: LiveSessionRegistry;
|
||||
now?: () => number;
|
||||
/** What this connection knows of a session it runs. */
|
||||
ownSessionState?: (sessionId: string) => OwnSessionState | undefined;
|
||||
/** Sends `_session/list/changes`. */
|
||||
notifyListChanges?: (changes: ListChanges) => Promise<void>;
|
||||
/** The timing of the list subscriptions, for tests. */
|
||||
listSubscriptionTiming?: Pick<
|
||||
ListSubscriptionDeps,
|
||||
"debounceMs" | "maxWaitMs" | "rescanMs" | "minSessionIntervalMs"
|
||||
>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
export class SessionIndexService {
|
||||
readonly index: SessionIndex;
|
||||
readonly registry: LiveSessionRegistry;
|
||||
private subscriptions?: ListSubscriptions;
|
||||
/** Set for good by {@link dispose}: no subscription is made after it. */
|
||||
private disposed = false;
|
||||
/** The mutation in flight per session, which the next one waits for. */
|
||||
private readonly mutations = new Map<string, Promise<unknown>>();
|
||||
private readonly now: () => number;
|
||||
private readonly deleteSession: (sessionId: string) => Promise<void>;
|
||||
|
||||
constructor(private readonly deps: SessionIndexDeps) {
|
||||
this.index = new SessionIndex();
|
||||
this.registry = deps.registry ?? new LiveSessionRegistry();
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.deleteSession = deps.deleteSession ?? ((id) => sdkDeleteSession(id));
|
||||
}
|
||||
|
||||
/** `session/list` of a `sessionIndex` client. `own` reports the sessions
|
||||
* that this connection runs. */
|
||||
async list(
|
||||
params: ListSessionsRequest,
|
||||
own: (sessionId: string) => OwnSessionState | undefined,
|
||||
): Promise<ListSessionsResponse> {
|
||||
const { limit, archived, includeWorktrees } = parseListOptions(params._meta);
|
||||
const cwd = params.cwd ?? null;
|
||||
const scope: ListScope = { cwd, archived, includeWorktrees };
|
||||
const after =
|
||||
params.cursor === null || params.cursor === undefined
|
||||
? undefined
|
||||
: decodeListCursor(params.cursor, scope);
|
||||
// The live registry is read while the transcripts are.
|
||||
const livePromise = this.registry.snapshot();
|
||||
const { rows, hasMore } = await this.index.list({
|
||||
cwd,
|
||||
includeWorktrees,
|
||||
limit,
|
||||
archived,
|
||||
after,
|
||||
});
|
||||
const live = await livePromise;
|
||||
const now = this.now();
|
||||
const sessions = rows.map((row) =>
|
||||
sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now),
|
||||
);
|
||||
const last = rows[rows.length - 1];
|
||||
return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
|
||||
}
|
||||
|
||||
/** Runs `mutation` after the previous mutation of the session ended. */
|
||||
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(mutation);
|
||||
const settled = next.catch(() => undefined);
|
||||
this.mutations.set(sessionId, settled);
|
||||
void settled.then(() => {
|
||||
if (this.mutations.get(sessionId) === settled) this.mutations.delete(sessionId);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retitles every transcript of a session with `change`: rename, archive
|
||||
* and unarchive. Returns the title stored through the CLI that runs here,
|
||||
* else the title of the first copy that got one; undefined when nothing
|
||||
* was stored.
|
||||
*
|
||||
* - The CLI runs here (`live`): it titles its own transcript and sidecar
|
||||
* (`rename_session`), the only writer that cannot be overtaken by the
|
||||
* title the CLI holds. The other copies get the records and the sidecar
|
||||
* here, as a best effort once the CLI has the title: a failure is
|
||||
* logged, not returned.
|
||||
* - Otherwise every transcript gets the records (and the sidecar, see
|
||||
* {@link RetitleOptions.sidecar}).
|
||||
*
|
||||
* As the CLI's `/rename`, the records are written whether or not another
|
||||
* process has the session open.
|
||||
*
|
||||
* A session without a transcript is unknown (`-32002`), unless
|
||||
* {@link RetitleOptions.mayBeUnwritten}.
|
||||
*/
|
||||
async retitle(
|
||||
sessionId: string,
|
||||
change: TitleChange,
|
||||
options: RetitleOptions,
|
||||
): Promise<string | undefined> {
|
||||
const { live } = options;
|
||||
if (!isSessionId(sessionId)) {
|
||||
if (!live || !options.mayBeUnwritten) throw sessionNotFound(sessionId);
|
||||
return this.exclusive(sessionId, async () => {
|
||||
const title = change(live.stored() ?? live.shown, sessionId);
|
||||
if (title !== undefined) {
|
||||
await live.rename(title);
|
||||
live.remember(title);
|
||||
}
|
||||
return title;
|
||||
});
|
||||
}
|
||||
return this.exclusive(sessionId, async () => {
|
||||
const transcripts = await this.index.findTranscripts(sessionId);
|
||||
if (transcripts.length === 0 && !options.mayBeUnwritten) throw sessionNotFound(sessionId);
|
||||
let stored: string | undefined;
|
||||
// The cache is left as it is: the appended records change the size,
|
||||
// and the next read keeps what the earlier scans found (a last prompt
|
||||
// beyond the tail search).
|
||||
if (live) {
|
||||
const canonical = await canonicalPath(live.cwd);
|
||||
const paths = [...new Set([live.cwd, canonical])];
|
||||
const own: string[] = [];
|
||||
for (const transcript of transcripts) {
|
||||
if (await isTranscriptOf(transcript, paths)) own.push(transcript);
|
||||
}
|
||||
// The CLI writes the transcript of the resolved cwd.
|
||||
const cli =
|
||||
own.find((transcript) =>
|
||||
isExactProjectDir(path.basename(path.dirname(transcript)), canonical),
|
||||
) ?? own[0];
|
||||
const others = transcripts.filter((transcript) => transcript !== cli);
|
||||
// The transcript holds the current title, also one changed otherwise
|
||||
// (a `/rename`); without one, the title last given to the CLI.
|
||||
const primary = cli ?? others[0];
|
||||
const read = primary ? await copyTitle(primary, sessionId) : undefined;
|
||||
const current = read ?? live.stored() ?? live.shown;
|
||||
stored = change(current, sessionId);
|
||||
if (stored !== undefined) {
|
||||
await live.rename(stored);
|
||||
live.remember(stored);
|
||||
// The CLI writes the custom title before it answers, the agent name
|
||||
// only later. An older agent name decides the archive state first,
|
||||
// so the copy gets the new one now.
|
||||
// A best effort: the CLI took the title, and writes the agent name
|
||||
// itself too.
|
||||
if (cli) {
|
||||
try {
|
||||
await ensureAgentName(cli, sessionId, stored);
|
||||
} catch (error) {
|
||||
this.deps.logError(`writing the agent name of ${sessionId} failed`, error);
|
||||
}
|
||||
}
|
||||
} else if (cli) {
|
||||
await alignSidecar(cli, current, sessionId);
|
||||
}
|
||||
try {
|
||||
await this.titleCopies(sessionId, others, change, options.sidecar);
|
||||
} catch (error) {
|
||||
this.deps.logError(`titling the other transcripts of ${sessionId} failed`, error);
|
||||
}
|
||||
} else {
|
||||
stored = await this.titleCopies(sessionId, transcripts, change, options.sidecar);
|
||||
}
|
||||
return stored;
|
||||
}).finally(() => {
|
||||
// Whatever was written, also by a change that failed part way.
|
||||
this.subscriptions?.sessionWritten(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends the title records `change` gives each copy, then its sidecar.
|
||||
* A copy already in the requested state gets nothing, but its sidecar is
|
||||
* brought in line with it. Returns the title the first titled copy got. */
|
||||
private async titleCopies(
|
||||
sessionId: string,
|
||||
transcripts: readonly string[],
|
||||
change: TitleChange,
|
||||
sidecar: RetitleOptions["sidecar"],
|
||||
): Promise<string | undefined> {
|
||||
let first: string | undefined;
|
||||
for (const transcript of transcripts) {
|
||||
const current = await copyTitle(transcript, sessionId);
|
||||
const title = change(current, sessionId);
|
||||
if (title === undefined) {
|
||||
await alignSidecar(transcript, current, sessionId);
|
||||
continue;
|
||||
}
|
||||
await ensureTitleRecords(transcript, sessionId, title);
|
||||
first ??= title;
|
||||
if (sidecar === "always" || (await exists(sidecarPath(transcript)))) {
|
||||
await writeCustomTitleSidecar(transcript, title);
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** Deletes every transcript of the session, whoever else has it open, as
|
||||
* the CLI does. `known`: the session was loaded here (and is torn down
|
||||
* already), so a missing transcript is no error.
|
||||
*
|
||||
* The SDK deletes the first non-empty transcript it finds and its
|
||||
* `<sessionId>/` directory, one copy per call; empty transcripts, which it
|
||||
* skips, are removed here. */
|
||||
async delete(sessionId: string, known: boolean): Promise<void> {
|
||||
if (!isSessionId(sessionId)) throw sessionNotFound(sessionId);
|
||||
await this.exclusive(sessionId, async () => {
|
||||
// A session directory can outlive its transcript: a delete that removed
|
||||
// the transcript and then failed on the directory.
|
||||
const found = await this.index.scanSession(sessionId);
|
||||
const all = found.transcripts.map(({ filePath }) => filePath);
|
||||
if (all.length === 0 && found.sessionDirs.length === 0) {
|
||||
// Unknown, unless it was loaded here.
|
||||
if (known) return;
|
||||
throw sessionNotFound(sessionId);
|
||||
}
|
||||
try {
|
||||
// The SDK deletes one non-empty copy per call.
|
||||
for (const { filePath } of found.transcripts.filter(({ size }) => size > 0)) {
|
||||
try {
|
||||
await this.deleteSession(sessionId);
|
||||
} catch (error) {
|
||||
// Only "not found" with that copy gone is a copy removed meanwhile.
|
||||
if (isSdkNotFound(error) && !(await exists(filePath))) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Empty transcripts, which the SDK skips, and session directories
|
||||
// without a transcript.
|
||||
for (const { filePath, size } of found.transcripts) {
|
||||
if (size === 0) await fs.rm(filePath, { force: true });
|
||||
}
|
||||
for (const sessionDir of found.sessionDirs) {
|
||||
if (await exists(`${sessionDir}.jsonl`)) continue;
|
||||
await fs.rm(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
const left = await this.index.scanSession(sessionId);
|
||||
const remaining = [
|
||||
...left.transcripts.map(({ filePath }) => filePath),
|
||||
...left.sessionDirs,
|
||||
];
|
||||
if (remaining.length > 0) {
|
||||
throw new Error(`Session ${sessionId} was not deleted: ${remaining.join(", ")} remain`);
|
||||
}
|
||||
} finally {
|
||||
this.index.invalidate(all);
|
||||
this.subscriptions?.sessionWritten(sessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** `_session/list/subscribe`: pushes the changed rows of `cwd` (an
|
||||
* absolute path) until {@link unsubscribeList} or {@link dispose}. */
|
||||
subscribeList(cwd: string): Promise<ListSubscribeResponse> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(RequestError.internalError(undefined, "The connection is closed"));
|
||||
}
|
||||
this.subscriptions ??= new ListSubscriptions({
|
||||
index: this.index,
|
||||
registry: this.registry,
|
||||
own: (sessionId) => this.deps.ownSessionState?.(sessionId),
|
||||
notify: (changes) => this.deps.notifyListChanges?.(changes) ?? Promise.resolve(),
|
||||
logError: this.deps.logError,
|
||||
...this.deps.listSubscriptionTiming,
|
||||
});
|
||||
return this.subscriptions.subscribe(cwd);
|
||||
}
|
||||
|
||||
/** `_session/list/unsubscribe`. Idempotent. */
|
||||
unsubscribeList(subscriptionId: string): void {
|
||||
this.subscriptions?.unsubscribe(subscriptionId);
|
||||
}
|
||||
|
||||
/** A session this connection runs changed what its row shows. */
|
||||
ownSessionChanged(sessionId: string): void {
|
||||
this.subscriptions?.ownSessionChanged(sessionId);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.subscriptions?.dispose();
|
||||
this.subscriptions = undefined;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* A session index row as the client sees it: the `SessionInfo` of
|
||||
* `session/list` and of `_session/list/changes`, with the flat row fields of
|
||||
* the RFDs in `_meta.jetbrains.air`.
|
||||
*/
|
||||
|
||||
import type { SessionInfo } from "@agentclientprotocol/sdk";
|
||||
import { withAirMeta } from "../air-extension.js";
|
||||
import { deriveActivity, selectCost, type OwnSessionState } from "./activity.js";
|
||||
import type { LiveRecord } from "./live-registry.js";
|
||||
import type { IndexRow } from "./session-index.js";
|
||||
|
||||
function iso(ms: number | undefined): string | undefined {
|
||||
return ms === undefined || !Number.isFinite(ms) ? undefined : new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
/** The `SessionInfo` of `row`. `own` is what this connection knows of a
|
||||
* session it runs, `live` the registry record of the session. */
|
||||
export function sessionInfoOf(
|
||||
row: IndexRow,
|
||||
own: OwnSessionState | undefined,
|
||||
live: LiveRecord | undefined,
|
||||
now: number,
|
||||
): SessionInfo {
|
||||
const activity = deriveActivity({
|
||||
own,
|
||||
live,
|
||||
facts: row.facts,
|
||||
transcriptMtimeMs: row.mtimeMs,
|
||||
now,
|
||||
});
|
||||
const cost = selectCost(own);
|
||||
const { facts } = row;
|
||||
// The row fields of the session list extensions RFD and RFD #2161, flat;
|
||||
// each is omitted when unknown, except `archived`.
|
||||
const fields: Record<string, unknown> = {
|
||||
archived: row.archived,
|
||||
lastPromptAt: iso(facts.lastPromptAt),
|
||||
model: facts.model,
|
||||
forkedFrom: facts.forkedFrom,
|
||||
state: activity?.state,
|
||||
lastTurnEndedAt: activity?.lastTurnEndedAt,
|
||||
cost: cost === undefined ? undefined : { amount: cost, currency: "USD" },
|
||||
};
|
||||
let meta: Record<string, unknown> | undefined;
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value !== undefined) meta = withAirMeta(meta, key, value);
|
||||
}
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
cwd: row.cwd,
|
||||
title: row.title,
|
||||
updatedAt: new Date(row.updatedAtMs).toISOString(),
|
||||
_meta: meta,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a change of the row is compared by: everything but `updatedAt`,
|
||||
* which alone is no change. */
|
||||
export function changeSignature(info: SessionInfo): string {
|
||||
return JSON.stringify([info.sessionId, info.cwd, info.title, info._meta ?? null]);
|
||||
}
|
||||
@@ -1,631 +0,0 @@
|
||||
/**
|
||||
* What the session list needs from a transcript beyond the SDK metadata.
|
||||
*
|
||||
* One open reads the first and the last {@link CHUNK_SIZE} bytes, the same
|
||||
* window that the SDK reads. The tail gives the time of the last message, the
|
||||
* end of the last turn. The head and the tail give the `cwd` candidates.
|
||||
*
|
||||
* A last message longer than the tail window leaves no message in it: then
|
||||
* the tail window grows, up to {@link MAX_TAIL_SIZE}, until it holds one.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import { firstPrompt, isUserPrompt, mediaPrompt, promptOf } from "./first-prompt.js";
|
||||
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
/** The largest tail window read to find the last message. */
|
||||
const MAX_TAIL_SIZE = 4 * 1024 * 1024;
|
||||
const TAIL_GROWTH = 4;
|
||||
const FULL_SEARCH_CACHE_SIZE = 512;
|
||||
|
||||
/** By `path\0size`, the facts of the transcripts whose last
|
||||
* {@link MAX_TAIL_SIZE} bytes lack the last message or prompt: a file of
|
||||
* the same size is not searched again. */
|
||||
const fullySearched = new Map<string, TranscriptFacts>();
|
||||
|
||||
export type TurnState = "finished" | "unfinished";
|
||||
|
||||
export type TranscriptFacts = {
|
||||
/** Time of the last user or assistant message in the tail, epoch ms. */
|
||||
lastMessageAt?: number;
|
||||
/** Whether the last turn in the tail ended. Undefined when the tail shows
|
||||
* no turn at all. */
|
||||
turnState?: TurnState;
|
||||
/** Time the last ended turn ended, epoch ms. */
|
||||
lastTurnEndedAt?: number;
|
||||
/** The last turn ended with an API error (an `isApiErrorMessage`
|
||||
* assistant record), not a user interrupt, and nothing came after it. */
|
||||
lastTurnError?: boolean;
|
||||
/** The first `cwd` of the head. */
|
||||
headCwd?: string;
|
||||
/** The last `cwd` of the tail. */
|
||||
tailCwd?: string;
|
||||
/** Time of the last real user prompt in the tail (not a tool result, a
|
||||
* meta record or a slash command), epoch ms. */
|
||||
lastPromptAt?: number;
|
||||
/** Whether `lastPromptAt` is final: the prompt was found, or the tail
|
||||
* window searched up to {@link MAX_TAIL_SIZE} holds none. */
|
||||
promptSearched?: boolean;
|
||||
/** The model of the last assistant message in the tail. */
|
||||
model?: string;
|
||||
/** The session this one was forked from: the `forkedFrom.sessionId` that
|
||||
* the SDK and CLI fork write on every copied record. */
|
||||
forkedFrom?: string;
|
||||
/** Whether the head or the tail holds a user or an assistant message. A
|
||||
* transcript without one is a metadata-only stub. */
|
||||
hasMessages: boolean;
|
||||
};
|
||||
|
||||
export type HeadTail = {
|
||||
head: string;
|
||||
/** The last 64 KB from their first whole line on; the whole file when it
|
||||
* fits in the head. */
|
||||
tail: string;
|
||||
/** The last 64 KB as the SDK searches them, with the cut line before
|
||||
* `tail`; absent when that is `tail`. */
|
||||
sdkTail?: string;
|
||||
};
|
||||
|
||||
/** Facts that an earlier scan may have found before the tail window. */
|
||||
const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt"] as const;
|
||||
|
||||
/** What an earlier scan of the same file found, at `size`. */
|
||||
export type PreviousScan = { size: number } & Pick<
|
||||
TranscriptFacts,
|
||||
"lastPromptAt" | "promptSearched" | (typeof INHERITED_FACTS)[number]
|
||||
>;
|
||||
|
||||
/** The last `window` bytes of a file of `size` bytes, without the first,
|
||||
* cut line. The whole file when it fits. */
|
||||
async function readTail(
|
||||
handle: fs.FileHandle,
|
||||
size: number,
|
||||
window: number,
|
||||
): Promise<{ raw: string; tail: string }> {
|
||||
const length = Math.min(window, size);
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
||||
const raw = buffer.toString("utf8", 0, bytesRead);
|
||||
if (length === size) return { raw, tail: raw };
|
||||
const newline = raw.indexOf("\n");
|
||||
return { raw, tail: newline >= 0 ? raw.slice(newline + 1) : "" };
|
||||
}
|
||||
|
||||
/** The first and the last 64 KB of the first `size` bytes of `filePath`:
|
||||
* what was appended after the file was stat'ed is not read, so the result
|
||||
* matches the size it is cached by. The tail is the head for a file that
|
||||
* fits in one chunk. */
|
||||
export async function readHeadTail(filePath: string, size: number): Promise<HeadTail> {
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
return await readHeadTailOf(handle, size);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link readHeadTail} of a file that is open already. */
|
||||
export async function readHeadTailOf(handle: fs.FileHandle, size: number): Promise<HeadTail> {
|
||||
const buffer = Buffer.allocUnsafe(CHUNK_SIZE);
|
||||
const first = await handle.read(buffer, 0, Math.min(CHUNK_SIZE, size), 0);
|
||||
const head = buffer.toString("utf8", 0, first.bytesRead);
|
||||
if (size <= CHUNK_SIZE) return { head, tail: head };
|
||||
const { raw, tail } = await readTail(handle, size, CHUNK_SIZE);
|
||||
return raw === tail ? { head, tail } : { head, tail, sdkTail: raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* The facts of `filePath`, from its head and tail. When the transcript has
|
||||
* messages but the tail window lacks the last message or the last user
|
||||
* prompt (a long answer or tool output follows it), the window grows,
|
||||
* reading only the new range each step, until it holds both or reaches
|
||||
* {@link MAX_TAIL_SIZE}. The result of a full search is kept per
|
||||
* `(path, size)`.
|
||||
*
|
||||
* `previous` is what an earlier scan of the same file found: when the file
|
||||
* only grew by less than the tail window, and the tail holds no prompt, the
|
||||
* appended bytes hold none either, so the earlier `lastPromptAt` stands.
|
||||
*/
|
||||
export async function scanTranscriptFile(
|
||||
filePath: string,
|
||||
size: number,
|
||||
headTail?: HeadTail,
|
||||
previous?: PreviousScan,
|
||||
/** Tells a file replaced by another of the same size apart (its inode). */
|
||||
identity?: number,
|
||||
): Promise<TranscriptFacts> {
|
||||
const read = headTail ?? (await readHeadTail(filePath, size));
|
||||
const facts = scanTranscript(read);
|
||||
// The file only grew by bytes that the tail covers, and the tail holds no
|
||||
// prompt: the appended bytes hold none either, so the earlier search
|
||||
// stands (its prompt, or that the last 4 MB held none).
|
||||
const grownWithinTail =
|
||||
previous !== undefined &&
|
||||
size >= previous.size &&
|
||||
size - Buffer.byteLength(read.tail) <= previous.size;
|
||||
if (grownWithinTail) {
|
||||
// What the earlier, possibly wider, scan found before the tail stands
|
||||
// when the tail has nothing newer.
|
||||
for (const key of INHERITED_FACTS) {
|
||||
if (facts[key] === undefined && previous[key] !== undefined) {
|
||||
(facts as Record<string, unknown>)[key] = previous[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (facts.lastPromptAt !== undefined || size <= CHUNK_SIZE) {
|
||||
facts.promptSearched = true;
|
||||
} else if (grownWithinTail && (previous.lastPromptAt !== undefined || previous.promptSearched)) {
|
||||
facts.lastPromptAt = previous.lastPromptAt;
|
||||
facts.promptSearched = true;
|
||||
}
|
||||
const complete = (found: TranscriptFacts) =>
|
||||
found.lastMessageAt !== undefined && found.promptSearched === true;
|
||||
if (!facts.hasMessages || complete(facts) || size <= CHUNK_SIZE) return facts;
|
||||
const key = `${filePath}\0${size}\0${identity ?? ""}`;
|
||||
const searched = fullySearched.get(key);
|
||||
if (searched) return searched;
|
||||
let result = facts;
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
// The bytes from `start` to the end, grown by reading the new range only.
|
||||
let start = Math.max(0, size - CHUNK_SIZE);
|
||||
const first = Buffer.allocUnsafe(size - start);
|
||||
const { bytesRead: firstRead } = await handle.read(first, 0, first.length, start);
|
||||
let bytes = first.subarray(0, firstRead);
|
||||
for (let window = CHUNK_SIZE * TAIL_GROWTH; start > 0; window *= TAIL_GROWTH) {
|
||||
const nextStart = Math.max(0, size - Math.min(window, MAX_TAIL_SIZE));
|
||||
if (nextStart >= start) break;
|
||||
const range = Buffer.allocUnsafe(start - nextStart);
|
||||
const { bytesRead } = await handle.read(range, 0, range.length, nextStart);
|
||||
bytes = Buffer.concat([range.subarray(0, bytesRead), bytes]);
|
||||
start = nextStart;
|
||||
let tail = bytes.toString("utf8");
|
||||
if (start > 0) {
|
||||
// The first line of the window is cut; drop it.
|
||||
const newline = tail.indexOf("\n");
|
||||
tail = newline >= 0 ? tail.slice(newline + 1) : "";
|
||||
}
|
||||
// A wider tail ends with the same records: what the narrow one found
|
||||
// stays, and the wider one adds what lay before it.
|
||||
const wider = scanTranscript(
|
||||
{ head: read.head, tail },
|
||||
{
|
||||
wide: true,
|
||||
known: { model: facts.model, tailCwd: facts.tailCwd },
|
||||
},
|
||||
);
|
||||
const lastPromptAt = wider.lastPromptAt ?? facts.lastPromptAt;
|
||||
result = {
|
||||
...wider,
|
||||
...(facts.model !== undefined && { model: facts.model }),
|
||||
...(facts.tailCwd !== undefined && { tailCwd: facts.tailCwd }),
|
||||
// A turn end the wider tail lacks may lie before it: the
|
||||
// narrow scan inherited it from an earlier scan of this file.
|
||||
lastTurnEndedAt: wider.lastTurnEndedAt ?? facts.lastTurnEndedAt,
|
||||
hasMessages: facts.hasMessages,
|
||||
lastPromptAt,
|
||||
// Searched to the start of the file, or found.
|
||||
promptSearched: facts.promptSearched || lastPromptAt !== undefined || start === 0,
|
||||
};
|
||||
if (complete(result)) return result;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// The whole window is searched: no prompt in it either.
|
||||
result = { ...result, promptSearched: true };
|
||||
fullySearched.set(key, result);
|
||||
if (fullySearched.size > FULL_SEARCH_CACHE_SIZE) {
|
||||
fullySearched.delete(fullySearched.keys().next().value as string);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const RELOCATED_MARKER = '"relocated"';
|
||||
|
||||
/** The cwd of the last `relocated` record of the tail: where the session
|
||||
* was moved, whatever cwd its earlier messages carry. */
|
||||
export function relocatedCwd(tail: string): string | undefined {
|
||||
const lines = tail.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
if (!line.includes(RELOCATED_MARKER)) continue;
|
||||
const entry = parseLine(line);
|
||||
if (entry?.type === "relocated" && typeof entry.relocatedCwd === "string") {
|
||||
return entry.relocatedCwd;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The cwd a transcript belongs to, as the SDK reads it: the last
|
||||
* `relocated` record of the tail, else the first cwd of the head. */
|
||||
export function transcriptProjectCwd({ head, tail }: HeadTail): string | undefined {
|
||||
const relocated = relocatedCwd(tail);
|
||||
if (relocated !== undefined) return relocated;
|
||||
const headCwd = CWD_PATTERN.exec(head)?.[1];
|
||||
return headCwd === undefined ? undefined : decodeJsonString(headCwd);
|
||||
}
|
||||
|
||||
const MESSAGE_MARKERS = [
|
||||
'"type":"user"',
|
||||
'"type":"assistant"',
|
||||
'"type": "user"',
|
||||
'"type": "assistant"',
|
||||
];
|
||||
|
||||
/** System records that the CLI writes when a turn ends. */
|
||||
const TURN_END_SYSTEM_SUBTYPES = new Set(["stop_hook_summary", "turn_duration"]);
|
||||
|
||||
const INTERRUPT_PREFIX = "[Request interrupted by user";
|
||||
|
||||
type Entry = Record<string, unknown>;
|
||||
|
||||
function parseLine(line: string): Entry | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(line);
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Entry)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function timestampOf(entry: Entry): number | undefined {
|
||||
if (typeof entry.timestamp !== "string") return undefined;
|
||||
const value = Date.parse(entry.timestamp);
|
||||
return Number.isNaN(value) ? undefined : value;
|
||||
}
|
||||
|
||||
function firstText(entry: Entry): string | undefined {
|
||||
const message = entry.message as { content?: unknown } | undefined;
|
||||
const content = message?.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a main-chain record bears on the end of the turn: `finished` for a
|
||||
* record that ends a turn (an assistant `end_turn`, an API error, a user
|
||||
* interrupt, the CLI's turn-end system records), `unfinished` for a record of
|
||||
* a turn in progress, and undefined for a record that says nothing.
|
||||
*/
|
||||
export function turnEffect(entry: Entry): TurnState | undefined {
|
||||
if (entry.isSidechain === true) return undefined;
|
||||
switch (entry.type) {
|
||||
case "assistant": {
|
||||
if (entry.isApiErrorMessage === true) return "finished";
|
||||
const stopReason = (entry.message as { stop_reason?: unknown } | undefined)?.stop_reason;
|
||||
return stopReason === "end_turn" ? "finished" : "unfinished";
|
||||
}
|
||||
case "user": {
|
||||
if (entry.isMeta === true) return undefined;
|
||||
return firstText(entry)?.startsWith(INTERRUPT_PREFIX) ? "finished" : "unfinished";
|
||||
}
|
||||
case "system":
|
||||
return TURN_END_SYSTEM_SUBTYPES.has(entry.subtype as string) ? "finished" : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const FORKED_FROM_PATTERN =
|
||||
/"forkedFrom":\s?\{"sessionId":\s?"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"/;
|
||||
|
||||
const CWD_PATTERN = /"cwd":\s?"((?:[^"\\]|\\.)*)"/;
|
||||
|
||||
function decodeJsonString(raw: string): string | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(`"${raw}"`);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The facts of a transcript, from its head and its tail. */
|
||||
export function scanTranscript(
|
||||
{ head, tail }: HeadTail,
|
||||
options: { wide?: boolean; known?: { model?: string; tailCwd?: string } } = {},
|
||||
): TranscriptFacts {
|
||||
const facts: TranscriptFacts = {
|
||||
hasMessages: MESSAGE_MARKERS.some((marker) => head.includes(marker) || tail.includes(marker)),
|
||||
};
|
||||
const headCwd = CWD_PATTERN.exec(head)?.[1];
|
||||
if (headCwd !== undefined) facts.headCwd = decodeJsonString(headCwd);
|
||||
const forkedFrom = FORKED_FROM_PATTERN.exec(head)?.[1];
|
||||
if (forkedFrom !== undefined) facts.forkedFrom = forkedFrom;
|
||||
|
||||
const lines = tail.split("\n");
|
||||
let turnEndFound = false;
|
||||
let messageFound = false;
|
||||
let promptFound = false;
|
||||
/** The last turn ended: is its end an API error? */
|
||||
let checkingTurnError = false;
|
||||
// A wide tail (see scanTranscriptFile) is searched for what the narrow
|
||||
// one lacked: the last message, prompt and turn end, and the model and
|
||||
// the cwd only when the narrow tail had none (its records can all be cut,
|
||||
// e.g. one large tool result), so a long answer is not parsed line by line.
|
||||
const wide = options.wide === true;
|
||||
let modelFound = wide && options.known?.model !== undefined;
|
||||
const searchCwd = !wide || options.known?.tailCwd === undefined;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
if (!line) continue;
|
||||
const isTurnRecord =
|
||||
line.includes('"user"') || line.includes('"assistant"') || line.includes('"system"');
|
||||
const hasCwd = searchCwd && facts.tailCwd === undefined && line.includes('"cwd"');
|
||||
if (!isTurnRecord && !hasCwd) continue;
|
||||
// Everything has been found: stop parsing.
|
||||
if (
|
||||
turnEndFound &&
|
||||
messageFound &&
|
||||
promptFound &&
|
||||
modelFound &&
|
||||
!checkingTurnError &&
|
||||
!hasCwd
|
||||
) {
|
||||
break;
|
||||
}
|
||||
// Once the last message and the turn state are known, only a line that
|
||||
// can still tell something is parsed: lines are often large (tool
|
||||
// results, long answers), and a long tail has thousands of them.
|
||||
if (
|
||||
messageFound &&
|
||||
facts.turnState !== undefined &&
|
||||
!hasCwd &&
|
||||
!(!promptFound && mayBePrompt(line)) &&
|
||||
!(!modelFound && hasType(line, "assistant")) &&
|
||||
!(!turnEndFound && mayEndTurn(line)) &&
|
||||
!(checkingTurnError && isTurnRecord)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const entry = parseLine(line);
|
||||
if (!entry) continue;
|
||||
if (hasCwd && typeof entry.cwd === "string" && entry.cwd) facts.tailCwd = entry.cwd;
|
||||
if (entry.isSidechain === true) continue;
|
||||
if (!messageFound && (entry.type === "user" || entry.type === "assistant")) {
|
||||
messageFound = true;
|
||||
facts.lastMessageAt = timestampOf(entry);
|
||||
}
|
||||
if (!promptFound && entry.type === "user" && isUserPrompt(entry)) {
|
||||
promptFound = true;
|
||||
facts.lastPromptAt = timestampOf(entry);
|
||||
}
|
||||
if (!modelFound && entry.type === "assistant") {
|
||||
const model = (entry.message as { model?: unknown } | undefined)?.model;
|
||||
// The CLI marks the messages it makes up (API errors) `<synthetic>`.
|
||||
if (typeof model === "string" && model && model !== "<synthetic>") {
|
||||
modelFound = true;
|
||||
facts.model = model;
|
||||
}
|
||||
}
|
||||
const effect = turnEffect(entry);
|
||||
if (effect === undefined) continue;
|
||||
if (facts.turnState === undefined) {
|
||||
facts.turnState = effect;
|
||||
checkingTurnError = effect === "finished";
|
||||
}
|
||||
if (checkingTurnError) {
|
||||
// The record that ended the last turn, behind the CLI's turn-end
|
||||
// system records: an API error is an error.
|
||||
if (entry.type === "assistant" && entry.isApiErrorMessage === true) {
|
||||
facts.lastTurnError = true;
|
||||
checkingTurnError = false;
|
||||
} else if (entry.type !== "system") {
|
||||
checkingTurnError = false;
|
||||
}
|
||||
}
|
||||
if (effect === "finished" && !turnEndFound) {
|
||||
turnEndFound = true;
|
||||
facts.lastTurnEndedAt = timestampOf(entry);
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function hasType(line: string, type: string): boolean {
|
||||
return line.includes(`"type":"${type}"`) || line.includes(`"type": "${type}"`);
|
||||
}
|
||||
|
||||
/** Whether a line may be a user prompt: a user record that is no tool
|
||||
* result, or one that carries an image or a document. */
|
||||
function mayBePrompt(line: string): boolean {
|
||||
if (!hasType(line, "user")) return false;
|
||||
return !hasType(line, "tool_result") || hasType(line, "image") || hasType(line, "document");
|
||||
}
|
||||
|
||||
/** Whether a line may end a turn (see {@link turnEffect}). */
|
||||
function mayEndTurn(line: string): boolean {
|
||||
return (
|
||||
line.includes('"end_turn"') ||
|
||||
line.includes('"isApiErrorMessage"') ||
|
||||
line.includes(INTERRUPT_PREFIX) ||
|
||||
[...TURN_END_SYSTEM_SUBTYPES].some((subtype) => line.includes(`"${subtype}"`))
|
||||
);
|
||||
}
|
||||
|
||||
/** Every `"key":"value"` string of `text`, in order, decoded. */
|
||||
function stringFields(text: string, key: string): string[] {
|
||||
const pattern = new RegExp(`"${key}":\\s?"((?:[^"\\\\]|\\\\.)*)"`, "g");
|
||||
const values: string[] = [];
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const value = decodeJsonString(match[1]!);
|
||||
if (value !== undefined) values.push(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
const lastField = (text: string, key: string) => stringFields(text, key).at(-1) || undefined;
|
||||
|
||||
/** Whether the first record of the transcript is a sidechain one, which the
|
||||
* SDK never lists. */
|
||||
export function isSidechainTranscript(head: string): boolean {
|
||||
const newline = head.indexOf("\n");
|
||||
const first = newline >= 0 ? head.slice(0, newline) : head;
|
||||
return first.includes('"isSidechain":true') || first.includes('"isSidechain": true');
|
||||
}
|
||||
|
||||
/** The last `customTitle` of the tail, as the SDK reads it. */
|
||||
export function tailCustomTitle({ tail, sdkTail }: HeadTail): string | undefined {
|
||||
return lastField(sdkTail ?? tail, "customTitle");
|
||||
}
|
||||
|
||||
/** The last non-blank top-level `agentName` of the records of `text`, the
|
||||
* name `/rename`, a `rename_session` and AIR write with the custom title. */
|
||||
function lastAgentName(text: string): string | undefined {
|
||||
if (!text.includes('"agentName"')) return undefined;
|
||||
const lines = text.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
if (!line.includes('"agentName"')) continue;
|
||||
const value = parseLine(line)?.agentName;
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The last agent name of a transcript, as AIR's native Claude provider
|
||||
* reads it: the top-level `agentName` of any record, from the tail, else
|
||||
* from the head. */
|
||||
export function transcriptAgentName({ head, tail }: HeadTail): string | undefined {
|
||||
return lastAgentName(tail) ?? lastAgentName(head);
|
||||
}
|
||||
|
||||
/** What the SDK reports as the titles of a session. */
|
||||
export type SdkTitles = {
|
||||
/** `SDKSessionInfo.customTitle`: the title the CLI or a client set. */
|
||||
customTitle?: string;
|
||||
/** `SDKSessionInfo.summary`: the custom title, else a generated one. */
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The titles the SDK gives a transcript, from its own head and tail, in the
|
||||
* SDK's order: custom title (tail, the sidecar, head), AI title, then
|
||||
* {@link generatedTitle}: what the SDK `getSessionInfo` reports for this
|
||||
* file, without its lookup of the file. The sidecar title counts only when
|
||||
* the tail has no custom title.
|
||||
*/
|
||||
export function sdkTitles(
|
||||
{ head, tail: wholeLines, sdkTail }: HeadTail,
|
||||
sidecarTitle?: string,
|
||||
): SdkTitles {
|
||||
const tail = sdkTail ?? wholeLines;
|
||||
const customTitle =
|
||||
lastField(tail, "customTitle") ??
|
||||
sidecarTitle ??
|
||||
lastField(head, "customTitle") ??
|
||||
lastField(tail, "aiTitle") ??
|
||||
lastField(head, "aiTitle");
|
||||
return { customTitle, summary: customTitle ?? generatedTitle({ head, tail }) };
|
||||
}
|
||||
|
||||
/** The title of a transcript without a custom title, in the SDK's order: AI
|
||||
* title, last prompt, summary, first prompt. */
|
||||
export function generatedTitle({ head, tail }: HeadTail): string | undefined {
|
||||
return (
|
||||
lastField(tail, "aiTitle") ??
|
||||
lastField(head, "aiTitle") ??
|
||||
lastField(tail, "lastPrompt") ??
|
||||
lastField(tail, "summary") ??
|
||||
(firstPrompt(head) || mediaPrompt(head) || undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/** The time of the last `"timestamp"` in the last 64 KB of a file, cut
|
||||
* line included, epoch ms: a record longer than that still ends with it. */
|
||||
export async function lastTimestamp(filePath: string, size: number): Promise<number | undefined> {
|
||||
const handle = await fs.open(filePath, "r");
|
||||
let text: string;
|
||||
try {
|
||||
const length = Math.min(size, CHUNK_SIZE);
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
||||
text = buffer.toString("utf8", 0, bytesRead);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
for (const raw of stringFields(text, "timestamp").reverse()) {
|
||||
const value = Date.parse(raw);
|
||||
if (!Number.isNaN(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const CONTINUED_IN_MARKER = '"type":"continued-in"';
|
||||
|
||||
/**
|
||||
* The session this transcript was continued in, as the SDK list reads it: the
|
||||
* last `continued-in` record of the tail, unless a completed turn follows it
|
||||
* (the session was resumed here again).
|
||||
*/
|
||||
export function continuedInSessionId(tail: string): string | undefined {
|
||||
if (!tail.includes(CONTINUED_IN_MARKER)) return undefined;
|
||||
const lines = tail.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
const isContinuation = line.includes(CONTINUED_IN_MARKER);
|
||||
const isMessage = line.includes('"type":"user"') || line.includes('"type":"assistant"');
|
||||
if (!isContinuation && !isMessage) continue;
|
||||
const entry = parseLine(line);
|
||||
if (!entry) continue;
|
||||
if (isContinuation && entry.type === "continued-in") {
|
||||
const id = entry.continuedInSessionId;
|
||||
return typeof id === "string" && SESSION_ID.test(id) ? id : undefined;
|
||||
}
|
||||
if (entry.type === "assistant") {
|
||||
const stopReason = (entry.message as { stop_reason?: unknown } | undefined)?.stop_reason;
|
||||
if (entry.isApiErrorMessage !== true && typeof stopReason === "string") return undefined;
|
||||
} else if (promptOf(entry, { commandFallback: "" }) !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const HISTORY_MARKER = '"parentUuid":';
|
||||
const HISTORY_SCAN_LIMIT = 16 * 1024 * 1024;
|
||||
const HISTORY_SCAN_CHUNK = 1024 * 1024;
|
||||
|
||||
/** Whether the transcript at `filePath` holds conversation history (a record
|
||||
* with a `parentUuid`), as the SDK checks a continuation's successor. */
|
||||
export async function hasHistory(filePath: string): Promise<boolean> {
|
||||
let handle: fs.FileHandle;
|
||||
try {
|
||||
handle = await fs.open(filePath, "r");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
const buffer = Buffer.allocUnsafe(HISTORY_SCAN_CHUNK + HISTORY_MARKER.length);
|
||||
let carry = 0;
|
||||
for (let offset = 0; offset < Math.min(size, HISTORY_SCAN_LIMIT);) {
|
||||
const { bytesRead } = await handle.read(buffer, carry, HISTORY_SCAN_CHUNK, offset);
|
||||
if (bytesRead === 0) return false;
|
||||
const end = carry + bytesRead;
|
||||
if (buffer.subarray(0, end).includes(HISTORY_MARKER)) return true;
|
||||
carry = Math.min(HISTORY_MARKER.length, end);
|
||||
buffer.copyWithin(0, end - carry, end);
|
||||
offset += bytesRead;
|
||||
}
|
||||
// Past the scan limit the SDK assumes history.
|
||||
return size > HISTORY_SCAN_LIMIT;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* The git worktrees of a working directory, read from the repository files.
|
||||
*
|
||||
* The SDK `listSessions({ dir })` includes the sessions of every worktree of
|
||||
* the repository and runs `git worktree list` for that on each call. This
|
||||
* module reads the same list without spawning git, the way the Codex TUI does:
|
||||
* the main worktree is the parent of the common `.git` directory, and each
|
||||
* linked worktree is named by `<common-dir>/worktrees/<name>/gitdir`.
|
||||
* A worktree whose directory no longer exists is dropped. Nothing is cached:
|
||||
* `git worktree move` rewrites a `gitdir` file without touching the
|
||||
* `worktrees` directory, so a cache keyed on it would miss the move.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { normalizePath } from "./project-dirs.js";
|
||||
|
||||
async function statOrUndefined(target: string) {
|
||||
try {
|
||||
return await fs.stat(target);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The git dir of the repository that contains `cwd`, or undefined. */
|
||||
async function findGitDir(cwd: string): Promise<string | undefined> {
|
||||
let current = path.resolve(cwd);
|
||||
for (;;) {
|
||||
const candidate = path.join(current, ".git");
|
||||
const stats = await statOrUndefined(candidate);
|
||||
if (stats?.isDirectory()) return candidate;
|
||||
if (stats?.isFile()) {
|
||||
try {
|
||||
const text = await fs.readFile(candidate, "utf8");
|
||||
const match = /^gitdir:\s*(.+)$/m.exec(text);
|
||||
if (match) return path.resolve(current, match[1]!.trim());
|
||||
} catch {
|
||||
// An unreadable `.git` file is not a repository.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
async function commonDirOf(gitDir: string): Promise<string> {
|
||||
try {
|
||||
const text = await fs.readFile(path.join(gitDir, "commondir"), "utf8");
|
||||
const value = text.trim();
|
||||
if (value) return path.resolve(gitDir, value);
|
||||
} catch {
|
||||
// No `commondir`: the git dir is the common dir.
|
||||
}
|
||||
return gitDir;
|
||||
}
|
||||
|
||||
async function linkedWorktrees(commonDir: string): Promise<string[]> {
|
||||
const worktreesDir = path.join(commonDir, "worktrees");
|
||||
const stats = await statOrUndefined(worktreesDir);
|
||||
if (!stats?.isDirectory()) return [];
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(worktreesDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
try {
|
||||
const text = await fs.readFile(path.join(worktreesDir, name, "gitdir"), "utf8");
|
||||
const gitFile = text.trim();
|
||||
return gitFile ? path.dirname(path.resolve(worktreesDir, name, gitFile)) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((value): value is string => value !== undefined);
|
||||
}
|
||||
|
||||
/** The root of the worktree that contains `cwd`: the directory of its
|
||||
* `.git` (a directory in the main worktree, a file in a linked one). */
|
||||
async function worktreeRootOf(cwd: string): Promise<string | undefined> {
|
||||
let current = path.resolve(cwd);
|
||||
for (;;) {
|
||||
if (await statOrUndefined(path.join(current, ".git"))) return current;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same subdirectory as `cwd` in every existing worktree of its
|
||||
* repository (the main one and the linked ones), as the Codex TUI expands a
|
||||
* cwd: for `/repo/packages/a`, `/wt1/packages/a` if it exists. For a cwd at a
|
||||
* worktree root, the worktree roots. Empty outside a repository.
|
||||
*/
|
||||
export async function worktreeCounterparts(cwd: string): Promise<string[]> {
|
||||
const root = await worktreeRootOf(cwd);
|
||||
if (!root) return [];
|
||||
const relative = path.relative(root, path.resolve(cwd));
|
||||
const candidates = (await repositoryWorktrees(cwd)).map((worktree) =>
|
||||
relative ? path.join(worktree, relative) : worktree,
|
||||
);
|
||||
const existing = await Promise.all(
|
||||
candidates.map(async (candidate) =>
|
||||
(await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined,
|
||||
),
|
||||
);
|
||||
return [...new Set(existing.filter((value): value is string => value !== undefined))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every existing worktree of the repository that contains `cwd`: the main
|
||||
* worktree and the linked ones. Empty outside a repository.
|
||||
*/
|
||||
export async function repositoryWorktrees(cwd: string): Promise<string[]> {
|
||||
const gitDir = await findGitDir(cwd);
|
||||
if (!gitDir) return [];
|
||||
const commonDir = await commonDirOf(gitDir);
|
||||
const candidates: string[] = [];
|
||||
if (path.basename(commonDir) === ".git") candidates.push(path.dirname(commonDir));
|
||||
candidates.push(...(await linkedWorktrees(commonDir)));
|
||||
const existing = await Promise.all(
|
||||
candidates.map(async (candidate) =>
|
||||
(await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined,
|
||||
),
|
||||
);
|
||||
return [...new Set(existing.filter((value): value is string => value !== undefined))];
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import {
|
||||
RequestError,
|
||||
type SessionConfigOption,
|
||||
type SessionModeState,
|
||||
type SessionNotification,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
ModelInfo,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
Query,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_KIND_KEY, airOnlyMeta } from "./air-extension.js";
|
||||
import { resolvePermissionMode } from "./permissions/modes.js";
|
||||
import {
|
||||
noticeOrTranscriptUpdate,
|
||||
noticeTranscriptText,
|
||||
sentenceCase,
|
||||
type SessionNotice,
|
||||
} from "./session-notices.js";
|
||||
|
||||
export const MODE_CONFIG_ID = "mode";
|
||||
export const AUTO_MODE_FALLBACK: PermissionMode = "acceptEdits";
|
||||
|
||||
/** Reads on after the bold label in the transcript line; capitalized when it
|
||||
* stands alone as a notice description. */
|
||||
const AUTO_MODE_FALLBACK_REASON =
|
||||
"the selected model does not support Auto mode; using Accept edits instead.";
|
||||
const AUTO_MODE_FALLBACK_NOTICE: SessionNotice = {
|
||||
severity: "warning",
|
||||
title: "Auto mode unavailable",
|
||||
description: sentenceCase(AUTO_MODE_FALLBACK_REASON),
|
||||
};
|
||||
|
||||
export type SessionMode = {
|
||||
query: Pick<Query, "setPermissionMode">;
|
||||
queryClosed?: boolean;
|
||||
modes: SessionModeState;
|
||||
models: { currentModelId: string };
|
||||
modelInfos: ModelInfo[];
|
||||
/** Prevents the model-specific Auto fallback from spamming the transcript. */
|
||||
autoModeFallbackWarningShown?: boolean;
|
||||
/** Initial mode fallback is reported after session/new, on the first prompt. */
|
||||
autoModeFallbackWarningPending?: boolean;
|
||||
/** The mode the session left when it entered plan mode, if it is in plan. */
|
||||
prePlanMode?: string;
|
||||
};
|
||||
|
||||
export type SessionModeManagerOptions<S extends SessionMode> = {
|
||||
getSession(sessionId: string): S | undefined;
|
||||
sessionEndedMessage: string;
|
||||
updateConfigOption(sessionId: string, configId: string, value: string): Promise<void>;
|
||||
sessionUpdate(params: SessionNotification): Promise<void>;
|
||||
/** Whether the client can present `notice` updates; the fallback warning
|
||||
* is a transcript line otherwise. Read per call: capabilities are only
|
||||
* known after `initialize`. */
|
||||
supportsNotices?(): boolean;
|
||||
logError(...args: unknown[]): void;
|
||||
/** Whether the client is AIR. Only AIR gets the mode kind, under `_meta.jetbrains.air`. */
|
||||
airClient?(): boolean;
|
||||
};
|
||||
|
||||
type ModeConfigSession = SessionMode & {
|
||||
configOptions: SessionConfigOption[];
|
||||
};
|
||||
|
||||
type InitializeSessionModeParams = {
|
||||
query: Pick<Query, "setPermissionMode">;
|
||||
requestedMode: PermissionMode;
|
||||
currentModelInfo?: ModelInfo;
|
||||
currentModelId: string;
|
||||
/** When false, bypassPermissions is omitted from the mode catalog. */
|
||||
allowBypass: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission mode a new Claude query starts in.
|
||||
*
|
||||
* An explicit mode wins. A resume or a load continues in the mode of the last
|
||||
* user record of the transcript. If that mode is unknown or not available now,
|
||||
* for example bypass, the session starts in `defaultMode` of the settings.
|
||||
*/
|
||||
export function resolveInitialPermissionMode(
|
||||
sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown },
|
||||
logger: { error: (...args: unknown[]) => void },
|
||||
allowBypass: boolean,
|
||||
): PermissionMode {
|
||||
if (sources.explicit !== undefined) {
|
||||
return resolvePermissionMode(sources.explicit, logger, allowBypass);
|
||||
}
|
||||
if (sources.resumed !== undefined) {
|
||||
let rejected = false;
|
||||
const mode = resolvePermissionMode(
|
||||
sources.resumed,
|
||||
{ error: () => (rejected = true) },
|
||||
allowBypass,
|
||||
);
|
||||
if (!rejected) return mode;
|
||||
logger.error(
|
||||
`Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` +
|
||||
`not available in this session.`,
|
||||
);
|
||||
}
|
||||
return resolvePermissionMode(sources.defaultMode, logger, allowBypass);
|
||||
}
|
||||
|
||||
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
|
||||
export class SessionModeManager<S extends SessionMode> {
|
||||
constructor(private readonly options: SessionModeManagerOptions<S>) {}
|
||||
|
||||
async initialize({
|
||||
query,
|
||||
requestedMode,
|
||||
currentModelInfo,
|
||||
currentModelId,
|
||||
allowBypass,
|
||||
}: InitializeSessionModeParams): Promise<{
|
||||
modes: SessionModeState;
|
||||
autoModeFallbackWarningPending: boolean;
|
||||
}> {
|
||||
const availableModes = this.buildAvailableModes(allowBypass);
|
||||
let effectiveMode = requestedMode;
|
||||
let autoModeFallbackWarningPending = false;
|
||||
|
||||
if (
|
||||
effectiveMode === "auto" &&
|
||||
currentModelInfo !== undefined &&
|
||||
currentModelInfo.supportsAutoMode !== true
|
||||
) {
|
||||
this.options.logError(
|
||||
`permissions.defaultMode "auto" is not available for model ` +
|
||||
`"${currentModelId}"; falling back to "${AUTO_MODE_FALLBACK}".`,
|
||||
);
|
||||
effectiveMode = AUTO_MODE_FALLBACK;
|
||||
autoModeFallbackWarningPending = true;
|
||||
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
|
||||
} else if (!this.isAvailable(availableModes, effectiveMode)) {
|
||||
this.options.logError(
|
||||
`permissions.defaultMode "${effectiveMode}" is not available in ` +
|
||||
`this session; falling back to "default".`,
|
||||
);
|
||||
effectiveMode = "default";
|
||||
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
|
||||
}
|
||||
|
||||
return {
|
||||
modes: { currentModeId: effectiveMode, availableModes },
|
||||
autoModeFallbackWarningPending,
|
||||
};
|
||||
}
|
||||
|
||||
static configOption(modes: SessionModeState): SessionConfigOption {
|
||||
return {
|
||||
id: MODE_CONFIG_ID,
|
||||
name: "Mode",
|
||||
description: "Session permission mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: modes.currentModeId,
|
||||
options: modes.availableModes.map((mode) => ({
|
||||
value: mode.id,
|
||||
name: mode.name,
|
||||
description: mode.description,
|
||||
_meta: mode._meta,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
syncConfig(session: ModeConfigSession, mode: string): void {
|
||||
const previousMode = session.modes.currentModeId;
|
||||
if (mode !== "plan") {
|
||||
session.prePlanMode = undefined;
|
||||
} else if (previousMode !== "plan") {
|
||||
session.prePlanMode = previousMode;
|
||||
}
|
||||
session.modes = { ...session.modes, currentModeId: mode };
|
||||
session.configOptions = session.configOptions.map((option) =>
|
||||
option.id === MODE_CONFIG_ID && typeof option.currentValue === "string"
|
||||
? { ...option, currentValue: mode }
|
||||
: option,
|
||||
);
|
||||
}
|
||||
|
||||
availableModeIds(modes: SessionModeState): string[] {
|
||||
return modes.availableModes.map((mode) => mode.id);
|
||||
}
|
||||
|
||||
effectiveMode(session: SessionMode, requestedMode: PermissionMode): PermissionMode {
|
||||
return requestedMode === "auto" && this.isAutoUnavailable(session)
|
||||
? AUTO_MODE_FALLBACK
|
||||
: requestedMode;
|
||||
}
|
||||
|
||||
applyPermissionFallback(
|
||||
session: SessionMode,
|
||||
permissionResult: PermissionResult,
|
||||
): { permissionResult: PermissionResult; fallbackApplied: boolean } {
|
||||
const fallbackApplied =
|
||||
this.isAutoUnavailable(session) &&
|
||||
permissionResult.behavior === "allow" &&
|
||||
permissionResult.updatedPermissions?.some(
|
||||
(update) => update.type === "setMode" && update.mode === "auto",
|
||||
) === true;
|
||||
if (!fallbackApplied || permissionResult.behavior !== "allow") {
|
||||
return { permissionResult, fallbackApplied: false };
|
||||
}
|
||||
|
||||
return {
|
||||
permissionResult: {
|
||||
...permissionResult,
|
||||
updatedPermissions: permissionResult.updatedPermissions?.map((update) =>
|
||||
update.type === "setMode" && update.mode === "auto"
|
||||
? { ...update, mode: AUTO_MODE_FALLBACK }
|
||||
: update,
|
||||
),
|
||||
},
|
||||
fallbackApplied: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reconcile mode after a model switch. The caller rebuilds config options
|
||||
* before publishing the returned state change. */
|
||||
async reconcileForModel(session: SessionMode, model: ModelInfo | undefined) {
|
||||
if (
|
||||
session.modes.currentModeId !== "auto" ||
|
||||
model === undefined ||
|
||||
model.supportsAutoMode === true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
session.modes = {
|
||||
availableModes: session.modes.availableModes,
|
||||
currentModeId: AUTO_MODE_FALLBACK,
|
||||
};
|
||||
await this.trySyncMode(
|
||||
session.query,
|
||||
AUTO_MODE_FALLBACK,
|
||||
`Failed to sync permissionMode to "${AUTO_MODE_FALLBACK}" after model switch invalidated "auto":`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async selectMode(sessionId: string, modeId: string): Promise<PermissionMode> {
|
||||
const session = this.requireOpenSession(sessionId);
|
||||
const requestedMode = this.parseMode(modeId);
|
||||
if (!this.isAvailable(session.modes.availableModes, requestedMode)) {
|
||||
throw new Error(`Mode ${modeId} is not available in this session`);
|
||||
}
|
||||
|
||||
const effectiveMode = this.effectiveMode(session, requestedMode);
|
||||
try {
|
||||
await session.query.setPermissionMode(effectiveMode);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (!error.message) error.message = "Invalid Mode";
|
||||
throw error;
|
||||
}
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error("Invalid Mode");
|
||||
}
|
||||
|
||||
if (effectiveMode !== requestedMode) {
|
||||
await this.publishFallbackWarning(sessionId, session);
|
||||
}
|
||||
return effectiveMode;
|
||||
}
|
||||
|
||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
||||
const effectiveMode = await this.selectMode(params.sessionId, params.modeId);
|
||||
if (effectiveMode !== params.modeId) {
|
||||
await this.publishCurrent(params.sessionId, effectiveMode);
|
||||
}
|
||||
await this.options.updateConfigOption(params.sessionId, MODE_CONFIG_ID, effectiveMode);
|
||||
return {};
|
||||
}
|
||||
|
||||
async publishCurrent(sessionId: string, mode: string): Promise<void> {
|
||||
await this.options.sessionUpdate({
|
||||
sessionId,
|
||||
update: { sessionUpdate: "current_mode_update", currentModeId: mode },
|
||||
});
|
||||
}
|
||||
|
||||
async publishFallbackWarning(sessionId: string, session: SessionMode): Promise<void> {
|
||||
session.autoModeFallbackWarningPending = false;
|
||||
if (session.autoModeFallbackWarningShown) return;
|
||||
session.autoModeFallbackWarningShown = true;
|
||||
try {
|
||||
await this.options.sessionUpdate({
|
||||
sessionId,
|
||||
update: noticeOrTranscriptUpdate(
|
||||
AUTO_MODE_FALLBACK_NOTICE,
|
||||
this.options.supportsNotices?.() ?? false,
|
||||
noticeTranscriptText({
|
||||
...AUTO_MODE_FALLBACK_NOTICE,
|
||||
description: AUTO_MODE_FALLBACK_REASON,
|
||||
}),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
// The fallback has already been applied; a failed advisory must not turn
|
||||
// the successful mode change into a failed request.
|
||||
this.options.logError(`Failed to publish Auto mode fallback warning: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async publishFallbackState(sessionId: string, session: SessionMode): Promise<void> {
|
||||
await this.publishCurrent(sessionId, AUTO_MODE_FALLBACK);
|
||||
await this.publishFallbackWarning(sessionId, session);
|
||||
}
|
||||
|
||||
private requireOpenSession(sessionId: string): S {
|
||||
const session = this.options.getSession(sessionId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (session.queryClosed) {
|
||||
throw RequestError.internalError(undefined, this.options.sessionEndedMessage);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private isAutoUnavailable(
|
||||
session: SessionMode,
|
||||
modelId = session.models.currentModelId,
|
||||
): boolean {
|
||||
const modelInfo = session.modelInfos.find((model) => model.value === modelId);
|
||||
return modelInfo !== undefined && modelInfo.supportsAutoMode !== true;
|
||||
}
|
||||
|
||||
private isAvailable(availableModes: SessionModeState["availableModes"], modeId: string): boolean {
|
||||
return availableModes.some((mode) => mode.id === modeId);
|
||||
}
|
||||
|
||||
private parseMode(modeId: string): PermissionMode {
|
||||
switch (modeId) {
|
||||
case "auto":
|
||||
case "default":
|
||||
case "acceptEdits":
|
||||
case "bypassPermissions":
|
||||
case "dontAsk":
|
||||
case "plan":
|
||||
return modeId;
|
||||
default:
|
||||
throw new Error("Invalid Mode");
|
||||
}
|
||||
}
|
||||
|
||||
private buildAvailableModes(allowBypass: boolean): SessionModeState["availableModes"] {
|
||||
// Only AIR gets the mode kind.
|
||||
const air = this.options.airClient?.() ?? false;
|
||||
const kind = (value: string) => {
|
||||
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
|
||||
return meta ? { _meta: meta } : {};
|
||||
};
|
||||
const modes: SessionModeState["availableModes"] = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
...kind("plan"),
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
...kind("auto_review"),
|
||||
},
|
||||
];
|
||||
if (allowBypass) {
|
||||
modes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
...kind("full_access"),
|
||||
});
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
private async trySyncMode(
|
||||
query: Pick<Query, "setPermissionMode">,
|
||||
mode: PermissionMode,
|
||||
errorMessage: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await query.setPermissionMode(mode);
|
||||
} catch (error) {
|
||||
this.options.logError(errorMessage, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo, Query, Settings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
|
||||
export const MODEL_CONFIG_ID = "model";
|
||||
|
||||
export type SessionModelState = {
|
||||
availableModels: Array<{ modelId: string; name: string; description?: string }>;
|
||||
currentModelId: string;
|
||||
};
|
||||
|
||||
type ModelLogger = {
|
||||
log: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
type ModelSettingsSource = { getSettings(): Settings };
|
||||
|
||||
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
|
||||
const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
|
||||
const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
|
||||
const VERSIONABLE_MODEL_DISPLAY_PATTERN =
|
||||
/^(?:Claude\s+)?([a-z][a-z0-9-]*)(?:\s+\d+(?:\.\d+)?)?(?:\s+\(\d+[mk]\s+context\))?$/i;
|
||||
const DISPLAY_CONTEXT_SUFFIX_PATTERN = /\s+\((\d+[mk])\s+context\)$/i;
|
||||
|
||||
function stripContextHints(value: string): string {
|
||||
return value.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
|
||||
}
|
||||
|
||||
function canonicalizeModelId(value: string): string {
|
||||
return value.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
|
||||
}
|
||||
|
||||
function contextHintOf(value: string): string | null {
|
||||
return canonicalizeModelId(value).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function extractModelFamilyVersion(value: string): string | null {
|
||||
const match = stripContextHints(value).match(MODEL_FAMILY_VERSION_PATTERN);
|
||||
if (!match) return null;
|
||||
return match[2] ? `${match[1]}.${match[2]}` : match[1];
|
||||
}
|
||||
|
||||
function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean {
|
||||
const preferred = extractModelFamilyVersion(preference);
|
||||
if (!preferred) return true;
|
||||
const candidateVersion =
|
||||
extractModelFamilyVersion(candidate.value) ??
|
||||
extractModelFamilyVersion(candidate.displayName) ??
|
||||
extractModelFamilyVersion(candidate.description);
|
||||
return candidateVersion ? preferred === candidateVersion : true;
|
||||
}
|
||||
|
||||
function displayModelVersion(model: ModelInfo, family: string): string | undefined {
|
||||
const familyVersion = new RegExp(`\\b${family}[-\\s]+(\\d+)(?:[-.](\\d+))?`, "i");
|
||||
// The display name is the value we are deciding whether to rewrite, so it
|
||||
// cannot validate its own normalization. Require independent SDK metadata.
|
||||
for (const source of [model.resolvedModel, model.description, model.value]) {
|
||||
const match = source?.match(familyVersion);
|
||||
if (match) return match[2] ? `${match[1]}.${match[2]}` : match[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Add concrete versions when a terse SDK label names the same family as the
|
||||
* underlying model metadata. Multi-word/custom labels remain untouched.
|
||||
* Context size remains in the option description; if normalization would
|
||||
* collide, callers fall back to the original labels. */
|
||||
function versionedModelDisplayName(model: ModelInfo): string {
|
||||
const versionable = model.displayName.match(VERSIONABLE_MODEL_DISPLAY_PATTERN);
|
||||
if (!versionable) return model.displayName;
|
||||
const version = displayModelVersion(model, versionable[1]);
|
||||
if (!version) return model.displayName;
|
||||
const contextSuffix = model.displayName.match(DISPLAY_CONTEXT_SUFFIX_PATTERN);
|
||||
const descriptionKeepsContext =
|
||||
contextSuffix !== null &&
|
||||
new RegExp(`\\b${contextSuffix[1]}\\s+context\\b`, "i").test(model.description);
|
||||
const withoutContext = model.displayName.replace(DISPLAY_CONTEXT_SUFFIX_PATTERN, "");
|
||||
if (/\b\d+(?:\.\d+)?\b/.test(withoutContext)) {
|
||||
return descriptionKeepsContext ? withoutContext : model.displayName;
|
||||
}
|
||||
if (descriptionKeepsContext || contextSuffix === null) return `${withoutContext} ${version}`;
|
||||
// Keep the suffix in its original spelling and insert the version before it.
|
||||
return `${withoutContext} ${version}${contextSuffix[0]}`;
|
||||
}
|
||||
|
||||
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
|
||||
const lower = model.trim().toLowerCase();
|
||||
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
|
||||
const tokens = lower
|
||||
.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ")
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
if (token === "opusplan") return "opus";
|
||||
if (token === "best" || token === "default") return "";
|
||||
return token;
|
||||
})
|
||||
.filter((token) => token && token !== "claude")
|
||||
.filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
|
||||
return { tokens, contextHint };
|
||||
}
|
||||
|
||||
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
|
||||
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
|
||||
let score = 0;
|
||||
let nonHintMatched = false;
|
||||
for (const token of tokens) {
|
||||
if (!haystack.includes(token)) continue;
|
||||
if (token !== contextHint) nonHintMatched = true;
|
||||
score += token === contextHint ? 3 : 1;
|
||||
}
|
||||
return contextHint && !nonHintMatched ? 0 : score;
|
||||
}
|
||||
|
||||
export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null {
|
||||
const trimmed = preference.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
const canonicalPreference = canonicalizeModelId(trimmed);
|
||||
const directMatch = models.find(
|
||||
(model) =>
|
||||
model.value === trimmed ||
|
||||
canonicalizeModelId(model.value) === canonicalPreference ||
|
||||
model.displayName.toLowerCase() === lower,
|
||||
);
|
||||
if (directMatch) return directMatch;
|
||||
|
||||
const matchesResolved = (model: ModelInfo) =>
|
||||
model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
|
||||
const resolvedMatch =
|
||||
models.find((model) => model.value !== "default" && matchesResolved(model)) ??
|
||||
models.find(matchesResolved);
|
||||
if (resolvedMatch) return resolvedMatch;
|
||||
|
||||
const preferenceHint = contextHintOf(trimmed);
|
||||
const includesMatch = models.find((model) => {
|
||||
if (!modelVersionsCompatible(trimmed, model)) return false;
|
||||
if (contextHintOf(model.value) !== preferenceHint) return false;
|
||||
const value = model.value.toLowerCase();
|
||||
const display = model.displayName.toLowerCase();
|
||||
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
||||
});
|
||||
if (includesMatch) return includesMatch;
|
||||
|
||||
const { tokens, contextHint } = tokenizeModelPreference(trimmed);
|
||||
if (tokens.length === 0) return null;
|
||||
let bestMatch: ModelInfo | null = null;
|
||||
let bestScore = 0;
|
||||
for (const model of models) {
|
||||
if (!modelVersionsCompatible(trimmed, model)) continue;
|
||||
const score = scoreModelMatch(model, tokens, contextHint);
|
||||
if (score > 0 && (!bestMatch || score > bestScore)) {
|
||||
bestMatch = model;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {
|
||||
const live = canonicalizeModelId(liveModel);
|
||||
const defaultEntry = models.find((model) => model.value === "default");
|
||||
const defaultResolved = defaultEntry?.resolvedModel
|
||||
? canonicalizeModelId(defaultEntry.resolvedModel)
|
||||
: undefined;
|
||||
if (defaultEntry && defaultResolved === live) return defaultEntry;
|
||||
const exactMatch = models.find(
|
||||
(model) => model.resolvedModel && canonicalizeModelId(model.resolvedModel) === live,
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
if (
|
||||
defaultEntry &&
|
||||
defaultResolved &&
|
||||
stripContextHints(defaultResolved) === stripContextHints(live)
|
||||
) {
|
||||
return defaultEntry;
|
||||
}
|
||||
return (
|
||||
resolveModelPreference(models, liveModel) ?? {
|
||||
value: liveModel,
|
||||
displayName: liveModel,
|
||||
description: "",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function buildModelConfigOption(
|
||||
models: SessionModelState,
|
||||
modelInfos: ModelInfo[],
|
||||
useRecommendedValue: boolean,
|
||||
): SessionConfigOption {
|
||||
const defaultInfo = modelInfos.find((model) => model.value === "default");
|
||||
const selectableIds = new Set(models.availableModels.map((model) => model.modelId));
|
||||
const concreteInfos = modelInfos.filter(
|
||||
(model) => model.value !== "default" && selectableIds.has(model.value),
|
||||
);
|
||||
// Recommendations describe the running default, so fuzzy family matching
|
||||
// must never substitute a different generation or context window.
|
||||
const defaultResolved = defaultInfo?.resolvedModel;
|
||||
const recommended = defaultResolved
|
||||
? (concreteInfos.find(
|
||||
(model) =>
|
||||
canonicalizeModelId(model.resolvedModel ?? model.value) ===
|
||||
canonicalizeModelId(defaultResolved),
|
||||
) ?? null)
|
||||
: null;
|
||||
const concreteRecommendation = useRecommendedValue && recommended !== null;
|
||||
const available = concreteRecommendation
|
||||
? models.availableModels.filter((model) => model.modelId !== "default")
|
||||
: models.availableModels;
|
||||
|
||||
return {
|
||||
id: MODEL_CONFIG_ID,
|
||||
name: "Model",
|
||||
description: "AI model to use",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue:
|
||||
concreteRecommendation && models.currentModelId === "default"
|
||||
? recommended.value
|
||||
: models.currentModelId,
|
||||
...(concreteRecommendation
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommended.value),
|
||||
}
|
||||
: {}),
|
||||
options: available.map((model) => {
|
||||
if (model.modelId === "default" && defaultInfo?.resolvedModel) {
|
||||
const named = modelInfos.find(
|
||||
(info) => info.value !== "default" && info.resolvedModel === defaultInfo.resolvedModel,
|
||||
);
|
||||
return {
|
||||
value: model.modelId,
|
||||
name: model.name,
|
||||
description: named?.displayName ?? defaultInfo.resolvedModel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: model.modelId,
|
||||
name: model.name,
|
||||
description: model.description ?? undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSettingsModel(
|
||||
models: ModelInfo[],
|
||||
settingsModel: unknown,
|
||||
logger: ModelLogger,
|
||||
): ModelInfo | null {
|
||||
if (settingsModel === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof settingsModel !== "string") {
|
||||
const typeLabel = settingsModel === null ? "null" : typeof settingsModel;
|
||||
logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`);
|
||||
return null;
|
||||
}
|
||||
return resolveModelPreference(models, settingsModel);
|
||||
}
|
||||
|
||||
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
|
||||
|
||||
/** A model id without the spellings `deniedModels` ignores: provider
|
||||
* prefixes, dates, version suffixes, `-fast` and context hints. */
|
||||
function normalizeDeniedModelId(value: string): string {
|
||||
let id = stripContextHints(value.trim().toLowerCase());
|
||||
const providerPrefix = id.lastIndexOf("anthropic.");
|
||||
if (providerPrefix !== -1) id = id.slice(providerPrefix + "anthropic.".length);
|
||||
return id
|
||||
.replace(/@.*$/, "")
|
||||
.replace(/-v\d+(?::\d+)?$/, "")
|
||||
.replace(/-fast$/, "")
|
||||
.replace(/-\d{8}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether managed `deniedModels` blocks a model id, following the CLI's rules
|
||||
* closely enough for the picker: a family alias blocks the whole family, and a
|
||||
* model id blocks that version in every spelling plus the later minor versions
|
||||
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). The CLI stays
|
||||
* the enforcer; this only keeps the picker from offering a refused row.
|
||||
*/
|
||||
export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean {
|
||||
const id = normalizeDeniedModelId(modelId);
|
||||
if (!id) return false;
|
||||
return deniedModels.some((entry) => {
|
||||
const denied = normalizeDeniedModelId(entry);
|
||||
if (!denied) return false;
|
||||
if (MODEL_FAMILY_ALIASES.has(denied)) return id === denied || id.includes(`-${denied}`);
|
||||
return id === denied || id.startsWith(`${denied}-`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the SDK's model list to the user's `availableModels` allowlist
|
||||
* (already merged-and-deduped across settings sources by `SettingsManager`).
|
||||
* The user's exact entries become the model IDs surfaced via configOptions
|
||||
* and passed to `setModel`, which prevents Claude Code from silently
|
||||
* substituting a date-pinned variant (e.g. `haiku` →
|
||||
* `claude-haiku-4-5-20251001`) that the user may not have access to.
|
||||
*
|
||||
* Display info and capability flags are copied from the closest SDK match so
|
||||
* the UI still renders sensible names and effort levels.
|
||||
*
|
||||
* Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
|
||||
* - `undefined` is handled by the caller (no allowlist applied).
|
||||
* - The Default option is unaffected by `availableModels` — it always remains
|
||||
* available, even when the allowlist is `[]`.
|
||||
* - Managed `deniedModels` wins over the allowlist, so a denied entry is
|
||||
* dropped even though the CLI would otherwise leave it unmatched.
|
||||
*/
|
||||
export function applyAvailableModelsAllowlist(
|
||||
sdkModels: ModelInfo[],
|
||||
allowlist: string[],
|
||||
settingsModelOverrides?: Record<string, string>,
|
||||
deniedModels: readonly string[] = [],
|
||||
): ModelInfo[] {
|
||||
// Default is always preserved per the docs. Synthesize one if the SDK
|
||||
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
|
||||
// `models[0]` as a fallback) still has something to work with.
|
||||
const defaultModel = sdkModels.find((m) => m.value === "default") ?? {
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
};
|
||||
const result: ModelInfo[] = [defaultModel];
|
||||
const seen = new Set<string>([defaultModel.value]);
|
||||
|
||||
const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
|
||||
|
||||
// Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6")
|
||||
// in availableModels but require provider-specific IDs at the API. We still
|
||||
// resolve `sdkMatch` against the alias (`trimmed`) — that's what the
|
||||
// matching heuristics above are built for, and override targets (ARNs,
|
||||
// opaque provider IDs) often won't textually resemble anything in
|
||||
// `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
|
||||
// override target, so it's what `setModel` ends up passing to the API.
|
||||
for (const entry of allowlist) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
|
||||
const overridden = settingsModelOverrides?.[trimmed];
|
||||
const effective = overridden ?? trimmed;
|
||||
if (seen.has(effective)) continue;
|
||||
|
||||
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
|
||||
if (
|
||||
[trimmed, effective, sdkMatch?.resolvedModel].some(
|
||||
(id) => id != null && isDeniedModel(id, deniedModels),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (sdkMatch) {
|
||||
result.push({ ...sdkMatch, value: effective });
|
||||
} else {
|
||||
result.push({ value: effective, displayName: trimmed, description: "" });
|
||||
}
|
||||
seen.add(effective);
|
||||
}
|
||||
|
||||
// The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
|
||||
// allowlist, the same way Default is. Per the model-config docs it adds an
|
||||
// entry "without replacing the built-in aliases" and "appears at the bottom of
|
||||
// the /model picker", so we append it last and skip the allowlist filter; this
|
||||
// keeps a slim alias allowlist from hiding the custom model row.
|
||||
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
|
||||
const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim();
|
||||
if (customModelOption && !seen.has(customModelOption)) {
|
||||
const customModel = sdkModels.find((m) => m.value === customModelOption);
|
||||
if (customModel) {
|
||||
result.push(customModel);
|
||||
seen.add(customModel.value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Whether a rejected `setModel` was vetoed by a user-configured
|
||||
* PreModelSwitch hook. The CLI's rejection message is the stable,
|
||||
* distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
|
||||
* <reason>"); a format change makes this stop matching and the failure
|
||||
* falls back to the ordinary fail-loud path, no worse than before. */
|
||||
function isPreModelSwitchHookBlock(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes("blocked by a PreModelSwitch hook");
|
||||
}
|
||||
|
||||
export async function getAvailableModels(
|
||||
query: Query,
|
||||
models: ModelInfo[],
|
||||
sdkModels: ModelInfo[],
|
||||
settingsManager: ModelSettingsSource,
|
||||
logger: ModelLogger,
|
||||
isResumedSession: boolean,
|
||||
sessionId: string,
|
||||
resumedModelHint?: string,
|
||||
): Promise<SessionModelState> {
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
let currentModel = models[0];
|
||||
let resolvedFromInput: string | undefined;
|
||||
// Model priority (highest to lowest):
|
||||
// 1. ANTHROPIC_MODEL environment variable
|
||||
// 2. settings.model (user configuration)
|
||||
// 3. the resumed session's live model (resumed sessions only)
|
||||
// 4. models[0] (default first model)
|
||||
if (process.env.ANTHROPIC_MODEL) {
|
||||
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
resolvedFromInput = process.env.ANTHROPIC_MODEL;
|
||||
}
|
||||
} else if (typeof settings.model === "string") {
|
||||
const match = resolveSettingsModel(models, settings.model, logger);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
resolvedFromInput = settings.model;
|
||||
}
|
||||
}
|
||||
|
||||
// A resumed session restores the model from its last real assistant
|
||||
// transcript record. Use that same local record instead of getContextUsage:
|
||||
// the latter is a control request to the live CLI process and can add tens
|
||||
// of seconds to session/load before its first turn. No `setModel` here: the
|
||||
// SDK is already running this model, and pushing a picker alias back (e.g.
|
||||
// "opus[1m]") could change the live model rather than describe it.
|
||||
if (resolvedFromInput === undefined && isResumedSession) {
|
||||
currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : currentModel;
|
||||
}
|
||||
|
||||
// Skip the setModel round-trip when we can prove the SDK has already landed
|
||||
// on the same model. Two cases qualify:
|
||||
// (a) No override applied — currentModel is the SDK's own default (or, on
|
||||
// resume, the live model read back from the SDK above); nothing to sync.
|
||||
// (b) The resolver returned the user's input verbatim AND that value exists
|
||||
// in the SDK's original model list — meaning no fuzzy match or
|
||||
// allowlist rewrite was involved, and the SDK (which reads the same
|
||||
// ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
|
||||
// This only holds for fresh sessions: a resumed session lands on the
|
||||
// transcript's model regardless of env/settings, so the override must
|
||||
// be re-asserted to keep the reported model truthful.
|
||||
// Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
|
||||
// setModel call so we don't drift from the user's intended pin.
|
||||
const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
|
||||
const skipSetModel =
|
||||
resolvedFromInput === undefined ||
|
||||
(!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
|
||||
if (!skipSetModel) {
|
||||
const setModelStartedAt = performance.now();
|
||||
try {
|
||||
await query.setModel(currentModel.value);
|
||||
logger.log(
|
||||
`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=success`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.log(
|
||||
`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=error`,
|
||||
);
|
||||
// On a fresh session the pin is a defining option — fail loudly. A
|
||||
// resumed session already runs fine on the transcript's model, so
|
||||
// failing the whole session/load over the re-assert would be worse
|
||||
// than loading with the pin unapplied (mirrors the setPermissionMode
|
||||
// containment in createSession). The SDK then stayed on the
|
||||
// transcript's model, so read that back rather than reporting the
|
||||
// pin the session isn't running.
|
||||
//
|
||||
// One fresh-session failure is advisory, not defining: a
|
||||
// user-configured PreModelSwitch hook can veto the pin (CLI 2.1.251+;
|
||||
// 'deny', or 'ask' — which headless sessions refuse), and the SDK
|
||||
// rejects setModel with "Model switch blocked by a PreModelSwitch
|
||||
// hook: …". Terminal Claude Code never lets a hook veto its startup
|
||||
// model (the spawn model isn't a switch), so failing session/new here
|
||||
// would make the same hook config fatal only over ACP. The session
|
||||
// stays on the SDK's own default — report that. We can't read the
|
||||
// live model back on this path: getContextUsage isn't serviced on a
|
||||
// fresh session until the first prompt turn (issues #886/#880).
|
||||
if (!isResumedSession) {
|
||||
if (!isPreModelSwitchHookBlock(error)) throw error;
|
||||
logger.error(
|
||||
`Model pin "${currentModel.value}" was vetoed by a PreModelSwitch hook; staying on the default model:`,
|
||||
error,
|
||||
);
|
||||
currentModel = models[0];
|
||||
} else {
|
||||
logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
|
||||
currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : models[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const displayNames = models.map(versionedModelDisplayName);
|
||||
const displayNameCounts = new Map<string, number>();
|
||||
for (const name of displayNames) {
|
||||
displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
availableModels: models.map((model, index) => ({
|
||||
modelId: model.value,
|
||||
name:
|
||||
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
|
||||
description: model.description,
|
||||
})),
|
||||
currentModelId: currentModel.value,
|
||||
};
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
|
||||
/** Fire-and-forget advisory text for the user: live events rather than
|
||||
* conversation history. See the ACP Session Notices RFD
|
||||
* (https://agentclientprotocol.com/rfds/session-notices). */
|
||||
export type SessionNotice = {
|
||||
severity: "info" | "warning" | "error";
|
||||
/** Plain text that stands alone. */
|
||||
title: string;
|
||||
/** Plain-text detail or guidance. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/** Longest free-form line that may serve as a notice title on its own. Past
|
||||
* this the text moves to `description` under a generic title. */
|
||||
export const MAX_NOTICE_TITLE_LENGTH = 256;
|
||||
|
||||
/** Whether the Client advertised `clientCapabilities.session.notices`. Agents
|
||||
* MUST NOT send `notice` updates otherwise; the alternative is a bold-label
|
||||
* transcript line, which was the only way to flag these before. */
|
||||
export function clientSupportsNotices(capabilities?: ClientCapabilities | null): boolean {
|
||||
const notices = capabilities?.session?.notices;
|
||||
return typeof notices === "object" && notices !== null && !Array.isArray(notices);
|
||||
}
|
||||
|
||||
export function noticeUpdate(notice: SessionNotice): SessionNotification["update"] {
|
||||
return {
|
||||
sessionUpdate: "notice",
|
||||
severity: notice.severity,
|
||||
title: notice.title,
|
||||
...(notice.description ? { description: notice.description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The transcript rendering of a notice for clients without the capability. */
|
||||
export function noticeTranscriptText(notice: SessionNotice): string {
|
||||
return notice.description ? `**${notice.title}:** ${notice.description}` : `**${notice.title}**`;
|
||||
}
|
||||
|
||||
/** The update to send for `notice`: a `notice` when the client can present
|
||||
* one, else an agent message carrying `transcriptText` (by default the
|
||||
* bold-label rendering of the notice) and `transcriptMeta`, so clients
|
||||
* without the capability can still tell the line apart from Claude's reply. */
|
||||
export function noticeOrTranscriptUpdate(
|
||||
notice: SessionNotice,
|
||||
supportsNotices: boolean,
|
||||
transcriptText: string = noticeTranscriptText(notice),
|
||||
transcriptMeta?: Record<string, unknown>,
|
||||
): SessionNotification["update"] {
|
||||
if (supportsNotices) return noticeUpdate(notice);
|
||||
return {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: transcriptText },
|
||||
...(transcriptMeta ? { _meta: transcriptMeta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fragments that read on after a bold label stand alone as a notice
|
||||
* description, so start them with a capital. */
|
||||
export function sentenceCase(text: string): string {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
/** Collapses whitespace so a text shown as a notice can be recognized when
|
||||
* the SDK repeats it elsewhere (a hook-blocked turn's result, for one). */
|
||||
export function normalizeNoticeText(text: string): string {
|
||||
return text.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Notice fields are plain text. Free-form SDK/hook output may carry light
|
||||
* markdown that a transcript would render; drop the inline markers rather
|
||||
* than show literal asterisks and backticks in a toast. */
|
||||
export function noticePlainText(text: string): string {
|
||||
return text
|
||||
.replace(/^[ \t]*#{1,6}[ \t]+/gm, "")
|
||||
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2")
|
||||
.replace(/(?<![\w`])`([^`\n]+)`(?![\w`])/g, "$1");
|
||||
}
|
||||
|
||||
/** Shapes free-form text into a notice title plus optional description: the
|
||||
* first line stands as the title when it is short enough, otherwise the whole
|
||||
* text becomes the description under `fallbackTitle`. */
|
||||
export function splitNoticeText(
|
||||
text: string,
|
||||
fallbackTitle: string,
|
||||
): Pick<SessionNotice, "title" | "description"> {
|
||||
const trimmed = noticePlainText(text).trim();
|
||||
if (!trimmed) return { title: fallbackTitle };
|
||||
const lineBreak = trimmed.search(/\r?\n/);
|
||||
const firstLine = lineBreak === -1 ? trimmed : trimmed.slice(0, lineBreak).trimEnd();
|
||||
const rest = lineBreak === -1 ? "" : trimmed.slice(lineBreak).trim();
|
||||
if (firstLine.length > MAX_NOTICE_TITLE_LENGTH) {
|
||||
return { title: fallbackTitle, description: trimmed };
|
||||
}
|
||||
return rest ? { title: firstLine, description: rest } : { title: firstLine };
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
type TimingLogger = {
|
||||
log: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
/** Small phase timer for session lifecycle diagnostics. */
|
||||
export class SessionTiming {
|
||||
private readonly startedAt: number;
|
||||
private phaseStartedAt: number;
|
||||
|
||||
constructor(
|
||||
private readonly logger: TimingLogger | undefined,
|
||||
private readonly operation: string,
|
||||
private readonly sessionId: string,
|
||||
startedAt = performance.now(),
|
||||
) {
|
||||
this.startedAt = startedAt;
|
||||
this.phaseStartedAt = startedAt;
|
||||
}
|
||||
|
||||
phase(name: string, detail = ""): void {
|
||||
const finishedAt = performance.now();
|
||||
this.logger?.log(
|
||||
`[session/${this.operation}] sessionId=${this.sessionId} phase=${name} durationMs=${Math.round(finishedAt - this.phaseStartedAt)} totalMs=${Math.round(finishedAt - this.startedAt)}${detail}`,
|
||||
);
|
||||
this.phaseStartedAt = finishedAt;
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
/**
|
||||
* AI-generated session titles.
|
||||
*
|
||||
* Claude Code's own auto-title generation never runs under the Agent SDK — the
|
||||
* latch that arms it is pre-set in the headless path — so `SDKSessionInfo.summary`
|
||||
* degrades to the raw first prompt. This module asks the CLI for a real title
|
||||
* instead, via the `generate_session_title` control request, and publishes it
|
||||
* over ACP as a `session_info_update`.
|
||||
*
|
||||
* {@link SessionTitles} holds the per-session state and is owned by `Session`.
|
||||
* `acp-agent.ts` drives it from four places: {@link SessionTitles.onPrompt} and
|
||||
* {@link SessionTitles.onAssistantText} to collect text,
|
||||
* {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`, and
|
||||
* {@link SessionTitles.reset} on `conversation_reset`.
|
||||
*/
|
||||
|
||||
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk";
|
||||
import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent, Session } from "./acp-agent.js";
|
||||
import { ExplicitTitle } from "./session-index/explicit-title.js";
|
||||
|
||||
const MAX_TITLE_LENGTH = 256;
|
||||
|
||||
/** How much session text the title is generated from. The CLI caps its own
|
||||
* title input at the same 1000 trailing characters. */
|
||||
const MAX_TITLE_CONTEXT_LENGTH = 1000;
|
||||
|
||||
/** Below this the generator returns null without calling the model, so there is
|
||||
* nothing to gain by asking yet. */
|
||||
const MIN_TITLE_CONTEXT_LENGTH = 10;
|
||||
|
||||
/** `generateSessionTitle` is present on the SDK's runtime `Query` class but is
|
||||
* not declared in `sdk.d.ts` (0.3.220), so it is reached through this optional
|
||||
* shape rather than the published interface. */
|
||||
type TitleCapableQuery = Query & {
|
||||
generateSessionTitle?: (
|
||||
description: string,
|
||||
options?: { persist?: boolean },
|
||||
) => Promise<string | null>;
|
||||
};
|
||||
|
||||
/** A title already stored for the session, published when generation is
|
||||
* unavailable or yields nothing. */
|
||||
type TitleFallback = { title: string; lastModified: number };
|
||||
|
||||
/** How much of a long text {@link sanitizeTitle} reads first. Collapsed, it
|
||||
* is longer than a title unless it is mostly whitespace. */
|
||||
const TITLE_SCAN_LENGTH = 4096;
|
||||
|
||||
export function sanitizeTitle(text: string): string {
|
||||
if (text.length > TITLE_SCAN_LENGTH) {
|
||||
// A stored summary can be a whole first prompt of megabytes. The title
|
||||
// depends only on the start of the text: collapsing the whitespace of a
|
||||
// prefix gives a prefix of the collapsed text. Two more characters than a
|
||||
// title hold at least one that is not a space (spaces are collapsed), so
|
||||
// the full text is longer than a title and gets the same cut.
|
||||
const head = text.slice(0, TITLE_SCAN_LENGTH).replace(/\s+/g, " ").trimStart();
|
||||
if (head.length >= MAX_TITLE_LENGTH + 2) {
|
||||
return head.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
||||
}
|
||||
}
|
||||
// Replace newlines and collapse whitespace
|
||||
const sanitized = text
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (sanitized.length <= MAX_TITLE_LENGTH) {
|
||||
return sanitized;
|
||||
}
|
||||
return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
||||
}
|
||||
|
||||
/** Rolling text a title is generated from: `previous` plus `text`, keeping only
|
||||
* the trailing {@link MAX_TITLE_CONTEXT_LENGTH} characters. */
|
||||
export function appendTitleContext(previous: string | undefined, text: string): string {
|
||||
const next = (previous ?? "") + text;
|
||||
return next.length > MAX_TITLE_CONTEXT_LENGTH ? next.slice(-MAX_TITLE_CONTEXT_LENGTH) : next;
|
||||
}
|
||||
|
||||
/** Whether this SDK still offers on-demand title generation. */
|
||||
function supportsTitleGeneration(query: Query): boolean {
|
||||
return typeof (query as TitleCapableQuery).generateSessionTitle === "function";
|
||||
}
|
||||
|
||||
/** One session's title: the text a generated title is derived from, whether a
|
||||
* title has been settled on, and the last one published. Created with the
|
||||
* session and reachable as `session.titles`. */
|
||||
export class SessionTitles {
|
||||
/** Last title pushed to the client via `session_info_update`, so an unchanged
|
||||
* title is never re-notified. Undefined until the first push. */
|
||||
private lastTitle?: string;
|
||||
|
||||
/** Rolling tail of this session's own user + assistant text, capped at
|
||||
* {@link MAX_TITLE_CONTEXT_LENGTH}. Dropped once a title exists. */
|
||||
private context?: string;
|
||||
|
||||
/** Set once this session has a title or has asked for one. A title is
|
||||
* generated at most once per session: the SDK reports a generated title in
|
||||
* the same field as a user `/rename`, so re-titling could silently overwrite
|
||||
* one. Released by {@link reset}, and when generation yields nothing. */
|
||||
private settled = false;
|
||||
|
||||
/** The title a client set, which no generated title replaces. */
|
||||
private readonly explicit = new ExplicitTitle();
|
||||
|
||||
/** The title last stored through {@link setExplicitTitle}, as stored
|
||||
* (with the archive prefix of an archived session): the title the CLI
|
||||
* holds. */
|
||||
private persistedTitle?: string;
|
||||
|
||||
/** Client title changes in flight, and the title state before the first. */
|
||||
private explicitChanges = 0;
|
||||
private beforeExplicit?: { settled: boolean; context: string | undefined };
|
||||
|
||||
get storedTitle(): string | undefined {
|
||||
return this.persistedTitle;
|
||||
}
|
||||
|
||||
/** The title last published, as the client shows it. */
|
||||
get shownTitle(): string | undefined {
|
||||
return this.lastTitle;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly agent: ClaudeAcpAgent,
|
||||
private readonly sessionId: string,
|
||||
) {}
|
||||
|
||||
/** Collect a prompt's own text for the title, skipping openers not worth
|
||||
* titling after. No-op once the title is settled. */
|
||||
onPrompt(prompt: PromptRequest["prompt"]): void {
|
||||
if (this.settled) {
|
||||
return;
|
||||
}
|
||||
const promptText = prompt
|
||||
.flatMap((chunk) => (chunk.type === "text" ? [chunk.text] : []))
|
||||
.join("\n");
|
||||
|
||||
this.context = appendTitleContext(this.context, `\n${promptText}\n`);
|
||||
}
|
||||
|
||||
/** Collect the assistant's own answer for the title. Chunks are appended
|
||||
* verbatim so streamed text reassembles. No-op once the title is settled. */
|
||||
onAssistantText(content: ContentBlock): void {
|
||||
if (this.settled || content.type !== "text") {
|
||||
return;
|
||||
}
|
||||
this.context = appendTitleContext(this.context, content.text);
|
||||
}
|
||||
|
||||
/** Drop the title state so the next turn re-evaluates. Used on
|
||||
* `conversation_reset`, which mounts a fresh transcript. */
|
||||
reset(): void {
|
||||
this.settled = false;
|
||||
this.explicit.reset();
|
||||
this.context = undefined;
|
||||
this.lastTitle = undefined;
|
||||
this.persistedTitle = undefined;
|
||||
}
|
||||
|
||||
/** Turn-end title handling. `idle` is the SDK's turn-over signal, so it is
|
||||
* when a title may have landed or become generatable.
|
||||
*
|
||||
* `info.customTitle` is non-empty only once the session file holds a real
|
||||
* title — a user `/rename` or one generated earlier; the SDK folds both into
|
||||
* that one field. Adopt it and latch, so neither is ever titled over.
|
||||
*
|
||||
* With no title yet, ask the SDK to generate one in the background: it is a
|
||||
* ~2s small-model call and turn-end must not wait on it. Only when that is
|
||||
* not possible do we fall back to `summary`, which for an SDK-driven session
|
||||
* is just the raw first prompt. */
|
||||
async onTurnEnd(session: Session): Promise<void> {
|
||||
if (this.explicit.settledByClient) {
|
||||
// No title is generated over the one the client set, but a custom title
|
||||
// stored since by someone else (a `/rename`) is adopted, as before.
|
||||
const renames = this.explicit.renames;
|
||||
const info = await this.readSessionInfo(session);
|
||||
// No client change ran during the read, so the stored title is current;
|
||||
// an unchanged one is not published again.
|
||||
const stored = info?.customTitle;
|
||||
if (stored && this.explicit.settledByClient && this.explicit.renames === renames) {
|
||||
await this.publish(stored, info.lastModified);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// While a client title change is in flight, the session info read below
|
||||
// could still hold the previous title.
|
||||
if (this.explicit.active) return;
|
||||
const info = await this.readSessionInfo(session);
|
||||
if (this.explicit.active) return;
|
||||
|
||||
if (info?.customTitle) {
|
||||
this.settled = true;
|
||||
this.context = undefined;
|
||||
await this.publish(info.customTitle, info.lastModified);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = info?.summary
|
||||
? { title: info.summary, lastModified: info.lastModified }
|
||||
: undefined;
|
||||
|
||||
if (this.canRequest(session)) {
|
||||
this.settled = true;
|
||||
|
||||
this.explicit.track(
|
||||
this.requestGenerateTitle(session, fallback).catch((error) => {
|
||||
this.agent.logger.error(
|
||||
`Session ${this.sessionId}: session title update failed: ${error}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
// Only while this session has no title of its own: `summary` degrades to the
|
||||
// raw first prompt, which must never overwrite a generated title if the
|
||||
// persisted one is slow to show up in `info`.
|
||||
if (fallback && !this.settled) {
|
||||
await this.publish(fallback.title, fallback.lastModified);
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a title that the client chose (a rename, an archive or an
|
||||
* unarchive). Settles the title for good, waits for a generation in flight
|
||||
* so that its persisted title cannot land after this one, persists the
|
||||
* title with `persist`, which returns what it stored, if anything, and
|
||||
* publishes `title` unless it is undefined. A change that stored and
|
||||
* publishes nothing leaves the title state as it was. */
|
||||
async setExplicitTitle(
|
||||
publish: string | ((stored: string | undefined) => string) | undefined,
|
||||
persist: () => Promise<string | undefined | void>,
|
||||
): Promise<void> {
|
||||
// The state before the first of overlapping changes, which a change that
|
||||
// fails or names nothing puts back.
|
||||
if (this.explicitChanges++ === 0) {
|
||||
this.beforeExplicit = { settled: this.settled, context: this.context };
|
||||
}
|
||||
const previous = this.beforeExplicit!;
|
||||
this.settled = true;
|
||||
this.context = undefined;
|
||||
let stored: string | undefined;
|
||||
try {
|
||||
await this.explicit.apply(
|
||||
async () => {
|
||||
stored = (await persist()) ?? undefined;
|
||||
if (stored !== undefined) this.persistedTitle = stored;
|
||||
return stored !== undefined || publish !== undefined;
|
||||
},
|
||||
() => {
|
||||
this.settled = previous.settled;
|
||||
this.context ??= previous.context;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (--this.explicitChanges === 0) this.beforeExplicit = undefined;
|
||||
}
|
||||
const title = typeof publish === "function" ? publish(stored) : publish;
|
||||
if (title === undefined) return;
|
||||
this.lastTitle = title;
|
||||
await this.agent.client.sessionUpdate({
|
||||
sessionId: this.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", title },
|
||||
});
|
||||
}
|
||||
|
||||
/** Records a title the CLI took, as soon as it took it. */
|
||||
rememberStoredTitle(title: string): void {
|
||||
this.persistedTitle = title;
|
||||
}
|
||||
|
||||
/** Read the SDK's stored info for this session. A missing session file or read
|
||||
* error is non-fatal: the title is best-effort and another turn will retry. */
|
||||
private async readSessionInfo(session: Session): Promise<SDKSessionInfo | undefined> {
|
||||
try {
|
||||
return await getSessionInfo(this.sessionId, { dir: session.cwd });
|
||||
} catch (error) {
|
||||
this.agent.logger.error(`Session ${this.sessionId}: failed to read session info: ${error}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Notify the client of a title, unless it is the one we last sent. */
|
||||
private async publish(rawTitle: string, lastModified: number): Promise<void> {
|
||||
const title = sanitizeTitle(this.agent.clientTitle?.(rawTitle) ?? rawTitle);
|
||||
if (title === this.lastTitle) {
|
||||
return;
|
||||
}
|
||||
this.lastTitle = title;
|
||||
await this.agent.client.sessionUpdate({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
title,
|
||||
updatedAt: new Date(lastModified).toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether to ask the SDK for a generated title now: once per session, on a
|
||||
* live query, with enough collected text for the generator to work with. */
|
||||
private canRequest(session: Session): boolean {
|
||||
if (this.settled || session.queryClosed || session.cancelled) {
|
||||
return false;
|
||||
}
|
||||
if (!supportsTitleGeneration(session.query)) {
|
||||
return false;
|
||||
}
|
||||
return (this.context?.trim().length ?? 0) >= MIN_TITLE_CONTEXT_LENGTH;
|
||||
}
|
||||
|
||||
/** Generate a title from the session's own text and publish it. `persist`
|
||||
* writes it to the session file, which is what carries it into
|
||||
* `session/list` and `session/load`. A null or failed generation releases the
|
||||
* latch so a later turn retries, and publishes `fallback` (the stored
|
||||
* summary) so a backend that can't generate titles is no worse off than
|
||||
* before. */
|
||||
private async requestGenerateTitle(
|
||||
session: Session,
|
||||
fallback: TitleFallback | undefined,
|
||||
): Promise<void> {
|
||||
const description = this.context?.trim() ?? "";
|
||||
let title: string | null = null;
|
||||
|
||||
try {
|
||||
title =
|
||||
(await (session.query as TitleCapableQuery).generateSessionTitle?.(description, {
|
||||
persist: true,
|
||||
})) ?? null;
|
||||
} catch (error) {
|
||||
this.agent.logger.error(
|
||||
`Session ${this.sessionId}: failed to generate a session title: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// A session torn down or replaced while the title was in flight must not
|
||||
// adopt it, and a title the client set meanwhile wins.
|
||||
if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
this.settled = false;
|
||||
|
||||
if (fallback) {
|
||||
await this.publish(fallback.title, fallback.lastModified);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.context = undefined;
|
||||
await this.publish(title, Date.now());
|
||||
}
|
||||
}
|
||||
226
src/settings.ts
226
src/settings.ts
@@ -1,226 +0,0 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
// resolveSettings and filterEscalatingDefaultMode are marked @alpha in the
|
||||
// SDK; API may shift in a future release.
|
||||
import {
|
||||
filterEscalatingDefaultMode,
|
||||
resolveSettings,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { claudeConfigDir } from "./paths.js";
|
||||
|
||||
/**
|
||||
* Permission rule format examples:
|
||||
* - "Read" - matches all Read tool calls
|
||||
* - "Read(./.env)" - matches specific path
|
||||
* - "Read(./.env.*)" - glob pattern
|
||||
* - "Read(./secrets/**)" - recursive glob
|
||||
* - "Bash(npm run lint)" - exact command prefix
|
||||
* - "Bash(npm run:*)" - command prefix with wildcard
|
||||
*
|
||||
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
||||
*/
|
||||
|
||||
function getManagedSettingsPath(): string {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
||||
case "win32":
|
||||
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
||||
default:
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsManagerOptions {
|
||||
onChange?: () => void;
|
||||
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
|
||||
* so the values we see match what `query()` would observe.
|
||||
*
|
||||
* Watches the user/project/local/managed settings files for changes and
|
||||
* re-resolves through the SDK on update. Escalating `permissions.defaultMode`
|
||||
* values from repo-committed sources are filtered out via
|
||||
* `filterEscalatingDefaultMode`, matching the CLI's trust policy.
|
||||
*/
|
||||
export class SettingsManager {
|
||||
private cwd: string;
|
||||
private effective: Settings = {};
|
||||
private managedDeniedModels: string[] = [];
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
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;
|
||||
this.onChange = options?.onChange;
|
||||
this.logger = options?.logger ?? console;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the settings manager by loading all settings and setting up file watchers
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
this.disposed = false;
|
||||
this.initPromise = this.loadAllSettings().then(() => {
|
||||
if (!this.disposed) {
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
}
|
||||
this.initPromise = null;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths the SDK reads when resolving settings for this cwd. Watching the
|
||||
* containing directories means we pick up file creation as well as edits.
|
||||
*/
|
||||
private getWatchedPaths(): string[] {
|
||||
return [
|
||||
path.join(claudeConfigDir(), "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.local.json"),
|
||||
getManagedSettingsPath(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective settings via the SDK and applies the CLI's trust
|
||||
* filter for escalating `permissions.defaultMode` values.
|
||||
*/
|
||||
private async loadAllSettings(): Promise<void> {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: this.cwd });
|
||||
this.effective = filterEscalatingDefaultMode(resolved);
|
||||
// The CLI reads `deniedModels` from managed settings only.
|
||||
this.managedDeniedModels = resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.flatMap((entry) => entry.settings.deniedModels ?? []);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
this.managedDeniedModels = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up file watchers for all settings files
|
||||
*/
|
||||
private setupWatchers(): void {
|
||||
for (const filePath of this.getWatchedPaths()) {
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
|
||||
if (fs.existsSync(dir)) {
|
||||
const watcher = fs.watch(dir, (eventType, changedFilename) => {
|
||||
if (changedFilename === filename) {
|
||||
this.handleSettingsChange();
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.error(`Settings watcher error for ${filePath}:`, error);
|
||||
});
|
||||
|
||||
this.watchers.push(watcher);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to set up watcher for ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles settings file changes with debouncing to avoid rapid reloads
|
||||
*/
|
||||
private handleSettingsChange(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
this.debounceTimer = null;
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.loadAllSettings();
|
||||
if (!this.disposed) {
|
||||
this.onChange?.();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to reload settings:", error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current merged settings
|
||||
*/
|
||||
getSettings(): Settings {
|
||||
return this.effective;
|
||||
}
|
||||
|
||||
/**
|
||||
* `deniedModels` entries from the managed tier, the only tier the CLI
|
||||
* honors them from.
|
||||
*/
|
||||
getManagedDeniedModels(): string[] {
|
||||
return this.managedDeniedModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current working directory
|
||||
*/
|
||||
getCwd(): string {
|
||||
return this.cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the working directory and reloads project-specific settings
|
||||
*/
|
||||
async setCwd(cwd: string): Promise<void> {
|
||||
if (this.cwd === cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispose();
|
||||
this.cwd = cwd;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
for (const watcher of this.watchers) {
|
||||
watcher.close();
|
||||
}
|
||||
this.watchers = [];
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { findTranscript } from "./resumed-session.js";
|
||||
|
||||
/** Where the subagent transcripts of a session are. */
|
||||
export interface SubagentHistory {
|
||||
/** The subagent id of each Agent or Task tool use that launched a subagent. */
|
||||
ids: Map<string, string>;
|
||||
/**
|
||||
* The project directory of the session, as the SDK takes it in `dir`. With
|
||||
* it, the SDK reads a subagent transcript without a search of every project.
|
||||
*/
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the subagent transcripts of a session.
|
||||
*
|
||||
* Claude keeps the history of a subagent in its own transcript,
|
||||
* `<session>/subagents/agent-<id>.jsonl`, next to the session transcript.
|
||||
* The `agent-<id>.meta.json` file beside it names the launching tool use.
|
||||
* The ids are empty for a session without a local transcript.
|
||||
*/
|
||||
export async function subagentHistory(sessionId: string): Promise<SubagentHistory> {
|
||||
const ids = new Map<string, string>();
|
||||
const transcript = await findTranscript(sessionId);
|
||||
if (!transcript) return { ids };
|
||||
const directory = path.join(path.dirname(transcript), sessionId, "subagents");
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(directory);
|
||||
} catch {
|
||||
return { ids };
|
||||
}
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const match = /^agent-(.+)\.meta\.json$/.exec(name);
|
||||
if (!match) return;
|
||||
try {
|
||||
const meta: unknown = JSON.parse(await readFile(path.join(directory, name), "utf8"));
|
||||
const toolUseId = (meta as { toolUseId?: unknown } | null)?.toolUseId;
|
||||
if (typeof toolUseId === "string") ids.set(toolUseId, match[1]!);
|
||||
} catch {
|
||||
// A damaged meta file leaves its subagent without history.
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (ids.size === 0) return { ids };
|
||||
// The folder name encodes the project directory, and the encoding loses
|
||||
// characters. The session info reads it back from the transcript.
|
||||
const dir = (await getSessionInfo(sessionId).catch(() => undefined))?.cwd;
|
||||
return dir ? { ids, dir } : { ids };
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
description: 10 * 3 = 30
|
||||
---
|
||||
|
||||
What is 10 \* 3. Only respond with the number.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
description: Say hello
|
||||
argument-hint: name
|
||||
---
|
||||
|
||||
Respond with "Hello $ARGUMENTS" and nothing else.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareWithBaseline } from "./acp-scenarios/compare.js";
|
||||
import type { Recorded } from "./acp-scenarios/harness.js";
|
||||
|
||||
function update(fields: Record<string, unknown>): Recorded {
|
||||
return { kind: "sessionUpdate", payload: { sessionId: "s", update: fields } };
|
||||
}
|
||||
|
||||
const toolCall = update({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "t",
|
||||
title: "Read",
|
||||
status: "pending",
|
||||
_meta: { claudeCode: { toolName: "Read" } },
|
||||
});
|
||||
|
||||
const result = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "t",
|
||||
title: "Read",
|
||||
status: "completed",
|
||||
rawOutput: "text",
|
||||
_meta: { claudeCode: { toolName: "Read" } },
|
||||
};
|
||||
|
||||
const baseline = [toolCall, update(result)];
|
||||
|
||||
/** A copy of `value` without `key`. */
|
||||
function omit(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const copy = { ...value };
|
||||
delete copy[key];
|
||||
return copy;
|
||||
}
|
||||
|
||||
describe("compareWithBaseline", () => {
|
||||
it("accepts the baseline itself", () => {
|
||||
expect(compareWithBaseline(baseline, baseline)).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts an update without a field that did not change", () => {
|
||||
expect(compareWithBaseline(baseline, [toolCall, update(omit(result, "title"))])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a dropped _meta key", () => {
|
||||
expect(compareWithBaseline(baseline, [toolCall, update(omit(result, "_meta"))])).toContainEqual(
|
||||
expect.stringMatching(/^origin\/main sent .*"toolName":"Read"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a changed rawOutput", () => {
|
||||
const changed = update({ ...result, rawOutput: "other" });
|
||||
expect(compareWithBaseline(baseline, [toolCall, changed])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"rawOutput":"other"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an extra update", () => {
|
||||
const extra = update({ sessionUpdate: "tool_call_update", toolCallId: "t", status: "failed" });
|
||||
expect(compareWithBaseline(baseline, [...baseline, extra])).toEqual([
|
||||
expect.stringMatching(/^origin\/main did not send /u),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("available_commands_update", () => {
|
||||
const commands = (...names: string[]) =>
|
||||
update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: names.map((name) => ({ name, description: name })),
|
||||
});
|
||||
|
||||
it("accepts the mcp command of the adapter", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("compact", "mcp")])).toEqual([]);
|
||||
expect(compareWithBaseline([commands()], [commands("mcp")])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports any other extra command", () => {
|
||||
expect(compareWithBaseline([commands()], [commands("mcp", "other")])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"name":"other"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a dropped command", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("mcp")])).toContainEqual(
|
||||
expect.stringMatching(/^origin\/main sent .*"name":"compact"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a changed mcp command that origin/main listed", () => {
|
||||
const changed = update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "mcp", description: "other" }],
|
||||
});
|
||||
expect(compareWithBaseline([commands("mcp")], [changed])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"description":"other"/u),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2
|
||||
* surface: through the protocol router, with the SDK's v2 client.
|
||||
*
|
||||
* - Status: each scenario passes, every prompt turn ending with a stop reason,
|
||||
* unless {@link V2_STOPS} names why it stops. Every scenario passes today;
|
||||
* a scenario that the v2 surface cannot run yet goes in the table.
|
||||
* - Schema: every message the agent sends, also in a scenario that stops, is
|
||||
* valid against the draft v2 schema of the SDK.
|
||||
* - Tool calls: the first report of each tool call has a title. v2 has no
|
||||
* `tool_call`, so a report of a tool call that the client has not seen
|
||||
* creates it, where v1 drops it.
|
||||
* - Golden files: `acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
|
||||
* passing scenario. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u`
|
||||
* to update them.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import {
|
||||
resetIds,
|
||||
runScenarioV2,
|
||||
type ScenarioRunV2,
|
||||
type WireRecorded,
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import { canonical } from "./acp-scenarios/compare.js";
|
||||
import { validateV2Message } from "./acp-scenarios/schema.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// The recordings replace only the ids that the run generated, so the harness
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
const id = actual.randomUUID(...args);
|
||||
harness.noteGeneratedId(id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PASSES = "passes";
|
||||
|
||||
/**
|
||||
* The scenarios that stop on the v2 surface, each with the reason, which names
|
||||
* the step of `docs/acp-v2.md` that removes it. Every other scenario passes.
|
||||
*/
|
||||
const V2_STOPS: Record<string, string> = {};
|
||||
|
||||
const statusOf = (scenario: string) => V2_STOPS[scenario] ?? PASSES;
|
||||
|
||||
const runs = new Map<string, ScenarioRunV2>();
|
||||
let configDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// A run must not depend on the machine: no remote login, no Claude CLI, no
|
||||
// user settings, and the bypass mode also for root.
|
||||
for (const name of ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER"]) {
|
||||
vi.stubEnv(name, "");
|
||||
}
|
||||
vi.stubEnv("CLAUDE_CODE_REMOTE", "");
|
||||
vi.stubEnv("ANTHROPIC_MODEL", "");
|
||||
vi.stubEnv("IS_SANDBOX", "1");
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { acpProtocolRouter } = await import("../serve.js");
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
const quiet = { log: () => {}, error: () => {} };
|
||||
runs.set(
|
||||
scenario.name,
|
||||
await runScenarioV2(
|
||||
(stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream),
|
||||
scenario,
|
||||
),
|
||||
);
|
||||
await Promise.all(agents.map((agent) => agent.dispose()));
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(scenario: string): ScenarioRunV2 {
|
||||
const recorded = runs.get(scenario);
|
||||
if (!recorded) throw new Error(`no v2 run of ${scenario}`);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function toJsonLines(recorded: WireRecorded[]): string {
|
||||
return recorded.map((record) => `${canonical(record)}\n`).join("");
|
||||
}
|
||||
|
||||
describe("ACP v2 scenarios", () => {
|
||||
it("pass, or stop as the table says", () => {
|
||||
const table = (status: (scenario: string) => string) =>
|
||||
Object.fromEntries(SCENARIOS.map((scenario) => [scenario.name, status(scenario.name)]));
|
||||
expect(table((scenario) => run(scenario).status)).toEqual(table(statusOf));
|
||||
});
|
||||
|
||||
describe("schema check", () => {
|
||||
const chunk = (update: Record<string, unknown>): WireRecorded => ({
|
||||
kind: "notification",
|
||||
method: "session/update",
|
||||
payload: { sessionId: "s", update: { sessionUpdate: "agent_message_chunk", ...update } },
|
||||
});
|
||||
|
||||
it("rejects a message that breaks the v2 schema", () => {
|
||||
const content = { type: "text", text: "hi" };
|
||||
expect(validateV2Message(chunk({ messageId: "m", content }))).toEqual([]);
|
||||
// v2 requires the id of the message a chunk belongs to.
|
||||
expect(validateV2Message(chunk({ content }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
expect(run(scenario).raw.flatMap((message) => validateV2Message(message))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool calls are reported with a title first", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
const reported = new Set<string>();
|
||||
const untitled: string[] = [];
|
||||
for (const message of run(scenario).raw) {
|
||||
if (message.kind !== "notification" || message.method !== "session/update") continue;
|
||||
const { sessionId, update } = message.payload as {
|
||||
sessionId: string;
|
||||
update: { sessionUpdate: string; toolCallId?: string; title?: unknown };
|
||||
};
|
||||
if (update.sessionUpdate !== "tool_call_update") continue;
|
||||
const key = `${sessionId} ${update.toolCallId}`;
|
||||
if (!reported.has(key) && typeof update.title !== "string") untitled.push(key);
|
||||
reported.add(key);
|
||||
}
|
||||
expect(untitled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2 golden files", () => {
|
||||
const passing = SCENARIOS.map((scenario) => scenario.name).filter(
|
||||
(scenario) => statusOf(scenario) === PASSES,
|
||||
);
|
||||
it.each(passing)("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,994 +0,0 @@
|
||||
/**
|
||||
* The outbound ACP traffic of every scenario in `acp-scenarios/scenarios.ts`,
|
||||
* for three client profiles: a plain ACP client, Zed, and JetBrains AIR.
|
||||
*
|
||||
* - Recordings are JSON Lines: one outbound message on each line, with sorted
|
||||
* keys. `normalize` in `harness.ts` replaces only exact run-specific values:
|
||||
* the generated ids, the paths, and the argv and version of this process.
|
||||
* - AIR golden files: `acp-scenarios/__snapshots__/air/<scenario>.jsonl`.
|
||||
* Run `npx vitest run src/tests/acp-scenarios.test.ts -u` to update them.
|
||||
* - Schema: every message is valid against the ACP schema of the SDK.
|
||||
* - Plain and Zed: the same information as origin/main
|
||||
* (`acp-scenarios/origin-main/<profile>/<scenario>.jsonl`). `compare.ts`
|
||||
* allows only the documented differences. These profiles have no golden
|
||||
* files of their own. `origin-main/zed/` holds a scenario only when its
|
||||
* recording differs from the plain client.
|
||||
* - Zed: the Zed conventions and the upstream `_meta` keys.
|
||||
* - AIR: the AIR extensions of `docs/air-extensions.md`, each fact once.
|
||||
*
|
||||
* To record the origin/main baseline again, copy `src/tests/acp-scenarios/`
|
||||
* and this file into a checkout of origin/main, and run this file there with
|
||||
* `ACP_SCENARIO_BASELINE_DIR=<dir>`. Then copy `<dir>/plain` and `<dir>/zed`
|
||||
* to `acp-scenarios/origin-main/`.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
AIR_CAPABILITY_NAMES,
|
||||
assistantTurn,
|
||||
normalize,
|
||||
PROFILES,
|
||||
type Profile,
|
||||
type Recorded,
|
||||
resetIds,
|
||||
result,
|
||||
runScenario,
|
||||
type Scenario,
|
||||
type ScenarioRun,
|
||||
streamMessage,
|
||||
toolCall,
|
||||
toolResult,
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import {
|
||||
AIR_ONLY_CLAUDE_CODE_KEYS,
|
||||
AIR_ONLY_META_KEYS,
|
||||
canonical,
|
||||
compareWithBaseline,
|
||||
} from "./acp-scenarios/compare.js";
|
||||
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
|
||||
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// The recordings replace only the ids that the run generated, so the harness
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
const id = actual.randomUUID(...args);
|
||||
harness.noteGeneratedId(id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const baselineDir = process.env.ACP_SCENARIO_BASELINE_DIR;
|
||||
const profiles = Object.values(PROFILES);
|
||||
const runs = new Map<string, ScenarioRun>();
|
||||
const key = (profile: Profile["name"], scenario: string) => `${profile}/${scenario}`;
|
||||
let configDir: string;
|
||||
let Agent: Parameters<typeof runScenario>[0];
|
||||
|
||||
beforeAll(async () => {
|
||||
// A run must not depend on the machine: no remote login, no Claude CLI, no
|
||||
// user settings, and the bypass mode also for root.
|
||||
for (const name of ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER"]) {
|
||||
vi.stubEnv(name, "");
|
||||
}
|
||||
vi.stubEnv("CLAUDE_CODE_REMOTE", "");
|
||||
vi.stubEnv("ANTHROPIC_MODEL", "");
|
||||
vi.stubEnv("IS_SANDBOX", "1");
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
Agent = ClaudeAcpAgent;
|
||||
for (const profile of profiles) {
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
runs.set(
|
||||
key(profile.name, scenario.name),
|
||||
await runScenario(ClaudeAcpAgent, profile, scenario),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A recording as JSON Lines: one message on each line, with sorted keys. */
|
||||
function toJsonLines(recorded: Recorded[]): string {
|
||||
return recorded.map((record) => `${canonical(record)}\n`).join("");
|
||||
}
|
||||
|
||||
function fromJsonLines(text: string): Recorded[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as Recorded);
|
||||
}
|
||||
|
||||
function run(profile: Profile["name"], scenario: string): ScenarioRun {
|
||||
const recorded = runs.get(key(profile, scenario));
|
||||
if (!recorded) throw new Error(`no run of ${scenario} for ${profile}`);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/** Runs one more scenario for AIR without some AIR capabilities. */
|
||||
async function runAir(scenario: Scenario, without: string[] = []): Promise<Recorded[]> {
|
||||
resetIds();
|
||||
const profile: Profile = {
|
||||
name: "air",
|
||||
capabilities: {
|
||||
...PROFILES.air.capabilities,
|
||||
_meta: {
|
||||
...PROFILES.air.capabilities._meta,
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: AIR_CAPABILITY_NAMES.filter((name) => !without.includes(name)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return (await runScenario(Agent, profile, scenario)).raw;
|
||||
}
|
||||
|
||||
function updates(recorded: Recorded[]): Record<string, any>[] {
|
||||
return recorded
|
||||
.filter((record) => record.kind === "sessionUpdate")
|
||||
.map((record) => (record.payload as { update: Record<string, any> }).update);
|
||||
}
|
||||
|
||||
function toolCallReports(recorded: Recorded[], toolCallId?: string): Record<string, any>[] {
|
||||
return updates(recorded).filter(
|
||||
(update) =>
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
(toolCallId === undefined || update.toolCallId === toolCallId),
|
||||
);
|
||||
}
|
||||
|
||||
function permissionRequests(recorded: Recorded[]): Record<string, any>[] {
|
||||
return recorded
|
||||
.filter((record) => record.kind === "requestPermission")
|
||||
.map((record) => record.payload as Record<string, any>);
|
||||
}
|
||||
|
||||
/** Every `_meta` object in a message, with the path to it. */
|
||||
function metaObjects(value: unknown, at = "$"): { at: string; meta: Record<string, unknown> }[] {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
if (Array.isArray(value)) return value.flatMap((item, i) => metaObjects(item, `${at}[${i}]`));
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([k, item]) => [
|
||||
...(k === "_meta" && item && typeof item === "object" && !Array.isArray(item)
|
||||
? [{ at: `${at}._meta`, meta: item as Record<string, unknown> }]
|
||||
: []),
|
||||
...metaObjects(item, `${at}.${k}`),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The origin/main recording of a scenario. Zed has a file only where it differs from the plain client. */
|
||||
function readBaseline(profile: "plain" | "zed", scenario: string): string {
|
||||
const file = (name: string) =>
|
||||
path.join(here, "acp-scenarios", "origin-main", name, `${scenario}.jsonl`);
|
||||
return fs.readFileSync(
|
||||
profile === "zed" && !fs.existsSync(file("zed")) ? file("plain") : file(profile),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe.runIf(baselineDir)("the origin/main baseline", () => {
|
||||
it("writes the recordings of the plain and Zed profiles", () => {
|
||||
for (const profile of ["plain", "zed"] as const) {
|
||||
fs.mkdirSync(path.join(baselineDir!, profile), { recursive: true });
|
||||
for (const scenario of SCENARIOS) {
|
||||
const text = toJsonLines(run(profile, scenario.name).normalized);
|
||||
if (profile === "zed" && text === toJsonLines(run("plain", scenario.name).normalized)) {
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(path.join(baselineDir!, profile, `${scenario.name}.jsonl`), text);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
describe("normalize", () => {
|
||||
const cwd = "/tmp/acp-scenario-x";
|
||||
const session = "aaaaaaaa-0000-4000-8000-000000000001";
|
||||
const first = "bbbbbbbb-0000-4000-8000-000000000002";
|
||||
const second = "cccccccc-0000-4000-8000-000000000003";
|
||||
const scripted = "00000000-0000-4000-8000-00000000c001";
|
||||
const version = JSON.parse(
|
||||
fs.readFileSync(path.join(here, "..", "..", "package.json"), "utf8"),
|
||||
).version;
|
||||
const argv = process.argv.slice(1);
|
||||
|
||||
it("keeps a UUID that the run did not generate", () => {
|
||||
expect(normalize({ toolCallId: scripted }, cwd, session, new Set([first]))).toEqual({
|
||||
toolCallId: scripted,
|
||||
});
|
||||
});
|
||||
|
||||
it("names the generated ids in the order of their first appearance", () => {
|
||||
const generated = new Set([first, second]);
|
||||
const reports = (ids: string[]) =>
|
||||
normalize(
|
||||
ids.map((id) => ({ sessionId: session, toolCallId: id })),
|
||||
cwd,
|
||||
session,
|
||||
generated,
|
||||
);
|
||||
expect(reports([first, first])).toEqual([
|
||||
{ sessionId: "<session>", toolCallId: "<id-1>" },
|
||||
{ sessionId: "<session>", toolCallId: "<id-1>" },
|
||||
]);
|
||||
// A report that names another generated id, or the session id, still differs.
|
||||
expect(reports([first, second])).not.toEqual(reports([first, first]));
|
||||
expect(reports([first, session])).not.toEqual(reports([first, first]));
|
||||
});
|
||||
|
||||
it("replaces only the exact argv prefix of this process", () => {
|
||||
expect(argv.length).toBeGreaterThan(0);
|
||||
expect(normalize({ args: [...argv, "--cli", "login"] }, cwd, session)).toEqual({
|
||||
args: ["<argv>", "--cli", "login"],
|
||||
});
|
||||
// An extra argument stays in the recording, before or after the prefix.
|
||||
expect(normalize({ args: [...argv, "--inspect", "--cli"] }, cwd, session)).toEqual({
|
||||
args: ["<argv>", "--inspect", "--cli"],
|
||||
});
|
||||
const before = ["--inspect", ...argv, "--cli"];
|
||||
const { args } = normalize({ args: before }, cwd, session) as { args: string[] };
|
||||
expect(args).toHaveLength(before.length);
|
||||
expect(args).not.toContain("<argv>");
|
||||
expect(normalize({ args: ["other.js", "--cli"] }, cwd, session)).toEqual({
|
||||
args: ["other.js", "--cli"],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces only the version of this adapter and the path of this Node binary", () => {
|
||||
expect(normalize({ version, command: process.execPath }, cwd, session)).toEqual({
|
||||
version: "<version>",
|
||||
command: "<executable>",
|
||||
});
|
||||
expect(normalize({ version: "9.9.9", command: "/usr/bin/node" }, cwd, session)).toEqual({
|
||||
version: "9.9.9",
|
||||
command: "/usr/bin/node",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps durations and other numbers", () => {
|
||||
expect(normalize({ durationMs: 7, totalDurationMs: 10 }, cwd, session)).toEqual({
|
||||
durationMs: 7,
|
||||
totalDurationMs: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema check", () => {
|
||||
const none = new Set<string>();
|
||||
const elicitation = (url: string): Recorded => ({
|
||||
kind: "createElicitation",
|
||||
payload: {
|
||||
sessionId: "s",
|
||||
message: "Sign in",
|
||||
mode: "url",
|
||||
elicitationId: "e",
|
||||
url,
|
||||
},
|
||||
});
|
||||
const location = (line: number): Recorded => ({
|
||||
kind: "sessionUpdate",
|
||||
payload: {
|
||||
sessionId: "s",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "t",
|
||||
locations: [{ path: "/a.ts", line }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("rejects a value that breaks a standard format", () => {
|
||||
expect(validateRecorded(elicitation("https://example.com/login"), none)).toEqual([]);
|
||||
expect(validateRecorded(elicitation("not a uri"), none)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a value that breaks an ACP integer format", () => {
|
||||
expect(validateRecorded(location(2 ** 32 - 1), none)).toEqual([]);
|
||||
expect(validateRecorded(location(2 ** 32), none)).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("air golden files", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run("air", scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "air", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(profiles.map((profile) => profile.name))("%s ACP schema", (profile) => {
|
||||
const extensions = profile === "air" ? EXTENSION_SESSION_UPDATES : new Set<string>();
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends only valid messages",
|
||||
(scenario) => {
|
||||
const errors = run(profile, scenario).raw.flatMap((record) =>
|
||||
validateRecorded(record, extensions),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(profiles.map((profile) => profile.name))("%s session ids", (profile) => {
|
||||
// Only AIR negotiates native subagent sessions. There a child session
|
||||
// gets updates after its parent announced it with `subagent_spawned`.
|
||||
const nativeSubagents = profile === "air";
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends every session/update to the ACP session",
|
||||
(scenario) => {
|
||||
const { sessionId, raw } = run(profile, scenario);
|
||||
const sessions = new Set([sessionId]);
|
||||
const wrong: string[] = [];
|
||||
for (const record of raw) {
|
||||
if (record.kind !== "sessionUpdate") continue;
|
||||
const payload = record.payload as { sessionId: string; update: Record<string, any> };
|
||||
if (!sessions.has(payload.sessionId)) {
|
||||
wrong.push(`${payload.sessionId}: ${payload.update.sessionUpdate}`);
|
||||
continue;
|
||||
}
|
||||
if (nativeSubagents && payload.update.sessionUpdate === "subagent_spawned") {
|
||||
sessions.add(payload.update.subagentSessionId);
|
||||
}
|
||||
}
|
||||
expect(wrong).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(["plain", "zed"] as const)("%s client", (profile) => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s carries the same information as origin/main",
|
||||
(scenario) => {
|
||||
const baseline = fromJsonLines(readBaseline(profile, scenario));
|
||||
expect(compareWithBaseline(baseline, run(profile, scenario).normalized)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends no key that exists only for AIR",
|
||||
(scenario) => {
|
||||
const found = metaObjects(run(profile, scenario).raw).flatMap(({ at, meta }) => [
|
||||
...Object.keys(meta)
|
||||
.filter((k) => AIR_ONLY_META_KEYS.has(k))
|
||||
.map((k) => `${at}.${k}`),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {})
|
||||
.filter((k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k))
|
||||
.map((k) => `${at}.claudeCode.${k}`),
|
||||
]);
|
||||
expect(found).toEqual([]);
|
||||
expect(
|
||||
updates(run(profile, scenario).raw).filter((update) =>
|
||||
EXTENSION_SESSION_UPDATES.has(update.sessionUpdate),
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("plain client", () => {
|
||||
it("gets the Bash output as a code block and the description as content", () => {
|
||||
const reports = toolCallReports(run("plain", "bash-foreground").raw, "toolu_bash");
|
||||
expect(reports[0]).not.toHaveProperty("_meta.terminal_info");
|
||||
expect(reports.some((r) => JSON.stringify(r.content ?? []).includes("List files"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reports.at(-2)).toMatchObject({
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "```console\na.ts\nb.ts\n```" } },
|
||||
],
|
||||
rawOutput: "a.ts\nb.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets the streamed partial input as rawInput, and the Write text in rawInput", () => {
|
||||
const reports = toolCallReports(run("plain", "write-new").raw, "toolu_write");
|
||||
expect(reports[0]).toMatchObject({ sessionUpdate: "tool_call", rawInput: {} });
|
||||
expect(reports.some((r) => r.rawInput?.file_path && !("content" in r.rawInput))).toBe(true);
|
||||
expect(reports.some((r) => r.rawInput?.content === "export const x = 1;\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("gets the whole tool call again in a permission request", () => {
|
||||
const [request] = permissionRequests(run("plain", "edit-with-permission").raw);
|
||||
expect(request.toolCall).toMatchObject({
|
||||
toolCallId: "toolu_edit",
|
||||
name: "Edit",
|
||||
status: "pending",
|
||||
kind: "edit",
|
||||
title: "Edit src/app.ts",
|
||||
rawInput: {
|
||||
old_string: "const value = 1;",
|
||||
new_string: "const value = 2;",
|
||||
},
|
||||
content: [{ type: "diff", oldText: "const value = 1;", newText: "const value = 2;" }],
|
||||
});
|
||||
expect(request).not.toHaveProperty("_meta");
|
||||
});
|
||||
|
||||
it("gets the question of a single AskUserQuestion as the title", () => {
|
||||
const reports = toolCallReports(run("plain", "ask-user-question").raw, "toolu_ask");
|
||||
expect(reports.map((r) => r.title)).toContain("Which database?");
|
||||
});
|
||||
|
||||
it("gets the upstream NotebookEdit rendering and the result text", () => {
|
||||
const reports = toolCallReports(run("plain", "notebook-edit").raw, "toolu_nb");
|
||||
expect(reports[0]).toMatchObject({ title: "NotebookEdit", kind: "other", content: [] });
|
||||
expect(reports.at(-1)).toMatchObject({
|
||||
status: "completed",
|
||||
rawOutput: "Updated cell cell-1 with print('hi')",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets every Task* plan, also a repeated one", () => {
|
||||
const plans = updates(run("plain", "task-create-update").raw).filter(
|
||||
(update) => update.sessionUpdate === "plan",
|
||||
);
|
||||
expect(plans.map((plan) => plan.entries[0]?.status)).toEqual([
|
||||
"pending",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zed", () => {
|
||||
const zed = (scenario: string) => run("zed", scenario).raw;
|
||||
|
||||
it("keeps terminal_info, terminal_output, and terminal_exit", () => {
|
||||
const reports = toolCallReports(zed("bash-foreground"), "toolu_bash");
|
||||
expect(reports[0]).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
content: [{ type: "terminal", terminalId: "toolu_bash" }],
|
||||
_meta: { terminal_info: { terminal_id: "toolu_bash" } },
|
||||
});
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_meta: { terminal_output: { terminal_id: "toolu_bash", data: "a.ts\nb.ts" } },
|
||||
}),
|
||||
);
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
_meta: expect.objectContaining({
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
for (const report of reports) {
|
||||
expect(report._meta ?? {}).not.toHaveProperty("terminal_output_delta");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the upstream claudeCode keys and the full PostToolUse toolResponse", () => {
|
||||
const reports = toolCallReports(zed("bash-foreground"), "toolu_bash");
|
||||
expect(reports[0]._meta.claudeCode).toEqual({ toolName: "Bash" });
|
||||
expect(reports.at(-1)?._meta.claudeCode).toEqual({
|
||||
toolName: "Bash",
|
||||
toolResponse: { stdout: "a.ts\nb.ts", stderr: "", interrupted: false, isImage: false },
|
||||
});
|
||||
const child = toolCallReports(zed("subagent-task-legacy"), "toolu_sub_read");
|
||||
expect(child[0]._meta.claudeCode).toEqual({
|
||||
toolName: "Read",
|
||||
parentToolUseId: "toolu_task",
|
||||
});
|
||||
// ACP does not merge `_meta` keys, so each update names the parent again.
|
||||
for (const report of child) {
|
||||
expect(report._meta.claudeCode).toMatchObject({
|
||||
toolName: "Read",
|
||||
parentToolUseId: "toolu_task",
|
||||
});
|
||||
}
|
||||
const [childRequest] = permissionRequests(zed("subagent-task-legacy"));
|
||||
expect(childRequest.toolCall._meta).toEqual({
|
||||
claudeCode: { toolName: "Bash", parentToolUseId: "toolu_task" },
|
||||
});
|
||||
const [mcpRequest] = permissionRequests(zed("mcp-tool"));
|
||||
expect(mcpRequest.toolCall._meta).toEqual({
|
||||
claudeCode: {
|
||||
toolName: "mcp__docs__search",
|
||||
mcpServer: { name: "docs", source: "project" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the full permission denial toolResponse", () => {
|
||||
const denial = toolCallReports(zed("permission-denied"), "toolu_denied").find(
|
||||
(report) => report.status === "failed",
|
||||
);
|
||||
expect(denial?._meta.claudeCode.toolResponse).toEqual({
|
||||
decisionReasonType: "rule",
|
||||
decisionReason: "Denied by rule Bash(rm:*)",
|
||||
message: "Permission to use Bash has been denied.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the _claude/* keys", () => {
|
||||
const [elicitation] = zed("ask-user-question")
|
||||
.filter((record) => record.kind === "createElicitation")
|
||||
.map((record) => record.payload as Record<string, any>);
|
||||
const options = elicitation.requestedSchema.properties.question_0.oneOf;
|
||||
expect(options[0]._meta).toHaveProperty(["_claude/askUserQuestionOption"]);
|
||||
const usage = updates(zed("rate-limit-and-origin")).filter(
|
||||
(update) => update.sessionUpdate === "usage_update",
|
||||
);
|
||||
expect(usage.some((update) => update._meta?.["_claude/rateLimit"])).toBe(true);
|
||||
expect(usage.some((update) => update._meta?.["_claude/origin"])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps promptQueueing and steering", () => {
|
||||
const initialize = zed("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
expect(initialize.agentCapabilities._meta.claudeCode).toEqual({ promptQueueing: true });
|
||||
expect(initialize._meta).toEqual({ steering: { supported: true } });
|
||||
});
|
||||
|
||||
it("sends terminal-auth commands that rerun this adapter with --cli", () => {
|
||||
// The raw traffic, because the recordings normalize the machine paths.
|
||||
const initialize = zed("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
const argv = process.argv.slice(1);
|
||||
expect(initialize.authMethods).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "claude-ai-login",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
_meta: {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...argv, "--cli", "auth", "login", "--claudeai"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "console-login",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
_meta: {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...argv, "--cli", "auth", "login", "--console"],
|
||||
label: "Anthropic Console Login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AIR", () => {
|
||||
const air = (scenario: string) => run("air", scenario).raw;
|
||||
const airMeta = (meta: Record<string, any> | undefined) => meta?.jetbrains?.air;
|
||||
|
||||
it("gets the AIR capabilities and the goal capability under jetbrains.air", () => {
|
||||
const initialize = air("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
expect(initialize._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: expect.arrayContaining(
|
||||
AIR_CAPABILITY_NAMES.filter((name) => name !== "rawInputRendering"),
|
||||
),
|
||||
goal: expect.objectContaining({ version: 1 }),
|
||||
},
|
||||
},
|
||||
steering: { supported: true },
|
||||
});
|
||||
const session = air("session-setup").find((record) => record.kind === "newSession")!
|
||||
.payload as Record<string, any>;
|
||||
for (const mode of session.modes.availableModes) {
|
||||
expect(mode._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, kind: expect.any(String) } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("gets commandTitle, subagent, and skill under jetbrains.air, each once", async () => {
|
||||
const bash = toolCallReports(air("bash-foreground"), "toolu_bash");
|
||||
expect(bash.filter((r) => airMeta(r._meta)?.commandTitle === "List files")).toHaveLength(1);
|
||||
// A native subagent session replaces the tool call of the subagent, so
|
||||
// the marker shows without native subagent sessions.
|
||||
const [nested] = toolCallReports(
|
||||
await runAir(
|
||||
{
|
||||
name: "nested-agent",
|
||||
turns: [
|
||||
async function* () {
|
||||
yield* assistantTurn("msg_nested", [
|
||||
{ type: "tool_use", id: "toolu_nested", name: "Agent", input: { prompt: "Go" } },
|
||||
]);
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
},
|
||||
["nativeSubagentSessions"],
|
||||
),
|
||||
"toolu_nested",
|
||||
);
|
||||
expect(airMeta(nested?._meta)?.subagent).toBe(true);
|
||||
const skill = toolCallReports(air("skill"), "toolu_skill");
|
||||
expect(skill.filter((r) => airMeta(r._meta)?.skill)).toEqual([
|
||||
expect.objectContaining({
|
||||
_meta: expect.objectContaining({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
skill: { name: "commits", path: expect.stringMatching(/SKILL\.md$/) },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("gets the permission, customAnswer, goal, and contextCompaction keys", () => {
|
||||
const [request] = permissionRequests(air("bash-foreground"));
|
||||
expect(request._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, permission: { version: 1, title: "ls -la" } } },
|
||||
});
|
||||
const elicitation = air("ask-user-question").find(
|
||||
(record) => record.kind === "createElicitation",
|
||||
)!.payload as Record<string, any>;
|
||||
expect(elicitation.requestedSchema.properties.question_0_custom._meta).toEqual({
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { version: 1, customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
});
|
||||
const goal = updates(air("goal")).find((u) => u.sessionUpdate === "session_info_update");
|
||||
expect(goal?._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
goal: expect.objectContaining({ objective: "Ship the feature", status: "active" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const compaction = toolCallReports(air("compaction-legacy"));
|
||||
expect(compaction.at(-1)?._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
contextCompaction: expect.objectContaining({ version: 1, preTokens: 1000 }),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(compaction.at(-1)).not.toHaveProperty("rawOutput");
|
||||
});
|
||||
|
||||
it("sends no upstream copy of an AIR key", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const { at, meta } of metaObjects(air(scenario.name))) {
|
||||
const duplicates = [
|
||||
// Released AIR versions read only the legacy custom answer key, so AIR gets both.
|
||||
...Object.keys(meta).filter(
|
||||
(k) =>
|
||||
AIR_ONLY_META_KEYS.has(k) &&
|
||||
k !== "jetbrains" &&
|
||||
k !== LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {}).filter(
|
||||
(k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k),
|
||||
),
|
||||
];
|
||||
expect(duplicates, `${scenario.name} ${at}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("sends each tool fact in one field", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const report of toolCallReports(air(scenario.name))) {
|
||||
const where = `${scenario.name} ${report.toolCallId}`;
|
||||
// Output is never copied into rawOutput when content carries it.
|
||||
if (report.rawOutput !== undefined) {
|
||||
expect(report.content ?? [], where).toEqual([]);
|
||||
}
|
||||
// Terminal output goes to deltas, never to snapshots.
|
||||
expect(report._meta ?? {}, where).not.toHaveProperty("terminal_output");
|
||||
}
|
||||
}
|
||||
const write = toolCallReports(air("write-new"), "toolu_write");
|
||||
for (const report of write) expect(report.rawInput ?? {}).not.toHaveProperty("content");
|
||||
expect(write[0]).not.toHaveProperty("rawInput");
|
||||
});
|
||||
|
||||
describe("ExitPlanMode with a plan file", () => {
|
||||
const plan = "# Plan\n1. Do it";
|
||||
/**
|
||||
* The CLI streams the input that the model wrote, which has no plan.
|
||||
* The complete message and canUseTool get the text and the path of the
|
||||
* plan file. The structured result names the file again.
|
||||
*/
|
||||
const planFileScenario = (options: { file: boolean; reject?: boolean }): Scenario => ({
|
||||
name: "exit-plan-file",
|
||||
...(options.file ? { files: { "plans/plan.md": plan } } : {}),
|
||||
...(options.reject ? { permission: "reject_once" } : {}),
|
||||
turns: [
|
||||
async function* (ctx) {
|
||||
const planFilePath = path.join(ctx.cwd, "plans", "plan.md");
|
||||
const input = { plan, planFilePath };
|
||||
yield* streamMessage("msg_toolu_plan", [
|
||||
{ type: "tool_use", id: "toolu_plan", name: "ExitPlanMode", input: {} },
|
||||
]);
|
||||
yield* toolCall(
|
||||
ctx,
|
||||
{ id: "toolu_plan", name: "ExitPlanMode", input },
|
||||
options.reject
|
||||
? {
|
||||
ask: true,
|
||||
isError: true,
|
||||
content: "```\nThe user doesn't want to proceed with this tool use.\n```",
|
||||
}
|
||||
: {
|
||||
ask: true,
|
||||
content: `User has approved your plan.\n\n## Approved Plan:\n${plan}`,
|
||||
structured: { plan, isAgent: false, filePath: planFilePath },
|
||||
},
|
||||
);
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
});
|
||||
it("sends the path and no plan text in every report", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true }));
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
const [request] = permissionRequests(recorded);
|
||||
const withInput = [...reports, request.toolCall].filter((r) => "rawInput" in r);
|
||||
expect(withInput.length).toBeGreaterThan(0);
|
||||
for (const report of withInput) {
|
||||
expect(report.rawInput).toEqual({ planFilePath: expect.stringMatching(/plan\.md$/) });
|
||||
expect(path.isAbsolute(report.rawInput.planFilePath)).toBe(true);
|
||||
}
|
||||
expect(reports[0]).toMatchObject({ sessionUpdate: "tool_call" });
|
||||
expect(reports[0]).not.toHaveProperty("rawInput");
|
||||
expect(reports.at(-1)).toMatchObject({ status: "completed", title: "Exited Plan Mode" });
|
||||
expect(JSON.stringify([reports, request])).not.toContain("Do it");
|
||||
});
|
||||
|
||||
it("sends the path with a rejection", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true, reject: true }));
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ planFilePath: expect.any(String) });
|
||||
expect(reports.at(-1)).toMatchObject({ status: "failed" });
|
||||
expect(JSON.stringify([reports, request])).not.toContain("Do it");
|
||||
});
|
||||
|
||||
it("sends the plan text when the plan file does not exist", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: false }));
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
});
|
||||
|
||||
it("sends the plan text to an AIR client without planFile", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true }), ["planFile"]);
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
});
|
||||
|
||||
it("sends the whole input to a client that is not AIR", async () => {
|
||||
for (const profile of [PROFILES.plain, PROFILES.zed]) {
|
||||
resetIds();
|
||||
const recorded = (await runScenario(Agent, profile, planFileScenario({ file: true })))
|
||||
.raw;
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
expect(request.toolCall.content).toEqual([
|
||||
{ type: "content", content: { type: "text", text: plan } },
|
||||
]);
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
expect(reports.some((r) => r.rawInput?.plan === plan)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("drops every update of a tool call of a finished child session", () => {
|
||||
const recorded = air("subagent-late-child-update");
|
||||
const finished = recorded.findIndex(
|
||||
(record) =>
|
||||
record.kind === "sessionUpdate" &&
|
||||
(record.payload as { update: Record<string, any> }).update.sessionUpdate ===
|
||||
"subagent_state_update",
|
||||
);
|
||||
expect(finished).toBeGreaterThan(0);
|
||||
const late = recorded
|
||||
.slice(finished)
|
||||
.filter((record) => record.kind === "sessionUpdate")
|
||||
.map((record) => record.payload as { sessionId: string; update: Record<string, any> })
|
||||
.filter(({ update }) => update.toolCallId === "toolu_late_read");
|
||||
expect(late).toEqual([]);
|
||||
for (const record of recorded.filter((r) => r.kind === "sessionUpdate")) {
|
||||
const { sessionId, update } = record.payload as {
|
||||
sessionId: string;
|
||||
update: Record<string, any>;
|
||||
};
|
||||
if (update.toolCallId === "toolu_late_read") expect(sessionId).toBe("agent_late");
|
||||
}
|
||||
});
|
||||
|
||||
it("gets the Write patch, but no file text in the PostToolUse toolResponse", async () => {
|
||||
const scenario = writtenFileScenario();
|
||||
const hookReport = (recorded: Recorded[]) =>
|
||||
toolCallReports(recorded, "toolu_write").find(
|
||||
(report) =>
|
||||
report._meta?.claudeCode?.toolResponse !== undefined || report.content?.[0]?._meta,
|
||||
);
|
||||
const airReport = hookReport(await runAir(scenario));
|
||||
expect(airReport?.content?.[0]?._meta?.jetbrains?.air?.diffPatch?.text).toContain(
|
||||
"-export const x = 0;\n+export const x = 1;\n",
|
||||
);
|
||||
expect(airReport?._meta?.claudeCode?.toolResponse).toBeUndefined();
|
||||
expect(JSON.stringify(airReport)).not.toContain("originalFile");
|
||||
// A client that is not AIR keeps the full toolResponse of origin/main.
|
||||
resetIds();
|
||||
const plain = (await runScenario(Agent, PROFILES.plain, scenario)).raw;
|
||||
expect(hookReport(plain)?._meta?.claudeCode?.toolResponse).toMatchObject({
|
||||
type: "update",
|
||||
content: "export const x = 1;\n",
|
||||
originalFile: "export const x = 0;\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends no creation patch over a file and no Edit patch before the preview", async () => {
|
||||
const patches = (reports: Record<string, any>[]) =>
|
||||
reports.flatMap((report) =>
|
||||
(report.content ?? [])
|
||||
.map((block: any) => block._meta?.jetbrains?.air?.diffPatch?.text)
|
||||
.filter((text: unknown) => text !== undefined),
|
||||
);
|
||||
// Streamed: the Write of an existing file gets only the update patch of
|
||||
// its hook, and the Edit gets no patch before its approval.
|
||||
const writePatches = patches(toolCallReports(air("write-existing"), "toolu_write"));
|
||||
expect(writePatches).toHaveLength(1);
|
||||
expect(writePatches[0]).not.toContain("new file mode");
|
||||
expect(writePatches[0]).toContain("-export const x = 0;\n+export const x = 1;");
|
||||
const edit = air("edit-with-permission");
|
||||
const approval = edit.findIndex((record) => record.kind === "requestPermission");
|
||||
expect(approval).toBeGreaterThan(0);
|
||||
expect(patches(toolCallReports(edit.slice(0, approval), "toolu_edit"))).toEqual([]);
|
||||
// Replayed: session/load renders the same tool uses from the transcript.
|
||||
const replay = await runAir(replayedEditsScenario());
|
||||
expect(patches(toolCallReports(replay))).toEqual([]);
|
||||
// A replay does not read old.ts: the disk shows a later state than the history.
|
||||
expect(toolCallReports(replay, "toolu_r_write")[0].content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: expect.stringMatching(/old\.ts$/),
|
||||
oldText: null,
|
||||
newText: "export const x = 1;\n",
|
||||
},
|
||||
]);
|
||||
expect(toolCallReports(replay, "toolu_r_edit")[0].content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: expect.stringMatching(/app\.ts$/),
|
||||
oldText: "const value = 1;",
|
||||
newText: "const value = 2;",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the Bash output as terminal deltas", () => {
|
||||
const reports = toolCallReports(air("bash-foreground"), "toolu_bash");
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_meta: { terminal_output_delta: { terminal_id: "toolu_bash", data: "a.ts\nb.ts" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** A Write that changes the file on disk before its PostToolUse hook runs. */
|
||||
function writtenFileScenario(): Scenario {
|
||||
return {
|
||||
name: "write-on-disk",
|
||||
files: { "old.ts": "export const x = 0;\n" },
|
||||
turns: [
|
||||
async function* (ctx) {
|
||||
const file = path.join(ctx.cwd, "old.ts");
|
||||
const input = { file_path: file, content: "export const x = 1;\n" };
|
||||
yield* assistantTurn("msg_write", [
|
||||
{ type: "tool_use", id: "toolu_write", name: "Write", input },
|
||||
]);
|
||||
fs.writeFileSync(file, input.content);
|
||||
yield toolResult("toolu_write", `The file ${file} has been updated successfully.`);
|
||||
await ctx.postToolUse("toolu_write", "Write", input, {
|
||||
type: "update",
|
||||
filePath: file,
|
||||
content: input.content,
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 1,
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
lines: ["-export const x = 0;", "+export const x = 1;"],
|
||||
},
|
||||
],
|
||||
originalFile: "export const x = 0;\n",
|
||||
});
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A session/load of a Write over an existing file and of an Edit. */
|
||||
function replayedEditsScenario(): Scenario {
|
||||
return {
|
||||
name: "replayed-edits",
|
||||
files: { "old.ts": "export const x = 0;\n", "app.ts": "line 1\nconst value = 1;\n" },
|
||||
transcript: ({ cwd, sessionId }) => [
|
||||
{
|
||||
type: "user",
|
||||
uuid: "00000000-0000-4000-8000-00000000f001",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: { role: "user", content: "Change both files" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "00000000-0000-4000-8000-00000000f002",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
id: "msg_r_edits",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_r_write",
|
||||
name: "Write",
|
||||
input: { file_path: path.join(cwd, "old.ts"), content: "export const x = 1;\n" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_r_edit",
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: path.join(cwd, "app.ts"),
|
||||
old_string: "const value = 1;",
|
||||
new_string: "const value = 2;",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
turns: [],
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"_askUserQuestionCustomAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_1","canStop":true,"description":"Build assets","name":"Build assets","outputFilePath":"/tmp/tasks/workflow_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
@@ -1,5 +0,0 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","canStop":true,"description":"Watch the logs","name":"Watch the logs","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow","toolCallId":"toolu_workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"first line","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"second line","usage":{"durationMs":6,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
@@ -1,3 +0,0 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_2","canStop":true,"description":"Build assets","name":"Build assets","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
@@ -1,14 +0,0 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bg"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Wait","version":1}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","canStop":true,"description":"sleep 100","name":"sleep 100","outputFilePath":"/tmp/tasks/bash_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"shell","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"asyncTasks":{"backgrounded":true}}},"terminal_output_delta":{"data":"Command running in background with ID: bash_1. Output is being written to: /tmp/tasks/bash_1.output. You will be notified when it completes.","terminal_id":"toolu_bg"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_bg"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","sessionUpdate":"async_task_progress","summary":"still running","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
@@ -1,10 +0,0 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Fail","version":1}}},"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"Exit code 1\nboom","terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
@@ -1,14 +0,0 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"List files","version":1}}},"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"ls -la","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"command":"ls -la","description":"List files"},"title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"a.ts\nb.ts","terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Do","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"ne.","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"error":"Context too small","version":1}}}},"content":[{"content":{"text":"Compaction failed: Context too small","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
@@ -1,7 +0,0 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1}}}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user