Commit Graph

643 Commits

Author SHA1 Message Date
Ben Brandt
a4a8534c23 feat: serve session/set_title on ACP v1 and v2 (#1585)
Implements the [`session/set_title`
RFD](https://agentclientprotocol.com/rfds/session-set-title) on ACP v1
and v2.

## What clients get

- v1 advertises `agentCapabilities.sessionCapabilities.title: {}` and v2
advertises `capabilities.session.title: {}`. Both serve
`session/set_title` for every client.
- The session does not need to be loaded. A session that runs on this
connection is renamed through its CLI (`rename_session`). Any other
session gets the `custom-title` and `agent-name` records in every copy
of its transcript, plus the `custom-title.json` sidecar, as the CLI's
`/rename` writes them.
- The title is normalized the way `_session/rename` normalizes it:
whitespace collapsed, cut to the CLI's 200 characters. The response
carries the stored title, as that client's `session/list` shows it. On
v1 an AIR client gets it without the `[archived] ` prefix. An archived
session stays archived.
- A loaded session publishes `session_info_update { title }`. A title
generation in flight finishes first, and no generated title replaces the
client's.
- Errors: a title that is empty after normalization returns `-32602`. A
session that is not loaded and has no transcript returns `-32002`. On
v2, a subagent session is refused, as close and delete refuse it.

## Structure

- The transcript writer behind `_session/rename` lived in
`src/v1/session-index/`, which v2 cannot import. Everything there except
`connection.ts` moves to the shared `src/session-index/` with no change
beyond import paths. `connection.ts` becomes `src/v1/session-index.ts`.
- `src/session-index/rename.ts` holds the rename logic that used to be
in `SessionIndexConnection.rename`/`retitle`. `_session/rename` and
`session/set_title` on both versions call it.
- Clients without `sessionIndex` (and every v2 client) use a
`SessionIndexService` with no list and no watcher (`transcriptWriter`).
Their `session/delete` now runs inside that service's per-session
`exclusive()`, so a delete cannot race a rename that is still appending
records.
- If a restart or resume starts a new query of a v2 session while a
rename runs, v2 renames through the new query too. Its CLI could
otherwise hold the old title and write it back.

This doesn't use the SDK's top-level `renameSession()`. It writes only a
`custom-title` record to the first copy of the transcript it finds, with
no `agent-name` and no sidecar, so a session renamed earlier through
`/rename` would keep its old name in the CLI and in AIR.

## Tests

- v1 (`session-index.test.ts`): a stored session for a client without
the session index; an archived session for three clients (AIR with
`sessionIndex`, AIR without it, a client that is not AIR); unknown
session and empty title.
- v2 (`acp-v2.test.ts`): an active session through its CLI with the
published update; a stored session's records; the rename that a resume
overtakes; unknown session and empty title.
- Golden files: only the `initialize` capability line changes.
`compare.ts` now allows the `title` capability against the origin/main
baseline.
- Not run: `npm run test:integration` against the real CLI. The
delete/rename ordering has no test, since it is a timing race.
2026-11-10 18:28:46 +00:00
Ben Brandt
6bc2e058cb feat: serve session/status on v1 and v2 (#1584)
Implements the unstable [Session status
RFD](https://agentclientprotocol.com/rfds/session-status) on both
agents, and advertises `status: {}` in the v1 `sessionCapabilities` and
the v2 `capabilities.session`.

## What it answers

| Session | v1 | v2 |
| --- | --- | --- |
| Held by this connection | `open` | `open` with `state` |
| Not held, non-empty transcript stored | `saved` | `saved` |
| Not held, no transcript | `-32002` with `data.sessionId` | same |
| Subagent session | `-32002` | `-32602`, as resume, close, fork and
delete of one |

- The request starts no query, sends no update, and waits for no resume,
restart or close.
- On v2, `state` is the foreground state as a `state_update` snapshot
holds it: with the session's `background` list and without a stop
reason. `V2Background.snapshot` builds it without marking the list as
reported, so a status request does not change which later
`state_update`s go out.
- `saved` or unknown is decided in the new shared
`src/session-status.ts`: a non-empty `<sessionId>.jsonl` in any project
directory. The request names no `cwd`, so a resume with another `cwd`
can still fail with `-32002`. A directory that cannot be read for
another reason than being absent fails the request and does not answer
`-32002`.

## Behaviour to review

- **A v1 session whose query ended reads `open`.** The connection still
holds it, and a `session/load` with the same params returns it as it is,
so `saved` would point the client at a resume that revives nothing. Its
prompts fail with the "session ended" error. v2 drops such a session and
answers from its transcript.
- **A session that was never prompted is unknown once it is closed.**
Claude Code writes the transcript at the first prompt, and a resume of
it fails with `-32002` too.
- **A subagent session does not advertise `status`,** so v2 refuses the
request like its other requests, and v1 does not know the id.
- **`open` is per connection.** Each connection has its own agent and
its own Claude Code processes.

## Not done

- The lookup does one `readdir` and one `stat` per project directory for
each probe of a session that is not held. Sharing the v1 session index's
cached scan is a follow-up.
- Not run against the real CLI (`npm run test:integration`).

## Other changes

- `isSessionId` and `projectsRoot` moved from
`src/v1/session-index/project-dirs.ts` to `src/paths.ts`, so the shared
module can use them.
- The 40 v2 and 2 AIR scenario golden files gain the capability in
`initialize`, and the origin/main comparison allows it, next to
`systemPrompt` and `auth.status`.
- The README lists `session/status` in the v2 surface.
2026-11-10 18:11:39 +00:00
Ben Brandt
99cc1f3245 fix: report Claude Code's API error message as a notice in v2 (#1582)
## Problem

When an API request fails, Claude Code writes its own assistant message
into the conversation, e.g. "API Error: Connection lost mid-response.
The response above may be incomplete." The SDK marks it with `error` on
a `<synthetic>` message.

The v2 agent forwarded that message as an ordinary
`agent_message_chunk`, so clients showed it as Claude's own prose. Right
after a thinking block it reads as if Claude wrote it, and a client
cannot tell it apart without matching the string.

## Change

The v2 agent no longer sends that message as Claude's text:

- When the turn fails, the idle's `error` already carries the failure,
so nothing else is sent.
- Otherwise the text goes out as a `notice` with severity `error`, once,
before the turn's idle. A result that repeats the same text is not shown
again.

Claude Code's sign-in message is handled as before.

## Not changed

- v1 keeps its current behaviour: clients with the session-failure
extension already drop this text, and others keep the transcript line.
- `session/load` replay still shows these messages as text.
`getSessionMessages` strips the `error` marker, so replay cannot
identify them without matching prose.

## Testing

- New v2 cases: a notice when the turn ends normally, one notice when
the result repeats the text, and no notice when the idle fails. They
fail without the change.
- `npm run check` and `npm run test:run` pass.
2026-11-10 18:51:11 +01:00
Mark Tkachenko
bce309f482 refactor: move the v1 client connection out of acp-agent.ts (#1576)
Moves `ClientConnection` and `ChangedMetaClient` verbatim from
`src/v1/acp-agent.ts` into `src/v1/client-connection.ts`. No behaviour
change.

- Both classes gain `export`; nothing else in them changes.
- `client-connection.ts` imports nothing from `acp-agent.ts`.
- The `acp-client.ts` comment that names where `ClientConnection` lives
now points to the new file.
- Emitted JS differs from `main` only in `dist/v1/acp-agent.js` (the
import and the two classes) plus the new `dist/v1/client-connection.js`.

Review with `git diff --color-moved` to see the move.
2026-11-10 17:54:17 +01:00
Ben Brandt
7d95d30ec3 fix: distinguish Ultra from Max in auth status (#1574)
## Summary

- Read the optional `planTier` field from the existing `claude auth
status --json` probe. Display Max subscriptions with `max_20x` as
**Claude Ultra**, while `max_5x` remains **Claude Max**.
- Fall back to the existing subscription label when the tier is missing,
unknown, or malformed. Preserve auth-source precedence and raw plan
metadata.
- Keep the CLI-derived label when a session SDK account read reports the
same identity and plan without a tier. A fresh CLI probe can still
replace the label, including when its tier is absent.
- Cover ACP v1 and v2 notifications and responses, and document the
display behavior.

The SDK AccountInfo omits the plan tier, but the bundled CLI already
emits it in auth-status JSON. This uses that CLI-only field as
best-effort display metadata, without additional probes, direct
credential-file reads, or entitlement checks.

## Validation

- `npm run build`
- `npm run check`
- `npm run test:run` — 4,002 passed, 33 skipped
- Targeted v1 auth-status and v2 protocol tests — 341 passed
- Local bundled CLI smoke check: `subscriptionType: "max"`, `planTier:
"max_20x"` maps to `Claude Ultra`
- Live model integration tests were not run.
2026-11-10 13:25:20 +00:00
Ben Brandt
3d6eb7b5b6 fix: carry the auth detail in the ACP account label (#1573)
## Summary

The ACP `auth/status` account (`label`, `active`, `name`, `email`,
`organization`) has no field for the identity's `detail`: an API key's
source, a gateway's host, or "OAuth token". It was only in
`_meta.claudeCode.detail`, so a client that shows the standard fields
could not tell which API key or gateway is in use.

`toAcpAuthStatus` now appends the detail to the account's `label` in
parentheses:

- `Anthropic API key (ANTHROPIC_API_KEY)`
- `Custom model gateway (gw.example.com)`
- `Claude subscription (OAuth token)`

An identity without a detail keeps its label as it was (`Claude Max`).

## Unchanged

- `_meta.claudeCode` still carries `kind`, `detail` and `plan` on their
own.
- The `_auth/status_update` extension payload (`label` and `detail` as
separate fields).
- The mapping is shared, so v1 and v2 report the same label in
`auth/status` and `auth/status_update`.

## Testing

- `src/tests/v1/auth-status.test.ts` expectations updated for the API
key and gateway labels.
- `npm run test:run`, `npm run check` and `npm run build` pass.
2026-11-10 14:21:33 +01:00
Mark Tkachenko
c09543d85a fix: offer Anthropic Console login in remote environments (#1572)
## Problem

In a remote environment (`SSH_CONNECTION`, `SSH_CLIENT`, `SSH_TTY`,
`NO_BROWSER` or `CLAUDE_CODE_REMOTE` set), `initialize` advertised only
`claude-login`, the TUI `claude /login`. A user with API billing had no
Anthropic Console option. With `--hide-claude-auth`, the list was empty
and there was no way to log in at all.

There is a second bug. A `_meta["terminal-auth"]` command reruns the
agent with its own flags, for example `--hide-claude-auth --cli auth
login --console`. The `--cli` passthrough removed only `--cli`, so the
Claude CLI failed with `unknown option '--hide-claude-auth'`.

## Fix

- `terminalAuthMethods` (`src/auth-methods.ts`) now offers
`console-login` in remote environments too. Since Claude Code 2.1.126,
`auth login` accepts the OAuth code pasted into the terminal when the
localhost callback can't be reached. The subscription method stays as
`claude-login` on a remote host and `claude-ai-login` locally.
`--hide-claude-auth` hides it in both cases. The change is in the shared
module, so it applies to v1 and v2.
- New `cliArgs()` (`src/agent-command.ts`) removes `--cli` and
`--hide-claude-auth` before the arguments go to the Claude CLI.

## Tests

- `v1/authorization.test.ts`: an `it.each` over the five remote env vars
× typed/`_meta` terminal auth × with/without `--hide-claude-auth`.
- `v2/acp-v2.test.ts`: a remote host with `--hide-claude-auth` gets
exactly one `console-login` terminal method.
- `agent-command.test.ts`: `cliArgs` under Node, a compiled binary, and
`--cli --version`.

Manually checked in a JetBrains IDE through a local launch overlay with
`SSH_CONNECTION` set and `--hide-claude-auth`. Only Anthropic Console
was offered, the terminal login completed, and the chat worked
afterwards.
2026-11-10 13:40:44 +01:00
Ben Brandt
d202748976 feat: implement the ACP auth/status RFD next to _auth/status_update (#1570)
Implements the [auth status
RFD](https://agentclientprotocol.com/rfds/get-auth-state) on ACP v1 and
v2. The `_auth/status_update` extension is unchanged and still sent, so
clients that read it keep working.

## What changes for a client

- `initialize` advertises `auth.status: {}` (v1 under
`agentCapabilities`, v2 under `capabilities`), next to the existing
`_meta.authStatus` marker.
- `auth/status_update` is sent whenever the identity changes, right
after `_auth/status_update`. Each notification is deduplicated on its
own payload.
- `auth/status` answers with the identity the connection holds.
- If an identity is known, it answers at once and starts a `claude auth
status` read in the background. A change that read finds arrives as
`auth/status_update`.
- If none is known yet (a call right after `initialize`), it waits for
the probe `initialize` started, bounded by the existing 5 s timeout, and
answers `unknown` if that publishes nothing.

## Mapping

`toAcpAuthStatus` in `src/auth-status.ts` maps the existing identity:

| Identity | `state` | `accounts` |
| --- | --- | --- |
| not read yet | `unknown` | none |
| `kind: "none"` | `unauthenticated` | none |
| any other kind | `authenticated` | one entry, `active: true`, with
`label`, `email`, `organization` |

The RFD account has no field for the credential type, so `kind`,
`detail` and `plan` go under the account's `_meta.claudeCode`.

## Notes for review

- `state` describes the agent's own login. It does not count a
`providers/set` route, and `--hide-claude-auth` does not change it: a
subscription login is reported as `authenticated` even though the
subscription guard refuses `session/new` for it. The legacy notification
behaves the same way.
- `auth/status_update` goes to every client. The RFD has no client
capability to gate on.
- The background read from `auth/status` is an ordinary probe, so under
`--hide-claude-auth` it can mark a session for recreation when the
account kind changed, as the probe at the next prompt would.
- Probes are now numbered in start order. One that lands after a
later-started probe has published is discarded, so an older read cannot
overwrite a newer one.
- `AcpClient` gained `authStatusUpdate`, so two test doubles needed a
stub.
- 42 scenario golden files change by one line each (the new capability
in `initialize`), and the origin/main baseline comparison allows
`auth.status`.

## Testing

`npm run check`, `npm run build` and `npm run test:run` pass. New tests
cover the mapping, the capability, `auth/status` and
`auth/status_update` on v1 and v2, a gateway login, a first-probe
timeout, out-of-order probes and the dedupe.
2026-11-10 13:02:26 +01:00
Ben Brandt
966aee0f04 refactor: move the test-only tool render and metadata wrappers into src/tests/ (#1569)
`toolInfoFromToolUse` and `toolUpdateFromToolResult` in `src/tools.ts`
and `isLocalCommandMetadata` in `src/message-filters.ts` were public
`lib.ts` exports. #1566 removed them from `lib.ts`, so now only tests
call them. They're thin wrappers: the first two build an
`AcpToolCallRenderer` for one tool use or result, and the third checks
that `stripLocalCommandMetadata` drops everything.

They move unchanged to `src/tests/tool-render.ts`. `tools.test.ts`,
`diff-patch.test.ts` and `v1/acp-agent.test.ts` import them from there.
Production code renders through `AcpToolCallRenderer` directly, as
before. Code changes only: no test changes and no behavior change.

A scan of `src/` for exports finds no export that nothing uses. The only
remaining test-only exports are the deliberate test hooks
`clearDiffMemo` (`diff.ts`) and `sleep` (`utils.ts`).

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped
2026-11-10 08:31:08 +00:00
Ben Brandt
66adc1a4c9 refactor: share the subagent replay's transcript scan between v1 and v2 (#1568)
`src/subagent-replay.ts` (v1, `RfdSubagentReplay`) and
`src/v2/subagent-replay.ts` (`SubagentReplay`) each kept a copy of the
transcript scan. The copies differed only in formatting and variable
names. The scan does four things:
- It registers each Agent or Task launch that has metadata as a child,
and records a launch without metadata as omitted.
- It records each SendMessage and SubagentHandback call.
- It marks a failed hand-back.
- It sets a foreground launch's end from its tool result.

That scan, the `ReplayChild` and `SendMessageCall` types, the child's
end state, and the `contentBlocks`, `inputOf`, `textOf` and
`isDirectory` helpers move to `ReplayTranscript` in
`subagent-replay.ts`. Each replay holds one and reads `children`,
`sendMessages`, `handbacks` and `omitted` from it.

What each replay sends stays where it was. v1 publishes asynchronously
through `Publish`, and v2 sends synchronously through `SessionOutput`,
with the v2-only rules: each child's end also goes on the child's own
stream, and live children get their current state after the response.
Sharing those parts would tie the two protocols together, so I left them
separate. The scan only parses transcripts, so sharing it adds no
dependency between v1 and v2.

v2's `ReplayChild` is the shared type plus `output?: SessionOutput`. Its
`launchEnd` was a narrower `"completed" | "failed" | "cancelled"`, which
`endState` passed through `nativeSubagentState`. That call returns those
three values unchanged, so `replayEndState` gives the same result in
both versions.

No behavior change: 128 lines added and 242 removed, with no test
changes.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped. The v1 and v2 replay
scenario goldens are unchanged.
- `RUN_INTEGRATION_TESTS=1 npx vitest run`: passes. One run hit the
known load flake "absorbs the interrupt's lagged trailer after
cancelling a held turn", which passes on its own.
2026-11-10 08:19:11 +00:00
Ben Brandt
bc031f362c test: split src/tests/ into v1/, v2/ and shared tests (#1567)
`src/tests/` was flat, so it didn't follow the `src/v1/` / `src/v2/`
layout.

- **`src/tests/v1/`** holds the 37 tests that import `src/v1/`, plus
`session-doubles.ts`, which builds v1 sessions.
- **`src/tests/v2/`** holds the tests of `src/v2/` and `serve.ts`:
`v2.test.ts`, `v2-integration.test.ts`, `acp-v2.test.ts` and
`acp-scenarios-v2.test.ts`.
- **The top level** keeps the tests of shared modules, plus
`helpers.ts`, `setup-no-network.ts`, the scenario harness
(`acp-scenarios/`) and `fixtures/`.

A test goes to `v1/` when it imports the v1 agent, even if it is mostly
about a shared module. For example, `providers.test.ts` and
`fast-mode-config.test.ts` drive `ClaudeAcpAgent` for some of their
cases.

Only paths change. Imports and `vi.mock`/`vi.importActual` specifiers
gain one `../`. The `here`-relative paths to the goldens, the
origin/main recordings, `package.json` and `fixtures/` were updated too.
`AGENTS.md`, `docs/air-extensions.md` and the scenario files' doc
comments now give the new paths. No test, golden file or recording
changed. A script checked that every relative module specifier under
`src/tests/` resolves, because a `vi.mock` path that doesn't resolve
mocks nothing and raises no error.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped, the same as before the
move.
- `RUN_INTEGRATION_TESTS=1 npx vitest run`: 3964 passed / 8 skipped.
2026-11-10 09:07:09 +01:00
Ben Brandt
566e2652a9 refactor!: export only lib.ts, with serveAcp and the authStatus wire names (#1566)
Stacked on #1564.

The package's `exports` map listed `"./*": "./*"`, so every file under
`dist/` was public, and any file move could break a deep import. It now
lists only `.` (`dist/lib.js`) and `./package.json`.

**`lib.ts` exports:**
- `serveAcp`, which serves ACP v1 and v2 on stdio through the SDK's
protocol router. This is what `index.ts` runs.
- The `AcpProtocols` and `Logger` types.
- `AUTH_STATUS_UPDATE_METHOD` and the `authStatus` payload types, which
a client needs to read the extension's traffic.

**No longer exported:**
- `ClaudeAcpAgent`
- `runAcp`: v1 only and unused, replaced by `serveAcp`, and now deleted.
- `toAcpNotifications`, `streamEventToAcpNotifications`,
`isLocalCommandMetadata`, `stripLocalCommandMetadata`
- `toolInfoFromToolUse`, `toolUpdateFromToolResult`, `toDisplayPath`,
`planEntries`
- `SettingsManager`
- `nodeToWebReadable`, `nodeToWebWritable`, `Pushable`, `unreachable`
- The types `NewSessionMeta`, `SDKMessageFilter`, `ToolUpdateMeta` and
`ClaudePlanEntry`.

`src/acp-agent.ts`, which re-exported `src/v1/acp-agent.ts` for the
`dist/acp-agent.js` path, is gone. So are the re-exports in
`src/v1/acp-agent.ts` that kept moved names on that path. Tests now
import those names from their own modules. The ESLint boundary rules and
`AGENTS.md` drop their `acp-agent.ts` exemptions.

`release-please-config.json` sets `bump-minor-pre-major`, so the `!`
bumps the minor version, the same as a `feat:`.

BREAKING CHANGE: deep imports of `dist/` files fail with
`ERR_PACKAGE_PATH_NOT_EXPORTED`, and `lib.ts` exports only `serveAcp`,
`AUTH_STATUS_UPDATE_METHOD` and their types.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped. `module-exports.test.ts`
pins the `lib.ts` names and the `exports` map.
- `node scripts/smoke-binary.mjs -- node dist/index.js`: pass
- Resolving the package from its root:
`import("@agentclientprotocol/claude-agent-acp")` gives `serveAcp` and
`AUTH_STATUS_UPDATE_METHOD`, `…/dist/acp-agent.js` throws
`ERR_PACKAGE_PATH_NOT_EXPORTED`, and `…/package.json` resolves.
2026-11-10 07:56:24 +00:00
Ben Brandt
f115b90161 refactor: move the v1 agent into src/v1 (#1562)
Stacked on #1561.

Moves the modules that only the v1 agent uses into `src/v1/` with `git
mv`, so the tree reads as `src/v1/` (v1 agent), `src/v2/` (v2 agent) and
the rest of `src/` (shared). No behavior change.

The v1-only set comes from the import graph: modules reachable from
`acp-agent.ts` whose every non-test importer is itself v1-only (entry
points `lib.ts`/`serve.ts` aside).

Moved:
- `acp-agent.ts`, `background-work.ts`, `goal-extension.ts`,
`turn-events.ts`
- `tool-calls/changed-meta-filter.ts` ->
`v1/tool-calls/changed-meta-filter.ts`
- `session-index/*` -> `v1/session-index/*`, except
`session-index/explicit-title.ts`, which stays because
`session-titles.ts` (shared with v2) imports it

Stay shared: `clear-context-coordinator.ts`, `native-subagents.ts` and
`acp-subagents.ts`. v2 and shared modules import all three.

- `src/acp-agent.ts` is now `export * from "./v1/acp-agent.js"`.
`dist/acp-agent.js` exports the same names, and `module-exports.test.ts`
passes unchanged. `lib.ts` and `serve.ts` import `v1/acp-agent.js`
directly.
- Imports in `src/` and tests, path mentions in comments, and AGENTS.md
are updated. Scripts, vitest config and docs referenced no moved path.
- History: `git log --follow src/v1/acp-agent.ts` follows the move.
Because `src/acp-agent.ts` still exists as the re-export, blame needs
`git blame -C`.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped, goldens unchanged
- `node scripts/build-binary.mjs && node scripts/smoke-binary.mjs`: pass

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 08:44:57 +01:00
Ben Brandt
617d5351ed refactor: move the Claude Code to ACP mapping out of acp-agent.ts (#1561)
Stacked on #1560.

The v2 agent in `src/v2/` imported the Claude Code to ACP mapping,
shared types and a few helpers from `acp-agent.ts` (17 imports), so it
loaded the whole v1 agent module. This moves that code into modules that
own it. `acp-agent.ts` imports it back and keeps re-exporting every name
it exported before, so `dist/acp-agent.js` deep imports still work.

What moved where:

- `src/sdk-to-acp.ts`: `toAcpNotifications`,
`streamEventToAcpNotifications`, `messageIdForGrouping`,
`stampParentToolUseId`, the `ToolUseCache` / `StreamedToolInputCache`
types and the streamed tool input scanner.
- `src/message-filters.ts`: `stripLocalCommandMetadata`,
`isLocalCommandMetadata`, `isSyntheticLoginMessage`,
`isReplayHiddenMetaMessage`, `LOCAL_ONLY_COMMANDS` and the transcript
marker scanner.
- `src/prompt-to-claude.ts`: `promptToClaude`,
`SUPPORTED_PROMPT_CONTENT_TYPES`.
- `src/slash-commands.ts`: `getAvailableSlashCommands`.
- `src/result-end.ts` (existing): `AUTONOMOUS_RESULT_ORIGINS`,
`isAbortedResult`.
- `src/logger.ts`, `src/acp-client.ts`, `src/new-session-meta.ts`: the
`Logger`, `AcpClient`, `NewSessionMeta` and `SDKMessageFilter` types.

About 1,360 lines leave `acp-agent.ts` (12,718 to 11,354). The code
moved as-is. Only a few comments that named `acp-agent.ts` as the home
of the shared code were reworded. `src/v2/`, `utils.ts`, `serve.ts`,
`lib.ts` and `session-index/connection.ts` now import from the new
modules. Tests import the moved code from the new modules too. None of
the new modules imports `acp-agent.ts`. `grep -rn acp-agent src/v2`
returns nothing, and a walk of the static imports in `dist/v2` reaches
119 modules, none of them `acp-agent.js`.

No behavior change. The export pins in
`src/tests/module-exports.test.ts` for `lib.js` and `acp-agent.js` pass
unchanged.

Test results:

- `npx tsc --noEmit`, `npm run check` and `npm run build` pass.
- `npx vitest run`: 3939 passed, 33 skipped. That is the 3935 before,
plus the 4 provider tests now on the base branch.
- Goldens unchanged.
- `node scripts/build-binary.mjs && node scripts/smoke-binary.mjs` pass.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 08:40:42 +01:00
Ben Brandt
cb285cd285 feat: serve ACP v2 with a direct v2 agent and remove the v1 bridge (#1560)
## Summary

ACP v2 was served by a bridge (`src/v2/agent.ts`) that converted each v2
request to v1 and ran it through `ClaudeAcpAgent`. Every v2 session
therefore ran the v1 core's turn machinery: held prompts, echo matching,
trailing-idle debt and the hold for background subagents. v2 asks for
none of it. This PR serves v2 with its own agent in `src/v2/`, which
follows Claude Code's own events. A prompt is answered when Claude Code
takes it in, and `state_update` reports foreground work. Background
subagents and tasks are listed in `background` and do not hold the turn.

- **New v2 agent (`src/v2/`).** `V2Agent` and `V2Session` cover:
  - the prompt lifecycle and cancellation
- subagents as child sessions, following the subagents RFD, plus
background lists
  - replay, restarts for provider and route changes, auth and providers
- elicitation, config options, `/remote-control`, and the
`--hide-claude-auth` guard
- **Bridge removed.** `ClaudeAcpAgent` serves ACP v1 only: its `v2`
option, foreground/background hooks and `this.v2` branches are gone. v1
behavior is unchanged, and no v1 test expectation or golden file
changed. The control flow of `acp-agent.ts` is simpler and the file is
about 450 lines shorter.
- **Shared modules.** Protocol-neutral logic moved out of `acp-agent.ts`
into modules both versions use, including:
- `session-model.ts`, `session-effort.ts`, `session-config-options.ts`
and `session-env.ts`
  - `providers.ts`, `auth-status-reporter.ts` and `context-window.ts`
  - `result-end.ts`: one set of rules for reading a result in both cores
  - `prompt-suggestions.ts` and `mcp-servers.ts`
- **JetBrains AIR on v2.** An AIR v2 client gets the AIR extensions that
ACP v2 does not cover:
  - `recommendedValue`
  - the permission presentation
  - question custom answers
  - prompt suggestions
- `agentFileChangeReport`, sent at the end of the turn, before its idle

ACP v2 itself covers subagent sessions, background subagents, async
tasks, plan files, diff patches, compaction, custom instructions and
session failures. See "ACP v2" in `docs/air-extensions.md`.
- **Ported from main.** Main's v2 changes made on the bridge are ported
to the new agent, and main's v2 tests run against it:
  - workflow cards and phases (#1550, #1552)
  - task tool calls (#1556)
  - plan removal on reset (#1554)
  - subagent checklist plan ids (#1558)
  - the Fable alias (#1548)

## Behavior changes for v2 clients

Each one follows the v2 draft spec, where the bridge did not:

- **Background subagents.** They no longer hold the prompt open. They
are listed in `background`, and their work is reported in their child
session's state.
- **`requires_action`.** While the root waits on foreground subagents,
it reports `requires_action` only when every one of them waits on the
user.
- **Resume of an active session.** Its work keeps running, and the new
MCP servers replace the old ones (`setMcpServers`). The bridge tore the
session down.
- **Cancel.** The idle after a cancel waits for Claude Code's aborted
result and carries its usage. A stopped child's open tool calls end
before its idle.
- **`/remote-control`.** It runs as its own turn. Sent while Claude
works, it waits for that work to end.
- **Accepted URL elicitation.** It keeps the work `requires_action`
until Claude Code reports the elicitation complete.
- **Exited Claude Code.** A session whose Claude Code process exited
stays active and answers -32603 with what happened, until it is resumed.
- **Provider change.** `providers/set`/`disable` restarts idle sessions
before it answers. A busy session restarts at its next turn boundary,
and its prompts are not held meanwhile, which the provider RFD allows.

## Workarounds

- **`/remote-control` waits outside the prompt queue.** The adapter's
own `/clear` and plan prompts, which continue a turn in a fresh context,
use their own push queue (`ownPushes`). They must not wait behind a
`/remote-control` that waits for that same turn to end.
- **Restart on additional directories.** A resume with other additional
directories restarts the query at its next turn boundary. The SDK cannot
change the directories of a running query.

## Rollback

`CLAUDE_AGENT_ACP_PROTOCOL=v1` negotiates every client down to v1. The
bridge and its `CLAUDE_AGENT_ACP_V2_CORE` switch are removed.

## Not served on v2 yet

- AIR's tool call keys (`commandTitle`, `subagent`, `skill`, `mcp`), the
session index, and API retry warnings.
- A `replayFrom` cursor other than `start`, which is rejected with
-32602, and steering, which is v1 only.

## Packaging

- `lib.js` exports are unchanged.
- `acp-agent.js` exports no name fewer than before. It adds
`AUTONOMOUS_RESULT_ORIGINS`, `getAvailableSlashCommands` and
`isAbortedResult`, which the v2 agent uses, and the export pin test is
updated.
- Internal `dist/v2/*` modules changed. They are reachable only through
the `"./*"` export and are not documented.

## Testing

- `npm run format:check`, `lint`, `build` and `test:run` pass: 3935
passed, 33 skipped.
- `npm run test:integration` against the real CLI passes: 3960 passed, 8
skipped. This includes `src/tests/v2-integration.test.ts`.
- The v2 golden scenarios (`acp-scenarios-v2.test.ts`) match without
being regenerated.
- The binary builds and `scripts/smoke-binary.mjs` passes.
- Two tests unchanged from main can time out under heavy load: the
session-index file watcher test and v1's "lagged trailer" test. Both
pass on rerun.

## Follow-up

- Split `src/` into v1, v2 and shared modules.
- Move the SDK-to-ACP mapping that `src/v2/` still imports from
`acp-agent.ts` into a shared module.
- Add a lint rule that keeps the boundary.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 08:34:38 +01:00
Ben Brandt
2f7e386f36 fix: give subagent checklists their own plan ID (#1558)
A subagent's TodoWrite checklist was published with the main checklist's
`planId: "plan"`, so it replaced the main conversation's plan.

This only happened on ACP v1 clients that advertise `plan` but not
`subagents`. With native subagent sessions (every v2 client, AIR, and v1
with `subagents`), the checklist already goes to the child session, and
that's unchanged.

Changes:

- In that case, a subagent's TodoWrite gets `planId:
"plan:<parentToolUseId>"`, so it sits next to the main checklist
(`todoWritePlanUpdate` in `src/plans.ts`).
- An empty subagent TodoWrite sends `plan_removed` for its own plan ID,
not `"plan"`.
- `/clear` also removes the subagent checklist plans it published.
- Nothing is removed when a subagent finishes. The plan operations RFD
now says Agents remove cancelled plans, not finished ones.

Not changed:

- Task* tools keep `"plan"`. Claude Code shares one task list across all
agents in a session, so a per-subagent ID would show the same list
twice.
- Clients without the `plan` capability can hold only one plan, so a
subagent's checklist still replaces it there, as before.

Tests cover v1 (main and subagent plans side by side, then `/clear`) and
v2 (the checklist arrives as `"plan"` in the child session, and the
root's plan is untouched).
2026-11-09 23:22:19 +01:00
Ben Brandt
855447c7cd fix: update to @anthropic-ai/claude-agent-sdk 0.3.321 (#1557)
## Summary

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.319 to 0.3.321. It
bundles Claude Code 2.1.321, which adds Fable 5.2 (`claude-fable-5-2`)
as the new default `fable` model. Also bumps these dev dependencies:
`@anthropic-ai/sdk` 1.6.0 → 1.7.0 and `@typescript-eslint/*` 8.72.1 →
8.72.2. `typescript` 7 is left for its own PR.

## Fable 5.2

Checked against the real CLI:

- A new session lists `fable` → `claude-fable-5-2`, and
`claude-fable-5-1` as its own row.
- A resumed Fable 5.2 session lists `claude-fable-5-2` and no `fable`
row, the same pattern 5.1 had.

`aliasResumedModelRow` already handles any version, so the code is
unchanged. Its doc comment and test fixture now describe the 2.1.321
lists. The fixture also covers a resumed Fable 5.1 session keeping
`claude-fable-5-1`, because a newer Fable is listed.

## Changelog review

- No SDK type changes that affect us. The only changes are doc text and
a new optional `endedLoopWakeup` field in `sdk-tools.d.ts`.
- Queued subagent reports in auto mode now share one model call, and all
but the last end in a `num_turns: 0` placeholder result. These results
carry `task-notification` or `peer` origins, which we already treat as
placeholders. That handling dates from CLI 2.1.274, which did the same
for background-task notifications.
- Fixes we pick up: one `undefined` entry in `hooks` no longer drops
every hook; a PreToolUse `ask` is no longer lost when another hook's
`defer` finishes first; project skills and commands no longer go missing
after entering a worktree; MCP OAuth no longer sends refresh tokens to
another host after a redirect.

## Testing

- `npm run build`, `npm run check`, `npm run test:run`
- Real CLI: listed the models for a new session and for a resumed Fable
5.2 session

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-09 19:43:47 +00:00
Ben Brandt
d098fb08d5 fix: report v2 background tasks without a tool call as their own tool call (#1556)
## Summary

Background tasks that have no tool call or terminal on v2 now get a tool
call of their own. Before, they showed up as a `_claude/task` vendor
item. This follows the spec rule in
agentclientprotocol/agent-client-protocol#2786.

Examples: a workflow, or an MCP task that starts after its tool call
already returned, or a task a hook started.

- `BackgroundWork.adoptTasks` starts a tool call `task_<taskId>` for
each such task. It uses the task description as title, `kind: "other"`,
and `in_progress` or `paused`. A subagent's task goes on the subagent's
stream. The message loop sends these calls before `sync`, so a call is
always announced before the first `background` list names it. The same
path covers the `state_update` snapshot on `session/resume`.
- These calls are listed as `tool_call` items, so `tool_call/cancel`
stops the task. When the task ends they get a final status like any
other listed call (`completed`, `failed`, or `cancelled`).
- The `_claude/task` item type is gone. `BackgroundItem` is now
`terminal | tool_call | subagent`.
- Ambient tasks such as the monitor, and tasks whose own tool call is
still open, are unchanged.

`adoptTasks` is synchronous and returns the notifications, so the loop
only awaits when there is something to send. An extra await on every
pass broke the steering-cancel timing in `acp-agent.test.ts`.

## Compatibility

- ACP v1 clients, including JetBrains AIR's `_session/async_task/*`
extension, are unaffected. Background lists, and therefore this path,
only run when the v2 background sink exists and the session has
`perTaskStopAffordance`. On v1, `adoptTasks` returns `[]`.
- v2 clients that saw `_claude/task` items (v0.100.0 to v0.101.1) now
see `task_<id>` tool calls instead.

## Tests

`acp-v2.test.ts` replaces the `_claude/task` tests. New tests cover:

- starting and listing a call for a task that starts after its tool call
returned
- cancelling such a task with `tool_call/cancel`
- the call being sent before the `session/resume` snapshot
- a subagent's task appearing on the subagent's stream

`npm run check`, `build` and `test:run` pass.
2026-11-09 20:40:39 +01:00
Ben Brandt
766fd142f6 feat: send the checklist as a plan operation and remove plans on reset (#1554)
## Summary

Follow-up to the [plan operations
RFD](https://agentclientprotocol.com/rfds/plan-operations). The adapter
already sent approved ExitPlanMode plans as `markdown` plans to clients
with the `plan` capability, but two parts of the RFD were unused:

- **The checklist is now a plan operation on v1 too.** Clients with
`plan` (every v2 client, and v1 clients that opt in) get the
TodoWrite/Task checklist as `plan_update` with type `items` and the ID
`plan`. Before, v1 clients with the capability still got an ID-less
legacy `plan` next to the `plan-mode` plan. Clients without the
capability still get the legacy `plan`.
- **`plan_removed` is now sent.**
- An empty checklist sends `plan_removed` for `plan`. v2 clients used to
get a `plan_update` with no entries.
- A conversation reset (`/clear`) removes the checklist and the old
conversation's approved `plan-mode` plan. AIR keeps its approved plans,
which are joined to their approval cards by ID (#1469).

The new `src/plans.ts` builds the checklist updates. All three places
that send the checklist use it, and so does the v2 conversion of a
legacy `plan`, so v1 and v2 follow one rule.

The reset sends `plan_removed` for `plan-mode` even when the session
never published an approved plan. Tracking whether one was published
would be state that a restart loses, so the reset would behave
differently before and after one.

`file` plans are left out. A `file://` URI only works for a client on
the same machine, and v2 has no client filesystem.

## Tests

- `tools.test.ts`: TodoWrite with and without todos, and a TaskCreate
snapshot, for a v1 client with `plan`.
- `acp-agent.test.ts`: the reset test now runs for a client without plan
operations, a client with them, and AIR.
- `acp-v2.test.ts`: v2 end to end, TodoWrite then `/clear`, and an empty
legacy plan converting to `plan_removed`.
2026-11-09 14:58:36 +00:00
Ben Brandt
176ae0634c feat: list workflow phases and agents on the workflow card (#1552)
## Summary

Show the phases and agents of a running workflow on its ACP v2 tool
card, not only the current activity and totals.

Claude Code attaches a `workflow_progress` snapshot to some of a
workflow's `task_progress` events: every phase, and every agent with its
label, phase, state, tokens, tool calls, duration and last tool. #1550
deliberately left it out. This change renders it under the existing card
body:

```
**Count**
- two: done · 14,629 tokens · 0 tool calls · 1s
- three: running · Bash: echo 3

**Sum**
- not started
```

### Why an untyped field

The SDK (0.3.319) does not type `workflow_progress`. Workflow agents
also don't stream their messages through the root query, so this
snapshot is the only per-agent data the adapter gets.
`src/workflow-progress.ts` reads the shape the CLI sends and skips
entries that don't match it. If the CLI changes the format, the card
loses the list but still renders. The right long-term fix is for the SDK
to type this field, or to stream workflow agents with a parent id so
each one can become a child session.

### Behavior

- The snapshot is a full list, and the CLI leaves it out of some
reports. A report without one keeps the last list.
- A running agent shows its last tool (e.g. `Bash: echo 3`). A finished
one shows tokens, tool calls and duration.
- Agents with no known phase are listed first. A phase with no agent yet
shows `not started`. A retry shows its attempt. A state other than
`start`/`done` is shown as the CLI sends it.
- The existing duplicate suppression and the `session/resume` snapshot
cover the list too.
- AIR `async_task_progress` is unchanged. Like the rest of the card
progress from #1550, this only reaches v2 clients that get workflow
progress on the card.

Result previews, models and prompt previews are left out to keep the
card short.

## Validation

- Live probe on Claude Code 2.1.319 (SDK `query()`, two-phase
three-agent workflow, permissions limited to `Workflow` and one `echo
3`). It captured the snapshot shape used in the test doubles. Every
captured snapshot rendered through the built module as shown above. This
checked the protocol output, not the Delta UI.
- New v2 scenario built from the captured payload, plus unit cases in
`src/tests/workflow-progress.test.ts`.
- `npm run check`, `npm run build`, `npm run test:run` (3,691 passed, 30
skipped).
2026-11-09 14:49:48 +00:00
Ben Brandt
30600ba348 fix: render workflow tool cards and progress correctly in ACP v2 (#1550)
## Summary

Fix workflow tool cards that remain pending indefinitely, and surface
workflow progress through standard ACP v2 updates.

### Prevent orphan preview cards

Claude Code invokes Edit/Write preview hooks for workflow agents without
streaming those tools' uses and results through the root query.
Publishing a card from the hook alone leaves a pending call with no
completion path.

Stage early preview patches until the stream or a permission request
publishes the actual tool call. Keep patches pinned against later input
refinements, bound unclaimed previews, and clear them on teardown. A
call's own content takes precedence over a staged preview.

### Show workflow progress in place

Forward the SDK's typed task-progress fields to the existing workflow
card: current activity, summary, last activity, token count, tool-call
count, and elapsed time. Preserve progress arriving before the launch
result, suppress duplicate display updates, and include the latest
progress on session resume. Progress does not change paused/running
state or reopen completed calls.

This reads no workflow transcripts, journals, or metadata files and
introduces no ACP extension. The CLI also emits an untyped
`workflow_progress` snapshot; consuming that richer phase/agent data is
deliberately left out of this change. Existing v1 AIR progress uses the
same field normalization as before.

## Validation

- Regression reproduced before the orphan-preview fix.
- Nine relevant Vitest suites: **1,972 passed, 10 skipped**, including
v1/v2 scenarios, preview ordering, progress-before-launch, idle and
paused updates, duplicate suppression, resume snapshots, and late
progress after completion.
- `npm run check`
- `npm run build`
- `git diff --check`
- Live Claude Code **2.1.319** probes: captured SDK workflow events and
ran a real workflow through the updated ACP v2 adapter. One card
received two progress updates and then reached `completed`. This
verified protocol updates, not the Delta UI.

Rebased onto current `main` and reran the automated checks before
opening the PR.
2026-11-09 14:59:02 +01:00
Mark Tkachenko
dd9eed4e46 test: pin runtime exports of lib.js and acp-agent.js (#1546)
Adds a test that pins the runtime export names of `lib.js` and
`acp-agent.js`.

`acp-agent.ts` is about to be split into smaller modules in a series of
pure-move PRs. Each move keeps the old exports as re-exports, because
`"exports": { "./*": "./*" }` lets consumers deep-import
`dist/acp-agent.js`. tsc only catches a missing export that something in
the repo imports; this test catches the rest. The list should change
only on purpose.
2026-11-09 14:07:08 +01:00
Ben Brandt
4cceb06b31 fix: list a resumed Fable 5.1 session's model as fable (#1548)
## Problem

The Claude Code CLI gives Fable 5.1 a different option value depending
on how the session starts:

- **New session:** listed as `fable` (`resolvedModel:
"claude-fable-5-1"`).
- **Resumed session whose transcript last ran Fable 5.1:** the same row
is listed as `claude-fable-5-1`, and there is no `fable` row.

Reproduced with CLI 2.1.310. Other families don't do this: a session
resumed on `claude-opus-5-5` still lists `opus`.

Clients store the option value as the session's choice. A choice saved
in a new session (`fable`) is not among the options of a resumed one,
and the reverse. A client that checks its saved value against the
options then refuses to apply it, and the model choice keeps flipping
between the two ids.

## Fix

`aliasResumedModelRow` in `session-model.ts` gives the resumed model's
row its family alias back. It changes a row only when:

- the row's value is the model read from the resumed transcript, equals
its own `resolvedModel` and has no context hint,
- no row already has the alias, and
- no newer version of the family is listed, since the alias runs the
family's newest model.

`createSession` applies it before the allowlist and model selection, so
new, resumed and loaded sessions over ACP v1 and v2 all get the same
list.

This is a workaround for the CLI listing the row under a different
value. The CLI also lists rows this way when `ANTHROPIC_MODEL` or
`settings.model` pins `claude-fable-5-1`. Those rows keep the pinned id
on purpose: relabelling them would turn the user's pinned version into
the alias, which moves to a newer Fable when one ships.

## Tests

- `acp-agent-settings.test.ts`: a session resumed on `claude-fable-5-1`
offers `fable` as the current value and option, with no `setModel` call.
Fails without the fix.
- `model-resolution.test.ts`: unit cases for the relabel and for each
case that keeps the list as it is.
- Checked end to end with the real CLI: a Fable turn, then
`session/load`, reports `fable`.
2026-11-09 13:47:11 +01:00
Ben Brandt
a58293f4c9 fix: update to @anthropic-ai/claude-agent-sdk 0.3.319 (#1545)
Updates the Claude Agent SDK from 0.3.318 to 0.3.319, which bundles
Claude Code 2.1.319. Also updates `@anthropic-ai/sdk` (1.4.0 → 1.6.0)
and the transitive dependencies in the lockfile (`@babel/runtime`,
`@eslint-community/regexpp`, `@rolldown/pluginutils`, `hono`, `jose`,
`vite`).

## Reviewed, no change needed

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

## Not updated

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

## Testing

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

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

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

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

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

## Validation

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

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

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

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

```
…推上去。

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

## Behavior

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

## Tests

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

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

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

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

Fixes #1453.

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

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

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

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

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

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

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

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

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

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

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

## Fix

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

## Tests

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

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

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

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

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

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

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

## Change

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

## Tests

- The Monitor fixture in `acp-v2.test.ts` now carries `ambient: true`,
as the CLI sends it. Without the fix, the four existing Monitor tests
fail with it.
- The `_claude/task` test is now an `it.each`: a non-ambient task
without a call is listed, and an ambient watcher without a call is not.
- `npm run check`, `npm run build`, `npm run test:run` pass. Not run:
`npm run test:integration` against the real CLI.
2026-11-06 12:12:58 +01:00
Ben Brandt
7ea9eec839 feat: start sessions in Auto mode by default (#1533)
Claude Code now starts interactive terminal and VS Code sessions in Auto
(v2.1.283+). Agent SDK runs with no `permissionMode` still start in
Manual ([docs](https://code.claude.com/docs/en/permission-modes)). Until
now the adapter followed the SDK default. ACP clients are interactive
editors, so this PR makes them start like the VS Code extension.

## How a session now starts

- **No mode configured:** with no `permissions.defaultMode`, or an
invalid one that settings resolution drops, a new session starts in
`auto`.
- **Model without Auto support:** that built-in Auto default starts in
Manual (`default`) with no warning, as Claude Code does. A user who
explicitly sets `"auto"` still gets the existing fallback to
`acceptEdits` with a warning.
- **`disableAutoMode: "disable"`:** the adapter ignored this setting
before. Now, set at the top level or under `permissions`, it removes
Auto from the mode list, the plan-exit options and mode switching, and
the session starts in `default`. The bundled CLI reads both spellings.
- **Unchanged:** an explicit `permissionMode`, a resumed transcript's
mode and a configured `defaultMode` behave as before.

## Limitations

- **HIPAA:** Claude Code starts organizations with the HIPAA
configuration in Manual. The SDK doesn't report that configuration (it
is not in `AccountInfo`, the `init` message or the model list), so those
sessions start in Auto here. This is noted in a code comment.
- **Mid-session setting changes:** like the bypass setting,
`disableAutoMode` is read when the session is created.

## Tests

- Updated the tests that expected the old default.
- Added cases for the built-in default, the Manual fallback on a model
without Auto, and both spellings of `disableAutoMode`.
- Regenerated the scenario snapshots for ACP v1 and v2. The 4
`origin-main` baseline files change only the mode value, from
`"default"` to `"auto"`.
2026-11-05 12:01:11 +00:00
Mark Tkachenko
a79d07324d feat: compact idle sessions before the prompt cache expires (#1522)
Claude Code compacts a large idle session shortly before its one-hour
prompt cache expires, but only in attended sessions. SDK sessions
(entrypoint `sdk-ts`) never arm that timer, so every return after an
hour re-writes the whole context to the cache.

The adapter now runs its own timer: 54 minutes after the last
main-thread request, if that request used the 1h cache, the context is
at least 200k tokens and the session is idle, it sends `/compact`
through the normal prompt path. It respects `idleCompaction: false`,
`autoCompactEnabled: false` and `DISABLE_COMPACT` /
`DISABLE_AUTO_COMPACT`. AIR gets `trigger: "idle"` in the compaction
`_meta`.

Closes #1454
Closes #1518
2026-11-04 20:04:11 +01:00
Ben Brandt
1d4418f80d fix: update to @anthropic-ai/claude-agent-sdk 0.3.318 (#1525)
Updates the Claude Agent SDK from 0.3.316 to 0.3.318, which bundles
Claude Code 2.1.318. Also updates `@anthropic-ai/sdk` (1.3.0 → 1.4.0),
`vitest` (5.0.5 → 5.1.0) and the transitive dependencies in the
lockfile.

## Changes from the changelogs

- **`session_writes_lost` (SDK 0.3.317):** a new system message that the
CLI sends as it exits, after the last result, when a full disk or
used-up quota kept it from saving the transcript or `~/.claude.json`. No
turn is open to carry a `session/update` then, so the adapter logs it,
as it does `mirror_error`. Without a case for it, the build fails on the
exhaustive switch.
- **TaskCreate `task.description` (SDK 0.3.317):** the plan now takes a
task's subject and description from the TaskCreate result, which holds
the task as saved. It differs from the input when Claude Code corrects a
malformed call or a PreToolUse hook changes it. This applies to the live
stream and to `session/load` replay.

## Reviewed, no change needed

- New `startup_failure_reason` values (`config_file_invalid`,
`initialize_not_first`) already reach the client as an internal error
with the CLI's own message.
- `onFailure: "block"` on hook matchers: none of the adapter's hooks
guard permissions, and a failed Edit/Write preview must not block the
edit.
- Removed `file_unchanged` Read output, optional `status` in
`PushNotificationInput`, and Grep/Glob `timedOut`: nothing in the
adapter reads these.
- `workingDirectoriesAsGiven`: it makes Claude Code not follow links at
the listed working directories. Listing the ACP `cwd` would put every
file of a symlinked workspace outside the working directories. Hosts
that want it can already pass it through
`_meta.claudeCode.options.settings`.
- The comments on the `getSettings` and `unanswered` type workarounds
now name 0.3.318; both are still missing from the SDK's types.

## Not updated

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

## Testing

`npm run build`, `npm run check` and `npm run test:run` pass. New tests
cover both changes. `npm run test:integration` was not run.
2026-11-04 12:50:19 +00:00
Ben Brandt
e3f0b6c89c feat: ship single-file executables built with bun (#1524)
Brings back the standalone executables that #525 and #572 turned off.
npm publishing is unchanged. Each stable release also attaches 8
archives (Linux glibc and musl, macOS, Windows, on x64 and arm64) to its
GitHub release.

## Why it works now

The old executables ran the SDK's JS CLI from inside Bun's `$bunfs`, and
it couldn't reach its `vendor/` files there. The SDK's CLI is now a
native binary shipped in a per-platform npm package. So each archive
ships that binary as `claude` next to `claude-agent-acp`, and the agent
runs it from its own directory. Nothing is embedded or extracted, and
the agent has no `node_modules` to resolve.

The first commit embedded the CLI and extracted it with the SDK's
`@anthropic-ai/claude-agent-sdk/extract` helper. That works, but the OS
can only run a real file, and Bun-embedded files only exist inside the
agent process. So it wrote a second ~300 MB copy to `/tmp` on first run,
once per version. Shipping the CLI next to the executable avoids that,
and the download size is the same (~180 MB `.tar.gz`).

Claude Code's helper tools work too. Native Claude Code has no Grep or
Glob tools: its Bash shell wraps `grep` and `find` in functions that run
the ugrep and bfs built into the binary at `$CLAUDE_CODE_EXECPATH`. That
variable points at the `claude` shipped next to the agent.

## Changes

- `src/claude-cli.ts` now owns `claudeCliPath()`, moved out of
`acp-agent.ts`. `useBundledClaudeCli(dir)` makes it return
`<dir>/claude[.exe]`. If that file is missing, it throws an error saying
to keep `claude` next to `claude-agent-acp` or set
`CLAUDE_CODE_EXECUTABLE`; before, the user got a bare ENOENT.
`CLAUDE_CODE_EXECUTABLE` still wins.
- `src/binary-entry.ts` is the executable's entry. It registers the
directory of `realpath(process.execPath)`, so a symlink in
`/usr/local/bin` works, then imports the agent. It's left out of the npm
tarball.
- `scripts/build-binary.mjs` (`npm run build:binary`) runs `bun build
--compile` for a target and copies that target's `claude` next to the
output.
- `scripts/smoke-binary.mjs` (`npm run smoke:binary`) runs:
  - `--version` and `--cli --version`
  - a symlinked executable
  - the missing-`claude` error
  - `initialize` over v1 and v2
- the `terminal-auth` login command, run with `--version` (checks that
no `$bunfs` path leaks into it)

  With `--full` it also drives a real session:
- Bash `grep`/`find`, checking that they ran Claude's embedded ugrep/bfs
  - Read on text and on a PNG
  - Edit
  - a subagent
  - `session/cancel` during a running command
  - `session/list`
  - `session/load` in a new process
  - `session/fork` and `session/delete`
- `src/agent-command.ts` builds the `terminal-auth` login command, which
reruns the agent. Under Node that's `node <script> ...flags`. In a
compiled executable, `argv[1]` is a path inside the executable
(`/$bunfs/root/claude-agent-acp`), so it's dropped. Passing it back, as
the old `argv.slice(1)` did, handed it to the Claude CLI as an argument
and broke `auth login`.
- `.github/workflows/binaries.yml` builds and smoke-tests each target on
its own platform's runner; the musl targets are tested in an Alpine
container. CI runs it on every PR, using the basic smoke test, since CI
has no Claude login. On a stable release, `publish.yml` attaches the
archives (same names as before). These jobs don't gate the npm publish,
and previews get no executables. On stable releases the registry
dispatch waits for `upload-binaries`, so the archives (and the digests
the registry reads) exist first. It only waits: a failed upload doesn't
stop the dispatch.

## Testing

I also ran the whole unit suite on Bun's runtime (`bun --bun vitest
run`). 3,573 of 3,575 tests pass. The two failures are test artifacts:
Bun's `os.homedir()` ignores a `HOME` changed at runtime, and one
steer-and-cancel test asserts before the mock delivers its last idle. An
idle-accounting trace is identical on both runtimes.


On macOS arm64 with Bun 1.4.4, `smoke:binary --full` passes all checks.
`CLAUDE_CODE_EXECPATH` pointed at the shipped `claude`, and nothing was
written to the temp directory. The basic smoke test also passes from an
unpacked archive outside the repo with `PATH=/usr/bin:/bin`, and `node
dist/index.js` passes the full run. On Linux, Windows and Intel macOS,
only the basic smoke test runs, in this PR's CI.
2026-11-04 11:00:25 +00:00
Slapoguzov Alexander
8bbdea6cc6 fix: honor configured fallback for unavailable restored modes (#1520)
A restored transcript mode such as `dontAsk` is recognized by the Claude
SDK but absent from the ACP mode catalog. It previously reached
initialization and was forced to Manual, skipping a supported
`permissions.defaultMode` from settings.

Use the same catalog for restored-mode selection and initialization.
Supported restored modes retain precedence; an unavailable restored mode
falls back to an available configured mode, then Manual. Explicit
initial modes, bypass restrictions, model-specific Auto handling, and
new-session configuration behavior remain unchanged.

Validation: TypeScript build, ESLint, Prettier, and 243 focused tests
pass. Added regressions cover helper selection plus v1 and v2 responses;
nine checks fail against unchanged production code.

The full local suite **FAILED**: 3,549 passed, 20 failed, 30 skipped.
Four selected failures also reproduce on unchanged base: three
session-index cases (watcher setup, live notification, SDK path-list
parity) and one background-work case. The remaining 16 failures were not
individually classified. No test timeouts or product behavior were
changed to suppress them. Live CLI and IDE permission-enforcement
acceptance remain unverified.

Co-authored-by: Codex <codex@openai.com>
2026-11-03 10:40:14 +01:00
Ben Brandt
1e986b526d refactor: give each session one owner of its background work (#1517)
Follow-up to #1510, #1511 and #1512. Clients receive exactly what they
did before.

## Why

The v2 background work grew as about 14 free functions taking `(session,
sessionId, …)` and reaching into six `Session` fields. Four of them it
kept itself: the task set, terminals, tool calls, and
`reportsBackground`. Three belong to other modules:
`liveBackgroundTasks`, the subagent map, and `toolUseCache`. The repo
guidelines ask for one module that owns the state.

## What changed

- **One owner per session.** `SessionBackgroundWork`, stored on
`Session.background`, owns the task set, terminals, tool calls, output
tails, and whether the client gets the lists. It has one method per
event: a Bash result, tool results, a task's end or pause, a new task
set, and a closed query.
- **The reporter only filters.** The object builds each session's
report, so `BackgroundWorkReporter` only drops unchanged lists and
forwards the rest.
- **No reference back to the session.** A recreated session (sign-out
respawn, provider change) gets a new `Session` under the same id, so an
old consumer must not reach the new one. Callers pass in the state other
modules own.
- **No `toolUseCache` dependency.** Whether a task's launching call has
returned used to be judged by the call still being in `toolUseCache`,
which Claude Code prunes at the tool's result. The object now records
returned calls itself, capped at 1024 like the other per-session sets.
- **Stricter typing on the output path.** Typing the terminals map
properly exposed that the parsed `outputFilePath` was loosely typed, so
it is now checked to be a string.
- **Test doubles.** The hand-built `Session` doubles in
`acp-agent.test.ts` and `session-doubles.ts` now carry the new required
field.

## Tests

- The existing suite passes unchanged.
- One new v2 test covers a task whose `task_started` arrives after its
call returned, so it is listed as `_claude/task`. A first version of
this refactor recorded "returned" per task and silently missed that
case. The test fails against that version, and against listing tasks
before their call returns.

`npm run check`, `npm run build` and `npm run test:run` pass (3556
passed, 30 skipped).
2026-11-02 19:52:25 +01:00
Ben Brandt
53e3eec013 feat: stream the output of v2 background commands (#1512)
Follow-up to #1510 and #1511, phase 2b of the v2 [Background Work
RFD](https://agentclientprotocol.com/rfds/v2/background-work).

Claude Code streams no output of a Bash command that went to the
background: it writes the output to the task's output file. So the
command's terminal showed only the Bash result's placeholder ("Command
running in background with ID …") until the command exited. The v2 spec
says the Agent **SHOULD** keep a listed terminal's output current
whenever it can read the process output.

## What a v2 client gets

- **Snapshot:** the file's first text replaces the placeholder as a
`terminal_output` snapshot.
- **Chunks:** later text is appended as `terminal_output_chunk`s.
- **Task end:** the rest of the file is read before the terminal's
`exitStatus`, as the spec asks.
- **Query close:** reading stops.

## Design

- New `src/background-output.ts` (`BackgroundOutputTail`) follows one
file:
- It polls every 500 ms, because watching a file is not reliable on
every platform and a missing file just means nothing was written yet.
- It reads at most 256 KiB per read, so a burst of output doesn't block
the event loop. Each read is sent before the next, including when
reading to the end at the task's end.
- Past 1 MiB of unread output (a command writing faster than the polls
read, about 512 KiB/s), it skips to the file's last 1 MiB and sends it
as a replacing snapshot, so the terminal shows the latest output without
a gap in it. A file that shrinks is read again from its start, also as a
snapshot.
- Its timer is unref'd, so a long-running command doesn't keep the agent
process alive.
- A `StringDecoder` holds a UTF-8 character split across reads until its
last byte is written.
  - Reads are serialised.
- `background-work.ts` starts the tail once the Bash result's own
updates are sent, so the first output replaces the placeholder rather
than coming before it. It finishes the tail before the terminal's exit
and stops it when the query closes. `acp-agent.ts` only gets wiring.
- The output path comes from the Bash result's text, the only place the
SDK gives it before the task ends. The existing
`asyncTaskOutputFilePath` parser only accepts an absolute path ending in
`tasks/<taskId>.output` for this task. Without a path, the terminal
keeps the placeholder as before.
- v2 only (`reportsBackground`), like the rest of the background work.
v1 clients that render terminals through the `_meta` extension could use
it too. That is a small follow-up, but it changes what v1 clients
receive.

## Tests

- Nine unit tests for `BackgroundOutputTail`:
  - the replacing first read, and appends
  - a file that appears later, and a file that never appears
  - a UTF-8 character split across reads
  - finishing, an empty output, and stopping
  - a backlog past 1 MiB, and a file that shrinks
- One v2 test over a real connection with a real output file. It checks
that the placeholder is replaced, later output arrives as chunks, and
the rest arrives before the exit.
- I checked by mutation that removing the read-before-exit, the
replacing snapshot, the UTF-8 decoder, the backlog cap or the shrink
restart each fails a test.

`npm run check`, `npm run build` and `npm run test:run` pass (3555
passed, 30 skipped).
2026-11-02 19:37:49 +01:00
Ben Brandt
4d79b9ffbc feat: list v2 background tool calls (#1511)
Follow-up to #1510, phase 2a of the v2 [Background Work
RFD](https://agentclientprotocol.com/rfds/v2/background-work).

A Monitor, Workflow or backgrounded MCP call returns as soon as its task
starts, so its tool call completed at its result while the task ran on.
#1510 listed such tasks as `_claude/task` items, which clients don't
recognise. This PR lists them as the tool calls that started them, as
the RFD's adapter table describes.

## What a v2 client gets

| When | The tool call |
|---|---|
| Tool result, while the task is in Claude Code's live background task
set | Reports `in_progress` instead of `completed`, and is listed as a
`tool_call` item |
| While listed | Advertises `capabilities.cancel`. `tool_call/cancel`
stops the task with `stopTask`, or returns `-32602` if the call isn't
listed (also when its task ended mid-cancel) |
| `task_updated` with `paused` / `running` | `paused` / `in_progress` |
| The task ends | `completed`, `failed`, or `cancelled` for a stopped
(`killed`) task, with `capabilities: null`, before the list drops it.
The same happens when the live set drops the task without an end event
(`completed`, since nothing was reported), or the query closes
(`cancelled`) |
| `session/resume` | The call's current state, rebuilt from its tool
use, then its capability, before the snapshot |

The task is matched to its call through the structured `taskId` of a
Monitor or Workflow result, or through the live task whose
`task_started.tool_use_id` names the call (backgrounded MCP calls).

`session/cancel` no longer stops background workflows for sessions that
report their background work, because listed work must keep running.
Clients stop them with `tool_call/cancel` instead. v1 and AIR are
unchanged.

A task without such a call is still a `_claude/task` item. It is now
listed only once its `task_started` has arrived and the call that
started it, if any, has returned. Before that, the foreground work is
still waiting on that call, and the spec says foreground work is not
listed.

## Design

- `background-work.ts` holds the logic: registering background tool
calls, pause and end updates, the shared `cancelBackgroundWork` for
`terminal/cancel` and `tool_call/cancel`, and the resume states.
- `acp-agent.ts` only gets wiring.
- `paused` travels as a cast v1 status, alongside the existing
`V2_CANCELLED`.
- `V2Background` advertises and clears the cancel capability.

## Tests

- Six new v2 tests, using the real CLI's event order (tool use, task
joins the live set, `task_started`, tool result) and the `killed` +
`stopped` pair for a stopped task. They cover:
  - listing, pause and resume, and the final status
  - `tool_call/cancel`
  - a query that closes
  - a listed Workflow left running on `session/cancel`
  - resume
  - the `_claude/task` fallback
- I checked by mutation that each new guard fails a test when removed.

`npm run check`, `npm run build` and `npm run test:run` pass (3545
passed, 30 skipped).

## Still to come

- Tailing background Bash `output_file` into terminal output (phase 2b).
- `ScheduleWakeup` / `CronCreate`, which need a signal from the SDK.
- The SHOULD gap about follow-up work is being relaxed upstream in
agentclientprotocol/agent-client-protocol#2745.
2026-11-02 14:40:01 +01:00
Ben Brandt
5565259771 feat: report v2 background work (#1510)
Implements phase 1 of the ACP v2 [Background Work
RFD](https://agentclientprotocol.com/rfds/v2/background-work), now part
of schema v2.0.0-alpha.13. v2 sessions report the work Claude Code keeps
running independently of foreground work as `background` on every
`state_update`.

## What the client gets

The lists are built from Claude Code's live background task set
(`background_tasks_changed`). Tasks Claude Code marks `ambient` are
skipped, since they are housekeeping. The root session and each subagent
session (subagents RFD form) has its own list. Each task is listed once,
in the list of the session that runs it:

| Work | Listed as |
|---|---|
| A Bash command that went to the background | Its display terminal,
from the Bash result until its task ends. While listed, the terminal
advertises `capabilities.cancel`, and `terminal/cancel` stops its task
with `stopTask`. |
| A detached subagent | Its child session, while it works, and while it
is idle with background work of its own. The child reports its own list
on its `state_update` and on the parent's `subagent_update.state`
mirror. |
| Any other task (`Monitor`, `Workflow`, `mcp_task`, …) | A
`_claude/task` item with `taskId` and `description`. Its tool call ends
at its tool result, so it cannot be a `tool_call` item yet. |

The ordering rules the spec requires:
- **List changes:** the state is re-sent with the new list and no stop
reason, so a repeated `idle` is not a new completion.
- **Before a terminal is listed:** a `terminal_update` advertising
`cancel` goes out first.
- **Before a terminal leaves the list:** its `exitStatus` goes out, with
`capabilities: null`. This also holds when the level drops the task
without an end event, or when the query closes (process death, stream
error, sign-out recreation, `session/close`).
- **A child and its parent:** the child's own list goes out before the
parent's list drops it.
- **`session/resume`:** each listed subagent's current `subagent_update`
and each listed terminal's current `terminal_update` come before the
state snapshot.
- **`terminal/cancel`:** it returns `-32602` for a terminal that is not
listed, including one whose command ended while the stop was in flight.

## Gating

Only v2 sessions with `perTaskStopAffordance` report lists. That is the
default for a non-AIR v2 client. Without it, Claude Code's interrupt
kills background tasks, which would break the rule that `session/cancel`
leaves listed work running. v1 and AIR are unchanged.

## Design

- `src/background-work.ts` owns the lists. It builds them from session
state and publishes changes. `acp-agent.ts` only gets wiring calls: one
sync per message-loop iteration, the level update, terminal exits when a
query closes, and the `terminal/cancel` entry point.
- `src/v2/background.ts` adds the lists to v2 state updates and to
subagent state mirrors. The cancel capability lives in `V2Terminals`,
which already owns terminal state.
- `NativeSubagentRuntime.currentUpdate` builds a child's live
`subagent_update` for resume.

## Not in this PR

- **SHOULD gap:** when finished background work starts a follow-up
cycle, the spec wants `running` in the same snapshot that drops the
item. We send `idle []` and then `running`, because the adapter can't
know in advance that Claude Code will start a follow-up. The RFD
acknowledges this.
- **Phase 2:**
- List `Monitor`, `Workflow` and `mcp_task` as `tool_call` items that
stay `in_progress` (or `paused`), with `tool_call/cancel`.
  - Tail background Bash `output_file` into terminal output.
- **Phase 3:** `ScheduleWakeup` / `CronCreate`. The SDK gives no signal
for when one fires or is removed.

## Tests

- New v2 tests cover:
- terminal listing, cancel advertisement, exit ordering, ambient tasks,
and a level drop without an end event
- query death, `terminal/cancel` including the race, and resume for both
terminals and subagents
- a background subagent, an idle subagent kept listed by its own
command, `_claude/task` items, and sessions without
`perTaskStopAffordance`
- I checked by mutation that the guards for each required ordering fail
a test when removed.
- The v2 golden files gain `background: []`, and the subagent ones also
gain child lists.

`npm run check`, `npm run build` and `npm run test:run` pass (3540
passed, 30 skipped).
2026-11-02 14:16:10 +01:00
Ben Brandt
720bbb3e4c fix: update to @agentclientprotocol/sdk 2.4.0 (#1509)
Updates `@agentclientprotocol/sdk` from 2.2.0 to 2.4.0 (schema v1.29.1
and v2.0.0-alpha.13).

## What changed in the protocol

- **v1:** the stable schema and spec pages only changed wording. There
are no requirement changes.
- **v2 `session/cancel`:** the Client no longer answers pending
permission requests with `cancelled`. The Agent MUST stop waiting for
the cancelled work's permission requests and SHOULD cancel each one with
`$/cancel_request`.
- **v2 `session/close`:** the existing MUST to cancel pending
elicitations with `$/cancel_request` before responding now covers
permission requests too.

The adapter already did both: the tool call's signal and the session's
abort controller cancel the request, and a hand-written abort race
stopped the wait. Until now, no test checked that a v2 client actually
receives `$/cancel_request` for a permission request.

## Changes

- Permission requests and elicitations, on both v1 and v2, now use SDK
2.4.0's `stopWaitingOnCancel` option instead of `raceWithAbort`. The SDK
sends `$/cancel_request`, rejects at once with `-32800`, and quietly
drops any late response. Every caller already maps a rejection after an
abort to "Tool use aborted" or to `cancel`, so nothing else changes.
`raceWithAbort` is still used for the eager `tool_call` notification.
- **Tests:**
- **v2:** the existing tests where a `session/cancel` or `session/close`
withdraws a held request now also check that the client received
`$/cancel_request`. For `session/close`, it must arrive before the
response.
- **v1:** new tests over a real SDK connection with a client that holds
requests. These cover the v1 `ClientConnection` wiring, which the
mocked-client tests bypass.
- The v1 mock clients that "ignore cancellation" now model the SDK's
behaviour with a `heldRequest` helper.
- Removed one test that only covered `raceWithAbort` handling a
rejection from the client.
- I checked that the new assertions fail if the `stopWaitingOnCancel`
option is removed or v2 permission requests stop passing their
cancellation signal.

## Follow-ups (not in this PR)

- v2 background work: the `background` list on state updates, the
`paused` status, and `tool_call/cancel` / `terminal/cancel`. The AIR
`asyncTasks` extension stays as it is.
- The new unstable v1 methods (`session/set_title`,
`session/archive`/`unarchive`, `session/status`, `auth/status`).

`npm run check`, `npm run build` and `npm run test:run` pass (3520
passed, 30 skipped).
2026-11-02 11:41:32 +00:00
Ben Brandt
38cf656fc9 feat: report why Claude Code could not start a session (#1508)
When Claude Code refuses to start (invalid proxy URL, wrong organization
sign-in, a required sandbox that can't run, …), `session/new`,
`session/load`, `session/resume` and `session/fork` used to fail with a
generic `Claude Code process exited with code 1. stderr: …` error.

This sets `CLAUDE_CODE_STARTUP_FAILURE_RESULTS=1` on the query env, so
the CLI writes an error result with a `startup_failure_reason` before
exiting on a known startup failure. The SDK then rejects
`initializationResult()` with an error that carries
`startupFailureReason`. The new `src/startup-failure.ts` maps it to an
ACP error:

| `startupFailureReason` | ACP error |
|---|---|
| `gateway_signin_required`, `org_pin_mismatch`, `org_config_refused` |
`authRequired` (signing in again, or with another account, fixes it) |
| `cwd_unavailable` | `invalidParams` (matches the existing cwd
validation) |
| any other reason | `internalError` |

The message is the CLI's own text, which says what to fix (e.g. `Invalid
proxy URL in HTTPS_PROXY: … Fix or unset HTTPS_PROXY and restart Claude
Code.`), and `data.startupFailureReason` carries the reason. Errors
without a reason (older CLIs, unknown causes) pass through unchanged,
and the existing `sessionNotFound` mapping for resumes is untouched.

Checked against the real CLI (SDK 0.3.316): with `HTTPS_PROXY="not a
url"`, `initializationResult()` rejects with `startupFailureReason:
"proxy_invalid"` when the variable is set, and with a plain exit-code
error without it.

## Testing

- v1: `create-session-options.test.ts` covers the env var, each mapping,
and pass-through of errors without a reason.
- v2: `acp-v2.test.ts` covers the `session/new` error over the wire.
- `npm run build`, `npm run check`, `npm run test:run` pass. The
subprocess integration tests also passed locally with this change.

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-02 11:23:33 +00:00
Ben Brandt
1d0306468b fix: update to Claude Agent SDK 0.3.316 (Claude Code 2.1.316) (#1507)
Updates dependencies to their latest versions.

- `@anthropic-ai/claude-agent-sdk` 0.3.312 → 0.3.316 (bundles Claude
Code 2.1.316)
- `@types/node` 26.6.5 → 26.6.6, `eslint` 10.13.0 → 10.14.0, `globals`
17.13.0 → 17.14.0
- `npm update` for transitive patch/minor bumps in the lockfile
- Bumps the "as of" version in two comments about SDK type gaps
(`Query.getSettings()` still missing from the declarations; `unanswered`
still only in the AskUserQuestion output schema). Both were re-checked
against 0.3.316.

TypeScript 7.0.2 is left out: `@typescript-eslint` 8.72.1 supports
TypeScript `<6.1.0`.

## Changelog review (SDK 0.3.313–0.3.316, Claude Code 2.1.313–2.1.316)

No required changes. The SDK type changes only add fields and union
members (`sandbox_required_unavailable` startup failure reason,
`blockWritesOutsideWorkingDirectories` / `blockReadsExemptPaths`
settings, `pending_elicitation_requests` on `initialize`, alpha
`prewarm()` in `/core`), none of which the adapter reads or needs. No
new `fast_mode_disabled_reason` values accompany the Opus 4.8 fast mode
deprecation.

Follow-up planned separately: use `CLAUDE_CODE_STARTUP_FAILURE_RESULTS`
/ `startup_failure_reason` to report typed startup failures to clients.

## Testing

`npm run build`, `npm run check`, `npm run test:run` pass.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-02 12:18:36 +01:00
Ben Brandt
eb78afa855 feat: report subagent working directories (#1496)
Schema v1.28.0 / v2.0.0-alpha.11 adds `cwd` and `additionalDirectories`
to `subagent_update` (agentclientprotocol/agent-client-protocol#2593,
#2660). When a child runs in a different directory from its parent, for
example an Agent call with `isolation: "worktree"`, the Agent must
report the child's `cwd` before sending child traffic that depends on
it.

**Signal**
- The agent reads the `cwd` of the child's own hooks, matched by
`agent_id`. The existing per-child configuration hook already runs on
SubagentStart, PreToolUse, PostToolUse and the others.
- Claude Code runs SubagentStart before the child's first model request
and waits for the hook, so the `cwd` reaches the client before the
child's first tool call.
- `worktreePath` in the Agent tool result arrives only when the child
finishes, which is too late.

**Behaviour (RFD form only; AIR is unchanged)**
- `cwd` is sent only when the child's directory differs from its
parent's, compared with `realpath`. A hook that runs before the
announcement puts the `cwd` in the announcing `subagent_update`.
Otherwise a separate `subagent_update { cwd }` follows.
- A child that moves back to its parent's directory gets `cwd: null`. A
child whose hook `cwd` changes later, for example after a `cd` that
Claude Code keeps, is reported again, since that is where it now runs.
- `additionalDirectories` is never sent. Claude Code gives subagents the
session's additional directories, so inheriting them is accurate.
- Replay uses the child's `.meta.json` (`cwd`, `worktreePath`,
`inheritedWorktreePath`) and the first `cwd` in its transcript. After
the load response, a child whose directory no longer exists gets `cwd:
null`.
- v2 passes the fields through unchanged.

**Not covered**
- `remote` isolation, which fires no local hooks.
- After a reconnect to a live session, the `cwd` the runtime holds is
not sent again.
2026-10-30 16:05:38 +01:00
Ben Brandt
d2ef490a31 fix: validate session, prompt, config, and auth requests per the latest ACP spec (#1495)
Request validation from the ACP schema v1.28.0 / v2.0.0-alpha.11 review.
Each change has a test.

**Paths**
- `session/list` rejects a relative `cwd` with -32602. Without a session
index it passes `includeWorktrees: false`, so the filter matches only
sessions of exactly that directory, not other git worktrees of the same
repo.
- Resume, load and fork check `cwd` and `additionalDirectories` before
anything else. Before, a resume with a bad `cwd` tore down the live
session and then failed, and a fork copied the transcript first.
- Relative `additionalDirectories` entries are rejected with -32602.

**Session setup (v2)**
- Uses the SDK's `assertSupportedMcpServers`, which also rejects
duplicate MCP server names with -32602.
- Rejects an empty `prompt` with -32602.
- The v2 initialize response no longer advertises the AIR `asyncTasks`
capability or the `goal` entry, since the v2 app has no handlers for
`_session/async_task/stop` or `_session/goal`. v1 AIR is unchanged.

**Errors**
- `session/fork` of an unknown session returns -32002 with
`data.sessionId` instead of -32603. This matches the Claude SDK's
"Session … not found" and "Invalid sessionId" messages.
- `authenticate` with an unknown `methodId` returns -32602 instead of
-32603.
- `set_config_option` rejects a value whose type doesn't match the
advertised option type. A `type: "boolean"` option takes `true`/`false`,
and a select option takes a string. Model aliases such as `"opus"` are
still accepted.
- An AIR `session/delete` of a session that has no transcript now
succeeds instead of failing with a generic error.

**Prompts and commands**
- v1 `session/prompt` rejects `audio` and unknown content blocks with
-32602 before queueing them. Before, they were silently dropped.
- Advertised command names that contain whitespace are dropped, and of
duplicate names only the first is kept.
- The `/mcp:server:prompt` rewrite now accepts arguments that span
lines, and applies only to the first text block.
2026-10-30 16:03:58 +01:00
Ben Brandt
f50598a39c fix: align session/cancel, session/close, and v2 setup with the ACP v2 spec (#1494)
**`session/cancel` cancels only earlier prompts**
(agentclientprotocol/agent-client-protocol#2685)
- `cancelTurns` used to read the turn queue again after two awaits: the
compaction interrupt and publishing the subagent state. A prompt that
arrived during those awaits was cancelled too, which is -32800 on v2.
- Each prompt now gets a sequence number when `startTurn` receives it.
The cancel records the count before its first await and cancels only
turns at or below it. That includes a prompt received earlier that
`startTurn` had not queued yet.
- Known gap: if the cancelled turn ends during those awaits and Claude
Code starts the newer prompt, the `query.interrupt()` that follows can
still stop that prompt. Fixing it means skipping the interrupt when
nothing the cancel covers is still running, which also affects
cancelling background work, so it is left for later.

**`session/close` cancels pending requests first**
(agentclientprotocol/agent-client-protocol#2683)
- MCP elicitations, AskUserQuestion, the refusal-fallback dialog and
permission requests now combine their callback signal with the session's
`abortController.signal`.
- `teardownSession` therefore sends `$/cancel_request` for each of them
before `session/close` responds, instead of relying on Claude Code to
abort its callback signals in time.

**v2 setup responses** (agentclientprotocol/agent-client-protocol#2688,
#2603)
- If reading the commands for the setup response fails, the agent now
sends them in an `available_commands_update` after the response. Before,
the client was never sent them.
- On v2, MCP OAuth starts after the `session/new`, resume or fork
response is sent, so its URL elicitation no longer reaches the client
before the response.

Not done: withdrawing a held prompt with `$/cancel_request`. The user
message is already in Claude Code's input once it is queued, and
removing it safely needs `cancel_async_message`, which not every CLI
version has. agentclientprotocol/agent-client-protocol#2636 allows
ignoring the cancel and sending the normal response.
2026-10-30 16:03:03 +01:00
Ben Brandt
3bc671193c fix: forward v2 elicitation values and read dismissed v2 permission prompts as a refusal (#1493)
**Elicitation content** (agentclientprotocol/agent-client-protocol#2595)
- v2 lets an elicitation answer hold any JSON value, such as an object.
The agent checked accepted content with the v1 guard, which allows only
strings, numbers, booleans and string arrays. A v2 accept with an object
therefore reached the MCP server as `{ action: "accept", content: {} }`.
- The agent now forwards the accepted content unchanged, and the MCP
server checks it against its own schema. If Claude Code's MCP client
still uses the older value types, it rejects such an answer with an
error instead of passing on empty content.
- AskUserQuestion reads only string, number and boolean picks, so it no
longer shows an object as `[object Object]`.
- The MCP OAuth url-mode check now tests `action !== "accept"` instead
of the v1 guard.

**Permission `cancelled` on v2**
(agentclientprotocol/agent-client-protocol#2582, #2658)
- In v2, `cancelled` also means the user dismissed the prompt or the
client declined it by policy.
- When no `session/cancel` is in progress, a foreground v2 `cancelled`
is now denied as "User refused permission to run tool", the same denial
background subagents already get, and the turn goes on. Before, Claude
Code reported it as "Tool permission request failed".
- During a `session/cancel` it is still aborted. v1 is unchanged.
- Unknown v2 outcomes map to `cancelled`, so they are also denied now
instead of aborted.
2026-10-30 14:44:34 +00:00
Ben Brandt
c3428de1bf fix: keep native Windows paths in v2 git patches (#1492)
ACP v2 now says each `git_patch` path, once unquoted, is the same native
absolute path string as the matching `changes` entry
(agentclientprotocol/agent-client-protocol#2650).

The v2 form converted `C:\work\App.ts` to `C:/work/App.ts`, so on
Windows the patch and `changes[].path` named different strings. The v2
form now keeps the native path, which Git quoting writes as
`"C:\\work\\App.ts"`. Git's own form, which the AIR extension uses,
still uses forward slashes and the `a/`/`b/` prefixes.
2026-10-30 14:39:21 +00:00
Ben Brandt
1d2b92feae fix: update to @agentclientprotocol/sdk 2.2.0 (#1491)
Moves to ACP schema v1.28.0 and v2.0.0-alpha.11.

- v2 dropped JSON-RPC batching, so `AnyWireMessage` is gone. The test
harnesses use `AnyMessage`, and a request id can now be `null` in the
types.
- The v2 elicitation schema now requires `properties`. A forwarded MCP
schema without them, such as `{ "type": "object" }`, gets an empty map,
so clients on SDK 2.2.0 don't reject it.

Runtime changes that come with the SDK and need no agent code:
- The SDK handles a repeated `initialize`, rejects batches, and rejects
messages sent before initialization. A failed `initialize` now leaves
the connection open.
- v2 `usage_update.cost` is now a patch field, where omitted means
unchanged. The agent sends `cost` only on the result update, so the
client keeps showing the last cost during a turn.

Follow-up PRs for the spec changes in this range are stacked on this
branch.
2026-10-30 14:39:09 +00:00