Compare commits

...

10 Commits

Author SHA1 Message Date
mark.tkachenko
a39e511c97 refactor: move AcpClient and Logger out of acp-agent.ts 2026-11-09 13:02:48 +01:00
Ben Brandt
a58293f4c9 fix: update to @anthropic-ai/claude-agent-sdk 0.3.319 (#1545)
Updates the Claude Agent SDK from 0.3.318 to 0.3.319, which bundles
Claude Code 2.1.319. Also updates `@anthropic-ai/sdk` (1.4.0 → 1.6.0)
and the transitive dependencies in the lockfile (`@babel/runtime`,
`@eslint-community/regexpp`, `@rolldown/pluginutils`, `hono`, `jose`,
`vite`).

## Reviewed, no change needed

- **`mcp_task` background tasks:** SDK sessions get these only with
`CLAUDE_AUTO_BACKGROUND_TASKS=1` and streaming input. The adapter does
not set that variable, so they don't occur by default. If a user sets
it, the Async Tasks panel shows the raw `mcp_task` type, and the
`resource_links` on the task notification are not forwarded. Left as a
possible follow-up.
- **`deleteSession()` rejects with `ENOTEMPTY`** when the session folder
is only partly removed. `session/delete` already rethrows any error
other than "not found".
- **`session_writes_lost`** is now sent only when the CLI exits 0. The
adapter only logs it.
- **`permissions.denyInsteadOfPrompting`:** with it set, the CLI denies
instead of calling `canUseTool`. That is the setting's intent.
- **`mcp_call.check_permissions`, `set_has_subscribers`,
`disableWindowsBashCommandSplit`:** the adapter does not use these.
- The comments on the `getSettings` and `unanswered` type workarounds
now name 0.3.319; both are still missing from the SDK's types.

## Not updated

TypeScript stays on 6.0.3: typescript-eslint 8.72.1 requires `typescript
<6.1.0`, so 7.0.2 has to wait for a typescript-eslint release that
supports it.

## Testing

`npm run build`, `npm run check` and `npm run test:run` pass. `npm run
test:integration` was not run.
2026-11-09 08:56:37 +00:00
PerryLink
3b22586cf6 fix(test): assert the blockquoted MCP startup transcript line (#1543)
`#1362` moved every transcript fallback for clients without
`session.notices` into its own blockquote paragraph (`\n\n> …\n\n`) and
updated the existing assertions, but missed the MCP server startup one.
`main` has been red since that merge:

- The `Build` job on `47b64c0` fails with an annotation at
`src/tests/acp-agent.test.ts` line 13860:

  ```
AssertionError: expected '\n\n> **MCP server "client-fs" failed…' to be
'**MCP server "client-fs" failed to st…'
  ```

- The neighbouring fallback tests already assert the blockquote form,
e.g. `"\n\n> **Warning:** hook says no\n\n"` and the two-line `Stop
says:` message.

This updates the expected string to the blockquoted text; product code
is untouched.

## Validation

- `npx vitest run src/tests/acp-agent.test.ts -t "falls back to a
transcript line without the notices capability"` → 1 passed
- `npx vitest run src/tests/acp-agent.test.ts` → 759 passed | 9 skipped
- `npm run check` → passes
2026-11-08 07:57:15 +00:00
alexwan
47b64c04de fix: start transcript notices on their own line for clients without session.notices (#1362)
## What

When a client does not advertise `session.notices`, a notice falls back
to an `agent_message_chunk` like `**Notice:** …`. Clients render
consecutive chunks as one markdown stream, so the fallback is glued to
Claude's last sentence. In Zed 1.24 a Stop hook's `systemMessage` shows
up as:

```
…推上去。**Notice:** Stop says: token: 3 calls
Stop says: hand off now
```

The fallback is now its own blockquote paragraph, with blank lines on
both sides:

```
…推上去。

> **Notice:** Stop says: token: 3 calls
> hand off now
```

## Behavior

- `noticeOrTranscriptUpdate` wraps the transcript text with
`transcriptBlock`: a leading blank line so it starts on a new line, `> `
on every line, and a trailing blank line so Claude's next chunk does not
continue the quote. This covers every transcript fallback (hook notices,
"Task stopped by user", "Fast mode turned off", "Auto mode
unavailable").
- Claude Code prefixes every line of a hook's `systemMessage` with
`<HookEvent> says: `. The informational fallback keeps that prefix on
the first line only, and only when every line repeats the same prefix
(`collapseHookSpeaker`).
- Clients that advertise `session.notices` get the same `notice` update
as before.
- `Turn.noticeTexts` still records `message.content` unchanged, so the
hook-blocked result de-duplication keeps working.

## Tests

- New unit tests for `transcriptBlock` and `collapseHookSpeaker`, plus
an end-to-end test for a two-line `notice`-level hook message.
- Updated the existing transcript fallback assertions to the blockquote
form.
- `npm run check`, `npm run build` and `npm run test:run` pass (3111
tests).

Co-authored-by: alex <hongchang.wan@longbridge-inc.com>
2026-11-06 22:14:44 +00:00
Jonathan Ellis
1ee2422934 fix: keep autonomous turn tasks open across owed trailing idles (#1455)
When Claude Code starts a new autonomous cycle before the previous
cycle's trailing idle arrives, that late idle completes the new cycle's
AIR async task while Claude is still working. The next output then
creates a second task for the same cycle.

This change completes the autonomous task inside the existing cycle-end
branch, which already checks that `owedTrailingIdles` is zero. An owed
idle is now only consumed by the existing accounting and leaves the
current task open. The task still completes at its own terminal result
or at an unowed idle, so the task and the cycle end on the same
decision.

It adds a scripted SDK-stream regression for two consecutive autonomous
cycles with no running transition between them. The test checks that the
late idle leaves the second task open and that later output stays on the
same task, for a cycle that ends at a result and for one that ends at an
unowed idle. Both cases fail before the fix and pass after it.

Fixes #1453.

Validation, rebased on 0.98.0 (c90f856):
- `npm run build` and `npm run check` pass.
- `npm run test:run`: 3,378 passed, 32 skipped. One unrelated test,
`tools.test.ts` "picks up a rewrite that restored the modification time
of the file", failed once in the full run on a heavily loaded machine.
It passed 3 of 3 runs alone, both on this branch and on `main`, and this
change does not touch `tools.ts` or its tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:11:36 +00:00
Kerekes Dániel
7d1ef76c1d fix: report setup-token logins as logged in (#1376)
Fixes #1295

With a `claude setup-token` login (`CLAUDE_CODE_OAUTH_TOKEN`), `claude
auth status --json` only returns `{"loggedIn": true, "authMethod":
"oauth_token", "apiProvider": "firstParty"}`. `fromCliStatus()` handed
that to `mapAuthFields()`, which has no plan or key fields to go on and
fell back to "Not logged in". Clients then showed a working session as
logged out.

This maps that exact state to `kind: "account"` with the label suggested
in the issue ("Claude subscription", detail "OAuth token"). Everything
else still goes through `mapAuthFields()`.

Added a case to `auth-status.test.ts` that fails without the change.
`npm run check`, `npm run build` and `npm run test:run` pass.
2026-11-06 23:10:04 +01:00
Hussain Hammad
aa0c1234b5 fix: replay a compaction summary by its flag, not its framing (#1536)
Fixes #1535

A compaction summary on replay was only sent as a `compaction_update` if
its text also parsed, so a change in Claude Code's framing turned it
into a `user_message_chunk`, shown as text the user typed. That is what
happens to summaries from Claude Code 2.1.312 and 2.1.316.

`isCompactSummary` now decides on its own. A summary whose framing isn't
recognized is sent as a completed `compaction_update` without summary
text. This reverses the choice to keep such text visible: the
model-facing framing isn't kept as a summary either way, and showing it
as the user's words is the worse failure.

`PERSISTED_PREAMBLE` also allows content before the `Summary:` line, so
current summaries keep their text. All 31 summaries in my own
transcripts now parse, and a real `session/load` on one from 2.1.316
sends a `compaction_update` with its text and no summary
`user_message_chunk`.
2026-11-06 22:09:30 +00:00
Alexis Rouillard
54392e144d fix: relay a rate_limit_event that arrives before the turn's first assistant frame (#1534)
## Problem

`rate_limit_event` is relayed to the client as
`usage_update._meta["_claude/rateLimit"]`, but only when
`lastAssistantTotalUsage !== null`. With partial messages on, the SDK
emits `rate_limit_event` before the turn's first `assistant` message,
while `lastAssistantTotalUsage` is still null (reset at each turn). The
SDK emits it once per turn, so in practice clients never receive
`_claude/rateLimit`.

Observed with a real subscription session (SDK 0.3.316): the SDK sends
`stream_event`s, then `rate_limit_event`, then the first `assistant`.
The adapter dropped the event and sent only `usage_update`s with
`_claude/model`.

## Fix

When the event arrives before the turn has any usage, keep its
`rate_limit_info` and attach it to the next `usage_update`. That happens
in `attachUsageModel`, which every `usage_update` already goes through.
A rate limit that the update already carries wins over the pending one.
When usage is already known, the event is sent right away, as before.

## Tests

- New test in `acp-agent.test.ts`: a `rate_limit_event` before the first
assistant frame ends up on the turn's first `usage_update`, and on no
later one. It fails without the fix.
- `npm run check`, `npm run build` and `npm run test:run` pass.
- Checked by hand over stdio with a real session: the turn's first
`usage_update` now carries `_claude/rateLimit`, including
`unifiedWindows.seven_day`.
2026-11-06 22:07:33 +00:00
Ben Brandt
6597626d86 fix: list a subagent's background Bash commands (#1540)
Claude Code sends no `tool_use_result` with a subagent's tool results.
The adapter recognizes a background Bash command only from the
`backgroundTaskId` in that structured result, so it never recognized one
that a subagent started. The command's terminal was reported exited
(code 0) as soon as the result came, while the command kept running, and
the command was missing from the subagent's `state_update.background`.

The real SDK stream (CLI 2.1.318) for a subagent running `sleep 30` in
the background:

```
task_started  task_id=beure6fer task_type=local_bash tool_use_id=toolu_018M… owned_by_subagent=true is_backgrounded=true
user          parent_tool_use_id=toolu_01HY… tool_result for toolu_018M…, no tool_use_result
```

The local transcripts agree: none of the 35 subagent background Bash
results across CLI 2.1.293 to 2.1.318 has a `toolUseResult`.

When a Bash result has no structured task id, the adapter now uses the
live background `local_bash` task whose `task_started` named that Bash
call. The existing subagent test now sends the stream the CLI sends. It
fails without the fix.
2026-11-06 19:12:42 +01:00
Ben Brandt
ba162346c9 fix: list Monitor calls in the session's background work (#1538)
Claude Code marks every live-update watcher `ambient`, "requested or
auto-started" per the SDK's `background_tasks_changed` docs, so the task
of a `Monitor` call the model made is ambient too. `background-work.ts`
left out every ambient task, so a running Monitor was never listed:

- its tool call went straight to `completed` instead of staying
`in_progress` until its task ends;
- a client couldn't stop it with `tool_call/cancel`;
- a client deciding whether a session is quiet, for example before
restarting or releasing it, saw no background work while the Monitor
kept starting turns.

Seen live in Delta against 0.101.0: a Monitor watching a loop kept
waking the agent every 20 seconds while every `state_update` reported
`background: []`.

## Change

- `toolResults` registers a task a tool call started even when it is
ambient, so the call stays `in_progress` and advertises `cancel`.
- The list builder lists a registered call's task before skipping
ambient tasks. Ambient tasks without a call of their own, such as
watchers Claude Code starts by itself, and ambient terminals stay out.

## Tests

- The Monitor fixture in `acp-v2.test.ts` now carries `ambient: true`,
as the CLI sends it. Without the fix, the four existing Monitor tests
fail with it.
- The `_claude/task` test is now an `it.each`: a non-ambient task
without a call is listed, and an ambient watcher without a call is not.
- `npm run check`, `npm run build`, `npm run test:run` pass. Not run:
`npm run test:integration` against the real CLI.
2026-11-06 12:12:58 +01:00
19 changed files with 584 additions and 285 deletions

120
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.4.0",
"@anthropic-ai/claude-agent-sdk": "0.3.318",
"@anthropic-ai/claude-agent-sdk": "0.3.319",
"diff": "9.0.0",
"zod": "4.6.7"
},
@@ -18,7 +18,7 @@
"claude-agent-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "1.4.0",
"@anthropic-ai/sdk": "1.6.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.6",
"@types/node": "26.6.6",
@@ -53,22 +53,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.318.tgz",
"integrity": "sha512-z5WHCUeW52SKQ3rnvI3zt8X5h2QQ5o3Cgg5xarO5Rimfe67f3pOJt2aOh00S8t/nRzqfs+8nCb9o1EoXe+yccA==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.319.tgz",
"integrity": "sha512-7X+sh4uZv/u4OL4I4cQvMr9N3NM6FDiuyk829hFylgbrnNwVyiGElWEDWGuO26zwMIxmgpKdPoDILoFIHh1uwA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.14.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.318",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.318",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.318",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.318"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.319",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.319"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -77,9 +77,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.318.tgz",
"integrity": "sha512-AWoXeDXzAMD3RaA21lWuHvCrKd8xmbTTmKjovninn4j07jheoVSMzFbCFjpp4kk5TdhT36JtWPkAxvjNMbcO8w==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.319.tgz",
"integrity": "sha512-rNiAbMbRhgOM2CELsDTIq7q++sFVIZukTkgeswcncGbcX8STIgtf0XaZzKdRXtKK+v9ez+mQn8P4pb2Ka17wGA==",
"cpu": [
"arm64"
],
@@ -90,9 +90,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.318.tgz",
"integrity": "sha512-6CkZ5PCdebSY/LePp16sf/HYRtM6zVuxtn8qYICFS5KUZFsjccJJ9swVsOdHXNHi4SWZcSmIFTwoUbfFCzisRw==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.319.tgz",
"integrity": "sha512-jM730Fl+cFotDnjF9cStIf+Xe57YdfDB3TXaSVVv8uVAXW4xoWFwIj2eDvmsciEEe5UMkKzjVqXu9kR21OrXng==",
"cpu": [
"x64"
],
@@ -103,9 +103,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.318.tgz",
"integrity": "sha512-mBm4su17aMitLBBCQj3bHdejyUDY6z/ormGih+HG24N689RSbdKECjGjkT87tDBIR/4jyoqmuSeHqM1DI8TJlg==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.319.tgz",
"integrity": "sha512-g2tpMsHLMQahI4bk5CIcMHAB/RwzxoPGmL5Q5G5MDir5WTrl71vulWbtLQISUPm/yFPRG97tdzZnCa5Uq1v/dw==",
"cpu": [
"arm64"
],
@@ -119,9 +119,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.318.tgz",
"integrity": "sha512-l4LSYFOrvlYCrUImGfxMTQ0wg+T3s9CBhrBvPF/0oJGKZg+QnxHHU8jUVm9e9/xYmDRTtqFIrr4id/5AfH4XGw==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.319.tgz",
"integrity": "sha512-8KDZQJAS2CEb5aGAvuXkpyHMc9TOM/zYTw2iKmbbVi4im0atw93EtJPapYyp0g/lQI/Xk8jMYdWU6Zlv9EWjLw==",
"cpu": [
"arm64"
],
@@ -135,9 +135,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.318.tgz",
"integrity": "sha512-+mE+ueBD3oRFdaG16rd0h+LMjdeivGxL0pr/+FZK7JeLWVu+yPSw/f/X02b9oK36/DWykiFTu2G6HnJ9/+YO6w==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.319.tgz",
"integrity": "sha512-uBAZA8x0hd5gVifyL5Lo73dVmi9xZeJ6jro2NO0al6yyWaw/6UL9xbsVh9lOhLFOmYIdSbQ2j4zAfnvG/t5F3Q==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.318.tgz",
"integrity": "sha512-SAF3lj/2OzTU84p+OOA13QEKA9+5fYAw0M9E7AJjdnRmVFGqQfbV7Do1i1f9Sj0mIubB3rLTRphGRw8O4HZjgA==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.319.tgz",
"integrity": "sha512-XkKYj3c1jVFjLATrZr4YTsmS5AIb5s2iphF9X670EcVQqDCCGIKKTVNLLg9mg1e7D+2xX8FU0XNlOJatWfpBeA==",
"cpu": [
"x64"
],
@@ -167,9 +167,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.318.tgz",
"integrity": "sha512-19iVSZ4379QbZU1XnGUtfFvhKS1WzS8H1sbU/DbrzqbJ5Kq1eB1drj+obB1fCxTh6m26tRPj0zMJmdeu/jk3Gw==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.319.tgz",
"integrity": "sha512-YoP0lYHJCziENFCotHQq9qd/gaJrwUNkAcnZyVjXxg1OPmtKuyw31aG60s4cWXRDXXGm/m1siNSQaGGfAYBvrw==",
"cpu": [
"arm64"
],
@@ -180,9 +180,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.318",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.318.tgz",
"integrity": "sha512-ZdKVWpyVNVt0cXrOf8sUBaxcxLEAP+oGMdQDLBWr/BqOxTWLy7TBWOj1kzw+wVg/9WIkjkgQWeJzGXPWulR1sA==",
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.319.tgz",
"integrity": "sha512-UC27x+8gfCS2OjNGJWrueP0gLOOKsLEsJ/kg0p6aa8GToZcJYjJ8KeAapHHRYMUAIB6Sarqe49Itm5P1wswCcQ==",
"cpu": [
"x64"
],
@@ -193,9 +193,9 @@
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.4.0.tgz",
"integrity": "sha512-tyuibnhQL57kLOx5oomY4Ld7jOVOPNC5v3IiyRtR3l+Friv66tI4VbuZYF2NMel/T0WMuQMBFOMKDBUbUh8gPA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.6.0.tgz",
"integrity": "sha512-XqiO/46n8S6So8jQ/6/uNCkHntyW6QHHs1gZ+hlwTx1fWDr929g4VmwIXfdMVk7SuBWNdQvAlwyi1G3t6SEIuQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.1",
@@ -215,9 +215,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.13.tgz",
"integrity": "sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -267,9 +267,9 @@
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.3.tgz",
"integrity": "sha512-R76UKbvsqI68DJJVTVyqFKqU98CN4+B+acxNfEA2IFarSzP0SjtKf/3+fFiXzTyv5Fj3PxLkGHe2wnVWWMxobQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -843,9 +843,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.1.0.tgz",
"integrity": "sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==",
"dev": true,
"license": "MIT",
"peer": true
@@ -2270,9 +2270,9 @@
}
},
"node_modules/hono": {
"version": "4.13.16",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.16.tgz",
"integrity": "sha512-AOLOEhvrV+iH92xXCnFr/5+3KjPKcI3xP63XJtX5spAnkznPdHijlIsUTpi7/9EhWqrTCekw5c5zh1K+YsVw7A==",
"version": "4.13.17",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.17.tgz",
"integrity": "sha512-vZIcbkvjOR5hHRWERbGkr8fGfF0GGM1ooYkuH448iO3b2AdVEr3AXZSw4Z3Kn+1L5z0CVoVeyV29VGYmFh05kQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -2408,9 +2408,9 @@
"license": "ISC"
},
"node_modules/jose": {
"version": "6.2.15",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz",
"integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==",
"version": "6.2.16",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.16.tgz",
"integrity": "sha512-4LSJpbuBuPdGrONCdZUy0xnxtLf8eEz+EY0SMXdHQEBU7bgUHDVblU/JAjryv3wID9/LK7/YuwFwq+AQNfMscQ==",
"license": "MIT",
"peer": true,
"funding": {
@@ -3699,17 +3699,17 @@
}
},
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz",
"integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"postcss": "^8.5.32",
"rolldown": "~1.2.16",
"tinyglobby": "^0.2.17"
},
"bin": {

View File

@@ -69,12 +69,12 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "2.4.0",
"@anthropic-ai/claude-agent-sdk": "0.3.318",
"@anthropic-ai/claude-agent-sdk": "0.3.319",
"diff": "9.0.0",
"zod": "4.6.7"
},
"devDependencies": {
"@anthropic-ai/sdk": "1.4.0",
"@anthropic-ai/sdk": "1.6.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.6",
"@types/node": "26.6.6",

View File

@@ -1,13 +1,11 @@
import {
agent as acpAgent,
AgentApp,
AgentContext,
AuthenticateRequest,
AuthMethod,
AvailableCommand,
CancelNotification,
ClientCapabilities,
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
DisableProviderRequest,
@@ -37,7 +35,6 @@ import {
RequestError,
SetProviderRequest,
SetProviderResponse,
RequestPermissionRequest,
RequestPermissionResponse,
ResumeSessionRequest,
ResumeSessionResponse,
@@ -106,6 +103,10 @@ import {
toGoalSnapshot,
} from "./goal-extension.js";
import { blobResourceToClaude } from "./blob-resources.js";
import { ChangedMetaClient, ClientConnection } from "./acp-client.js";
import type { AcpClient, Logger } from "./acp-client.js";
export type { AcpClient, Logger } from "./acp-client.js";
import { promptSuggestionsMeta, promptSuggestionText } from "./prompt-suggestions.js";
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
import {
@@ -284,6 +285,7 @@ import {
import { IdleCompaction } from "./idle-compaction.js";
import {
clientSupportsNotices,
collapseHookSpeaker,
MAX_NOTICE_TITLE_LENGTH,
normalizeNoticeText,
noticeOrTranscriptUpdate,
@@ -317,7 +319,6 @@ import {
withRunningTerminal,
withRunningToolCall,
} from "./tool-calls/background.js";
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
import {
@@ -426,16 +427,6 @@ function isEmptyUserInterruptionDiagnostic(
);
}
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
warn?: (...args: any[]) => void;
}
type AccumulatedUsage = {
inputTokens: number;
outputTokens: number;
@@ -2408,137 +2399,6 @@ export function isReplayHiddenMetaMessage(message: unknown): boolean {
);
}
/**
* Client-facing surface the agent calls back into. This is the subset of ACP
* client methods the agent actually uses, expressed as a narrow interface so
* tests can supply lightweight mocks. In production it is backed by
* {@link ClientConnection} over the SDK's typed `AgentContext`.
*/
export interface AcpClient {
sessionUpdate(params: AcpSessionNotification): Promise<void>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* permission request so the client can dismiss its prompt, and rejects the
* call at once: a client that ignores the cancel never answers. */
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse>;
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* elicitation so the client can dismiss its prompt, and rejects the call
* at once, as {@link requestPermission} does. */
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
}
/**
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
* valid for the entire connection lifetime, so it is captured once at
* construction.
*/
class ClientConnection implements AcpClient {
constructor(private readonly ctx: AgentContext) {}
sessionUpdate(params: AcpSessionNotification): Promise<void> {
return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
}
requestPermission(
params: RequestPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.ctx.request(methods.client.session.requestPermission, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.ctx.request(methods.client.fs.readTextFile, params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.ctx.request(methods.client.fs.writeTextFile, params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.ctx.request(methods.client.elicitation.create, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(methods.client.elicitation.complete, params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.ctx.notify(method, params);
}
}
/**
* The client that the agent talks to. For an AIR client, every session update
* passes the {@link ChangedMetaFilter} last, after the native subagent routing,
* so a `tool_call_update` carries only the `_meta` keys that changed. ACP
* merges only the top-level tool call fields, not the `_meta` keys. So another
* client gets the full `_meta` on each update.
*/
class ChangedMetaClient implements AcpClient {
private readonly filter = new ChangedMetaFilter();
constructor(
private readonly inner: AcpClient,
private readonly airClient: () => boolean,
) {}
async sessionUpdate(params: AcpSessionNotification): Promise<void> {
if (!this.airClient()) return this.inner.sessionUpdate(params);
const update = this.filter.apply(params.update as SessionNotification["update"]);
if (update) await this.inner.sessionUpdate({ ...params, update } as AcpSessionNotification);
}
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.inner.requestPermission(params, signal);
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.inner.readTextFile(params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.inner.writeTextFile(params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.inner.createElicitation(params, signal);
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.inner.completeElicitation(params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.inner.extNotification(method, params);
}
}
function raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const cleanup = () => signal.removeEventListener("abort", onAbort);
@@ -4660,6 +4520,8 @@ export class ClaudeAcpAgent {
let lastAssistantTotalUsage: number | null = null;
let lastAssistantUsage: UsageSnapshot | null = null;
let lastAssistantModel: string | null = null;
// A rate_limit_event that came before the turn had any usage to report.
let pendingRateLimit: unknown = null;
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
// problem, billing), it sets a categorical `error` field on the
// `SDKAssistantMessage` that precedes the final `result` message. We capture
@@ -4967,7 +4829,8 @@ export class ClaudeAcpAgent {
};
// ACP's usage_update has no model field, so the model the usage belongs
// to rides in `_meta` alongside the other `_claude/*` keys.
// to rides in `_meta` alongside the other `_claude/*` keys, as does a
// pending rate limit.
const attachUsageModel = <
T extends {
sessionUpdate: "usage_update";
@@ -4978,6 +4841,13 @@ export class ClaudeAcpAgent {
>(
update: T,
): T => {
if (pendingRateLimit !== null) {
update = {
...update,
_meta: { "_claude/rateLimit": pendingRateLimit, ...(update._meta ?? {}) },
};
pendingRateLimit = null;
}
if (!lastAssistantModel) return update;
return {
...update,
@@ -6404,8 +6274,6 @@ export class ClaudeAcpAgent {
!firstUnsettledQueuedTurn()
) {
await asyncTasks.autonomousTurnStarted();
} else if (message.state === "idle" && asyncTasks.autonomousTurnOpen) {
await asyncTasks.autonomousTurnEnded("completed");
}
if (
message.state === "running" &&
@@ -6437,6 +6305,9 @@ export class ClaudeAcpAgent {
// here. An owed idle belongs to an earlier result: it can lag
// past the cycle's first output.
if (session.owedTrailingIdles === 0) {
if (asyncTasks.autonomousTurnOpen) {
await asyncTasks.autonomousTurnEnded("completed");
}
await closeCycleToolCalls();
this.endAutonomousCycle(params.sessionId, {
outcome: { stopReason: session.cancelled ? "cancelled" : "end_turn" },
@@ -6709,10 +6580,11 @@ export class ClaudeAcpAgent {
}
}
const severity = message.level === "warning" ? "warning" : "info";
const transcriptContent = collapseHookSpeaker(message.content);
const transcriptText =
message.level === "info"
? message.content
: `**${sentenceCase(message.level)}:** ${message.content}`;
? transcriptContent
: `**${sentenceCase(message.level)}:** ${transcriptContent}`;
await sendUpdate({
sessionId: params.sessionId,
update: noticeOrTranscriptUpdate(
@@ -8502,6 +8374,18 @@ export class ClaudeAcpAgent {
content,
toolUseResult,
session.toolUseCache,
(toolUseId) => {
for (const [taskId, task] of session.liveBackgroundTasks) {
if (
task.parentToolUseId === toolUseId &&
task.taskType === "local_bash" &&
task.backgrounded
) {
return taskId;
}
}
return undefined;
},
);
if (backgroundBashTask) await asyncTasks.taskBackgrounded(backgroundBashTask);
// The command's terminal runs until its task ends, unless the
@@ -8676,7 +8560,11 @@ export class ClaudeAcpAgent {
}
case "rate_limit_event": {
session.idleCompaction?.noteRateLimit(message.rate_limit_info);
if (lastAssistantTotalUsage !== null) {
// With partial messages on, the SDK sends this before the turn's
// first assistant frame, while there is no usage yet to send it with.
if (lastAssistantTotalUsage === null) {
pendingRateLimit = message.rate_limit_info;
} else {
await sendUpdate({
sessionId: params.sessionId,
update: attachUsageModel({
@@ -10025,9 +9913,10 @@ export class ClaudeAcpAgent {
// The entity is keyed by the summary message's uuid, which differs from
// the live id (the `compacting` status uuid) — replay is terminal-first
// by the RFD, and no client merges a replay into a live entity store.
// A subagent's own compaction summary is not the root session's, and a
// summary whose framing is unrecognized stays visible as transcript
// text rather than vanishing behind a summary-less entity.
// A subagent's own compaction summary is not the root session's. A summary
// whose framing is unrecognized is still this compaction, without summary
// text: isCompactSummary says what it is, and the framing is Claude Code's
// to change.
if (
replayCompactionUpdates &&
isCompactSummaryMessage(message) &&
@@ -10043,10 +9932,9 @@ export class ClaudeAcpAgent {
airClient: this.toolCallCapabilities.air.client,
},
);
if (replayCompaction.recordSummary(assistantMessageText(message.message))) {
await replayCompaction.finish(message.uuid, "completed");
return;
}
replayCompaction.recordSummary(assistantMessageText(message.message));
await replayCompaction.finish(message.uuid, "completed");
return;
}
for (const notification of toAcpNotifications(

158
src/acp-client.ts Normal file
View File

@@ -0,0 +1,158 @@
import {
AgentContext,
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
methods,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk/unstable";
import { AcpSessionNotification, asSdkSessionNotification } from "./acp-subagents.js";
import { type AcpPermissionRequest } from "./permissions/presentation.js";
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
warn?: (...args: any[]) => void;
}
/**
* Client-facing surface the agent calls back into. This is the subset of ACP
* client methods the agent actually uses, expressed as a narrow interface so
* tests can supply lightweight mocks. In production it is backed by
* {@link ClientConnection} over the SDK's typed `AgentContext`.
*/
export interface AcpClient {
sessionUpdate(params: AcpSessionNotification): Promise<void>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* permission request so the client can dismiss its prompt, and rejects the
* call at once: a client that ignores the cancel never answers. */
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse>;
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* elicitation so the client can dismiss its prompt, and rejects the call
* at once, as {@link requestPermission} does. */
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
}
/**
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
* valid for the entire connection lifetime, so it is captured once at
* construction.
*/
export class ClientConnection implements AcpClient {
constructor(private readonly ctx: AgentContext) {}
sessionUpdate(params: AcpSessionNotification): Promise<void> {
return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
}
requestPermission(
params: RequestPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.ctx.request(methods.client.session.requestPermission, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.ctx.request(methods.client.fs.readTextFile, params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.ctx.request(methods.client.fs.writeTextFile, params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.ctx.request(methods.client.elicitation.create, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(methods.client.elicitation.complete, params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.ctx.notify(method, params);
}
}
/**
* The client that the agent talks to. For an AIR client, every session update
* passes the {@link ChangedMetaFilter} last, after the native subagent routing,
* so a `tool_call_update` carries only the `_meta` keys that changed. ACP
* merges only the top-level tool call fields, not the `_meta` keys. So another
* client gets the full `_meta` on each update.
*/
export class ChangedMetaClient implements AcpClient {
private readonly filter = new ChangedMetaFilter();
constructor(
private readonly inner: AcpClient,
private readonly airClient: () => boolean,
) {}
async sessionUpdate(params: AcpSessionNotification): Promise<void> {
if (!this.airClient()) return this.inner.sessionUpdate(params);
const update = this.filter.apply(params.update as SessionNotification["update"]);
if (update) await this.inner.sessionUpdate({ ...params, update } as AcpSessionNotification);
}
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.inner.requestPermission(params, signal);
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.inner.readTextFile(params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.inner.writeTextFile(params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.inner.createElicitation(params, signal);
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.inner.completeElicitation(params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.inner.extNotification(method, params);
}
}

View File

@@ -863,18 +863,29 @@ function recordPublished(task: AsyncTask, fields: Record<string, unknown>): void
* Recovers a background Bash task from the structured `backgroundTaskId` of
* its tool result. The text of a tool result never gives a task id: a
* foreground command can print any text.
*
* Claude Code sends no `tool_use_result` with a subagent's tool results. For
* those, `startedTaskOf` gives the background `local_bash` task whose
* `task_started` named the Bash call, which comes before the result.
*/
export function backgroundBashTaskFromToolResult(
content: unknown,
toolUseResult: unknown,
toolUses: Record<string, { name: string; input: unknown }>,
startedTaskOf?: (toolUseId: string) => string | undefined,
): (AsyncTaskStarted & { taskId: string; toolCallId: string }) | undefined {
if (!Array.isArray(content)) return undefined;
const toolResults = content.flatMap(toolResultBlock);
const bashResults = toolResults.filter((result) => toolUses[result.toolUseId]?.name === "Bash");
if (bashResults.length === 0) return undefined;
const backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
let backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
if (backgroundResults.length === 0 && startedTaskOf) {
backgroundResults = bashResults.flatMap((result) => {
const taskId = result.isError ? undefined : startedTaskOf(result.toolUseId);
return taskId ? [{ taskId, toolUseId: result.toolUseId }] : [];
});
}
const distinctTaskIds = new Set(backgroundResults.map((result) => result.taskId));
if (distinctTaskIds.size !== 1) return undefined;
const taskId = [...distinctTaskIds][0];

View File

@@ -210,6 +210,15 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
if (!externalBackend && !status.loggedIn && !status.apiKeySource) {
return notLoggedInAuthStatus();
}
if (
status.loggedIn &&
status.authMethod === "oauth_token" &&
status.apiProvider === "firstParty" &&
!status.apiKeySource &&
!status.subscriptionType
) {
return { kind: "account", label: "Claude subscription", detail: "OAuth token" };
}
return mapAuthFields({
apiProvider: status.apiProvider,
subscriptionType: status.subscriptionType,

View File

@@ -5,8 +5,9 @@
* "Background work").
*
* The lists are built from Claude Code's live background task set
* (`background_tasks_changed`), without the tasks it marks `ambient`, which
* are housekeeping that it started on its own. The root session and each
* (`background_tasks_changed`). Claude Code marks `ambient` its own
* housekeeping and every live-update watcher; those are left out, except a
* task that a tool call started, such as a `Monitor` call's. The root session and each
* announced subagent session (the subagents RFD form) has its own list. Each
* task is listed once, in the list of the session that runs it:
*
@@ -166,7 +167,11 @@ export class SessionBackgroundWork {
* `Monitor` or `Workflow` call, whose structured result names its task,
* or a call that a live task names as its tool use, such as a
* backgrounded MCP call. The task must be in the level: one that ended
* already, or that Claude Code marks `ambient`, leaves its call as it is.
* already leaves its call as it is. Claude Code marks every live-update
* watcher `ambient`, including the task of a `Monitor` call; a call
* started it, so it is work the client can see and stop, and is
* registered all the same. Ambient tasks without a call of their own,
* such as watchers Claude Code starts by itself, are never registered.
*
* Returns the ids of the registered calls, whose result then reports
* `in_progress` (see `withRunningToolCall`).
@@ -210,12 +215,7 @@ export class SessionBackgroundWork {
([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent,
)?.[0];
const task = taskId === undefined ? undefined : this.level.get(taskId);
if (
!task ||
task.ambient ||
task.taskType === "local_bash" ||
task.taskType === "local_agent"
) {
if (!task || task.taskType === "local_bash" || task.taskType === "local_agent") {
continue;
}
this.toolCalls.set(taskId!, {
@@ -363,6 +363,15 @@ export class SessionBackgroundWork {
const ownerOf = (parentToolUseId: string | undefined) =>
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : this.sessionId;
for (const [taskId, task] of this.level) {
// A registered call's task is listed even when ambient, as a `Monitor`
// call's is (see `toolResults`).
const toolCall = this.toolCalls.get(taskId);
if (toolCall) {
const session = ownerOf(toolCall.parentToolUseId);
if (session)
lists.get(session)!.push({ type: "tool_call", toolCallId: toolCall.toolCallId });
continue;
}
if (task.ambient) continue;
const terminal = this.terminals.get(taskId);
if (terminal) {
@@ -371,13 +380,6 @@ export class SessionBackgroundWork {
lists.get(session)!.push({ type: "terminal", terminalId: terminal.toolCallId });
continue;
}
const toolCall = this.toolCalls.get(taskId);
if (toolCall) {
const session = ownerOf(toolCall.parentToolUseId);
if (session)
lists.get(session)!.push({ type: "tool_call", toolCallId: toolCall.toolCallId });
continue;
}
// A command without a terminal yet is one that foreground work waits on.
// A subagent is listed below, as its session.
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;

View File

@@ -67,7 +67,7 @@ const ANALYSIS_BLOCKS = [
* it as the post-compaction user message. */
const CONTINUATION_PREAMBLE = /^\s*This session is being continued from a previous conversation/i;
const PERSISTED_PREAMBLE =
/^\s*This session is being continued from a previous conversation[^\n]*\n+Summary:[ \t]*\r?\n/;
/^\s*This session is being continued from a previous conversation[^\n]*\n(?:[\s\S]*?\n)?Summary:[ \t]*\r?\n/;
const PERSISTED_TRAILERS = [
/\n+If you need specific details from before compaction[\s\S]*$/,
/\n+(?:Please )?[Cc]ontinue the conversation from where it left (?:it )?off[\s\S]*$/,

View File

@@ -303,7 +303,7 @@ export function applyAskElicitationResponse(
questions: AskUserQuestion[],
options: { reportUnanswered?: boolean } = {},
): AskUserQuestionOutcome {
// Typed against the output schema: as of SDK 0.3.318 only it lists
// Typed against the output schema: as of SDK 0.3.319 only it lists
// `unanswered`, though the tool reads the same annotations from its input.
const annotations: NonNullable<AskUserQuestionOutput["annotations"]> = {};
const withAnnotations = (answers: AskUserQuestionOutput["answers"]) => ({

View File

@@ -50,11 +50,35 @@ export function noticeOrTranscriptUpdate(
if (supportsNotices) return noticeUpdate(notice);
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: transcriptText },
content: { type: "text", text: transcriptBlock(transcriptText) },
...(transcriptMeta ? { _meta: transcriptMeta } : {}),
};
}
/** Clients render consecutive agent_message_chunks as one markdown stream, so
* a bare `**Notice:** …` chunk lands mid-sentence: "…all done.**Notice:** Stop
* says: …". Put it in its own blockquote paragraph, blank lines on both sides,
* so it starts on a new line and Claude's next chunk does not continue the
* quote. */
export function transcriptBlock(text: string): string {
const lines = text.trim().split(/\r?\n/);
return `\n\n${lines.map((line) => (line ? `> ${line}` : ">")).join("\n")}\n\n`;
}
const HOOK_SPEAKER = /^[^\n:]{1,80} says: /;
/** Claude Code prefixes every line of a hook's `systemMessage` with
* "<HookEvent> says: ", so a two-line message reads "Stop says: a\nStop says:
* b". Keep the prefix on the first line only when every line repeats it. */
export function collapseHookSpeaker(text: string): string {
const lines = text.split(/\r?\n/);
const prefix = lines[0].match(HOOK_SPEAKER)?.[0];
if (!prefix || lines.length < 2 || !lines.every((line) => line.startsWith(prefix))) {
return text;
}
return [lines[0], ...lines.slice(1).map((line) => line.slice(prefix.length))].join("\n");
}
/** Fragments that read on after a bold label stand alone as a notice
* description, so start them with a capital. */
export function sentenceCase(text: string): string {

View File

@@ -25,7 +25,7 @@ export type UltracodeSettingsReport = {
};
// `Query.getSettings()` exists at runtime (sdk.mjs) but is missing from the
// SDK's type declarations as of 0.3.318.
// SDK's type declarations as of 0.3.319.
type GetSettingsQuery = Query & {
getSettings?: () => Promise<{
applied?: { ultracodeAvailable?: unknown; ultracodeRequested?: unknown };

View File

@@ -13858,7 +13858,7 @@ describe("MCP server startup failures", () => {
);
expect(lines).toHaveLength(1);
expect(lines[0].content.text).toBe(
'**MCP server "client-fs" failed to start:** Its tools are unavailable in this session. Run /mcp for the error.',
'\n\n> **MCP server "client-fs" failed to start:** Its tools are unavailable in this session. Run /mcp for the error.\n\n',
);
});
});
@@ -18610,8 +18610,8 @@ describe("assembled assistant text fallback", () => {
]);
expect(child.some((update) => update.sessionUpdate === "compaction_update")).toBe(false);
// Unrecognized framing: the text stays visible rather than vanishing
// behind a summary-less entity.
// Unrecognized framing: still the compaction isCompactSummary says it is,
// without summary text, and never a message from the user.
const unknownFraming = await replay(true, [
{
...summaryMessage("odd-summary"),
@@ -18622,12 +18622,14 @@ describe("assembled assistant text fallback", () => {
},
},
]);
expect(unknownFraming.some((update) => update.sessionUpdate === "compaction_update")).toBe(
false,
);
expect(unknownFraming.some((update) => update.sessionUpdate === "user_message_chunk")).toBe(
true,
);
expect(unknownFraming).toEqual([
{
sessionUpdate: "compaction_update",
compactionId: "odd-summary",
status: "completed",
_meta: { jetbrains: { air: { version: 1, contextCompaction: { version: 1 } } } },
},
]);
});
it("preserves the model response after multiple compactions in one turn", async () => {
@@ -18848,7 +18850,7 @@ describe("assembled assistant text fallback", () => {
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "1+2" }] });
const chunks = updates.filter(({ update }) => update.sessionUpdate === "agent_message_chunk");
expect(messageChunkTexts(updates)).toEqual(["**Warning:** hook says no"]);
expect(messageChunkTexts(updates)).toEqual(["\n\n> **Warning:** hook says no\n\n"]);
expect(chunks[0]?.update).toMatchObject({
_meta: { claudeCode: { kind: "informational", level: "warning" } },
});
@@ -18903,12 +18905,33 @@ describe("assembled assistant text fallback", () => {
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "1+2" }] });
const chunks = updates.filter(({ update }) => update.sessionUpdate === "agent_message_chunk");
expect(messageChunkTexts(updates)).toEqual(["plain hook detail"]);
expect(messageChunkTexts(updates)).toEqual(["\n\n> plain hook detail\n\n"]);
expect(chunks[0]?.update).toMatchObject({
_meta: { claudeCode: { kind: "informational", level: "info" } },
});
});
it("renders a multi-line hook notice as one quote with a single speaker prefix", async () => {
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
{
type: "system",
subtype: "informational",
content: "Stop says: token: 3 calls\nStop says: hand off now",
level: "notice",
session_id: "test-session",
},
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "1+2" }] });
expect(messageChunkTexts(updates)).toEqual([
"\n\n> **Notice:** Stop says: token: 3 calls\n> hand off now\n\n",
]);
});
it("delivers an informational frame as a notice and still owns the result text", async () => {
const { agent, updates } = createMockAgentWithCapture();
await initializeClient(agent, { session: { notices: {} } });
@@ -25951,6 +25974,82 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
]);
});
// Issue #1453: a previous cycle's trailing idle completed the next turn task.
it.each(["result", "idle"] as const)(
"keeps the next turn task open across an owed idle until its own %s",
async (end) => {
const { agent, asyncTaskUpdates } = await airAgent();
let wake = () => {};
const woken = new Promise<void>((resolve) => (wake = resolve));
let finish = () => {};
const finished = new Promise<void>((resolve) => (finish = resolve));
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
yield userEcho((await iter.next()).value);
yield running();
yield resultMessage();
yield idle();
await woken;
yield running();
yield assistantText("first autonomous cycle");
yield resultMessage({ origin: { kind: "task-notification" } });
// The CLI stays running: the next output opens a new turn task.
yield assistantText("next autonomous cycle");
yield idle(); // The previous autonomous result's late trailer.
await finished;
yield assistantText("still working in the next cycle");
if (end === "result") {
yield resultMessage({ origin: { kind: "task-notification" } });
}
yield idle();
}
return messageGenerator();
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
wake();
try {
await waitFor(
() =>
asyncTaskUpdates().filter((u) => u.sessionUpdate === "async_task_spawned").length ===
2 && agent.sessions["test-session"].owedTrailingIdles === 0,
);
const [first, firstEnd, next] = asyncTaskUpdates();
expect(asyncTaskUpdates()).toEqual([
first,
{
sessionUpdate: "async_task_state_update",
asyncTaskId: first.asyncTaskId,
state: "completed",
},
next,
]);
expect(firstEnd.state).toBe("completed");
expect(next.sessionUpdate).toBe("async_task_spawned");
expect(next.asyncTaskId).not.toBe(first.asyncTaskId);
} finally {
finish();
await agent.sessions["test-session"]?.consumer;
}
const [first, , next] = asyncTaskUpdates();
expect(asyncTaskUpdates()).toEqual([
first,
{
sessionUpdate: "async_task_state_update",
asyncTaskId: first.asyncTaskId,
state: "completed",
},
next,
{
sessionUpdate: "async_task_state_update",
asyncTaskId: next.asyncTaskId,
state: "completed",
},
]);
},
);
describe("started by a scheduler fire", () => {
const lifecycleStarted = (fire: { wakeup_source?: string; scheduled_for?: number }) =>
({
@@ -30206,6 +30305,61 @@ describe("agent selection config option", () => {
});
});
describe("rate_limit_event", () => {
it("rides on the next usage_update when it comes before the turn's first assistant frame", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: SessionNotification) => {
updates.push(notification);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
const rateLimit = { status: "allowed", resetsAt: 1700000000, rateLimitType: "seven_day" };
const usage = {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
injectGeneratorSession(agent, async function* (input) {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
yield {
type: "rate_limit_event",
rate_limit_info: rateLimit,
uuid: randomUUID(),
session_id: "test-session",
};
yield {
type: "assistant",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
message: {
id: "msg_rate",
role: "assistant",
model: "claude-sonnet-4-20250514",
content: [{ type: "text", text: "ok" }],
usage,
},
};
yield successfulResultMessage({ usage });
yield { type: "system", subtype: "session_state_changed", state: "idle" };
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
const usageMetas = updates
.map((notification) => notification.update)
.filter((update) => update.sessionUpdate === "usage_update")
.map((update) => update._meta?.["_claude/rateLimit"]);
expect(usageMetas[0]).toEqual(rateLimit);
expect(usageMetas.slice(1)).toEqual(usageMetas.slice(1).map(() => undefined));
});
});
describe("the SDK session id of a fresh Claude context", () => {
// After a clear-context restart the SDK runs under a new session id, and
// every SDK message carries it. The client knows only the ACP session id.

View File

@@ -4510,10 +4510,13 @@ describe("ACP v2 requests that subagents make", () => {
description: "make test",
tool_use_id: "toolu_bg",
parent_task_id: "agent-1",
owned_by_subagent: true,
is_backgrounded: true,
uuid: randomUUID(),
session_id: options.sessionId,
};
// As seen live (CLI 2.1.318): a subagent's tool result has no
// `tool_use_result`, so only the `task_started` above names the task.
yield childMessage(options, {
type: "user",
message: {
@@ -4526,13 +4529,6 @@ describe("ACP v2 requests that subagents make", () => {
},
],
},
tool_use_result: {
stdout: "",
stderr: "",
interrupted: false,
isImage: false,
backgroundTaskId: "bg1",
},
});
yield taskEnd(options, "agent-1", "toolu_agent-1");
yield level(options, [bash]);
@@ -5879,10 +5875,13 @@ describe("ACP v2 terminal of a command in the background", () => {
});
describe("a task that a tool call started", () => {
// Claude Code marks every live-update watcher ambient, a requested
// Monitor's task included.
const monitor = {
task_id: "mon1",
task_type: "local_monitor",
description: "Watch the logs",
ambient: true,
};
const workflow = { task_id: "wf1", task_type: "local_workflow", description: "Review" };
/** A Monitor call whose task `mon1` runs on after the call returns. The
@@ -6107,25 +6106,34 @@ describe("ACP v2 terminal of a command in the background", () => {
});
});
it("lists a task without such a call as a _claude/task item", async () => {
scriptTurns(async function* (options) {
yield level(options, [monitor]);
// No tool call started it, such as a task that a hook started.
yield taskEvent(options, "task_started", monitor);
yield result(options);
yield idleState(options);
});
const client = await promptUntil("idle end_turn [_claude/task]");
// No tool call started these, such as tasks that a hook started.
it.each([
{
task: workflow,
trace: ["running []", "running [_claude/task]", "idle end_turn [_claude/task]"],
background: [{ type: "_claude/task", taskId: "wf1", description: "Review" }],
},
// A watcher Claude Code started by itself is housekeeping.
{
task: monitor,
trace: ["running []", "idle end_turn []"],
background: [],
},
])(
"lists a $task.task_type task without such a call unless it is ambient",
async ({ task, trace, background }) => {
scriptTurns(async function* (options) {
yield level(options, [task]);
yield taskEvent(options, "task_started", task);
yield result(options);
yield idleState(options);
});
const client = await promptUntil(trace.at(-1)!);
expect(backgroundTrace(client)).toEqual([
"running []",
"running [_claude/task]",
"idle end_turn [_claude/task]",
]);
expect(client.updates("state_update").at(-1)).toMatchObject({
background: [{ type: "_claude/task", taskId: "mon1", description: "Watch the logs" }],
});
});
expect(backgroundTrace(client)).toEqual(trace);
expect(client.updates("state_update").at(-1)).toMatchObject({ background });
},
);
});
it("rejects terminal/cancel with -32602 when the command ends before Claude Code stops it", async () => {

View File

@@ -168,7 +168,10 @@ describe("AsyncTaskRuntime", () => {
expect(published[1]?.update).not.toHaveProperty("summary");
expect(published.at(-1)?.update).toMatchObject({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "**Task stopped by user:** Build generated assets." },
content: {
type: "text",
text: "\n\n> **Task stopped by user:** Build generated assets.\n\n",
},
});
});
@@ -234,7 +237,7 @@ describe("AsyncTaskRuntime", () => {
]);
expect(published.at(-1)?.update).toMatchObject({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "**Task stopped by user:** npm run build." },
content: { type: "text", text: "\n\n> **Task stopped by user:** npm run build.\n\n" },
});
});
@@ -264,7 +267,7 @@ describe("AsyncTaskRuntime", () => {
});
expect(published.at(-1)?.update).toMatchObject({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "**Task stopped by user:** npm run build." },
content: { type: "text", text: "\n\n> **Task stopped by user:** npm run build.\n\n" },
});
});

View File

@@ -306,6 +306,14 @@ describe("auth status mappers", () => {
});
});
it("maps a setup-token CLI login without profile fields to an account", () => {
expect(
fromCliStatus(
JSON.stringify({ loggedIn: true, authMethod: "oauth_token", apiProvider: "firstParty" }),
),
).toEqual({ kind: "account", label: "Claude subscription", detail: "OAuth token" });
});
it("lets a third-party backend outrank the logged-out flag", () => {
// `loggedIn` tracks the claude.ai login only; on Bedrock the credentials
// are external and the agent is authenticated all the same.

View File

@@ -130,6 +130,17 @@ describe("compactionSummaryText", () => {
);
});
it("strips the framing when other content sits before the Summary line", () => {
expect(
compactionSummaryText(
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n" +
"<system-reminder>\n<tools-used-before-summary>Read, Bash</tools-used-before-summary>\n</system-reminder>\n" +
"Summary:\n1. Primary Request and Intent:\n Count upward.\n\n" +
"Continue the conversation from where it left off without asking the user any further questions.",
),
).toBe("1. Primary Request and Intent:\n Count upward.");
});
it("strips the older tagged transcript framing", () => {
expect(
compactionSummaryText(

View File

@@ -413,7 +413,7 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "**Fast mode turned off:** requires extra usage to be enabled for this account.",
text: "\n\n> **Fast mode turned off:** requires extra usage to be enabled for this account.\n\n",
},
});
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });

View File

@@ -112,7 +112,7 @@ describe("session mode", () => {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "**Auto mode unavailable:** the selected model does not support Auto mode; using Accept edits instead.",
text: "\n\n> **Auto mode unavailable:** the selected model does not support Auto mode; using Accept edits instead.\n\n",
},
});
});

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
clientSupportsNotices,
collapseHookSpeaker,
MAX_NOTICE_TITLE_LENGTH,
normalizeNoticeText,
noticeOrTranscriptUpdate,
@@ -8,6 +9,7 @@ import {
noticeTranscriptText,
sentenceCase,
splitNoticeText,
transcriptBlock,
} from "../session-notices.js";
describe("session notices", () => {
@@ -30,21 +32,42 @@ describe("session notices", () => {
expect(noticeTranscriptText({ severity: "info", title: "Done" })).toBe("**Done**");
expect(noticeOrTranscriptUpdate(notice, false)).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "**Fast mode turned off:** Why." },
content: { type: "text", text: "\n\n> **Fast mode turned off:** Why.\n\n" },
});
expect(noticeOrTranscriptUpdate(notice, false, "custom line")).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "custom line" },
content: { type: "text", text: "\n\n> custom line\n\n" },
});
const meta = { claudeCode: { kind: "informational", level: "warning" } };
expect(noticeOrTranscriptUpdate(notice, false, "custom line", meta)).toEqual({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "custom line" },
content: { type: "text", text: "\n\n> custom line\n\n" },
_meta: meta,
});
expect(noticeOrTranscriptUpdate(notice, true, "custom line", meta)).not.toHaveProperty("_meta");
});
it("puts a transcript notice in its own blockquote paragraph", () => {
// Without blank lines the chunk renders glued to Claude's last sentence:
// "…all done.**Notice:** Stop says: …".
expect(transcriptBlock("**Notice:** one line")).toBe("\n\n> **Notice:** one line\n\n");
expect(transcriptBlock("**Notice:** first\nsecond\n\nfourth\n")).toBe(
"\n\n> **Notice:** first\n> second\n>\n> fourth\n\n",
);
});
it("keeps a hook's repeated speaker prefix on the first line only", () => {
expect(collapseHookSpeaker("Stop says: token: 3 calls\nStop says: hand off now")).toBe(
"Stop says: token: 3 calls\nhand off now",
);
expect(collapseHookSpeaker("Stop says: one line")).toBe("Stop says: one line");
// Left alone unless every line repeats the same prefix.
expect(collapseHookSpeaker("Stop says: a\nsomething else")).toBe(
"Stop says: a\nsomething else",
);
expect(collapseHookSpeaker("no prefix\nStop says: b")).toBe("no prefix\nStop says: b");
});
it("sends a notice update when the client can present one", () => {
expect(
noticeOrTranscriptUpdate({ severity: "info", title: "Task stopped by user" }, true),