Compare commits

..

14 Commits

Author SHA1 Message Date
Slapoguzov Alexander
e369479f93 fix: preserve foreground fork reports until launch completes (#1431)
A foreground fork can stream a copied `tool_result` for the Agent call
that launched it. That placeholder completed the call and removed its
tracked state, so the real report arriving later was dropped. Ignore
only results whose tool ID equals the message's parent tool ID, keeping
the launching call open for its actual report.

Keep other results in the same message and discard the message-level
`tool_use_result` sidecar when its launch result was removed, so it
cannot be attributed to a remaining tool result. Regression coverage
exercises plain and Air clients, root and nested launch results, and a
mixed message containing a genuine nested Agent result.

Fixes #1389.

Validation:

- [Linux
CI](https://github.com/agentclientprotocol/claude-agent-acp/actions/runs/40217593917)
passes the full suite: 3,304 passed, 32 skipped (57 test files passed, 4
skipped). All PR checks are green.
- `npm run check` and `npm run build` pass.
- `vitest run src/tests/acp-agent.test.ts --maxWorkers=1`: 712 passed,
10 skipped. The four new regression cases fail on the original code and
pass with the fix.
- Eight actual-SDK runs against localhost fake gateways complete
successfully without model calls: plain and Air foreground forks, native
Air child sessions, nested forks with the background option omitted or
false, the SDK's fork-inside-fork rejection, a background launch
receipt, and history replay. Foreground reports arrive exactly once and
no launch-result tracking warning remains.

The local full-suite run encountered 19 failures in
`session-index.test.ts` and then stopped progressing, so it was
interrupted. A standalone recursive-file-watcher probe also emits
`EMFILE` on this machine. The successful Linux CI run above provides the
complete-suite verification.

Review dependencies (updated 26 October):

| Related proposal | Relationship to this PR |
| --- | --- |
|
[#1408](https://github.com/agentclientprotocol/claude-agent-acp/pull/1408)
— targeted session lookup | Independent. Both orders merge cleanly onto
the checked main and produce the same tree. |
|
[#1422](https://github.com/agentclientprotocol/claude-agent-acp/pull/1422)
— resumed subagents | Complementary, with no dependency on this PR. It
now conflicts with upstream main in `src/acp-agent.ts`; its author needs
to resolve that conflict, integrate the [separately shared AIR routing
patch](https://github.com/agentclientprotocol/claude-agent-acp/pull/1422#issuecomment-6401008862)
and complete its validation. |

<details>
<summary>Additional local integration validation — 25 October</summary>

A local combination of the recorded main, #1408, #1431, #1422 and the
already-reviewed routing patch passed 199 focused
foreground/resume/native-subagent checks (647 other cases filtered or
skipped), TypeScript build, and two replays of previously captured SDK
frames for AIR and standard subagents. The two captured-frame replays
were independently repeated. No new model calls were made.

The combination is a private test tree, not a published change. It does
not replace full CI for #1422 or live Air acceptance. The routing patch
needed three-way application against that recorded main because its
surrounding source had moved; it applied cleanly that way. The
current-main conflict in #1422 means this historical result does not
establish today’s combined compatibility.

Checked main `739dde3`, #1408 `e3b3aa4`, #1431 `8f96f46`, #1422
`dd3bf2c`. The two orders of #1408 and #1431 produce tree `1716d7e`; the
full local test tree is `61c0447`. Refresh this evidence if these inputs
change.

</details>

Co-authored-by: Codex <codex@openai.com>
2026-10-26 11:20:15 +01:00
dependabot[bot]
c26e640915 fix: bump body-parser from 2.3.0 to 2.4.0 (#1436)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.3.0
to 2.4.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/expressjs/body-parser/releases">body-parser's
releases</a>.</em></p>
<blockquote>
<h2>v2.4.0</h2>
<h2>Important: Security</h2>
<ul>
<li>Security fix for <a
href="https://www.cve.org/CVERecord?id=CVE-2026-111181">CVE-2026-111181</a>
(<a
href="https://github.com/expressjs/body-parser/security/advisories/GHSA-27r3-jxmh-p828">GHSA-27r3-jxmh-p828</a>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>build(deps): bump actions/checkout from 6.0.3 to 7.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/737">expressjs/body-parser#737</a></li>
<li>chore: group github actions updates by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/743">expressjs/body-parser#743</a></li>
<li>build(deps): bump the github-actions group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/745">expressjs/body-parser#745</a></li>
<li>test: fix 'presist' typo in test descriptions by <a
href="https://github.com/maxtaran2010"><code>@​maxtaran2010</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/749">expressjs/body-parser#749</a></li>
<li>docs: include security fix in 2.3.0 changes by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/748">expressjs/body-parser#748</a></li>
<li>fix: avoid mutating read options when reading body by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/742">expressjs/body-parser#742</a></li>
<li>refactor: move getCharset helper to only usage by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/707">expressjs/body-parser#707</a></li>
<li>build(deps): bump the github-actions group with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/753">expressjs/body-parser#753</a></li>
<li>refactor: switch to neostandard by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/751">expressjs/body-parser#751</a></li>
<li>perf(json): avoid first-char regex for direct JSON bodies by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/744">expressjs/body-parser#744</a></li>
<li>docs: fix entity verify error description by <a
href="https://github.com/Rawal27"><code>@​Rawal27</code></a> in <a
href="https://redirect.github.com/expressjs/body-parser/pull/755">expressjs/body-parser#755</a></li>
<li>feat: zstandard support by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/586">expressjs/body-parser#586</a></li>
<li>test: remove unused experimentalZstd option by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/757">expressjs/body-parser#757</a></li>
<li>ci: run tests on windows and macos matrix by <a
href="https://github.com/kilisamemarisaaa"><code>@​kilisamemarisaaa</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/759">expressjs/body-parser#759</a></li>
<li>deps: qs@^6.16.0 by <a
href="https://github.com/krzysdz"><code>@​krzysdz</code></a> in <a
href="https://redirect.github.com/expressjs/body-parser/pull/762">expressjs/body-parser#762</a></li>
<li>build(deps): bump the github-actions group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/763">expressjs/body-parser#763</a></li>
<li>ci: trigger npm publish on release published and set dist-tag by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/768">expressjs/body-parser#768</a></li>
<li>build(deps): bump the github-actions group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/771">expressjs/body-parser#771</a></li>
<li>fix(test): pin supertest to 7.3.0 to avoid 7.3.1 regression by <a
href="https://github.com/saadkhantareen"><code>@​saadkhantareen</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/773">expressjs/body-parser#773</a></li>
<li>perf: inline content stream setup by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/750">expressjs/body-parser#750</a></li>
<li>docs: note that req.body may be undefined in v2 by <a
href="https://github.com/Hashim1999164"><code>@​Hashim1999164</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/752">expressjs/body-parser#752</a></li>
<li>docs: fix strict option description by <a
href="https://github.com/Rawal27"><code>@​Rawal27</code></a> in <a
href="https://redirect.github.com/expressjs/body-parser/pull/756">expressjs/body-parser#756</a></li>
<li>docs: fix depth error description by <a
href="https://github.com/johnnyhuirilef"><code>@​johnnyhuirilef</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/776">expressjs/body-parser#776</a></li>
<li>chore: update dependencies by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/778">expressjs/body-parser#778</a></li>
<li>Release: 2.4.0 by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/777">expressjs/body-parser#777</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/maxtaran2010"><code>@​maxtaran2010</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/749">expressjs/body-parser#749</a></li>
<li><a href="https://github.com/Rawal27"><code>@​Rawal27</code></a> made
their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/755">expressjs/body-parser#755</a></li>
<li><a
href="https://github.com/kilisamemarisaaa"><code>@​kilisamemarisaaa</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/759">expressjs/body-parser#759</a></li>
<li><a
href="https://github.com/saadkhantareen"><code>@​saadkhantareen</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/773">expressjs/body-parser#773</a></li>
<li><a
href="https://github.com/Hashim1999164"><code>@​Hashim1999164</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/752">expressjs/body-parser#752</a></li>
<li><a
href="https://github.com/johnnyhuirilef"><code>@​johnnyhuirilef</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/776">expressjs/body-parser#776</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0">https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/expressjs/body-parser/blob/master/HISTORY.md">body-parser's
changelog</a>.</em></p>
<blockquote>
<h1>2.4.0</h1>
<ul>
<li>Security fix for <a
href="https://github.com/expressjs/body-parser/security/advisories/GHSA-27r3-jxmh-p828">GHSA-27r3-jxmh-p828</a>
(CVE-2026-111181)</li>
<li>feat: add <code>zstd</code> content-encoding support (<a
href="https://redirect.github.com/expressjs/body-parser/issues/586">#586</a>)
<ul>
<li>Requires Node.js <code>^22.15.0</code> or <code>&gt;=23.8.0</code>;
older versions keep returning <code>415</code> for <code>zstd</code>
encoded bodies</li>
</ul>
</li>
<li>fix: avoid mutating the parser options and the request object while
reading the body (<a
href="https://redirect.github.com/expressjs/body-parser/issues/742">#742</a>,
<a
href="https://redirect.github.com/expressjs/body-parser/issues/750">#750</a>)</li>
<li>perf(json): avoid first-char regex for direct JSON bodies (<a
href="https://redirect.github.com/expressjs/body-parser/issues/744">#744</a>)</li>
<li>deps:
<ul>
<li>content-type@^2.1.0</li>
<li>iconv-lite^0.7.3</li>
<li>qs@^6.16.1</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b4744d739e"><code>b4744d7</code></a>
2.4.0 (<a
href="https://redirect.github.com/expressjs/body-parser/issues/777">#777</a>)</li>
<li><a
href="f7075eae3a"><code>f7075ea</code></a>
fix: prevent process crash from trailing bytes after a compressed
body</li>
<li><a
href="185ff1d555"><code>185ff1d</code></a>
chore: update dependencies (<a
href="https://redirect.github.com/expressjs/body-parser/issues/778">#778</a>)</li>
<li><a
href="73713f76c4"><code>73713f7</code></a>
docs: fix depth error description (<a
href="https://redirect.github.com/expressjs/body-parser/issues/776">#776</a>)</li>
<li><a
href="2dcc826c6d"><code>2dcc826</code></a>
docs: improve readability (<a
href="https://redirect.github.com/expressjs/body-parser/issues/756">#756</a>)</li>
<li><a
href="402dbdc6f1"><code>402dbdc</code></a>
docs: note that req.body may be undefined in v2 (<a
href="https://redirect.github.com/expressjs/body-parser/issues/752">#752</a>)</li>
<li><a
href="9883a1eeb4"><code>9883a1e</code></a>
perf: inline content stream setup (<a
href="https://redirect.github.com/expressjs/body-parser/issues/750">#750</a>)</li>
<li><a
href="02101da415"><code>02101da</code></a>
fix(test): pin supertest to 7.3.0 to avoid 7.3.1 regression (<a
href="https://redirect.github.com/expressjs/body-parser/issues/773">#773</a>)</li>
<li><a
href="822a0fd462"><code>822a0fd</code></a>
build(deps): bump the github-actions group with 3 updates (<a
href="https://redirect.github.com/expressjs/body-parser/issues/771">#771</a>)</li>
<li><a
href="aa9889bc35"><code>aa9889b</code></a>
ci: trigger npm publish on release published and set dist-tag (<a
href="https://redirect.github.com/expressjs/body-parser/issues/768">#768</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for body-parser since your current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=body-parser&package-manager=npm_and_yarn&previous-version=2.3.0&new-version=2.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/agentclientprotocol/claude-agent-acp/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:19:09 +01:00
acp-release-bot[bot]
f6958db1ee chore(main): release 0.96.0 (#1425)
🤖 I have created a release *beep* *boop*
---


##
[0.96.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.95.0...v0.96.0)
(2026-10-26)


### Features

* report SendMessage delegations as linked session messages
([#1424](https://github.com/agentclientprotocol/claude-agent-acp/issues/1424))
([739dde3](739dde3ae5))
* report the model of each native subagent
([#1432](https://github.com/agentclientprotocol/claude-agent-acp/issues/1432))
([c319e5e](c319e5e542))


### Bug Fixes

* do not hold an AIR load replay until the CLI starts
([#1433](https://github.com/agentclientprotocol/claude-agent-acp/issues/1433))
([eb9d63e](eb9d63e5fd))
* refine RFD child models after a load, and retry a failed model update
([#1434](https://github.com/agentclientprotocol/claude-agent-acp/issues/1434))
([9a8bf71](9a8bf71d45))
* send subagents' current state after a load response
([#1423](https://github.com/agentclientprotocol/claude-agent-acp/issues/1423))
([17a967b](17a967b4da))
* update to Claude Code 2.1.310
([#1435](https://github.com/agentclientprotocol/claude-agent-acp/issues/1435))
([2f3c6bb](2f3c6bb7b3))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-26 09:50:05 +00:00
Ben Brandt
2f3c6bb7b3 fix: update to Claude Code 2.1.310 (#1435)
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.309 to **0.3.310**
(Claude Code **2.1.310**), `@agentclientprotocol/sdk` 2.0.1 → 2.0.2 and
`@anthropic-ai/sdk` 1.1.0 → 1.3.0.

TypeScript stays on 6.0.3, because typescript-eslint 8.72 still requires
`typescript <6.1.0`.

## Behaviour changes ACP users may notice

- **2.1.310:** the Monitor tool and plugin monitors now run on Bedrock,
Vertex and Foundry, and with telemetry or nonessential traffic disabled.
On those setups, long foreground sleeps are now blocked. We already hide
Monitor tasks from the client (`local_monitor` / `HIDDEN_TASK_TOOLS`),
so the only visible change is that a blocked sleep shows up as a failed
Bash call.
- **2.1.310:** when an MCP tool call times out because its elicitation
went unanswered, the error now tells Claude not to repeat the call.

## Upstream fixes this picks up

- Requests behind gateways that reject the `effort-2025-11-24` beta or
mid-conversation system messages are retried without them. This affects
our `providers/set` gateway sessions.
- After a "Prompt is too long" error, compaction now trims dense content
(base64, SVG) properly, so the next request no longer fails again.
Compaction in SDK sessions with very large transcripts also uses less
memory.
- File read permission prompts now show where a symbolic link leads.
- Windows: PreToolUse and PermissionRequest hooks no longer let a tool
call through when Git Bash is missing and the hook's command is only
quoted strings.

## Checked, no change needed

- **Agent SDK 0.3.310:** the `sdk.d.ts` diff is doc text only
(`connectorId` in the `sdkMcpServerConfigs` comment, and the provider
list for `'highlights'` thinking display). Every `CLAUDE_*` env var the
adapter reads is still in the 2.1.310 binary.
- **ACP SDK 2.0.2:** only fixes the HTTP transport (CR line endings in
SSE, request-scoped POST failures), which we don't use.
- **`@anthropic-ai/sdk` 1.2/1.3:** adds API endpoints (message threads,
usage/cost reports, Claude Tag); we only use its types.

Verified: `npm run build`, `npm run check`, `npm run test:run` (3323
passed, 30 skipped), and a typecheck of `examples/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-26 10:46:44 +01:00
nikita-ashihmin
9a8bf71d45 fix: refine RFD child models after a load, and retry a failed model update (#1434)
## Summary

Follow-up to #1433, which was merged before these review fixes landed.

- **RFD child models after a load.** On a real `session/load` (session
not live in this process), #1433 also gave `RfdSubagentReplay` no model
list, so after the load response `currentStates` could not map a child's
model to the root session's `model` entry. RFD children kept the raw
model ID. The replay now reads the session's models when needed; only
the in-replay mapping is off while the load replays.
- **Retry after a failed publish.** `reportModel` skipped a model that
matched the stored options, which are written before the publish. A
failed publish then blocked every later report of the same model. It now
compares with the last model the client actually received.
- **Failed load replay.** The replayed child models are also dropped
when the replay itself fails.
- The buffer of an unannounced child collapses only model reports from
`reportModel`; other config updates stay as they are.

## Tests

- `replayRfd` gained a `load` option that replays like a real load;
"sends the model of each child's responses, and after the load response
only a changed entry" uses it (fails without the fix).
- A retry after a failed publish, for the RFD and AIR forms.
- "refines an ended AIR child's model" now goes through the load path.
- `npm run build`, `npm run check`, `npm run test:run`: 3323 passed, 30
skipped.

## Related

- Follow-up to #1433 and #1432
- RFD change:
https://github.com/agentclientprotocol/agent-client-protocol/pull/2482
- RFD section: [Child
configuration](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx#child-configuration)
- JetBrains issue:
[IJAI-4754](https://youtrack.jetbrains.com/issue/IJAI-4754)
2026-10-25 23:24:19 -07:00
nikita-ashihmin
eb9d63e5fd fix: do not hold an AIR load replay until the CLI starts (#1433)
## Summary

#1432 made an AIR `session/load` replay wait for the CLI start before it
sent the children's models. So the end of every such replay
(`loadReplayEnd` and the children's `subagent_state_update`) came only
after the CLI started, also for a history without subagents.

- The replay no longer waits. It sends each child's model as the replay
reaches it. During a load the session's model list is not known yet, so
the model goes as its id.
- After the load response, each replayed child whose model the list maps
to another entry gets that entry. This also applies to a child that the
replay already ended. It is a more exact snapshot of the same
configuration.
- `modelEntryFor` and `modelDisplayName` keep their results per model
list. `reportModel` skips a model that the child already has.
- The buffer of a child that is not announced yet keeps only its latest
configuration update, so model reports do not crowd out the child's
output.
- AIR: the runtime forgets the configuration of an ended child.

## Validation

- `npm run build`, `npm run check`
- `npm run test:run`, two runs: 3321 passed, 30 skipped.
- `src/tests/acp-scenarios.test.ts`, 30 runs in 6 parallel workers: no
failure.
- New tests: the load end event before the CLI start, with and without
subagents (both fail on `main`); the post-load refinement of an ended
child; the buffer; the cleanup; the memo after a list change.
- Microbenchmark of 10,000 child responses over 200 children, with 51
models:

  | | before | after |
  |---|---|---|
  | `modelEntryFor`, last entry | 12.16 µs | 0.23 µs |
  | `modelEntryFor`, no match | 64.60 µs | 0.08 µs |
  | `reportModel`, AIR | 13.66 µs | 4.03 µs |
  | `reportModel`, RFD | 12.80 µs | 3.23 µs |

## Related

- Follow-up to
https://github.com/agentclientprotocol/claude-agent-acp/pull/1432
- RFD change:
https://github.com/agentclientprotocol/agent-client-protocol/pull/2482
- JetBrains issue:
[IJAI-4754](https://youtrack.jetbrains.com/issue/IJAI-4754)
2026-10-25 23:05:36 -07:00
nikita-ashihmin
c319e5e542 feat: report the model of each native subagent (#1432)
## Summary

A native subagent child session gets its model only from what the child
reports. The update is a `config_option_update` with the child's session
id and a single `model` select option. This follows the subagents RFD,
[Child
configuration](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx#child-configuration)
(see also agentclientprotocol/agent-client-protocol#2482).

## Relation to #1417

#1417 reports a child's model from its responses, in the `rfd` form
only, with the raw model id as the name. This PR keeps that source and
extends it:

- The `air` form gets the same update.
- History replay sends each child's model from its transcript.
- The value maps to an entry of the root session's `model` option, so a
client can compare the child's model with the session's.

There is no model at spawn. The adapter cannot tell the model that
Claude Code picks for a new child, for example with
`CLAUDE_CODE_SUBAGENT_MODEL_FORCE`, or when an alias runs on the
parent's exact model. So the first update comes with the child's first
real response (`message.model`, not `<synthetic>`). A new update goes
only when the entry changes.

The configuration hooks of #1417 (`PostModelSwitch` and the others) now
also run for `air` clients. A hook names the SDK agent id, so in the
`air` form its options go to the agent's current generation session
(`<agent id>:generation:<n>`). In the `air` form, no configuration goes
after a child's end.

## Notification

```json
{
  "sessionId": "<child session id>",
  "update": {
    "sessionUpdate": "config_option_update",
    "configOptions": [{
      "id": "model", "name": "Model", "category": "model", "type": "select",
      "currentValue": "opus[1m]",
      "options": [{ "value": "opus[1m]", "name": "Opus 4.6 (1M context)" }]
    }]
  }
}
```

- The client cannot change a child's configuration, so the select offers
only the current value.
- No field is added to `subagent_spawned` or `subagent_update`.

## Matching a model id to an entry

Best first:

1. the root session's current entry, when it runs the same model up to a
context hint, such as `opus[1m]` or `default` for `claude-opus-4-6`;
2. the listed entry whose value or resolved model is the id;
3. a listed entry that runs the same model up to a context hint.

When several entries run the model, a concrete entry comes before
`default`, unless `default` is the current entry. A model id is compared
as it is, so a dated or provider id (`claude-haiku-4-5-20251001`,
`eu.anthropic.claude-sonnet-5`) matches only the same id:
`ModelInfo.resolvedModel` is the wire id that a response reports. A
model that matches no entry goes as its full id, with its display name
when the session's model list has one. A `PostModelSwitch` hook of a
child uses the same matching.

## Replay

- A child without responses in its transcript gets no model.
- `rfd` form: the replay sends a child's model right after its
announcement, from its first response, and again when a later response
changes it. During a load, the session's model list can be unknown, so
the replay can send the full id. After the load response, a child's
model goes again only when the list maps it to another entry.
- `air` form: the replay ends every child, and nothing may follow the
end. So each child's latest response model goes once the session
creation ended, just before the child's end, as the entry of the
session's model list.

## Validation

- `npm run build`
- `npm run check`
- `npm run test:run`, three runs: 3315 passed, 30 skipped.
- `src/tests/acp-scenarios.test.ts`, 30 runs in 6 parallel workers: no
failure.

New tests cover:

- no model before the child's first response, in both forms;
- an update only when the entry changes;
- no update to an ended `air` child, also with an update in flight at
the end;
- a hook's options under the current `air` generation;
- matching for `opus[1m]` and `default`, concrete entries before
`default`, listed entries, dated and provider ids, and unlisted ids;
- the `PostModelSwitch` entry, in both forms;
- an `air` load replay that maps a child's model only once the session
exists;
- `rfd` replay with an unlisted full id, and no duplicate update after
the load response.

The scenario snapshots for both forms and v2 are updated.
2026-10-25 22:37:14 -07:00
Ben Brandt
739dde3ae5 feat: report SendMessage delegations as linked session messages (#1424)
In the subagents RFD form, a `SendMessage` call to a child now becomes a
message between the two sessions, as a launch's prompt already does
(#1405). Before, it was a tool call of its sender plus an unlinked
message in the child. Every delegation now has a parent-side message,
which a client can anchor a per-delegation card on, and both views name
each other with `senderMessageId`/`recipientMessageId`.

## Live

- The call is held until its final update. The SDK reports that the
message resumed a child before it reports the call's result.
- **Message reached an announced child:** the sender gets the outgoing
view and the child the incoming one (`linkedSessionMessages`). The
call's result isn't shown.
- **Message reached no announced child**, for example one sent to the
parent: the call is shown after all, with its final status
(`unexposedControlFallback`).
- **Call a permission request already showed:** it keeps its updates,
and its message goes only to the child.

## Replay

- A child's history replays right after its launch, so a call from a
parent to its child is always matched to its delivery first.
- The child's view then names the call, and the sender's view goes out
where the call sits.
- A call the replay reaches before its delivery stays a tool call,
unlinked. That can happen with a child's message to a sibling whose
history replays later.

`publishPrompt`'s `replacesLaunch` option is now `replacesCall`.

## Testing

- **Live:** the existing `SendMessage` tests in
`native-subagents.test.ts` and `acp-agent.test.ts` now expect both views
and check that the call and its completed result are hidden. A new test
checks that a call reaching no announced child is shown with its final
status.
- **Replay:** the existing test expects the linked views in place of
`root tool_call toolu_send`. A new test covers a call reached before its
delivery, and fails if delivery ignores that the call was already shown.
- `npm run check`, `npm run build` and `npm run test:run` (3301 tests)
pass locally.

Not tried against a real Claude Code run yet.
2026-10-25 00:14:54 +02:00
Ben Brandt
17a967b4da fix: send subagents' current state after a load response (#1423)
The subagents RFD makes the response to a load (v1 `session/load`, v2
`session/resume` with replay) the freshness boundary. Work states and
`cancel` grants sent before it are history, and "current snapshots are
sent after the response". The adapter sent each replayed child's end
only during the replay, so a client that follows the RFD strictly showed
every child as unknown after a reload. This replaces the RFD carve-out
proposed in agentclientprotocol/agent-client-protocol#2475.

After the response, each replayed child now gets one current
`subagent_update`:

- **A child still running in this process**, as when a live session is
loaded again, reports its live state and `capabilities.cancel`, as it
did live.
- **Any other child** can't be running, because the process that ran it
is gone. Its replayed end becomes its current state, with no grant.

When it's sent:

- **v1:** with the commands, in the timer that follows the setup
response (`afterSetupResponse`).
- **v2:** the surface sends it only once its own resume response is out,
after `setupCommands`. A timer set inside `loadSession` could fire
before that response.

`RfdSubagentReplay.finish` and the new `currentStates` share one
`endState`. `NativeSubagentRuntime.liveWorkState` exposes the live
state.

## Testing

- `sends each child's current state after the load response`: a live
child and a finished one, after the replay and its response.
- `replays a subagent's history to its own session…` (v2) now records
when the client receives the response. It checks that the child's
current `state_update` arrives after it, and that the parent's copy
grants nothing. It fails without the v2 change.
- `npm run check`, `npm run build` and `npm run test:run` (3300 tests)
pass locally.

Not tested end to end: the v1 timer path. It reuses the timer that
already sends v1 commands after the setup response.
2026-10-24 23:28:11 +02:00
acp-release-bot[bot]
ebfbfbdaa9 chore(main): release 0.95.0 (#1391)
🤖 I have created a release *beep* *boop*
---


##
[0.95.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.94.0...v0.95.0)
(2026-10-23)


### Features

* end a turn at its result for AIR clients with backgroundSubagents
([#1262](https://github.com/agentclientprotocol/claude-agent-acp/issues/1262))
([9b3a5c8](9b3a5c891c))
* report read-only subagent configuration
([#1417](https://github.com/agentclientprotocol/claude-agent-acp/issues/1417))
([6ab670b](6ab670baae))
* report RFD delegations as linked session messages
([#1405](https://github.com/agentclientprotocol/claude-agent-acp/issues/1405))
([59e292e](59e292e29f))
* serve experimental ACP v2
([#1400](https://github.com/agentclientprotocol/claude-agent-acp/issues/1400))
([4d378f9](4d378f93f3))
* show messages typed in other Remote Control clients
([#1371](https://github.com/agentclientprotocol/claude-agent-acp/issues/1371))
([5b11f47](5b11f470b1))
* update to Claude Code 2.1.309
([#1413](https://github.com/agentclientprotocol/claude-agent-acp/issues/1413))
([a1521b6](a1521b6196))


### Bug Fixes

* bump fast-uri from 3.1.8 to 3.1.9
([#1410](https://github.com/agentclientprotocol/claude-agent-acp/issues/1410))
([0fe22ae](0fe22ae5a1))
* bump postcss from 8.5.30 to 8.5.31 in the minor group
([#1403](https://github.com/agentclientprotocol/claude-agent-acp/issues/1403))
([ecaa2c9](ecaa2c935b))
* bump the minor group across 1 directory with 2 updates
([#1416](https://github.com/agentclientprotocol/claude-agent-acp/issues/1416))
([f7d6030](f7d6030c04))
* IJAI-3744 keep a session running while its background tasks are open
([#1399](https://github.com/agentclientprotocol/claude-agent-acp/issues/1399))
([e9f986b](e9f986b8a9))
* publish the session title as soon as the CLI has it
([#1406](https://github.com/agentclientprotocol/claude-agent-acp/issues/1406))
([50cd76e](50cd76e78d))
* publish turns that Claude Code starts after end_turn as an async task
in v1
([#1390](https://github.com/agentclientprotocol/claude-agent-acp/issues/1390))
([a1d0eee](a1d0eeeaad))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-23 21:58:47 +02:00
Ben Brandt
6ab670baae feat: report read-only subagent configuration (#1417)
## Summary

Expose the observed configuration of RFD subagent sessions using the
same config-option IDs as ordinary sessions, without granting
client-initiated configuration changes.

- Report model from child responses and model-switch hooks, and mode,
applied reasoning effort, and agent type from child-attributed SDK
hooks.
- Merge partial observations into complete, ordered config snapshots;
buffer early observations until truthful child announcement and
serialize concurrent publications.
- Advertise only each observed current value. Do not inherit parent
settings or invent child Fast-mode state.
- Keep child plan-mode and model-switch hooks from changing the parent's
configuration.
- Cover initial/changed settings, announcement ordering, publication
failures, root isolation, and hook registration in both protocol
versions; update v2 scenario snapshots.

## Related

Protocol clarification:
https://github.com/agentclientprotocol/agent-client-protocol/pull/2468

Zed client implementation:
https://github.com/zed-industries/zed/pull/66058

## Validation

Passed after rebasing onto current `main`:

- `npm run build`
- `npm run check`
- `npm run test:run -- --silent`: 3,294 passed, 30 skipped.

No live Claude-to-Zed smoke test was run. Settings are reported only
when observed from the SDK; availability depends on runtime support and
events received.
2026-10-23 21:51:35 +02:00
dependabot[bot]
f7d6030c04 fix: bump the minor group across 1 directory with 2 updates (#1416)
Bumps the minor group with 2 updates in the / directory:
[@opentelemetry/api](https://github.com/open-telemetry/opentelemetry-js)
and [jose](https://github.com/panva/jose).

Updates `@opentelemetry/api` from 1.9.1 to 1.10.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/open-telemetry/opentelemetry-js/releases">@​opentelemetry/api's
releases</a>.</em></p>
<blockquote>
<h2>api/v1.10.0</h2>
<h2>1.10.0</h2>
<h3>🚀 Features</h3>
<ul>
<li>feat(api): integrate the Logs API into
<code>@opentelemetry/api</code> <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/4862">#4862</a>
<a
href="https://github.com/hectorhdzg"><code>@​hectorhdzg</code></a></li>
<li>feat(api): add imperative <code>context.attach()</code> for setting
context across callback boundaries that <code>with()</code> cannot wrap.
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6845">#6845</a>
<a href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a>
<ul>
<li><code>attach()</code> returns a <code>Token</code> whose
<code>dispose()</code> method restores the previous context. Use
<code>token.dispose()</code> to detach.</li>
<li><code>attach</code> is an optional method on the
<code>ContextManager</code> interface; when the active context manager
does not implement it, <code>context.attach()</code> logs a warning and
returns a no-op token.</li>
</ul>
</li>
<li>feat(api): add <code>AnyValue</code> type (of type
<code>unknown</code>) and extend <code>Attributes</code> type values to
use <code>AnyValue</code> <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6780">#6780</a>
<a href="https://github.com/trentm"><code>@​trentm</code></a>
<ul>
<li>This is part of supporting <a
href="https://github.com/open-telemetry/opentelemetry-specification/blob/main/oteps/4485-extending-attributes-to-support-complex-values.md#how">OTEP
4485</a>.</li>
<li>This is a <strong>breaking change for SDK implementations</strong>,
in that they may need to support more attribute types for any APIs that
accept <code>Attributes</code> or <code>AnyValue</code>. Implementors
should refer to the OTEP 4485 link above for &quot;MUST / SHOULD&quot;
requirements for complex attributes handling. See also the <a
href="https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/opentelemetry-core/src/common/attributes.ts">attributes-handling
utilities provided by <code>@opentelemetry/core</code> to help</a>. For
example, <code>cleanSimpleAttributes()</code> can be used to limit
user-provided attributes to the set of value types supported in earlier
<code>@opentelemetry/api</code> versions.</li>
<li>This change is <strong>typically not a breaking change for
users</strong> of <code>@opentelemetry/api</code>. There is one possible
breaking case: If a TypeScript user was using the
<code>Attributes</code> type in their own code and using <a
href="https://www.typescriptlang.org/docs/handbook/2/narrowing.html">TypeScript
Narrowing</a> to reduce an attribute value to an expected type (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6579#issuecomment-4345316200">example
here</a>). It is expected that this usage should be rare and, likely,
limited to SDK implementations, if at all. (If this impacted your code,
<a
href="https://github.com/open-telemetry/opentelemetry-js/issues/new">please
let us know</a>.)</li>
<li><strong>Note for users of attributes:</strong> Simple attributes
SHOULD be used whenever possible. Instrumentations SHOULD assume that
backends do not index individual properties of complex attributes, that
querying or aggregating on such properties is inefficient and
complicated, and that reporting complex attributes carries higher
performance overhead.</li>
</ul>
</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>fix(api): cap tracestate list-members and total length in the
deprecated <code>TraceState.set()</code> so it stays in step with
<code>TraceState</code> from <code>@opentelemetry/core</code> <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6964">#6964</a>
<a href="https://github.com/Zuhef"><code>@​Zuhef</code></a></li>
<li>fix: resolve the ESM build through the <code>default</code> export
condition instead of <code>import</code>, so a resolver that activates
neither <code>import</code> nor <code>require</code> still finds an
entry point <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/7132">#7132</a>
<a
href="https://github.com/overbalance"><code>@​overbalance</code></a></li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>docs(api): complete <code>@since</code> annotations for the public
API <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/7023">#7023</a>
<a
href="https://github.com/nabeelamjadsheikh"><code>@​nabeelamjadsheikh</code></a>
<ul>
<li>Adds the three missing exported-type annotations and the
member-level annotations for methods and properties introduced after
their enclosing type.</li>
<li>Corrects <code>Attributes</code>, <code>AttributeValue</code> and
<code>TracerOptions</code> from <code>1.3.0</code> to
<code>1.1.0</code>; all three were already importable in 1.1.0 via
wildcard re-export.</li>
</ul>
</li>
</ul>
<h3>🏠 Internal</h3>
<ul>
<li>build: migrate the package build from <code>tsc</code> to
<code>tsdown</code>, emitting dual CJS/ESM output from a single
<code>dist/</code> directory <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6293">#6293</a>
<a href="https://github.com/overbalance"><code>@​overbalance</code></a>
<ul>
<li>The public entry points are unchanged:
<code>@opentelemetry/api</code> and
<code>@opentelemetry/api/experimental</code>. This package already
declared an <code>exports</code> map, so deep imports such as
<code>@opentelemetry/api/build/src/...</code> were rejected before this
change and continue to be.</li>
<li>The emitted files moved out of <code>build/src</code> (CJS),
<code>build/esm</code> and <code>build/esnext</code> (ESM) into
<code>dist/</code>, using <code>.cjs</code>/<code>.mjs</code> extensions
with matching <code>.d.cts</code>/<code>.d.mts</code> declarations. The
non-standard <code>esnext</code> condition has been dropped in favour of
the standard <code>import</code>/<code>require</code> conditions.</li>
</ul>
</li>
<li>perf(api): add getGlobal fast-path <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6956">#6956</a>
<a
href="https://github.com/legendecas"><code>@​legendecas</code></a></li>
<li>chore: remove the redundant <code>module</code> field from
<code>package.json</code>; bundlers resolve the ESM build through the
<code>exports</code> map <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/7130">#7130</a>
<a
href="https://github.com/overbalance"><code>@​overbalance</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md">@​opentelemetry/api's
changelog</a>.</em></p>
<blockquote>
<h2>1.10.0</h2>
<h3>🚀 (Enhancement)</h3>
<ul>
<li>feat(resource): create sync resource with some attributes that
resolve asynchronously <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3460">#3460</a>
<a
href="https://github.com/samimusallam"><code>@​samimusallam</code></a></li>
<li>feat: collect host id for non-cloud environments <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3575">#3575</a>
<a href="https://github.com/mwear"><code>@​mwear</code></a></li>
<li>feat (api-logs): separate Events API into its own package <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3550">3550</a>
<a
href="https://github.com/martinkuba"><code>@​martinkuba</code></a></li>
<li>feat(sdk-metrics): apply binary search in histogram recording <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3539">#3539</a>
<a
href="https://github.com/legendecas"><code>@​legendecas</code></a></li>
<li>perf(propagator-jaeger): improve deserializeSpanContext performance
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3541">#3541</a>
<a href="https://github.com/doochik"><code>@​doochik</code></a></li>
<li>feat: support TraceState in SamplingResult <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3530">#3530</a>
<a
href="https://github.com/raphael-theriault-swi"><code>@​raphael-theriault-swi</code></a></li>
<li>feat(sdk-trace-base): add diagnostic logging when spans are dropped
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3610">#3610</a>
<a
href="https://github.com/neoeinstein"><code>@​neoeinstein</code></a></li>
<li>feat: add unit to view instrument selection criteria <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3647">#3647</a>
<a href="https://github.com/jlabatut"><code>@​jlabatut</code></a></li>
<li>feat(tracing): expose dropped counts for attributes, events and
links on span <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3576">#3576</a>
<a href="https://github.com/mohitk05"><code>@​mohitk05</code></a></li>
</ul>
<h3>🐛 (Bug Fix)</h3>
<ul>
<li>fix(core): added falsy check to make otel core work with browser
where webpack config had process as false or null <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3613">#3613</a>
<a
href="https://github.com/ravindra-dyte"><code>@​ravindra-dyte</code></a></li>
<li>fix(instrumentation-http): include query params in http.target <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3646">#3646</a>
<a href="https://github.com/kobi-co"><code>@​kobi-co</code></a></li>
</ul>
<h3>📚 (Refine Doc)</h3>
<ul>
<li>chore: update http example <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3651">#3651</a>
<a
href="https://github.com/JamieDanielson"><code>@​JamieDanielson</code></a></li>
</ul>
<h3>🏠 (Internal)</h3>
<ul>
<li>chore(exporter-jaeger): deprecate jaeger exporter <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3585">#3585</a>
<a
href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a></li>
<li>fix(sdk-metrics): fix flaky LastValueAggregator test by using fake
timer <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3587">#3587</a>
<a
href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a></li>
<li>fix(test): fix failing tests by preventing source-map generation <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3642">#3642</a>
<a
href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="56e6b1bb89"><code>56e6b1b</code></a>
chore: Release API 1.4.1, SDK 1.10.0, Experimental 0.36.0 (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3654">#3654</a>)</li>
<li><a
href="ecb5ebe86e"><code>ecb5ebe</code></a>
fix: Minor typing issues for web sandbox (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3660">#3660</a>)</li>
<li><a
href="48ecf229eb"><code>48ecf22</code></a>
feat(sdk-trace-base): add dropped attributes and events count on span
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3576">#3576</a>)</li>
<li><a
href="95bf6fc7cc"><code>95bf6fc</code></a>
fix(build): add <code>example/http</code> to ignore list in
<code>update-ts-configs</code> (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3657">#3657</a>)</li>
<li><a
href="3a2ca58075"><code>3a2ca58</code></a>
feat: add unit to view instrument selection criteria (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3647">#3647</a>)</li>
<li><a
href="ebc8575a0c"><code>ebc8575</code></a>
chore(jeager-exporter): deprecate jaeger exporter (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3585">#3585</a>)</li>
<li><a
href="2f715bd78d"><code>2f715bd</code></a>
feat: allow to add attributes from grpc metadata in the patched server
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3589">#3589</a>)</li>
<li><a
href="cbbdfd3798"><code>cbbdfd3</code></a>
fix(sdk-node): update instrumentations once MeterProvider is initialized
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3624">#3624</a>)</li>
<li><a
href="4a4484a910"><code>4a4484a</code></a>
chore: update http example (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3651">#3651</a>)</li>
<li><a
href="494efffcf4"><code>494efff</code></a>
fix(otlp-exporter-base): fix changelog for merged retry pr (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3650">#3650</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/open-telemetry/opentelemetry-js/compare/v1.9.1...v1.10.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `jose` from 6.2.12 to 6.2.13
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/panva/jose/releases">jose's
releases</a>.</em></p>
<blockquote>
<h2>v6.2.13</h2>
<h3>Refactor</h3>
<ul>
<li>align terminology with JOSE specifications (<a
href="bafcb74f5f">bafcb74</a>)</li>
<li>preserve underlying error causes (<a
href="724ba24b5a">724ba24</a>),
closes <a
href="https://redirect.github.com/panva/jose/issues/908">#908</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/panva/jose/blob/main/CHANGELOG.md">jose's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/panva/jose/compare/v6.2.12...v6.2.13">6.2.13</a>
(2026-10-20)</h2>
<h3>Refactor</h3>
<ul>
<li>align terminology with JOSE specifications (<a
href="bafcb74f5f">bafcb74</a>)</li>
<li>preserve underlying error causes (<a
href="724ba24b5a">724ba24</a>),
closes <a
href="https://redirect.github.com/panva/jose/issues/908">#908</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="316b7f2cb4"><code>316b7f2</code></a>
chore(release): 6.2.13</li>
<li><a
href="8106395886"><code>8106395</code></a>
chore: bump packages</li>
<li><a
href="724ba24b5a"><code>724ba24</code></a>
refactor: preserve underlying error causes</li>
<li><a
href="b112a5e6e1"><code>b112a5e</code></a>
chore: bump packages</li>
<li><a
href="2a3a525968"><code>2a3a525</code></a>
chore(deps-dev): bump workerd in /tap/workerd</li>
<li><a
href="8f540d89de"><code>8f540d8</code></a>
chore(deps): bump the actions group with 3 updates</li>
<li><a
href="8d6c02b5fa"><code>8d6c02b</code></a>
chore(deps-dev): bump electron from 44.0.0 to 44.4.5 in
/tap/electron</li>
<li><a
href="55c959fd16"><code>55c959f</code></a>
ci: use gh run rerun for retries</li>
<li><a
href="5b00c649d7"><code>5b00c64</code></a>
ci: address zizmor findings and bump shared workflows</li>
<li><a
href="1761d7f1b5"><code>1761d7f</code></a>
chore: bump packages</li>
<li>Additional commits viewable in <a
href="https://github.com/panva/jose/compare/v6.2.12...v6.2.13">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 19:49:50 +00:00
Ben Brandt
28a09b1008 chore: remove the unused ts-node devDependency (#1414)
Removes `ts-node` 10.9.2 from devDependencies. Nothing in the repo uses
it: no script, config or test references it. `npm run build` uses `tsc`,
tests run through vitest, and `npm run example:simple-client` uses `node
--experimental-strip-types`.

Verified: `npm run build`, `npm run check`, `npm run test:run`.
2026-10-23 21:43:16 +02:00
Ben Brandt
a1521b6196 feat: update to Claude Code 2.1.309 (#1413)
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.303 to **0.3.309**
(Claude Code **2.1.309**), `@agentclientprotocol/sdk` 2.0.0 → 2.0.1,
`@anthropic-ai/sdk` 0.135.0 → 1.1.0 and typescript-eslint 8.71.2 →
8.72.0.

TypeScript stays on 6.0.3. typescript-eslint requires `typescript
<6.1.0` and has no TS 7 support yet
(typescript-eslint/typescript-eslint#10940), so we wait for that rather
than running two TypeScript versions side by side.

## Uses new SDK features

- **`/usage` recommendations (0.3.305):** our structured `/usage`
Markdown now lists Claude Code's per-window suggestions (`- **41%**
Disable my_server · /mcp`) above the MCP server table, in the order
Claude Code sends them. `fix` is not rendered: its messages must be
queued at priority `later` with `client_composed` set, which an ACP
prompt cannot express. A malformed recommendation makes the response
fail validation, so we fall back to Claude Code's own `/usage` text.
- **`wakeup_source` on `command_lifecycle` "started" (0.3.307):** when a
ScheduleWakeup or another scheduled task starts a turn with no prompt
open, the async-task (AIR) "Claude is working" task now says `A
scheduled wakeup fired (set for <UTC time>).` or `A scheduled task
fired.` instead of `Claude Code started a turn without a prompt.` It
works whether the frame arrives before or after `session_state_changed:
running`; older CLIs and unknown values behave as before.

## Fixes for changes in the new CLI

- **Fork transcripts (2.1.307):** the placeholder `tool_result` text in
a fork's copied prefix changed (`Fork started — that fork is you.` /
`This is the parent's call, not yours. …`). `stripForkPrefix` matches
only the directive text, so behaviour is unchanged; its doc comment and
the replay fixture now describe both formats.

## Behaviour changes ACP users may notice

- **2.1.304:** tool search is on by default behind a custom
`ANTHROPIC_BASE_URL` (our `providers/set` gateway sessions), and a
gateway that rejects it is retried without it (2.1.305).
- **2.1.304:** `rm -r` of a mounted drive or a top-level folder on it
(`/Volumes/…`, `/media/…`, `/mnt/c/…`) now asks for permission, even in
bypass mode, without an always-allow option.
`CLAUDE_CODE_DISABLE_MOUNTED_DRIVE_RM_PROMPT` turns it off.
- **2.1.309:** with `sandbox.enabled` but no runnable sandbox, commands
in `sandbox.excludedCommands` now ask before writing to the sandbox's
write-list paths, and the unsandboxed fallback is refused.
- **2.1.304:** `allowedMcpServers` no longer filters `managed-mcp.json`
stdio servers that use `${VAR}` only in `env`.

## Upstream fixes this picks up

- Model switches, prompts and interrupts no longer wait behind our
background `getContextUsage` call (2.1.304); `setModel()` waits up to 15
s for the API to confirm a model (2.1.307).
- A PreToolUse hook's deny reason is no longer replaced by a timeout of
our `Edit|Write` preview callback hook (2.1.304).
- Host-supplied `managedSettings` no longer drop `disableAllHooks`,
`disableSkillShellExecution` and empty HTTP hook lists (0.3.309).
- Gateway fixes: `context_management` rejections are retried without the
field (2.1.304); no `prompt-caching-scope` header behind custom gateways
(2.1.307).
- A disabled MCP server is no longer reconnected by a later permission
prompt in SDK sessions; HTTP MCP tool calls are no longer sent twice
when the connection drops (2.1.307/2.1.309).
- Bash permission and sandbox hardening (2.1.309), fewer "prompt is too
long" and autocompact-thrashing errors, and faster resume of large
compacted sessions.

## Checked, no change needed

- **ACP SDK 2.0.1** only changes the client-side `createHttpStream`
transport, which we don't use.
- **`@anthropic-ai/sdk` 1.x** (API version 2026-10-21, Bearer auth,
sampling params removed): we only use its types, none of which changed
in a way that affects us. Neither our `dist/` nor the Agent SDK runtime
imports it, and the bundled CLI carries its own client. It is Apache-2.0
from 1.0.0 (previously MIT).
- **`sed -i` (2.1.309)** runs as plain Bash; SDK sessions already did,
since `_simulatedSedEdit` only exists in the TUI preview.
- The remaining type changes (`liveReply`, context-usage row `id`s,
`pluginId`/`path` on agents and skills, new settings keys) are additive
and unused.

Verified: `npm run build`, `npm run check`, `npm run test:run` (3277
passed, 30 skipped), and a typecheck of `examples/`.
2026-10-23 21:43:02 +02:00
36 changed files with 2805 additions and 564 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.94.0"
".": "0.96.0"
}

View File

@@ -1,5 +1,43 @@
# Changelog
## [0.96.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.95.0...v0.96.0) (2026-10-26)
### Features
* report SendMessage delegations as linked session messages ([#1424](https://github.com/agentclientprotocol/claude-agent-acp/issues/1424)) ([739dde3](https://github.com/agentclientprotocol/claude-agent-acp/commit/739dde3ae5bb848bdb90e2c9150e2cc4687d480c))
* report the model of each native subagent ([#1432](https://github.com/agentclientprotocol/claude-agent-acp/issues/1432)) ([c319e5e](https://github.com/agentclientprotocol/claude-agent-acp/commit/c319e5e542aabe353bb2c5e177e4c5853392b8c4))
### Bug Fixes
* do not hold an AIR load replay until the CLI starts ([#1433](https://github.com/agentclientprotocol/claude-agent-acp/issues/1433)) ([eb9d63e](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb9d63e5fde9b8fd8fe692dfb3280faba6626a42))
* refine RFD child models after a load, and retry a failed model update ([#1434](https://github.com/agentclientprotocol/claude-agent-acp/issues/1434)) ([9a8bf71](https://github.com/agentclientprotocol/claude-agent-acp/commit/9a8bf71d454fa85d03f55d4101a77e3ea7a149d0))
* send subagents' current state after a load response ([#1423](https://github.com/agentclientprotocol/claude-agent-acp/issues/1423)) ([17a967b](https://github.com/agentclientprotocol/claude-agent-acp/commit/17a967b4dab78bbb9cf9fedae60e6821578dc5cb))
* update to Claude Code 2.1.310 ([#1435](https://github.com/agentclientprotocol/claude-agent-acp/issues/1435)) ([2f3c6bb](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f3c6bb7b3451a00cd77d7bb31c09e690ca220b3))
## [0.95.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.94.0...v0.95.0) (2026-10-23)
### Features
* end a turn at its result for AIR clients with backgroundSubagents ([#1262](https://github.com/agentclientprotocol/claude-agent-acp/issues/1262)) ([9b3a5c8](https://github.com/agentclientprotocol/claude-agent-acp/commit/9b3a5c891c75e78b985adb31f4e4b59a16ad2ebc))
* report read-only subagent configuration ([#1417](https://github.com/agentclientprotocol/claude-agent-acp/issues/1417)) ([6ab670b](https://github.com/agentclientprotocol/claude-agent-acp/commit/6ab670baaefb30aaac2606d8ee6524e08243e5b5))
* report RFD delegations as linked session messages ([#1405](https://github.com/agentclientprotocol/claude-agent-acp/issues/1405)) ([59e292e](https://github.com/agentclientprotocol/claude-agent-acp/commit/59e292e29f773cc6f03c0999993cbe8a2d97a7e3))
* serve experimental ACP v2 ([#1400](https://github.com/agentclientprotocol/claude-agent-acp/issues/1400)) ([4d378f9](https://github.com/agentclientprotocol/claude-agent-acp/commit/4d378f93f36d37ba5ef64af87b617706dd9c6e82))
* show messages typed in other Remote Control clients ([#1371](https://github.com/agentclientprotocol/claude-agent-acp/issues/1371)) ([5b11f47](https://github.com/agentclientprotocol/claude-agent-acp/commit/5b11f470b1c05b114c876b3b6e5b4df9441b1087))
* update to Claude Code 2.1.309 ([#1413](https://github.com/agentclientprotocol/claude-agent-acp/issues/1413)) ([a1521b6](https://github.com/agentclientprotocol/claude-agent-acp/commit/a1521b6196c58dbc5065f9c19fc9694273d3d435))
### Bug Fixes
* bump fast-uri from 3.1.8 to 3.1.9 ([#1410](https://github.com/agentclientprotocol/claude-agent-acp/issues/1410)) ([0fe22ae](https://github.com/agentclientprotocol/claude-agent-acp/commit/0fe22ae5a172b228a574665bb7136b900746041d))
* bump postcss from 8.5.30 to 8.5.31 in the minor group ([#1403](https://github.com/agentclientprotocol/claude-agent-acp/issues/1403)) ([ecaa2c9](https://github.com/agentclientprotocol/claude-agent-acp/commit/ecaa2c935b9f2b63c1846ca17c6c3398390fb0ef))
* bump the minor group across 1 directory with 2 updates ([#1416](https://github.com/agentclientprotocol/claude-agent-acp/issues/1416)) ([f7d6030](https://github.com/agentclientprotocol/claude-agent-acp/commit/f7d6030c04af23171058772480d55f6d2aee5298))
* IJAI-3744 keep a session running while its background tasks are open ([#1399](https://github.com/agentclientprotocol/claude-agent-acp/issues/1399)) ([e9f986b](https://github.com/agentclientprotocol/claude-agent-acp/commit/e9f986b8a941f5c1789078a39e474f247bd0db60))
* publish the session title as soon as the CLI has it ([#1406](https://github.com/agentclientprotocol/claude-agent-acp/issues/1406)) ([50cd76e](https://github.com/agentclientprotocol/claude-agent-acp/commit/50cd76e78dd9bb1ab0e4850a8d95431175ab5947))
* publish turns that Claude Code starts after end_turn as an async task in v1 ([#1390](https://github.com/agentclientprotocol/claude-agent-acp/issues/1390)) ([a1d0eee](https://github.com/agentclientprotocol/claude-agent-acp/commit/a1d0eeeaad3d67a0765f4b89e3d81c1af6ca0dbb))
## [0.94.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.93.0...v0.94.0) (2026-10-19)

400
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.94.0",
"version": "0.96.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.94.0",
"version": "0.96.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.0.0",
"@anthropic-ai/claude-agent-sdk": "0.3.303",
"@agentclientprotocol/sdk": "2.0.2",
"@anthropic-ai/claude-agent-sdk": "0.3.310",
"diff": "9.0.0",
"zod": "4.6.7"
},
@@ -18,19 +18,18 @@
"claude-agent-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.135.0",
"@anthropic-ai/sdk": "1.3.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.6",
"@types/node": "26.6.5",
"@typescript-eslint/eslint-plugin": "8.71.2",
"@typescript-eslint/parser": "8.71.2",
"@typescript-eslint/eslint-plugin": "8.72.0",
"@typescript-eslint/parser": "8.72.0",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"eslint": "10.13.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.13.0",
"prettier": "3.9.10",
"ts-node": "10.9.2",
"typescript": "6.0.3",
"vitest": "5.0.5"
},
@@ -39,31 +38,31 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.0.0.tgz",
"integrity": "sha512-Za5gWOPQ44aBOdegDJZ+OjCAPn7POc0cw8BlFJQ1mmUY1OoUWxRi3+QZT4gdZnyEWgKXyfeGRb0Oo1ag9YkQ1w==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.0.2.tgz",
"integrity": "sha512-jLywRB42hZMRMc4aUgt0uRjkY9FQnMdybv4XgLl+ddi75QuJn8Vbl0+CHcVHJDC0+WDfi1KQXRWtHaR5vp6gHw==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.76 || ^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.303",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.303.tgz",
"integrity": "sha512-XBql04V40xr+fmGJh8AmIpqRZKFNrs99BzXzzSVlx+uE5NjFCzKmkNKTdMD0DQtDvFJlijOLoh1siaH+au9Psw==",
"version": "0.3.310",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.310.tgz",
"integrity": "sha512-fo+sr+9Ks73P001f6XKRQlyzfIBAJMgXArLUkH5Tu98NikRDZeGtFQVj23d5/hj2ijpFgzffrr4fHywY9qUkoQ==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.303",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.303",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.303",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.303",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.303",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.303",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.303",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.303"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.310",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.310",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.310",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.310",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.310",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.310",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.310",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.310"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -72,9 +71,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.303",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.303.tgz",
"integrity": "sha512-QAWV9Zd7i7FQDaUSwZNVXJirqCS9CSF4aPwbkGwLvLH83qcfFOmnyxWUg1H7dU6tJFLOCyDLQzc3xzUA4dakoA==",
"version": "0.3.310",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.310.tgz",
"integrity": "sha512-gpzMjDZdcdA2Webuqm9j76exG8yNK/755ZYWbS3MM4vVBlAsm+enEO+hhNt0Mx1VMil28RWetvTO4ZJ3YODVMA==",
"cpu": [
"arm64"
],
@@ -85,9 +84,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.303",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.303.tgz",
"integrity": "sha512-o931musaryuq04fMaEjceCEmgsBrLh39WvwsQWmv070h9jXS8EmWRFpdSILv1xrUFXiukRSq8yUDRVzg+FvTug==",
"version": "0.3.310",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.310.tgz",
"integrity": "sha512-ZRQ4ZrzE2OOfR+Fx8h+CM4NKh0UiYqlh5TowP7HUhn5KUoYMjoSR/xIhix8ow42X2IDiBy0FjEVaA/Awhu2KAw==",
"cpu": [
"x64"
],
@@ -98,12 +97,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.303",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.303.tgz",
"integrity": "sha512-c04i9i1BU3j9G3D7XdVzXTdM3/sDpQH+1o8jNcMPnztOTYbNFmH/cacgeX9x3N112r+5VhCEHXGILoe5GxVxeg==",
"version": "0.3.310",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.310.tgz",
"integrity": "sha512-l6SPgUmJknjx+6/Pb7OR353fm3xBNAWqodFoC/Cflg0yMcLfz9iwNIc3Drgos2DqfHnid+ODqDzSO93AgQ7RmA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -111,12 +113,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.303",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.303.tgz",
"integrity": "sha512-a/OsLTEx9ly9hC26cXidg9mtTMA00SQnEiTN9P4IPNj64uSp/7uBnlWl0VeUoVFbgKYYq6XrGRcD6F1ilQsYxg==",
"version": "0.3.310",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.310.tgz",
"integrity": "sha512-viXuvvtli3pfHzTEI+oeJzk3nldkfHvj757Xh/uAeximUT9ANZIq4SMZw2cSqbhASjni0qcO6B1x4BGx2LuQpw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -124,12 +129,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.303",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.303.tgz",
"integrity": "sha512-lsm1sX2+9Wos8glBbn86M8leFuK60LKB1cJdS0b2HiTSwiDCqpUKYRGJNUlW1JdUTwJB5nDv6S8/FOJExm1/tw==",
"version": "0.3.310",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.310.tgz",
"integrity": "sha512-jx7awHPoofXuE6cRRQSzLUFyW6aUXbYD9AtnNIDUHmmPPrgJ9nSnqjPaqcNhOdaZH7onUUujEAq26N2tx29fYg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -137,12 +145,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.303",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.303.tgz",
"integrity": "sha512-yixAa3ZOZd1hiI4mpoukzT9TnuDCzSEHyh7pHcbYTRmdRE2y8bAOmI0eiMFJPivnG4iyGhS2C4XNAe6hAjl30w==",
"version": "0.3.310",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.310.tgz",
"integrity": "sha512-/qFgN5LcofB8PayxBXoa+xDxkYPPQeo/eCyN0DsyyeI67ifH/l49pz9J+sH0+ctDe9und0vMCJPzng70bx2K+Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -150,9 +161,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.303",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.303.tgz",
"integrity": "sha512-AnEseiw5E1JF3XRaGIZN880V/i2OxCaTOQJF9F1+oELleSdvLnCUxCE6/baGvaZjrgvffO9YbYChBpgUKj9j7A==",
"version": "0.3.310",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.310.tgz",
"integrity": "sha512-tRmDMhZpoWAIrZxFBTh6vyZB8W5gldU5/Rfo+cyq/OvBKTxxbim+/0tIa9QlJQncoVLXVN9eySYeeB9KMjio7w==",
"cpu": [
"arm64"
],
@@ -163,9 +174,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.303",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.303.tgz",
"integrity": "sha512-j5kABFEQ6x6T5AB/glmn0r9VwZRbzcH+nSEOWglB69ODNfkZA2btjcz9r794+2BLqJ9QAyswiYWcOX4sDz/RmQ==",
"version": "0.3.310",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.310.tgz",
"integrity": "sha512-zvrIApxPccTJc6gCOtw5hj27QjWhhTi3z5x5iMmf9f0LZAKR+9mrgENVeFL7PP7zRGdYVhhX2SahbzxN83L/eA==",
"cpu": [
"x64"
],
@@ -176,10 +187,10 @@
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.135.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.135.0.tgz",
"integrity": "sha512-WWrdL57JhU5KoynpTjt9GXK6k7ud9nPw2uw/Trn+oBXh6s0KpjhAccWZm2Qxw08ARYG1UDQj5iC+JO42j1rt/g==",
"license": "MIT",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.3.0.tgz",
"integrity": "sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.1",
"json-schema-to-ts": "^3.1.1",
@@ -230,19 +241,6 @@
"keyv": "^5.6.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
@@ -454,17 +452,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@keyv/bigmap": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
@@ -531,9 +518,9 @@
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz",
"integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
@@ -834,34 +821,6 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz",
"integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node22": {
"version": "22.0.6",
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.6.tgz",
@@ -919,17 +878,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz",
"integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
"integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.71.2",
"@typescript-eslint/type-utils": "8.71.2",
"@typescript-eslint/utils": "8.71.2",
"@typescript-eslint/visitor-keys": "8.71.2",
"@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/type-utils": "8.72.0",
"@typescript-eslint/utils": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -942,22 +901,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.71.2",
"@typescript-eslint/parser": "^8.72.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz",
"integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz",
"integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.71.2",
"@typescript-eslint/types": "8.71.2",
"@typescript-eslint/typescript-estree": "8.71.2",
"@typescript-eslint/visitor-keys": "8.71.2",
"@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0",
"debug": "^4.4.3"
},
"engines": {
@@ -973,14 +932,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz",
"integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.71.2",
"@typescript-eslint/types": "^8.71.2",
"@typescript-eslint/tsconfig-utils": "^8.72.0",
"@typescript-eslint/types": "^8.72.0",
"debug": "^4.4.3"
},
"engines": {
@@ -995,14 +954,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz",
"integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.71.2",
"@typescript-eslint/visitor-keys": "8.71.2"
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1013,9 +972,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz",
"integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1030,15 +989,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz",
"integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz",
"integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.71.2",
"@typescript-eslint/typescript-estree": "8.71.2",
"@typescript-eslint/utils": "8.71.2",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.72.0",
"@typescript-eslint/utils": "8.72.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -1055,9 +1014,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz",
"integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1069,16 +1028,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz",
"integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.71.2",
"@typescript-eslint/tsconfig-utils": "8.71.2",
"@typescript-eslint/types": "8.71.2",
"@typescript-eslint/visitor-keys": "8.71.2",
"@typescript-eslint/project-service": "8.72.0",
"@typescript-eslint/tsconfig-utils": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -1097,16 +1056,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz",
"integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.71.2",
"@typescript-eslint/types": "8.71.2",
"@typescript-eslint/typescript-estree": "8.71.2"
"@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.72.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1121,13 +1080,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz",
"integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.71.2",
"@typescript-eslint/types": "8.72.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -1237,19 +1196,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -1283,13 +1229,6 @@
}
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1311,19 +1250,19 @@
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"content-type": "^2.1.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"iconv-lite": "^0.7.3",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"qs": "^6.16.1",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
@@ -1489,13 +1428,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2448,9 +2380,9 @@
"license": "ISC"
},
"node_modules/jose": {
"version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"version": "6.2.13",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz",
"integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==",
"license": "MIT",
"peer": true,
"funding": {
@@ -2813,13 +2745,6 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3632,60 +3557,6 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3773,13 +3644,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -3997,16 +3861,6 @@
"license": "ISC",
"peer": true
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.94.0",
"version": "0.96.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -65,25 +65,24 @@
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "2.0.0",
"@anthropic-ai/claude-agent-sdk": "0.3.303",
"@agentclientprotocol/sdk": "2.0.2",
"@anthropic-ai/claude-agent-sdk": "0.3.310",
"diff": "9.0.0",
"zod": "4.6.7"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.135.0",
"@anthropic-ai/sdk": "1.3.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.6",
"@types/node": "26.6.5",
"@typescript-eslint/eslint-plugin": "8.71.2",
"@typescript-eslint/parser": "8.71.2",
"@typescript-eslint/eslint-plugin": "8.72.0",
"@typescript-eslint/parser": "8.72.0",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"eslint": "10.13.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.13.0",
"prettier": "3.9.10",
"ts-node": "10.9.2",
"typescript": "6.0.3",
"vitest": "5.0.5"
}

View File

@@ -116,14 +116,19 @@ import {
type SubagentState,
} from "./acp-subagents.js";
import {
childModelOption,
isNativeSubagentControlTool,
isNativeSubagentControlUpdate,
NativeSubagent,
NativeSubagentRuntime,
nativeSubagentState,
type ModelEntry,
responseModelEntry,
resumedNativeSubagentId,
sameModelEntry,
sendMessageResume,
sendMessageResumePrompt,
type SubagentModels,
} from "./native-subagents.js";
import {
AIR_ASYNC_TASKS_CAPABILITY,
@@ -203,6 +208,7 @@ import {
import {
type ReplayChild,
RfdSubagentReplay,
firstResponseModel,
replaySubagentTerminalState,
} from "./subagent-replay.js";
import {
@@ -338,6 +344,8 @@ import {
buildModelConfigOption,
getAvailableModels,
MODEL_CONFIG_ID,
modelDisplayName,
modelEntryFor,
narrowToGatewayModels,
resolveModelPreference,
type SessionModelState,
@@ -353,6 +361,7 @@ import {
buildEffortConfigOption,
EFFORT_CONFIG_ID,
effortFlagSettings,
effortLevelName,
mergeEffortSettings,
settingsEffortForModel,
} from "./session-effort.js";
@@ -973,12 +982,7 @@ type Turn = {
* while the CLI starts. The session record then takes the same objects. */
type ReplayState = Pick<
Session,
| "cwd"
| "additionalDirectories"
| "taskState"
| "forwardSubagentText"
| "messageIdToUuid"
| "sessionFailureState"
"cwd" | "taskState" | "forwardSubagentText" | "messageIdToUuid" | "sessionFailureState"
>;
/** A replay that runs before its session record exists. */
@@ -1123,10 +1127,6 @@ export type Session = {
* the command name. A `commands_changed` clears it, because the skill
* files can have changed then. */
skillPaths?: Map<string, string | undefined>;
/** The `--add-dir` roots the SDK got: `_meta.claudeCode.options` ones, then
* the ACP `additionalDirectories`. Skill commands of these roots resolve to
* their SKILL.md for AIR clients. */
additionalDirectories?: string[];
/** Serialized `system`/init `plugin_errors` last logged, so the per-turn
* init re-emit logs a plugin load failure once, not every turn. */
loggedPluginErrors?: string;
@@ -2723,6 +2723,20 @@ export class ClaudeAcpAgent {
* express: see {@link ToolCallClientCapabilities.v2}.
*/
private readonly v2: boolean;
/**
* The subagent replay of each session's last load, until
* {@link publishCurrentSubagentStates} sends its children's current states.
*/
private readonly replayedSubagents = new Map<string, RfdSubagentReplay>();
/**
* AIR's draft: the model that the last load of each session replayed to each
* child session, as the model id and the entry that went out. See
* {@link publishReplayedChildModels}.
*/
private readonly replayedChildModels = new Map<
string,
Map<string, { model: string; sent: ModelEntry }>
>();
/** See the `foreground` constructor option. */
private readonly foregroundEvents?: (sessionId: string) => ForegroundEvents;
/**
@@ -2801,6 +2815,7 @@ export class ClaudeAcpAgent {
clearHookCallbacks(id);
session.nativeSubagentRuntime?.clear();
session.asyncTaskRuntime?.clear();
this.replayedChildModels.delete(id);
if (this.sessions[id] === session) delete this.sessions[id];
},
settleCancelledTurn: (original, session, turn) => {
@@ -3063,7 +3078,6 @@ export class ClaudeAcpAgent {
const pending: PendingReplay = {
state: {
cwd: params.cwd,
additionalDirectories: sessionAdditionalDirectories(params),
taskState: new Map(),
forwardSubagentText: this.forwardsSubagentText(params._meta),
messageIdToUuid: new Map(),
@@ -3097,11 +3111,15 @@ export class ClaudeAcpAgent {
} catch (error) {
pending.stopped = true;
await replay;
this.replayedChildModels.delete(params.sessionId);
throw error;
}
timing.phase("session-ready");
const replayed = await replay;
if (!replayed.ok) throw replayed.error;
if (!replayed.ok) {
this.replayedChildModels.delete(params.sessionId);
throw replayed.error;
}
return {
sessionId: response.sessionId,
modes: response.modes,
@@ -4553,6 +4571,7 @@ export class ClaudeAcpAgent {
this.logger,
(agentId) => subagentLineage(params.sessionId, agentId, this.logger),
(launchId) => subagentLaunchedBy(params.sessionId, launchId),
this.subagentModels(params.sessionId),
));
// Adapter-composed advisories (hook feedback, model fallbacks) are live
// events, not something the model said. Clients on the notice contract get
@@ -5402,7 +5421,6 @@ export class ClaudeAcpAgent {
toolCallCapabilities: this.toolCallCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: session.cwd,
additionalDirectories: session.additionalDirectories,
taskState: session.taskState,
emittedToolCalls: session.emittedToolCalls,
toolCallFields: toolCallFieldsOf(session),
@@ -5887,9 +5905,28 @@ export class ClaudeAcpAgent {
// turn activation and settlement stay driven by echoes/results/idle.
// (Raw-mode emission above still forwards these frames.)
if ((message as { type: string }).type === "command_lifecycle") {
const frame = message as unknown as { command_uuid: string; state: string };
const frame = message as unknown as {
command_uuid: string;
state: string;
wakeup_source?: string;
scheduled_for?: number;
};
switch (frame.state) {
case "started": {
// CLIs 2.1.307+ (capability lifecycle_wakeup_source_v1) mark a
// command that a scheduler fire started. With no prompt of ours
// open, it starts an autonomous turn: name the fire as its cause,
// whether `session_state_changed` already opened the task or not.
const cause = scheduledFireCause(frame);
if (
cause &&
asyncTasks.enabled &&
!session.cancelled &&
!session.activeTurn &&
!firstUnsettledQueuedTurn()
) {
await asyncTasks.autonomousTurnStarted(cause);
}
// Remember dispatch on the live turn so a cancel() that orphans
// it seeds the right state (see Turn.commandStarted)...
const queued = findUnsettledTurn(frame.command_uuid);
@@ -6357,7 +6394,7 @@ export class ClaudeAcpAgent {
availableCommands: getAvailableSlashCommands(
message.commands,
session.terminalSlashCommands,
this.toolCallCapabilities.air.client ? session : undefined,
this.toolCallCapabilities.air.client ? session.cwd : undefined,
session.skillPaths,
session.query,
),
@@ -7640,6 +7677,11 @@ export class ClaudeAcpAgent {
// it) can all be tagged with the same, replay-stable id.
if (message.event.type === "message_start") {
currentStreamMessageId = message.event.message.id || undefined;
await subagents.reportModel(
message.parent_tool_use_id,
message.event.message.model,
sendUpdate,
);
// A new top-level message starts: clear any streamed-content
// residue from a prior message that never reached its
// consolidated reset — a cancelled turn breaks out before the
@@ -8006,6 +8048,14 @@ export class ClaudeAcpAgent {
break;
}
if (message.type === "assistant") {
await subagents.reportModel(
message.parent_tool_use_id,
message.message.model,
sendUpdate,
);
}
// Snapshot the latest top-level assistant usage and model so the
// next `result` can emit a usage_update tied to the right context
// window. Subagent messages are excluded to keep the snapshot
@@ -8150,6 +8200,7 @@ export class ClaudeAcpAgent {
}
let content: typeof message.message.content;
let toolUseResult = message.type === "user" ? message.tool_use_result : undefined;
if (
message.type === "assistant" &&
(message.parent_tool_use_id === null || forwardsSubagentText())
@@ -8170,6 +8221,20 @@ export class ClaudeAcpAgent {
(item) => item.type !== "text" && item.type !== "thinking",
);
streamedBlocksOf(message.parent_tool_use_id).length = 0;
} else if (
message.parent_tool_use_id !== null &&
Array.isArray(message.message.content)
) {
// A foreground fork inherits a placeholder result for its own
// launch. Only the launching side can complete that Agent call
// with the fork's actual report.
const launchId = message.parent_tool_use_id;
content = message.message.content.filter(
(block) => block.type !== "tool_result" || block.tool_use_id !== launchId,
);
// The message-level sidecar cannot describe a remaining result
// after its launch result was removed.
if (content.length !== message.message.content.length) toolUseResult = undefined;
} else {
content = message.message.content;
}
@@ -8187,7 +8252,7 @@ export class ClaudeAcpAgent {
);
const backgroundBashTask = backgroundBashTaskFromToolResult(
content,
message.tool_use_result,
toolUseResult,
session.toolUseCache,
);
if (backgroundBashTask) await asyncTasks.taskBackgrounded(backgroundBashTask);
@@ -8216,7 +8281,7 @@ export class ClaudeAcpAgent {
asyncTasks,
backgroundBashTask,
);
const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
const resumedAgentId = resumedNativeSubagentId(toolUseResult);
if (resumedAgentId) {
const resume = sendMessageResume(
session.toolUseCache,
@@ -8257,7 +8322,7 @@ export class ClaudeAcpAgent {
emittedToolCalls: session.emittedToolCalls,
toolCallFields: toolCallFieldsOf(session),
messageId: messageIdForGrouping(message),
toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
toolUseResult,
// On the wire since CLI 2.1.216 but not in SDKUserMessage's
// type, hence the cast. Validated by parseToolResultMeta.
toolResultMeta:
@@ -8465,6 +8530,7 @@ export class ClaudeAcpAgent {
clearHookCallbacks(params.sessionId);
session.nativeSubagentRuntime?.clear();
session.asyncTaskRuntime?.clear();
this.replayedChildModels.delete(params.sessionId);
delete this.sessions[params.sessionId];
} else {
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
@@ -9043,6 +9109,7 @@ export class ClaudeAcpAgent {
clearHookCallbacks(sessionId);
session.nativeSubagentRuntime?.clear();
session.asyncTaskRuntime?.clear();
this.replayedChildModels.delete(sessionId);
delete this.sessions[sessionId];
this.sessionIndex.onTeardown(sessionId);
const ended = await raceTimeoutAndAbort(
@@ -9257,8 +9324,17 @@ export class ClaudeAcpAgent {
/** The state of the last persisted task notification of the child.
* It comes after the launch tool_result, so it wins. */
notifiedState?: SubagentState;
/** The model id of the child's latest response in its transcript. */
responseModel?: string;
/** The `model` option entry that the replay sent to the child. */
model?: ModelEntry;
}
>();
// A load replays while the CLI starts, and the session's model list comes
// with the CLI at an unknown point of the replay. So a pending replay maps
// no model to the list, and each child's model is its id until the load
// response refines it (see `publishCurrentSubagentStates`).
const replayModels = pending ? undefined : this.subagentModels(sessionId);
// Registers the Agent and Task launches of `list`, and the terminal state
// of each launch from its tool_result.
@@ -9388,8 +9464,11 @@ export class ClaudeAcpAgent {
: { agents: new Map() };
const rfdReplay =
form === "rfd"
? new RfdSubagentReplay(rfdHistory, (notification) =>
this.client.sessionUpdate(asSdkSessionNotification(notification)),
? new RfdSubagentReplay(
rfdHistory,
(notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
this.subagentModels(sessionId),
!pending,
)
: undefined;
rfdReplay?.scan(messages, sessionId);
@@ -9512,6 +9591,29 @@ export class ClaudeAcpAgent {
: nativeReplayEnabled && parentToolUseId
? await announceReplayChild(parentToolUseId)
: sessionId;
if (message.type === "assistant") {
const model = (message.message as { model?: string }).model;
if (rfdReplay && child) {
await rfdReplay.responseModel(child, model);
} else if (nativeReplayEnabled && parentToolUseId) {
// AIR's draft: the entry of the model list that is known now. A load
// can lack the list until the CLI started, so the entry goes again
// after the load response when the list maps it to another one.
const replayChild = replayChildren.get(parentToolUseId);
const entry = responseModelEntry(model, replayModels);
if (replayChild?.announced && entry && !sameModelEntry(replayChild.model, entry)) {
replayChild.responseModel = model;
replayChild.model = entry;
await this.client.sessionUpdate({
sessionId: replayChild.sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: [childModelOption(entry)],
},
});
}
}
}
if (
message.type === "assistant" &&
parentToolUseId &&
@@ -9597,7 +9699,6 @@ export class ClaudeAcpAgent {
clientCapabilities: this.clientCapabilities,
toolCallCapabilities: this.toolCallCapabilities,
cwd: replayState()?.cwd,
additionalDirectories: replayState()?.additionalDirectories,
taskState: replayState()?.taskState,
messageId: replayMessageId,
parentToolUseId,
@@ -9643,13 +9744,13 @@ export class ClaudeAcpAgent {
for (const toolUseId of subagentLaunchIds(content)) {
const launched = rfdReplay.childOfLaunch(toolUseId);
if (!launched || launched.announced) continue;
await rfdReplay.announce(launched);
const childMessages = await readSubagentMessages(
sessionId,
launched.sessionId,
rfdHistory,
this.logger,
);
await rfdReplay.announce(launched, firstResponseModel(childMessages));
rfdReplay.scan(childMessages, launched.sessionId);
for (const childMessage of childMessages) await replayMessage(childMessage, launched);
await rfdReplay.childReplayed(launched, childMessages);
@@ -9659,9 +9760,17 @@ export class ClaudeAcpAgent {
for (const message of messages) await replayMessage(message);
if (rfdReplay && !pending?.stopped) {
await rfdReplay.finish(clientSupportsNotices(this.clientCapabilities), sessionId);
this.replayedSubagents.set(sessionId, rfdReplay);
}
if (nativeReplayEnabled && !pending?.stopped) {
const childModels = new Map<string, { model: string; sent: ModelEntry }>();
for (const child of replayChildren.values()) {
if (child.responseModel && child.model) {
childModels.set(child.sessionId, { model: child.responseModel, sent: child.model });
}
}
if (childModels.size > 0) this.replayedChildModels.set(sessionId, childModels);
// Claude history persists sidechain messages and Agent/Task tool uses,
// but not task_started/task_updated lifecycle frames. Recover terminal
// state from the launch tool_result. Missing results and malformed or
@@ -10083,7 +10192,7 @@ export class ClaudeAcpAgent {
(session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
const update = new AcpToolCallRenderer(this.toolCallCapabilities).toolCall(
{ id: toolCallId, name: toolName, input: toolInput },
{ cwd: session.cwd, additionalDirectories: session.additionalDirectories, previewContent },
{ cwd: session.cwd, previewContent },
);
if (parentToolUseId) stampParentToolUseId(update, parentToolUseId);
toolCallFieldsOf(session).apply(update);
@@ -10132,6 +10241,84 @@ export class ClaudeAcpAgent {
};
}
/**
* The models of the session `sessionId`, for the `model` option of its
* subagents: the entries of the session's own `model` option.
*/
private subagentModels(sessionId: string): SubagentModels {
return {
entry: (model) => {
const session = this.sessions[sessionId];
return session
? modelEntryFor(session.configOptions, session.modelInfos, model)
: undefined;
},
name: (model) => {
const session = this.sessions[sessionId];
return session ? modelDisplayName(session.modelInfos, model) : undefined;
},
};
}
private subagentConfigurationHook(sessionId: string): HookCallback {
return async (input) => {
const session = this.sessions[sessionId];
if (!session || !input.agent_id) return { continue: true };
const options: SessionConfigOption[] = [];
if (input.permission_mode) {
const mode = session.modes.availableModes.find((mode) => mode.id === input.permission_mode);
options.push(
SessionModeManager.configOption({
currentModeId: input.permission_mode,
availableModes: [mode ?? { id: input.permission_mode, name: input.permission_mode }],
}),
);
}
const switchedModel =
input.hook_event_name === "PostModelSwitch"
? responseModelEntry(input.to_model, this.subagentModels(sessionId))
: undefined;
// The entry of the root session's `model` option, as for a child response.
if (switchedModel) options.push(childModelOption(switchedModel));
if (input.effort?.level) {
options.push({
id: EFFORT_CONFIG_ID,
name: "Effort",
description: "Current reasoning effort",
category: "thought_level",
type: "select",
currentValue: input.effort.level,
options: [{ value: input.effort.level, name: effortLevelName(input.effort.level) }],
});
}
if (input.agent_type) {
const agent = session.agents.find((agent) => agent.name === input.agent_type);
options.push({
id: AGENT_CONFIG_ID,
name: "Agent",
description: "Subagent type",
type: "select",
currentValue: input.agent_type,
options: [
{
value: input.agent_type,
name: input.agent_type,
...(agent?.description ? { description: agent.description } : {}),
},
],
});
}
await session.nativeSubagentRuntime
?.reportConfiguration(input.agent_id, options)
.catch((error) =>
this.logger.error(
`Session ${sessionId}: failed to report subagent configuration: ${error}`,
),
);
return { continue: true };
};
}
/**
* The PreToolUse hook of a v2 connection, for Edit and Write: the tool call
* shows the exact patch of the change before the tool runs.
@@ -10592,7 +10779,7 @@ export class ClaudeAcpAgent {
return getAvailableSlashCommands(
commands,
session.terminalSlashCommands,
this.toolCallCapabilities.air.client ? session : undefined,
this.toolCallCapabilities.air.client ? session.cwd : undefined,
(session.skillPaths ??= new Map()),
session.query,
);
@@ -10611,16 +10798,75 @@ export class ClaudeAcpAgent {
/**
* The work that follows a session setup response: the session's commands,
* and MCP server authentication. A v2 client gets the commands in the setup
* response itself (`src/v2/agent.ts`), so it gets no update with them here.
* the current states of the subagents a load replayed, and MCP server
* authentication. A v2 client gets the commands in the setup response itself,
* and the subagent states from the v2 surface once it has sent its own
* response (`src/v2/agent.ts`), so it gets neither here.
*/
private afterSetupResponse(sessionId: string, mcpServers: McpServer[]): void {
setTimeout(() => {
if (!this.v2) this.sendAvailableCommandsUpdate(sessionId);
if (!this.v2) {
this.sendAvailableCommandsUpdate(sessionId);
void this.publishCurrentSubagentStates(sessionId);
}
startMcpAuthentication(this, sessionId, mcpServers);
}, 0);
}
/**
* Sends the current state of each subagent that the last load of
* `sessionId` replayed (see {@link RfdSubagentReplay.currentStates}). Run it
* only after the load response: before it, the client treats subagent states
* as history.
*/
async publishCurrentSubagentStates(sessionId: string): Promise<void> {
await this.publishReplayedChildModels(sessionId);
const replay = this.replayedSubagents.get(sessionId);
if (!replay) return;
this.replayedSubagents.delete(sessionId);
const runtime = this.sessions[sessionId]?.nativeSubagentRuntime;
try {
for (const notification of replay.currentStates(
(child) => runtime?.liveWorkState(child),
(child) => runtime?.configurationOf?.(child),
)) {
await this.client.sessionUpdate(asSdkSessionNotification(notification));
}
} catch (error) {
this.logger.error(`Session ${sessionId}: failed to send current subagent states:`, error);
}
}
/**
* AIR's draft: a load replays while the CLI starts, so the replay can lack
* the session's model list and send a child's model as its id. After the
* load response the list is known, and each replayed child whose model the
* list maps to another entry gets that entry. The replay already ended the
* child, and AIR's draft allows no other update after a child's end, but it
* takes this more exact snapshot of the same configuration.
*/
private async publishReplayedChildModels(sessionId: string): Promise<void> {
const childModels = this.replayedChildModels.get(sessionId);
if (!childModels) return;
this.replayedChildModels.delete(sessionId);
const models = this.subagentModels(sessionId);
try {
for (const [childSessionId, { model, sent }] of childModels) {
const entry = responseModelEntry(model, models);
if (!entry || sameModelEntry(entry, sent)) continue;
await this.client.sessionUpdate({
sessionId: childSessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: [childModelOption(entry)],
},
});
}
} catch (error) {
this.logger.error(`Session ${sessionId}: failed to send subagent models:`, error);
}
}
private async updateConfigOption(
sessionId: string,
configId: string,
@@ -11322,7 +11568,12 @@ export class ClaudeAcpAgent {
// Resolve every workspace root once. The native checkpoint report uses
// this same set for lexical path validation, and the SDK receives it below.
const additionalDirectories = sessionAdditionalDirectories(params);
const acpAdditionalDirectories =
params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
const additionalDirectories = [
...(userProvidedOptions?.additionalDirectories ?? []),
...acpAdditionalDirectories,
];
const fileChangeReporter = supportsAgentFileChangeReport(this.clientCapabilities)
? createNativeFileChangeReporter({
@@ -11389,6 +11640,10 @@ export class ClaudeAcpAgent {
`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`,
);
// Both forms of native subagent sessions report the configuration of a child.
const subagentConfigHook = subagentForm(this.clientCapabilities)
? this.subagentConfigurationHook(sessionId)
: undefined;
const options: Options = {
systemPrompt,
settingSources: ["user", "project", "local"],
@@ -11457,14 +11712,33 @@ export class ClaudeAcpAgent {
tools,
hooks: {
...userProvidedOptions?.hooks,
...(subagentConfigHook
? {
SubagentStart: [
...(userProvidedOptions?.hooks?.SubagentStart ?? []),
{ hooks: [subagentConfigHook] },
],
SubagentStop: [
...(userProvidedOptions?.hooks?.SubagentStop ?? []),
{ hooks: [subagentConfigHook] },
],
PostToolUseFailure: [
...(userProvidedOptions?.hooks?.PostToolUseFailure ?? []),
{ hooks: [subagentConfigHook] },
],
}
: {}),
// On v2, an Edit or Write shows its exact patch before it runs, also
// when no permission request shows it (`fileChangePreviewHook`): a v2
// diff cannot hold the input's snippet.
...(this.v2
...(this.v2 || subagentConfigHook
? {
PreToolUse: [
...(userProvidedOptions?.hooks?.PreToolUse || []),
{ matcher: "Edit|Write", hooks: [this.fileChangePreviewHook(sessionId)] },
...(subagentConfigHook ? [{ hooks: [subagentConfigHook] }] : []),
...(this.v2
? [{ matcher: "Edit|Write", hooks: [this.fileChangePreviewHook(sessionId)] }]
: []),
],
}
: {}),
@@ -11472,6 +11746,7 @@ export class ClaudeAcpAgent {
...(userProvidedOptions?.hooks?.PostToolUse || []),
{
hooks: [
...(subagentConfigHook ? [subagentConfigHook] : []),
createPostToolUseHook({
onEnterPlanMode: async () => {
await this.sessionModes.publishCurrent(sessionId, "plan");
@@ -11495,9 +11770,11 @@ export class ClaudeAcpAgent {
...(userProvidedOptions?.hooks?.PostModelSwitch || []),
{
hooks: [
...(subagentConfigHook ? [subagentConfigHook] : []),
async (input) => {
if (
input.hook_event_name === "PostModelSwitch" &&
!input.agent_id &&
input.source !== "sdk" &&
input.source !== "resume"
) {
@@ -11879,7 +12156,6 @@ export class ClaudeAcpAgent {
settingsManager.getApiKeyHelper(configuredSettingsObject),
)?.kind,
fileChangeReporter,
additionalDirectories,
};
timing.phase("register");
this.refreshContextWindowInBackground(sessionId, this.sessions[sessionId]);
@@ -12742,24 +13018,19 @@ function getAvailableSlashCommands(
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
// the static list. Raw CLI names, matched before the MCP rename.
terminalCommands?: readonly string[],
// The session cwd and `--add-dir` roots of an AIR client, or undefined for
// every other client. AIR gets the kind and the SKILL.md path of each skill
// command.
airSkillRoots?: Pick<Session, "cwd" | "additionalDirectories">,
// The session cwd of an AIR client, or undefined for every other client.
// AIR gets the kind and the SKILL.md path of each skill command.
airSkillCwd?: string,
// The resolved SKILL.md paths of the session, keyed by the cwd and the name.
skillPaths?: Map<string, string | undefined>,
query?: Query,
): AvailableCommand[] {
// The container listings of this one pass, so a list of a hundred commands lists each container once.
const skillScans: SkillScanCache = new Map();
const skillPath = (
name: string,
{ cwd, additionalDirectories }: Pick<Session, "cwd" | "additionalDirectories">,
): string | undefined => {
// A session's roots never change, so the cwd and the name are key enough.
const skillPath = (name: string, cwd: string): string | undefined => {
const key = `${cwd}\0${name}`;
if (skillPaths?.has(key)) return skillPaths.get(key);
const resolved = resolveSkillPath(name, cwd, skillScans, additionalDirectories);
const resolved = resolveSkillPath(name, cwd, skillScans);
skillPaths?.set(key, resolved);
return resolved;
};
@@ -12805,8 +13076,8 @@ function getAvailableSlashCommands(
name = `mcp:${name.replace(" (MCP)", "")}`;
}
const skillMdPath =
airSkillRoots && !command.builtin && !mcpPrompt
? skillPath(command.name, airSkillRoots)
airSkillCwd && !command.builtin && !mcpPrompt
? skillPath(command.name, airSkillCwd)
: undefined;
return {
name,
@@ -13142,8 +13413,6 @@ export function toAcpNotifications(
toolCallCapabilities?: ToolCallClientCapabilities;
parentToolUseId?: string | null;
cwd?: string;
// The session's `--add-dir` roots, where a Skill tool's SKILL.md can also be.
additionalDirectories?: readonly string[];
taskState?: TaskState;
// Tracks tool_use ids already emitted as a `tool_call` so a permission
// request (which emits the tool_call eagerly) and the streamed tool_use
@@ -13350,11 +13619,10 @@ export function toAcpNotifications(
? // Already surfaced (full assistant message after streaming, or a
// permission request emitted it first): refine it with the
// complete input.
renderer.refinement(toolUse, options?.cwd, options?.additionalDirectories)
renderer.refinement(toolUse, options?.cwd)
: // First surface (streaming content_block_start or replay).
renderer.toolCall(toolUse, {
cwd: options?.cwd,
additionalDirectories: options?.additionalDirectories,
inputComplete: options?.inputComplete,
});
}
@@ -13578,7 +13846,6 @@ export function streamEventToAcpNotifications(
// See toAcpNotifications.
toolCallCapabilities?: ToolCallClientCapabilities;
cwd?: string;
additionalDirectories?: readonly string[];
taskState?: TaskState;
emittedToolCalls?: Set<string>;
toolCallFields?: ToolCallFieldTracker;
@@ -13594,7 +13861,6 @@ export function streamEventToAcpNotifications(
toolCallCapabilities: options?.toolCallCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
additionalDirectories: options?.additionalDirectories,
taskState: options?.taskState,
emittedToolCalls: options?.emittedToolCalls,
toolCallFields: options?.toolCallFields,
@@ -13661,7 +13927,6 @@ export function streamEventToAcpNotifications(
streamedInput,
input,
options?.cwd,
options?.additionalDirectories,
);
if (message.parent_tool_use_id) stampParentToolUseId(update, message.parent_tool_use_id);
applyMessageId(update, options?.messageId);
@@ -13994,17 +14259,27 @@ function getMatchingModelUsage(modelUsage: Record<string, ModelUsage>, currentMo
}
/**
* The `--add-dir` roots of a session: the `_meta.claudeCode.options` ones,
* then the ACP `additionalDirectories`, or the legacy `_meta.additionalRoots`
* for clients that do not send the ACP field.
* What a `command_lifecycle` "started" frame says started the command: a
* ScheduleWakeup (dynamic `/loop`) wakeup, with the time it was set for in
* epoch milliseconds, or another scheduled task such as a cron job. Undefined
* for any other command, and for a value this adapter does not know. The time
* is given in UTC because the agent does not know the client's time zone.
*/
function sessionAdditionalDirectories(params: {
additionalDirectories?: string[] | null;
_meta?: { [key: string]: unknown } | null;
}): string[] {
const meta = params._meta as NewSessionMeta | undefined;
return [
...(meta?.claudeCode?.options?.additionalDirectories ?? []),
...(params.additionalDirectories ?? meta?.additionalRoots ?? []),
];
function scheduledFireCause(frame: {
wakeup_source?: string;
scheduled_for?: number;
}): string | undefined {
switch (frame.wakeup_source) {
case "loop_wakeup": {
const scheduledFor =
typeof frame.scheduled_for === "number" ? new Date(frame.scheduled_for) : undefined;
return scheduledFor && !Number.isNaN(scheduledFor.getTime())
? `A scheduled wakeup fired (set for ${scheduledFor.toISOString()}).`
: "A scheduled wakeup fired.";
}
case "schedule_wakeup":
return "A scheduled task fired.";
default:
return undefined;
}
}

View File

@@ -458,18 +458,25 @@ export class AsyncTaskRuntime {
* example to answer a task notification. ACP v1 has no session state, and
* the client already got the stop reason of its last prompt, so this task is
* the only sign that the agent works. The tool calls of the turn are in the
* transcript already, so the task is not shown there. Does nothing while
* such a task is open.
* transcript already, so the task is not shown there.
*
* [cause] names what started the turn when Claude Code says so, for example
* "A scheduled wakeup fired." It replaces the description of a task that is
* already open; without it, an open task is left as it is.
*/
async autonomousTurnStarted(): Promise<void> {
if (!this.enabled || this.autonomousTurn) return;
async autonomousTurnStarted(cause?: string): Promise<void> {
if (!this.enabled) return;
if (this.autonomousTurn) {
if (cause) await this.publishProgress(this.autonomousTurn, { description: cause });
return;
}
const task = this.task(`autonomous-turn-${randomUUID()}`);
task.autonomousTurn = true;
task.startedObserved = true;
task.name = "Claude is working";
task.taskType = "turn";
task.description =
this.lastNotificationSummary ?? "Claude Code started a turn without a prompt.";
cause ?? this.lastNotificationSummary ?? "Claude Code started a turn without a prompt.";
task.showInTranscript = false;
this.lastNotificationSummary = undefined;
this.autonomousTurn = task;

View File

@@ -1,6 +1,11 @@
import { RequestError, type SessionNotification } from "@agentclientprotocol/sdk/unstable";
import {
RequestError,
type SessionConfigOption,
type SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
import { MODEL_CONFIG_ID } from "./session-model.js";
import { isFinalToolCallStatus } from "./tool-calls/renderer.js";
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
@@ -64,6 +69,32 @@ export type NativeSubagentSession = {
type Publish = (notification: AcpSessionNotification) => Promise<void>;
type Logger = { log(message: string): void };
/** An entry of a session's `model` select option. */
export type ModelEntry = { value: string; name: string };
/**
* The models of the root session. A child's model is an entry of the root
* session's `model` option when one matches, so that a client can compare the
* two sessions' models.
*/
export type SubagentModels = {
/**
* The entry of the root session's `model` option for `model`, a model id
* that a response reported. Undefined when no entry matches.
*/
entry(model: string): ModelEntry | undefined;
/** The display name of the model id `model` in the session's model list, if the list has it. */
name(model: string): string | undefined;
};
type ChildConfiguration = {
options: Map<string, SessionConfigOption>;
published?: string;
/** The `model` option value and name that the client last got. */
publishedModel?: ModelEntry;
publishing: Promise<void>;
};
/** What the SDK says about the end of a task, beyond its status. */
export type TaskEnd = {
/** The SDK's text for a failure. */
@@ -137,6 +168,7 @@ export class NativeSubagentRuntime {
private readonly parentByToolUse: Map<string, string>;
private readonly identityByToolUse = new Map<string, SubagentIdentity>();
private readonly controlByToolUse = new Map<string, AcpSessionNotification>();
private readonly configBySession = new Map<string, ChildConfiguration>();
private readonly childByParentToolUse = new Map<string, NativeSubagent>();
/**
* The child session of each tool call that went to a child session. A later
@@ -153,6 +185,15 @@ export class NativeSubagentRuntime {
* closes an unfinished call has no `toolName` to recognize it by.
*/
private readonly hiddenLaunches = new Map<string, { background: boolean }>();
/**
* The RFD form: the SendMessage calls that the client is not shown, until
* their final update, with their first report and the session that made
* them. A message that reaches an announced child replaces its call.
*/
private readonly hiddenSendMessages = new Map<
string,
{ initial: AcpSessionNotification; sender: string; delivered: boolean }
>();
/**
* The RFD form: the SubagentHandback calls of announced children that the
* client is not shown yet, with their first report, the report from their
@@ -184,6 +225,8 @@ export class NativeSubagentRuntime {
* `form` is undefined for a client without native subagent sessions. The RFD
* form restores an agent from before a load or resume with `lineage`. The AIR
* form does too, and `launchedAgent` finds the agent that a launch call made.
* `models` maps the model of a child response to the root session's entry
* (see {@link reportModel}).
*/
constructor(
form: SubagentForm | undefined,
@@ -193,6 +236,7 @@ export class NativeSubagentRuntime {
private readonly logger: Logger,
private readonly lineage?: SubagentLineage,
private readonly launchedAgent?: LaunchedAgent,
private readonly models?: SubagentModels,
) {
this.form = form;
this.enabled = form !== undefined;
@@ -212,8 +256,145 @@ export class NativeSubagentRuntime {
forcedSessionId?: string,
): Promise<AcpSessionNotification | null> {
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
if (routed) this.rememberSendMessage(routed);
return routed;
if (!routed) return routed;
this.rememberSendMessage(routed);
return this.form === "rfd" ? this.routeRfdSendMessage(routed, forcedSessionId) : routed;
}
/**
* The RFD form: the work state that the client holds for the announced child
* `sessionId` of this live session, or undefined when no such child runs here.
*/
liveWorkState(sessionId: string): WorkState["state"] | undefined {
if (this.form !== "rfd") return undefined;
const child = this.children.get(sessionId);
return child?.announced ? child.workState : undefined;
}
/**
* Reports the model of a child response, an exact model id, as the entry of
* the root session's `model` option that matches it, else as the id (see
* {@link responseModelEntry}). The child gets no model before its first
* response: the adapter cannot tell the model that Claude Code picks for it.
* The client gets a new configuration only when the entry changes.
*/
async reportModel(
parentToolUseId: string | null,
model: string | undefined,
deliver: Publish,
): Promise<void> {
const entry = this.form && parentToolUseId ? responseModelEntry(model, this.models) : undefined;
if (!entry) return;
// Every response of a child reports its model: most repeat the model that
// the client already has, and need no routing.
// A publication that failed sent nothing, so only the published model counts.
const child = this.childByParentToolUse.get(parentToolUseId!);
if (
child?.announced &&
sameModelEntry(this.configBySession.get(child.sessionId)?.publishedModel, entry)
) {
return;
}
await deliver({
sessionId: this.rootSessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: [childModelOption(entry)],
_meta: { claudeCode: { parentToolUseId } },
},
});
}
/** The configuration that the client last got for the announced child `sessionId`, if any. */
configurationOf(sessionId: string): SessionConfigOption[] | undefined {
const published = this.configBySession.get(sessionId)?.published;
if (!this.childOfSession(sessionId)?.announced || !published) return undefined;
return JSON.parse(published) as SessionConfigOption[];
}
/**
* Takes configuration options of the child `agentId`: an SDK agent id, as a
* hook names it, or a child session id. Hook observations can precede the
* task event that establishes a child's parent.
*/
async reportConfiguration(
agentId: string,
options: SessionConfigOption[],
meta?: SessionNotification["update"]["_meta"],
): Promise<void> {
if (!this.form || agentId === this.rootSessionId || options.length === 0) return;
// In AIR's draft, the session id of a resumed or restored agent is
// `<agent id>:generation:<n>`, so the agent id names its current generation.
const known = this.children.get(agentId) ?? this.childOfSession(agentId);
const sessionId = known?.sessionId ?? agentId;
// AIR's draft takes no configuration of an ended child (see `finishTask`).
if (
this.form === "air" &&
known &&
(known.terminalState !== undefined || known.terminalPromise)
) {
return;
}
let config = this.configBySession.get(sessionId);
if (!config) {
const pending = [...this.configBySession.keys()].filter(
(id) => !this.childOfSession(id),
).length;
if (!known && pending >= MAX_PENDING_PARENTS) {
this.logger.log(
`Session ${this.rootSessionId}: dropping configuration for unannounced subagent ${agentId}; pending buffer limit reached`,
);
return;
}
config = { options: new Map(), publishing: Promise.resolve() };
this.configBySession.set(sessionId, config);
}
for (const option of options) config.options.set(option.id, option);
if (known?.announced) await this.publishConfiguration(known, meta);
}
private async publishConfiguration(
child: NativeSubagent,
meta?: SessionNotification["update"]["_meta"],
): Promise<void> {
const config = this.configBySession.get(child.sessionId);
// AIR's draft ends a child for good, so it gets no update after its end,
// also when the end comes while this publication waits for an earlier one.
const ended = () =>
this.form === "air" && (child.terminalState !== undefined || !!child.terminalPromise);
if (!config || ended()) return;
// SDK hooks can run concurrently with streamed responses. Serialize snapshots
// so a delayed publication cannot replace newer settings with older ones.
const publishing = config.publishing.then(async () => {
if (this.configBySession.get(child.sessionId) !== config || ended()) return;
const priority = ["mode", "model", "thought_level", "model_config"];
const rank = (option: SessionConfigOption) => {
const index = priority.indexOf(option.category ?? "");
return index < 0 ? priority.length : index;
};
const configOptions = [...config.options.values()].sort(
(left, right) => rank(left) - rank(right),
);
const snapshot = JSON.stringify(configOptions);
if (config.published === snapshot) return;
await this.publish({
sessionId: child.sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions,
...(meta ? { _meta: meta } : {}),
},
});
config.published = snapshot;
const model = configOptions.find((option) => option.id === MODEL_CONFIG_ID);
const shown = model?.type === "select" ? model.options[0] : undefined;
if (model && shown && "name" in shown && typeof model.currentValue === "string") {
config.publishedModel = { value: model.currentValue, name: shown.name };
}
});
// The caller receives the error; later observations must still be able to retry.
config.publishing = publishing.catch(() => {});
await publishing;
}
private async routeUpdate(
@@ -315,6 +496,10 @@ export class NativeSubagentRuntime {
(claudeMeta?.parentToolUseId
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
: undefined);
if (announcedChild?.announced && update.sessionUpdate === "config_option_update") {
await this.reportConfiguration(announcedChild.sessionId, update.configOptions, update._meta);
return null;
}
if (this.form === "rfd" && announcedChild?.announced) {
const handback = await this.routeRfdHandback(notification, announcedChild);
if (handback !== undefined) return handback;
@@ -557,6 +742,8 @@ export class NativeSubagentRuntime {
if (child.parentToolUseId) (child.endedRuns ??= new Set()).add(child.parentToolUseId);
} else {
await finishNativeSubagent(this.session, taskId, state, this.publish);
// AIR's draft takes no configuration of an ended child.
this.configBySession.delete(child.sessionId);
}
} finally {
if (child.parentToolUseId) {
@@ -595,6 +782,7 @@ export class NativeSubagentRuntime {
this.pendingCount = 0;
this.identityByToolUse.clear();
this.controlByToolUse.clear();
this.configBySession.clear();
this.parentByToolUse.clear();
this.forgetRestores();
}
@@ -888,13 +1076,16 @@ export class NativeSubagentRuntime {
});
}
if (prompt && toolUseId) {
const sendMessage = this.hiddenSendMessages.get(toolUseId);
await publishPrompt(
child,
prompt,
toolUseId,
this.senderByToolUse.get(toolUseId),
sendMessage?.sender ?? this.senderByToolUse.get(toolUseId),
this.publish,
{ replacesCall: sendMessage !== undefined },
);
if (sendMessage) sendMessage.delivered = true;
}
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
}
@@ -908,6 +1099,7 @@ export class NativeSubagentRuntime {
*/
private withdraw(taskId: string, child: NativeSubagent): void {
this.children.delete(taskId);
this.configBySession.delete(child.sessionId);
if (child.parentToolUseId) {
this.takePending(child.parentToolUseId);
this.cleanupControl(child.parentToolUseId);
@@ -929,9 +1121,14 @@ export class NativeSubagentRuntime {
await this.reportState(child, rfdEndState("failed", { error }));
}
/** Announces `child` in the client's form. A runtime without one never does. */
/**
* Announces `child` in the client's form, then its configuration. A runtime
* without a form never does.
*/
private async announce(child: NativeSubagent): Promise<void> {
if (this.form) await announceNativeSubagent(child, this.publish, this.form);
if (!this.form) return;
await announceNativeSubagent(child, this.publish, this.form);
await this.publishConfiguration(child);
}
/** The RFD form: publishes the work state of `child` on its parent. */
@@ -1100,6 +1297,44 @@ export class NativeSubagentRuntime {
return unexposedControlFallback(handback.initial, notification, child.sessionId);
}
/**
* The RFD form: an update of a SendMessage call, which its message replaces
* once it reaches an announced child: the sender's outgoing view and the
* child's incoming one, see {@link linkedSessionMessages}. The SDK reports
* that delivery before the call's final update. A call whose message reached
* no announced child, such as one to the parent, is shown after all, with its
* final status. Any other update is returned as is.
*/
private routeRfdSendMessage(
notification: AcpSessionNotification,
forcedSessionId: string | undefined,
): AcpSessionNotification | null {
const { update } = notification;
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return notification;
}
const toolCallId = update.toolCallId;
// A permission request already showed the call to the client, so it keeps
// getting its updates, and its message goes only to the child.
if (forcedSessionId) {
this.hiddenSendMessages.delete(toolCallId);
return notification;
}
let sendMessage = this.hiddenSendMessages.get(toolCallId);
if (!sendMessage) {
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
if (update.sessionUpdate !== "tool_call" || claudeMeta?.toolName !== "SendMessage") {
return notification;
}
sendMessage = { initial: notification, sender: notification.sessionId, delivered: false };
this.hiddenSendMessages.set(toolCallId, sendMessage);
}
if (!isFinalToolCallStatus(update.status)) return null;
this.hiddenSendMessages.delete(toolCallId);
if (sendMessage.delivered) return null;
return unexposedControlFallback(sendMessage.initial, notification, sendMessage.sender);
}
/** Whether `update` is of an Agent/Task call, by its tool name or, in the
* RFD form, by the id of a call the client is not shown. */
private isControlUpdate(
@@ -1157,10 +1392,12 @@ export class NativeSubagentRuntime {
this.parentByToolUse.clear();
this.identityByToolUse.clear();
this.controlByToolUse.clear();
this.configBySession.clear();
this.childByParentToolUse.clear();
this.taskFinishPromises.clear();
this.senderByToolUse.clear();
this.hiddenLaunches.clear();
this.hiddenSendMessages.clear();
this.handbacks.clear();
this.generationByTaskId.clear();
this.pending.clear();
@@ -1188,6 +1425,20 @@ export class NativeSubagentRuntime {
private buffer(parentToolUseId: string, notification: AcpSessionNotification): void {
const updates = this.pending.get(parentToolUseId);
// A child reports its model with each response before its announcement
// (see `reportModel`). Only the latest such report waits, so they do not
// fill the buffer. Any other configuration update waits as it is.
const modelOnly = ({ update }: AcpSessionNotification) =>
update.sessionUpdate === "config_option_update" &&
update.configOptions.length === 1 &&
update.configOptions[0]?.id === MODEL_CONFIG_ID;
if (updates && modelOnly(notification)) {
const index = updates.findIndex(modelOnly);
if (index >= 0) {
updates[index] = notification;
return;
}
}
if (
this.pendingCount >= MAX_PENDING_UPDATES ||
(updates === undefined && this.pending.size >= MAX_PENDING_PARENTS) ||
@@ -1301,6 +1552,40 @@ export class NativeSubagentRuntime {
}
}
/**
* The `model` option of a child session. The client cannot change a child's
* configuration, so the select offers only the current model.
*/
export function childModelOption(entry: ModelEntry): SessionConfigOption {
return {
id: MODEL_CONFIG_ID,
name: "Model",
category: "model",
type: "select",
currentValue: entry.value,
options: [{ value: entry.value, name: entry.name }],
};
}
/** Whether two model entries have the same value and name. */
export function sameModelEntry(left: ModelEntry | undefined, right: ModelEntry): boolean {
return left?.value === right.value && left.name === right.name;
}
/**
* The model entry for `model`, the model id of a child's response: the entry
* of the root session's `model` option that matches it, else the id, with its
* display name when the session's model list has one. Undefined for no model,
* and for `<synthetic>`, which marks a message that no model wrote.
*/
export function responseModelEntry(
model: unknown,
models: SubagentModels | undefined,
): ModelEntry | undefined {
if (typeof model !== "string" || !model || model === "<synthetic>") return undefined;
return models?.entry(model) ?? { value: model, name: models?.name(model) ?? model };
}
/**
* Announces the child on its parent, once, before any update of the child.
* The RFD form announces it as running and then sends the prompt as the
@@ -1339,7 +1624,7 @@ export async function announceNativeSubagent(
child.parentToolUseId,
child.parentSessionId,
publish,
{ replacesLaunch: true },
{ replacesCall: true },
);
}
return;
@@ -1401,9 +1686,9 @@ export async function finishNativeSubagent(
* transcript is the same delegation and is not sent again. `sender` is the
* session that made that call, when known.
*
* A launch's prompt (`replacesLaunch`) also goes to the parent, see
* {@link linkedSessionMessages}. A SendMessage call stays a tool call of the
* sender, so its message has only the incoming view.
* When the message replaces its call (`replacesCall`), a launch's or a
* SendMessage call the client is not shown, it also goes to the sender, see
* {@link linkedSessionMessages}. Otherwise it has only the incoming view.
*/
async function publishPrompt(
child: NativeSubagent,
@@ -1411,13 +1696,13 @@ async function publishPrompt(
toolUseId: string | undefined,
sender: string | undefined,
publish: Publish,
{ replacesLaunch = false }: { replacesLaunch?: boolean } = {},
{ replacesCall = false }: { replacesCall?: boolean } = {},
): Promise<void> {
child.promptIds ??= new Set();
const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
if (child.promptIds.has(messageId)) return;
child.promptIds.add(messageId);
if (replacesLaunch && sender !== undefined && toolUseId !== undefined) {
if (replacesCall && sender !== undefined && toolUseId !== undefined) {
for (const view of linkedSessionMessages(toolUseId, sender, child.sessionId, prompt)) {
await publish(view);
}
@@ -1439,8 +1724,9 @@ async function publishPrompt(
* The RFD form: a message that a tool call sent, which the client is not
* shown, as two session messages: the sender's outgoing view, then the
* recipient's incoming one. That call is a launch's Agent/Task call, whose
* prompt goes from the parent to the child, or a child's SubagentHandback
* call, whose report goes from the child to the parent. Both views use the
* prompt goes from the parent to the child, a SendMessage call, whose message
* goes from its sender to a child, or a child's SubagentHandback call, whose
* report goes from the child to the parent. Both views use the
* call's tool use id, and each names the other with the draft RFD's
* `recipientMessageId` and `senderMessageId`.
*/
@@ -1710,7 +1996,8 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole
* A tool call that the client was not shown, as one `tool_call` with its
* final status, shown after all: an Agent/Task call whose child was never
* exposed (AIR's draft shows only a failed one, the RFD form also a completed
* one), or a failed SubagentHandback call.
* one), a SendMessage call whose message reached no announced child, or a
* failed SubagentHandback call.
*/
function unexposedControlFallback(
initial: AcpSessionNotification | undefined,

View File

@@ -79,6 +79,13 @@ export function mergeEffortSettings(base: Settings, override: Settings | undefin
};
}
export function effortLevelName(level: string): string {
return level
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ");
}
export function buildEffortConfigOption(
modelInfos: ModelInfo[],
currentModelId: string,
@@ -98,10 +105,7 @@ export function buildEffortConfigOption(
...(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(" "),
name: effortLevelName(level),
})),
];
const includes = (level: string) =>

View File

@@ -606,3 +606,88 @@ export async function getAvailableModels(
currentModelId: currentModel.value,
};
}
/** The display name of the model id `model` in `modelInfos`, if it is there. */
export function modelDisplayName(modelInfos: ModelInfo[], model: string): string | undefined {
let byModel = displayNameCache.get(modelInfos);
if (!byModel) displayNameCache.set(modelInfos, (byModel = new Map()));
if (!byModel.has(model)) {
const info = modelInfos.find((info) => info.value === model || info.resolvedModel === model);
byModel.set(model, info?.displayName);
}
return byModel.get(model);
}
/** {@link modelDisplayName} per model list, which a session replaces and never edits. */
const displayNameCache = new WeakMap<ModelInfo[], Map<string, string | undefined>>();
/** The flat entries of a session's `model` select option. */
function modelOptionEntries(option: SessionConfigOption): Array<{ value: string; name: string }> {
if (option.type !== "select") return [];
return option.options.flatMap((entry) =>
"value" in entry ? [{ value: entry.value, name: entry.name }] : entry.options,
);
}
/**
* The entry of the `model` option among `configOptions` for `model`, a model
* id that a response reported, so that a client can compare it with the
* session's own model. Best first:
*
* 1. the current entry, when it runs that model up to a context hint: a
* session on `opus[1m]` or `default` that answers as `claude-opus-4-6`;
* 2. the entry whose value or resolved model is that id;
* 3. an entry that runs that model up to a context hint.
*
* Among several entries for one model, a concrete entry comes before
* `default`, as in {@link resolveModelPreference}. Undefined when no entry
* matches. A model's resolved id comes from `modelInfos`
* (`ModelInfo.resolvedModel`), the wire id that a response reports, as for
* {@link matchResumedModel}. It is compared as it is: a dated id such as
* `claude-haiku-4-5-20251001` matches only the same dated id.
*/
export function modelEntryFor(
configOptions: readonly SessionConfigOption[],
modelInfos: ModelInfo[],
model: string,
): { value: string; name: string } | undefined {
// Each response of a subagent reports its model, so the result is kept per
// list. A session replaces its lists on a change and never edits them.
let byInfos = modelEntryCache.get(configOptions);
if (!byInfos) modelEntryCache.set(configOptions, (byInfos = new WeakMap()));
let byModel = byInfos.get(modelInfos);
if (!byModel) byInfos.set(modelInfos, (byModel = new Map()));
if (byModel.has(model)) return byModel.get(model);
const entry = findModelEntry(configOptions, modelInfos, model);
byModel.set(model, entry);
return entry;
}
const modelEntryCache = new WeakMap<
readonly SessionConfigOption[],
WeakMap<ModelInfo[], Map<string, { value: string; name: string } | undefined>>
>();
function findModelEntry(
configOptions: readonly SessionConfigOption[],
modelInfos: ModelInfo[],
model: string,
): { value: string; name: string } | undefined {
const option = configOptions.find((candidate) => candidate.id === MODEL_CONFIG_ID);
if (option?.type !== "select") return undefined;
// Concrete entries first; `sort` keeps the list order otherwise.
const entries = modelOptionEntries(option).sort(
(left, right) => Number(left.value === "default") - Number(right.value === "default"),
);
const observed = canonicalizeModelId(model);
const resolved = (value: string) =>
canonicalizeModelId(modelInfos.find((info) => info.value === value)?.resolvedModel ?? value);
const sameModel = (value: string) =>
stripContextHints(resolved(value)) === stripContextHints(observed);
const current = entries.find((entry) => entry.value === option.currentValue);
if (current && sameModel(current.value)) return current;
return (
entries.find((entry) => entry.value === model || resolved(entry.value) === observed) ??
entries.find((entry) => sameModel(entry.value))
);
}

View File

@@ -292,9 +292,13 @@ function isForkDirectiveMessage(message: { type?: unknown; message?: unknown }):
* The transcript of a `subagent_type: "fork"` child without the context it
* inherited. A fork transcript starts with a copy of the parent's launching
* assistant message (its text, thinking and every tool_use, including the
* Agent call itself), then a user message with a "Fork started — processing
* in background" tool_result for each of those tool_uses and the text
* `<fork-boilerplate>…</fork-boilerplate>\nYour directive: <prompt>`. A fork
* Agent call itself), then a user message with a placeholder tool_result for
* each of those tool_uses and the text
* `<fork-boilerplate>…</fork-boilerplate>\nYour directive: <prompt>`. Since
* Claude Code 2.1.307 the fork's own Agent call gets "Fork started — that fork
* is you." and the other tool_uses get "This is the parent's call, not
* yours. …"; before that, every one got "Fork started — processing in
* background". Only the directive text is matched here. A fork
* launched from inside a subagent also gets a copy of that subagent's history
* before them. None of it is the child's own work: replayed as is, the
* parent's text and tool calls would show again in the child.

View File

@@ -6,7 +6,8 @@
* `subagentHistory`). The replay announces each child right after that call,
* under the session id it had live, the SDK agent id, and sends the child's
* history to the child session. Every child's end goes out before the replay
* ends, since no subagent from before the reload still runs.
* ends. These states are history to the client, so each child's current
* state goes out again after the load response (`currentStates`).
*
* As live, the parent is not shown the Agent or Task call of a child that the
* replay announces: the launch's prompt is the parent's outgoing message, and
@@ -15,21 +16,38 @@
* Unlike live, the replay does not tell runs apart, so a child that handed
* back any report gets no reply from its launch's result.
*
* A SendMessage call whose message the replay delivered to a child before it
* reached the call is that message too: the sender's outgoing view, at the
* call, and the child's incoming one, in the child's history. A child's
* history follows its launch, so a call to a child from its parent is always
* delivered first. Any other SendMessage call stays a tool call.
*
* Right after its announcement, each child gets its `model` option: the model
* of its first response, in its transcript. A later response with another
* model sends it again. A child without responses gets none. After the load
* response, a model goes again only when the session's model list, which the
* replay can lack, maps it to another entry.
*
* A launch without metadata has no known id, so it is not announced: the RFD
* forbids inventing one. Its Agent call stays an ordinary tool call of the
* parent, and a notice says that subagent history is missing.
*/
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
import {
childModelOption,
HANDBACK_TOOL,
handbackMessage,
isNativeSubagentControlTool,
launchReply,
linkedSessionMessages,
type ModelEntry,
nativeSubagentState,
responseModelEntry,
rfdEndState,
rfdLabels,
type SubagentModels,
type WorkState,
} from "./native-subagents.js";
import { noticeUpdate } from "./session-notices.js";
@@ -56,6 +74,10 @@ export type ReplayChild = {
promptSent: boolean;
/** Whether it handed a report to its parent with a SubagentHandback call. */
handedBack?: boolean;
/** The `model` option entry that the replay sent. */
model?: ModelEntry;
/** The model id of the latest response in its transcript. */
responseModel?: string;
};
type SendMessageCall = {
@@ -63,7 +85,12 @@ type SendMessageCall = {
to: string;
message: string;
senderSessionId: string;
delivered: boolean;
/** The child the replay delivered the message to. */
recipientSessionId?: string;
/** The replay reached the call before the delivery, so it stays a tool call. */
shownAsToolCall: boolean;
/** The sender's view of the message went out, at the call. */
outgoingSent: boolean;
};
export class RfdSubagentReplay {
@@ -84,6 +111,13 @@ export class RfdSubagentReplay {
constructor(
private readonly history: SubagentHistory,
private readonly publish: Publish,
private readonly models?: SubagentModels,
/**
* Whether the replay maps a model to the session's model list as it goes.
* A load lacks the list until the CLI started, so it maps only in
* {@link currentStates}, after the load response.
*/
private readonly mapsDuringReplay = true,
) {}
/**
@@ -107,7 +141,8 @@ export class RfdSubagentReplay {
to: input.to,
message: input.message,
senderSessionId: ownerSessionId,
delivered: false,
shownAsToolCall: false,
outgoingSent: false,
});
}
}
@@ -129,8 +164,12 @@ export class RfdSubagentReplay {
return this.children.get(toolUseId);
}
/** Announces `child` on its parent, as live: running, with the parent's labels. */
async announce(child: ReplayChild): Promise<void> {
/**
* Announces `child` on its parent, as live: running, with the parent's
* labels. Then it sends `firstModel`, the model of the child's first
* response, when the transcript has one.
*/
async announce(child: ReplayChild, firstModel?: string): Promise<void> {
if (child.announced) return;
const labels = rfdLabels(
child.record.name ?? child.input.name,
@@ -148,6 +187,30 @@ export class RfdSubagentReplay {
},
});
child.announced = true;
await this.responseModel(child, firstModel);
}
/**
* Takes the model of a replayed response of the child `owner`. A child
* whose model changes gets its `model` option again.
*/
async responseModel(owner: ReplayChild, model: string | undefined): Promise<void> {
const entry = responseModelEntry(model, this.mapsDuringReplay ? this.models : undefined);
if (!entry || !owner.announced) return;
owner.responseModel = model;
await this.sendModel(owner, entry);
}
private async sendModel(child: ReplayChild, model: ModelEntry): Promise<void> {
if (child.model?.value === model.value && child.model.name === model.name) return;
child.model = model;
await this.publish({
sessionId: child.sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: [childModelOption(model)],
},
});
}
/**
@@ -193,10 +256,11 @@ export class RfdSubagentReplay {
/**
* Takes a replayed update of a tool call, from the transcript of `owner`
* when it is a child's. Returns whether the client is not shown it: an
* update of the launch of a child that the replay shows, or of a successful
* update of the launch of a child that the replay shows, of a SendMessage
* call whose message the replay already delivered, or of a successful
* SubagentHandback call of `owner`. A foreground launch's completed result
* goes out as the child's reply, and a hand-back as the child's message to
* its parent.
* goes out as the child's reply, a delivered SendMessage as the sender's
* outgoing message, and a hand-back as the child's message to its parent.
*/
async toolCallUpdate(
update: AcpSessionNotification["update"],
@@ -205,6 +269,24 @@ export class RfdSubagentReplay {
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return false;
}
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
if (sendMessage) {
if (sendMessage.recipientSessionId === undefined) {
sendMessage.shownAsToolCall = true;
return false;
}
if (!sendMessage.outgoingSent) {
sendMessage.outgoingSent = true;
const [outgoing] = linkedSessionMessages(
sendMessage.toolUseId,
sendMessage.senderSessionId,
sendMessage.recipientSessionId,
sendMessage.message,
);
if (outgoing) await this.publish(outgoing);
}
return true;
}
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
if (owner && handback && !handback.failed) {
if (!handback.sent) {
@@ -247,19 +329,19 @@ export class RfdSubagentReplay {
}
/**
* Sends the end of every announced child, and a notice when subagent history
* is missing. The end is the state of the child's last task notification,
* else `cancelled` for a child the user stopped, else the result of its
* foreground launch, else `idle` with no stop reason.
* Sends the end of every announced child (see `endState`), and a notice when
* subagent history is missing.
*/
async finish(supportsNotices: boolean, rootSessionId: string): Promise<void> {
for (const child of [...this.children.values()].reverse()) {
if (!child.announced) continue;
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
const state: WorkState = end ? rfdEndState(end, undefined) : { state: "idle" };
await this.publish({
sessionId: child.parentSessionId,
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
state: endState(child),
},
});
}
const missing = this.omitted.size + this.withoutHistory;
@@ -278,6 +360,56 @@ export class RfdSubagentReplay {
}
}
/**
* The current state of each announced child, to send after the load
* response. The RFD makes that response the freshness boundary: a client
* treats the replayed states before it as history, so they show no current
* activity and allow no cancel.
*
* `live` gives the work state of a child that still runs in this process, as
* when a live session is loaded again. Such a child reports that state and
* can be cancelled, as live. Any other child cannot be running, because the
* process that ran it is gone, so its replayed end is its current state.
*/
currentStates(
live: (sessionId: string) => WorkState["state"] | undefined,
liveConfiguration?: (sessionId: string) => SessionConfigOption[] | undefined,
): AcpSessionNotification[] {
return [...this.children.values()]
.filter((child) => child.announced)
.flatMap((child): AcpSessionNotification[] => {
const liveState = live(child.sessionId);
const state: WorkState =
liveState === undefined || liveState === "idle" ? endState(child) : { state: liveState };
// The session's model list may be known only now, after the load response.
const entry = responseModelEntry(child.responseModel, this.models);
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
const current =
liveConfiguration?.(child.sessionId) ?? (entry ? [childModelOption(entry)] : undefined);
// A configuration that the replay already sent does not go again.
const configOptions = current && JSON.stringify(current) !== replayed ? current : undefined;
return [
{
sessionId: child.parentSessionId,
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
...(liveState !== undefined ? { capabilities: { cancel: {} } } : {}),
state,
},
},
...(configOptions
? [
{
sessionId: child.sessionId,
update: { sessionUpdate: "config_option_update" as const, configOptions },
},
]
: []),
];
});
}
private registerLaunch(
toolUseId: string,
input: Record<string, unknown>,
@@ -322,6 +454,9 @@ export class RfdSubagentReplay {
* first undelivered SendMessage to the child whose message the delivered
* text contains. With that call, the message has the call's id and text and
* its sender, as live; without it, its transcript id and the delivered text.
* Unless the replay already showed the call as a tool call, the message
* replaces it, so this view names the sender's view, which goes out at the
* call (see {@link toolCallUpdate}).
*/
private async sendDelivery(
child: ReplayChild,
@@ -331,13 +466,15 @@ export class RfdSubagentReplay {
const names = [child.sessionId, child.record.name, child.input.name];
const call = this.sendMessages.find(
(candidate) =>
!candidate.delivered &&
candidate.recipientSessionId === undefined &&
names.includes(candidate.to) &&
text.includes(candidate.message.trim()),
);
if (call) {
call.delivered = true;
await this.sendMessage(child, call.toolUseId, call.message, call.senderSessionId);
call.recipientSessionId = child.sessionId;
await this.sendMessage(child, call.toolUseId, call.message, call.senderSessionId, {
linked: !call.shownAsToolCall,
});
} else {
await this.sendMessage(child, message.uuid, text, undefined);
}
@@ -348,6 +485,7 @@ export class RfdSubagentReplay {
messageId: string,
text: string,
sender: string | undefined,
{ linked = false }: { linked?: boolean } = {},
): Promise<void> {
await this.publish({
sessionId: child.sessionId,
@@ -356,12 +494,23 @@ export class RfdSubagentReplay {
messageId,
...(sender ? { senderSessionId: sender } : {}),
recipientSessionId: child.sessionId,
...(linked ? { senderMessageId: messageId } : {}),
content: [{ type: "text", text }],
},
});
}
}
/** The model of the first response in `messages`, a subagent's transcript. */
export function firstResponseModel(messages: readonly SessionMessage[]): string | undefined {
for (const message of messages) {
if (message.type !== "assistant") continue;
const model = (message.message as { model?: unknown } | undefined)?.model;
if (typeof model === "string" && model && model !== "<synthetic>") return model;
}
return undefined;
}
/**
* The end of a foreground launch from its tool result: an error result that
* names a stop is a cancellation, any other error a failure.
@@ -376,6 +525,16 @@ export function replaySubagentTerminalState(
: "failed";
}
/**
* How `child`'s work ended: the state of its last task notification, else
* `cancelled` for a child the user stopped, else the result of its foreground
* launch, else `idle` with no stop reason.
*/
function endState(child: ReplayChild): WorkState {
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
return end ? rfdEndState(end, undefined) : { state: "idle" };
}
function replayContentText(value: unknown, seen = new Set<unknown>()): string {
if (typeof value === "string") return value;
if (typeof value !== "object" || value === null || seen.has(value)) return "";

View File

@@ -16,6 +16,7 @@ import {
RequestError,
RequestPermissionRequest,
RequestPermissionResponse,
SessionConfigOption,
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
@@ -67,6 +68,7 @@ import {
type HookCallback,
importSessionToStore,
listSessions,
type ModelInfo,
PermissionUpdate,
query,
SDKAssistantMessage,
@@ -2269,6 +2271,57 @@ describe("usage Markdown", () => {
expect(formatted).not.toContain("lowest");
});
it("renders each window's recommendations in order, escaped, before its MCP table", () => {
const formatted = formatUsageResponse({
...usageResponse,
behaviors: {
...usageResponse.behaviors,
day: {
...usageResponse.behaviors.day,
recommendations: [
{ key: "mcp_server", pct: 41, title: "Disable my_server", detail: "/mcp" },
{ key: "effort", pct: 12, title: "Lower effort | max" },
],
fix: { keys: ["mcp_server"], messages: ["/mcp disable my_server"] },
},
week: {
...usageResponse.behaviors.week,
mcp_servers: [],
recommendations: [{ key: "agent", pct: 5, title: "Trim the reviewer agent" }],
},
},
});
const day = formatted.slice(
formatted.indexOf("**Last 24h**"),
formatted.indexOf("**Last 7d**"),
);
expect(day).toContain(
"- **41%** Disable my\\_server · /mcp\n- **12%** Lower effort \\| max\n\n| MCP server |",
);
expect(formatted).not.toContain("undefined");
expect(formatted).not.toContain("/mcp disable");
expect(
formatted.endsWith(
"**Last 7d** · 43 requests · 5 sessions\n\n- **5%** Trim the reviewer agent",
),
).toBe(true);
});
it("falls back to the CLI text for a malformed recommendation", () => {
expect(
parseUsageResponse({
...usageResponse,
behaviors: {
...usageResponse.behaviors,
day: {
...usageResponse.behaviors.day,
recommendations: [{ key: "effort", pct: "high", title: "Lower effort" }],
},
},
}),
).toBeNull();
});
it("recognizes only exact local usage commands", () => {
expect(isUsageCommandText(" /usage ")).toBe(true);
expect(isUsageCommandText("/cost")).toBe(false);
@@ -3533,6 +3586,73 @@ describe("subagent transcript replay", () => {
return updates;
}
it("refines an ended AIR child's model after the load response, once the model list is known", async () => {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) =>
updates.push(update as AcpSessionNotification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, AIR_NATIVE_SUBAGENTS);
// A load replays while the CLI starts: the session and its model list come later.
await (
agent as unknown as {
replaySessionHistory(
sessionId: string,
messages: unknown[],
pending: unknown,
): Promise<void>;
}
).replaySessionHistory("s1", replayHistory, {
state: { cwd: "/tmp", taskState: new Map(), messageIdToUuid: new Map() },
stopped: false,
});
const child = "s1:replay-subagent:parent-agent-call";
const models = () =>
updates.flatMap(({ sessionId, update }) =>
update.sessionUpdate === "config_option_update"
? update.configOptions.map((option) => [
sessionId,
option.currentValue,
(option as { options: Array<{ name: string }> }).options[0]?.name,
])
: [],
);
// Without the list, the replay sends the model id, and then ends the child.
expect(models()).toEqual([[child, "claude-sonnet-4-5", "claude-sonnet-4-5"]]);
expect(updates.at(-1)?.update.sessionUpdate).toBe("subagent_state_update");
(agent.sessions as Record<string, unknown>).s1 = {
configOptions: [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "sonnet",
options: [{ value: "sonnet", name: "Sonnet 4.5" }],
},
],
modelInfos: [
{
value: "sonnet",
resolvedModel: "claude-sonnet-4-5",
displayName: "Sonnet 4.5",
description: "",
},
],
};
await agent.publishCurrentSubagentStates("s1");
expect(models()).toEqual([
[child, "claude-sonnet-4-5", "claude-sonnet-4-5"],
[child, "sonnet", "Sonnet 4.5"],
]);
// Once only.
await agent.publishCurrentSubagentStates("s1");
expect(models()).toHaveLength(2);
});
it("replays a subagent from its own transcript file", async () => {
// Claude keeps a subagent history in <session>/subagents/, and the SDK
// returns the main transcript without it and without parent_tool_use_id.
@@ -3689,8 +3809,11 @@ describe("subagent transcript replay", () => {
[sessionId, "user_message_chunk", "Explore it"],
[sessionId, "subagent_spawned", child1],
[child1, "user_message_chunk", "Look around"],
// The model of the child's response, as the replay reaches it.
[child1, "config_option_update", undefined],
[child1, "agent_message_chunk", "Child report."],
// A launch without a subagent file still gets its child and its state.
// Nothing names its model, so it gets none.
[sessionId, "subagent_spawned", child2],
[sessionId, "agent_message_chunk", "Done."],
[sessionId, "subagent_state_update", child2],
@@ -3757,6 +3880,19 @@ describe("subagent transcript replay", () => {
root: object[],
children: Record<string, { meta: object; records: object[] }>,
capabilities: ClientCapabilities = { subagents: {}, session: { notices: {} } },
afterResponse?: {
live: (childSessionId: string) => string | undefined;
/** The session's config options and model list, known after the response. */
models?: { configOptions: SessionConfigOption[]; modelInfos: ModelInfo[] };
},
{
traceModels = false,
load = false,
}: {
traceModels?: boolean;
/** Replay as a load does, while the CLI starts: without the session record. */
load?: boolean;
} = {},
): Promise<string[]> {
const configDir = await mkdtemp(path.join(os.tmpdir(), "claude-acp-rfd-replay-"));
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
@@ -3794,15 +3930,42 @@ describe("subagent transcript replay", () => {
);
await initializeClient(agent, capabilities);
vi.mocked(getSessionInfo).mockResolvedValueOnce({ cwd: "/tmp/proj" } as any);
const pending = load
? {
state: { cwd: "/tmp/proj", taskState: new Map(), messageIdToUuid: new Map() },
stopped: false,
}
: undefined;
await (
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
).replaySessionHistory(sessionId);
return updates.flatMap(({ sessionId: target, update }) => {
agent as unknown as {
replaySessionHistory(
sessionId: string,
messages?: unknown[],
pending?: unknown,
): Promise<void>;
}
).replaySessionHistory(sessionId, undefined, pending);
const responseAt = updates.length;
if (afterResponse) {
// Stands in for the live session's runtime, which knows its running children.
(agent.sessions as Record<string, unknown>)[sessionId] = {
nativeSubagentRuntime: { liveWorkState: afterResponse.live },
configOptions: afterResponse.models?.configOptions ?? [],
modelInfos: afterResponse.models?.modelInfos ?? [],
};
await agent.publishCurrentSubagentStates(sessionId);
}
return updates.flatMap(({ sessionId: target, update }, index) => {
const on = target === sessionId ? "root" : target;
const u = update as any;
const response = afterResponse && index === responseAt ? ["-- response --"] : [];
switch (update.sessionUpdate) {
case "subagent_update":
return [`${on} subagent_update ${u.sessionId} ${JSON.stringify(u.state)}`];
return [
...response,
`${on} subagent_update ${u.sessionId} ${JSON.stringify(u.state)}` +
(u.capabilities ? ` capabilities=${JSON.stringify(u.capabilities)}` : ""),
];
case "session_message": {
const references = [
...(u.senderMessageId ? [`senderMessageId=${u.senderMessageId}`] : []),
@@ -3818,6 +3981,13 @@ describe("subagent transcript replay", () => {
}
case "tool_call":
return [`${on} tool_call ${u.toolCallId}`];
case "config_option_update":
return traceModels
? u.configOptions.map(
(option: any) =>
`${on} model ${option.currentValue} ${JSON.stringify(option.options)}`,
)
: [];
case "notice":
return [`${on} notice ${u.title}: ${u.description}`];
case "user_message_chunk":
@@ -3837,7 +4007,8 @@ describe("subagent transcript replay", () => {
it("replays a fork child without the launching message and boilerplate it inherited", async () => {
// A `subagent_type: "fork"` transcript starts with a copy of the
// launching message (same API message id) and a directive message with
// a "Fork started" result for each of its tool_uses.
// a placeholder result for each of its tool_uses, worded as Claude Code
// 2.1.307+ writes them.
const launching = [
{ type: "text", text: "I'll fork a worker." },
toolUse("toolu_fork", "Agent", {
@@ -3850,7 +4021,15 @@ describe("subagent transcript replay", () => {
const forkStarted = (id: string) => ({
type: "tool_result",
tool_use_id: id,
content: [{ type: "text", text: "Fork started — processing in background" }],
content: [
{
type: "text",
text:
id === "toolu_fork"
? "Fork started — that fork is you."
: "This is the parent's call, not yours. It may not have run yet, and its result goes to the parent.",
},
],
});
const trace = await replayRfd(
[
@@ -3984,11 +4163,13 @@ describe("subagent transcript replay", () => {
"def456 agent_message_chunk X.",
"abc123 session_message toolu_nested:reply from=def456 The spec says X.",
// The delivered message has the id, text, and sender of its SendMessage
// call, which stays a tool call of its sender.
"abc123 session_message toolu_send from=" + sessionId + " Check the tests too",
// call, which it replaces: the sender's view goes out at the call.
"abc123 session_message toolu_send from=" +
sessionId +
" senderMessageId=toolu_send Check the tests too",
"abc123 agent_message_chunk Tests pass.",
"root session_message toolu_agent:reply from=abc123 Found it.",
"root tool_call toolu_send",
`root session_message toolu_send from=${sessionId} recipientMessageId=toolu_send Check the tests too`,
"root agent_message_chunk Done.",
// Every end comes before the response.
'abc123 subagent_update def456 {"state":"idle","stopReason":"end_turn"}',
@@ -3997,6 +4178,198 @@ describe("subagent transcript replay", () => {
]);
});
it("sends each child's current state after the load response", async () => {
const launch = (id: string, name: string) =>
toolUse(id, "Agent", { name, subagent_type: "Explore", prompt: `Run ${name}` });
const child = (name: string, launchId: string) => ({
meta: { agentType: "Explore", name, toolUseId: launchId, requestShape: "foreground" },
records: [user(`${name}1`, `Run ${name}`)],
});
const trace = await replayRfd(
[
user("u1", "Go"),
assistant("a1", [launch("toolu_live", "live"), launch("toolu_gone", "gone")]),
user("u2", [toolResult("toolu_live", "Live."), toolResult("toolu_gone", "Gone.")]),
],
{ aaa111: child("live", "toolu_live"), bbb222: child("gone", "toolu_gone") },
undefined,
// The session is loaded again in the process that still runs `aaa111`.
{ live: (childSessionId) => (childSessionId === "aaa111" ? "running" : undefined) },
);
expect(trace.slice(trace.indexOf("-- response --"))).toEqual([
"-- response --",
// A child that still runs reports its live state, and can be cancelled.
'root subagent_update aaa111 {"state":"running"} capabilities={"cancel":{}}',
// Any other child's process is gone: its replayed end is its current
// state, and nothing can cancel it.
'root subagent_update bbb222 {"state":"idle","stopReason":"end_turn"}',
]);
});
it("sends the model of each child's responses, and after the load response only a changed entry", async () => {
const launch = (id: string, name: string, input: object = {}) =>
toolUse(id, "Agent", {
name,
subagent_type: "general-purpose",
prompt: `Run ${name}`,
...input,
});
const answer = (uuid: string, model: string, text: string) => ({
...assistant(uuid, []),
message: {
id: `m-${uuid}`,
role: "assistant",
model,
content: [{ type: "text", text }],
},
});
const meta = (name: string, launchId: string) => ({
agentType: "general-purpose",
name,
toolUseId: launchId,
requestShape: "foreground",
});
const trace = await replayRfd(
[
user("u1", "Go"),
assistant("a1", [
launch("toolu_answered", "answered"),
launch("toolu_unlisted", "unlisted", { model: "haiku" }),
launch("toolu_silent", "silent"),
]),
user("u2", [
toolResult("toolu_answered", "Done."),
toolResult("toolu_unlisted", "Done."),
toolResult("toolu_silent", "Done."),
]),
],
{
aaa111: {
meta: meta("answered", "toolu_answered"),
records: [
user("x1", "Run answered"),
answer("x2", "claude-haiku-4-5", "First."),
answer("x3", "claude-haiku-4-5", "Same model."),
answer("x4", "claude-opus-5", "Fallback."),
],
},
// A model that the session's list does not have.
bbb222: {
meta: meta("unlisted", "toolu_unlisted"),
records: [user("y1", "Run unlisted"), answer("y2", "claude-fable-9", "Done.")],
},
// No response: the replay does not guess the launch input's or the parent's model.
ccc333: { meta: meta("silent", "toolu_silent"), records: [user("z1", "Run silent")] },
},
undefined,
{
live: () => undefined,
models: {
configOptions: [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "opus",
options: [
{ value: "opus", name: "Opus 5" },
{ value: "haiku", name: "Haiku 4.5" },
],
},
],
modelInfos: [
{
value: "opus",
displayName: "Opus 5",
description: "",
resolvedModel: "claude-opus-5",
},
{
value: "haiku",
displayName: "Haiku 4.5",
description: "",
resolvedModel: "claude-haiku-4-5",
},
],
},
},
{ traceModels: true, load: true },
);
const opus = JSON.stringify([{ value: "opus", name: "Opus 5" }]);
const raw = (model: string) => JSON.stringify([{ value: model, name: model }]);
expect(trace.filter((line) => / (model|subagent_update) /.test(line))).toEqual([
// During the replay the session has no model list yet, so the model is the id.
'root subagent_update aaa111 {"state":"running"}',
`aaa111 model claude-haiku-4-5 ${raw("claude-haiku-4-5")}`,
// A response on another model sends the model again; the same model does not.
`aaa111 model claude-opus-5 ${raw("claude-opus-5")}`,
'root subagent_update bbb222 {"state":"running"}',
`bbb222 model claude-fable-9 ${raw("claude-fable-9")}`,
'root subagent_update ccc333 {"state":"running"}',
'root subagent_update ccc333 {"state":"idle","stopReason":"end_turn"}',
'root subagent_update bbb222 {"state":"idle","stopReason":"end_turn"}',
'root subagent_update aaa111 {"state":"idle","stopReason":"end_turn"}',
// After the response, a model goes again only when the root session's
// `model` option maps it to another entry.
'root subagent_update aaa111 {"state":"idle","stopReason":"end_turn"}',
`aaa111 model opus ${opus}`,
'root subagent_update bbb222 {"state":"idle","stopReason":"end_turn"}',
'root subagent_update ccc333 {"state":"idle","stopReason":"end_turn"}',
]);
});
it("keeps a SendMessage call that the replay reaches before its delivery as a tool call", async () => {
const launch = (id: string, name: string) =>
toolUse(id, "Agent", { name, subagent_type: "Explore", prompt: `Run ${name}` });
const meta = (name: string, launchId: string) => ({
agentType: "Explore",
name,
toolUseId: launchId,
requestShape: "foreground",
});
const trace = await replayRfd(
[
user("u1", "Go"),
assistant("a1", [launch("toolu_a", "alpha"), launch("toolu_b", "beta")]),
user("u2", [toolResult("toolu_a", "A."), toolResult("toolu_b", "B.")]),
],
{
// alpha's history, which replays first, messages beta.
aaa111: {
meta: meta("alpha", "toolu_a"),
records: [
user("a1", "Run alpha"),
assistant("a2", [
toolUse("toolu_sib", "SendMessage", { to: "bbb222", message: "Ping sibling" }),
]),
user("a3", [toolResult("toolu_sib", "Message sent.")]),
],
},
bbb222: {
meta: meta("beta", "toolu_b"),
records: [
user("b1", "Run beta"),
user(
"b2",
"The coordinator sent a message while you were working:\nPing sibling\n\nAddress this before completing your current task.",
{ isMeta: true, origin: { kind: "coordinator" } },
),
],
},
},
);
expect(trace.filter((line) => line.includes("toolu_sib"))).toEqual([
// The call came first, so it stays a tool call, and the message that
// arrives later names no other view.
"aaa111 tool_call toolu_sib",
"bbb222 session_message toolu_sib from=aaa111 Ping sibling",
]);
});
it("replays a child's hand-back as its message to the parent, instead of the launch's result", async () => {
const trace = await replayRfd(
[
@@ -4093,6 +4466,8 @@ describe("subagent transcript replay", () => {
const updates = await replay("native");
expect(updates.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
["s1", "subagent_spawned"],
// The model of its response, as the replay reaches it.
["s1:replay-subagent:parent-agent-call", "config_option_update"],
["s1:replay-subagent:parent-agent-call", "agent_message_chunk"],
["s1:replay-subagent:parent-agent-call", "tool_call"],
["s1", "subagent_state_update"],
@@ -7177,6 +7552,64 @@ describe("subagent permission attribution (issue #851)", () => {
session_id: "test-session",
});
it("reports the child's observed model as a singleton config without enabling changes", async () => {
const response = (model: string) => ({
type: "assistant",
parent_tool_use_id: "toolu_parent",
uuid: randomUUID(),
session_id: "test-session",
message: {
id: randomUUID(),
role: "assistant",
model,
content: [{ type: "text", text: "Working" }],
usage: { input_tokens: 1, output_tokens: 1 },
},
});
const first = response("child-model");
const { agent, updates } = await runAgent([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
{
type: "stream_event",
parent_tool_use_id: "toolu_parent",
uuid: randomUUID(),
session_id: "test-session",
event: { type: "message_start", message: first.message },
},
first,
response("fallback-model"),
taskUpdated({ status: "completed" }),
]);
const configs = updates.filter(
({ update }) => update.sessionUpdate === "config_option_update",
);
expect(configs).toHaveLength(2);
for (const [index, model] of ["child-model", "fallback-model"].entries()) {
expect(configs[index]).toMatchObject({
sessionId: "agent-42",
update: {
configOptions: [
{
id: "model",
currentValue: model,
options: [{ value: model, name: model }],
},
],
},
});
}
expect(
updates.findIndex(({ update }) => update.sessionUpdate === "subagent_update"),
).toBeLessThan(updates.indexOf(configs[0]!));
await expect(
agent.setSessionConfigOption({
sessionId: "agent-42",
configId: "model",
value: "fallback-model",
}),
).rejects.toThrow("Session not found");
});
it("announces the child as running, then sends it the prompt as a message", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore", prompt: "Go" },
@@ -7593,6 +8026,18 @@ describe("subagent permission attribution (issue #851)", () => {
state: { state: "running" },
},
},
// The SendMessage call's message replaces it, as a launch's does.
{
sessionId: "test-session",
update: {
sessionUpdate: "session_message",
messageId: "toolu_send",
senderSessionId: "test-session",
recipientSessionId: "agent-42",
recipientMessageId: "toolu_send",
content: [{ type: "text", text: "Also check Windows" }],
},
},
{
sessionId: "agent-42",
update: {
@@ -7600,6 +8045,7 @@ describe("subagent permission attribution (issue #851)", () => {
messageId: "toolu_send",
senderSessionId: "test-session",
recipientSessionId: "agent-42",
senderMessageId: "toolu_send",
content: [{ type: "text", text: "Also check Windows" }],
},
},
@@ -7744,6 +8190,7 @@ describe("subagent permission attribution (issue #851)", () => {
messageId: "toolu_send",
senderSessionId: "test-session",
recipientSessionId: "agent-7",
senderMessageId: "toolu_send",
content: [{ type: "text", text: "Say PING" }],
},
},
@@ -7763,6 +8210,17 @@ describe("subagent permission attribution (issue #851)", () => {
},
},
]);
// The root has the message instead of the completed SendMessage call.
const root = updates.flatMap(({ sessionId, update }) =>
sessionId === "test-session" &&
"toolCallId" in update &&
update.toolCallId === "toolu_send"
? [update.sessionUpdate]
: sessionId === "test-session" && update.sessionUpdate === "session_message"
? [`session_message ${update.messageId} to=${update.recipientSessionId}`]
: [],
);
expect(root).toEqual(["session_message toolu_send to=agent-7"]);
} finally {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
@@ -8430,6 +8888,8 @@ describe("subagent permission attribution (issue #851)", () => {
expect(updates.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
["test-session", "subagent_spawned"],
["agent-42", "agent_message_chunk"],
// The model of the child's response.
["agent-42", "config_option_update"],
["agent-42", "tool_call"],
["agent-42", "agent_message_chunk"],
["test-session", "subagent_state_update"],
@@ -12902,8 +13362,6 @@ describe("skill commands in available_commands_update", () => {
async function advertisedCommands(
clientCapabilities: Record<string, unknown>,
change?: () => Promise<void>,
additionalDirectories?: string[],
extraCommands: Array<{ name: string; description: string; argumentHint: string }> = [],
) {
const sessionUpdate = vi.fn(async () => {});
const agent = new ClaudeAcpAgent({ sessionUpdate } as unknown as AcpClient, {
@@ -12936,7 +13394,6 @@ describe("skill commands in available_commands_update", () => {
},
{ name: "missing", description: "No SKILL.md", argumentHint: "" },
{ name: "review (MCP)", description: "An MCP prompt", argumentHint: "" },
...extraCommands,
],
};
yield commandsChanged;
@@ -12968,7 +13425,7 @@ describe("skill commands in available_commands_update", () => {
};
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
injectGeneratorSession(agent, generator, { cwd, additionalDirectories });
injectGeneratorSession(agent, generator, { cwd });
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
@@ -13004,29 +13461,6 @@ describe("skill commands in available_commands_update", () => {
});
});
it("resolves a skill of an --add-dir root, whose name another skill holds, as <basename>:<name>", async () => {
const addDir = path.join(cwd, "..", `${path.basename(cwd)}-repoB`);
const addDirSkill = path.join(addDir, ".claude", "skills", "review", "SKILL.md");
await mkdir(path.dirname(addDirSkill), { recursive: true });
await writeFile(addDirSkill, "# review\n");
try {
const commands = await advertisedCommands(
AIR_CLIENT_CAPABILITIES,
undefined,
[addDir],
[{ name: `${path.basename(addDir)}:review`, description: "", argumentHint: "" }],
);
expect(commands.find((c) => c.name === "review")?._meta).toEqual({
jetbrains: { air: { version: 1, kind: "skill", skillPath } },
});
expect(commands.find((c) => c.name === `${path.basename(addDir)}:review`)?._meta).toEqual({
jetbrains: { air: { version: 1, kind: "skill", skillPath: addDirSkill } },
});
} finally {
await rm(addDir, { recursive: true, force: true });
}
});
it("gives a client that is not AIR no _meta", async () => {
const commands = await advertisedCommands({});
@@ -15530,6 +15964,132 @@ describe("usage_update computation", () => {
});
});
describe("foreground fork tool results", () => {
it.each([
["plain", {}, null],
["AIR", AIR_CLIENT_CAPABILITIES, null],
["plain nested", {}, "toolu_outer"],
["AIR nested", AIR_CLIENT_CAPABILITIES, "toolu_outer"],
] as const)(
"waits for the launching side's result for a %s client",
async (_, capabilities, parent) => {
const updates: SessionNotification[] = [];
const logger = { log: vi.fn(), error: vi.fn() };
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: SessionNotification) => {
updates.push(notification);
},
} as unknown as AcpClient,
logger,
);
await initializeClient(agent, capabilities);
const input = new Pushable<any>();
const assistant = (content: unknown[], parentToolUseId: string | null) => ({
type: "assistant",
parent_tool_use_id: parentToolUseId,
uuid: randomUUID(),
session_id: "test-session",
message: {
id: randomUUID(),
role: "assistant",
model: "claude-sonnet-4-6",
content,
usage: successfulResultMessage().usage,
},
});
const user = (content: unknown[], parentToolUseId: string | null) => ({
type: "user",
parent_tool_use_id: parentToolUseId,
uuid: randomUUID(),
session_id: "test-session",
message: { role: "user", content },
});
const toolResult = (id: string, text: string) => ({
type: "tool_result",
tool_use_id: id,
content: [{ type: "text", text }],
});
async function* messages() {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
yield assistant(
[
{
type: "tool_use",
id: "toolu_fork",
name: "Agent",
input: {
subagent_type: "fork",
run_in_background: false,
prompt: "Inspect the parser",
},
},
],
parent,
);
// The fork inherits a result for its own launch. It cannot complete
// that call: the launching side sends the actual report later.
yield user([toolResult("toolu_fork", "Fork started — that fork is you.")], "toolu_fork");
yield assistant(
[
{
type: "tool_use",
id: "toolu_child",
name: "Agent",
input: { subagent_type: "general-purpose", prompt: "Read the parser" },
},
],
"toolu_fork",
);
// Keep results of tools the fork itself called, even in a message
// that also carries the copied launch result.
yield {
...user(
[
toolResult("toolu_fork", "Fork started — processing in background"),
toolResult("toolu_child", "child report"),
],
"toolu_fork",
),
// Filtering must not attribute the inherited launch sidecar to
// the one result that remains in this message.
tool_use_result: {
status: "completed",
content: [{ type: "text", text: "inherited launch report" }],
},
};
yield user([toolResult("toolu_fork", "fork report")], parent);
yield successfulResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(messages()), input });
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "Inspect it" }] }),
).resolves.toMatchObject({ stopReason: "end_turn" });
const completed = updates.flatMap(({ update }) =>
update.sessionUpdate === "tool_call_update" && update.status === "completed"
? [{ id: update.toolCallId, content: update.content, rawOutput: update.rawOutput }]
: [],
);
expect(completed.map(({ id }) => id)).toEqual(["toolu_child", "toolu_fork"]);
expect(completed.map(({ content }) => content)).toEqual([
[{ type: "content", content: { type: "text", text: "child report" } }],
[{ type: "content", content: { type: "text", text: "fork report" } }],
]);
if (!("_meta" in capabilities)) {
expect(completed.map(({ rawOutput }) => rawOutput)).toEqual([
[{ type: "text", text: "child report" }],
[{ type: "text", text: "fork report" }],
]);
}
expect(logger.error.mock.calls.flat().join("\n")).not.toContain("wasn't tracked");
},
);
});
describe("assembled assistant text fallback", () => {
const ZERO_USAGE = {
input_tokens: 0,
@@ -24189,6 +24749,93 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
]);
});
describe("started by a scheduler fire", () => {
const lifecycleStarted = (fire: { wakeup_source?: string; scheduled_for?: number }) =>
({
type: "command_lifecycle",
command_uuid: "scheduler-fire",
state: "started",
...fire,
uuid: randomUUID(),
session_id: "test-session",
}) as any;
const autonomousTurn = async (fireFrames: (afterRunning: boolean) => any[]) => {
const { agent, asyncTaskUpdates } = await airAgent();
let wake = () => {};
const woken = new Promise<void>((resolve) => (wake = 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* fireFrames(false);
yield running();
yield* fireFrames(true);
yield assistantText("woke up");
yield resultMessage();
yield idle();
}
return messageGenerator();
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
wake();
await agent.sessions["test-session"]?.consumer;
return asyncTaskUpdates()
.filter((update) => update.sessionUpdate !== "async_task_state_update")
.map(({ sessionUpdate, description }) => ({ sessionUpdate, description }));
};
it("names a ScheduleWakeup fire that arrives before the running state", async () => {
const updates = await autonomousTurn((afterRunning) =>
afterRunning
? []
: [
lifecycleStarted({
wakeup_source: "loop_wakeup",
scheduled_for: 1_790_000_000_000,
}),
],
);
expect(updates).toEqual([
{
sessionUpdate: "async_task_spawned",
description: "A scheduled wakeup fired (set for 2026-09-21T14:13:20.000Z).",
},
]);
});
it("renames the open task for a scheduled task fire after the running state", async () => {
const updates = await autonomousTurn((afterRunning) =>
afterRunning ? [lifecycleStarted({ wakeup_source: "schedule_wakeup" })] : [],
);
expect(updates).toEqual([
{
sessionUpdate: "async_task_spawned",
description: "Claude Code started a turn without a prompt.",
},
{ sessionUpdate: "async_task_progress", description: "A scheduled task fired." },
]);
});
it("leaves the task as before for a started frame without a known wakeup source", async () => {
const updates = await autonomousTurn((afterRunning) =>
afterRunning
? [lifecycleStarted({}), lifecycleStarted({ wakeup_source: "future_source" })]
: [],
);
expect(updates).toEqual([
{
sessionUpdate: "async_task_spawned",
description: "Claude Code started a turn without a prompt.",
},
]);
});
});
it("ends the turn task when a prompt is folded into the turn", async () => {
const { agent, updates, asyncTaskUpdates } = await airAgent();
let wake = () => {};

View File

@@ -8,8 +8,10 @@
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"app.ts","terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_r_bash"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_edit"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_r_task"}},"content":{"text":"Subagent text.","type":"text"},"messageId":"msg_r_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"<session>:replay-subagent:toolu_r_task"}}}
{"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}

View File

@@ -2,6 +2,7 @@
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Background review","prompt":"Review the diff","sessionUpdate":"subagent_spawned","subagentSessionId":"a2","task":"Review the diff"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"a2"}}}

View File

@@ -2,6 +2,7 @@
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Check the logs","prompt":"Check logs","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_late","task":"Check logs"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_late"}}}

View File

@@ -2,6 +2,7 @@
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Fix the build","prompt":"Make the build pass","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_n","task":"Make the build pass"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}

View File

@@ -2,7 +2,9 @@
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Plan the work","prompt":"Plan it","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_outer","task":"Plan it"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"capabilities":{},"name":"Read the spec","prompt":"Read spec.md","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_inner","task":"Read spec.md"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}

View File

@@ -2,6 +2,7 @@
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Explore code","prompt":"Find the parser","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_1","task":"Find the parser"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}

View File

@@ -2,6 +2,7 @@
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Summarize","prompt":"Summarize the repo","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_t","task":"Summarize the repo"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}

View File

@@ -9,6 +9,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientMessageId":"toolu_agent","recipientSessionId":"a2","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientSessionId":"a2","senderMessageId":"toolu_agent","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"a2","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}}

View File

@@ -9,6 +9,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientMessageId":"toolu_late","recipientSessionId":"agent_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientSessionId":"agent_late","senderMessageId":"toolu_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"The logs are clean.","type":"text"}],"messageId":"toolu_late:reply","recipientSessionId":"<session>","senderSessionId":"agent_late","sessionUpdate":"session_message"}}}

View File

@@ -9,6 +9,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientMessageId":"toolu_native","recipientSessionId":"agent_n","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientSessionId":"agent_n","senderMessageId":"toolu_native","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}

View File

@@ -9,10 +9,12 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientMessageId":"toolu_outer","recipientSessionId":"agent_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientSessionId":"agent_outer","senderMessageId":"toolu_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"capabilities":{"cancel":{}},"description":"Read the spec","sessionId":"agent_inner","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientMessageId":"toolu_inner","recipientSessionId":"agent_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientSessionId":"agent_inner","senderMessageId":"toolu_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"content":{"text":"```\n1\tspec\n```","type":"text"},"type":"content"}],"rawOutput":"1\tspec","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}

View File

@@ -9,6 +9,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientMessageId":"toolu_task","recipientSessionId":"agent_1","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientSessionId":"agent_1","senderMessageId":"toolu_task","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}

View File

@@ -9,6 +9,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientMessageId":"toolu_transcript","recipientSessionId":"agent_t","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientSessionId":"agent_t","senderMessageId":"toolu_transcript","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}

View File

@@ -1116,15 +1116,37 @@ describe("ACP v2 prompts", () => {
cwd,
replayFrom: { type: "start" },
});
client.sessionUpdates.push({
sessionId: "response",
update: { sessionUpdate: "response" },
} as never);
await vi.waitFor(() =>
expect(
client.sessionUpdates.filter(
({ sessionId, update }) =>
sessionId === "abc123" && update.sessionUpdate === "state_update",
),
).toHaveLength(2),
);
await client.authUpdate(1);
});
const child = client.sessionUpdates.flatMap(({ sessionId, update }) =>
sessionId === "abc123" ? [update] : [],
sessionId === "abc123" || sessionId === "response" ? [update] : [],
);
// The child's past `running` does not replay; its last state goes once,
// after the replay.
// after the replay. That state is history to the client, so the child's
// current state follows the response: its process is gone, so it is idle.
// The child's model, from its transcript, goes right after its
// announcement. It does not go again after the response: it is unchanged.
const model = expect.objectContaining({
sessionUpdate: "config_option_update",
configOptions: [
expect.objectContaining({ configId: "model", currentValue: "claude-haiku-4-5" }),
],
});
expect(child).toEqual([
model,
expect.objectContaining({ sessionUpdate: "session_message", content: text("Look around") }),
// The client may still hold the child's message, so the replay clears it.
{ sessionUpdate: "agent_message", messageId: "msg_child", content: [] },
@@ -1134,7 +1156,16 @@ describe("ACP v2 prompts", () => {
content: text("Child report.")[0],
}),
{ sessionUpdate: "state_update", state: "idle", stopReason: "end_turn" },
{ sessionUpdate: "response" },
{ sessionUpdate: "state_update", state: "idle", stopReason: "end_turn" },
]);
// The current state on the parent grants nothing: the child cannot run again here.
expect(
client.sessionUpdates.filter(
({ update }) =>
update.sessionUpdate === "subagent_update" && (update as any).capabilities != null,
),
).toEqual([]);
});
it("ends a resume with replay with one end event for a client with loadReplayEnd", async () => {

View File

@@ -7,7 +7,9 @@ import {
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { ALLOW_BYPASS } from "../permissions/modes.js";
import { NativeSubagentRuntime } from "../native-subagents.js";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -185,6 +187,211 @@ describe("createSession options merging", () => {
]);
});
it.each([false, true])("reports child settings through registered hooks (v2: %s)", async (v2) => {
const updates: AcpSessionNotification[] = [];
const client = createMockClient();
client.sessionUpdate = async (notification) => {
updates.push(notification);
};
const observedAgent = new ClaudeAcpAgent(client, undefined, { v2 });
await observedAgent.initialize({ protocolVersion: 1, clientCapabilities: { subagents: {} } });
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
const session = observedAgent.sessions[sessionId]!;
const parentOptions = structuredClone(session.configOptions);
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
"rfd",
sessionId,
session,
(notification) => client.sessionUpdate(notification),
{ log: () => {} },
));
const invoke = async (
event: "PreToolUse" | "PostToolUse" | "PostModelSwitch" | "SubagentStart" | "SubagentStop",
fields: Record<string, unknown>,
) => {
const matcher = capturedOptions!.hooks![event]!.find((matcher) => !matcher.matcher);
expect(matcher).toBeDefined();
for (const hook of matcher!.hooks) {
await expect(
hook(
{
hook_event_name: event,
session_id: sessionId,
cwd: process.cwd(),
transcript_path: "/tmp/transcript",
...fields,
} as any,
undefined,
{ signal: new AbortController().signal },
),
).resolves.toMatchObject({ continue: true });
}
};
// A main-thread --agent persona has agent_type too, but no agent_id.
await invoke("PreToolUse", {
agent_type: "reviewer",
permission_mode: "plan",
effort: { level: "max" },
});
expect(updates).toEqual([]);
await invoke("SubagentStart", {
agent_id: "child",
agent_type: "Explore",
permission_mode: "acceptEdits",
});
expect(updates).toEqual([]);
await runtime.taskStarted({ taskId: "child", subagentType: "Explore" }, async () => {});
const configs = () =>
updates.flatMap(({ update }) =>
update.sessionUpdate === "config_option_update" ? [update.configOptions] : [],
);
expect(
configs()
.at(-1)
?.map((option) => [option.id, option.currentValue]),
).toEqual([
["mode", "acceptEdits"],
["agent", "Explore"],
]);
await invoke("PreToolUse", {
agent_id: "child",
agent_type: "Explore",
effort: { level: "high" },
});
expect(
configs()
.at(-1)
?.find((option) => option.id === "effort"),
).toMatchObject({
category: "thought_level",
currentValue: "high",
options: [{ value: "high", name: "High" }],
});
await invoke("PostToolUse", {
agent_id: "child",
tool_name: "EnterPlanMode",
permission_mode: "plan",
});
await invoke("PostModelSwitch", {
agent_id: "child",
to_model: "child-model",
source: "auto",
effort: { level: "low" },
});
await invoke("SubagentStop", { agent_id: "child", agent_type: "Explore" });
expect(
configs()
.at(-1)
?.map((option) => [option.id, option.currentValue]),
).toEqual([
["mode", "plan"],
["model", "child-model"],
["effort", "low"],
["agent", "Explore"],
]);
expect(
configs()
.at(-1)
?.every((option) => option.type === "select" && option.options.length === 1),
).toBe(true);
// A model of the root session's list goes as that list's entry, with its name.
const rootModel = session.configOptions.find((option) => option.id === "model");
const listed =
rootModel?.type === "select"
? (rootModel.options as Array<{ value: string; name: string }>).find(
(entry) => entry.value !== "default",
)
: undefined;
expect(listed).toBeDefined();
await invoke("PostModelSwitch", { agent_id: "child", to_model: listed!.value, source: "auto" });
expect(
configs()
.at(-1)
?.find((option) => option.id === "model"),
).toMatchObject({
currentValue: listed!.value,
options: [{ value: listed!.value, name: listed!.name }],
});
await new Promise((resolve) => setImmediate(resolve));
expect(session.configOptions).toEqual(parentOptions);
expect(
updates
.filter(({ update }) => update.sessionUpdate === "config_option_update")
.every(({ sessionId: target }) => target === "child"),
).toBe(true);
});
it("reports an AIR child's model switch under its current generation", async () => {
const updates: AcpSessionNotification[] = [];
const client = createMockClient();
client.sessionUpdate = async (notification) => {
updates.push(notification);
};
const observedAgent = new ClaudeAcpAgent(client);
await observedAgent.initialize({
protocolVersion: 1,
clientCapabilities: {
_meta: { jetbrains: { air: { version: 1, capabilities: ["nativeSubagentSessions"] } } },
} as ClientCapabilities,
});
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
const session = observedAgent.sessions[sessionId]!;
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
"air",
sessionId,
session,
(notification) => client.sessionUpdate(notification),
{ log: () => {} },
));
const matcher = capturedOptions!.hooks!.PostModelSwitch!.find((matcher) => !matcher.matcher);
const switchModel = async (fields: Record<string, unknown>) => {
for (const hook of matcher!.hooks) {
await hook(
{
hook_event_name: "PostModelSwitch",
session_id: sessionId,
cwd: process.cwd(),
transcript_path: "/tmp/transcript",
source: "auto",
...fields,
} as any,
undefined,
{ signal: new AbortController().signal },
);
}
};
const deliver = async () => {};
await runtime.taskStarted({ taskId: "child", subagentType: "Explore" }, deliver);
await runtime.finishTask("child", "completed", deliver);
await runtime.taskResumed("child", deliver, "Continue");
// The hook names the SDK agent id; AIR's draft runs the resumed agent in a new session.
await switchModel({ agent_id: "child", to_model: "claude-sonnet-4-6" });
const rootModel = session.configOptions.find((option) => option.id === "model");
const rootEntryName =
rootModel?.type === "select"
? (rootModel.options as Array<{ value: string; name: string }>).find(
(entry) => entry.value === "claude-sonnet-4-6",
)?.name
: undefined;
expect(rootEntryName).toBeDefined();
const models = updates.flatMap(({ sessionId: target, update }) =>
update.sessionUpdate === "config_option_update"
? update.configOptions.map((option) => [target, option.currentValue, option])
: [],
);
expect(models).toEqual([
[
"child:generation:2",
"claude-sonnet-4-6",
expect.objectContaining({
id: "model",
// The name of the root session's entry for the model.
options: [{ value: "claude-sonnet-4-6", name: rootEntryName }],
}),
],
]);
});
it("merges user-provided disallowedTools with ACP internal list", async () => {
await agent.newSession({
cwd: process.cwd(),
@@ -1951,6 +2158,66 @@ describe("createSession options merging", () => {
await loading;
});
it.each([
["without subagents", [] as Record<string, unknown>[]],
[
"with a subagent",
[
{
type: "assistant",
uuid: "child-message",
session_id: "replay-probe",
parent_tool_use_id: "agent-call",
message: {
id: "api-child-message",
model: "claude-sonnet-4-6",
role: "assistant",
content: [{ type: "text", text: "child report" }],
},
},
],
],
])(
"sends the end event before the CLI start finishes for native subagent sessions (%s)",
async (_label, history) => {
let releaseInit!: () => void;
initGate = new Promise((resolve) => (releaseInit = resolve));
sessionMessages = [userMessage("u1", "first"), ...history];
const updates: SessionNotification[] = [];
const loading = (
await endAgent(updates, undefined, {
_meta: {
jetbrains: {
air: {
version: 1,
capabilities: ["loadReplayEnd", "nativeSubagentSessions"],
},
},
},
} as ClientCapabilities)
).loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] });
// The child's model goes as the replay reaches it, with no model list yet.
await vi.waitFor(() =>
expect(kinds(updates)).toEqual(
history.length === 0
? ["user_message_chunk", "end"]
: [
"user_message_chunk",
"subagent_spawned",
"config_option_update",
"agent_message_chunk",
"subagent_state_update",
"end",
],
),
);
releaseInit();
await loading;
},
);
it("sends the end event for an empty history", async () => {
sessionMessages = [];
const updates: SessionNotification[] = [];

View File

@@ -2,11 +2,13 @@ import { describe, it, expect } from "vitest";
import { randomUUID } from "crypto";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
import {
resolveModelPreference,
applyAvailableModelsAllowlist,
isDeniedModel,
matchResumedModel,
modelEntryFor,
} from "../session-model.js";
import { effortFlagSettings, settingsEffortForModel } from "../session-effort.js";
@@ -392,3 +394,120 @@ describe("effortFlagSettings", () => {
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
});
});
describe("modelEntryFor", () => {
const infos: ModelInfo[] = [
{
value: "default",
resolvedModel: "claude-opus-4-6[1m]",
displayName: "Default (recommended)",
description: "",
},
{ value: "opus", resolvedModel: "claude-opus-4-6", displayName: "Opus 4.6", description: "" },
{
value: "opus[1m]",
resolvedModel: "claude-opus-4-6[1m]",
displayName: "Opus 4.6 (1M context)",
description: "",
},
{
value: "haiku",
resolvedModel: "claude-haiku-4-5",
displayName: "Haiku 4.5",
description: "",
},
];
/** The session's `model` option with `current` selected. */
const options = (current: string): SessionConfigOption[] => [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: current,
options: infos.map((info) => ({ value: info.value, name: info.displayName })),
},
];
const entryOf = (current: string, model: string) => modelEntryFor(options(current), infos, model);
it("prefers the entry that the user picked when it runs the model up to a context hint", () => {
// A response reports the model id without the context hint.
expect(entryOf("opus[1m]", "claude-opus-4-6")).toEqual({
value: "opus[1m]",
name: "Opus 4.6 (1M context)",
});
expect(entryOf("default", "claude-opus-4-6")).toEqual({
value: "default",
name: "Default (recommended)",
});
});
it("takes the listed entry of another model", () => {
expect(entryOf("opus[1m]", "claude-haiku-4-5")).toEqual({ value: "haiku", name: "Haiku 4.5" });
expect(entryOf("haiku", "claude-opus-4-6")).toEqual({ value: "opus", name: "Opus 4.6" });
// A concrete entry comes before `default` when both run the model.
expect(entryOf("haiku", "claude-opus-4-6[1m]")).toEqual({
value: "opus[1m]",
name: "Opus 4.6 (1M context)",
});
});
it("maps a model again when the session replaces its option list", () => {
const before = options("haiku");
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
value: "opus",
name: "Opus 4.6",
});
// The user picks `opus[1m]`: the session replaces its option list.
expect(modelEntryFor(options("opus[1m]"), infos, "claude-opus-4-6")).toEqual({
value: "opus[1m]",
name: "Opus 4.6 (1M context)",
});
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
value: "opus",
name: "Opus 4.6",
});
});
it("compares a dated or a provider model id as it is", () => {
// `resolvedModel` is the wire id that a response reports, date and provider prefix included.
const dated: ModelInfo[] = [
{
value: "haiku",
resolvedModel: "claude-haiku-4-5-20251001",
displayName: "Haiku 4.5",
description: "",
},
{
value: "sonnet",
resolvedModel: "eu.anthropic.claude-sonnet-5",
displayName: "Sonnet 5",
description: "",
},
];
const datedOptions: SessionConfigOption[] = [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "sonnet",
options: dated.map((info) => ({ value: info.value, name: info.displayName })),
},
];
expect(modelEntryFor(datedOptions, dated, "claude-haiku-4-5-20251001")).toEqual({
value: "haiku",
name: "Haiku 4.5",
});
expect(modelEntryFor(datedOptions, dated, "eu.anthropic.claude-sonnet-5")).toEqual({
value: "sonnet",
name: "Sonnet 5",
});
expect(modelEntryFor(datedOptions, dated, "claude-haiku-4-5")).toBeUndefined();
});
it("matches no entry for a model that the list does not have", () => {
expect(entryOf("opus", "claude-sonnet-4-6")).toBeUndefined();
expect(entryOf("opus", "claude-opus-4-7")).toBeUndefined();
});
});

View File

@@ -10,6 +10,7 @@ import {
resumedNativeSubagentId,
sendMessageResume,
sendMessageResumePrompt,
type SubagentModels,
} from "../native-subagents.js";
function deferred(): { promise: Promise<void>; resolve: () => void } {
@@ -814,6 +815,19 @@ describe("NativeSubagentRuntime in the RFD form", () => {
},
},
}) as AcpSessionNotification;
const sendMessageResult = (
toolCallId: string,
status: "completed" | "failed",
): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status,
_meta: { claudeCode: { toolName: "SendMessage" } },
},
}) as AcpSessionNotification;
/** A runtime whose child `worker-1` was launched by `launch-1` and runs. */
async function runningWorker(input: Record<string, unknown> = {}) {
@@ -842,6 +856,218 @@ describe("NativeSubagentRuntime in the RFD form", () => {
update.sessionUpdate === "subagent_update" && update.state ? [update.state] : [],
);
it("reports only the observed child model and refreshes it on a model change", async () => {
const { runtime, published } = await runningWorker({ model: "inherit" });
const deliver = async (notification: AcpSessionNotification) => {
const routed = await runtime.route(notification, deliver);
if (routed) published.push(routed);
};
await runtime.reportModel(null, "parent-model", deliver);
await runtime.reportModel("launch-1", "<synthetic>", deliver);
expect(published.some(({ update }) => update.sessionUpdate === "config_option_update")).toBe(
false,
);
await runtime.reportModel("launch-1", "child-model", deliver);
await runtime.reportModel("launch-1", "child-model", deliver);
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "resume-1", prompt: "Continue" },
deliver,
);
await runtime.reportModel("resume-1", "fallback-model", deliver);
const configs = published.filter(
({ update }) => update.sessionUpdate === "config_option_update",
);
expect(configs).toHaveLength(2);
for (const [index, model] of ["child-model", "fallback-model"].entries()) {
expect(configs[index]).toMatchObject({
sessionId: "worker-1",
update: {
sessionUpdate: "config_option_update",
configOptions: [
{
id: "model",
category: "model",
type: "select",
currentValue: model,
options: [{ value: model, name: model }],
},
],
},
});
}
});
it("buffers model observations until the child is announced", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
"rfd",
"root",
{},
async (notification) => {
published.push(notification);
},
{ log: () => {} },
);
const deliver = async (notification: AcpSessionNotification) => {
const routed = await runtime.route(notification, deliver);
if (routed) published.push(routed);
};
await runtime.reportModel("launch-1", "child-model", deliver);
expect(published).toEqual([]);
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
expect(published).toEqual([]);
await deliver(launch("launch-1", {}));
expect(published.map(({ update }) => update.sessionUpdate)).toEqual([
"subagent_update",
"config_option_update",
]);
expect(published[1]?.sessionId).toBe("worker-1");
});
const effortOption = (level: string) => ({
id: "effort",
name: "Effort",
category: "thought_level",
type: "select" as const,
currentValue: level,
options: [{ value: level, name: level }],
});
it("merges hook observations with model snapshots without inheriting other sessions", async () => {
const { runtime, published } = await runningWorker();
const deliver = async (notification: AcpSessionNotification) => {
const routed = await runtime.route(notification, deliver);
if (routed) published.push(routed);
};
await runtime.reportConfiguration("worker-1", [
effortOption("high"),
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: "plan",
options: [{ value: "plan", name: "Plan" }],
},
]);
await runtime.reportModel("launch-1", "child-model", deliver);
await runtime.reportConfiguration("worker-1", [effortOption("low")]);
await runtime.reportModel("launch-1", "fallback-model", deliver);
const configs = published.flatMap(({ update }) =>
update.sessionUpdate === "config_option_update" ? [update.configOptions] : [],
);
expect(configs).toHaveLength(4);
expect(configs.at(-1)?.map((option) => [option.id, option.currentValue])).toEqual([
["mode", "plan"],
["model", "fallback-model"],
["effort", "low"],
]);
expect(
configs.at(-1)?.every((option) => option.type === "select" && option.options.length === 1),
).toBe(true);
await runtime.reportConfiguration("root", [effortOption("max")]);
expect(
published.filter(({ update }) => update.sessionUpdate === "config_option_update"),
).toHaveLength(4);
});
it("keeps hook observations pending until truthful parentage is announced", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
"rfd",
"root",
{},
async (notification) => {
published.push(notification);
},
{ log: () => {} },
);
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
expect(published).toEqual([]);
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
async () => {},
);
expect(published).toEqual([]);
await runtime.route(launch("launch-1", {}), async () => {});
expect(published.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
["root", "subagent_update"],
["worker-1", "config_option_update"],
]);
});
it("serializes concurrent config publications and retries a failed snapshot", async () => {
const started = deferred();
const release = deferred();
const published: AcpSessionNotification[] = [];
const value = child({ announced: true });
let fail = false;
const runtime = new NativeSubagentRuntime(
"rfd",
"root",
sessionWith(value),
async (notification) => {
if (fail) throw new Error("transport failed");
published.push(notification);
if (published.length === 1) {
started.resolve();
await release.promise;
}
},
{ log: () => {} },
);
const first = runtime.reportConfiguration(value.sessionId, [effortOption("high")]);
await started.promise;
const second = runtime.reportConfiguration(value.sessionId, [effortOption("low")]);
release.resolve();
await Promise.all([first, second]);
expect(
published.flatMap(({ update }) =>
update.sessionUpdate === "config_option_update"
? update.configOptions.map((option) => option.currentValue)
: [],
),
).toEqual(["high", "low"]);
fail = true;
await expect(
runtime.reportConfiguration(value.sessionId, [effortOption("max")]),
).rejects.toThrow("transport failed");
fail = false;
await runtime.reportConfiguration(value.sessionId, [effortOption("max")]);
expect(published).toHaveLength(3);
});
it("forgets unannounced configuration when the runtime is cleared", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
"rfd",
"root",
{},
async (notification) => {
published.push(notification);
},
{ log: () => {} },
);
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
runtime.clear();
await runtime.taskStarted({ taskId: "worker-1", subagentType: "Explore" }, async () => {});
expect(published.map(({ update }) => update.sessionUpdate)).toEqual(["subagent_update"]);
});
it("does not report child config without native subagent sessions", async () => {
const deliver = vi.fn(async () => {});
const runtime = new NativeSubagentRuntime(undefined, "root", {}, deliver, { log: () => {} });
await runtime.reportModel("launch-1", "child-model", deliver);
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
expect(deliver).not.toHaveBeenCalled();
});
it("hides the Agent tool call, which the parent's session messages replace", async () => {
const { runtime, control } = await runningWorker();
@@ -1085,10 +1311,15 @@ describe("NativeSubagentRuntime in the RFD form", () => {
await expect(runtime.route(output("launch-1"), async () => {})).resolves.toMatchObject({
sessionId: "worker-1",
});
await runtime.route(sendMessage("send-1"), async () => {});
// The SendMessage call is not shown: its message replaces it.
await expect(runtime.route(sendMessage("send-1"), async () => {})).resolves.toBeNull();
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
// A repeated resume signal of the running child changes nothing.
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
// The SDK reports the delivery before the call's result, which stays hidden.
await expect(
runtime.route(sendMessageResult("send-1", "completed"), async () => {}),
).resolves.toBeNull();
await runtime.finishTask("worker-1", "completed", async () => {});
expect(states(published)).toEqual([
@@ -1097,21 +1328,19 @@ describe("NativeSubagentRuntime in the RFD form", () => {
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
]);
// The SendMessage call stays a tool call of its sender, so its message has
// only the child's view, with no reference.
// As a launch's prompt, the message goes to both, each view naming the other.
const message = {
sessionUpdate: "session_message",
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "worker-1",
content: [{ type: "text", text: "Check the tests too" }],
};
expect(published.filter(({ update }) => update.sessionUpdate === "session_message")).toEqual([
expect.objectContaining({ sessionId: "root" }),
expect.objectContaining({ sessionId: "worker-1" }),
{
sessionId: "worker-1",
update: {
sessionUpdate: "session_message",
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "worker-1",
content: [{ type: "text", text: "Check the tests too" }],
},
},
{ sessionId: "root", update: { ...message, recipientMessageId: "send-1" } },
{ sessionId: "worker-1", update: { ...message, senderMessageId: "send-1" } },
]);
expect(
published.some(({ update }) => "sessionId" in update && update.sessionId !== "worker-1"),
@@ -1156,7 +1385,22 @@ describe("NativeSubagentRuntime in the RFD form", () => {
.map(
({ sessionId, update }) => `${sessionId} ${(update as { messageId: string }).messageId}`,
),
).toEqual(["root launch-1", "worker-1 launch-1", "worker-1 send-1"]);
).toEqual(["root launch-1", "worker-1 launch-1", "root send-1", "worker-1 send-1"]);
});
it("shows a SendMessage call whose message reached no announced child", async () => {
const { runtime, published } = await runningWorker();
published.length = 0;
// A message to the parent, or to an agent this client was never shown,
// resumes no announced child.
await expect(runtime.route(sendMessage("send-2"), async () => {})).resolves.toBeNull();
await expect(
runtime.route(sendMessageResult("send-2", "completed"), async () => {}),
).resolves.toMatchObject({
sessionId: "root",
update: { sessionUpdate: "tool_call", toolCallId: "send-2", status: "completed" },
});
expect(published).toEqual([]);
});
it("ignores a SendMessage result that arrives after the run it started ended", async () => {
@@ -1331,6 +1575,17 @@ describe("NativeSubagentRuntime in the RFD form", () => {
state: { state: "running" },
},
},
{
sessionId: "root",
update: {
sessionUpdate: "session_message",
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "echoer-1",
recipientMessageId: "send-1",
content: [{ type: "text", text: "Say PING" }],
},
},
{
sessionId: "echoer-1",
update: {
@@ -1338,6 +1593,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "echoer-1",
senderMessageId: "send-1",
content: [{ type: "text", text: "Say PING" }],
},
},
@@ -1415,7 +1671,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
expect(
published.map(({ sessionId, update }) => `${sessionId} ${update.sessionUpdate}`),
).toEqual(["root subagent_update", "echoer-1 session_message"]);
).toEqual(["root subagent_update", "root session_message", "echoer-1 session_message"]);
});
it("keeps an agent that the history cannot place, or not read, unexposed", async () => {
@@ -2212,3 +2468,279 @@ describe("NativeSubagentRuntime in the AIR form, for an agent from before a load
expect(spawned(published)).toEqual(["root -> worker-1"]);
});
});
describe("NativeSubagentRuntime child models", () => {
const entries = {
default: { value: "default", name: "Default (recommended)" },
sonnet: { value: "sonnet", name: "Sonnet 4.6" },
};
/** The root session's `model` option, as `modelEntryFor` maps a model id to it. */
const models: SubagentModels = {
entry: (model) => (model === "claude-sonnet-4-6" ? entries.sonnet : undefined),
name: (model) => (model === "claude-opus-5" ? "Opus 5" : undefined),
};
const agentCall = (toolCallId: string, input: Record<string, unknown>): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "tool_call",
toolCallId,
title: "Agent",
status: "pending",
rawInput: { description: "Fix the build", prompt: "Make it pass", ...input },
_meta: {
claudeCode: { toolName: "Agent" },
jetbrains: { air: { version: 1, subagent: true } },
},
},
}) as AcpSessionNotification;
/** A runtime of `form` whose child `worker-1` the call `launch-1` with `input` launched. */
async function spawn(form: "rfd" | "air", input: Record<string, unknown> = {}) {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
form,
"root",
{},
async (notification) => {
published.push(notification);
},
{ log: () => {} },
undefined,
undefined,
models,
);
const deliver = async (notification: AcpSessionNotification) => {
const routed = await runtime.route(notification, deliver);
if (routed) published.push(routed);
};
await deliver(agentCall("launch-1", input));
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
return { runtime, published, deliver };
}
/** Each child `model` option sent: its session, value, and name, the only choice. */
const modelUpdates = (published: AcpSessionNotification[]) =>
published.flatMap(({ sessionId, update }) =>
update.sessionUpdate === "config_option_update"
? update.configOptions.map((option) => {
const choices = option.type === "select" ? option.options : [];
expect(option).toMatchObject({ id: "model", category: "model", type: "select" });
expect(choices).toEqual([{ value: option.currentValue, name: expect.any(String) }]);
return [sessionId, option.currentValue, (choices[0] as { name: string }).name];
})
: [],
);
it.each(["rfd", "air"] as const)(
"%s: sends no model before the child's first response, whatever the launch asks for",
async (form) => {
const { runtime, published, deliver } = await spawn(form, { model: "sonnet" });
expect(modelUpdates(published)).toEqual([]);
await runtime.reportModel("launch-1", "<synthetic>", deliver);
expect(modelUpdates(published)).toEqual([]);
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
},
);
it.each(["rfd", "air"] as const)(
"%s: sends the model again only when the entry changes",
async (form) => {
const { runtime, published, deliver } = await spawn(form);
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
// The root session's own responses do not change the child.
await runtime.reportModel(null, "claude-opus-5", deliver);
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
// A model id that matches no entry goes as the id, with its display name.
await runtime.reportModel("launch-1", "claude-opus-5", deliver);
await runtime.reportModel("launch-1", "claude-opus-5", deliver);
expect(modelUpdates(published)).toEqual([
["worker-1", "sonnet", "Sonnet 4.6"],
["worker-1", "claude-opus-5", "Opus 5"],
]);
},
);
it("air: drops a model update that waits for an earlier one when the child ends", async () => {
const published: AcpSessionNotification[] = [];
const firstSent = deferred();
const runtime = new NativeSubagentRuntime(
"air",
"root",
{},
async (notification) => {
published.push(notification);
// The client takes the first model update slowly.
if (notification.update.sessionUpdate === "config_option_update") await firstSent.promise;
},
{ log: () => {} },
undefined,
undefined,
models,
);
const deliver = async (notification: AcpSessionNotification) => {
const routed = await runtime.route(notification, deliver);
if (routed) published.push(routed);
};
await deliver(agentCall("launch-1", {}));
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
const first = runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
await vi.waitFor(() => expect(modelUpdates(published)).toHaveLength(1));
// The child ends while the client has not taken the first update yet, and
// a model update comes in meanwhile.
const finished = runtime.finishTask("worker-1", "completed", deliver, "launch-1");
const second = runtime.reportModel("launch-1", "claude-opus-5", deliver);
firstSent.resolve();
await Promise.all([first, second, finished]);
// Nothing follows the end.
expect(published.at(-1)?.update.sessionUpdate).toBe("subagent_state_update");
});
it("air: reports a hook's configuration under the agent's current generation", async () => {
const { runtime, published, deliver } = await spawn("air");
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
// The SDK resumes the agent: AIR's draft opens a new generation session.
await runtime.taskResumed("worker-1", deliver, "Continue");
const generation = published
.flatMap(({ update }) =>
update.sessionUpdate === "subagent_spawned" ? [update.subagentSessionId] : [],
)
.at(-1);
expect(generation).toBe("worker-1:generation:2");
// A hook names the SDK agent id, not the generation session.
await runtime.reportConfiguration("worker-1", [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "sonnet",
options: [{ value: "sonnet", name: "Sonnet 4.6" }],
},
]);
expect(modelUpdates(published)).toEqual([[generation, "sonnet", "Sonnet 4.6"]]);
});
it.each(["rfd", "air"] as const)(
"%s: keeps only the latest model of a child that is not announced yet",
async (form) => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
form,
"root",
{},
async (notification) => {
published.push(notification);
},
{ log: () => {} },
undefined,
undefined,
models,
);
const deliver = async (notification: AcpSessionNotification) => {
const routed = await runtime.route(notification, deliver);
if (routed) published.push(routed);
};
// More responses than the buffer of one parent holds (32), before the launch call.
for (let index = 0; index < 40; index++) {
await runtime.reportModel(
"launch-1",
index % 2 ? "claude-opus-5" : "claude-sonnet-4-6",
deliver,
);
}
await runtime.route(
{
sessionId: "root",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "working" },
_meta: { claudeCode: { parentToolUseId: "launch-1" } },
},
} as AcpSessionNotification,
deliver,
);
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
await deliver(agentCall("launch-1", {}));
expect(modelUpdates(published)).toEqual([["worker-1", "claude-opus-5", "Opus 5"]]);
// The child's output is not crowded out of the buffer.
expect(published.map(({ update }) => update.sessionUpdate)).toContain("agent_message_chunk");
},
);
it.each(["rfd", "air"] as const)(
"%s: sends a model again after its publication failed",
async (form) => {
const published: AcpSessionNotification[] = [];
let failures = 1;
const runtime = new NativeSubagentRuntime(
form,
"root",
{},
async (notification) => {
if (notification.update.sessionUpdate === "config_option_update" && failures-- > 0) {
throw new Error("client gone");
}
published.push(notification);
},
{ log: () => {} },
undefined,
undefined,
models,
);
const deliver = async (notification: AcpSessionNotification) => {
const routed = await runtime.route(notification, deliver);
if (routed) published.push(routed);
};
await deliver(agentCall("launch-1", {}));
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
await expect(runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver)).rejects.toThrow(
"client gone",
);
expect(modelUpdates(published)).toEqual([]);
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
},
);
it("air: forgets the configuration of an ended child", async () => {
const { runtime, published, deliver } = await spawn("air");
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
expect(runtime.configurationOf("worker-1")).toHaveLength(1);
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
expect(runtime.configurationOf("worker-1")).toBeUndefined();
// A hook after the end stores nothing either.
await runtime.reportConfiguration("worker-1", [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "sonnet",
options: [{ value: "sonnet", name: "Sonnet 4.6" }],
},
]);
expect(runtime.configurationOf("worker-1")).toBeUndefined();
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
});
it("air: sends no model to a child after its end", async () => {
const { runtime, published, deliver } = await spawn("air");
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
expect(modelUpdates(published)).toEqual([]);
});
});

View File

@@ -41,7 +41,8 @@ const launch = (uuid: string, launchId: string) => ({
});
/**
* The directive message that Claude Code writes after the copy of the launching
* message in a `subagent_type: "fork"` child's transcript.
* message in a `subagent_type: "fork"` child's transcript, with the tool_result
* text of Claude Code before 2.1.307.
*/
const forkDirective = (uuid: string, launchIds: string[], prompt: string) => ({
...base,

View File

@@ -4575,7 +4575,7 @@ describe("Skill tool rendering", () => {
});
describe("_meta.jetbrains.air.skill.path", () => {
const skillMeta = (skill: string, cwd?: string, additionalDirectories?: string[]) =>
const skillMeta = (skill: string, cwd?: string) =>
(
toAcpNotifications(
[{ type: "tool_use", id: "toolu_skill_path", name: "Skill", input: { skill } }] as any,
@@ -4584,7 +4584,7 @@ describe("Skill tool rendering", () => {
{},
{} as AcpClient,
mockLogger,
{ clientCapabilities: AIR_CLIENT, ...(cwd ? { cwd } : {}), additionalDirectories },
{ clientCapabilities: AIR_CLIENT, ...(cwd ? { cwd } : {}) },
)[0]?.update as any
)?._meta?.jetbrains?.air?.skill;
@@ -4659,53 +4659,6 @@ describe("Skill tool rendering", () => {
expect(skillMeta("reviewer:audit", root).path).toBe(file);
});
it("never resolves a prefix:name skill to the unscoped skill of that name", () => {
// Claude Code 2.1.309 offers an --add-dir skill whose name is taken as `<folder>:<name>`.
writeSkill(".claude/skills/review");
writeSkill(".claude/skills/audit", "audit");
expect(skillMeta("repoB:review", root)).toEqual({ name: "repoB:review" });
expect(skillMeta("repoB:audit", root)).toEqual({ name: "repoB:audit" });
expect(skillMeta("review", root).path).toBe(
path.join(root, ".claude/skills/review/SKILL.md"),
);
});
describe("in an --add-dir root", () => {
const cwd = () => path.join(root, "w/repoA");
it("resolves <basename>:<name> to the root's skill and <name> to the cwd's", () => {
const own = writeSkill("w/repoA/.claude/skills/review");
const added = writeSkill("w/repoB/.claude/skills/review");
expect(skillMeta("repoB:review", cwd(), ["../repoB"]).path).toBe(added);
expect(skillMeta("review", cwd(), ["../repoB"]).path).toBe(own);
});
it("labels a root <parent>/<basename> when another root has its basename", () => {
writeSkill("w/one/app/.claude/skills/deploy");
const second = writeSkill("w/two/app/.claude/skills/deploy");
const roots = [path.join(root, "w/one/app"), path.join(root, "w/two/app")];
expect(skillMeta("two/app:deploy", cwd(), roots).path).toBe(second);
expect(skillMeta("app:deploy", cwd(), roots)).toEqual({ name: "app:deploy" });
});
it("resolves a skill of a .claude folder below the root by its path", () => {
const nested = writeSkill("w/repoB/tools/.claude/skills/lint");
expect(skillMeta("repoB/tools:lint", cwd(), ["../repoB"]).path).toBe(nested);
});
it("resolves a root's skill that no other skill names by its plain name", () => {
const added = writeSkill("w/repoB/.claude/skills/release");
expect(skillMeta("release", cwd(), ["../repoB"]).path).toBe(added);
});
it("never leaves the root for a scope with ..", () => {
writeSkill("w/outside/.claude/skills/review");
expect(skillMeta("repoB/../outside:review", cwd(), ["../repoB"])).toEqual({
name: "repoB/../outside:review",
});
});
});
it("omits the path when no known layout holds the skill", () => {
const meta = skillMeta("nonexistent", root);
expect(meta).toEqual({ name: "nonexistent" });

View File

@@ -236,18 +236,13 @@ export class AcpToolCallRenderer {
*/
toolCall(
toolUse: ToolUse,
options: {
cwd?: string;
additionalDirectories?: readonly string[];
inputComplete?: boolean;
previewContent?: ToolCallContent[];
} = {},
options: { cwd?: string; inputComplete?: boolean; previewContent?: ToolCallContent[] } = {},
): ToolCallUpdate {
const facts = this.facts(toolUse, options.cwd);
const isMcp = toolUse.name.startsWith("mcp__");
return {
_meta: {
...this.toolUseMeta(toolUse, options.cwd, options.additionalDirectories),
...this.toolUseMeta(toolUse, options.cwd),
...(this.hasTerminal(facts) ? { terminal_info: { terminal_id: toolUse.id } } : {}),
// Only AIR gets the MCP flag. The upstream adapter does not send it.
...(isMcp && this.capabilities.air.client ? { is_mcp_tool_call: true } : {}),
@@ -269,14 +264,10 @@ export class AcpToolCallRenderer {
}
/** The report of a tool call whose input is complete now. */
refinement(
toolUse: ToolUse,
cwd?: string,
additionalDirectories?: readonly string[],
): ToolCallUpdate {
refinement(toolUse: ToolUse, cwd?: string): ToolCallUpdate {
const facts = this.facts(toolUse, cwd);
return {
_meta: this.toolUseMeta(toolUse, cwd, additionalDirectories),
_meta: this.toolUseMeta(toolUse, cwd),
toolCallId: toolUse.id,
sessionUpdate: "tool_call_update",
rawInput: this.rawInput(facts, toolUse.input),
@@ -294,15 +285,10 @@ export class AcpToolCallRenderer {
* invalid. AIR gets no `rawInput`, which goes out once it is complete. Every
* other client gets the partial input as `rawInput`.
*/
partialRefinement(
toolUse: Pick<ToolUse, "id" | "name">,
input: unknown,
cwd?: string,
additionalDirectories?: readonly string[],
) {
partialRefinement(toolUse: Pick<ToolUse, "id" | "name">, input: unknown, cwd?: string) {
const facts = this.facts({ name: toolUse.name, input }, cwd);
return {
_meta: this.toolUseMeta({ name: toolUse.name, input }, cwd, additionalDirectories),
_meta: this.toolUseMeta({ name: toolUse.name, input }, cwd),
toolCallId: toolUse.id,
sessionUpdate: "tool_call_update",
...(this.capabilities.air.client ? {} : { rawInput: input }),
@@ -774,14 +760,9 @@ export class AcpToolCallRenderer {
* The `_meta` of a tool use report: the tool name, and for AIR the tool
* flags under `_meta.jetbrains.air`. A shell description stays out of the
* standard `title`, which clients use as the command preview. Every other
* client gets only the upstream tool name. [additionalDirectories] are the
* session's `--add-dir` roots, where a Skill tool's SKILL.md can also be.
* client gets only the upstream tool name.
*/
toolUseMeta(
toolUse: { name: string; input?: unknown },
cwd?: string,
additionalDirectories?: readonly string[],
): ToolUpdateMeta {
toolUseMeta(toolUse: { name: string; input?: unknown }, cwd?: string): ToolUpdateMeta {
const input =
toolUse.input !== null && typeof toolUse.input === "object"
? (toolUse.input as Record<string, unknown>)
@@ -798,7 +779,7 @@ export class AcpToolCallRenderer {
if (description) meta = withAirMeta(meta, AIR_COMMAND_TITLE_KEY, description);
if (subagent) meta = withAirMeta(meta, AIR_SUBAGENT_KEY, true);
if (skillName) {
const skillPath = resolveSkillPath(skillName, cwd, undefined, additionalDirectories);
const skillPath = resolveSkillPath(skillName, cwd);
meta = withAirMeta(meta, AIR_SKILL_KEY, {
name: skillName,
...(skillPath ? { path: skillPath } : {}),

View File

@@ -388,28 +388,6 @@ function escapesContainer(segment: string): boolean {
return path.isAbsolute(segment) || segment.split(/[/\\]/).includes("..");
}
/**
* The folder whose `.claude/skills` holds the `<scope>:<name>` skill of an `--add-dir` root, as Claude Code
* 2.1.309+ spells it. The scope starts with the root's label: its basename, or `<parent>/<basename>` when
* another root has the same basename. A skill in a `.claude` folder below the root adds the path to that
* folder. With `/w/repoB` added, `repoB:review` is `/w/repoB/.claude/skills/review` and `repoB/tools:lint`
* is `/w/repoB/tools/.claude/skills/lint`.
*/
function addDirSkillBase(scope: string, addDirs: readonly string[]): string | undefined {
for (const dir of addDirs) {
const basename = path.basename(dir);
const parent = path.basename(path.dirname(dir));
const sharesBasename = addDirs.some(
(other) => other !== dir && path.basename(other) === basename,
);
const label = sharesBasename && parent !== "" ? `${parent}/${basename}` : basename;
if (label === "") continue;
if (scope === label) return dir;
if (scope.startsWith(`${label}/`)) return path.join(dir, scope.slice(label.length + 1));
}
return undefined;
}
/**
* Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
*
@@ -426,16 +404,11 @@ function addDirSkillBase(scope: string, addDirs: readonly string[]): string | un
*
* [scans] holds the container listings of one resolution pass. Pass the same cache for every command of one
* list, and each container is listed once instead of once per command.
*
* [additionalDirectories] are the session's `--add-dir` roots, relative ones taken from [cwd]. Claude Code
* offers a skill of such a root by its plain name, or as `<label>:<name>` when another skill holds the name
* (see {@link addDirSkillBase}).
*/
export function resolveSkillPath(
skillName: string,
cwd?: string,
scans?: SkillScanCache,
additionalDirectories?: readonly string[],
): string | undefined {
if (!cwd) {
return undefined;
@@ -477,21 +450,10 @@ export function resolveSkillPath(
containers.push(path.join(marketplaces, marketplace, "plugins", scope, "skills"));
}
};
const addDirs = (additionalDirectories ?? []).map((dir) => path.resolve(cwd, dir));
if (scope !== undefined) {
const base = addDirSkillBase(scope, addDirs);
if (base !== undefined) addContainers(base);
}
addScopedContainers(cwd);
addScopedContainers(os.homedir());
// A `<prefix>:<name>` command is never the unscoped `<name>` skill. Since Claude Code 2.1.309 an
// `--add-dir` skill whose name is taken is offered as `<folder>:<name>`, and `<cwd>/.claude/skills/<name>`
// is then the other skill that holds the name.
if (scope === undefined) {
addContainers(cwd);
addContainers(os.homedir());
for (const dir of addDirs) addContainers(dir);
}
addContainers(cwd);
addContainers(os.homedir());
for (const container of containers) {
const candidate = path.join(container, name, "SKILL.md");
let stats: Stats;

View File

@@ -431,7 +431,7 @@ export const createPostToolUseHook =
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
if (input.hook_event_name === "PostToolUse") {
// Handle EnterPlanMode tool - notify client of mode change after successful execution
if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
if (input.tool_name === "EnterPlanMode" && !input.agent_id && options?.onEnterPlanMode) {
await options.onEnterPlanMode();
}

View File

@@ -8,10 +8,18 @@ const usageWindowSchema = z
.nullable()
.optional();
const contributionSchema = z.object({ name: z.string(), pct: percentSchema });
// Claude Code 2.1.305+ suggests one change per notable source, largest share first.
const recommendationSchema = z.object({
key: z.string(),
pct: percentSchema,
title: z.string(),
detail: z.string().optional(),
});
const behaviorPeriodSchema = z.object({
request_count: countSchema,
session_count: countSchema,
mcp_servers: z.array(contributionSchema),
recommendations: z.array(recommendationSchema).optional(),
});
const modelUsageSchema = z.object({
inputTokens: countSchema,
@@ -133,12 +141,23 @@ function appendContributions(
request_count: number;
session_count: number;
mcp_servers: { name: string; pct: number }[];
recommendations?: { pct: number; title: string; detail?: string }[];
},
): void {
lines.push(
"",
`**${label}** · ${period.request_count} requests · ${period.session_count} sessions`,
);
// Rendered in the order Claude Code sends them, as its own `/usage` does. The
// window's `fix` is left out: its messages must be queued at priority "later"
// with `client_composed` set, which an ACP prompt cannot express.
if (period.recommendations?.length) {
lines.push("");
for (const recommendation of period.recommendations) {
const detail = recommendation.detail ? ` · ${escapeMarkdown(recommendation.detail)}` : "";
lines.push(`- **${recommendation.pct}%** ${escapeMarkdown(recommendation.title)}${detail}`);
}
}
if (period.mcp_servers.length === 0) return;
lines.push("", "| MCP server | Usage |", "|:--|--:|");
for (const server of [...period.mcp_servers].sort((a, b) => b.pct - a.pct).slice(0, 3)) {

View File

@@ -95,10 +95,11 @@ export function v2AgentApp(
sessionId: params.sessionId,
update: stateSnapshot(agent.foregroundState(params.sessionId)),
});
return {
...v2ResumeSessionResponse(response),
...(await setupCommands(agent, params.sessionId, logger)),
};
const commands = await setupCommands(agent, params.sessionId, logger);
// The response ends a replay. The subagents RFD treats the replayed
// subagent states before it as history, so their current states follow it.
setTimeout(() => void agent.publishCurrentSubagentStates(params.sessionId), 0);
return { ...v2ResumeSessionResponse(response), ...commands };
})
.onRequest(v2.methods.agent.session.fork, ({ params }) =>
agent.forking(params.sessionId, async () => {