Compare commits

...

32 Commits

Author SHA1 Message Date
acp-release-bot[bot]
c90f856c11 chore(main): release 0.98.0 (#1442) 2026-10-27 19:48:23 +01:00
dependabot[bot]
059d76e14a ci(deps): Bump @modelcontextprotocol/sdk from 1.32.1 to 1.32.2 in the minor group (#1449)
Bumps the minor group with 1 update:
[@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk).

Updates `@modelcontextprotocol/sdk` from 1.32.1 to 1.32.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/modelcontextprotocol/typescript-sdk/releases">@​modelcontextprotocol/sdk's
releases</a>.</em></p>
<blockquote>
<h2>1.32.2</h2>
<h2>What's Changed</h2>
<ul>
<li>fix: add explicit | undefined to Transport optional properties (v1.x
backport of <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1766">#1766</a>)
by <a
href="https://github.com/kishormorol"><code>@​kishormorol</code></a> in
<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/2814">modelcontextprotocol/typescript-sdk#2814</a></li>
<li>[v1.x] fix: allow registering after connect() when the capability
was declared by <a
href="https://github.com/claude"><code>@​claude</code></a>[bot] in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/2958">modelcontextprotocol/typescript-sdk#2958</a></li>
<li>chore: bump version to 1.32.2 by <a
href="https://github.com/claude"><code>@​claude</code></a>[bot] in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/3089">modelcontextprotocol/typescript-sdk#3089</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/kishormorol"><code>@​kishormorol</code></a>
made their first contribution in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/2814">modelcontextprotocol/typescript-sdk#2814</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.32.1...1.32.2">https://github.com/modelcontextprotocol/typescript-sdk/compare/1.32.1...1.32.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="3b73021a0a"><code>3b73021</code></a>
chore: bump version to 1.32.2 (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/3089">#3089</a>)</li>
<li><a
href="fa56d815ac"><code>fa56d81</code></a>
[v1.x] fix: allow registering after connect() when the capability was
declare...</li>
<li><a
href="6919e9526a"><code>6919e95</code></a>
fix: add explicit | undefined to Transport optional properties (v1.x
backport...</li>
<li>See full diff in <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.32.1...1.32.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@modelcontextprotocol/sdk&package-manager=npm_and_yarn&previous-version=1.32.1&new-version=1.32.2)](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 <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-27 17:05:30 +01:00
Mark Tkachenko
4324dce4be fix: publish Monitor async tasks as monitors (#1452)
Since #1249 the adapter hides every async task started by the `Monitor`
tool, so Air cannot link the task to the Monitor row and counts the
session as having no background work.

Monitor tasks (and SDK `local_monitor` tasks) are published again, but
as monitors instead of shells: `taskType: "monitor"`, `showInTranscript:
false`, `canStop: false`. That keeps the two reasons from #1249: no
empty shell card, and no Stop that only breaks the agent's wait.
Background Bash, `mcp` and subagent tasks are unchanged.
2026-10-27 16:40:16 +01:00
Ben Brandt
c7c80f7b26 fix(v2): leave the tool call status out of permission subjects (#1447)
## Summary

A v2 `session/request_permission` sends its subject as the tool call
that `renderer.permissionToolCall` builds. For a client that isn't AIR,
that is the upstream shape, which includes `status: "pending"`.

The call is already emitted before the request
(`ensureToolCallEmitted`), and its status belongs to the call's own
updates. The v2 spec describes the subject as "a `toolCall` update
containing details about the operation". A client that applies it as an
update, as that shape invites, would see a call that already reported
progress go back to `pending`. `permissionToolCall` now leaves `status`
out for v2 clients; v1 clients keep the upstream shape.

## Changes

- `src/tool-calls/renderer.ts`: no `status` in a v2 client's permission
tool call.
- v2 golden files: each permission request's subject loses
`"status":"pending"`. Nothing else changes.

## Testing

- `npx vitest run`: 3379 passed, 30 skipped.
- `npm run check`: clean.

Found while adding participant approval of permission requests to Delta,
which applies a tool call subject as an update to its call.
2026-10-27 13:07:48 +00:00
Ben Brandt
8f43ac8089 fix: settle the tool call of a SandboxNetworkAccess permission request (#1448)
## Summary

`SandboxNetworkAccess` is a permission-only callback. The SDK asks under
a synthetic tool use id and never sends a `tool_result` for it.
`ensureToolCallEmitted` emits a `tool_call` for that id before asking,
so clients have the call the request refers to (#851). But nothing ever
ended that call, so clients showed a tool call stuck at `pending`
(related: #1061).

Once the request is answered, the adapter now sends a `tool_call_update`
with the call's final status: `completed` when the network access was
allowed, `failed` otherwise. Delivery follows the same routing as the
call's other updates: the native subagent deliverer when there is one,
otherwise the call's session.

A request withdrawn by a cancel, rather than answered, still leaves the
call unsettled, as before.

## Changes

- `src/acp-agent.ts`: `settleSandboxNetworkCall`, called after the
permission response is decoded.
- `src/tests/acp-scenarios/compare.ts`: documents the new update as an
allowed difference from origin/main for plain and Zed clients.
- `network-permission` golden files (v2 and AIR) gain the one settling
update.

## Testing

- `npx vitest run`: 3379 passed, 30 skipped.
- `npm run check`: clean.

Independent of #1447. The two touch the same v2 golden file, so
whichever lands second needs a snapshot refresh.
2026-10-27 13:55:40 +01:00
Slapoguzov Alexander
0ce42f2c31 feat: add batch session metadata lookup (#1408)
A session browser may load only the 50 most recent conversations, while
pinned sessions and saved folders refer to much older ones. After a
restart, the client still has those IDs but needs their current titles,
locations and archive state to rebuild the saved views. Finding a
handful of retained sessions by paging through the entire history
becomes increasingly expensive as that history grows.

Add `_session/batch_get` to retrieve metadata for known session IDs in
one request; the client chooses how many IDs to send per request.
Successful results use the same row format as `session/list`, including
archived sessions and sessions in worktrees, so the client can merge
them with its recent page and apply its usual display filters.

The response contains the sessions whose metadata is available, in
request order. Other IDs are simply omitted. The client can refresh the
rows it receives while keeping its saved references, so pinned and
folder views stay independent of the recent-list window.

---------

Co-authored-by: Aleksandr Slapoguzov <aleksandr.slapoguzov@jetbrains.com>
2026-10-27 00:44:18 +01:00
dependabot[bot]
782e463ff4 ci(deps): Bump the minor group with 3 updates (#1440)
Bumps the minor group with 3 updates:
[on-finished](https://github.com/jshttp/on-finished),
[rolldown](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).

Updates `on-finished` from 2.4.1 to 2.4.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jshttp/on-finished/releases">on-finished's
releases</a>.</em></p>
<blockquote>
<h2>2.4.2</h2>
<h2>Highlights</h2>
<h3>🔧 Node.js deprecation</h3>
<ul>
<li><code>isFinished(res)</code> now reads <code>writableEnded</code>
instead of the deprecated <code>finished</code> property (<a
href="https://nodejs.org/api/deprecations.html#DEP0136">DEP0136</a>),
with a fallback to <code>finished</code> on older Node.js versions. No
behavior change for consumers. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/54">#54</a>)</li>
</ul>
<h3>📦 Packaging</h3>
<ul>
<li><code>HISTORY.md</code> is no longer shipped in the npm tarball. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/109">#109</a>)</li>
<li><code>package.json</code> now declares <code>&quot;type&quot;:
&quot;commonjs&quot;</code> explicitly. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/108">#108</a>)</li>
</ul>
<h3>🧪 Testing and CI</h3>
<ul>
<li>Added edge case tests for <code>isFinished</code> with invalid
input. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/88">#88</a>)</li>
<li>Node.js 19 through 26 added to the test matrix. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/53">#53</a>,
<a
href="https://redirect.github.com/jshttp/on-finished/issues/106">#106</a>)</li>
<li><code>mocha</code> bumped to 12.0.3 and the CI pipeline refreshed.
(<a
href="https://redirect.github.com/jshttp/on-finished/issues/103">#103</a>)</li>
<li>OSSF Scorecard enabled and security best practices applied to the
workflows. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/47">#47</a>,
<a
href="https://redirect.github.com/jshttp/on-finished/issues/60">#60</a>)</li>
<li>Dependabot now groups GitHub Actions updates. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/104">#104</a>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Update node 8 version and pin nyc. Also added missing 9.x to
ci-tests… by <a
href="https://github.com/carpasse"><code>@​carpasse</code></a> in <a
href="https://redirect.github.com/jshttp/on-finished/pull/49">jshttp/on-finished#49</a></li>
<li>Add OSSF scorecard by <a
href="https://github.com/carpasse"><code>@​carpasse</code></a> in <a
href="https://redirect.github.com/jshttp/on-finished/pull/47">jshttp/on-finished#47</a></li>
<li>build: Node.js 19+ by <a
href="https://github.com/bjohansebas"><code>@​bjohansebas</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/53">jshttp/on-finished#53</a></li>
<li>ci: apply OSSF Scorecard security best practices by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/60">jshttp/on-finished#60</a></li>
<li>chore: add funding to package.json by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/76">jshttp/on-finished#76</a></li>
<li>use writableEnd instead finished by <a
href="https://github.com/bjohansebas"><code>@​bjohansebas</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/54">jshttp/on-finished#54</a></li>
<li>build(deps): bump ossf/scorecard-action from 2.0.6 to 2.4.3 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/85">jshttp/on-finished#85</a></li>
<li>build(deps): bump actions/upload-artifact from 3.1.3 to 4.6.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/64">jshttp/on-finished#64</a></li>
<li>build(deps): bump actions/checkout from 3.6.0 to 5.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/84">jshttp/on-finished#84</a></li>
<li>build(deps): bump actions/download-artifact from 4.3.0 to 5.0.0 by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/82">jshttp/on-finished#82</a></li>
<li>test: add edge case tests for isFinished with invalid input by <a
href="https://github.com/Ayoub-Mabrouk"><code>@​Ayoub-Mabrouk</code></a>
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/88">jshttp/on-finished#88</a></li>
<li>build(deps): bump actions/checkout from 5.0.0 to 6.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/93">jshttp/on-finished#93</a></li>
<li>build(deps): bump actions/upload-artifact from 4.6.2 to 5.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/92">jshttp/on-finished#92</a></li>
<li>build(deps): bump actions/download-artifact from 5.0.0 to 6.0.0 by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/91">jshttp/on-finished#91</a></li>
<li>build(deps): bump github/codeql-action from 2.23.2 to 4.31.6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/90">jshttp/on-finished#90</a></li>
<li>build(deps): bump coverallsapp/github-action from 2.3.6 to 2.3.7 by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/89">jshttp/on-finished#89</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/jshttp/on-finished/pull/104">jshttp/on-finished#104</a></li>
<li>bump Mocha to version 12.0.3 by <a
href="https://github.com/rafapaezbas"><code>@​rafapaezbas</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/103">jshttp/on-finished#103</a></li>
<li>ci: add Node 25 and 26 to workflow by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/106">jshttp/on-finished#106</a></li>
<li>build(deps): bump the github-actions group with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/105">jshttp/on-finished#105</a></li>
<li>chore: add explicit type commonjs to package.json by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/108">jshttp/on-finished#108</a></li>
<li>chore: remove HISTORY.md from being packaged on publish by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/109">jshttp/on-finished#109</a></li>
<li>chore(ci): npm-publish via stage publish by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/111">jshttp/on-finished#111</a></li>
<li>Release: 2.4.2 by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/110">jshttp/on-finished#110</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/carpasse"><code>@​carpasse</code></a>
made their first contribution in <a
href="https://redirect.github.com/jshttp/on-finished/pull/49">jshttp/on-finished#49</a></li>
<li><a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
made their first contribution in <a
href="https://redirect.github.com/jshttp/on-finished/pull/60">jshttp/on-finished#60</a></li>
<li><a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
made their first contribution in <a
href="https://redirect.github.com/jshttp/on-finished/pull/85">jshttp/on-finished#85</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/jshttp/on-finished/blob/master/HISTORY.md">on-finished's
changelog</a>.</em></p>
<blockquote>
<h1>2.4.2</h1>
<ul>
<li>Use <code>writableEnded</code> instead of deprecated
<code>finished</code>, with a fallback for older Node.js versions (<a
href="https://redirect.github.com/jshttp/on-finished/issues/54">#54</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9a73f9c1c0"><code>9a73f9c</code></a>
2.4.2 (<a
href="https://redirect.github.com/jshttp/on-finished/issues/110">#110</a>)</li>
<li><a
href="0ec28ddc1d"><code>0ec28dd</code></a>
chore(ci): npm-publish via stage publish (<a
href="https://redirect.github.com/jshttp/on-finished/issues/111">#111</a>)</li>
<li><a
href="4b8ac4669c"><code>4b8ac46</code></a>
chore: remove HISTORY.md from being packaged on publish (<a
href="https://redirect.github.com/jshttp/on-finished/issues/109">#109</a>)</li>
<li><a
href="9c5f7b805b"><code>9c5f7b8</code></a>
chore: add explicit type commonjs to package.json (<a
href="https://redirect.github.com/jshttp/on-finished/issues/108">#108</a>)</li>
<li><a
href="8c33090d5b"><code>8c33090</code></a>
build(deps): bump the github-actions group with 10 updates (<a
href="https://redirect.github.com/jshttp/on-finished/issues/105">#105</a>)</li>
<li><a
href="1ca32c56e7"><code>1ca32c5</code></a>
ci: add Node 25 and 26 to workflow (<a
href="https://redirect.github.com/jshttp/on-finished/issues/106">#106</a>)</li>
<li><a
href="4ea365ed85"><code>4ea365e</code></a>
deps: bump mocha to v12.0.3 and update CI pipeline (<a
href="https://redirect.github.com/jshttp/on-finished/issues/103">#103</a>)</li>
<li><a
href="673ff9fbb2"><code>673ff9f</code></a>
chore: group github actions updates (<a
href="https://redirect.github.com/jshttp/on-finished/issues/104">#104</a>)</li>
<li><a
href="500c84cec0"><code>500c84c</code></a>
build(deps): bump coverallsapp/github-action from 2.3.6 to 2.3.7 (<a
href="https://redirect.github.com/jshttp/on-finished/issues/89">#89</a>)</li>
<li><a
href="1dbd9cd615"><code>1dbd9cd</code></a>
build(deps): bump github/codeql-action from 2.23.2 to 4.31.6 (<a
href="https://redirect.github.com/jshttp/on-finished/issues/90">#90</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/jshttp/on-finished/compare/v2.4.1...v2.4.2">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 on-finished since your current version.</p>
</details>
<br />

Updates `rolldown` from 1.2.14 to 1.2.15
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rolldown/rolldown/releases">rolldown's
releases</a>.</em></p>
<blockquote>
<h2>v1.2.15</h2>
<h2>[1.2.15] - 2026-10-21</h2>
<h3>🚀 Features</h3>
<ul>
<li>rolldown_plugin_vite_resolve: add <code>isBundled</code> to pick the
browser-external stub format (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11260">#11260</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
<li>dev: mark entry modules in the HMR module graph (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11252">#11252</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
<li>mark module starts with <code>// &lt;id&gt;</code> comments instead
of <code>#region</code> pairs (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11235">#11235</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>plugin: stop <code>this.resolve</code> recursion between plugins
with <code>skipSelf: true</code> (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11181">#11181</a>)
by <a href="https://github.com/anzal1"><code>@​anzal1</code></a></li>
<li>rolldown_plugin_vite_resolve: handle external <code>require</code>
for <code>ssr.target: 'webworker'</code> (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11205">#11205</a>)
by <a
href="https://github.com/sapphi-red"><code>@​sapphi-red</code></a></li>
<li>rolldown_plugin_vite_build_import_analysis: only wrap dynamic
imports with static specifiers in __vitePreload (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10879">#10879</a>)
by <a href="https://github.com/smeng9"><code>@​smeng9</code></a></li>
<li>strict-execution-order: wrap eager modules that forward wrapped
definers (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11186">#11186</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>chunk-import-map: keep common chunk keys unique when enabling inline
common chunks (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11230">#11230</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>only register signal-exit handler when a trace subscriber is active
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11208">#11208</a>)
by <a
href="https://github.com/DaniilIsupov"><code>@​DaniilIsupov</code></a></li>
</ul>
<h3>🧪 Testing</h3>
<ul>
<li>fold hand-unrolled Rust test tables (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11212">#11212</a>)
by <a
href="https://github.com/Brooooooklyn"><code>@​Brooooooklyn</code></a></li>
</ul>
<h3>⚙️ Miscellaneous Tasks</h3>
<ul>
<li>deps: upgrade oxc to 0.155.0 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11228">#11228</a>)
by <a href="https://github.com/Dunqing"><code>@​Dunqing</code></a></li>
<li>rollup-tests: retriage failed tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11251">#11251</a>)
by <a
href="https://github.com/sapphi-red"><code>@​sapphi-red</code></a></li>
<li>deps: update rollup submodule for tests to v4.64.12 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11249">#11249</a>)
by <a
href="https://github.com/rolldown-guard"><code>@​rolldown-guard</code></a>[bot]</li>
<li>deps: update test262 submodule for tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11242">#11242</a>)
by <a
href="https://github.com/rolldown-guard"><code>@​rolldown-guard</code></a>[bot]</li>
</ul>
<h3>❤️ New Contributors</h3>
<ul>
<li><a href="https://github.com/anzal1"><code>@​anzal1</code></a> made
their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/11181">#11181</a></li>
<li><a href="https://github.com/smeng9"><code>@​smeng9</code></a> made
their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/10879">#10879</a></li>
<li><a
href="https://github.com/DaniilIsupov"><code>@​DaniilIsupov</code></a>
made their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/11208">#11208</a></li>
</ul>
<p>Co-authored-by: shulaoda <a
href="mailto:165626830+shulaoda@users.noreply.github.com">165626830+shulaoda@users.noreply.github.com</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rolldown/rolldown/blob/main/CHANGELOG.md">rolldown's
changelog</a>.</em></p>
<blockquote>
<h2>[1.2.15] - 2026-10-21</h2>
<h3>🚀 Features</h3>
<ul>
<li>rolldown_plugin_vite_resolve: add <code>isBundled</code> to pick the
browser-external stub format (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11260">#11260</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
<li>dev: mark entry modules in the HMR module graph (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11252">#11252</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
<li>mark module starts with <code>// &lt;id&gt;</code> comments instead
of <code>#region</code> pairs (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11235">#11235</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>plugin: stop <code>this.resolve</code> recursion between plugins
with <code>skipSelf: true</code> (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11181">#11181</a>)
by <a href="https://github.com/anzal1"><code>@​anzal1</code></a></li>
<li>rolldown_plugin_vite_resolve: handle external <code>require</code>
for <code>ssr.target: 'webworker'</code> (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11205">#11205</a>)
by <a
href="https://github.com/sapphi-red"><code>@​sapphi-red</code></a></li>
<li>rolldown_plugin_vite_build_import_analysis: only wrap dynamic
imports with static specifiers in __vitePreload (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10879">#10879</a>)
by <a href="https://github.com/smeng9"><code>@​smeng9</code></a></li>
<li>strict-execution-order: wrap eager modules that forward wrapped
definers (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11186">#11186</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>chunk-import-map: keep common chunk keys unique when enabling inline
common chunks (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11230">#11230</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>only register signal-exit handler when a trace subscriber is active
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11208">#11208</a>)
by <a
href="https://github.com/DaniilIsupov"><code>@​DaniilIsupov</code></a></li>
</ul>
<h3>🧪 Testing</h3>
<ul>
<li>fold hand-unrolled Rust test tables (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11212">#11212</a>)
by <a
href="https://github.com/Brooooooklyn"><code>@​Brooooooklyn</code></a></li>
</ul>
<h3>⚙️ Miscellaneous Tasks</h3>
<ul>
<li>deps: upgrade oxc to 0.155.0 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11228">#11228</a>)
by <a href="https://github.com/Dunqing"><code>@​Dunqing</code></a></li>
<li>rollup-tests: retriage failed tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11251">#11251</a>)
by <a
href="https://github.com/sapphi-red"><code>@​sapphi-red</code></a></li>
<li>deps: update rollup submodule for tests to v4.64.12 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11249">#11249</a>)
by <a
href="https://github.com/rolldown-guard"><code>@​rolldown-guard</code></a>[bot]</li>
<li>deps: update test262 submodule for tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11242">#11242</a>)
by <a
href="https://github.com/rolldown-guard"><code>@​rolldown-guard</code></a>[bot]</li>
</ul>
<h3>❤️ New Contributors</h3>
<ul>
<li><a href="https://github.com/anzal1"><code>@​anzal1</code></a> made
their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/11181">#11181</a></li>
<li><a href="https://github.com/smeng9"><code>@​smeng9</code></a> made
their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/10879">#10879</a></li>
<li><a
href="https://github.com/DaniilIsupov"><code>@​DaniilIsupov</code></a>
made their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/11208">#11208</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="89d821ca07"><code>89d821c</code></a>
release: v1.2.15 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11261">#11261</a>)</li>
<li><a
href="3b0841ef80"><code>3b0841e</code></a>
feat(rolldown_plugin_vite_resolve): add <code>isBundled</code> to pick
the browser-exter...</li>
<li><a
href="a973d41247"><code>a973d41</code></a>
chore(deps): upgrade oxc to 0.155.0 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11228">#11228</a>)</li>
<li><a
href="5bfb74e2b0"><code>5bfb74e</code></a>
feat(dev): mark entry modules in the HMR module graph (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11252">#11252</a>)</li>
<li><a
href="fb0a8db6f1"><code>fb0a8db</code></a>
fix(rolldown_plugin_vite_resolve): handle external <code>require</code>
for `ssr.target:...</li>
<li><a
href="547a5a733c"><code>547a5a7</code></a>
fix(rolldown_plugin_vite_build_import_analysis): only wrap dynamic
imports wi...</li>
<li><a
href="648591f0eb"><code>648591f</code></a>
feat: mark module starts with <code>// \&lt;id&gt;</code> comments
instead of <code>#region</code> pairs (...</li>
<li><a
href="3f0374181d"><code>3f03741</code></a>
fix(chunk-import-map): keep common chunk keys unique when enabling
inline com...</li>
<li><a
href="33a987d8bf"><code>33a987d</code></a>
fix: only register signal-exit handler when a trace subscriber is active
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11">#11</a>...</li>
<li>See full diff in <a
href="https://github.com/rolldown/rolldown/commits/v1.2.15/packages/rolldown">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.3.4 to 8.3.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v8.3.5</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>build:</strong> handle missing chunk import maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23777">#23777</a>)
(<a
href="8d7623d9b5">8d7623d</a>)</li>
<li><strong>build:</strong> include preload dependencies to chunk import
map hashing (<a
href="https://redirect.github.com/vitejs/vite/issues/23793">#23793</a>)
(<a
href="4d16e7c14d">4d16e7c</a>)</li>
<li><strong>bundled-dev:</strong> apply <code>server.origin</code> to
asset URLs (<a
href="https://redirect.github.com/vitejs/vite/issues/23683">#23683</a>)
(<a
href="52ba877693">52ba877</a>)</li>
<li><strong>bundled-dev:</strong> declare utf-8 charset in fallback page
(<a
href="https://redirect.github.com/vitejs/vite/issues/23846">#23846</a>)
(<a
href="197daf6c4c">197daf6</a>)</li>
<li><strong>bundled-dev:</strong> ignore build.chunkImportMap (<a
href="https://redirect.github.com/vitejs/vite/issues/23685">#23685</a>)
(<a
href="cfe917d67d">cfe917d</a>)</li>
<li><strong>bundled-dev:</strong> include user
<code>import.meta.env.*</code> defines in the
<code>import.meta.env</code> object (<a
href="https://redirect.github.com/vitejs/vite/issues/23713">#23713</a>)
(<a
href="6019aa7997">6019aa7</a>)</li>
<li><strong>bundled-dev:</strong> map forwarded error stacks through the
bundle source maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23741">#23741</a>)
(<a
href="11fe1e46ff">11fe1e4</a>)</li>
<li><strong>bundled-dev:</strong> normalize served output files (<a
href="https://redirect.github.com/vitejs/vite/issues/23684">#23684</a>)
(<a
href="df4e02cc83">df4e02c</a>)</li>
<li><strong>bundled-dev:</strong> stop hmr propagation at modules that
are no longer imported (<a
href="https://redirect.github.com/vitejs/vite/issues/23839">#23839</a>)
(<a
href="7471e8c3b3">7471e8c</a>)</li>
<li><strong>css:</strong> don't fail on a literal <code>%</code> in
<code>url()</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23676">#23676</a>)
(<a
href="50b382d318">50b382d</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23731">#23731</a>)
(<a
href="cf80aa552c">cf80aa5</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23826">#23826</a>)
(<a
href="77d8373510">77d8373</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23733">#23733</a>)
(<a
href="43f0d052e5">43f0d05</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23827">#23827</a>)
(<a
href="c2e04187e4">c2e0418</a>)</li>
<li><strong>optimizer:</strong> fall back to yarn.lock for lockfile hash
(<a
href="https://redirect.github.com/vitejs/vite/issues/23667">#23667</a>)
(<a
href="f20609630f">f206096</a>)</li>
<li><strong>optimizer:</strong> scan <code>oxc.jsxInject</code> imports
(<a
href="https://redirect.github.com/vitejs/vite/issues/23758">#23758</a>)
(<a
href="38f3cb45b1">38f3cb4</a>)</li>
<li><strong>optimizer:</strong> skip patches dir check for yarn.lock
fallback (<a
href="https://redirect.github.com/vitejs/vite/issues/23725">#23725</a>)
(<a
href="8e890e1ce4">8e890e1</a>)</li>
<li><strong>optimizer:</strong> skip patches dir hash for nub lockfile
(<a
href="https://redirect.github.com/vitejs/vite/issues/23761">#23761</a>)
(<a
href="a7d56967ef">a7d5696</a>)</li>
<li><strong>resolve:</strong> support subpath imports with multiple
<code>#</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23631">#23631</a>)
(<a
href="0d86b3485f">0d86b34</a>)</li>
<li><strong>resolve:</strong> update resolve.exports to 2.0.4 (<a
href="https://redirect.github.com/vitejs/vite/issues/23739">#23739</a>)
(<a
href="4ea708a3ae">4ea708a</a>)</li>
<li><strong>server:</strong> handle socket errors on upgrade requests
not taken by the HMR server (<a
href="https://redirect.github.com/vitejs/vite/issues/23728">#23728</a>)
(<a
href="c2c9ef4dcd">c2c9ef4</a>)</li>
<li><strong>server:</strong> only strip base when path matches a full
segment (<a
href="https://redirect.github.com/vitejs/vite/issues/23641">#23641</a>)
(<a
href="3dfde87b90">3dfde87</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<ul>
<li><strong>ssr:</strong> extract inline source maps without a regexp
(<a
href="https://redirect.github.com/vitejs/vite/issues/23775">#23775</a>)
(<a
href="6a0278055d">6a02780</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li>migrate to Oxlint (<a
href="https://redirect.github.com/vitejs/vite/issues/23746">#23746</a>)
(<a
href="5b73935881">5b73935</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li><strong>deps:</strong> replace <code>@vercel/detect-agent</code>
with <code>std-env</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23744">#23744</a>)
(<a
href="ce4293f15c">ce4293f</a>)</li>
<li><strong>dev:</strong> consolidate moduleURL to file path utilities
(<a
href="https://redirect.github.com/vitejs/vite/issues/23828">#23828</a>)
(<a
href="df7b40ee9d">df7b40e</a>)</li>
<li><strong>dev:</strong> separate import resolution from URL conversion
(<a
href="https://redirect.github.com/vitejs/vite/issues/23791">#23791</a>)
(<a
href="611668983d">6116689</a>)</li>
<li><strong>dev:</strong> share ModuleURL and BrowserURL conversions (<a
href="https://redirect.github.com/vitejs/vite/issues/23790">#23790</a>)
(<a
href="446954c69c">446954c</a>)</li>
<li><strong>dev:</strong> use ModuleURLs throughout HTML transforms (<a
href="https://redirect.github.com/vitejs/vite/issues/23815">#23815</a>)
(<a
href="a16299092c">a162990</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.3.4...v8.3.5">8.3.5</a>
(2026-10-21)<!-- raw HTML omitted --></h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>build:</strong> handle missing chunk import maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23777">#23777</a>)
(<a
href="8d7623d9b5">8d7623d</a>)</li>
<li><strong>build:</strong> include preload dependencies to chunk import
map hashing (<a
href="https://redirect.github.com/vitejs/vite/issues/23793">#23793</a>)
(<a
href="4d16e7c14d">4d16e7c</a>)</li>
<li><strong>bundled-dev:</strong> apply <code>server.origin</code> to
asset URLs (<a
href="https://redirect.github.com/vitejs/vite/issues/23683">#23683</a>)
(<a
href="52ba877693">52ba877</a>)</li>
<li><strong>bundled-dev:</strong> declare utf-8 charset in fallback page
(<a
href="https://redirect.github.com/vitejs/vite/issues/23846">#23846</a>)
(<a
href="197daf6c4c">197daf6</a>)</li>
<li><strong>bundled-dev:</strong> ignore build.chunkImportMap (<a
href="https://redirect.github.com/vitejs/vite/issues/23685">#23685</a>)
(<a
href="cfe917d67d">cfe917d</a>)</li>
<li><strong>bundled-dev:</strong> include user
<code>import.meta.env.*</code> defines in the
<code>import.meta.env</code> object (<a
href="https://redirect.github.com/vitejs/vite/issues/23713">#23713</a>)
(<a
href="6019aa7997">6019aa7</a>)</li>
<li><strong>bundled-dev:</strong> map forwarded error stacks through the
bundle source maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23741">#23741</a>)
(<a
href="11fe1e46ff">11fe1e4</a>)</li>
<li><strong>bundled-dev:</strong> normalize served output files (<a
href="https://redirect.github.com/vitejs/vite/issues/23684">#23684</a>)
(<a
href="df4e02cc83">df4e02c</a>)</li>
<li><strong>bundled-dev:</strong> stop hmr propagation at modules that
are no longer imported (<a
href="https://redirect.github.com/vitejs/vite/issues/23839">#23839</a>)
(<a
href="7471e8c3b3">7471e8c</a>)</li>
<li><strong>css:</strong> don't fail on a literal <code>%</code> in
<code>url()</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23676">#23676</a>)
(<a
href="50b382d318">50b382d</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23731">#23731</a>)
(<a
href="cf80aa552c">cf80aa5</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23826">#23826</a>)
(<a
href="77d8373510">77d8373</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23733">#23733</a>)
(<a
href="43f0d052e5">43f0d05</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23827">#23827</a>)
(<a
href="c2e04187e4">c2e0418</a>)</li>
<li><strong>optimizer:</strong> fall back to yarn.lock for lockfile hash
(<a
href="https://redirect.github.com/vitejs/vite/issues/23667">#23667</a>)
(<a
href="f20609630f">f206096</a>)</li>
<li><strong>optimizer:</strong> scan <code>oxc.jsxInject</code> imports
(<a
href="https://redirect.github.com/vitejs/vite/issues/23758">#23758</a>)
(<a
href="38f3cb45b1">38f3cb4</a>)</li>
<li><strong>optimizer:</strong> skip patches dir check for yarn.lock
fallback (<a
href="https://redirect.github.com/vitejs/vite/issues/23725">#23725</a>)
(<a
href="8e890e1ce4">8e890e1</a>)</li>
<li><strong>optimizer:</strong> skip patches dir hash for nub lockfile
(<a
href="https://redirect.github.com/vitejs/vite/issues/23761">#23761</a>)
(<a
href="a7d56967ef">a7d5696</a>)</li>
<li><strong>resolve:</strong> support subpath imports with multiple
<code>#</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23631">#23631</a>)
(<a
href="0d86b3485f">0d86b34</a>)</li>
<li><strong>resolve:</strong> update resolve.exports to 2.0.4 (<a
href="https://redirect.github.com/vitejs/vite/issues/23739">#23739</a>)
(<a
href="4ea708a3ae">4ea708a</a>)</li>
<li><strong>server:</strong> handle socket errors on upgrade requests
not taken by the HMR server (<a
href="https://redirect.github.com/vitejs/vite/issues/23728">#23728</a>)
(<a
href="c2c9ef4dcd">c2c9ef4</a>)</li>
<li><strong>server:</strong> only strip base when path matches a full
segment (<a
href="https://redirect.github.com/vitejs/vite/issues/23641">#23641</a>)
(<a
href="3dfde87b90">3dfde87</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<ul>
<li><strong>ssr:</strong> extract inline source maps without a regexp
(<a
href="https://redirect.github.com/vitejs/vite/issues/23775">#23775</a>)
(<a
href="6a0278055d">6a02780</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li>migrate to Oxlint (<a
href="https://redirect.github.com/vitejs/vite/issues/23746">#23746</a>)
(<a
href="5b73935881">5b73935</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li><strong>deps:</strong> replace <code>@vercel/detect-agent</code>
with <code>std-env</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23744">#23744</a>)
(<a
href="ce4293f15c">ce4293f</a>)</li>
<li><strong>dev:</strong> consolidate moduleURL to file path utilities
(<a
href="https://redirect.github.com/vitejs/vite/issues/23828">#23828</a>)
(<a
href="df7b40ee9d">df7b40e</a>)</li>
<li><strong>dev:</strong> separate import resolution from URL conversion
(<a
href="https://redirect.github.com/vitejs/vite/issues/23791">#23791</a>)
(<a
href="611668983d">6116689</a>)</li>
<li><strong>dev:</strong> share ModuleURL and BrowserURL conversions (<a
href="https://redirect.github.com/vitejs/vite/issues/23790">#23790</a>)
(<a
href="446954c69c">446954c</a>)</li>
<li><strong>dev:</strong> use ModuleURLs throughout HTML transforms (<a
href="https://redirect.github.com/vitejs/vite/issues/23815">#23815</a>)
(<a
href="a16299092c">a162990</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="981f07fa45"><code>981f07f</code></a>
release: v8.3.5 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23856">#23856</a>)</li>
<li><a
href="0d86b3485f"><code>0d86b34</code></a>
fix(resolve): support subpath imports with multiple <code>#</code> (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23631">#23631</a>)</li>
<li><a
href="6a0278055d"><code>6a02780</code></a>
perf(ssr): extract inline source maps without a regexp (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23775">#23775</a>)</li>
<li><a
href="8e890e1ce4"><code>8e890e1</code></a>
fix(optimizer): skip patches dir check for yarn.lock fallback (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23725">#23725</a>)</li>
<li><a
href="197daf6c4c"><code>197daf6</code></a>
fix(bundled-dev): declare utf-8 charset in fallback page (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23846">#23846</a>)</li>
<li><a
href="4d16e7c14d"><code>4d16e7c</code></a>
fix(build): include preload dependencies to chunk import map hashing (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23793">#23793</a>)</li>
<li><a
href="df7b40ee9d"><code>df7b40e</code></a>
refactor(dev): consolidate moduleURL to file path utilities (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23828">#23828</a>)</li>
<li><a
href="a16299092c"><code>a162990</code></a>
refactor(dev): use ModuleURLs throughout HTML transforms (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23815">#23815</a>)</li>
<li><a
href="611668983d"><code>6116689</code></a>
refactor(dev): separate import resolution from URL conversion (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23791">#23791</a>)</li>
<li><a
href="446954c69c"><code>446954c</code></a>
refactor(dev): share ModuleURL and BrowserURL conversions (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23790">#23790</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v8.3.5/packages/vite">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-26 16:18:48 +01:00
Ben Brandt
c9374abfc7 test: fix integration-run failures in provider, model and session-index tests (#1439)
## Summary

Fixes the 23 failures seen with `RUN_INTEGRATION_TESTS=true`. All are
test-side; no adapter changes.

- **Provider tests (20 failures):** since #1340, `providers/set` lists
the gateway's models with `fetch` in the test process.
`vitest.config.ts` only loaded `setup-no-network.ts` outside integration
runs, so tests that point at `https://gateway.example.com` made real
requests and failed with `fetch failed`. The stub now loads in every
run. `gateway-models.ts` is the only in-process `fetch` caller, and no
integration test targets a real gateway.
- **"the real SDK exposes one plain Opus row":** the CLI catalogue now
also lists pinned older models (`claude-opus-5`, `claude-opus-4-8`, …),
and the alias row's display name is "Opus 5.5". The check now covers
only alias rows (`value` starting with `opus`) and still asserts there
is a single row with no 1M variant.
- **session-index `ENOTEMPTY` in teardown:** integration runs leave
`CLAUDE_CODE_EXECUTABLE` unset, so initialize ran the real `claude auth
status` in the background. It wrote into the temp `CLAUDE_CONFIG_DIR`
while `afterEach` removed it. The file now stubs the executable to
`/usr/bin/false`, as the scenario tests do.
- **session-index relocation test:** the wait ended on any removal from
the old path's subscription. That removal can name the other path's
session, so the test now waits for the relocated session's own id.

## Testing

- The six previously failing files pass with
`RUN_INTEGRATION_TESTS=true` (454 tests).
- `session-index.test.ts` passed 3× with integration on and 1× without
(173 tests). Those failures were intermittent, so a few clean runs don't
fully prove the fix.
- `npx vitest run` (3360 tests) and `npm run check` pass.
- The full integration suite was not rerun end to end.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:15:14 +01:00
acp-release-bot[bot]
d52c7c64d8 chore(main): release 0.97.0 (#1437)
🤖 I have created a release *beep* *boop*
---


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


### Features

* add ultracode session config option
([#1438](https://github.com/agentclientprotocol/claude-agent-acp/issues/1438))
([de96cef](de96cef382))


### Bug Fixes

* bump body-parser from 2.3.0 to 2.4.0
([#1436](https://github.com/agentclientprotocol/claude-agent-acp/issues/1436))
([c26e640](c26e640915))
* distinguish model options with duplicate labels
([#1286](https://github.com/agentclientprotocol/claude-agent-acp/issues/1286))
([434be13](434be13a33))
* keep the usage-limit error when it arrives after compaction
([#1377](https://github.com/agentclientprotocol/claude-agent-acp/issues/1377))
([2a1525e](2a1525e57c))
* pass --settings before the auth status subcommand
([#1392](https://github.com/agentclientprotocol/claude-agent-acp/issues/1392))
([6a33ded](6a33dedcbb))
* preserve foreground fork reports until launch completes
([#1431](https://github.com/agentclientprotocol/claude-agent-acp/issues/1431))
([e369479](e369479f93))
* settle advisor tool results before completing turns
([#1247](https://github.com/agentclientprotocol/claude-agent-acp/issues/1247))
([bed795d](bed795d261))
* settle live steers folded into autonomous cycles
([#1185](https://github.com/agentclientprotocol/claude-agent-acp/issues/1185))
([a4957d1](a4957d17c1))

---
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 13:46:13 +01:00
Ben Brandt
de96cef382 feat: add ultracode session config option (#1438)
Adds an **Ultracode** session config option. It turns on Claude Code's
dynamic workflows for every task, at any effort level. Clients that
support boolean config options get a native switch; the rest get an
On/Off select. This follows the Fast mode pattern.

## How the CLI handles ultracode (2.1.310, checked against the bundled
binary and a live session)

- **Which models:** ultracode works on exactly the models that support
`xhigh` effort. The CLI uses the same check for both. So Opus 4.7+,
Sonnet 5+, Haiku 5.5 and Fable qualify; Opus 4.6 and Haiku 4.5 don't.
Effort level no longer matters.
- **Workflows:** dynamic workflows must be enabled. They are off by
default on Pro plans, and org or managed policy can turn them off.
- **State reporting:** `query.getSettings()` returns
`applied.ultracodeAvailable` and `applied.ultracodeRequested`. The CLI's
schema says hosts should "offer an Ultracode control only while this is
true". This method exists at runtime but is missing from the SDK's
`.d.ts`, so the adapter reads it with a cast and falls back to settings
and model data when it is absent.
- **Unsupported requests:** `applyFlagSettings({ultracode})` is never
validated. The request is stored and stays in place across model
switches; it takes effect whenever the model supports it.
- **Effort changes:** an `effortLevel` change sent without an
`ultracode` key turns ultracode off.

## Behavior

- **Visibility:** at session start, one `getSettings()` call decides
availability and the starting value. Model switches combine the
workflows result learned at start with the new model's
`supportedEffortLevels`, so the switch path makes no extra control
request.
- **Value:** the session tracks the user's choice, like
`fastModeEnabled`. The option hides on unsupported models and comes back
with the same value.
- **Effort applies:** `effortFlagSettings` now carries the session's
toggle instead of `settings.ultracode`. This applies at the initial
apply, on model switch, and from the effort picker, so effort changes no
longer turn ultracode off.
- **Clear context:** the clear-context restart restores the toggle, as
it does Fast mode.

## Known limitation

If a user types `/effort ultracode on` as a prompt, the toggle does not
update. The adapter already doesn't pick up effort changes typed as
`/effort` commands.

## Testing

- New `src/tests/ultracode-config.test.ts`. It covers the helpers and
session behavior: seeding from the CLI's report, hiding while workflows
are off, following the model's support, keeping the value across effort
and model switches, and rejected applies.
- New clear-context restore test. `effortFlagSettings` tests updated.
- Live probe against CLI 2.1.310 confirmed the `getSettings` fields, the
`xhigh` proxy (`claude-opus-4-6` reports unavailable while the request
stays in place), and the effort/key behavior.
- `npm run check`, `npm run build`, `npm run test:run` all pass.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:34:13 +00:00
Sean McCullough
a4957d17c1 fix: settle live steers folded into autonomous cycles (#1185)
#1233 fixed queued prompts folded into task-notification cycles. One
case remains: a steer sent to the active prompt can be folded into an
autonomous cycle, leaving `session/prompt` pending when the result names
only the steer UUID.

Keep queued prompt matching and also match the active turn's steer UUID.
Exclude settled turns and turns held open for background tasks. Preserve
plural UUID precedence.

The six new settlement cases fail on main and pass with the fix; all 11
new tests pass. Build, lint and format checks pass. The full default
suite reports 2,497 passed and 30 skipped with an identical Node binary
at a separate path. At `/usr/bin/node`, it reports 2,496 passed, one
failed and 30 skipped: an unchanged path-normalization test also fails
on main. No test was weakened. No live-provider tests were run.

Co-authored-by: Sean McCullough <sean@mcculloughs.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:35:31 +01:00
Nacho Lopez
bed795d261 fix: settle advisor tool results before completing turns (#1247)
When Claude uses the advisor server tool, a completed turn can fail with
`ACP error -32603: Internal error: Claude ended the turn without
returning results for tool calls: srvtoolu_...`. `server_tool_use` is
tracked as a foreground call, but the matching `advisor_tool_result` is
discarded, leaving that call unfinished at settlement.

Route advisor results through the existing tool-result handler so they
clear the outstanding call and publish a terminal update. Plaintext
advice is displayed, redacted advice gets a readable completion message
without exposing ciphertext, and advisor error results publish a failed
tool status with the error code. Release the advisor's hook callback
immediately because the server tool has no later client-side
`PostToolUse` hook.

Addresses the result-lifecycle part of #914.

Validation:

- Regression coverage through the real prompt consumer for plaintext,
redacted, and error results, including the complete assistant message
that dispatches the advisor call. All three cases fail without the fix.
A dispatched advisor call without a result still fails with
`incomplete_tool_call`.
- `npm run test:run -- src/tests/incomplete-tools.test.ts`: 59 passed.
- `npm run test:run`: 3,055 passed, 30 skipped.
- `npm run build` and `npm run check`: passed.
- Exact-head Linux CI passed in the fork:
https://github.com/mrmans0n/claude-agent-acp/actions/runs/38595756003.

The upstream PR workflows are waiting for maintainer approval to run.
The required upstream `Build` check remains pending; the fork CI result
does not satisfy that merge gate.
2026-10-26 11:28:57 +01:00
Jasper
434be13a33 fix: distinguish model options with duplicate labels (#1286)
Fixes #1246.

When `availableModels` includes aliases and pinned IDs that inherit the
same SDK display name, name-only model pickers show indistinguishable
rows. Append the selectable model ID to names that remain duplicated
after the existing version-label normalization. For example, the four
`Opus 5.5` rows become `Opus 5.5 (opus)`, `Opus 5.5 (opus[1m])`, and
their explicit-ID equivalents.

Keep every option and its ID, description, selection, and recommendation
behavior. Existing distinct labels stay unchanged. Regression tests
exercise the real allowlist transformation and cover duplicate custom
labels too.

Validation:

- Linux, locked dependencies and the repository's `lts/*` Node
configuration: `npm run check`, `npm run build`, and the full test suite
passed at ba37dd8: **2,491 passed, 30 skipped** ([fix
job](https://github.com/jipeng6036-del/claude-agent-acp/actions/runs/37923655565/job/113797353655)).
- Exact baseline 908c994: **2,489 passed, 30 skipped**; both new
regression tests fail against the original implementation. All existing
test outcomes match ([baseline
verification](https://github.com/jipeng6036-del/claude-agent-acp/actions/runs/37923859076)).
- Windows/Node 24: build and 105 focused tests passed. The full suite
has the same 48 failures on baseline and fix; only the two new tests add
passes. Authenticated SDK and JetBrains UI integration were not run.

Signed-off-by: Jasper <241105014+jipeng6036-del@users.noreply.github.com>
Co-authored-by: Jasper <241105014+jipeng6036-del@users.noreply.github.com>
2026-10-26 10:27:07 +00:00
Kerekes Dániel
2a1525e57c fix: keep the usage-limit error when it arrives after compaction (#1377)
Fixes #1370

After a compaction in the same turn, the synthetic usage-limit frame is
dropped by the stale-frame suppression before its failure details are
read. The prompt then ends with a generic internal error instead of the
quota error, so clients can't show the limit UI.

The suppression branch now records the usage-limit title and error
before it skips the frame. Only recognized usage-limit frames are read
there, so a stale replayed frame still can't overwrite the failure of
the current turn.

Added a test for a usage-limit frame after a completed compaction, for
both the legacy `errorKind` and the typed session failure, plus a stale
frame arriving after it. It fails without the change. `npm run check`,
`npm run build` and `npm run test:run` pass.
2026-10-26 10:26:18 +00:00
PerryLink
54f1e21bc4 test: retry the acp-v2 teardown rm on Windows (#1373)
## What breaks

On Windows, `npm run test:run` fails inside `src/tests/acp-v2.test.ts`,
and every failure is raised by the teardown rather than by the behaviour
the test checks. Measured on Windows 11 build 26200, Node 22.22.3,
vitest 5.0.4, two runs:

```
$ node node_modules/vitest/vitest.mjs run src/tests/acp-v2.test.ts
 Test Files  1 failed (1)
      Tests  45 failed | 91 passed (136)
   Duration  11.75s (tests 91%, transform 7%, import 2%)
```

```
 Test Files  1 failed (1)
      Tests  53 failed | 83 passed (136)
   Duration  11.79s (tests 96%, import 2%, transform 2%)
```

Exit code 1 both times. Every failure is the same error, and it comes
from `afterEach`, after the test body has finished:

```
 FAIL  src/tests/acp-v2.test.ts > ACP v2 prompts > does not report the next turn as waiting for a question that its client never settled
Error: EBUSY: resource busy or locked, rmdir 'C:\Users\...\Temp\acp-v2-TNkkd2\project-J9PwxJ'

 FAIL  src/tests/acp-v2.test.ts > ACP v2 prompts > the tool calls that a cancel stops > ends a stopped tool call and its subagent's as cancelled, before it reports idle cancelled
Error: EBUSY: resource busy or locked, rmdir 'C:\Users\...\Temp\acp-v2-O3wrrr\project-NgBUMz'
```

The string `EBUSY` occurs 45 and 53 times in those two logs, matching
the two failure counts. The failing set moves between runs; which test
loses the race depends on when Windows releases each directory handle.

CI runs the suite on `ubuntu-latest` only
(`.github/workflows/ci.yml:20`, `:45`), so CI cannot show this. Anyone
who runs the documented `npm run test:run` on Windows gets a red file
and a non-zero exit.

## Why

The per-test teardown at `src/tests/acp-v2.test.ts:156` is:

```ts
await rm(tempDir, { recursive: true, force: true });
```

`force: true` suppresses ENOENT only; it does not retry EBUSY. Each
test's cwd is a child of `tempDir` (`cwd = await
mkdtemp(path.join(tempDir, "project-"))`, line 147), and Windows keeps a
directory busy briefly after the process holding it exits, so `rmdir`
returns EBUSY, `afterEach` rejects, and vitest marks the test failed.

## The fix

```diff
-  await rm(tempDir, { recursive: true, force: true });
+  await rm(tempDir, { recursive: true, force: true, maxRetries: 10 });
```

`maxRetries` is Node's own retry loop for `fs.rm`, covering EBUSY,
EMFILE, ENFILE, ENOTEMPTY and EPERM with a backoff between attempts. The
repository already uses it for the same class of problem in
`src/tests/session-hooks.test.ts:150`:

```ts
// The CLI may still write the transcript of the last session after dispose.
if (root) await rm(root, { recursive: true, force: true, maxRetries: 10 });
```

## Measured after

```
$ node node_modules/vitest/vitest.mjs run src/tests/acp-v2.test.ts
 Test Files  1 passed (1)
      Tests  136 passed (136)
   Duration  16.16s (tests 96%, transform 2%, import 2%)
```

A second run after restoring the patched file: `Tests 136 passed (136)`,
`Duration 16.56s`, exit code 0, `EBUSY` count 0. `npm run check` (eslint
plus `prettier --check .`) also passes, and `npm run build` (`tsc`,
whose `include` covers `src/**/*.ts` so the test file is typechecked
too) exits 0.

## Scope

1 file changed, 1 insertion, 1 deletion: `src/tests/acp-v2.test.ts`.

Deliberately not changed:

- No other test file, and no teardown other than this one call.
`session-hooks.test.ts` is untouched.
- No assertion, fixture, mock or production code, so the 136 tests still
check exactly what they checked before.
- No CI change. Adding a Windows job is a separate call about CI time
and runner budget, and this fix can be verified on Windows without one.

## Unproven

- I have one Windows 11 machine. On `ubuntu-latest` the teardown did not
fail here before or after the patch, and I did not run the job on Linux;
the Linux side is "no behaviour change" by inspection, not something I
measured.
- `maxRetries: 10` covers a handle that closes shortly after the process
exits. A handle held for longer than the whole retry window would still
surface EBUSY; I did not observe one.
- I ran only this file, not the full suite, so I cannot say whether
other files fail on Windows for other reasons.
2026-10-26 10:25:48 +00:00
dependabot[bot]
01b686713d ci(deps): Bump actions/create-github-app-token from 3.2.0 to 3.3.0 (#1383)
Bumps
[actions/create-github-app-token](https://github.com/actions/create-github-app-token)
from 3.2.0 to 3.3.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/create-github-app-token/releases">actions/create-github-app-token's
releases</a>.</em></p>
<blockquote>
<h2>v3.3.0</h2>
<h2><a
href="https://github.com/actions/create-github-app-token/compare/v3.2.0...v3.3.0">3.3.0</a>
(2026-10-12)</h2>
<h3>Features</h3>
<ul>
<li>update permission inputs (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/399">#399</a>)
(<a
href="a47236f79f">a47236f</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> bump nested undici from 6.24.0 to 6.29.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/403">#403</a>)
(<a
href="12461a3a79">12461a3</a>)</li>
<li><strong>deps:</strong> bump the production-dependencies group across
1 directory with 3 updates (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/395">#395</a>)
(<a
href="a69c19ddfd">a69c19d</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md">actions/create-github-app-token's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2><a
href="https://github.com/actions/create-github-app-token/compare/v3.2.0...v3.3.0">3.3.0</a>
(2026-10-12)</h2>
<h3>Features</h3>
<ul>
<li>update permission inputs (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/399">#399</a>)
(<a
href="a47236f79f">a47236f</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> bump nested undici from 6.24.0 to 6.29.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/403">#403</a>)
(<a
href="12461a3a79">12461a3</a>)</li>
<li><strong>deps:</strong> bump the production-dependencies group across
1 directory with 3 updates (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/395">#395</a>)
(<a
href="a69c19ddfd">a69c19d</a>)</li>
</ul>
<h2><a
href="https://github.com/actions/create-github-app-token/compare/v3.1.1...v3.2.0">3.2.0</a>
(2026-05-12)</h2>
<h3>Features</h3>
<ul>
<li>add support for enterprise-level GitHub Apps (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/263">#263</a>)
(<a
href="952a2a7073">952a2a7</a>)</li>
<li>support full repository names in <code>repositories</code> input (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/372">#372</a>)
(<a
href="85eb8dd414">85eb8dd</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> bump <code>@​actions/core</code> from 3.0.0
to 3.0.1 in the production-dependencies group (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/364">#364</a>)
(<a
href="43e5c345bf">43e5c34</a>)</li>
<li>validate private-key input (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/376">#376</a>)
(<a
href="f24bbd8964">f24bbd8</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="64ab729a62"><code>64ab729</code></a>
chore(main): release 3.3.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/402">#402</a>)</li>
<li><a
href="f3a828e4b9"><code>f3a828e</code></a>
ci: create signed dist commits in release workflow (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/404">#404</a>)</li>
<li><a
href="655017ffa7"><code>655017f</code></a>
docs: clarify when owner grants access to all repositories (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/378">#378</a>)</li>
<li><a
href="12461a3a79"><code>12461a3</code></a>
fix(deps): bump nested undici from 6.24.0 to 6.29.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/403">#403</a>)</li>
<li><a
href="a47236f79f"><code>a47236f</code></a>
feat: update permission inputs (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/399">#399</a>)</li>
<li><a
href="a69c19ddfd"><code>a69c19d</code></a>
fix(deps): bump the production-dependencies group across 1 directory
with 3 u...</li>
<li><a
href="d5cb7ae41d"><code>d5cb7ae</code></a>
build(deps): bump actions/checkout from 6 to 7 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/384">#384</a>)</li>
<li><a
href="575da866bd"><code>575da86</code></a>
build(deps): bump stefanzweifel/git-auto-commit-action from 7.1.0 to
7.2.0 in...</li>
<li><a
href="e85f677b44"><code>e85f677</code></a>
test: omit stack traces from snapshots (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/401">#401</a>)</li>
<li><a
href="9419396df4"><code>9419396</code></a>
build(deps-dev): bump c8 from 11.0.0 to 12.0.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/400">#400</a>)</li>
<li>Additional commits viewable in <a
href="bcd2ba4921...64ab729a62">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/create-github-app-token&package-manager=github_actions&previous-version=3.2.0&new-version=3.3.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)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-10-26 10:24:25 +00:00
KlausUllrich
6a33dedcbb fix: pass --settings before the auth status subcommand (#1392)
`runCliAuthProbe` builds `["auth", "status", "--json"]` and, when the
session has a settings
override, appends `--settings <json>`. `--settings` is a global option
of the Claude Code CLI;
the `auth status` subcommand only accepts `--json` and `--text` (see
`claude auth status --help`).
So every probe with an override fails with `error: unknown option
'--settings'` and an empty
stdout. The catch branch logs `claude auth status failed: …` on every
call and returns
`undefined`. For sessions that pass `_meta.claudeCode.options.settings`,
the session-creation and
per-prompt refreshes therefore never update the auth status. Only the
initialize probe, which has
no override, still works.

This moves `--settings <json>` in front of the subcommand. Without an
override the argv is
unchanged.

**Reproduce** (no credentials needed; an empty config dir answers "not
logged in"):

```sh
d=$(mktemp -d)
env -i HOME="$d" PATH="$PATH" CLAUDE_CONFIG_DIR="$d/.claude" \
  claude auth status --json --settings '{}'
# error: unknown option '--settings'
env -i HOME="$d" PATH="$PATH" CLAUDE_CONFIG_DIR="$d/.claude" \
  claude --settings '{}' auth status --json
# {"loggedIn": false, "authMethod": "none", ...}
```

The option is applied in the global position, not just parsed:
`claude --settings '{"apiKeyHelper":"/bin/echo rt-fake-key"}' auth
status --json` reports
`"loggedIn": true, "authMethod": "api_key_helper"`.

Tested with 10 native Claude Code binaries (linux-x64), each in an empty
`HOME`:

| Claude Code | `auth status --json --settings '{}'` | `--settings '{}'
auth status --json` |
|---|---|---|
| 2.1.286 | unknown option | JSON |
| 2.1.294 | unknown option | JSON |
| 2.1.295, 2.1.296, 2.1.299 | unknown option | JSON |
| 2.1.300, 2.1.301, 2.1.302 | unknown option | JSON |
| 2.1.303 (the SDK's `claudeCodeVersion`) | unknown option | JSON |
| 2.1.304 | unknown option | JSON |

The existing test in `auth-status.test.ts` pinned the old order; it now
expects `--settings`
first. Its mock and `statusCalls()` matched probes by `args[1] ===
"status"`, which a leading
global option shifts, so both now locate the subcommand instead.

`npm run build`, `format:check`, `lint` and `test:run` pass, apart from
one test that fails
identically on `main` without this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:24:11 +00:00
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
dependabot[bot]
0fe22ae5a1 fix: bump fast-uri from 3.1.8 to 3.1.9 (#1410)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to
3.1.9.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fastify/fast-uri/releases">fast-uri's
releases</a>.</em></p>
<blockquote>
<h2>v3.1.9</h2>
<h2>Security</h2>
<p>This security release fixes four URI parsing and normalization
vulnerabilities in fast-uri 3.x. Applications that process untrusted URI
input should upgrade to v3.1.9.</p>
<ul>
<li><strong>Medium — URI scheme spoofing through percent-encoded
schemes</strong> (<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-w74x-v6f9-j3xg">GHSA-w74x-v6f9-j3xg</a>,
CVE-2026-110306): percent-encoded scheme input is now rejected instead
of being decoded into a live scheme.</li>
<li><strong>Medium — Host confusion during domain-host
canonicalization</strong> (<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-wjr9-j795-jxcr">GHSA-wjr9-j795-jxcr</a>,
CVE-2026-110180): malformed domain hosts containing ambiguous delimiters
or stripped control characters are now rejected instead of being
silently rewritten.</li>
<li><strong>Low — Incorrect relative-reference normalization</strong>
(<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-xhq7-xmqj-769h">GHSA-xhq7-xmqj-769h</a>,
CVE-2026-110095): dot-segment removal now preserves the resolution
semantics of relative references.</li>
<li><strong>Low — Scheme injection in suffix references</strong> (<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-333x-w4r6-6crp">GHSA-333x-w4r6-6crp</a>,
CVE-2026-110725): first-segment colons in path-only suffix references
are now escaped so the output is not reparsed as a scheme.</li>
</ul>
<p>Upgrade with:</p>
<pre lang="sh"><code>npm install fast-uri@3.1.9
</code></pre>
<p>Thanks to <a href="https://github.com/zekth"><code>@​zekth</code></a>
for reporting the scheme, host, and relative-reference issues, and to <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
for finding the suffix-reference issue and reviewing the
remediations.</p>
<h2>What's Changed</h2>
<ul>
<li>ci: enable trusted publishing releases (v3.x) by <a
href="https://github.com/mcollina"><code>@​mcollina</code></a> in <a
href="https://redirect.github.com/fastify/fast-uri/pull/229">fastify/fast-uri#229</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9">https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="0def70338a"><code>0def703</code></a>
Bumped v3.1.9</li>
<li><a
href="a1d0da2f5c"><code>a1d0da2</code></a>
test: generate only relative path references</li>
<li><a
href="9eb8759a98"><code>9eb8759</code></a>
fix: preserve relative paths in suffix and relative resolution</li>
<li><a
href="54c30f7589"><code>54c30f7</code></a>
docs: note suffix-reference authority ambiguity</li>
<li><a
href="39e6493c13"><code>39e6493</code></a>
fix: escape a first-segment colon in suffix references</li>
<li><a
href="745183c1d9"><code>745183c</code></a>
test: cover first-segment colon in suffix references</li>
<li><a
href="ddfc6a5112"><code>ddfc6a5</code></a>
fix(SEC-03): reject non reg-name characters in domain hosts</li>
<li><a
href="d86a8d29f4"><code>d86a8d2</code></a>
fix: keep a colon-first segment behind &quot;./&quot; in relative
normalization</li>
<li><a
href="eb483b5da2"><code>eb483b5</code></a>
test: cover colon-first relative references in normalization</li>
<li><a
href="b110a3571c"><code>b110a35</code></a>
fix(SEC-02): preserve meaning of relative references during
normalization</li>
<li>Additional commits viewable in <a
href="https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9">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 fast-uri since your current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fast-uri&package-manager=npm_and_yarn&previous-version=3.1.8&new-version=3.1.9)](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)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 20:54:32 +02:00
67 changed files with 5076 additions and 615 deletions

View File

@@ -36,7 +36,7 @@ jobs:
# Generating a GitHub token, so that PRs and tags created by
# the release-please-action can trigger actions workflows.
- name: Generate GitHub token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
id: generate-token
with:
# GitHub App ID secret name
@@ -256,7 +256,7 @@ jobs:
exit 1
- name: Generate token scoped to the registry repo
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
id: registry-token
with:
app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }}

View File

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

View File

@@ -1,5 +1,75 @@
# Changelog
## [0.98.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.97.0...v0.98.0) (2026-10-27)
### Features
* add batch session metadata lookup ([#1408](https://github.com/agentclientprotocol/claude-agent-acp/issues/1408)) ([0ce42f2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0ce42f2c312d190f78024d8b4b1ae5f00d9e5939))
### Bug Fixes
* publish Monitor async tasks as monitors ([#1452](https://github.com/agentclientprotocol/claude-agent-acp/issues/1452)) ([4324dce](https://github.com/agentclientprotocol/claude-agent-acp/commit/4324dce4be877c55385b3389cebac1c63d08f456))
* settle the tool call of a SandboxNetworkAccess permission request ([#1448](https://github.com/agentclientprotocol/claude-agent-acp/issues/1448)) ([8f43ac8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8f43ac808931a3f14719bbed37b9b153c9933139))
* **v2:** leave the tool call status out of permission subjects ([#1447](https://github.com/agentclientprotocol/claude-agent-acp/issues/1447)) ([c7c80f7](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7c80f7b26f8d90f7f1c828036db64bc46dddddf))
## [0.97.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.96.0...v0.97.0) (2026-10-26)
### Features
* add ultracode session config option ([#1438](https://github.com/agentclientprotocol/claude-agent-acp/issues/1438)) ([de96cef](https://github.com/agentclientprotocol/claude-agent-acp/commit/de96cef382d8daf27301f1f90ae4b5e7f665f73a))
### Bug Fixes
* bump body-parser from 2.3.0 to 2.4.0 ([#1436](https://github.com/agentclientprotocol/claude-agent-acp/issues/1436)) ([c26e640](https://github.com/agentclientprotocol/claude-agent-acp/commit/c26e640915739dc7a3cd0664f8f5879098e0fd83))
* distinguish model options with duplicate labels ([#1286](https://github.com/agentclientprotocol/claude-agent-acp/issues/1286)) ([434be13](https://github.com/agentclientprotocol/claude-agent-acp/commit/434be13a33141af8655c6f7c30f574841bf2279c))
* keep the usage-limit error when it arrives after compaction ([#1377](https://github.com/agentclientprotocol/claude-agent-acp/issues/1377)) ([2a1525e](https://github.com/agentclientprotocol/claude-agent-acp/commit/2a1525e57cd89246d1a50993fd6ba335c4e634ca))
* pass --settings before the auth status subcommand ([#1392](https://github.com/agentclientprotocol/claude-agent-acp/issues/1392)) ([6a33ded](https://github.com/agentclientprotocol/claude-agent-acp/commit/6a33dedcbb90e7bf9db91207f0edbf19ca6d705e))
* preserve foreground fork reports until launch completes ([#1431](https://github.com/agentclientprotocol/claude-agent-acp/issues/1431)) ([e369479](https://github.com/agentclientprotocol/claude-agent-acp/commit/e369479f93b34f84a11103323d097c44df42e02e))
* settle advisor tool results before completing turns ([#1247](https://github.com/agentclientprotocol/claude-agent-acp/issues/1247)) ([bed795d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bed795d261620e6d5113e427a7196d94e66e7034))
* settle live steers folded into autonomous cycles ([#1185](https://github.com/agentclientprotocol/claude-agent-acp/issues/1185)) ([a4957d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/a4957d17c1e1a925ba3b72b29c5dcde4faabbe17))
## [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)

View File

@@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
- Interactive (and background) terminals
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
- Client MCP servers
- An Ultracode session config option, which turns on Claude Code's dynamic workflows for every task. It appears when dynamic workflows are enabled and the model supports ultracode (any model with `xhigh` effort)
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)

View File

@@ -141,8 +141,8 @@ The `initialize` response of an AIR client carries the agent side of the extensi
}
```
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`.
An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`.
An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
The response to a client that is not AIR has no `_meta.jetbrains` key.
### Capabilities
@@ -158,6 +158,7 @@ The response to a client that is not AIR has no `_meta.jetbrains` key.
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
| `backgroundSubagents` | yes | Ends a prompt turn at its result while background subagents still run. | [Background subagents](#background-subagents) |
| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) |
| `sessionBatchGet` | with `sessionIndex` | Supports `_session/batch_get` for metadata of known session IDs. | [Batch get](#batch-get) |
| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) |
| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) |
| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) |
@@ -707,7 +708,7 @@ A non-empty plan also gets one clear-context choice with the same priority.
After that choice, the adapter stops the old private Claude query and hides that internal rejection.
It starts a new private query under the same ACP session.
It continues the turn with `Implement the following plan:` and the complete plan.
The mode, model, agent, effort, Fast mode, and turn usage stay the same.
The mode, model, agent, effort, Fast mode, ultracode, and turn usage stay the same.
The SDK cannot apply `allowedPrompts` again, so those hints do not survive the handoff.
The adapter does not allow a callback only because the session mode is `bypassPermissions`.
@@ -1282,8 +1283,8 @@ The session index makes `session/list` a bounded, ordered page with row metadata
AIR declares `sessionIndex` in `_meta.jetbrains.air.capabilities`.
Unlike the other capabilities, the agent advertises `sessionIndex` only to a client that declared it, and never under ACP v2.
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)).
The client does not declare these four; declaring them without `sessionIndex` enables nothing.
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionBatchGet` (it supports `_session/batch_get`), `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)).
The client does not declare these five; declaring them without `sessionIndex` enables nothing.
Everything in this section applies only to such a client, except where [Delete and close](#delete-and-close) says otherwise.
A client without the capability gets the `session/list` of before: the SDK `listSessions` call, pages of 1000 by file mtime, `offset:N` cursors, and no row `_meta`.
It gets no new notification, and the adapter starts no watcher for it.
@@ -1442,6 +1443,71 @@ In all of the session index methods and in `session/delete`, a session id matche
Loading an archived session does not unarchive it.
The CLI cleanup still deletes old transcripts (`cleanupPeriodDays`), archived or not.
### Batch get
`_session/batch_get` refreshes metadata for retained session IDs, such as pinned
sessions and folder members outside the recent list page. Check for
`sessionBatchGet` in `initialize.result._meta.jetbrains.air.capabilities` before
calling it.
The params object must contain only `sessionIds`: an array of distinct,
non-blank strings. IDs match exactly, without trimming or case normalization.
Malformed params, duplicates or extra fields return `Invalid params` (`-32602`).
The agent sets no limit on the number of IDs; the client chooses the batch size
for the latency it accepts. Archived sessions and sessions in other projects
or worktrees are eligible.
```json
{
"jsonrpc": "2.0",
"id": 7,
"method": "_session/batch_get",
"params": { "sessionIds": ["oldA", "oldB", "oldC"] }
}
```
```json
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"sessions": [
{
"sessionId": "oldA",
"cwd": "/project",
"title": "Fix the build",
"updatedAt": "2026-01-01T00:00:00.000Z",
"_meta": { "jetbrains": { "air": { "version": 1, "archived": false } } }
}
]
}
}
```
The example IDs are placeholders for stored Claude session UUIDs. `sessions`
contains the same full rows as `session/list` without a project filter, including
archived sessions and `_meta.jetbrains.air`. Copies use the same
newest-readable-copy selection as the list. Returned sessions preserve their
relative request order. Non-UUID IDs and IDs without a listable transcript are
omitted: absent, empty, unreadable or malformed transcripts, sidechains, sessions
whose continuation has history, and transcripts whose cwd cannot be recovered.
A sidecar alone does not supply a row.
`{ "sessionIds": [] }` returns `{ "sessions": [] }` without reading the store.
A store-wide failure, such as an unreadable projects root, rejects the request.
Individual results reflect the store as each ID is read.
Lookup normally addresses only the requested transcript filenames in each project
and reads their metadata. A transcript without a cwd can use a sibling's cwd,
as the list does. This fallback enumerates and stats sibling transcripts only
in the directories of rows still missing a cwd, then reads at most 64 siblings
per directory, newest mtime first. A failed recovery is cached for 60 seconds
unless the directory mtime changes. Other project histories are not enumerated.
The agent looks up 100 IDs at a time; the client may request any number of IDs.
Merge returned rows with the recent page by `sessionId`. A batch read does not
expand an existing list subscription's scope; refresh retained IDs outside that
scope explicitly.
### List subscription
`_session/list/subscribe { "cwd": "…" }` returns `{ "subscriptionId": "…" }`.
@@ -1465,6 +1531,7 @@ From then on the agent sends `_session/list/changes` notifications for the sessi
- `cwd` is required and must be an absolute path; a missing, non-string or relative `cwd` is `-32602`. Other parameters are ignored.
- The scope is the sessions of `cwd` and of the same subdirectory in every existing linked worktree of its repository, as a list with `includeWorktrees: true` resolves it, in any archive state. Sidechain and subagent transcripts are left out, as in the list.
- There is no page or recent-session limit: changes to older pinned or folder sessions within that scope are reported even outside the page AIR loaded. Pinned IDs outside the subscribed project and worktrees are not tracked; `_session/batch_get` does not add them to the subscription. AIR must subscribe to their project scope or refresh them explicitly.
- `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom`, `agentWorktree` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included.
- After subscribe, the first change of each session sends its row in full, even when only `updatedAt` changed. From then on, a change of `updatedAt` alone sends nothing; the new `updatedAt` comes with the next change.
- `removed` holds the ids of sessions that left the scope: deleted (the transcript is gone), or moved to a path outside it. It may name a session the client does not list (one it never listed, one the list hides, such as a session continued in another one when that one changes); the client ignores such an id.
@@ -1517,6 +1584,7 @@ Names and semantics follow them; the transport differs, and so do the `archived`
| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` |
| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) |
| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) |
| capability `sessionBatchGet`, `_session/batch_get` | none |
| `_meta.jetbrains.air.list.limit` | `session/list` `limit` |
| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` |
| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there |

586
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.94.0",
"version": "0.98.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,
@@ -142,7 +147,11 @@ import {
withAirMeta,
} from "./air-extension.js";
import * as sessionIndex from "./session-index/connection.js";
import type { RenameSessionRequest, SessionIdRequest } from "./session-index/service.js";
import type {
BatchGetSessionsRequest,
RenameSessionRequest,
SessionIdRequest,
} from "./session-index/service.js";
import type {
ListSubscribeRequest,
ListUnsubscribeRequest,
@@ -203,6 +212,7 @@ import {
import {
type ReplayChild,
RfdSubagentReplay,
firstResponseModel,
replaySubagentTerminalState,
} from "./subagent-replay.js";
import {
@@ -338,6 +348,8 @@ import {
buildModelConfigOption,
getAvailableModels,
MODEL_CONFIG_ID,
modelDisplayName,
modelEntryFor,
narrowToGatewayModels,
resolveModelPreference,
type SessionModelState,
@@ -353,9 +365,19 @@ import {
buildEffortConfigOption,
EFFORT_CONFIG_ID,
effortFlagSettings,
effortLevelName,
mergeEffortSettings,
settingsEffortForModel,
} from "./session-effort.js";
import {
createUltracodeConfigOption,
readUltracodeSettings,
resolveUltracodeEnabled,
ULTRACODE_CONFIG_ID,
ultracodeSupported,
ultracodeWorkflowsEnabled,
type UltracodeOptionState,
} from "./session-ultracode.js";
export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
export {
@@ -1182,6 +1204,15 @@ export type Session = {
* user's intent so it persists across model switches; the Fast mode config
* option is only surfaced while the selected model supports it. */
fastModeEnabled: boolean;
/** Whether ultracode is turned on for this session. Tracked as the user's
* intent, like `fastModeEnabled`: the CLI keeps the request across model
* switches and applies it whenever the model supports ultracode, while the
* config option is only surfaced on supporting models. */
ultracodeEnabled: boolean;
/** Whether dynamic workflows are enabled, learned from the CLI's
* `get_settings` at session start; undefined when unknown. Combined with
* the model's support to decide whether to show the ultracode option. */
ultracodeWorkflowsEnabled?: boolean;
/** The non-default effort the user picked through the ACP picker this
* session. A pin lives at the SDK's flag layer, which overrides the CLI's
* persisted effort (including the per-model `modelSettings` entries), so it
@@ -2714,6 +2745,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;
/**
@@ -2778,6 +2823,7 @@ export class ClaudeAcpAgent {
return session;
},
applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
applyUltracode: (session, enabled) => this.applyUltracode(session, enabled),
sessionUpdate: (notification) => this.client.sessionUpdate(notification),
supportsPlanOperations: () => this.clientCapabilities?.plan != null,
ensureConsumer: (session, id) => this.ensureConsumer(session, id),
@@ -2792,6 +2838,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) => {
@@ -3087,11 +3134,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,
@@ -3202,7 +3253,7 @@ export class ClaudeAcpAgent {
return this.sessionIndex.clientTitle(title);
}
/** `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`,
/** `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`,
* `_session/archive`, `_session/unarchive`. */
async subscribeSessionList(params: ListSubscribeRequest) {
return this.sessionIndex.subscribeList(params);
@@ -3210,6 +3261,9 @@ export class ClaudeAcpAgent {
async unsubscribeSessionList(params: ListUnsubscribeRequest) {
return this.sessionIndex.unsubscribeList(params);
}
async batchGetSessions(params: BatchGetSessionsRequest) {
return this.sessionIndex.batchGet(params);
}
renameSessionTitle(params: RenameSessionRequest) {
return this.sessionIndex.rename(params);
}
@@ -3402,7 +3456,9 @@ export class ClaudeAcpAgent {
try {
const cliPath = await claudeCliPath();
const args = ["auth", "status", "--json"];
if (context?.override) args.push("--settings", JSON.stringify(context.override));
// `--settings` is a global CLI option: after the `auth status` subcommand the CLI rejects
// it with "unknown option '--settings'", so it has to come first.
if (context?.override) args.unshift("--settings", JSON.stringify(context.override));
({ stdout } = await execFileAsync(cliPath, args, {
timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
cwd: context?.manager.getCwd() ?? process.cwd(),
@@ -4543,6 +4599,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
@@ -5058,6 +5115,8 @@ export class ClaudeAcpAgent {
* marked as a background result, but it lists the uuids of the prompts it
* answered.
*
* A steer belongs to the live active turn even though it has no queued
* Turn of its own. Its stamped result can answer that turn too.
* A turn held open for its background subagents already has its result,
* so a result that names it does not answer it. */
const answersPendingPrompt = (message: {
@@ -5073,8 +5132,15 @@ export class ClaudeAcpAgent {
answeredPromptUuids = [message.user_message_uuid];
}
const activeTurn = session.activeTurn;
const liveActiveTurn =
activeTurn && !activeTurn.settled && !isHeldOpen(activeTurn) ? activeTurn : undefined;
for (const promptUuid of answeredPromptUuids) {
const turn = findUnsettledTurn(promptUuid);
// Keep queued prompt matching, including folded sends with no echo.
// Steers qualify only through their live active owner.
const turn =
findUnsettledTurn(promptUuid) ??
(liveActiveTurn?.steeredUuids?.has(promptUuid) ? liveActiveTurn : undefined);
if (turn === undefined) {
// Not a prompt of this session, or it was answered already.
continue;
@@ -5876,9 +5942,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);
@@ -7629,6 +7714,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
@@ -7992,9 +8082,24 @@ export class ClaudeAcpAgent {
message.message.model === "<synthetic>" &&
compaction.hasDeliveredOutput
) {
if (isSyntheticUsageLimitMessage(message.message)) {
lastAssistantWasUsageLimit = true;
lastAssistantFailureTitle = assistantMessageText(message.message);
if (message.error) {
lastAssistantError = message.error;
}
}
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
@@ -8139,6 +8244,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())
@@ -8159,6 +8265,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;
}
@@ -8176,7 +8296,7 @@ export class ClaudeAcpAgent {
);
const backgroundBashTask = backgroundBashTaskFromToolResult(
content,
message.tool_use_result,
toolUseResult,
session.toolUseCache,
);
if (backgroundBashTask) await asyncTasks.taskBackgrounded(backgroundBashTask);
@@ -8205,7 +8325,7 @@ export class ClaudeAcpAgent {
asyncTasks,
backgroundBashTask,
);
const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
const resumedAgentId = resumedNativeSubagentId(toolUseResult);
if (resumedAgentId) {
const resume = sendMessageResume(
session.toolUseCache,
@@ -8246,7 +8366,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:
@@ -8454,6 +8574,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}`);
@@ -9032,6 +9153,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(
@@ -9107,13 +9229,18 @@ export class ClaudeAcpAgent {
throw new Error(`Unknown config option: ${params.configId}`);
}
// Fast mode carries a boolean value (for Clients that opted into boolean
// config options) or the "on"/"off" select fallback, so it bypasses the
// Fast mode and ultracode carry a boolean value (for Clients that opted
// into boolean config options) or the "on"/"off" select fallback, so they
// bypass the
// string-only validation the select-style options below rely on.
if (params.configId === FAST_MODE_CONFIG_ID) {
await this.applyFastMode(session, resolveFastModeEnabled(params));
return { configOptions: session.configOptions };
}
if (params.configId === ULTRACODE_CONFIG_ID) {
await this.applyUltracode(session, resolveUltracodeEnabled(params));
return { configOptions: session.configOptions };
}
if (typeof params.value !== "string") {
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
@@ -9246,8 +9373,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.
@@ -9377,8 +9513,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);
@@ -9501,6 +9640,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 &&
@@ -9631,13 +9793,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);
@@ -9647,9 +9809,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
@@ -10018,6 +10188,35 @@ export class ClaudeAcpAgent {
return raceWithAbort(this.client.createElicitation(request, signal), signal);
}
/** Settle the tool call `ensureToolCallEmitted` emitted for a
* SandboxNetworkAccess request once the request is answered. The callback
* asks for permission and runs nothing, so the SDK never sends a
* `tool_result` for its synthetic id, and the call would otherwise stay
* pending in the client. */
private async settleSandboxNetworkCall(
sessionId: string,
toolCallSessionId: string,
toolCallId: string,
allowed: boolean,
): Promise<void> {
const session = this.sessions[sessionId];
if (!session) return;
const update: SessionNotification["update"] = {
sessionUpdate: "tool_call_update",
toolCallId,
status: allowed ? "completed" : "failed",
};
try {
if (session.nativeSubagentDeliver) {
await session.nativeSubagentDeliver({ sessionId, update });
} else {
await this.client.sessionUpdate({ sessionId: toolCallSessionId, update });
}
} catch (error) {
this.logger.error("Failed to settle the SandboxNetworkAccess tool call:", error);
}
}
/** Emit the `tool_call` a permission request references if it hasn't been sent
* yet, so the client has the tool call before being asked to approve it. The
* matching streamed tool_use chunk later refines it with a `tool_call_update`
@@ -10120,6 +10319,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.
@@ -10389,6 +10666,14 @@ export class ClaudeAcpAgent {
let permissionResult = decodedPermission.permissionResult;
const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
permissionResult = autoFallback.permissionResult;
if (toolName === "SandboxNetworkAccess") {
await this.settleSandboxNetworkCall(
sessionId,
permissionSessionId,
toolUseID,
permissionResult.behavior === "allow",
);
}
if (autoFallback.fallbackApplied) {
await this.sessionModes.publishFallbackWarning(sessionId, session);
}
@@ -10599,16 +10884,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,
@@ -10762,6 +11106,13 @@ export class ClaudeAcpAgent {
{
useRecommendedValue,
},
{
// Like Fast mode, the toggle hides on models without ultracode
// support and comes back with the retained intent on one that has it.
supported: ultracodeSupported(session.ultracodeWorkflowsEnabled, newModelInfo),
enabled: session.ultracodeEnabled,
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
},
);
// Opted-in clients apply the concrete displayed effort on every switch,
@@ -10788,10 +11139,7 @@ export class ClaudeAcpAgent {
// deliberately apply their concrete displayed effort.
effortFlagSettings(
useRecommendedValue ? newEffort : undefined,
mergeEffortSettings(
session.settingsManager.getSettings(),
session.effortSettingsOverride,
),
session.ultracodeEnabled,
),
);
session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined;
@@ -10860,15 +11208,7 @@ export class ClaudeAcpAgent {
} else if (configId === EFFORT_CONFIG_ID) {
// Apply first so a rejected control request cannot leave the displayed
// value ahead of the SDK flag layer.
await session.query.applyFlagSettings(
effortFlagSettings(
value,
mergeEffortSettings(
session.settingsManager.getSettings(),
session.effortSettingsOverride,
),
),
);
await session.query.applyFlagSettings(effortFlagSettings(value, session.ultracodeEnabled));
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
@@ -10950,6 +11290,22 @@ export class ClaudeAcpAgent {
this.refreshFastModeOption(session, enabled);
}
/** Turn ultracode on or off for a session. Sends an explicit boolean, which
* leaves the effort level alone (`null` would reset the flag layer instead).
* The SDK flag is applied first so a rejected request leaves the session
* state and option untouched. */
private async applyUltracode(session: Session, enabled: boolean): Promise<void> {
await session.query.applyFlagSettings({ ultracode: enabled });
session.ultracodeEnabled = enabled;
const refreshed = createUltracodeConfigOption(
enabled,
clientSupportsBooleanConfigOptions(this.clientCapabilities),
);
session.configOptions = session.configOptions.map((o) =>
o.id === ULTRACODE_CONFIG_ID ? refreshed : o,
);
}
/** Reconcile the session's Fast mode toggle with an SDK-reported
* `fast_mode_state` (delivered on `system`/init and on user-turn `result`s).
* The SDK can flip fast mode independently of the user — e.g. back to `on`
@@ -11382,6 +11738,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"],
@@ -11450,14 +11810,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)] }]
: []),
],
}
: {}),
@@ -11465,6 +11844,7 @@ export class ClaudeAcpAgent {
...(userProvidedOptions?.hooks?.PostToolUse || []),
{
hooks: [
...(subagentConfigHook ? [subagentConfigHook] : []),
createPostToolUseHook({
onEnterPlanMode: async () => {
await this.sessionModes.publishCurrent(sessionId, "plan");
@@ -11488,9 +11868,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"
) {
@@ -11762,6 +12144,21 @@ export class ClaudeAcpAgent {
settingsManager.getSettings(),
configuredSettingsObject,
);
// The CLI reports whether ultracode can run (workflows enabled and a
// supporting model) and whether the session already requests it (an
// `ultracode: true` setting). Without a report, fall back to settings.
const ultracodeReport = await readUltracodeSettings(q);
timing.phase("ultracode");
const ultracodeEnabled = ultracodeReport?.requested ?? effortSettings.ultracode === true;
const workflowsEnabled = ultracodeWorkflowsEnabled(ultracodeReport, currentModelInfo);
const ultracode: UltracodeOptionState = {
supported:
ultracodeReport?.available ?? ultracodeSupported(workflowsEnabled, currentModelInfo),
enabled: ultracodeEnabled,
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
};
const configOptions = buildConfigOptions(
modes,
models,
@@ -11773,10 +12170,11 @@ export class ClaudeAcpAgent {
{
useRecommendedValue,
},
ultracode,
);
const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
if (useRecommendedValue && typeof initialEffort?.currentValue === "string") {
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, effortSettings));
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, ultracodeEnabled));
}
// Seed the context window without awaited IPC. The cached authoritative
// window from a prior turn wins (`result.modelUsage`, cross-session),
@@ -11844,6 +12242,8 @@ export class ClaudeAcpAgent {
currentAgent,
fastModeEnabled,
fastModeDisabledReason,
ultracodeEnabled,
ultracodeWorkflowsEnabled: workflowsEnabled,
abortController,
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
forwardSubagentText,
@@ -12673,6 +13073,7 @@ export function buildConfigOptions(
currentAgent: string = DEFAULT_AGENT_ID,
fastMode?: FastModeOptionState,
presentation?: ConfigOptionPresentation,
ultracode?: UltracodeOptionState,
): SessionConfigOption[] {
const options: SessionConfigOption[] = [SessionModeManager.configOption(modes)];
// A gateway that lists none of the SDK's models leaves no model to offer, so
@@ -12703,6 +13104,12 @@ export function buildConfigOptions(
);
}
// Surface the ultracode toggle only while the CLI can run it: dynamic
// workflows are enabled and the current model supports it.
if (ultracode?.supported) {
options.push(createUltracodeConfigOption(ultracode.enabled, ultracode.useBooleanOption));
}
// Only surface the Agent picker when there's a real choice — i.e. the user
// has configured at least one custom agent (built-ins are filtered out in
// discoverCustomAgents). With none configured, "Default" would be the only
@@ -13345,6 +13752,38 @@ export function toAcpNotifications(
break;
}
case "advisor_tool_result": {
// Advisor results arrive in assistant content, including encrypted advice.
// Resolve the server tool through the normal result path without rendering
// opaque ciphertext. Server tools have no later PostToolUse hook to await.
const result = chunk.content;
unregisterHookCallback(chunk.tool_use_id);
output.push(
...toAcpNotifications(
[
{
type: "tool_result",
tool_use_id: chunk.tool_use_id,
is_error: result.type === "advisor_tool_result_error",
content:
result.type === "advisor_result"
? result.text
: result.type === "advisor_redacted_result"
? "Advisor guidance applied server-side."
: `Advisor error: ${result.error_code}`,
},
],
role,
sessionId,
toolUseCache,
client,
logger,
options,
),
);
break;
}
case "tool_result":
case "tool_search_tool_result":
case "web_fetch_tool_result":
@@ -13514,7 +13953,6 @@ export function toAcpNotifications(
case "container_upload":
case "compaction":
case "compaction_delta":
case "advisor_tool_result":
case "fallback":
case "mcp_tool_listing":
break;
@@ -13973,3 +14411,29 @@ function getMatchingModelUsage(modelUsage: Record<string, ModelUsage>, currentMo
return { key: bestKey, usage: modelUsage[bestKey] };
}
}
/**
* 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 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

@@ -22,6 +22,9 @@ export const AIR_PLAN_FILE_CAPABILITY = "planFile";
* subscription. Advertised only to a client that declares it.
*/
export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex";
/** Metadata lookup of known ids with `_session/batch_get`. Advertised
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is negotiated. */
export const AIR_SESSION_BATCH_GET_CAPABILITY = "sessionBatchGet";
/**
* The agent supports `_session/archive` and `_session/unarchive`. Advertised
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not

View File

@@ -15,6 +15,8 @@ type AsyncTask = {
taskType: string;
description: string;
showInTranscript: boolean;
/** A Monitor tool call or an SDK `local_monitor` task: published as a monitor the client cannot stop. */
monitor: boolean;
outputFilePath?: string;
toolCallId?: string;
/**
@@ -142,7 +144,8 @@ export class AsyncTaskRuntime {
* the tasks that a tool call in another session started, for example in
* a native subagent session. `toolNameOf`: the name of the tool of a tool
* call, or `undefined` when the tool call is unknown. A task that a
* `Monitor` tool call started is ignored. */
* `Monitor` tool call started is published as a monitor: the SDK reports
* it as `local_bash`, so the adapter names it a monitor. */
private readonly options: {
notices?: boolean;
routeOf?: (toolCallId: string) => Route | undefined;
@@ -155,7 +158,7 @@ export class AsyncTaskRuntime {
const taskId = taskIdOf(message);
if (!taskId) return;
const task = this.task(taskId);
if (isSubagentTask(message) || isMonitorTask(message)) {
if (isSubagentTask(message)) {
this.ignore(task);
return;
}
@@ -186,7 +189,7 @@ export class AsyncTaskRuntime {
const taskId = taskIdOf(message);
if (!taskId) return;
const task = this.task(taskId);
if (isSubagentTask(message) || isMonitorTask(message)) {
if (isSubagentTask(message)) {
this.ignore(task);
return;
}
@@ -355,7 +358,7 @@ export class AsyncTaskRuntime {
const task = this.task(taskId);
if (task.ignored || isTerminal(task.state)) continue;
const levelTaskType = field(item, "taskType", "task_type");
if (levelTaskType === "local_agent" || levelTaskType === "local_monitor") {
if (levelTaskType === "local_agent") {
this.ignore(task);
continue;
}
@@ -408,7 +411,13 @@ export class AsyncTaskRuntime {
canStop(taskId: string): boolean {
const task = this.tasks.get(taskId);
return task?.announced === true && !task.ignored && !task.stopping && !isTerminal(task.state);
return (
task?.announced === true &&
!task.monitor &&
!task.ignored &&
!task.stopping &&
!isTerminal(task.state)
);
}
claimStop(taskId: string): boolean {
@@ -458,18 +467,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;
@@ -568,6 +584,7 @@ export class AsyncTaskRuntime {
taskType: "task",
description: "Background task",
showInTranscript: true,
monitor: false,
announced: false,
held: false,
deferred: [],
@@ -588,6 +605,7 @@ export class AsyncTaskRuntime {
task.startedObserved = true;
const rawTaskType = field(message, "taskType", "task_type");
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
if (rawTaskType === "local_monitor") task.monitor = true;
const description = nonBlankString(message.description);
if (description) task.description = description;
else if (task.description === "Background task")
@@ -645,6 +663,7 @@ export class AsyncTaskRuntime {
private mergeLevel(task: AsyncTask, level: BackgroundTaskLevel): void {
const rawTaskType = field(level, "taskType", "task_type");
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
if (rawTaskType === "local_monitor") task.monitor = true;
const description = nonBlankString(level.description);
if (description) {
task.description = description;
@@ -669,25 +688,29 @@ export class AsyncTaskRuntime {
* ends first, or when the prompt result ends the model turn
* ({@link releaseHeld}). No guess by the command text binds a task.
*
* A task that a `Monitor` tool call started is ignored: Monitor streams its
* output to the model only, so the task has nothing for the client. The
* tool_use streams before the tool runs, so the tool name is known when the
* spawn has the tool call id.
* A task that a `Monitor` tool call started is published as a monitor. The
* SDK reports it as `local_bash`, so the adapter names it a monitor instead of
* a shell. It has no transcript card because Monitor streams its output to
* the model only, and no Stop because stopping it only ends the agent's wait
* (the agent's own TaskStop still works). The tool_use streams before the
* tool runs, so the tool name is known when the spawn has the tool call id.
*/
private async announce(
task: AsyncTask,
options: { withoutToolCall?: boolean } = {},
): Promise<void> {
if (task.announced || task.ignored) return;
const toolName = task.toolCallId ? this.options.toolNameOf?.(task.toolCallId) : undefined;
if (toolName !== undefined && HIDDEN_TASK_TOOLS.has(toolName)) {
this.ignore(task);
return;
}
if (!task.toolCallId && !options.withoutToolCall && !isTerminal(task.state)) {
task.held = true;
return;
}
if (task.toolCallId && this.options.toolNameOf?.(task.toolCallId) === "Monitor") {
task.monitor = true;
}
if (task.monitor) {
task.taskType = "monitor";
task.showInTranscript = false;
}
if (task.toolCallId) task.route ??= this.options.routeOf?.(task.toolCallId);
await this.send(task, {
sessionUpdate: "async_task_spawned",
@@ -696,7 +719,7 @@ export class AsyncTaskRuntime {
taskType: task.taskType,
description: task.description,
showInTranscript: task.showInTranscript,
canStop: true,
canStop: !task.monitor,
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
});
@@ -1044,13 +1067,6 @@ function isBackgroundTask(isBackgrounded: unknown, taskType: unknown): boolean {
return taskType !== "local_bash" && taskType !== "local_agent";
}
/** The tools whose async tasks the client never sees. */
const HIDDEN_TASK_TOOLS = new Set(["Monitor"]);
function isMonitorTask(message: AsyncTaskStarted): boolean {
return field(message, "taskType", "task_type") === "local_monitor";
}
function isSubagentTask(message: AsyncTaskStarted): boolean {
return (
field(message, "subagentType", "subagent_type") !== undefined ||

View File

@@ -39,6 +39,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
configOptions: SessionConfigOption[];
currentAgent: string;
fastModeEnabled: boolean;
ultracodeEnabled: boolean;
effortPinnedLevel?: string;
activeTurn?: Turn | null;
turnQueue?: Turn[];
@@ -59,6 +60,7 @@ export type ClearContextCoordinatorHost<
options: { publicSessionId: string; permissionMode: PermissionMode },
): Promise<Session>;
applyFastMode(session: Session, enabled: boolean): Promise<void>;
applyUltracode(session: Session, enabled: boolean): Promise<void>;
publishSessionState(
sessionId: string,
mode: PermissionMode,
@@ -146,6 +148,16 @@ export async function continuePlanInFreshContext<
}
assertRestartActive(freshSession);
}
// The fresh query seeds ultracode from settings only, so a toggle made
// during the old session would otherwise be lost.
if (oldSession.ultracodeEnabled !== freshSession.ultracodeEnabled) {
try {
await host.applyUltracode(freshSession, oldSession.ultracodeEnabled);
} catch (error) {
host.logError("Failed to restore ultracode after clearing context:", error);
}
assertRestartActive(freshSession);
}
oldSession.activeTurn = null;
oldSession.turnQueue = [];

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

@@ -2,3 +2,4 @@
export const DEFAULT_MODEL_ID = "default";
export const DEFAULT_AGENT_ID = "default";
export const EFFORT_CONFIG_ID = "effort";
export const ULTRACODE_CONFIG_ID = "ultracode";

View File

@@ -14,14 +14,15 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
// ultracode on at any level, so carry a settings-requested ultracode along
// with every effort apply.
// ultracode on at any level, so send `ultracode: true` with every effort apply
// while the session has ultracode on. When it is off, the key is omitted: an
// effort change then leaves ultracode off, as it already was.
export function effortFlagSettings(
value: string | undefined,
settings: Settings,
ultracode: boolean,
): { effortLevel: EffortLevel | null; ultracode?: true } {
const effortLevel = toSdkEffortLevel(value);
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
return ultracode ? { effortLevel, ultracode: true } : { effortLevel };
}
function canonicalizeModelSettingsKey(value: string): string {
@@ -79,6 +80,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 +106,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

@@ -20,6 +20,7 @@ import type { Query } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, Logger, Session } from "../acp-agent.js";
import {
AIR_SESSION_ARCHIVE_CAPABILITY,
AIR_SESSION_BATCH_GET_CAPABILITY,
AIR_SESSION_INDEX_CAPABILITY,
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
AIR_SESSION_RENAME_CAPABILITY,
@@ -46,6 +47,8 @@ import {
renameTo,
parseRenameSessionRequest,
parseSessionIdRequest,
parseBatchGetSessionsRequest,
SESSION_BATCH_GET_METHOD,
SESSION_ARCHIVE_METHOD,
SESSION_RENAME_METHOD,
SESSION_UNARCHIVE_METHOD,
@@ -54,6 +57,8 @@ import {
type RetitleOptions,
type SessionIdRequest,
type TitleChange,
type BatchGetSessionsRequest,
type BatchGetSessionsResponse,
} from "./service.js";
/** What the session index keeps on each `Session`. */
@@ -144,12 +149,13 @@ export class SessionIndexConnection {
}
/** The AIR capabilities the agent advertises for the index: the index
* itself, archive, rename, the list subscription and the `agentWorktree`
* itself, batch get, archive, rename, the list subscription and the `agentWorktree`
* row field, all only to a client that declared `sessionIndex`. */
capabilities(): string[] {
return this.service
? [
AIR_SESSION_INDEX_CAPABILITY,
AIR_SESSION_BATCH_GET_CAPABILITY,
AIR_SESSION_ARCHIVE_CAPABILITY,
AIR_SESSION_RENAME_CAPABILITY,
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
@@ -230,6 +236,12 @@ export class SessionIndexConnection {
return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId));
}
batchGet(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse> {
return this.requireService(SESSION_BATCH_GET_METHOD).batchGet(params, (sessionId) =>
this.ownSessionState(sessionId),
);
}
/** An AIR client archives with session/delete (see {@link deleteSession}):
* its archived sessions (an archived custom title) stay hidden from the
* old list, as when the delete removed them. */
@@ -448,6 +460,7 @@ export class SessionIndexConnection {
/** The agent methods behind the session index extension methods. */
type SessionIndexMethods = {
batchGetSessions(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse>;
subscribeSessionList(params: ListSubscribeRequest): Promise<ListSubscribeResponse>;
unsubscribeSessionList(params: ListUnsubscribeRequest): Promise<EmptyResponse>;
renameSessionTitle(params: RenameSessionRequest): Promise<EmptyResponse>;
@@ -455,10 +468,15 @@ type SessionIndexMethods = {
unarchiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
};
/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`,
/** Routes `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`,
* `_session/rename`, `_session/archive` and `_session/unarchive`. */
export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp {
return app
.onRequest<BatchGetSessionsRequest, BatchGetSessionsResponse>(
SESSION_BATCH_GET_METHOD,
{ parse: parseBatchGetSessionsRequest },
(ctx) => agent().batchGetSessions(ctx.params),
)
.onRequest<ListSubscribeRequest, ListSubscribeResponse>(
LIST_SUBSCRIBE_METHOD,
{ parse: parseListSubscribeRequest },

View File

@@ -104,6 +104,17 @@ export async function canonicalPath(cwd: string): Promise<string> {
}
}
/** Whether the path exists, including a symlink whose target is missing. */
export async function exists(target: string): Promise<boolean> {
try {
await fs.lstat(target);
return true;
} catch (error) {
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
throw error;
}
}
/** The names of the project directories of `cwd` among `rootEntries`. */
export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] {
const exact = encodeProjectPath(cwd);

View File

@@ -15,6 +15,7 @@ import {
RequestError,
type ListSessionsRequest,
type ListSessionsResponse,
type SessionInfo,
} from "@agentclientprotocol/sdk/unstable";
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
import { airExtensionMeta } from "../air-extension.js";
@@ -37,7 +38,7 @@ import {
import { LiveSessionRegistry } from "./live-registry.js";
import {
canonicalPath,
errorCode,
exists,
isExactProjectDir,
isSessionId,
projectDirMatches,
@@ -65,6 +66,7 @@ import { sessionInfoOf } from "./session-info.js";
export const SESSION_RENAME_METHOD = "_session/rename";
export const SESSION_ARCHIVE_METHOD = "_session/archive";
export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
export const SESSION_BATCH_GET_METHOD = "_session/batch_get";
/** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
const RESOURCE_NOT_FOUND = -32002;
@@ -73,6 +75,27 @@ const CURSOR_VERSION = 5;
export type SessionIdRequest = { sessionId: string };
export type RenameSessionRequest = { sessionId: string; title: string };
export type BatchGetSessionsRequest = { sessionIds: string[] };
export type BatchGetSessionsResponse = {
sessions: SessionInfo[];
};
export function parseBatchGetSessionsRequest(value: unknown): BatchGetSessionsRequest {
const record = asRecord(value);
const ids = record.sessionIds;
if (
Object.keys(record).length !== 1 ||
!Array.isArray(ids) ||
ids.some((id) => typeof id !== "string" || !id.trim()) ||
new Set(ids).size !== ids.length
) {
throw RequestError.invalidParams(
undefined,
"params require only sessionIds: distinct, non-blank strings",
);
}
return { sessionIds: ids };
}
export function sessionNotFound(sessionId: string): RequestError {
return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
@@ -364,16 +387,6 @@ function isSdkNotFound(error: unknown): boolean {
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
}
async function exists(target: string): Promise<boolean> {
try {
await fs.lstat(target);
return true;
} catch (error) {
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
throw error;
}
}
/** The error the SDK `deleteSession` gives for a session id it rejects or
* does not find, which a client without `sessionIndex` got before. */
function sdkDeleteError(sessionId: string): Error {
@@ -520,6 +533,22 @@ export class SessionIndexService {
return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
}
/** Metadata for known ids, independent of list pages and project filters. */
async batchGet(
{ sessionIds }: BatchGetSessionsRequest,
own: (sessionId: string) => OwnSessionState | undefined,
): Promise<BatchGetSessionsResponse> {
if (sessionIds.length === 0) return { sessions: [] };
const live = await this.registry.snapshot();
const now = this.now();
const rows = await this.index.getSessions(sessionIds);
return {
sessions: rows.map((row) =>
sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now),
),
};
}
/** Runs `mutation` after the previous mutation of the session ended. */
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
const previous = this.mutations.get(sessionId) ?? Promise.resolve();

View File

@@ -26,6 +26,8 @@ import { effectiveTitle, visibleTitle } from "./archive-title.js";
import {
canonicalPath,
encodeProjectPath,
errorCode,
exists,
isExactProjectDir,
isSessionId,
normalizePath,
@@ -58,11 +60,15 @@ import { isInLinkedWorktree, worktreeCounterparts } from "./worktrees.js";
export const DEFAULT_LIST_LIMIT = 50;
export const MAX_LIST_LIMIT = 200;
/** Discovery could not establish which sessions the store contains. */
export class SessionStoreUnavailableError extends Error {}
/** Entries of the metadata cache: a few hundred bytes each, so the
* transcripts of a large history (tens of thousands) stay cached and deep
* pages do not read them again. */
const METADATA_CACHE_SIZE = 50_000;
const READ_BATCH_SIZE = 16;
/** Session ids looked up together by {@link SessionIndex.getSessions}. */
const LOOKUP_CHUNK_SIZE = 100;
/** Transcripts read at most per directory to recover a sibling's cwd. */
const MAX_CWD_PROBES_PER_DIR = 64;
/** How long a directory that gave no cwd is not probed again. */
@@ -97,6 +103,24 @@ export type TranscriptCandidate = {
ino: number;
};
type TranscriptFile = Pick<
TranscriptCandidate,
"sessionId" | "filePath" | "dirName" | "projectPath"
>;
/** Non-empty transcript files, with bounded stats shared by lists and id lookups. */
async function transcriptCandidates(
files: readonly TranscriptFile[],
): Promise<TranscriptCandidate[]> {
const stats = await statFiles(files.map(({ filePath }) => filePath));
return files.flatMap((file, i) => {
const current = stats[i];
return current?.isFile() && current.size > 0
? [{ ...file, mtimeMs: current.mtimeMs, size: current.size, ino: current.ino }]
: [];
});
}
/** The cached metadata of one transcript. */
type TranscriptMetadata = {
/** The visible title: without the archive prefix. */
@@ -526,25 +550,16 @@ export class SessionIndex {
const listed = await this.listings.namesOfAll(dirPaths);
const files = dirs.flatMap(({ dirName, projectPath }, i) =>
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))).map(
(name) => ({ name, filePath: path.join(root, dirName, name), dirName, projectPath }),
(name) => ({
sessionId: name.slice(0, -6),
filePath: path.join(root, dirName, name),
dirName,
projectPath,
}),
),
);
const stats = await statFiles(files.map(({ filePath }) => filePath));
const all: TranscriptCandidate[] = [];
files.forEach(({ name, filePath, dirName, projectPath }, i) => {
const fileStats = stats[i];
if (!fileStats?.isFile() || fileStats.size === 0) return;
all.push({
sessionId: name.slice(0, -6),
filePath,
dirName,
projectPath,
mtimeMs: fileStats.mtimeMs,
size: fileStats.size,
ino: fileStats.ino,
});
});
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates: all };
const candidates = await transcriptCandidates(files);
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates };
}
/**
@@ -580,6 +595,90 @@ export class SessionIndex {
};
}
/** Known ids in request order, using the same rows as the list.
* Addresses exact filenames; only rows without a cwd need sibling discovery. */
async getSessions(sessionIds: readonly string[]): Promise<IndexRow[]> {
if (sessionIds.length === 0) return [];
const root = projectsRoot();
let projectEntries: string[];
try {
projectEntries = await fs.readdir(root);
} catch (cause) {
if (errorCode(cause) === "ENOENT" && !(await exists(root))) {
return [];
}
throw new SessionStoreUnavailableError("Session store could not be read", { cause });
}
const ids = sessionIds.filter(isSessionId);
const byId = new Map<string, IndexRow>();
// Paths are project directories × ids: bound them per chunk, not per request.
for (let start = 0; start < ids.length; start += LOOKUP_CHUNK_SIZE) {
const chunk = ids.slice(start, start + LOOKUP_CHUNK_SIZE);
for (const row of await this.lookupRows(root, projectEntries, chunk)) {
byId.set(row.sessionId, row);
}
}
return sessionIds.flatMap((id) => {
const row = byId.get(id);
return row ? [row] : [];
});
}
/** The rows of `ids` found under `projectEntries`. All copies of an id are
* in the same call, so copy selection is unchanged by chunking. */
private async lookupRows(
root: string,
projectEntries: readonly string[],
ids: readonly string[],
): Promise<IndexRow[]> {
let candidates = await transcriptCandidates(
projectEntries.flatMap((dirName) =>
ids.map((sessionId) => ({
sessionId,
filePath: path.join(root, dirName, `${sessionId}.jsonl`),
dirName,
})),
),
);
if (CASE_INSENSITIVE_PATHS) {
// On a case-insensitive file system, stat of `ABC.jsonl` also finds
// `abc.jsonl`. Keep a file only when its real name is the requested one,
// or differs by more than case (a symlink, as the list would read it).
// A failed realpath means the file is unreadable, so the id is omitted.
const exact = await Promise.all(
candidates.map(async ({ filePath }) => {
try {
const actual = path.basename(await fs.realpath(filePath));
const expected = path.basename(filePath);
return actual === expected || actual.toLowerCase() !== expected.toLowerCase();
} catch {
return false;
}
}),
);
candidates = candidates.filter((_, i) => exact[i]);
}
return this.collectRows(candidates.sort(byMtimeDescending), {
inScope: scopeOf(undefined),
archived: "all",
wanted: Infinity,
unread: async (read, dirNames) => {
const dirs = [...dirNames];
const listed = await this.listings.namesOfAll(dirs.map((dir) => path.join(root, dir)));
const siblings = dirs.flatMap((dirName, i) =>
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6)))
.map((name) => ({
sessionId: name.slice(0, -6),
filePath: path.join(root, dirName, name),
dirName,
}))
.filter(({ filePath }) => !read.has(filePath)),
);
return (await transcriptCandidates(siblings)).sort(byMtimeDescending);
},
});
}
/** Every transcript file of `sessionId` (see {@link scanSession}). Empty
* files only with `includeEmpty`. */
async findTranscripts(
@@ -778,9 +877,12 @@ export class SessionIndex {
archived: ArchivedFilter;
after?: ListCursor;
wanted: number;
/** The transcripts not read (by path), which may supply a sibling's
* cwd. */
unread: (read: ReadonlySet<string>) => readonly TranscriptCandidate[];
/** The unread transcripts of the directories still eligible for cwd
* recovery. Called only after checking the negative cache. */
unread?: (
read: ReadonlySet<string>,
dirNames: ReadonlySet<string>,
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>;
restat?: boolean;
},
): Promise<IndexRow[]> {
@@ -852,14 +954,15 @@ export class SessionIndex {
}
await settlePending();
}
if (pending.length > 0) {
const unread = options.unread;
if (pending.length > 0 && unread) {
// A row recovered after the scan stopped still sorts into the page: it
// was read before the stop, and more rows only raise the bound. A
// sibling that the archive filter or the cursor leaves out may still
// supply the cwd.
await this.learnDirCwds(
new Set(pending.map(({ candidate }) => candidate.dirName)),
options.unread(read),
(dirNames) => unread(read, dirNames),
options.restat ?? false,
);
await settlePending();
@@ -893,7 +996,9 @@ export class SessionIndex {
* changes. `restat` as in {@link collectRows}. */
private async learnDirCwds(
dirNames: ReadonlySet<string>,
unread: readonly TranscriptCandidate[],
unread: (
dirNames: ReadonlySet<string>,
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>,
restatFirst: boolean,
): Promise<void> {
const root = projectsRoot();
@@ -909,8 +1014,9 @@ export class SessionIndex {
if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS) continue;
mtimes.set(dirName, mtimeMs);
}
if (mtimes.size === 0) return;
const probes = new Map<string, number>();
let remaining = unread.filter((candidate) => {
let remaining = (await unread(new Set(mtimes.keys()))).filter((candidate) => {
if (!mtimes.has(candidate.dirName)) return false;
const count = probes.get(candidate.dirName) ?? 0;
if (count >= MAX_CWD_PROBES_PER_DIR) return false;

View File

@@ -595,14 +595,107 @@ export async function getAvailableModels(
for (const name of displayNames) {
displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1);
}
const names = models.map((model, index) =>
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
);
const nameCounts = new Map<string, number>();
for (const name of names) {
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
}
return {
availableModels: models.map((model, index) => ({
modelId: model.value,
name:
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
// Allowlisted aliases can inherit the same SDK label. Keep every ID
// selectable, but distinguish the rows for clients that show only names.
name: nameCounts.get(names[index]) === 1 ? names[index] : `${names[index]} (${model.value})`,
description: model.description,
})),
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))
);
}

136
src/session-ultracode.ts Normal file
View File

@@ -0,0 +1,136 @@
import type {
SessionConfigOption,
SetSessionConfigOptionRequest,
} from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
export { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
/** Select-fallback values for clients without boolean config options. */
export const ULTRACODE_ON = "on";
export const ULTRACODE_OFF = "off";
const ULTRACODE_DESCRIPTION = "Dynamic workflows on every task, at any effort level";
/** Ultracode state from the CLI's `get_settings` control response
* (`applied.ultracodeAvailable` / `applied.ultracodeRequested`). Each field is
* undefined when the CLI does not report it. */
export type UltracodeSettingsReport = {
/** Workflows are enabled and the current model supports ultracode. */
available?: boolean;
/** Ultracode is turned on for the session, whether or not it can take
* effect on the current model. */
requested?: boolean;
};
// `Query.getSettings()` exists at runtime (sdk.mjs) but is missing from the
// SDK's type declarations as of 0.3.310.
type GetSettingsQuery = Query & {
getSettings?: () => Promise<{
applied?: { ultracodeAvailable?: unknown; ultracodeRequested?: unknown };
}>;
};
/** Ask the CLI whether ultracode is available and requested for the session.
* Returns undefined when the SDK has no `getSettings` or the request fails;
* callers then fall back to {@link modelSupportsUltracode}. */
export async function readUltracodeSettings(
query: Query,
): Promise<UltracodeSettingsReport | undefined> {
const getSettings = (query as GetSettingsQuery).getSettings;
if (typeof getSettings !== "function") return undefined;
try {
const applied = (await getSettings.call(query))?.applied;
return {
available:
typeof applied?.ultracodeAvailable === "boolean" ? applied.ultracodeAvailable : undefined,
requested:
typeof applied?.ultracodeRequested === "boolean" ? applied.ultracodeRequested : undefined,
};
} catch {
return undefined;
}
}
/** The CLI supports ultracode on exactly the models that support `xhigh`
* effort (both use the same capability check), and `supportedEffortLevels`
* lists `xhigh` only for those models. E.g. Opus 4.7+, Sonnet 5+ and Haiku
* 5.5 qualify; Opus 4.6 and Haiku 4.5 do not. */
export function modelSupportsUltracode(modelInfo: ModelInfo | undefined): boolean {
return (
modelInfo?.supportsEffort === true &&
modelInfo.supportedEffortLevels?.includes("xhigh") === true
);
}
/** Whether dynamic workflows are enabled for the session, inferred from the
* CLI's `ultracodeAvailable` for the model the session started on. This is
* the part of availability that does not change with the model: the plan
* default (off on Pro), `enableWorkflows`/`disableWorkflows` and org policy.
* Undefined when the report can't tell, e.g. `ultracodeAvailable: false` on a
* model that lacks ultracode support anyway. */
export function ultracodeWorkflowsEnabled(
report: UltracodeSettingsReport | undefined,
modelInfo: ModelInfo | undefined,
): boolean | undefined {
if (report?.available === true) return true;
if (report?.available === false && modelSupportsUltracode(modelInfo)) return false;
return undefined;
}
/** Whether to offer the ultracode toggle for `modelInfo`. An unknown workflows
* state shows the toggle: the CLI keeps an unavailable request without
* error, so the toggle is merely inert there. */
export function ultracodeSupported(
workflowsEnabled: boolean | undefined,
modelInfo: ModelInfo | undefined,
): boolean {
return workflowsEnabled !== false && modelSupportsUltracode(modelInfo);
}
/** Ultracode state threaded into `buildConfigOptions`. The option is only
* surfaced while `supported`. */
export type UltracodeOptionState = {
supported: boolean;
enabled: boolean;
/** Whether the client opted into boolean config options. */
useBooleanOption: boolean;
};
/** Build the ultracode toggle: a native boolean for clients that opted into
* boolean config options, otherwise an "on"/"off" select. */
export function createUltracodeConfigOption(
enabled: boolean,
useBooleanOption: boolean,
): SessionConfigOption {
const base = {
id: ULTRACODE_CONFIG_ID,
name: "Ultracode",
description: ULTRACODE_DESCRIPTION,
category: "model_config",
} as const;
if (useBooleanOption) {
return { ...base, type: "boolean", currentValue: enabled };
}
return {
...base,
type: "select",
currentValue: enabled ? ULTRACODE_ON : ULTRACODE_OFF,
options: [
{ value: ULTRACODE_ON, name: "On" },
{ value: ULTRACODE_OFF, name: "Off" },
],
};
}
/** Read the requested ultracode value from a `session/set_config_option`
* request: a boolean, or the "on"/"off" select-fallback strings. */
export function resolveUltracodeEnabled(params: SetSessionConfigOptionRequest): boolean {
const value = params.value;
if (typeof value === "boolean") return value;
if (value === ULTRACODE_ON) return true;
if (value === ULTRACODE_OFF) return false;
throw new Error(`Invalid value for config option ${ULTRACODE_CONFIG_ID}: ${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 "";

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"SandboxNetworkAccess"}},"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Reason: Sandbox blocks network","title":"example.com","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"host":"example.com"},"title":"example.com","toolCallId":"toolu_net"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_net"}}}
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}

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

@@ -11,7 +11,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls -la","description":"List files"},"status":"pending","title":"ls -la","toolCallId":"toolu_bash"},"type":"tool_call"},"title":"ls -la"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls -la","description":"List files"},"title":"ls -la","toolCallId":"toolu_bash"},"type":"tool_call"},"title":"ls -la"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"YS50cwpiLnRz"},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}

View File

@@ -11,7 +11,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\nThe user doesn't want to proceed with this tool use. The tool use was rejected.\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use. The tool use was rejected.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}

View File

@@ -11,7 +11,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}

View File

@@ -8,7 +8,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"User has approved your plan. You can now start coding.\n\n## Approved Plan:\n# Plan\n1. Do it","sessionUpdate":"tool_call_update","status":"completed","title":"Exited Plan Mode","toolCallId":"toolu_plan"}}}

View File

@@ -8,7 +8,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\n```\nThe user doesn't want to proceed with this tool use.\n```\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}

View File

@@ -8,7 +8,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{"q":"acp"},"status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"},"type":"tool_call"},"title":"mcp__docs__search"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{"q":"acp"},"title":"mcp__docs__search","toolCallId":"toolu_mcp"},"type":"tool_call"},"title":"mcp__docs__search"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Found 2 documents.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Found 2 documents.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_mcp"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}

View File

@@ -5,6 +5,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call_update","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"description":"Reason: Sandbox blocks network","options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"```json\n{\n \"host\": \"example.com\"\n}\n```","type":"text"},"type":"content"}],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"status":"pending","title":"example.com","toolCallId":"toolu_net"},"type":"tool_call"},"title":"example.com"}}
{"kind":"request","method":"session/request_permission","payload":{"description":"Reason: Sandbox blocks network","options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"```json\n{\n \"host\": \"example.com\"\n}\n```","type":"text"},"type":"content"}],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"title":"example.com","toolCallId":"toolu_net"},"type":"tool_call"},"title":"example.com"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_net"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -11,7 +11,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[],"kind":"other","name":"NotebookEdit","rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"status":"pending","title":"NotebookEdit","toolCallId":"toolu_nb"},"type":"tool_call"},"title":"NotebookEdit"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[],"kind":"other","name":"NotebookEdit","rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"title":"NotebookEdit","toolCallId":"toolu_nb"},"type":"tool_call"},"title":"NotebookEdit"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Updated cell cell-1 with print('hi')","type":"text"},"type":"content"}],"rawOutput":"Updated cell cell-1 with print('hi')","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}

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"}}}
@@ -23,7 +24,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_n","sessionUpdate":"subagent_update","state":{"state":"requires_action"}}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_n","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"make","description":"Build"},"status":"pending","title":"make","toolCallId":"toolu_native_bash"},"type":"tool_call"},"title":"make"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_n","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"make","description":"Build"},"title":"make","toolCallId":"toolu_native_bash"},"type":"tool_call"},"title":"make"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_n","sessionUpdate":"subagent_update","state":{"state":"running"}}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"running"}}}

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"}}}
@@ -22,7 +23,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"requires_action"}}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"rg parser","description":"Search"},"status":"pending","title":"rg parser","toolCallId":"toolu_sub_bash"},"type":"tool_call"},"title":"rg parser"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"rg parser","description":"Search"},"title":"rg parser","toolCallId":"toolu_sub_bash"},"type":"tool_call"},"title":"rg parser"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"running"}}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}}
@@ -36,7 +37,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"requires_action"}}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"edit","locations":[{"path":"<cwd>/x.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/x.ts","new_string":"b","old_string":"a"},"status":"pending","title":"Edit x.ts","toolCallId":"toolu_sub_edit"},"type":"tool_call"},"title":"Edit x.ts"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"edit","locations":[{"path":"<cwd>/x.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/x.ts","new_string":"b","old_string":"a"},"title":"Edit x.ts","toolCallId":"toolu_sub_edit"},"type":"tool_call"},"title":"Edit x.ts"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"running"}}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}}

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

@@ -9,7 +9,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"allow_always","name":"Yes, and don't ask again for example.com","optionId":"allow-with-updates"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"kind":"fetch","name":"WebFetch","rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"status":"pending","title":"Fetch https://example.com","toolCallId":"toolu_fetch"},"type":"tool_call"},"title":"Fetch https://example.com"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"allow_always","name":"Yes, and don't ask again for example.com","optionId":"allow-with-updates"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"kind":"fetch","name":"WebFetch","rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"title":"Fetch https://example.com","toolCallId":"toolu_fetch"},"type":"tool_call"},"title":"Fetch https://example.com"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"The page is an example.","type":"text"},"type":"content"}],"rawOutput":"The page is an example.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"fetch","name":"WebSearch","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Web search","toolCallId":"toolu_search"}}}

View File

@@ -10,7 +10,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/new.ts"}],"name":"Write","rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"status":"pending","title":"Write new.ts","toolCallId":"toolu_write"},"type":"tool_call"},"title":"Write new.ts"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/new.ts"}],"name":"Write","rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"title":"Write new.ts","toolCallId":"toolu_write"},"type":"tool_call"},"title":"Write new.ts"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"File created successfully at: <cwd>/new.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}

View File

@@ -25,6 +25,10 @@
* - The `initialize` response also advertises
* `agentCapabilities.sessionCapabilities.systemPrompt`, which origin/main
* did not: the agent now applies the `systemPrompt` of session requests.
* - A SandboxNetworkAccess tool call is settled by a `tool_call_update` with
* only its final `status` once its permission request is answered. The
* callback runs nothing, so origin/main never settled the call (see
* {@link withoutSandboxNetworkSettlements}).
* - A `terminal_exit` has a `null` exit code where origin/main sent a code
* that the tool result does not say (see `CommandOutput.exitCode`): 1 for a
* failure that names no code or an interrupted command, 0 for a
@@ -126,6 +130,23 @@ function flatten(update: Json): Map<string, string> {
return flat;
}
/** `traffic` without the updates that settle a SandboxNetworkAccess tool call. */
function withoutSandboxNetworkSettlements(traffic: Recorded[]): Recorded[] {
const sandboxCalls = new Set<unknown>();
return traffic.filter((record) => {
const update = updateOf(record);
if (update?.sessionUpdate === "tool_call" && update.name === "SandboxNetworkAccess") {
sandboxCalls.add(update.toolCallId);
}
const settles =
update?.sessionUpdate === "tool_call_update" &&
sandboxCalls.has(update.toolCallId) &&
Object.keys(update).sort().join() === "sessionUpdate,status,toolCallId" &&
(update.status === "completed" || update.status === "failed");
return !settles;
});
}
/** A copy of `value` without `key`. */
function without(value: Json, key: string): Json {
const copy = { ...value };
@@ -232,7 +253,9 @@ export function compareWithBaseline(baseline: Recorded[], traffic: Recorded[]):
const update = updateOf(record);
return !(update?.sessionUpdate === "session_info_update" && Object.keys(update).length === 1);
});
const current = traffic.map((record) => withoutSessionCapability(record, "systemPrompt"));
const current = withoutSandboxNetworkSettlements(
traffic.map((record) => withoutSessionCapability(record, "systemPrompt")),
);
const violations: string[] = [];
const state = new Map<string, Map<string, string>>();
const summaryChunks = new Map<string, string>();

View File

@@ -153,7 +153,7 @@ afterEach(async () => {
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
execFileSpy.mockReset();
vi.unstubAllEnvs();
await rm(tempDir, { recursive: true, force: true });
await rm(tempDir, { recursive: true, force: true, maxRetries: 10 });
});
/** Connects a fresh router to a client, and returns the client end of the stream. */
@@ -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

@@ -1221,10 +1221,28 @@ describe("AsyncTaskRuntime", () => {
return { runtime, published };
};
it("never announces a task that a Monitor tool call started", async () => {
const updateKinds = (published: AcpSessionNotification[]) =>
published.map(({ update }) => update.sessionUpdate);
const MONITOR_SPAWN = {
sessionUpdate: "async_task_spawned",
taskType: "monitor",
showInTranscript: false,
canStop: false,
};
/**
* Scenario:
* 1. The SDK starts a `local_bash` task for a Monitor tool call.
* 2. The task reports progress, then its notification completes it.
* 3. The client asks to stop the task.
* Expected:
* - The spawn is a monitor with no transcript card and no Stop.
* - Progress and the completed state follow.
* - `canStop` and `claimStop` are false.
*/
it("publishes a task that a Monitor tool call started as a monitor", async () => {
const { runtime, published } = monitorRuntime();
// The SDK reports a Monitor task as a background shell.
await runtime.taskStarted({
task_id: "monitor",
task_type: "local_bash",
@@ -1233,22 +1251,38 @@ describe("AsyncTaskRuntime", () => {
tool_use_id: "monitor-tool",
});
await runtime.taskProgress({ task_id: "monitor", summary: "First line" });
await runtime.taskUpdated("monitor", { status: "paused" });
await runtime.backgroundTasksChanged([{ task_id: "monitor", task_type: "local_bash" }]);
expect(runtime.canStop("monitor")).toBe(false);
expect(runtime.claimStop("monitor")).toBe(false);
await runtime.taskStopped("monitor");
const stoppable = runtime.canStop("monitor");
const claimed = runtime.claimStop("monitor");
await runtime.taskNotification({
task_id: "monitor",
status: "completed",
tool_use_id: "monitor-tool",
});
await runtime.finishAll("stopped");
expect(published).toEqual([]);
expect(published[0]?.update).toMatchObject({
...MONITOR_SPAWN,
asyncTaskId: "monitor",
toolCallId: "monitor-tool",
});
expect(updateKinds(published)).toEqual([
"async_task_spawned",
"async_task_progress",
"async_task_state_update",
]);
expect(published[2]?.update).toMatchObject({ state: "completed" });
expect(stoppable).toBe(false);
expect(claimed).toBe(false);
});
it("never announces a held task whose tool call id names a Monitor", async () => {
/**
* Scenario:
* 1. A task starts without a tool call id and is held.
* 2. A later progress carries the Monitor tool call id.
* Expected:
* - The task is spawned as a monitor once the id arrives, not before.
* - It is not stoppable.
*/
it("spawns a held task as a monitor when its tool call id names a Monitor", async () => {
const { runtime, published } = monitorRuntime();
await runtime.taskStarted({
@@ -1257,18 +1291,26 @@ describe("AsyncTaskRuntime", () => {
description: "Watch the logs",
});
await runtime.taskProgress({ task_id: "monitor", summary: "First line" });
const spawnedBeforeId = published.length;
await runtime.taskProgress({
task_id: "monitor",
summary: "Second line",
tool_use_id: "monitor-tool",
});
await runtime.releaseHeld();
await runtime.taskNotification({ task_id: "monitor", status: "stopped" });
expect(published).toEqual([]);
expect(spawnedBeforeId).toBe(0);
expect(published[0]?.update).toMatchObject({ ...MONITOR_SPAWN, toolCallId: "monitor-tool" });
expect(runtime.canStop("monitor")).toBe(false);
});
it("never announces a held task that a Monitor ends", async () => {
/**
* Scenario:
* 1. A task starts without a tool call id and is held.
* 2. A notification with the Monitor tool call id completes it.
* Expected:
* - The task is spawned as a monitor, then reaches the completed state.
*/
it("spawns a held task that a Monitor ends as a monitor", async () => {
const { runtime, published } = monitorRuntime();
await runtime.taskStarted({ task_id: "monitor", task_type: "local_workflow" });
@@ -1278,23 +1320,47 @@ describe("AsyncTaskRuntime", () => {
tool_use_id: "monitor-tool",
});
expect(published).toEqual([]);
expect(updateKinds(published)).toEqual(["async_task_spawned", "async_task_state_update"]);
expect(published[0]?.update).toMatchObject(MONITOR_SPAWN);
expect(published[1]?.update).toMatchObject({ state: "completed" });
});
it("ignores a local_monitor task without a tool call", async () => {
/**
* Scenario:
* 1. The SDK starts a `local_monitor` task for a Bash tool call.
* 2. The SDK reports another `local_monitor` task only in the task level.
* 3. The turn ends (`releaseHeld`), which spawns the level-only task without a tool call.
* Expected:
* - Both tasks are spawned as monitors and neither is stoppable.
*/
it("spawns a local_monitor task as a monitor", async () => {
const { runtime, published } = monitorRuntime();
await runtime.backgroundTasksChanged([{ task_id: "level", task_type: "local_monitor" }]);
await runtime.taskStarted({ task_id: "level", task_type: "local_monitor" });
await runtime.taskStarted({ task_id: "started", task_type: "local_monitor" });
await runtime.taskProgress({ task_id: "started", summary: "line" });
await runtime.taskStarted({
task_id: "started",
task_type: "local_monitor",
tool_use_id: "bash-tool",
});
await runtime.backgroundTasksChanged([
{ task_id: "started", task_type: "local_monitor" },
{ task_id: "level", task_type: "local_monitor" },
]);
await runtime.releaseHeld();
await runtime.taskNotification({ task_id: "started", status: "completed" });
expect(published).toEqual([]);
const spawns = published.map(({ update }) => update);
expect(spawns).toHaveLength(2);
expect(spawns[0]).toMatchObject({ ...MONITOR_SPAWN, asyncTaskId: "started" });
expect(spawns[1]).toMatchObject({ ...MONITOR_SPAWN, asyncTaskId: "level" });
expect(runtime.canStop("started")).toBe(false);
expect(runtime.canStop("level")).toBe(false);
});
/**
* Scenario:
* 1. The SDK starts a background `local_bash` task for a Bash tool call.
* Expected:
* - The spawn is a shell that stays in the transcript and can be stopped.
*/
it("still announces a task of another tool", async () => {
const { runtime, published } = monitorRuntime();
@@ -1307,6 +1373,11 @@ describe("AsyncTaskRuntime", () => {
});
expect(published.map(({ update }) => update.sessionUpdate)).toEqual(["async_task_spawned"]);
expect(published[0]?.update).toMatchObject({
taskType: "shell",
showInTranscript: true,
canStop: true,
});
expect(runtime.canStop("shell")).toBe(true);
});
});

View File

@@ -22,6 +22,11 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
/** Shared spy for both `claude auth status --json` and `claude auth logout`.
* Tests set `execFileResults` per invoked subcommand. */
const execFileSpy = vi.hoisted(() => vi.fn());
/** `auth status` probes, wherever global options such as `--settings` put the subcommand. */
function isStatusProbe(args: string[]): boolean {
const auth = args.indexOf("auth");
return auth >= 0 && args[auth + 1] === "status";
}
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
@@ -409,7 +414,7 @@ describe("auth status over ACP", () => {
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
if (args[1] === "status") {
if (isStatusProbe(args)) {
const stdout = statusStdout;
const fails = statusFails;
const answer = () => {
@@ -451,7 +456,7 @@ describe("auth status over ACP", () => {
});
function statusCalls() {
return execFileSpy.mock.calls.filter((call) => (call[1] as string[])[1] === "status");
return execFileSpy.mock.calls.filter((call) => isStatusProbe(call[1] as string[]));
}
function updates() {
@@ -542,11 +547,11 @@ describe("auth status over ACP", () => {
env: { ANTHROPIC_BASE_URL: "https://example.invalid/override" },
});
expect(statusCalls().at(-1)?.[1]).toEqual([
"--settings",
JSON.stringify({ apiKeyHelper: "central proxy start" }),
"auth",
"status",
"--json",
"--settings",
JSON.stringify({ apiKeyHelper: "central proxy start" }),
]);
await promptOnce(sessionId);
await flushStatus();

View File

@@ -31,6 +31,7 @@ function testSession(overrides: Partial<TestSession> = {}): TestSession {
configOptions: [],
currentAgent: "default",
fastModeEnabled: false,
ultracodeEnabled: false,
input: { push: vi.fn() },
...overrides,
};
@@ -49,6 +50,7 @@ function testHost(
return freshSession;
}),
applyFastMode: vi.fn(async () => {}),
applyUltracode: vi.fn(async () => {}),
publishSessionState: vi.fn(async () => {}),
continuationMessage: vi.fn((sessionId, plan, promptUuid) => ({
type: "user" as const,
@@ -252,6 +254,25 @@ describe("continuePlanInFreshContext", () => {
expect(host.applyFastMode).toHaveBeenCalledWith(freshSession, false);
});
it("restores ultracode turned on before clearing context", async () => {
const turn: ClearContextTurn = {
settled: false,
promptUuid: "00000000-0000-4000-8000-000000000000",
};
const oldSession = testSession({ activeTurn: turn, ultracodeEnabled: true });
const freshSession = testSession({ ultracodeEnabled: false });
const host = testHost(oldSession, freshSession);
await continuePlanInFreshContext(
"public-session",
oldSession,
{ toolUseId: "tool-plan", plan: "Ship it", mode: "auto" },
host,
);
expect(host.applyUltracode).toHaveBeenCalledWith(freshSession, true);
});
it("does not publish or continue a restart cancelled while creating the fresh session", async () => {
const turn: ClearContextTurn = {
settled: false,

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

@@ -0,0 +1,298 @@
import { describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import {
mockSessionState,
successfulResultMessage,
userEcho,
wrapQuery,
} from "./session-doubles.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
const refused = () => {
throw new Error("Unexpected real SDK call");
};
return {
...actual,
query: vi.fn(refused),
getSessionMessages: vi.fn(async () => []),
getSubagentMessages: vi.fn(async () => []),
listSessions: vi.fn(async () => []),
getSessionInfo: vi.fn(async () => undefined),
deleteSession: vi.fn(refused),
forkSession: vi.fn(refused),
importSessionToStore: vi.fn(refused),
};
});
async function untilDone<T>(promise: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Prompt did not settle")), 3000);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
function createAgent() {
const client = { sessionUpdate: async () => {} } as unknown as AcpClient;
return new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
}
function usage(inputTokens: number, outputTokens: number) {
return {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
}
function modelUsage(inputTokens: number, outputTokens: number) {
return {
"claude-sonnet-4-6": {
inputTokens,
outputTokens,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
webSearchRequests: 0,
costUSD: 0,
contextWindow: 200000,
},
};
}
function stamp(uuid: string, fields: string) {
return {
...(fields !== "plural" ? { user_message_uuid: uuid } : {}),
...(fields !== "singular" ? { user_message_uuids: [uuid] } : {}),
};
}
const idle = { type: "system", subtype: "session_state_changed", state: "idle" };
describe("a steer folded into an autonomous cycle", () => {
it.each([
["singular", true],
["plural", true],
["both", true],
["singular", false],
["plural", false],
["both", false],
] as const)("settles from its own %s UUID stamp (echo: %s)", async (fields, echo) => {
const agent = createAgent();
const input = new Pushable<any>();
const echoed = Promise.withResolvers<void>();
const interrupted = Promise.withResolvers<void>();
const resume = Promise.withResolvers<void>();
const processed = Promise.withResolvers<void>();
const finish = Promise.withResolvers<void>();
async function* messages() {
const iter = input[Symbol.asyncIterator]();
const { value: original } = await iter.next();
yield userEcho(original);
echoed.resolve();
const { value: steered } = await iter.next();
yield successfulResultMessage({
origin: { kind: "human" },
...stamp(original.uuid, "both"),
queued_turn_count: 1,
stop_reason: "tool_use",
usage: usage(10, 5),
modelUsage: modelUsage(10, 5),
});
interrupted.resolve();
await resume.promise;
if (echo) yield userEcho(steered);
// Only the steer's UUID is named; it has no queued Turn of its own.
yield successfulResultMessage({
origin: { kind: "task-notification" },
...stamp(steered.uuid, fields),
queued_turn_count: 0,
stop_reason: "max_tokens",
usage: usage(20, 7),
modelUsage: modelUsage(30, 12),
});
yield idle;
processed.resolve();
// Stream shutdown must not supply the prompt's response.
await finish.promise;
}
const session = mockSessionState({ input, query: wrapQuery(messages()) });
agent.sessions["test-session"] = session;
let response: unknown;
const prompt = agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "original" }] })
.then((value) => (response = value));
try {
await untilDone(echoed.promise);
await expect(
agent.steer({
sessionId: "test-session",
prompt: [{ type: "text", text: "also handle this" }],
}),
).resolves.toEqual({ outcome: "injected" });
await untilDone(interrupted.promise);
expect(response).toBeUndefined();
resume.resolve();
await untilDone(processed.promise);
await untilDone(prompt);
const tokenCount = {
totalTokens: 42,
inputTokens: 30,
cachedInputTokens: 0,
cachedWriteTokens: 0,
outputTokens: 12,
reasoningOutputTokens: 0,
};
expect(response).toEqual({
stopReason: "max_tokens",
usage: {
inputTokens: 30,
outputTokens: 12,
cachedReadTokens: 0,
cachedWriteTokens: 0,
totalTokens: 42,
},
_meta: {
quota: {
token_count: tokenCount,
model_usage: [{ model: "claude-sonnet-4-6", token_count: tokenCount }],
},
},
});
expect(session.activeTurn).toBeNull();
expect(session.turnQueue).toHaveLength(0);
expect(session.owedTrailingIdles).toBe(0);
} finally {
resume.resolve();
finish.resolve();
await untilDone(session.consumer);
await untilDone(prompt);
}
});
it.each(["unrelated", "held", "settled", "plural-unrelated", "plural-empty"] as const)(
"does not attribute a result to a steer in the %s state",
async (state) => {
const agent = createAgent();
const input = new Pushable<any>();
const echoed = Promise.withResolvers<void>();
const processed = Promise.withResolvers<void>();
const resume = Promise.withResolvers<void>();
const finish = Promise.withResolvers<void>();
let response: unknown;
let owner: any;
let steerUuid = "";
const heldOutcome = { stopReason: "end_turn" };
async function* messages() {
const iter = input[Symbol.asyncIterator]();
const { value: original } = await iter.next();
yield userEcho(original);
echoed.resolve();
const { value: steered } = await iter.next();
steerUuid = steered.uuid;
const session = agent.sessions["test-session"];
owner = session.activeTurn;
// Constructed defensive states: these do not model normal settlement.
if (state === "held") {
owner.deferredSettle = heldOutcome;
owner.spawnedTaskIds = new Set(["still-running"]);
session.liveBackgroundTasks.set("still-running", { isSubagent: true });
} else if (state === "settled") {
owner.settled = true;
}
const unrelated = randomUUID();
yield successfulResultMessage({
origin: { kind: "task-notification" },
user_message_uuid: state === "unrelated" ? unrelated : steerUuid,
user_message_uuids:
state === "plural-empty" ? [] : [state.includes("unrelated") ? unrelated : steerUuid],
stop_reason: "refusal",
usage: usage(100, 50),
modelUsage: modelUsage(100, 50),
});
processed.resolve();
await resume.promise;
owner.settled = false;
owner.deferredSettle = undefined;
owner.spawnedTaskIds = undefined;
session.liveBackgroundTasks.clear();
yield successfulResultMessage({
origin: { kind: "human" },
...stamp(steerUuid, "both"),
usage: usage(10, 5),
});
yield idle; // the excluded autonomous result's trailer
yield idle; // the human result's trailer
await finish.promise;
}
const session = mockSessionState({ input, query: wrapQuery(messages()) });
agent.sessions["test-session"] = session;
const prompt = agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "original" }] })
.then((value) => (response = value));
try {
await untilDone(echoed.promise);
await expect(
agent.steer({
sessionId: "test-session",
prompt: [{ type: "text", text: "also handle this" }],
}),
).resolves.toEqual({ outcome: "injected" });
await untilDone(processed.promise);
expect(response).toBeUndefined();
expect(session.activeTurn).toBe(owner);
expect(session.accumulatedUsage).toEqual({
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
});
expect(session.accumulatedModelUsage).toEqual({});
expect(owner.steeredUuids).toEqual(new Set([steerUuid]));
expect(owner.steeredEchoes).toEqual(new Set([steerUuid]));
expect(owner.steeredSettle).toBeUndefined();
if (state === "held") expect(owner.deferredSettle).toBe(heldOutcome);
if (state === "settled") expect(owner.settled).toBe(true);
resume.resolve();
await untilDone(prompt);
expect(response).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 10,
outputTokens: 5,
cachedReadTokens: 0,
cachedWriteTokens: 0,
totalTokens: 15,
},
_meta: {
quota: {
token_count: {
totalTokens: 15,
inputTokens: 10,
cachedInputTokens: 0,
cachedWriteTokens: 0,
outputTokens: 5,
reasoningOutputTokens: 0,
},
model_usage: [],
},
},
});
} finally {
resume.resolve();
finish.resolve();
await untilDone(session.consumer);
await untilDone(prompt);
}
},
);
});

View File

@@ -31,6 +31,9 @@ export function makeMockQuery(overrides: Record<string, unknown> = {}) {
supportedCommands: async () => [],
mcpServerStatus: async () => [],
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
// No ultracode fields: session creation falls back to settings and the
// model's effort levels, as with a CLI that does not report them.
getSettings: async () => ({ applied: {} }),
close: () => {},
interrupt: async () => undefined,
[Symbol.asyncIterator]: async function* () {},

View File

@@ -97,6 +97,76 @@ describe("incomplete foreground tools", () => {
expect(agent.sessions[sessionId].toolCallParents?.size ?? 0).toBe(0);
});
it.each([
{
content: { type: "advisor_result", text: "Check the queue ordering." },
status: "completed",
text: "Check the queue ordering.",
},
{
content: { type: "advisor_redacted_result", encrypted_content: "opaque-advice" },
status: "completed",
text: "Advisor guidance applied server-side.",
},
{
content: { type: "advisor_tool_result_error", error_code: "overloaded" },
status: "failed",
text: "Advisor error: overloaded",
},
] as const)(
"settles an advisor call with a $content.type result",
async ({ content, status, text }) => {
const advisorId = "srvtoolu_advisor";
const { prompt, updates, logError, agent } = createTestSession(async function* (input) {
yield* echoNextPrompt(input);
yield advisorStart(advisorId);
yield advisorToolUse(advisorId);
yield advisorResult(advisorId, content);
expect(hasHookCallback(advisorId)).toBe(false);
yield successfulResultMessage();
});
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
const terminal = updates.filter(
(update) =>
update.toolCallId === advisorId &&
(update.status === "completed" || update.status === "failed"),
);
expect(terminal).toHaveLength(1);
expect(terminal[0]).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: advisorId,
status,
});
expect(JSON.stringify(terminal[0])).toContain(text);
expect(JSON.stringify(updates)).not.toContain("opaque-advice");
expect(agent.sessions[sessionId].emittedToolCalls.has(advisorId)).toBe(false);
expect(agent.sessions[sessionId].dispatchedToolCalls?.has(advisorId)).toBe(false);
expect(logError).not.toHaveBeenCalled();
},
);
it("fails a dispatched advisor call without a result", async () => {
const advisorId = "srvtoolu_advisor";
const { prompt, updates, agent } = createTestSession(async function* (input) {
yield* echoNextPrompt(input);
yield advisorStart(advisorId);
yield advisorToolUse(advisorId);
yield successfulResultMessage();
});
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
const terminal = updates.filter(
(update) =>
update.toolCallId === advisorId &&
(update.status === "completed" || update.status === "failed"),
);
expect(terminal).toHaveLength(1);
expect(terminal[0]).toMatchObject({ status: "failed" });
expect(agent.sessions[sessionId].emittedToolCalls.has(advisorId)).toBe(false);
expect(agent.sessions[sessionId].dispatchedToolCalls?.has(advisorId)).toBe(false);
});
it("allows a tool explicitly handed off to a background task", async () => {
const { prompt, updates } = createTestSession(backgroundToolMessages);
@@ -1123,6 +1193,44 @@ function toolUse(
};
}
function advisorStart(id: string) {
return {
...toolStart(id),
event: {
type: "content_block_start",
index: 0,
content_block: { type: "server_tool_use", id, name: "advisor", input: {} },
},
};
}
/** The complete assistant message that dispatches the advisor server tool. */
function advisorToolUse(id: string) {
return {
type: "assistant",
session_id: sessionId,
parent_tool_use_id: null,
message: {
role: "assistant",
usage: successfulResultMessage().usage,
content: [{ type: "server_tool_use", id, name: "advisor", input: {} }],
},
};
}
function advisorResult(id: string, content: unknown) {
return {
type: "assistant",
session_id: sessionId,
parent_tool_use_id: null,
message: {
role: "assistant",
usage: successfulResultMessage().usage,
content: [{ type: "advisor_tool_result", tool_use_id: id, content }],
},
};
}
function assistantText(text: string) {
return {
type: "assistant",

View File

@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { query, type ModelInfo, type Query } from "@anthropic-ai/claude-agent-sdk";
import { buildModelConfigOption, getAvailableModels } from "../session-model.js";
import {
applyAvailableModelsAllowlist,
buildModelConfigOption,
getAvailableModels,
} from "../session-model.js";
const OPUS: ModelInfo = {
value: "opus[1m]",
@@ -151,6 +155,68 @@ describe("versioned model display names", () => {
);
expect(state.availableModels.map((model) => model.name)).toEqual(["Opus (1M context)", "Opus"]);
});
it("distinguishes allowlisted aliases and pins that inherit the same SDK label", async () => {
const sdkModels: ModelInfo[] = [
{ value: "default", displayName: "Default (recommended)", description: "" },
{
value: "opus",
resolvedModel: "claude-opus-5-5",
displayName: "Opus 5.5",
description: "For complex work and everyday tasks",
},
{ value: "haiku", displayName: "Haiku", description: "Fast" },
];
const ids = ["claude-opus-5-5[1m]", "claude-opus-5-5", "opus[1m]", "opus", "haiku"];
const models = applyAvailableModelsAllowlist(sdkModels, ids);
const setModel = vi.fn();
const state = await getAvailableModels(
{ setModel } as unknown as Query,
models,
sdkModels,
{ getSettings: () => ({}) },
{ log: vi.fn(), error: vi.fn() },
false,
"model-presentation-test",
);
const expected = [
"Default (recommended)",
...ids.slice(0, 4).map((id) => `Opus 5.5 (${id})`),
"Haiku",
];
expect(state.availableModels.map((model) => model.name)).toEqual(expected);
expect(state.availableModels.map((model) => model.modelId)).toEqual(["default", ...ids]);
expect(state.currentModelId).toBe("default");
expect(state.availableModels.slice(1, 5).map((model) => model.description)).toEqual(
Array(4).fill(sdkModels[1].description),
);
const option = buildModelConfigOption(state, models, false);
expect(option).toMatchObject({ currentValue: "default" });
expect(option.type === "select" && option.options.map((model) => model.name)).toEqual(expected);
expect(sdkModels[1].displayName).toBe("Opus 5.5");
expect(setModel).not.toHaveBeenCalled();
});
it("distinguishes identical custom labels without changing their model IDs", async () => {
const models: ModelInfo[] = [
{ value: "provider-a", displayName: "My model", description: "First endpoint" },
{ value: "provider-b", displayName: "My model", description: "Second endpoint" },
];
const state = await getAvailableModels(
{} as Query,
models,
models,
{ getSettings: () => ({}) },
{ log: vi.fn(), error: vi.fn() },
false,
"model-presentation-test",
);
expect(state.availableModels).toEqual([
{ modelId: "provider-a", name: "My model (provider-a)", description: "First endpoint" },
{ modelId: "provider-b", name: "My model (provider-b)", description: "Second endpoint" },
]);
});
});
// Account-dependent model availability requires the same authenticated environment
@@ -172,19 +238,18 @@ it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
const q = query({ prompt: input(), options: { persistSession: false, abortController } });
try {
const { models } = await q.initializationResult();
const opusModels = models.filter(
(model) =>
model.value !== "default" &&
/\bopus\b/i.test(`${model.value} ${model.resolvedModel} ${model.displayName}`),
);
// Only alias rows ("opus", "opus[1m]"). The catalogue also lists pinned
// older versions such as "claude-opus-4-8"; those are separate models.
const opusModels = models.filter((model) => /^opus\b/i.test(model.value));
expect(opusModels).toHaveLength(1);
// CLI 2.1.283 dropped the "[1m]" / "(1M context)" spelling from the Opus
// row: Opus is natively 1M, so the row names the plain model.
expect(opusModels[0]).toMatchObject({
value: "opus",
displayName: "Opus",
displayName: expect.stringMatching(/^Opus\b/),
resolvedModel: expect.stringMatching(/^claude-opus-[\d-]+$/),
});
expect(opusModels[0].displayName).not.toMatch(/\b1m\b/i);
expect(opusModels[0].description).not.toMatch(/\b1m\b/i);
} finally {
clearTimeout(timeout);

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";
@@ -379,16 +381,133 @@ describe("settingsEffortForModel", () => {
});
describe("effortFlagSettings", () => {
it("carries ultracode only when settings request it", () => {
expect(effortFlagSettings("high", { ultracode: true })).toEqual({
it("carries ultracode only while the session has it on", () => {
expect(effortFlagSettings("high", true)).toEqual({
effortLevel: "high",
ultracode: true,
});
expect(effortFlagSettings("default", { ultracode: true })).toEqual({
expect(effortFlagSettings("default", true)).toEqual({
effortLevel: null,
ultracode: true,
});
expect(effortFlagSettings("high", { ultracode: false })).toEqual({ effortLevel: "high" });
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
expect(effortFlagSettings("high", false)).toEqual({ effortLevel: "high" });
expect(effortFlagSettings(undefined, false)).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

@@ -16,13 +16,18 @@ import {
listSessions,
} from "@anthropic-ai/claude-agent-sdk";
import { spawn } from "node:child_process";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
import { ClaudeAcpAgent, v1AgentApp, type AcpClient } from "../acp-agent.js";
import {
client as acpClient,
type InitializeResponse,
type SessionInfo,
} from "@agentclientprotocol/sdk/unstable";
import { encodeProjectPath } from "../session-index/project-dirs.js";
import {
archiveInsteadOfDelete,
archiveTo,
renameTo,
parseBatchGetSessionsRequest,
SessionIndexService,
writeCustomTitleSidecar,
} from "../session-index/service.js";
@@ -45,6 +50,11 @@ import {
} from "./session-doubles.js";
import { Pushable } from "../utils.js";
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return { ...actual, readdir: vi.fn(actual.readdir) };
});
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
return {
@@ -69,10 +79,15 @@ beforeEach(async () => {
configDir = fsSync.realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "session-index-cfg-")));
workspace = fsSync.realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "session-index-ws-")));
process.env.CLAUDE_CONFIG_DIR = configDir;
// Integration runs leave CLAUDE_CODE_EXECUTABLE unset, so initialize would run
// the real `claude auth status` in the background. It writes into configDir
// while afterEach removes it, and the rm fails with ENOTEMPTY.
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
vi.clearAllMocks();
});
afterEach(async () => {
vi.unstubAllEnvs();
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
await fs.rm(configDir, { recursive: true, force: true });
@@ -257,6 +272,7 @@ const airCapabilities = (response: { _meta?: Record<string, unknown> | null }) =
describe("sessionIndex negotiation", () => {
const indexCapabilities = [
"sessionIndex",
"sessionBatchGet",
"sessionArchive",
"sessionRename",
"sessionListSubscribe",
@@ -290,7 +306,7 @@ describe("sessionIndex negotiation", () => {
// Declaring archive or rename alone enables nothing.
const withoutIndex = await createAgent().agent.initialize({
protocolVersion: 1,
clientCapabilities: air("sessionArchive", "sessionRename"),
clientCapabilities: air("sessionArchive", "sessionRename", "sessionBatchGet"),
});
expect(airCapabilities(withoutIndex)).toEqual(baseline);
@@ -314,7 +330,7 @@ describe("sessionIndex negotiation", () => {
it("answers method-not-found to the new methods without the capability", async () => {
// Declaring sessionArchive or sessionRename without sessionIndex enables
// nothing either.
for (const capabilities of [[], ["sessionArchive", "sessionRename"]]) {
for (const capabilities of [[], ["sessionArchive", "sessionRename", "sessionBatchGet"]]) {
const { agent } = createAgent();
await initializeClient(agent, air(...capabilities));
const sessionId = randomUUID();
@@ -323,10 +339,423 @@ describe("sessionIndex negotiation", () => {
});
await expect(agent.archiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 });
await expect(agent.unarchiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 });
await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toMatchObject({
code: -32601,
});
}
});
});
describe("_session/batch_get", () => {
it("routes and validates the extension through the ACP connection", async () => {
let agent!: ClaudeAcpAgent;
const connection = acpClient({ name: "batch-get-test" }).connect(
v1AgentApp({ log: () => {}, error: () => {} }, (created) => {
agent = created;
}),
);
try {
const response = await connection.agent.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: air("sessionIndex"),
});
expect(airCapabilities(response)).toContain("sessionBatchGet");
expect(await connection.agent.request("_session/batch_get", { sessionIds: [] })).toEqual({
sessions: [],
});
await expect(
connection.agent.request("_session/batch_get", { sessionIds: [], cwd: "/repo" }),
).rejects.toMatchObject({ code: -32602 });
} finally {
connection.close();
await agent.dispose();
}
});
it("accepts only distinct, non-blank string ids and no other fields", () => {
for (const params of [
null,
[],
{},
{ sessionIds: null },
{ sessionIds: "id" },
{ sessionIds: [""] },
{ sessionIds: [" "] },
{ sessionIds: [1] },
{ sessionIds: ["id", "id"] },
{ sessionIds: [], cwd: null },
]) {
expect(() => parseBatchGetSessionsRequest(params)).toThrow(
expect.objectContaining({ code: -32602 }),
);
}
const sessionIds = Array.from({ length: 1000 }, (_, i) => `id-${i}`);
expect(parseBatchGetSessionsRequest({ sessionIds })).toEqual({ sessionIds });
// Ids are exact, not trimmed or otherwise normalized.
expect(parseBatchGetSessionsRequest({ sessionIds: [" id ", "id"] })).toEqual({
sessionIds: [" id ", "id"],
});
});
it("returns empty results without touching the store", async () => {
const { agent } = await indexAgent();
const index = (agent as any).sessionIndex.service.index;
const lookup = vi.spyOn(index, "getSessions").mockRejectedValue(new Error("store down"));
expect(await agent.batchGetSessions({ sessionIds: [] })).toEqual({
sessions: [],
});
expect(lookup).not.toHaveBeenCalled();
await agent.dispose();
});
it("returns no sessions when the store is absent", async () => {
const { agent } = await indexAgent();
const sessionIds = [randomUUID(), "retained-id"];
expect(await agent.batchGetSessions({ sessionIds })).toEqual({
sessions: [],
});
await agent.dispose();
});
it("finds ids older than the recent 50 without listing or reading unrelated history", async () => {
const base = Date.parse("2026-03-01T00:00:00Z");
const ids: string[] = [];
for (let i = 0; i < 55; i++) {
ids.push(
(
await writeTranscript({
cwd: path.join(workspace, `project-${i % 2}`),
lastMessageAt: base - i * 60_000,
})
).id,
);
}
const recent = await indexAgent();
const page = await recent.agent.listSessions({});
expect(page.sessions).toHaveLength(50);
expect(page.sessions.map((s) => s.sessionId)).not.toContain(ids[54]);
await recent.agent.dispose();
const { agent } = await indexAgent();
const index = (agent as any).sessionIndex.service.index;
const list = vi.spyOn(index, "list").mockRejectedValue(new Error("must not list"));
const names = vi.spyOn(index.listings, "namesOfAll").mockRejectedValue(new Error("history"));
const wanted = [ids[54]!, ids[53]!, ids[0]!];
const readdir = vi.mocked(fs.readdir);
readdir.mockClear();
try {
const result = await agent.batchGetSessions({ sessionIds: wanted });
expect(result.sessions.map((s) => s.sessionId)).toEqual(wanted);
expect(transcriptsRead(agent)).toBe(wanted.length);
const root = path.join(configDir, "projects");
const storeReads = readdir.mock.calls
.map(([dir]) => String(dir))
.filter((dir) => dir === root || dir.startsWith(root + path.sep));
expect(storeReads).toEqual([root]);
expect(list).not.toHaveBeenCalled();
expect(names).not.toHaveBeenCalled();
expect(listSessions).not.toHaveBeenCalled();
expect(getSessionInfo).not.toHaveBeenCalled();
} finally {
readdir.mockClear();
await agent.dispose();
}
});
it("keeps request order and copy selection across lookup chunks", async () => {
const base = Date.parse("2026-03-01T00:00:00Z");
const first = await writeTranscript({ lastMessageAt: base });
const last = await writeTranscript({ lastMessageAt: base - 60_000 });
const copyId = randomUUID();
await writeTranscript({ sessionId: copyId, prompt: "Old copy", lastMessageAt: base - 120_000 });
await writeTranscript({
sessionId: copyId,
cwd: path.join(workspace, "other"),
prompt: "New copy",
lastMessageAt: base - 30_000,
});
// 250 ids: three lookup chunks of 100, with found ids in the first, second and third.
const sessionIds: string[] = Array.from({ length: 250 }, () => randomUUID());
sessionIds[0] = last.id;
sessionIds[120] = copyId;
sessionIds[249] = first.id;
const { agent } = await indexAgent();
try {
const result = await agent.batchGetSessions({ sessionIds });
expect(result.sessions.map((s) => s.sessionId)).toEqual([last.id, copyId, first.id]);
expect(result.sessions[1]!.title).toBe("New copy");
} finally {
await agent.dispose();
}
});
it("recovers a cold batch row from a sibling and keeps it in the next list", async () => {
const base = Date.parse("2026-04-01T00:00:00Z");
const other = path.join(workspace, "other");
const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base });
const fillers: string[] = [];
for (let i = 1; i <= 16; i++) {
fillers.push((await writeTranscript({ lastMessageAt: base - i * 1000 })).id);
}
// This sibling is older than the transcripts needed to fill the page.
await writeTranscript({ cwd: other, lastMessageAt: base - 3600_000 });
const { agent } = await indexAgent();
const index = (agent as any).sessionIndex.service.index;
const names = vi.spyOn(index.listings, "namesOfAll");
const now = vi.spyOn(Date, "now").mockReturnValue(Date.now());
try {
const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] });
expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]);
expect(transcriptsRead(agent)).toBe(2);
expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]);
names.mockRestore();
// Use the same index without changing files or letting the negative cache expire.
const page = await agent.listSessions({ _meta: listMeta({ limit: 2 }) });
expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([
[noCwd.id, other],
[fillers[0], workspace],
]);
expect(page.sessions[0]).toEqual(result.sessions[0]);
} finally {
names.mockRestore();
now.mockRestore();
await agent.dispose();
}
});
it("bounds sibling cwd probes and retries a negative result after its cache expires", async () => {
const base = Date.parse("2026-04-01T00:00:00Z");
const other = path.join(workspace, "other");
const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base });
const siblings: { id: string; file: string }[] = [];
for (let i = 1; i <= 70; i++) {
siblings.push(
await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base - i * 1000 }),
);
}
await writeTranscript({ lastMessageAt: base });
const { agent } = await indexAgent();
const index = (agent as any).sessionIndex.service.index;
const names = vi.spyOn(index.listings, "namesOfAll");
const at = Date.now();
const now = vi.spyOn(Date, "now").mockReturnValue(at);
try {
expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] });
expect(transcriptsRead(agent)).toBe(1 + 64);
expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]);
// Rewriting an existing sibling does not change the directory mtime.
await writeTranscript({ sessionId: siblings[0]!.id, cwd: other, lastMessageAt: base });
expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] });
expect(transcriptsRead(agent)).toBe(1 + 64);
expect(names).toHaveBeenCalledTimes(1);
now.mockReturnValue(at + 60_001);
const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] });
expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]);
expect(transcriptsRead(agent)).toBe(1 + 64 + 1);
expect(names.mock.calls).toEqual([
[[path.dirname(noCwd.file)]],
[[path.dirname(noCwd.file)]],
]);
} finally {
names.mockRestore();
now.mockRestore();
await agent.dispose();
}
});
it("returns the same unscoped list rows for archived, worktree and duplicate copies in request order", async () => {
const repo = path.join(workspace, "repo");
const worktree = path.join(workspace, "worktree");
await linkWorktrees(repo, worktree);
const archivedId = randomUUID();
const archived = await writeTranscript({
sessionId: archivedId,
cwd: repo,
trailer: titleRecords(archivedId, "[archived] Done"),
});
const worktreeId = randomUUID();
await writeTranscript({
sessionId: worktreeId,
cwd: worktree,
trailer: [worktreeState(worktreeId, { path: worktree })],
});
const copyId = randomUUID();
await writeTranscript({ sessionId: copyId, cwd: repo, prompt: "Old copy" });
await writeTranscript({
sessionId: copyId,
cwd: worktree,
prompt: "New copy",
mtimeMs: Date.parse("2026-02-01"),
});
const { agent } = await indexAgent();
const requested = [worktreeId, archived.id, copyId];
const result = await agent.batchGetSessions({ sessionIds: requested });
const read = transcriptsRead(agent);
const listed = await agent.listSessions({ _meta: listMeta({ archived: "all" }) });
expect(transcriptsRead(agent)).toBe(read);
expect(result.sessions).toEqual(
requested.map((id) => listed.sessions.find((s) => s.sessionId === id)),
);
expect(airRow(result.sessions[1]!).archived).toBe(true);
expect(agentWorktreeOfRow(result.sessions[0])).toBe(true);
expect(result.sessions[2]).toMatchObject({ title: "New copy", cwd: worktree });
await agent.dispose();
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"returns readable sessions in request order and omits unavailable ids",
async () => {
const first = await writeTranscript({});
const found = await writeTranscript({});
const unreadable = await writeTranscript({});
await fs.chmod(unreadable.file, 0);
const absent = randomUUID();
const { agent } = await indexAgent();
try {
const result = await agent.batchGetSessions({
sessionIds: [absent, first.id, unreadable.id, found.id],
});
expect(result.sessions.map((s) => s.sessionId)).toEqual([first.id, found.id]);
expect(Object.keys(result)).toEqual(["sessions"]);
} finally {
await fs.chmod(unreadable.file, 0o600);
await agent.dispose();
}
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"uses a readable copy when another copy cannot be read or stat'ed, as the unscoped list does",
async () => {
const sessionId = randomUUID();
await writeTranscript({ sessionId, prompt: "Readable copy" });
const hiddenProject = path.join(workspace, "inaccessible");
const unreadable = await writeTranscript({
sessionId,
cwd: hiddenProject,
prompt: "Unreadable newer copy",
mtimeMs: Date.parse("2026-02-01"),
});
const { agent } = await indexAgent();
try {
// A failed metadata read, then a failed stat of the same copy.
for (const target of [unreadable.file, path.dirname(unreadable.file)]) {
await fs.chmod(target, 0);
const result = await agent.batchGetSessions({ sessionIds: [sessionId] });
const listed = await agent.listSessions({});
expect(result).toEqual({ sessions: listed.sessions });
expect(result.sessions[0]?.title).toBe("Readable copy");
await fs.chmod(target, 0o700);
}
} finally {
await fs.chmod(path.dirname(unreadable.file), 0o700);
await fs.chmod(unreadable.file, 0o600);
await agent.dispose();
}
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"omits failed stats, dangling links and non-file transcripts",
async () => {
const unreadable = await writeTranscript({ cwd: path.join(workspace, "inaccessible") });
const dangling = await writeTranscript({});
await fs.rm(dangling.file);
await fs.symlink(path.join(workspace, "not-there"), dangling.file);
const nonFile = await writeTranscript({});
await fs.rm(nonFile.file);
await fs.mkdir(nonFile.file);
const { agent } = await indexAgent();
const projectDir = path.dirname(unreadable.file);
await fs.chmod(projectDir, 0);
try {
const result = await agent.batchGetSessions({
sessionIds: [unreadable.id, dangling.id, nonFile.id],
});
expect(result).toEqual({ sessions: [] });
} finally {
await fs.chmod(projectDir, 0o700);
await agent.dispose();
}
},
);
it("matches UUIDs exactly without case or whitespace normalization", async () => {
const sessionId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
await writeTranscript({ sessionId });
const { agent } = await indexAgent();
const result = await agent.batchGetSessions({
sessionIds: [sessionId.toUpperCase(), ` ${sessionId} `, sessionId],
});
expect(result.sessions.map((row) => row.sessionId)).toEqual([sessionId]);
await agent.dispose();
});
it("omits empty, malformed, sidecar-only and unresolved-cwd records", async () => {
const empty = await writeTranscript({});
await fs.writeFile(empty.file, "");
const malformed = await writeTranscript({});
await fs.writeFile(malformed.file, "not json\n");
const sidecar = randomUUID();
const sidecarDir = path.join(configDir, "projects", encodeProjectPath(workspace), sidecar);
await fs.mkdir(sidecarDir, { recursive: true });
await fs.writeFile(path.join(sidecarDir, "custom-title.json"), '{"customTitle":"Saved"}');
const unresolved = await writeTranscript({
cwd: path.join(workspace, "unknown"),
recordCwd: null,
});
const { agent } = await indexAgent();
const ids = [empty.id, malformed.id, sidecar, unresolved.id];
const result = await agent.batchGetSessions({ sessionIds: ids });
expect(result).toEqual({ sessions: [] });
await agent.dispose();
});
it
.skipIf(process.platform === "win32" || process.getuid?.() === 0)
.each(["unreadable", "unreadable after a cached list", "a file", "a dangling symlink"])(
"rejects a projects root that is %s",
async (kind) => {
const found = await writeTranscript({});
const root = path.join(configDir, "projects");
const { agent } = await indexAgent();
try {
if (kind === "unreadable after a cached list") {
const old = new Date(Date.now() - 60_000);
await fs.utimes(root, old, old);
await agent.listSessions({});
}
if (kind.startsWith("unreadable")) {
await fs.chmod(root, 0);
} else {
await fs.rm(root, { recursive: true });
if (kind === "a file") await fs.writeFile(root, "not a store");
else await fs.symlink(path.join(workspace, "missing-store"), root);
}
for (const sessionId of [found.id, "retained-id"]) {
await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toThrow(
"Session store could not be read",
);
}
} finally {
if (kind.startsWith("unreadable")) await fs.chmod(root, 0o700);
await agent.dispose();
}
},
);
it("rejects under ACP v2 even when sessionIndex was declared", async () => {
const { agent } = createAgent({ v2: true });
await initializeClient(agent, air("sessionIndex"));
await expect(agent.batchGetSessions({ sessionIds: [] })).rejects.toMatchObject({
code: -32601,
});
await agent.dispose();
});
});
describe("session/list of a sessionIndex client", () => {
const base = Date.parse("2026-03-01T00:00:00Z");
@@ -3528,7 +3957,8 @@ describe("paths that share a project directory", () => {
.filter((s) => s.sessionId === mine.id);
const removed = () =>
changesFor(notifications, fromDotted.subscriptionId).flatMap((c) => c.removed);
expect(await waitFor(() => added().length > 0 && removed().length > 0)).toBe(true);
// Wait for mine.id itself: the other path's session can be removed first.
expect(await waitFor(() => added().length > 0 && removed().includes(mine.id))).toBe(true);
expect(added().map((s) => [s.sessionId, s.cwd])).toEqual([[mine.id, dashed]]);
// It may also name the other path's session, never listed here.
expect(removed()).toContain(mine.id);

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

@@ -0,0 +1,292 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import {
createUltracodeConfigOption,
modelSupportsUltracode,
readUltracodeSettings,
resolveUltracodeEnabled,
ULTRACODE_CONFIG_ID,
ULTRACODE_OFF,
ULTRACODE_ON,
ultracodeSupported,
ultracodeWorkflowsEnabled,
} from "../session-ultracode.js";
import { makeMockQuery } from "./helpers.js";
const { querySpy } = vi.hoisted(() => ({ querySpy: vi.fn() }));
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<any>("@anthropic-ai/claude-agent-sdk");
return { ...actual, query: querySpy };
});
const OPUS: ModelInfo = {
value: "opus",
displayName: "Opus",
description: "",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
};
const FABLE: ModelInfo = {
value: "fable",
displayName: "Fable",
description: "",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
};
// Opus 4.6 and Haiku 4.5 have no `xhigh` effort, so the CLI has no ultracode
// for them either.
const OLD_OPUS: ModelInfo = {
value: "claude-opus-4-6",
displayName: "Opus 4.6",
description: "",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
};
function getSettingsQuery(getSettings: unknown): Query {
return { getSettings } as unknown as Query;
}
describe("ultracode helpers", () => {
it("builds a boolean option or an on/off select", () => {
expect(createUltracodeConfigOption(true, true)).toEqual({
id: ULTRACODE_CONFIG_ID,
name: "Ultracode",
description: "Dynamic workflows on every task, at any effort level",
category: "model_config",
type: "boolean",
currentValue: true,
});
expect(createUltracodeConfigOption(false, false)).toMatchObject({
type: "select",
currentValue: ULTRACODE_OFF,
options: [
{ value: ULTRACODE_ON, name: "On" },
{ value: ULTRACODE_OFF, name: "Off" },
],
});
});
it("accepts booleans and the select strings", () => {
const request = (value: unknown) =>
({ sessionId: "s", configId: ULTRACODE_CONFIG_ID, value }) as never;
expect(resolveUltracodeEnabled(request(true))).toBe(true);
expect(resolveUltracodeEnabled(request(ULTRACODE_OFF))).toBe(false);
expect(() => resolveUltracodeEnabled(request("maybe"))).toThrow(
"Invalid value for config option ultracode: maybe",
);
});
it("treats a model as supporting ultracode when it supports xhigh effort", () => {
expect(modelSupportsUltracode(OPUS)).toBe(true);
expect(modelSupportsUltracode(OLD_OPUS)).toBe(false);
expect(modelSupportsUltracode({ ...OPUS, supportsEffort: false })).toBe(false);
expect(modelSupportsUltracode(undefined)).toBe(false);
});
it("infers the workflows gate only when the start model can tell", () => {
expect(ultracodeWorkflowsEnabled({ available: true }, OLD_OPUS)).toBe(true);
expect(ultracodeWorkflowsEnabled({ available: false }, OPUS)).toBe(false);
// Opus 4.6 is unavailable regardless of workflows.
expect(ultracodeWorkflowsEnabled({ available: false }, OLD_OPUS)).toBeUndefined();
expect(ultracodeWorkflowsEnabled(undefined, OPUS)).toBeUndefined();
expect(ultracodeSupported(undefined, OPUS)).toBe(true);
expect(ultracodeSupported(false, OPUS)).toBe(false);
expect(ultracodeSupported(true, OLD_OPUS)).toBe(false);
});
it("reads the CLI's ultracode report and tolerates CLIs without one", async () => {
await expect(
readUltracodeSettings(
getSettingsQuery(async () => ({
applied: { ultracodeAvailable: true, ultracodeRequested: false },
})),
),
).resolves.toEqual({ available: true, requested: false });
await expect(
readUltracodeSettings(getSettingsQuery(async () => ({ applied: {} }))),
).resolves.toEqual({ available: undefined, requested: undefined });
await expect(readUltracodeSettings(getSettingsQuery(undefined))).resolves.toBeUndefined();
await expect(
readUltracodeSettings(
getSettingsQuery(async () => {
throw new Error("unsupported control request");
}),
),
).resolves.toBeUndefined();
});
});
describe("ultracode session config option", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-ultracode-"));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
querySpy.mockReset();
vi.resetModules();
});
afterEach(async () => {
if (originalClaudeConfigDir) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
async function createSession(opts: {
models?: ModelInfo[];
getSettings?: () => Promise<unknown>;
settings?: Record<string, unknown>;
clientCapabilities?: ClientCapabilities;
}) {
if (opts.settings) {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify(opts.settings),
);
}
const applyFlagSettings = vi.fn();
querySpy.mockReturnValue(
makeMockQuery({
initializationResult: async () => ({ models: opts.models ?? [OPUS, FABLE, OLD_OPUS] }),
applyFlagSettings,
...(opts.getSettings ? { getSettings: opts.getSettings } : {}),
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent({
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient);
(agent as any).clientCapabilities = opts.clientCapabilities ?? {
session: { configOptions: { boolean: {} } },
};
const response = await (agent as any).createSession({
cwd: tempDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const sessionId: string = response.sessionId;
const ultracodeOption = () =>
agent.sessions[sessionId].configOptions.find((o) => o.id === ULTRACODE_CONFIG_ID);
return { agent, sessionId, applyFlagSettings, ultracodeOption };
}
it("seeds the option from the CLI's report", async () => {
const { ultracodeOption, agent, sessionId } = await createSession({
getSettings: async () => ({
applied: { ultracodeAvailable: true, ultracodeRequested: true },
}),
});
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(true, true));
expect(agent.sessions[sessionId].ultracodeEnabled).toBe(true);
});
it("hides the option while dynamic workflows are off, across model switches", async () => {
const { agent, sessionId, ultracodeOption } = await createSession({
getSettings: async () => ({
applied: { ultracodeAvailable: false, ultracodeRequested: false },
}),
});
expect(ultracodeOption()).toBeUndefined();
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
expect(ultracodeOption()).toBeUndefined();
});
it("follows the model's support and keeps the toggle across switches", async () => {
const { agent, sessionId, applyFlagSettings, ultracodeOption } = await createSession({
clientCapabilities: {},
});
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(false, false));
await agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
value: ULTRACODE_ON,
});
expect(applyFlagSettings).toHaveBeenLastCalledWith({ ultracode: true });
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "claude-opus-4-6" });
expect(ultracodeOption()).toBeUndefined();
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(true, false));
});
it("keeps a toggled-on ultracode when effort changes, and not a toggled-off one", async () => {
// An effortLevel apply without an ultracode key turns ultracode off, so
// the effort picker must carry the session's toggle.
const { agent, sessionId, applyFlagSettings } = await createSession({
settings: { ultracode: true },
});
await agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
type: "boolean",
value: false,
});
expect(applyFlagSettings).toHaveBeenLastCalledWith({ ultracode: false });
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "high" });
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "high" });
await agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
type: "boolean",
value: true,
});
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "low" });
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "low", ultracode: true });
});
it("keeps ultracode on through the effort apply that follows a model switch", async () => {
// recommendedValue clients apply the new model's effort on every switch.
const { agent, sessionId, applyFlagSettings } = await createSession({
clientCapabilities: {
session: { configOptions: { boolean: {} } },
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
},
});
await agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
type: "boolean",
value: true,
});
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "medium", ultracode: true });
});
it("does not change session state when the SDK rejects the flag", async () => {
const { agent, sessionId, applyFlagSettings, ultracodeOption } = await createSession({});
applyFlagSettings.mockRejectedValueOnce(new Error("nope"));
await expect(
agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
type: "boolean",
value: true,
}),
).rejects.toThrow("nope");
expect(agent.sessions[sessionId].ultracodeEnabled).toBe(false);
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(false, true));
});
});

View File

@@ -319,13 +319,16 @@ export class AcpToolCallRenderer {
const facts = this.facts(toolUse, options.cwd);
if (!this.capabilities.air.client) {
// The upstream shape: the whole tool call again. A v2 client also gets
// the exact preview patch, which the tool call does not show.
// the exact preview patch, which the tool call does not show, but not the
// status: its subject is an update of the call, which was emitted before
// the request (`ensureToolCallEmitted`), and a `pending` there would move
// a call that already runs back.
const content = options.previewContent ?? this.toolUseContent(toolUse.id, facts);
const locations = [...(facts.locations ?? []), ...(options.extraLocations ?? [])];
return {
toolCallId: toolUse.id,
name: toolUse.name,
status: "pending",
...(this.capabilities.v2 ? {} : { status: "pending" as const }),
rawInput: toolUse.input,
title: options.title ?? facts.title,
kind: facts.kind,

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 () => {

View File

@@ -14,8 +14,9 @@ export default defineConfig({
// tests off the machine's real CLI and Claude config, and stops a run
// from outliving its test (it wrote into temp config dirs being deleted).
env: usesRealCli ? {} : { CLAUDE_CODE_EXECUTABLE: "/usr/bin/false" },
// Real-CLI runs may want a real gateway listing.
setupFiles: usesRealCli ? [] : ["src/tests/setup-no-network.ts"],
// Applies to real-CLI runs too: the gateway listing runs in the test process,
// and tests point `providers/set` at fake URLs like gateway.example.com.
setupFiles: ["src/tests/setup-no-network.ts"],
include: [
"src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}",
// Release tooling lives in scripts/ rather than src/ so it stays outside tsc's