Compare commits

...

30 Commits

Author SHA1 Message Date
Ben Brandt
7184fd3cfe fix: keep a long Agent call's heartbeat on the session that made the call
A heartbeat of a running Agent call reports under a synthetic id, so the
adapter falls back to parent_tool_use_id, which is the Agent call itself, and
also stamped that id as the beat's parentToolUseId. For a client on the
subagents RFD, which sees the Agent call, the beat and every later update of
the call then went to the child session. Found in a live run with a 40-second
subagent.

The same run showed that Claude Code's interrupt currently stops background
subagents too, so the README and comments no longer say they keep running.
2026-10-13 14:59:44 +02:00
Ben Brandt
9382e10f2c fix: keep the cancel's tool-call cleanup for AIR, whose draft ends every child at the cancel
The previous commit stopped a cancel from clearing each tool call's state, so
that a raced tool call of an RFD client's background child stays on the root.
For AIR that let a later update of an ended child's permission tool call reach
the child's session after subagent_state_update cancelled. Only the RFD form
keeps the state now.
2026-10-13 09:52:54 +02:00
Ben Brandt
4b67682ff9 fix: never announce a nested subagent early or under the root, and keep a raced tool call where it was emitted after a cancel
Two MUSTs of the subagents RFD that rare event orders broke:

- A child is announced before any traffic that names it, and never under a
  guessed parent. An Agent call made inside a child that was known but not
  yet announced registered that child as the parent, so the nested child
  was announced under it before the child itself was; and one made inside a
  child not known yet fell back to the root. In the RFD form, such a call
  now establishes no parent: its update is buffered under the child's tool
  use, as before, and routed again once the child is announced, which
  registers the parent and announces the nested child after it.
- An interaction already issued never moves to another session. A cancel
  cleared the session of every eagerly emitted tool call, a relic of when a
  cancel ended every subagent. A background child keeps running after a
  cancel (agent-client-protocol#2308), so a tool call of its that a
  permission request emitted on the root before the child was announced
  then moved into the child, for example with its next progress beat. A
  cancel now keeps the per-tool-call state (the session, the fields the
  client holds, the hook callbacks); each tool call drops it when it ends,
  and the cancelled turn's own tool calls end through their results or
  closeUnfinished.
2026-10-13 08:28:34 +02:00
Ben Brandt
529718af76 fix: report a failed subagent with the error stop reason
The subagents RFD reports a failure that ends a child's foreground work as
`idle` with `stopReason: "error"` and, when available, the failure as a
JSON-RPC `error` (agent-client-protocol#2281). SDK 1.8.0's v1
`IdleStateUpdate` has that variant, so the stand-in
`_meta.claudeCode.error` goes: a failed child is now idle with the `error`
stop reason, and with the SDK's error text as an internal error, the code a
failed turn uses on the v2 surface.
2026-10-13 08:11:40 +02:00
Ben Brandt
69cd49afeb Merge remote-tracking branch 'origin/main' into feat/subagent-update
# Conflicts:
#	src/acp-agent.ts
2026-10-13 08:02:17 +02:00
Ben Brandt
30ad5f7372 refactor: send each tool call's _meta as the state a v2 client stores on the experimental ACP v2 surface (#1318)
## Problem

A v1 report's `_meta` belongs to that report, and the agent gives each
report its own `_meta.claudeCode` keys. A v2 client stores a tool call's
`_meta`, and any report that carries `_meta` replaces it
(`tool-calls.mdx`; the migration guide since
agentclientprotocol/agent-client-protocol#2355). So a v2 client kept
only the last report's keys. For example, a result's `{ toolName }`
replaced the denial reason that came before it. On top of that, a
PostToolUse hook stored the whole `tool_response` in `_meta`, which for
a Write is the whole file text.

## Change

All of this is v2 only. No v1 or AIR output changes, and no v1 golden
changes.

- **Stored `_meta` (`src/v2/tool-call-meta.ts`).** The v2 bridge keeps
each tool call's `_meta` the way the client stores it. `claudeCode` keys
merge one by one, and every other key replaces whole, the same model
`ToolCallFieldTracker` uses.
  - A report that changes the stored `_meta` carries all of it.
- A report that changes nothing carries no `_meta`, which leaves the
stored value unchanged.
- A permission request's subject always carries the stored `_meta`,
since a client can show the request on its own.
  - The tool call's first report starts from nothing.
- **`toolName` becomes `name`.** `claudeCode.toolName` repeats the
tool's name, which v2 has a field for. It becomes the `name` of a tool
call that has none yet, and is left out otherwise. A memory recall gets
a `name` this way. Subagent routing still reads `toolName` on the
agent's v1 reports, before the bridge.
- **The `toolResponse` AIR gets.** A v2 client also keeps `_meta`, so it
gets AIR's trimmed `toolResponse`:
- a hook carries only the `status` and `isAsync` markers, not output the
content already shows;
- a denial carries the SDK message only when it differs from the reason.

## Evidence

- **v2 goldens:** 28 change. Most only lose `toolName`. Hook reports
lose the repeated stdout or file text. Memory recall gains `name`. The
denial keeps its reason type and message. Subagent tool calls and their
permission subjects keep `parentToolUseId`.
- **Live, CLI 2.1.293 on Haiku, v2:** a Write and a Bash command arrived
with `name` on their first report and no `_meta` at all.
- **Tests:** 7 unit tests for the stored `_meta`.
- **Mutation checks:** removing any of these 9 rules fails at least one
test:
  - naming once;
  - leaving `toolName` out;
- merging with earlier `claudeCode` keys, which also fails the
`subagent-late-child-update` golden;
  - keeping other keys;
  - omitting unchanged `_meta`;
  - starting fresh;
  - the subject's stored `_meta`;
  - the bridge wiring;
  - the trimmed `toolResponse`.

Forgetting a closed session's tool calls is memory cleanup that no test
observes, like `V2Terminals.forget`. A limit of 2048 tool calls also
bounds the memory.
- `npm run check`, build and all 2820 tests pass.
2026-10-13 07:57:05 +02:00
Ben Brandt
383bc7dcf0 fix: start a forked session so the client can prompt it at once (#1317)
## Problem

The v1 schema says a forked session is active once the agent responds,
and can be prompted without `session/load` or `session/resume`
(agentclientprotocol/agent-client-protocol#2359). `session/fork` only
copied the transcript, so prompting the new id failed with "Session not
found" until the client loaded or resumed it.

## Change

- **The fork starts the new session the way a resume does.** It starts
from the copied transcript, with the model and the permission mode that
transcript records, and with the request's `cwd`, MCP servers,
additional directories and `_meta`. The response carries the session's
`modes` and `configOptions`, and the available commands follow it, as
for `session/new`.
- **A client that loads or resumes after a fork keeps working.** A later
`session/load` replays the history into the running session, and a later
`session/resume` returns it. Neither starts a second Claude Code
process. A load or resume with other settings restarts the session, as
for any loaded session. The fingerprint that decides this ignores the
AIR fork point in `_meta`.
- **Cost:** each fork now starts a Claude Code process. That added about
0.65 s to the fork response in the live run.
- **v2:** the v2 fork used to resume the forked session itself. It now
uses the session that the v1 fork starts, so the v2 bridge drops its own
resume.
- **AIR docs:** `docs/air-extensions.md` now says, under the session
fork point, what a load or resume after a fork does.

## Evidence

**Live, CLI 2.1.293 on Haiku, over v1:**
1. A new session answered "Remember the codeword PELICAN" with `OK`.
2. `session/fork` returned the fork with mode `auto` and the `mode`,
`model` and `effort` config options.
3. Prompting the fork right away returned `PELICAN`, with no load or
resume in between.
4. A `session/load` of the fork then replayed its history and was ready
in 4 ms. The timing log shows one session start for the fork and none
for the load, and a further prompt still answered.

**Tests:**
- New: the fork can be prompted at once, and its response carries modes
and config options; a later resume and load reuse the session; and the
fork fails when its session can't start.
- The existing fork tests now also check that the session starts from
the fork, with the request's additional directories.
- The v2 fork tests pass unchanged.
- **Mutation checks:** removing the start, the response's `modes`, or
the additional directories each fails a test.
- `npm run check`, build and all 2813 tests pass, with no golden
changes.
2026-10-13 05:28:48 +00:00
Ben Brandt
9db8a28b64 refactor: show an approved ExitPlanMode plan as a markdown plan on the experimental ACP v2 surface (#1314)
## Summary

When the user approves a plan through ExitPlanMode, a v2 client now gets
it as the session's markdown plan. The update uses the draft's unstable
plan operations:

```json
{ "sessionUpdate": "plan_update", "plan": { "type": "markdown", "planId": "plan-mode", "content": "# Plan…" } }
```

Until now a v2 client saw the plan only in the "Approve Plan" tool call
and its permission request.

## Behavior

- **Its own plan id.** The approved plan has the id `plan-mode`, and the
TodoWrite checklist keeps `plan`, so the client shows both. Claude Code
keeps one plan file per session and rewrites it on a later plan mode, so
each approval replaces the plan before it. Nothing removes it.
- **A normal approval** is a successful ExitPlanMode result. The plan
follows the tool call's result, live and on replay.
- **Live:** the plan comes from the tool's structured output, which
includes edits the user made to the plan.
- **Replay:** the SDK's `getSessionMessages` drops the structured
output, but the transcript keeps the `plan` that Claude Code adds to the
tool's input.
- **An approval that clears the context** ends the tool with an error
result. The restart into the fresh context publishes that plan, after
the new mode and config options and before the fresh context runs. It
does so only once that context exists, so a cancel or a failed restart
leaves no plan behind that was never carried out.
- **Not published:** a rejected plan, a subagent's plan
(`parent_tool_use_id` or `isAgent`), a plan still waiting for its team
lead, and an empty plan. Plans are trimmed.
- **Who gets it.** The agent sends plan operations only to a client that
advertises v1's draft `plan` capability, as the v1 draft requires. The
v2 bridge advertises it for every v2 client and passes `plan_update` and
`plan_removed` through unchanged, since the shapes match. No v1 client
advertises `plan` today, so v1 behavior doesn't change.

## Replay limits

- **After a "clear context" approval, replay doesn't show that plan.**
It reads the transcript from before the clear, where the approval is the
deny, so it shows the plan approved before that, or none. Replay never
reaches the conversation after a clear, so this is part of an existing
gap.
- **A plan approved before a compaction boundary isn't replayed**, like
the rest of the history before it.
- **Replay can't apply the subagent and team-lead checks**, since they
come from the structured output that `getSessionMessages` drops. A
subagent's transcript still carries its `parent_tool_use_id`, so it's
skipped.

## Evidence

**Live, CLI 2.1.293 on Haiku, through the adapter on v2 in plan mode:**
- **Normal approval:** the plan arrived right after "Exited Plan Mode",
and resuming with replay republished it.
- **"Clear context":** the plan arrived after the switch to auto mode,
before the fresh context's first tool call.
- **Raw SDK probe:** the structured result carries `plan`, `isAgent` and
`filePath`, and the transcript's tool use keeps `plan` and
`planFilePath`.

**Tests and checks**
- **Result path:** unit tests for each rule and for the order. They
include structured output versus input, input-only as on replay, and
structured output with no plan.
- **Restart path:** coordinator tests check that the plan comes after
the session state and before the continuation, that a client without the
capability gets none, and that a failed restart publishes none.
- **Agent:** a client that advertises `plan` gets the restart's plan,
and one that doesn't gets nothing.
- **v2:** a replay test and translation tests. The v2
`exit-plan-approve` golden gains the plan update. `exit-plan-reject` and
every v1 golden are unchanged.
- **Mutation checks:** removing any rule on either path fails at least
one test. That covers both capability checks, the agent's capability
wiring, success only, structured output first, the order, trimming, and
the `plan_removed` pass-through.
- `npm run check`, build and all 2810 tests pass.
2026-10-13 07:13:50 +02:00
Ben Brandt
1137df33aa fix: drop streamed output of prompts a cancel abandoned (#1313)
## Problem

After a cancel, the consumer drops the main conversation's consolidated
`assistant`/`user` messages until Claude Code starts a model request
that serves none of the cancelled prompts (#1309). `stream_event` frames
skipped that check. Claude Code always streams, so when it had already
taken an abandoned prompt off its queue, the prompt's answer still
streamed to the client as deltas. Only the consolidated copy was
dropped.

That output arrives after the cancel has been reported: on v1 after the
turn's `cancelled` response, and on v2 after `idle cancelled`, outside
any cycle. Both protocol versions require an agent's updates to come
before that report. Streamed tool calls leaked the same way.

#1309's tests missed this because they scripted only consolidated
messages.

## Change

- **After the client has been told the stopped work ended**, while the
window is still open, streamed text, thinking and tool calls are dropped
like the consolidated messages they belong to. On v1 that report is the
turn's prompt response (no active turn). On v2 it's the idle state (no
open foreground work). The v2 signal also covers cycles that Claude Code
starts on its own and turns that v1 holds for their subagents.
- **Before that report**, what the interrupt flushes still streams, as
it does today. Both drafts allow updates between the cancel and the
cancelled report, and the text is in Claude Code's transcript, so a
reload shows it anyway. Existing tests pin this: v2's queued-prompt
cancel, a held turn cancelled mid-follow-up, and a cancel that arrives
during a buffered backlog.
- **A prompt that is merely queued no longer keeps the window open.**
#1309 held the window while any queued prompt was unsettled. So a
follow-up that Claude Code ran ahead of a prompt sent after the cancel
lost its consolidated copy, and with this change it would have lost its
streamed copy too. Now a request holds the window only when:
  - the stopped turn is still unsettled;
  - an abandoned prompt's result is still expected;
  - the request names a cancelled prompt; or
- the request names an unsettled prompt. That covers one that arrived
while the cancel was still awaiting, before the cancel records it.
- **Per-block drop latch.** Where the consolidated message delivers the
text, a stream that loses a delta keeps dropping until its streamed-text
record resets: at the next message, or when a consolidated message
consumes the record. If a window closes mid-block (an older CLI's late
prompt echo), the record holds nothing, so the consolidated message
delivers the whole block once. Without the latch the record would hold
only the tail, which isn't a prefix of the text, and the client would
get the tail and then the whole text again. A client that gets subagent
text only streamed has no consolidated copy to fall back on, so its
subagent streams don't latch.
- Bookkeeping for dropped events (message id, usage, compaction
heartbeat) is unchanged.

## Evidence

**Tests**
- The "output after a cancel" suite now streams every answer the way
Claude Code does, with Claude Code's follow-up in two chunks. Without
the fix, 9 of its tests leak the streamed copy.
- New v1 tests:
  - a follow-up that runs ahead of a prompt sent after the cancel;
- a prompt sent during the cancel whose request arrives before the
cancel abandons it;
  - an echo in the middle of a two-block answer;
- a subagent stream for a client that gets subagent text only streamed.
- New v2 tests:
  - an abandoned prompt's streamed answer;
  - what the interrupt flushes of a cycle that Claude Code started;
- a held turn's follow-up that the cancel stopped before it showed
anything.
- **Mutation checks:** each of these fails at least one test:
- removing any of the window's four rules, or restoring #1309's hold for
any queued prompt;
  - removing the drop;
  - dropping the flushed tail before the report too;
  - using the v1 signal on v2;
- removing the latch, either of its record resets, or the subagent
condition.
The exception is clearing the latch when a subagent finishes. That's
memory cleanup that no test can observe, like the
`streamedBlocksByParent.delete` beside it.
- `npm run check`, build and all 2796 tests pass, with no golden
changes.

**Live, CLI 2.1.293 on Haiku, v1 and v2**
- A plain cancel mid-answer, and cancels with a background command
finishing during or after the cancelled turn.
- Nothing of the cancelled turn arrived after its report.
- Claude Code's follow-up after the cancel streamed in full. On v2 it
came in its own `running` … `idle end_turn` cycle.
- An abandoned prompt that Claude Code had already dequeued is a narrow
race through the adapter, so I didn't reproduce it live. The tests
follow the order a direct CLI probe recorded for #1309.

## Not changed

- A dropped prompt that runs a tool can still surface it after the
cancel, through `canUseTool` (tool call plus permission request) and the
PreToolUse preview hook. Neither checks the cancel. A check there would
read the consumer's state, which lags the CLI's control channel, so this
needs its own design.
- On CLIs without `request_marker_lists_v1`, the window still lasts
until the next prompt's echo. Claude Code's own follow-ups in that
window now lose their streamed copy as well as the consolidated one. The
bundled CLI has the marker.
2026-10-12 22:18:38 +02:00
Ben Brandt
d074558e4a fix: keep the terminal of a command in the background running until its task ends (#1312)
A Bash call that sends its command to the background returns as soon as
the command detaches, and its tool result carried `terminal_exit`. So a
client that renders terminals, whether Zed's terminal extension or a v2
display terminal, showed the command's terminal as exited, with no code,
at launch, while the command ran on.

## What changes

For a client that renders terminals and isn't AIR:

- **At launch:** the tool result of a command in the background leaves
the exit out (`withRunningTerminal`). The tool call still completes; the
v2 draft keeps "Terminal exit and tool call status are independent".
- **When the command's task ends:** the consumer sends `terminal_exit`.
It does so at the task's `task_notification`, or at its terminal
`task_updated` if that comes first. The update is routed like the tool
call, so a subagent's command ends in the subagent's session.
- **No exit code.** Claude Code names the code only in the
notification's summary text ("…failed with exit code 3"), which the
adapter doesn't parse. `completed` doesn't mean 0 either: Claude Code
accepts some non-zero codes as a success, such as grep's 1.

The terminal still exits at the tool result, as before, in four cases:
- **A fast command** whose task ended before its tool result.
- **A task that started after the tool result.** Claude Code sends
`task_started` first, but the existing `bash-background` scenario
scripts the reverse, so its goldens don't change.
- **AIR,** which shows the command through its async tasks.
- **Replay,** where the command from before the reload no longer runs,
and the structured tool result isn't available anyway.

## Evidence

- **Live, CLI 2.1.293, through the built adapter over ACP:**
- **v2:** the terminal stays running when the Bash call completes and
the turn goes idle. `terminal_update` with `exitStatus: {}` arrives when
`sleep 5` finishes, before Claude Code's follow-up.
- **v1 with `terminal_output`:** no exit at the Bash result;
`terminal_exit` with code `null` arrives when the command finishes.
- **Recorded, CLI 2.1.293:**
- the order is `task_started` (`local_bash`, `is_backgrounded: true`),
then the Bash result with `tool_use_result.backgroundTaskId`, then
`task_updated` and `task_notification` when the command ends;
  - neither event carries a structured exit code.
- **Tests:**
- v2: the terminal runs until the task ends, with one exit after the
turn's `idle`; and a task that ended before the result exits at the
result.
- v1 with Zed's terminal extension: the exit comes after the prompt's
answer. AIR keeps its exit at the result.
- Unit tests for `withRunningTerminal`, including dropping a `_meta`
that would be left empty, since an empty `_meta` would clear what a v2
client stores.
- **Mutations:** removing the strip, the end-of-task exit, the liveness
guard, or the AIR guard each fails a test.
- `npm run check`, build and the full suite pass, with no golden file
changes.
2026-10-12 20:53:07 +02:00
Ben Brandt
14b2233192 refactor: leave requests of agents without a record out of requires_action on the experimental ACP v2 surface (#1311)
A follow-up to #1310, from its review, which found no regressions but
two wrong answers. Both come from one choice in #1310: a request from an
agent without a `liveBackgroundTasks` record put v2's foreground work in
`requires_action`.

**When an agent has no record:**
- **A teammate:** it never gets one.
- **A settled subagent:** settling deletes its record, and settling
didn't re-check open requests.
- **A foreground subagent nested in a background one:** Claude Code
leaves it out of `background_tasks_changed`, so the level reconciliation
prunes it after two activations.

The main conversation waits on none of them. A background subagent's
request that the consumer read before its `task_started` also flickered
from `requires_action` to `running`.

## What changes

- **An agent without a record no longer blocks the foreground work.** A
subagent the main conversation waits on has a record by the time it
asks: its `task_started` comes first on the wire, and nothing prunes it
while the main turn waits. When the consumer reads the request first,
the `task_started` checks again, so `requires_action` comes slightly
later instead of a flicker for background subagents.
- **Settling a task re-checks** the open requests.
- **The comment on `nativeSubagentParentByToolUseId`** no longer claims
`parent_task_id` is unread.

## Tests

- **Replaced:** "stops waiting for a request once its subagent turns out
to run in the background". The new test checks that a request read
before its `task_started` doesn't block, then blocks once the
`task_started` says foreground.
- **New:** a foreground subagent's open request stops blocking once
Claude Code stops that subagent.
- **Mutations:** restoring "no record blocks" fails both tests; dropping
the re-check at settle fails the second.
- `npm run check` and the full suite pass, with no golden file changes.
2026-10-12 20:23:46 +02:00
Ben Brandt
c22ec1d189 refactor: report requires_action only for requests that block the foreground on the experimental ACP v2 surface (#1310)
v2's foreground work reported `requires_action` whenever any permission
request or question in the session was open, including a background
subagent's. The draft keeps `requires_action` for foreground work that
is blocked on user action, and a background subagent asks without
blocking the main conversation. So a v2 client saw a running turn as
waiting on the user while it kept working.

## What changes

A request blocks the foreground work when it is:
- the main conversation's own request, or
- the request of a subagent that runs in the foreground of the main
conversation, so the tool call that spawned it waits on it. This applies
up a chain of such subagents.

A request from a subagent in the background, or from one nested inside
it, no longer counts.

- **Where each task runs.** The adapter records this per task:
  - from `task_started.is_backgrounded` and `parent_task_id`;
- from a later `task_updated` patch that moves the task to the
background;
  - Claude Code always resumes a subagent in the background.
- **Unknown subagents.** A subagent without a record still counts,
because its request can arrive before its `task_started`. Registering a
task, or moving one to the background, checks the open requests again.
- **Requests that name no agent** still count: MCP elicitations and
dialogs.
- **Steering is unchanged.** It still treats any open request as
blocking. `Session.pendingUserInputCount` becomes
`Session.openUserInputs`: the open requests, each with the subagent that
asked.
- **v1 is unchanged.**

## Evidence

- **Live, CLI 2.1.293 via the SDK with a logging `canUseTool`:**
- `task_started` carries `is_backgrounded: true` for a background
subagent and `false` for a foreground one.
- A subagent's `canUseTool` call carries `agentID` equal to its
`task_started.task_id`.
  - `task_started` arrived before the subagent's request in both cases.
- `parent_task_id` for nested subagents was not checked live; it's
documented in the SDK types and covered by a test.
- **6 new v2 tests:**
- a background subagent's permission request leaves the turn `running`,
and so does its question;
  - a foreground subagent's request reports `requires_action`;
  - a foreground subagent launched by a background one doesn't block;
- a request that arrives before its subagent's `task_started` stops
blocking once the subagent turns out to run in the background;
- a request stops blocking once Claude Code moves its subagent to the
background.
- **Mutation checks.** Removing each rule in turn fails at least one
test. That includes passing the subagent through for permission requests
and for questions, the recheck at registration and at `task_updated`,
and the walk up the chain.
- `npm run check`, build and the full suite pass, with no golden file
changes. Two resume tests now expect the resumed task to be recorded as
backgrounded.
2026-10-12 20:08:01 +02:00
Ben Brandt
b007ac7c45 fix: end a cancelled turn at its interrupted result and deliver Claude Code's follow-up after a cancel (#1309)
After a cancel, Claude Code ends the interrupted turn with an aborted
result, then keeps working through its queue, and reports `idle` once at
the end. The queue can hold a prompt it had already taken off it, then
its own follow-ups, such as the summary of a background command that
finished. The adapter settled a cancelled turn only at that final
`idle`, and dropped everything in between until the next prompt
activated. As a result:

- **v1:** a cancelled `session/prompt` waited for any follow-up queued
behind it to finish.
- **v2:** a follow-up after a cancel streamed as text with no `running`
or `idle`.

This PR ends the cancel where Claude Code's own messages say the stopped
work ends.

## What changes

- **Where a cancelled turn settles.** It now settles at the result of
the work the interrupt stopped: its own result, or, for a turn held open
for its subagents, its follow-up's aborted result.
- The `idle`, the next echo's hand-off and the force-cancel backstop
still settle a turn whose result never comes.
- That result owes its trailing `idle` like any settling result, a
steered turn's included.
- **When the cancel ends.** The cancel records the prompts it stopped:
the unsettled turns and their steers when it arrives, and whatever it
abandons when it sweeps the queue.
- With `request_marker_lists_v1`, every `status: requesting` frame lists
the prompts its model request serves. A cycle Claude Code starts on its
own lists none.
- The cancel ends at the first request that serves none of the recorded
prompts, once no turn is unsettled and no orphan result is outstanding.
Compaction updates resume there too.
- A prompt Claude Code had already dequeued names itself, so its answer
stays dropped even when the interrupt receipt leaves it out.
- Without the capability, the cancel ends at the next activation, as
before.
- **Abandoned prompts' results.** A result that names a cancelled prompt
never promotes the next queued prompt, so a prompt sent after the cancel
can't take an abandoned prompt's result.
- **Held turns.** Since CLI 2.1.270, Claude Code reports `running` for
as long as background agents live. So with request markers, a held
turn's follow-up counts as live only when a model request has started
since the turn's result. A cancel while only the subagent runs settles
the held turn at once.
- **v2: aborted cycles.** A cycle whose result is aborted
(`terminal_reason` `aborted_streaming` or `aborted_tools`) ends
`cancelled`.

## Evidence

**What CLI 2.1.293 sends.** I recorded the stream live with a probe that
drives the SDK the way the adapter does. After an interrupt:
1. The interrupted turn's aborted result comes first.
2. Next, any prompt Claude Code had already taken off its queue runs.
Its request names it.
3. Then its own follow-ups run. Their requests name no prompt.
4. Finally, one `idle`.

The interrupt stops background agents, but not background commands.

**An audit of the bundled CLI** found exactly one source of `status:
requesting` frames: one per main query-loop iteration. Subagents,
compaction, side queries, hooks and retries emit none, or only a named
one.

**The adapter, end to end over ACP against the live CLI**, this branch
against `main`:

| Scenario | `main` | This PR |
| --- | --- | --- |
| v1: a background command finishes while the turn the user cancels
streams | `session/prompt` answered 14.1 s after the cancel | answered 4
ms after the cancel; the follow-up's text arrives after |
| v2: same | follow-up text with no `running`; `idle cancelled` 14.8 s
after the cancel | `idle cancelled` 10 ms after the cancel, then
`running`, the follow-up, `idle end_turn` |
| v2: a background command finishes after the cancelled turn | `idle
cancelled`, then text with no `running` or `idle` | `idle cancelled`,
then `running`, the follow-up, `idle end_turn` |
| v2: cancel while idle, then a background command finishes | text with
no `running` or `idle` | `running`, the follow-up, `idle end_turn` |
| v1: cancel while a background subagent holds the prompt open | (not
compared) | answered 2 ms after the cancel |

## Tests

- **20 new tests, scripted on the recorded orders.**
- v1 suite "output after a cancel" (12): a cancelled turn and a cancel
while idle; a dequeued prompt the receipt leaves out; a steer; a prompt
that Claude Code folds into its own follow-up; a prompt sent while the
cancel is still in progress; a repeated cancel; trailing idles after a
cancelled turn and after a steered one; older CLIs without request
markers.
- Held turns (3): a cancelled held turn settles at its follow-up's
aborted result, not at a placeholder result, and at once when only its
subagent runs.
- v2 (5): the follow-up after a cancelled turn as its own cycle; a
cancel while idle; a later cycle after a cancelled one; a cycle Claude
Code started before the cancel arrived; a follow-up's compaction.
- **Mutation checks.** Removing each rule in turn fails at least one
test. Two exceptions:
  - Clearing the record at activation can't be observed.
- Holding for any unsettled turn, not only a recorded one, can't be told
apart from activation.
- `npm run check`, build and the full suite pass, with no golden file
changes.

## Review

The design went through two rounds of independent review.

- **The first attempt, rejected.** It judged "the stopped work is over"
from the adapter's own bookkeeping. Review found that the interrupt
stops what Claude Code is running, which the adapter may not have seen
yet. That attempt is not in this PR.
- **This design.** A second round audited the CLI's signals and the
state machine. This PR fixes what it found:
- a prompt sent after a cancel could take an abandoned prompt's result;
  - a held turn could settle at a placeholder result;
- the window could close while an abandoned prompt that Claude Code
folds into its own follow-up was still due;
  - a prompt sent during the cancel's awaits wasn't recorded;
  - a held turn's follow-up was misread as live on CLI 2.1.270+.

## Known limitations

- **The cancelled turn's last streamed text still arrives after the
cancel**, as on `main`: the cancel never held streamed text back.
- **If the cancel lands just after Claude Code started its own
follow-up**, the interrupt stops that follow-up, and its partial output
gets through. On v2 it ends `cancelled`.
- **Trailing-idle debt can be over-counted by one in a drain run.** The
next `running` transition sweeps it, as before.
2026-10-12 19:17:16 +02:00
Ben Brandt
412da2789a refactor: report idle at a turn's result and Claude Code's own model cycles on the experimental ACP v2 surface (#1305)
Stacked on #1304, which this targets; it retargets to `main` once #1304
merges. Together they replace #1303.

## What a v2 client gets now

**`idle` at a turn's result,** also when v1 holds the turn's answer for
the background subagents it spawned (`Turn.deferredSettle`).
- The subagents' output and permission requests arrive while the session
is idle. The draft allows that: background activity may send updates
while idle, and they don't change the state.
- A background request while idle no longer shows `requires_action`,
which the draft reserves for foreground work.
- The hold itself is unchanged. It now only delays v1's answer, with no
v2 branch in the hold logic.

**Model cycles that Claude Code starts on its own** are foreground work:
the follow-up after a background subagent or command finished, or its
answer to a message from another session.
- A cycle reports `running` before its first output, `requires_action`
while it waits, then `idle` with its own stop reason, usage and
`_meta.quota`.
- An error result gives `idle` with the `error` stop reason, and a
cancel gives `idle cancelled`.
- Before this, such a cycle streamed while v2 said `idle`.

**Take-overs:** a prompt that Claude Code takes in during a cycle takes
it over, with no new `running` and no `idle` in between. So do a steer
that resumes a held turn, and the continuation of an accepted
clear-context plan. In both, the final `idle` carries the turn's usage,
not just the last cycle's.

v1 and AIR clients are unchanged.

## How

All of this builds on #1304's `ForegroundEvents`:

- **A turn's work ends at its result:** `endForeground` in the defer
branch of `settleOrDefer`, and where the steer idle lane re-applies a
hold.
- **A cycle starts** (`startAutonomousCycle`) at model output of the
main conversation while no work runs.
  - Subagent output doesn't count.
- Without command lifecycles, output while a prompt is queued stays that
prompt's, since its output can come before its echo. With lifecycles
(`msg_lifecycle_v1`), a prompt Claude Code hasn't started has no output
yet, so a cycle starts. This narrows the gap that #1303 left.
- **A cycle ends:**
- at a result no turn took, except a placeholder result (`num_turns:
0`);
- with `cancelled` at the diagnostic of an ExitPlanMode answer, as a
turn does, unless a held turn continues the accepted plan;
  - at an SDK `idle` that no earlier result owes;
  - when the stream ends or fails;
  - before `session/close` answers.
- **Starting work while a request is open** reports `requires_action` at
once.
- **The cycle's stop reason** comes from its result, as for a turn
(`autonomousCycleEnd`). Its `_meta.quota` uses the same builder as a
turn's (`quotaMeta`, extracted from `turnQuotaMeta`).

## Checks

- `npm run check`, `npm run build` and all 2756 tests pass. No golden
file changes against #1304: no recorded scenario has a held turn or
output while no turn is active.
- New tests:
- **14 v2 tests:** #1303's eleven, plus a held turn reporting `idle` at
its result with its follow-up as a cycle and a background request while
idle, output before a queued prompt that Claude Code reports it hasn't
started, and `session/close` during a cycle;
- **agent:** a steer resuming a held turn's work; the follow-up that
ExitPlanMode's "keep planning" interrupts ending `cancelled`; the
clear-context continuation taking the cycle over, with the turn's usage;
- **turn events:** work that starts while a request is open waits at
once.
- I made 20 mutations, one per rule above, and each failed a test. The
take-overs for a steer and for the clear-context restart first survived,
since a cycle stood in for the turn with the same states. The tests now
check the usage of the final `idle` too.

## Not covered

- No recorded scenario has such a cycle. The start signal (the first
main-thread output) is chosen from the agent's side, not confirmed
against a live CLI.
- After a cancel, the agent drops main-thread output until the next
prompt (`session.cancelled` stays set). So a follow-up that Claude Code
runs after a cancel is still lost, for v1 and v2. That predates this PR,
and I'll fix it separately.
2026-10-12 14:28:15 +02:00
Ben Brandt
2a8417ab89 refactor: report a session's foreground work apart from the prompt's answer on the experimental ACP v2 surface (#1304)
The first of two PRs that replace #1303, after a design review. This one
restructures how the agent reports v2's state, with two small changes in
behavior (below). The next PR makes v2 report `idle` at a turn's result
and adds model cycles that Claude Code starts on its own.

## Why

The agent reported each turn's v2 states (`running`, `requires_action`,
`idle`) through the prompt's `TurnEvents`, the same events that answer
the prompt. That ties v2's state to when **v1** gets its answer. The
clearest case is the hold (`Turn.deferredSettle`): v1 holds a turn open
while the background subagents it spawned run, because a v1 client stops
listening at the prompt response. So v2 stays `running` too.

Making v2 report `idle` at the turn's result in that design would need a
v2 branch inside the hold logic. That's the most intricate code in the
adapter: steering, cancel, the echo hand-off, ExitPlanMode, idle debts.
And a cycle that Claude Code starts on its own would need a second
source of v2 state beside the turn's (#1303).

## What changes

- **`TurnEvents` only answer the prompt:** `inserted`, `ended`,
`failed`. v1 answers at `ended` or `failed`. v2 answers at `inserted`,
and uses `ended`/`failed` only to reject a prompt before insertion.
- **New `ForegroundEvents`** (`src/turn-events.ts`): the session's
foreground work, meaning what a client would wait for before sending the
next prompt. Events: `running`, `awaitingUser`, `ended`, `failed`.
- Only the v2 surface supplies them, through the new `foreground`
option. They map to `state_update` in `v2Foreground`.
- A prompt's turn is the foreground work from its insertion until it
ends, exactly as before. So `idle` still waits for a held turn's
subagents; the next PR changes that.
- **The work is keyed by the session id the client knows,** not stored
on a `Session`. A clear-context restart (an accepted ExitPlanMode with a
fresh context) replaces the Claude Code session behind that id and moves
the running turn to it. Work stored on the old `Session` would stay
behind.
- **Requests report through the work** (`syncAwaitingUser`), not through
the active turn.

## Observable changes on v2

1. **The `session/prompt` response now comes before `running`, not after
it.**
- The 38 v2 golden files change by exactly that, and nothing else; I
checked every file with a script.
- The old order was not guaranteed: the SDK's `notify` writes after
`waitUntilInitialized(...).then(...)`, and the response is written once
the handler's promise resolves. The old code won that race by sending
`running` before resolving.
- The draft lets clients receive the updates before or after the
response.
2. **A request that Claude Code opens for a prompt it started, before
the prompt's echo, now reports `requires_action` when it opens,** rather
than at the echo. The prompt's work is already running then.

v1 and AIR clients are unchanged.

## Checks

- `npm run check`, `npm run build` and all 2739 tests pass.
- Every v2 state test in `acp-v2.test.ts` passes unchanged.
- `turn-events.test.ts` now reads the waiting events from the
foreground.
- New tests:
  - a request opened before the echo;
- the work staying with the session id across a clear-context restart,
with a permission request in the continuation.
- I made six mutations and each failed a test: no `running` at
insertion, no end on `resolve`, no end on `reject`, waiting never
reported, waiting only for the active turn, and the work tied to the
`Session` object.
2026-10-12 14:23:37 +02:00
Maksim Zheltoukhov
fe8b69edd4 feat: accept IDE session hooks without dropping model settings (#1265)
IDEs can pass their own Claude hooks through
`_meta.claudeCode.options.settings.hooks` on `session/new` and
`session/load`. Before this change, any session settings replaced
`CLAUDE_MODEL_CONFIG` entirely, so adding a single hook dropped the
deployment's `modelOverrides` / `availableModels`. The two are now
merged, and the session settings win per key.

The wrapper does not merge hooks itself: Claude Code already runs the
hooks of every settings tier, so user and project hooks keep running
next to the session ones. Hooks stay scoped to their session, and a
`session/load` with different settings recreates the query, which
replaces or removes them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:00:07 +02:00
Ben Brandt
b3bed2f763 fix: report a command whose result says interrupted as cancelled on the experimental ACP v2 surface (#1301)
#1289 made a v2 client get `cancelled` for a run that Claude Code
aborted, when Claude Code reports it as an error result with
`non_execution_kind`. Claude Code has a second way to report an aborted
command: a **successful** Bash result whose structured output says
`interrupted: true`. A v2 client got `completed` for that. With this PR
it gets `cancelled` too, so the rule is the same however Claude Code
reports the abort.

## When Claude Code sends that result

I read the CLI that SDK 0.3.293 bundles. I didn't reproduce these cases
live.

| What stops the command | What the adapter receives | v2 before | v2
after |
| --- | --- | --- | --- |
| The client cancels (`session/cancel`) | an error result,
`non_execution_kind: "interrupted"` | `cancelled` (#1289) | same |
| A timeout | an error, "Command timed out after …", **not**
`interrupted` (or the command is backgrounded) | `failed` | same |
| The output limit or a replaced output file | an error with `Exit code
137`, not `interrupted` | `failed` | same |
| A `now`-priority message such as `_session/steering`, landing just
before the command starts, or while the output limit kills it | a
successful result, `interrupted: true` | `completed` | **`cancelled`** |

A successful `interrupted` result needs the abort reason `interrupt`,
which a `now`-priority message causes. That abort doesn't kill a running
command, so this result only comes from the races in the last row.
Clients will rarely see it. The point of the change is that v2's status
follows what Claude Code reports, however it reports it.

v1 and AIR clients are unchanged: `completed` with no exit code, and AIR
still gets exit code 1.

## How

- `AcpToolCallRenderer.resultStatus` takes the command's `interrupted`
fact alongside `nonExecution`.
- `result()` used to call `resultFields`, which drops the command facts.
It now computes the facts once (`resultFacts`) and builds the fields
from them (`fieldsOfResult`). That lets it pass `interrupted` without
adding a field to `RenderedResult`, which the public
`toolUpdateFromToolResult` returns.
- The agent's checks for a finished tool call already count `cancelled`
(`isFinalToolCallStatus`, #1289).

## Checks

- `npm run check`, `npm run build` and all 2730 tests pass. No golden
file changes: no recorded scenario has an interrupted command.
- New tests:
- **renderer:** an interrupted command on v2, v1 and AIR, and one that
isn't interrupted on v2;
- **v2 surface:** a v2 client receives `cancelled`, and the turn still
ends `end_turn`.
- I made four mutations and each failed a test: the rule ignores
`interrupted`, every client gets `cancelled`, `result()` doesn't pass
the fact, and `result()` inverts it.
2026-10-12 12:15:06 +02:00
Ben Brandt
77910dea74 refactor: report a tool call that Claude dropped before it ran as cancelled on the experimental ACP v2 surface (#1300)
v2 has a `cancelled` tool call status ("the tool call was cancelled
before it completed"), which v1 doesn't. #1289 started using it for a
run that Claude Code aborted. This PR uses it for the other tool call
that never ran: one that Claude dropped before running it.

## What changes

When a turn ends with `end_turn` and a streamed `tool_use` never reached
a complete assistant message, Claude dropped that tool call before it
ran, for example when it switched to a steering message. The agent
closes it at the end of the turn with "Claude stopped this tool call
before it ran." The turn itself still succeeds.

| Case | v2 client | v1 and AIR clients |
| --- | --- | --- |
| Dropped before it ran | `cancelled` (was `failed`) | `failed`, as
before |
| Ran, but Claude Code returned no result | `failed`, as before, and the
turn fails with `incomplete_tool_call` | same |

The content text is unchanged in both cases.

## How

- **The renderer builds both closing reports**
(`AcpToolCallRenderer.unfinished`), and picks the status from
`ClientCapabilities.v2`, as `resultStatus` does since #1289. Before, the
agent built the report inline with a hardcoded `failed`.
- **The agent** passes whether the tool ran. Its closing step
(`failToolCall`, now `closeUnfinished`) already removes the call from
all of its tracking itself, so no "is this status final" check needed
changing.

## Checks

- `npm run check`, `npm run build` and all 2728 tests pass. No golden
file changes: no recorded scenario drops a tool call.
- New tests:
  - **renderer:** dropped and ran-without-result, on v2, v1 and AIR;
- **v2 surface:** a v2 client receives `pending` then `cancelled` for a
dropped tool call, and the turn ends `end_turn`.
- The existing v1 tests in `incomplete-tools.test.ts` are unchanged and
still pass.
- I made five mutations and each failed a test: the renderer never
cancels, the renderer cancels a tool that ran, the agent marks a dropped
call as having run, the agent marks a no-result call as dropped, and the
agent's renderer ignores the client's capabilities.
2026-10-12 11:57:21 +02:00
Ben Brandt
231bbd7dd6 refactor: show an Edit's or Write's exact patch before it runs on the experimental ACP v2 surface (#1291)
A v2 diff can't hold the snippet of an Edit's input (it needs line
numbers and the operation), so on v2 an auto-approved Edit or Write
showed its change only once the PostToolUse hook reported it, after the
tool had already run. Only an asked one showed its approval patch first.

## What changes, v2 only

The tool call shows the exact patch before the tool runs, auto-approved
or not.

## How

- **A PreToolUse hook for Edit and Write** (`fileChangePreviewHook`),
registered only on v2 connections. Claude Code runs the tool only after
the hook returns, so the file is still unchanged when the hook reads it.
That's the same guarantee the approval patch in `canUseTool` relies on.
The hook sends the same patch (`previewPatchContent`):
- **Stream already showed the tool call:** a `tool_call_update` through
the session's stream router, pinned like an approval patch.
- **Hook came first:** the SDK dispatches hook callbacks as soon as it
reads them, ahead of the message queue the agent consumes. The hook then
emits the tool call with the patch via `ensureToolCallEmitted`, the
permission flow's fix for the same race (#851).
- **Subagent tools** are attributed exactly as in `canUseTool`. That
rule moves into `toolUseOwner`, which both use, with no behavior change.
- **Safe failure:** the hook makes no permission decision, and any
failure only leaves the preview out.
- **v1 and AIR** register no PreToolUse hook, so Claude Code waits on
nothing new there and their output is unchanged.

I first tried hanging the preview on the per-tool-use hook registry that
PostToolUse uses. That fails when the hook comes before the stream, so
the hook lives on the agent instead.

## Golden files

The scenario harness now runs PreToolUse before each tool, as Claude
Code does, and applies each hook's `matcher`. The v2 golden files of the
four file-change scenarios change:
- **`write-existing`** (auto-approved): the patch now arrives before the
tool completes, and the final hook report no longer repeats it, because
the tracker sees no change.
- **`write-new`, `edit-with-permission`, `edit-rejected`:** the tool
call gets the patch just before the permission request, which carries
the same patch.

No v1, AIR or plain golden file changes.

## Checks

- `npm run check`, `npm run build` and all 2497 tests pass.
- A new v2 test runs an auto-approved Edit with the hook both after and
before the streamed tool use. It checks that the patch is computed from
the file before the edit, arrives before the result, and isn't replaced.
- A new test checks that only a v2 session registers the hook, with the
`Edit|Write` matcher.
- Mutations that fail tests: no hook on v2, the hook on v1 too, the
hook-first path doing nothing, the already-shown path sending nothing,
and Edit only.
- Not pinned by any test: the pin itself. On v2 nothing later in the
stream carries content for an Edit or Write, so it's only there for
consistency with the approval patch.

## Follow-up

AIR has the same gap for an auto-approved Write: it shows no diff until
the hook. That's out of scope here, since it changes AIR's output.
2026-10-12 11:31:13 +02:00
acp-release-bot[bot]
4e1fc1e8df chore(main): release 0.89.1 (#1294)
🤖 I have created a release *beep* *boop*
---


##
[0.89.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.89.0...v0.89.1)
(2026-10-10)


### Bug Fixes

* push renamed sessions to list subscribers and reuse one listing across
pages
([#1293](https://github.com/agentclientprotocol/claude-agent-acp/issues/1293))
([92abb8f](92abb8fd4d))

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

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-10 13:10:02 +04:00
nikita-ashihmin
92abb8fd4d fix: push renamed sessions to list subscribers and reuse one listing across pages (#1293)
Two findings of the 2026-10-10 night perf run on a 5,000-session home
(findings 8 and 9).

## Rename push (finding 9)

The run measured "no push within 10 s" after `_session/rename`. The
cause was the harness, not the adapter. The bench renames the newest
session to `"Night renamed"`, and an earlier bench run on the same home
had already given it that title. The transcript shows that pair of title
records twice. A rename to the current title appends nothing, so there
was nothing to push. On a fresh home, the same bench gets the push. The
hypotheses checked:

- The metadata cache is not updated by a rename. The appended records
change the transcript's size, and the watcher re-reads it.
- The sidecar lies outside the non-recursive watch, but so does every
rename. Each one also appends to the transcript, which is watched.

Still, the push depended on `fs.watch` plus its 150 ms quiet period. Now
a rename, archive, unarchive or delete through this connection publishes
at once. `ListSubscriptions.sessionWritten` stats the session's
transcripts and reads its row, the way a transcript event would, without
waiting. The event that follows finds the row unchanged and sends
nothing. Renames by other processes still arrive through the watcher.
The 1 s per-session interval of a subscription still applies.

## Listing every page (finding 8)

Profiling `SessionIndex.list` showed where a page's time goes. The full
enumeration and stat of 5,000 transcripts takes about 20 ms per page.
Most of the remaining ~380 ms was the SDK `getSessionInfo`, called once
per row read: its lookup runs a `readdir` of the whole project directory
(5,000 entries) on every call.

- A metadata read no longer calls the SDK. Titles come from the head and
tail the index already reads, by the same rule (`sdkTitles`). The tail
is searched as the SDK searches it, cut first line included.
- The pages after a cursor reuse the list's enumeration. It is keyed by
the cursor and kept for at most 30 s, at most 8 lists. It is dropped as
soon as a directory of the scope adds or removes an entry, through the
`DirListings` identity check. Such a page stats only the transcripts it
reads (each with its session's other copies) right before reading them.
A transcript that changed since either keeps its order key (no new
prompt) or moves up before the cursor, so the page is as fresh as one
from a new enumeration. The first page always enumerates.

## Measurements

Real adapter (`dist/index.js`), driven as AIR drives it by
`acp-perf-harness/air-ui/night/acp-adapter-bench.mjs`. Fixture: 5,000
sessions from `gen-night-data.mjs`.

| | before | after |
|---|---|---|
| Listing every page (25 × 200) | 10.1–16.3 s | 0.92–1.16 s |
| First page (51) | 156–472 ms | 46–62 ms |
| Show more (50) | 117–283 ms | 13–23 ms |
| Push after archive | ~170 ms | 3–4 ms |
| Push after a rename | ~170 ms | 3–5 ms |

## Tests

- A rename, archive, unarchive and delete each push within 500 ms while
the watcher's quiet period is 60 s (fails without the fix). A rename by
another process is pushed through the watcher.
- Paging:
  - one enumeration for all pages;
- between pages, a transcript emptied, one with a new prompt, and one
with only a new answer give the same pages as a new enumeration;
- an older copy of a session that got a prompt hides it on later pages;
  - adding or removing a transcript enumerates again.
- A custom title whose record starts before the tail window matches the
SDK.
- Read-count assertions use the new `SessionIndex.transcriptsRead`
counter instead of SDK call counts.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:06:40 +04:00
acp-release-bot[bot]
96b6b4f478 chore(main): release 0.89.0 (#1279)
🤖 I have created a release *beep* *boop*
---


##
[0.89.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.88.0...v0.89.0)
(2026-10-09)


### Features

* add the AIR sessionIndex extension for fast session lists
([#1268](https://github.com/agentclientprotocol/claude-agent-acp/issues/1268))
([03d84f6](03d84f6663))
* **deps:** Bump @agentclientprotocol/sdk to 1.8.0
([#1287](https://github.com/agentclientprotocol/claude-agent-acp/issues/1287))
([446a721](446a7217fa))
* Support custom instructions for agent sessions via meta
([#1177](https://github.com/agentclientprotocol/claude-agent-acp/issues/1177))
([908c994](908c994901))


### Bug Fixes

* recreate live sessions on logout when it clears a provider or gateway
override
([#1278](https://github.com/agentclientprotocol/claude-agent-acp/issues/1278))
([966be7a](966be7abe6))
* report a failed turn with the error stop reason on the experimental
ACP v2 surface
([#1288](https://github.com/agentclientprotocol/claude-agent-acp/issues/1288))
([060bdb2](060bdb212a))
* report an interrupted tool call as cancelled on the experimental ACP
v2 surface
([#1289](https://github.com/agentclientprotocol/claude-agent-acp/issues/1289))
([55c3106](55c3106c60))

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

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-10 02:03:17 +04:00
nikita-ashihmin
03d84f6663 feat: add the AIR sessionIndex extension for fast session lists (#1268)
## Summary

Adds the opt-in AIR `sessionIndex` extension. With it, an AIR client can
show the latest sessions of a project quickly, with their state, and can
rename, archive and unarchive them over ACP. The full contract is in
`docs/air-extensions.md` ("Session index").

The extension follows ACP RFDs. The transport differs: it uses
`_meta.jetbrains.air` request options and row fields, `_session/*`
methods, and AIR capabilities that are not advertised under ACP v2. The
`archived` list filter also differs (see below).

- **Session list extensions**
(agentclientprotocol/agent-client-protocol#2323):
- `limit`, ordered by last user activity (`lastPromptAt ?? updatedAt`),
and `includeWorktrees`.
  - Row fields `lastPromptAt`, `model` and `forkedFrom`.
- `state`: `running`, `idle`, `requires_action`, and `error`, which the
RFD lacks.
  - `lastTurnEndedAt`.
- `cost`: the SDK's `total_cost_usd` for a session that runs on this
connection only. No cost is read from transcripts.
  - Live updates come from an AIR list subscription (below).
- **Session archive** (agentclientprotocol/agent-client-protocol#2161):
- `archived` is on every row, `session_info_update` is sent on a change,
and an unknown session is `-32002`.
- The `archived` list filter has three values: `"unarchived"` (the
default), `"archived"` (archived sessions only) and `"all"` (both, in
one order). #2161 has a boolean here. Its `false` is `"unarchived"`, its
`true` is `"all"`, and `"archived"` has no counterpart.
- **Capabilities**: an AIR client that declares `sessionIndex` gets
these in `_meta.jetbrains.air.capabilities`. The client declares only
`sessionIndex`.
  - `sessionIndex`.
- `sessionArchive`: `_session/archive` and `_session/unarchive`. The RFD
capability is `archive` (#2161).
- `sessionRename`: `_session/rename`. The RFD has capability `setTitle`
and method `session/set_title`
(agentclientprotocol/agent-client-protocol#1987).
- `sessionListSubscribe`: `_session/list/subscribe`,
`_session/list/unsubscribe` and `_session/list/changes`. No RFD
counterpart.

### Clients without `sessionIndex`

Clients that don't declare `sessionIndex`, which means non-AIR clients
and released AIR builds, keep the current behaviour. They get the same
SDK `listSessions` path, page size, cursor and initialize capabilities,
with no watchers and no notifications. There are two deliberate
deviations, both strictly safer:

1. **`session/delete` from an AIR client without `sessionIndex`.**
Released AIR builds use delete as "Done". Instead of deleting the
transcript (data loss), the adapter archives the session in AIR's own
Claude format (below). Archived sessions are hidden from that client's
list, so "Done" looks the same and nothing is lost. An unknown session
keeps the SDK's error. Other clients get the SDK delete, as before.
2. **`session/close`** of a session that is not loaded here returns `{}`
instead of throwing.

### Sessions open in another process

`session/load`, `session/resume` and `session/prompt` work on a session
that another live Claude Code process has open, for every client, as the
Claude CLI does. Rename, archive, unarchive and delete also go ahead,
writing records or deleting transcripts as the CLI's `/rename` and
delete do. The other process keeps running.

### Archive format and rule

The archive state uses AIR's native Claude archive format
(`ClaudeStoreThreadBackend.appendTitleRecords`, ADR 0140), so AIR's
native path and the adapter read and write the same state.

- **Archive** appends `{"type":"custom-title","customTitle":"[archived]
<title>",…}` and `{"type":"agent-name","agentName":"[archived]
<title>",…}` to every transcript copy. **Unarchive** appends the same
pair without the prefix. Titles are cut to the CLI's 200 characters.
- **Archived is decided by the agent name first**, as in AIR's native
Claude provider. A session's name is its last top-level `agentName`,
else the custom title the SDK reports. The session is archived exactly
when that name starts with `[archived] `. The listed title comes from
the same source, shown without the prefix.
- Title records are metadata: `updatedAt` and the list order do not
move.

### Rename, archive and delete

- **Rename** writes the same title pair. An archived session keeps its
prefix. A session running here is renamed through its CLI's
`rename_session` control request, serialised with auto-titling. Other
sessions get the records plus the `custom-title.json` sidecar the CLI
writes. No generated title replaces a client title, but a title stored
later by someone else (a `/rename`) is still adopted.
- **Archiving stops the running session.** `_session/archive` of a
session loaded on this connection does four things:
  1. It interrupts the running turn.
2. It retitles the session through its CLI. The adapter also appends the
matching `agent-name` record to the CLI's transcript, because the CLI
writes that record only later.
  3. It closes the session as `session/close` does.
4. It archives the transcripts once more if the closed CLI left them
unarchived.

The client then gets `session_info_update` with `archived: true`.
Unarchive does not reopen the session. A session that is not loaded here
is archived by writing the title records.
- **Delete** for a `sessionIndex` client deletes every copy of the
session and its `<sessionId>/` directories. A session running here is
closed first.

### Implementation (`src/session-index/`)

- **Index**:
- Reads the project dirs with `readdir` + `stat`, and keeps a 2000-entry
metadata LRU keyed by (path, mtime, size).
- Cache misses are read in parallel through `getSessionInfo`, plus a
growing tail read (64 KB → 4 MB) for the last prompt, last message, turn
end and agent name.
- SDK semantics are kept: sidecar titles, `continued-in`, long-path
hashes, darwin case, and first-prompt extraction (ported, and
fuzz-compared with the SDK).
- The `cwd` scope follows the SDK `listSessions`. Worktrees expand to
the same relative subdirectory, and a session copied to several project
directories is listed from its newest copy.
- The keyset cursor is bound to `cwd`, `archived` and
`includeWorktrees`. Session ids match exactly.
- **State**:
  - For sessions of this process, the SDK state.
- For other sessions, the Claude Code live registry
(`~/.claude/sessions/<pid>.json`, with pid + procStart liveness and
Claude Desktop's rules) or the transcript tail. The registry is read
only for the row state.
- `error` means the session is not running and its last turn ended with
an error, not a cancel.

Documented in `docs/air-extensions.md`, with an extension → RFD mapping
table and the remaining differences.

### Measured on a real `~/.claude` (233 transcripts, read-only)

| Case | New path | Old SDK path |
|---|---|---|
| cwd, limit 50, cold process | ~80 ms | 78–156 ms (all rows) |
| cwd, limit 50, warm | 1.3–4 ms | — |

## Session list subscription

Replaces polling the first page. It is only for a client that declared
`sessionIndex`; the agent advertises `sessionListSubscribe` with it,
never under ACP v2.

- **Subscribe**: `_session/list/subscribe { cwd }` returns `{
subscriptionId }`. `cwd` must be absolute, else `-32602`; other params
are ignored.
- The scope is `cwd` and the same subdirectory in every linked worktree,
in any archive state, without sidechains.
- **Changes**: `_session/list/changes { subscriptionId, sessions,
removed }`.
- `sessions` holds the full `session/list` row of each session that
appeared, or whose title, `lastPromptAt`, `state`, `lastTurnEndedAt`,
`cost`, `model`, `forkedFrom` or `archived` changed since the last row
sent.
- The first change of a session after subscribe is sent in full. After
that, a change of `updatedAt` alone sends nothing.
  - `removed` lists sessions that were deleted or moved out of scope.
  - Archive and unarchive are row changes.
- A session is sent at most once a second, with one notification per
subscription per pass.
- **Unsubscribe**: `_session/list/unsubscribe { subscriptionId }`
returns `{}` and is idempotent.
- A subscription lives until unsubscribe or until the connection closes.
- A connection has at most 128 subscriptions. The 129th is `-32602` with
`data.reason: "too_many_subscriptions"`.
- Subscriptions of the same `cwd` share one watch and are notified
independently.
- **Delivery** is best effort, with no resync and no sequence numbers. A
rescan every 10 s (and 1 s after a project directory watch opens) sends
whatever differs.
- **Implementation** (`list-subscriptions.ts`, `session-info.ts`):
- A non-recursive `fs.watch` on the scope's project dirs, the registry
and the projects root.
- A transcript event re-reads only that transcript, and a registry event
reads only that `<pid>.json`. Sessions running here push their SDK state
at once.
- Subscribe reads no session row. The rescan reads only transcripts
whose stat changed or whose read failed.

### Measured: one subscription on the 5,200-session project (Docker
`node:22`, 2 CPU, 2 GB, shared host)

300 s of churn: 20 sessions appending 10/s each (updatedAt-only),
registry status flips every 200 ms, and a session created every 2 s and
deleted 10 s later.

| | Old `list_changed` watcher | Subscription |
|---|---|---|
| Agent CPU p50 / max | 19% / — | 12.9% / 49% |
| Event loop p99 (p50 / max of 1 s windows) | — | 16 / 31 ms; worst
single delay 87 ms |
| Change latency p50 / p95 | 950 ms (hint) | create 291 / 570 ms, delete
279 / 506 ms; registry state 902 / 990 ms* |
| Notifications / min | — | 90 (896 rows, 0 rows for updatedAt-only
appends) |
| RSS | flat ~160 MB | 159 → 198 MB (heap flat ~50 MB) |

\* The registry flips the same 5 sessions every 200 ms, so each is held
to the once-a-second limit. Latency is measured from the first unsent
change.

These numbers were measured with an earlier subscribe that read every
row up front. Subscribe now reads no rows, so that one-off cost no
longer occurs. The lazy subscription has not been re-measured yet.

## Load test: 10,000 sessions (Docker, 2 CPU, 2 GB)

Calibrated data: 10k transcripts, 15 GB, 36k subagent files, and one
5,200-session project. Sizes match a real profile (p50 311K / p90 2.67M
/ p99 20.5M).

| Scenario (limit 50) | Before | After |
|---|---|---|
| Big project, first page cold / warm p50 | out of memory | 225 / 19 ms
|
| No cwd, cold / warm p50 | 1,722 / 178 ms | 209 / 45 ms |
| Page through 4,637 rows | 457 s | 11.4 s cold, 2.9 s warm |
| Delete p50 / 100 concurrent | 23.8 ms / 4.4 s | 7.8 ms / 0.8 s |
| 300 s of churn (60k changes) | out of memory after 3 s | list p50 54
ms, 0 stalls >50 ms, RSS flat ~160 MB |

## Streaming: keep the event loop responsive under a backlog

Under a backlog of buffered SDK messages, the adapter could freeze its
event loop for seconds. Every `query.next()` on a non-empty SDK queue
resolves in a microtask, so `runConsumer` forwarded the whole backlog
without reaching a macrotask boundary. Incoming JSON-RPC, including
`session/cancel`, waited until the backlog drained.

1. **Consumer yields to the event loop.** A shared `EventLoopYielder`
(`src/utils.ts`) gives the event loop a turn via `setImmediate` once the
current iteration has run for 8 ms. Order, content and cancel semantics
are unchanged.
2. **No per-message copy in `nodeToWebWritable`** for byte streams.
Object-mode streams keep the copy.
3. **Diff memo.** The approval preview and the PostToolUse patch no
longer diff the same texts twice. Timed-out diffs are not remembered.
4. **Prefix guards** in `parseMcpCommand` and `sanitizeTitle`. Both give
identical output.
5. **Replay yields between history messages**, with the same budget.

With 10 sessions × 1 MB, the max event-loop delay went from 4205 ms to
1626 ms, and ping p95 from 2793 ms to 262 ms (interleaved A/B, Docker
`node:22`, 2 CPU). Single-stream throughput is unchanged. Remaining
stalls are inside the Claude SDK and GC.

## Test plan

- [x] Rebased on `origin/main`. `npm run check`, `npm run build` and
`npm run test:run` pass.
- [x] Regression tests for the unchanged non-`sessionIndex` paths.
- [x] Regression tests for every review finding.
- [x] Reviews: Codex (gpt-6-astra) and a Claude review subagent, run as
independent passes against `origin/main` until a round found nothing of
normal or higher severity.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:59:47 +04:00
Ben Brandt
764c5abdf1 fix: report no cancellation for a subagent that only outlived a cancel
From a third review of the RFD form, against the subagents RFD as
revised by agentclientprotocol/agent-client-protocol#2308 (a cancel stops
the descendant work the cancelled work waits on; the rest is up to the
Agent; the Client cleans up a descendant's requests once it reports a
cancelled idle):

- At the end of the stream, or a conversation reset, a child that still
  runs is idle with no stop reason. After a cancel it is a background
  child that outlived it, so reporting `cancelled` invented a
  cancellation, which under #2308 also tells the client to clean up its
  requests. Foreground children report their cancellation from the SDK's
  task events, before the cancelled prompt resolves.
- A SendMessage result whose call has no message text still names the
  call, so a late result does not resume a run that already ended.
- An agent-level test pins #2308's validation scenario: at a root
  cancel, the foreground child's request is withdrawn and it reports a
  cancelled idle before the prompt response; the background child keeps
  running with its request open, and ends idle with no reason.
- README says what `session/cancel` does to children.

AIR's path and golden files are unchanged.
2026-10-05 23:06:55 +02:00
Ben Brandt
5588538fe2 docs: note why a subagent's agent id is unique enough as its session id
The RFD needs a child session id unique within the connection and never
under two parents. Agent ids are random and an agent keeps its parent, so
only a fork could repeat one under another root, which the SDK's
forkSession cannot do today because it copies no subagents/ history.
2026-10-05 19:29:55 +02:00
Ben Brandt
fd41b69e05 Merge remote-tracking branch 'origin/main' into feat/subagent-update
# Conflicts:
#	src/native-subagents.ts
2026-10-05 17:02:56 +02:00
Ben Brandt
bce759a258 fix: order a resumed subagent's delegations by the call that made each one
Fixes from a second review of the RFD form, for resume and end events
that arrive out of their usual order:

- Each delegation is the call the SDK runs it under. A run's end records
  its call; a SendMessage result for a call whose run already ended
  resumes nothing (`isEndedDelegation`), so a late result no longer sets
  an idle child running for good or re-arms the turn's hold.
- The end of an earlier delegation ends nothing (`isStaleEnd`), now also
  in the agent: a stale task_notification no longer settles the resumed
  run's live task, which permission attribution and the turn hold use.
- A run under an id the child used before becomes the current delegation
  again: the check compares with the current call, not the map.
- A child that was never announced does not take a resume's call, so a
  late frame cannot announce a withdrawn child.
- A running task_updated patch reports only that the child runs: its
  prompt is a guess, and the task_started or SendMessage result carries
  the delegation (`sendMessageResumed`). `taskResumed` keeps its
  signature from main.
- A resume while a request of the child is open reports requires_action.
- The end-to-end stale-end test now checks that the child stays running
  until the real end.

AIR's path and golden files are unchanged.
2026-10-05 17:00:24 +02:00
Ben Brandt
f826b6025e Merge remote-tracking branch 'origin/main' into feat/subagent-update 2026-10-05 16:29:22 +02:00
Ben Brandt
020dab8dce fix: follow a resumed subagent under the call that resumed it, and other RFD review fixes
Fixes from a close review of the RFD form, against the subagents RFD and
the bundled Claude Code CLI:

- The CLI runs a resumed agent under the id of the call that resumed it
  (`toolUseContext.toolUseId`, the SendMessage call). The child now takes
  over that id, so the resumed run's output and its task_notification
  reach the child instead of being buffered and dropped. A task_started
  of a known child resumes it even without a subagent type.
- A prompt message's id is the id of the call that delegated, and a
  prompt whose id is already in the child's transcript is not sent
  again, so a repeated resume signal does not overwrite or repeat it.
- The end of an earlier delegation no longer ends the current one.
- A resume's `senderSessionId` is the session that made the SendMessage
  call, and is left out when unknown, instead of always the parent.
- A failure reported first without the SDK's error text gets it when a
  later event of the same failure carries it.
- A task that a worker restart orphaned is idle with no stop reason, not
  cancelled: the RFD forbids inventing a cancellation.
- A child that ends before its parent is known stays unexposed, rather
  than being announced under the root: the RFD forbids guessing parentage.
- A late frame that names a child keeps its role in the title.
- `announceNativeSubagent` takes its form explicitly.
- README, docs/air-extensions.md and doc comments say what the code does.

AIR's path and golden files are unchanged.
2026-10-05 16:29:21 +02:00
Ben Brandt
e34c447e96 feat: report subagents as the subagents RFD defines, for clients that are not AIR
A client that declares `clientCapabilities.subagents` and is not AIR now
gets native subagent sessions in the form of the revised subagents RFD
(agentclientprotocol/agent-client-protocol#1992, docs/rfds/subagents.mdx),
which SDK 1.7.0 types. AIR keeps the earlier draft it implements
(`subagent_spawned`, `subagent_state_update`), whichever signal it
declares; its golden files do not change.

In the RFD form (`SubagentForm`, chosen by `subagentForm`):

- The child's session id is the SDK agent id, for every delegation to
  it: a SendMessage resume no longer opens a new generation.
- `subagent_update` announces the child on its immediate parent before
  any child traffic, with `title` (the agent's name, with its role after
  it, else the role), `description` (the Agent input's short
  description, never the prompt), and the `running` state.
- The prompt of each delegation is a `session_message` in the child's
  transcript, from the parent, with the id of the tool use that sent it.
  The Agent, Task, or SendMessage tool call stays a tool call of the
  session that made it.
- The work state follows the SDK: `requires_action` while a permission
  or question request of the child is open, `idle` with `end_turn`,
  `cancelled` when the SDK stopped or killed the task, and a failure as
  `idle` with the SDK's error in `_meta.claudeCode.error`. (The RFD's
  `error` stop reason is newer than SDK 1.7.0's closed v1 StopReason.)
  `session/cancel` no longer ends the children: a background subagent
  outlives the interrupt, and the RFD reports a cancellation only from
  evidence. At the end of the stream a running child gets `idle`,
  `cancelled` after a cancel.
- An idle child is reusable, so its later updates still reach it.
- No per-child capabilities, so a client may not change a child.
- Replay does not expose children yet, until it keeps their live ids.

The agent-side `sessionCapabilities.subagents` goes to AIR alone: the RFD
has only the client capability. compare.ts allows it against the
origin/main baseline.

Tests: the end-to-end tests of the earlier draft now run with an AIR
client; new runtime and end-to-end tests cover the RFD form, and check
every update against the v1 schema.

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-10-05 13:27:21 +02:00
96 changed files with 19017 additions and 774 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.88.0"
".": "0.89.1"
}

View File

@@ -1,5 +1,28 @@
# Changelog
## [0.89.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.89.0...v0.89.1) (2026-10-10)
### Bug Fixes
* push renamed sessions to list subscribers and reuse one listing across pages ([#1293](https://github.com/agentclientprotocol/claude-agent-acp/issues/1293)) ([92abb8f](https://github.com/agentclientprotocol/claude-agent-acp/commit/92abb8fd4db862c465c4b3a49ea32dac0720de2c))
## [0.89.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.88.0...v0.89.0) (2026-10-09)
### Features
* add the AIR sessionIndex extension for fast session lists ([#1268](https://github.com/agentclientprotocol/claude-agent-acp/issues/1268)) ([03d84f6](https://github.com/agentclientprotocol/claude-agent-acp/commit/03d84f6663a2f561b3c59bc30e622e085b869024))
* **deps:** Bump @agentclientprotocol/sdk to 1.8.0 ([#1287](https://github.com/agentclientprotocol/claude-agent-acp/issues/1287)) ([446a721](https://github.com/agentclientprotocol/claude-agent-acp/commit/446a7217fa2654a9bcfaff9ddbfcdf2917cef024))
* Support custom instructions for agent sessions via meta ([#1177](https://github.com/agentclientprotocol/claude-agent-acp/issues/1177)) ([908c994](https://github.com/agentclientprotocol/claude-agent-acp/commit/908c994901b857918c23655670468298f6bbd33b))
### Bug Fixes
* recreate live sessions on logout when it clears a provider or gateway override ([#1278](https://github.com/agentclientprotocol/claude-agent-acp/issues/1278)) ([966be7a](https://github.com/agentclientprotocol/claude-agent-acp/commit/966be7abe60435f2f1c6379a5255ea7d533a1a20))
* report a failed turn with the error stop reason on the experimental ACP v2 surface ([#1288](https://github.com/agentclientprotocol/claude-agent-acp/issues/1288)) ([060bdb2](https://github.com/agentclientprotocol/claude-agent-acp/commit/060bdb212a9354f2dd0c88e4234a7659aeb6d27e))
* report an interrupted tool call as cancelled on the experimental ACP v2 surface ([#1289](https://github.com/agentclientprotocol/claude-agent-acp/issues/1289)) ([55c3106](https://github.com/agentclientprotocol/claude-agent-acp/commit/55c3106c60f58909af76f75df9f6c5815dcf0021))
## [0.88.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.87.0...v0.88.0) (2026-10-08)

View File

@@ -37,14 +37,35 @@ npm install @agentclientprotocol/claude-agent-acp@preview
### Subagent sessions
Subagents are exposed only after bilateral capability negotiation. Until the released ACP SDKs
preserve the draft `clientCapabilities.subagents` field, a supporting client may advertise
`nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; the adapter mirrors the capability
in its initialize response. The canonical field remains supported and takes precedence once it is
available. Without either client signal, Agent/Task lifecycle keeps its legacy ordinary ACP
tool-call representation and child interactions stay on the root session. Clients that use the
historical `_meta["subagent-transcript"]` capability or `forwardSubagentText` session option retain
the flattened child transcript behavior. See [AIR extensions](docs/air-extensions.md#native-subagent-sessions).
A client that declares `clientCapabilities.subagents`, other than JetBrains AIR, gets each Agent or
Task subagent as its own child session, following the [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx):
- `subagent_update` on the parent announces the child before any of its traffic, with the
parent's `title` and `description` for it, and reports its work state: `running`,
`requires_action` while one of its requests is open, and `idle` when its work ends, with
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
stop reason and the SDK's error text as its `error`. The child keeps its session for every
delegation to it.
- The prompt of the Agent or Task call that launches the child, and the message of each
SendMessage call that resumes it, is a `session_message` in the child's transcript. The tool
call stays a tool call of the session that made it.
- `session/cancel` on the parent interrupts Claude Code, which stops the foreground children that
the turn waits on and, currently, background children too. Each stopped child reports `idle`
with `cancelled` from Claude Code's own events, and the open requests of the foreground ones are
withdrawn. The adapter ends no child at a cancel itself, so a child that keeps running keeps its
open requests. A child that still runs when Claude Code exits is `idle` with no stop reason.
- A child whose parent is not yet known when it ends is not exposed, since the RFD forbids
guessing its parent.
- A client cannot prompt, cancel, or otherwise change a child yet, and a replay does not restore
children yet.
JetBrains AIR keeps the earlier draft of the RFD (`subagent_spawned`, `subagent_state_update`),
which it enables with `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`. See
[AIR extensions](docs/air-extensions.md#native-subagent-sessions).
Without either signal, Agent and Task stay ordinary tool calls and child interactions stay on the
root session. Clients that use the historical `_meta["subagent-transcript"]` capability or the
`forwardSubagentText` session option retain the flattened child transcript.
## Contribution Policy

View File

@@ -17,6 +17,7 @@ It describes only this adapter.
- [Claude tools and ACP fields](#claude-tools-and-acp-fields)
- [Diff patch](#diff-patch)
- [Plan file](#plan-file)
- [Session hooks](#session-hooks)
- [Permission presentation](#permission-presentation)
- [Goal](#goal)
- [Recommended config values](#recommended-config-values)
@@ -24,6 +25,7 @@ It describes only this adapter.
- [Agent file-change report](#agent-file-change-report)
- [Session failure](#session-failure)
- [Native subagent sessions](#native-subagent-sessions)
- [Session index](#session-index)
- [Context compaction](#context-compaction)
- [Question custom answers](#question-custom-answers)
- [Session fork point](#session-fork-point)
@@ -135,22 +137,26 @@ The `initialize` response of an AIR client carries the agent side of the extensi
}
```
The agent list does not depend on the capabilities that AIR declares.
An extension is active only when the client declared its capability.
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename` and `sessionListSubscribe`, only for a client that declared `sessionIndex`.
An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename` and `sessionListSubscribe`.
The response to a client that is not AIR has no `_meta.jetbrains` key.
### Capabilities
| Capability | Advertised | What the adapter does when the client declares it | Section |
| ------------------------ | ---------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `diffPatch` | yes | Sends an exact Git patch in the diff block of an Edit or a Write. | [Diff patch](#diff-patch) |
| `sessionFailure` | yes | Sends warnings and errors as typed transcript records. | [Session failure](#session-failure) |
| `recommendedValue` | yes | Replaces the `default` model and effort rows with concrete values and a recommendation. | [Recommended config values](#recommended-config-values) |
| `asyncTasks` | yes | Publishes background work that is not a subagent as async tasks. | [Async tasks](#async-tasks) |
| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) |
| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) |
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
| `rawInputRendering` | no | Sends no display copy of readable input in `content`. The client renders `rawInput`. | [Tool call contract](#tool-call-contract) |
| Capability | Advertised | What the adapter does when the client declares it | Section |
| ------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
| `diffPatch` | yes | Sends an exact Git patch in the diff block of an Edit or a Write. | [Diff patch](#diff-patch) |
| `sessionFailure` | yes | Sends warnings and errors as typed transcript records. | [Session failure](#session-failure) |
| `recommendedValue` | yes | Replaces the `default` model and effort rows with concrete values and a recommendation. | [Recommended config values](#recommended-config-values) |
| `asyncTasks` | yes | Publishes background work that is not a subagent as async tasks. | [Async tasks](#async-tasks) |
| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) |
| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) |
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) |
| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) |
| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) |
| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) |
| `rawInputRendering` | no | Sends no display copy of readable input in `content`. The client renders `rawInput`. | [Tool call contract](#tool-call-contract) |
The adapter ignores `planContentDelta`.
Claude does not stream a plan, so the adapter never sends `contentDelta` (see [Plan file](#plan-file)).
@@ -491,6 +497,21 @@ The plan text goes out as before in these cases:
AIR then shows the in-memory `plan` text.
A client that is not AIR always gets the whole SDK input, like upstream.
## Session hooks
The adapter accepts session hooks from any client, without a capability.
AIR puts the hooks into `_meta.claudeCode.options.settings.hooks` of `session/new` and `session/load`:
```json
{ "claudeCode": { "options": { "settings": { "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }] } } } }
```
- The settings go to the flag settings tier of that session only. Other sessions and the settings files do not change.
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/session-hooks.test.ts` checks this with the Claude Code binary.
- `CLAUDE_MODEL_CONFIG` stays in effect (see [Model configuration](model-configuration.md#precedence)).
- A `session/load` with different settings recreates the query, so it applies the new hooks.
- Claude asks for no hook trust step.
## Permission presentation
The adapter uses standard ACP permission requests and responses.
@@ -1165,8 +1186,9 @@ This section covers only the AIR bridge.
- The canonical client field is `clientCapabilities.subagents: {}`.
- Released ACP SDKs can strip that draft field.
AIR can instead declare `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`.
- Either signal enables native subagent sessions. The canonical field takes precedence when it is available.
- The agent advertises `nativeSubagentSessions` to AIR, and `agentCapabilities.sessionCapabilities.subagents` to every client.
- Either signal enables native subagent sessions, and AIR gets this earlier draft of the RFD whichever it declares.
- The agent advertises `nativeSubagentSessions` and `agentCapabilities.sessionCapabilities.subagents` to AIR.
- A client that is not AIR and declares `clientCapabilities.subagents` gets the revised RFD instead (`subagent_update`, `session_message`), see the README.
- With native sessions, the adapter sends `subagent_spawned` and `subagent_state_update`.
The child output goes to the child session. The Agent or Task tool call is not the subagent card.
- `subagent_spawned` can carry an optional `prompt`: the exact prompt of the child session.
@@ -1177,6 +1199,262 @@ This section covers only the AIR bridge.
Child interactions stay on the root session.
- A client that uses the older `_meta["subagent-transcript"]` capability or the `forwardSubagentText` session option keeps the flat child transcript.
## Session index
The session index makes `session/list` a bounded, ordered page with row metadata, and adds rename, archive and a list subscription.
### Activation
AIR declares `sessionIndex` in `_meta.jetbrains.air.capabilities`.
Unlike the other capabilities, the agent advertises `sessionIndex` only to a client that declared it, and never under ACP v2.
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`) and `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`).
The client does not declare these three; declaring them without `sessionIndex` enables nothing.
Everything in this section applies only to such a client, except where [Delete and close](#delete-and-close) says otherwise.
A client without the capability gets the `session/list` of before: the SDK `listSessions` call, pages of 1000 by file mtime, `offset:N` cursors, and no row `_meta`.
It gets no new notification, and the adapter starts no watcher for it.
The new methods answer it with `-32601`.
### List request
```json
{
"cwd": "/Users/me/repo",
"cursor": null,
"_meta": {
"jetbrains": {
"air": {
"version": 1,
"list": { "limit": 50, "includeWorktrees": false, "archived": "unarchived" }
}
}
}
}
```
- `limit` is an integer of at least 1; omitted or `null` is 50. The adapter clamps it to 200 and never returns more. Another value (a string, a fraction, 0 or less) is `-32602`.
- `includeWorktrees` is a boolean, `false` when omitted or `null`. Another value is `-32602`.
Without it, the page holds the sessions that the SDK `listSessions` lists for `cwd`: the transcripts of its project directory (`cwd` with symlinks resolved, encoded as the CLI names the directory).
As in the SDK, a transcript there whose cwd (its last `relocated` cwd, else its first `cwd`) is another path that encodes to the same name (`/ws/app.v2` and `/ws/app-v2`, `/a/b` and `/a-b`) is left out only while that path still exists and resolves to another path; once it is gone, the session is listed under `cwd`.
With `true`, the page also holds the sessions of the same subdirectory of `cwd` in every other existing worktree of its repository, as the Codex TUI expands a cwd: for `/repo/packages/a`, `/wt1/packages/a` if that directory exists. For a cwd at a worktree root, that is the worktree roots.
- `archived` and `includeWorktrees` treat `null` as omitted.
- `archived` is one of three strings: `"unarchived"` lists the unarchived sessions only, `"archived"` the archived sessions only, and `"all"` both together, in one order.
Omitted or `null` is `"unarchived"`. Any other value, including a boolean, is `-32602`.
The filter applies before pagination.
- Without `cwd` the page holds the sessions of all projects.
### List response
- Rows are ordered by the last user activity descending, then by session id: `lastPromptAt`, else `updatedAt` for a row without it.
A session the agent kept working on after the last prompt does not move up, as in Codex Desktop and the AIR session tree.
With `archived: "all"`, archived and unarchived rows merge on the same key.
- Every row has `updatedAt`: the time of the last message of any kind, capped at the transcript mtime.
A rename, an archive, or another metadata record moves neither `updatedAt` nor the order.
- The cursor is opaque. It holds the position (the order key and session id of the last row) and the `cwd`, `includeWorktrees` and `archived` it was issued for; another value of any of them rejects it with `-32602`. `limit` may change from page to page.
- A deleted session is never listed, whatever `archived` says.
- A page with `nextCursor` is never empty, and a page never repeats a row of the pages before it.
- With `includeWorktrees`, the worktrees come from `<git-common-dir>/worktrees/*/gitdir`, without running git; a worktree whose directory no longer exists is left out.
A path longer than the CLI's 200-character directory name limit matches directories by name prefix, so such a directory counts only when one of its transcripts belongs to the path (its last `relocated` cwd, else its first `cwd`), as the SDK checks.
A row's `cwd` is the session's own directory, so it can be a worktree path.
- A row's `title` is the session's agent name, as AIR's native Claude provider ranks it: the top-level `agentName` of the last record that has one in the transcript's 64 KB tail, else in its 64 KB head.
Without one, it is the title the SDK reports for the session (`summary` of `getSessionInfo`): its custom title (tail, the CLI's `custom-title.json` sidecar, head) or AI title, else the last prompt, a summary or the first prompt.
When the SDK reads another copy of the session than the listed transcript, the same title is taken from the listed transcript's 64 KB head and tail.
The `[archived] ` prefix of an archived session is not part of the title (see [Archive](#archive)).
- Sidechain and subagent transcripts, transcripts without a message, and transcripts without a title are not listed.
- A session copied to several project directories of the scope is listed once, as the SDK lists it: from its most recently modified transcript that is listed.
- A transcript continued in another session (a `continued-in` record) whose successor has history is not listed, as in the SDK list.
- On macOS a project directory whose name differs from the cwd's encoding in case only is the cwd's directory, as the file system resolves it for the SDK.
- A row's `cwd` comes from the transcript (the last `relocated` record, else the first `cwd`, else the last `cwd` or one of its parents) when it encodes to the project directory name.
Otherwise it is the requested path of that directory, else the `cwd` of another session in the same directory, read further if the page does not hold one.
- `updatedAt` needs the last message and the order key the last prompt: when the 64 KB tail window lacks either, the window grows up to 4 MB to find it, reading only the new bytes each step. A last message longer than that gives the last `timestamp` of the file's last 64 KB, so title records appended after it move nothing.
The directory name is never decoded.
Each row carries `_meta.jetbrains.air` with flat row fields of the RFDs. `archived` is always there (see [Archive](#archive)); every other field is omitted when unknown:
```json
{
"archived": false,
"lastPromptAt": "2026-10-06T09:57:40.000Z",
"model": "claude-opus-5-5",
"forkedFrom": "8f0c1d2e-0000-4000-8000-000000000000",
"state": "idle",
"lastTurnEndedAt": "2026-10-06T09:58:10.000Z",
"cost": { "amount": 1.23, "currency": "USD" }
}
```
- `lastPromptAt` is the time of the last real user prompt in the transcript tail: prompt text (the predicate of the SDK title extractor) or an image or a document, but not a tool result, a meta or compact summary record, a slash command or an interrupt. When a long answer or tool output follows the last prompt, the tail window grows (256 KB, 1 MB, 4 MB) to find it; it is omitted only when the last 4 MB hold no prompt.
- `model` is the model of the last assistant message in the tail; messages the CLI makes up (`<synthetic>`) do not count.
- `forkedFrom` is the parent session id that the SDK `forkSession` and the CLI fork write on every copied record (`forkedFrom.sessionId`). Sessions started any other way have none.
- `state` is `running`, `idle`, `requires_action`, or `error`, never `unknown`. `error` means the session is not running and its last turn ended with an error; a user cancel or interrupt is no error, and a new turn clears it:
- a session this connection runs: the SDK `session_state_changed` state; `error` instead of `idle` when its last turn failed (an error result, `is_error` or an `error_*` subtype, that was not a cancel). A session whose query failed here (not a cancel) is reported as any other session, but `error` until its transcript shows a prompt or a turn end after the failure, unless a busy or waiting interactive CLI holds it;
- a session no live Claude Code process holds: `idle`, or `error` when its last turn ended with an API error (an assistant record with `isApiErrorMessage: true`, also when the CLI's turn-end records follow it, with no prompt after it);
- a session that an interactive CLI holds (not an `sdk-*` entrypoint) whose registry status is newer than the transcript: `busy` and `shell` are `running`, `waiting` is `requires_action`, `idle` is `idle`, or `error` after such an API error;
- any other held session: `idle` after a finished turn (an assistant `end_turn`, a user interrupt, or the CLI turn-end records), `error` after a turn that ended with an API error, `running` for an unfinished turn written in the last 10 minutes, and no state otherwise.
- The live processes come from `<config>/sessions/<pid>.json`, with the liveness rules of Claude Desktop: `kill(pid, 0)` (or `EPERM`), a matching `procStart`, no foreign `pidDomain`, and records older than 24 hours only with a matching `procStart`.
The adapter never reads the `.key` files there and never changes the directory.
- `lastTurnEndedAt` is when the last turn ended.
- `cost` is the `total_cost_usd` that the SDK gave in the last result of a session this connection runs, when it is greater than 0. Any other session has no `cost`: the adapter neither reads nor estimates one from a transcript.
### Rename
`_session/rename { "sessionId": "…", "title": "…" }` returns `{}`.
- A blank title is `-32602`. The adapter collapses whitespace and keeps the first 200 characters, trimmed, as the CLI does. An `[archived] ` prefix of the requested title is dropped: a rename never changes the archive state.
- The title is stored as the title records of [Archive](#archive): a `custom-title` and an `agent-name` record. An archived session keeps its `[archived] ` prefix (and the title is cut to 200 characters with it), so a rename does not unarchive it.
- A session that runs here on a CLI without the `rename_session` control request is refused with `-32600`: its CLI holds the title and would write it back.
- A session this connection runs is renamed through its CLI (`rename_session` control request), which writes the records and the sidecar of its own transcript.
A title generation in flight finishes first, and no generated title replaces the new one, also after a reload.
The adapter then sends `session_info_update { title }` with the title as stored, without the archive prefix.
Copies of the session in other project directories get the records and sidecar from the adapter.
- Any other session gets the records in every transcript of the session and the CLI's `custom-title.json` sidecar next to each (mode 0600, written atomically).
- As with the CLI's `/rename`, the records are written whether or not another Claude Code process has the session open. A transcript whose last line is incomplete gets a newline before the records.
### Archive
`_session/archive { "sessionId": "…" }` and `_session/unarchive { "sessionId": "…" }` return `{}`.
Both are idempotent and work for a session that is not loaded.
Archiving a session stops it; unarchiving does not start it.
The archive state lives in the session title, in the format of AIR's own Claude integration, so AIR's native Claude path and this adapter read and write the same state:
- A session is archived when its name, with whitespace collapsed, starts with `[archived] `. As in AIR's native Claude provider, the name is the agent name (see [List response](#list-response)), else the custom title the SDK reports for it (`customTitle`); the agent name wins when both are there.
So an `agent-name` record with the prefix archives a session whose custom title has none, and one without the prefix unarchives a session whose custom title has it. No other title is read for the archive state.
- Archive appends the title records of `[archived] ` plus the current title without the prefix; unarchive appends those of the title without the prefix. The title is cut to the CLI's limit, its first 200 characters, trimmed; a session without a title is `Session ` plus the first 8 characters of its id.
- The title records are two lines, appended together to every transcript of the session:
```json
{"type":"custom-title","customTitle":"[archived] Fix the parser","sessionId":"…"}
{"type":"agent-name","agentName":"[archived] Fix the parser","sessionId":"…"}
```
A transcript already in the requested state gets nothing. A `custom-title.json` sidecar that the adapter finds is rewritten with the same title (also one left in the other state next to a transcript already in the requested state); the adapter creates none. The CLI of a session that runs here writes the sidecar of its own transcript, as for a rename.
The current title is the title of each transcript (see [List response](#list-response)), with the prefix only when the transcript is archived. For a session that runs here, it is the title last stored through its CLI while the last custom title of its transcript is that title; otherwise the transcript's.
Every `[archived] ` prefix of the current title is removed before one is added, so unarchive also unarchives a title stored with the prefix twice.
A new session archived before it has a title gets `[archived] Session …`, and keeps `Session …` as its name once unarchived: no title is generated for a named session.
- The list shows the title without the prefix, and so does the `session_info_update { title }` of a loaded session for an AIR client. Another client, and the `claude --resume` picker, show the title as stored, with the prefix.
- Archive of a session loaded on this connection stops it, as Codex's `thread/archive` unloads its thread:
1. it cancels the running turn, as `session/cancel` does, and sends the CLI the interrupt;
2. it retitles the session through its CLI (`rename_session`), as for [Rename](#rename): the CLI keeps the title in memory and writes it again, so only its own write cannot be overtaken. A title generation in flight finishes first, and no generated title replaces the archived one. The CLI writes the custom title before it answers and the agent name only later, so when the CLI's transcript has another agent name, the adapter appends the `agent-name` record of the new title to it at once. Its copies in other project directories get the records from the adapter as a best effort;
3. it closes the session as `session/close` does: the cancelled prompt ends with `cancelled`, and the session is no longer loaded. A later `session/prompt` fails as for a closed session; to work in it again, the client unarchives it and loads it.
4. it archives each transcript that the closed CLI left unarchived once more, with the title records.
If the retitle fails, the archive fails and the session stays loaded, with its turn cancelled.
A new session archived before the CLI wrote its transcript may have none once it is closed: it is then unknown (`-32002`) and not listed.
- Unarchive neither loads nor resumes the session. A session loaded on this connection keeps running and is retitled through its CLI, as for archive.
- Archive and unarchive of a session that is not loaded here write the title records, also when another Claude Code process has the session open, as for [Rename](#rename). That process keeps running. AIR's native Claude path stops the session's processes before it archives.
- Closing, loading or resuming a session does not change its archive state.
- After either, a session that was loaded on this connection gets `session_info_update` with `_meta.jetbrains.air.archived` set to the new state; no standard field changes. An archive sends it once the session is closed, as the last update of the session.
- Neither changes `updatedAt` or the list order: the title records are metadata.
- A session without a transcript (unknown, deleted, or removed by the CLI cleanup) is `-32002`. Unarchive never brings back a deleted session.
Only a new session that runs on this connection and has not finished a turn yet may have no transcript: it can be archived before the CLI writes one, and the CLI writes the title with it.
A loaded session whose query ended, that finished a turn, or that was loaded, resumed or forked from history needs its transcript.
- Rename, archive, unarchive and delete of one session run one at a time.
In all of the session index methods and in `session/delete`, a session id matches exactly: a transcript is the file named by the id as given.
Loading an archived session does not unarchive it.
The CLI cleanup still deletes old transcripts (`cleanupPeriodDays`), archived or not.
### List subscription
`_session/list/subscribe { "cwd": "…" }` returns `{ "subscriptionId": "…" }`.
From then on the agent sends `_session/list/changes` notifications for the sessions of that `cwd`:
```json
{
"subscriptionId": "5b0c…",
"sessions": [
{
"sessionId": "…",
"cwd": "/Users/me/repo",
"title": "…",
"updatedAt": "…",
"_meta": { "jetbrains": { "air": { "version": 1, "archived": false, "state": "running" } } }
}
],
"removed": ["…"]
}
```
- `cwd` is required and must be an absolute path; a missing, non-string or relative `cwd` is `-32602`. Other parameters are ignored.
- The scope is the sessions of `cwd` and of the same subdirectory in every existing linked worktree of its repository, as a list with `includeWorktrees: true` resolves it, in any archive state. Sidechain and subagent transcripts are left out, as in the list.
- `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included.
- After subscribe, the first change of each session sends its row in full, even when only `updatedAt` changed. From then on, a change of `updatedAt` alone sends nothing; the new `updatedAt` comes with the next change.
- `removed` holds the ids of sessions that left the scope: deleted (the transcript is gone), or moved to a path outside it. It may name a session the client does not list (one it never listed, one the list hides, such as a session continued in another one when that one changes); the client ignores such an id.
- Archive and unarchive are row changes (`archived`): their title records are a transcript change. The agent applies no archive filter: the client filters.
- A session is sent at most once a second per subscription. All changes of one pass go in one notification per subscription.
- Changes are tracked from the moment subscribe returns: the client subscribes first, then reads the first page with `session/list`. A row the page also holds may be sent.
- A transcript change is sent 150 ms after the last change of a burst, at most 1 s after the first; the state of a session that this connection runs is sent at once.
- Delivery is best effort: there is no resync and no sequence number. A rescan every 10 s, and one 1 s after the adapter starts watching a project directory, sends any difference it finds as a normal change. The client should still read the list now and then.
`_session/list/unsubscribe { "subscriptionId": "…" }` returns `{}`.
It is idempotent, and an unknown id also returns `{}`.
- A subscription lives until unsubscribe or until the connection closes; it has no time limit.
- A connection has at most 128 subscriptions. The 129th subscribe is `-32602` with `data.reason: "too_many_subscriptions"`.
- A second subscription of the same `cwd` is independent: it has its own id, its own unsubscribe and its own notifications.
- The adapter watches, without recursion, the project directories of the scope, the projects directory (for a new project directory), `<config>/sessions`, and the nearest existing parent of any of these that does not exist yet. All subscriptions of one `cwd` share that watching, which ends with the last of them.
- Subscribe reads no session row. The first subscription of a `cwd` opens the watchers, stats every transcript of the scope, and reads the registry before it returns.
- A transcript event names the file: only that transcript is read. A registry event names the record: only the sessions whose record changed get their `state` recomputed (their row is read on their first event). A session continued in a changed one is read again too.
- The rescan reads a transcript only when its stat changed or its last read failed. A session that a live process holds and that no event read gets its row, for its aging `state`, from what `session/list` already read; a worktree that came or went re-resolves only the sessions read before, by an event or by `session/list`.
- `session/list` starts or renews nothing.
### Delete and close
`session/delete`:
- a `sessionIndex` client: deletes every transcript of the session (empty ones too) and every `<sessionId>/` directory, also one left without a transcript by an earlier failed delete. Anything left behind fails the request. A session that runs here is closed first. As with the CLI, the session is deleted also when another Claude Code process has it open. An unknown session is `-32002`.
- an AIR client without `sessionIndex`: archives the session instead of deleting it (the title records of [Archive](#archive)), because AIR uses delete for "Done" and can reopen the session. A session that runs here is closed first. The list hides archived sessions from such a client too: those whose custom title, as the SDK list reports it, is archived. A session without a transcript fails with the error of the SDK delete, as before.
A `sessionIndex` client sees such a session as archived (`archived: true`), as AIR's native Claude path does.
- any other client: the SDK delete, as before.
`session/close` of a session that is not loaded returns `{}` for every client.
`session/load`, `session/resume`, `session/prompt`, rename, archive, unarchive and delete all work on a session that another live Claude Code process has open, for every client, as the Claude CLI does. Archive stops only a session that runs on this connection.
### Errors
| Case | Error |
| ---------------------------- | ----------------------------------- |
| Unknown session | `-32002` with `data: { sessionId }` |
| Invalid parameters or cursor | `-32602` |
### Relation to the RFDs
The session index is the `_meta` form of two ACP RFDs: "Session list extensions: limit, order, row metadata and change hints" and "Session Archive and Unarchive" (#2161).
Names and semantics follow them; the transport differs, and so do the `archived` list filter and the archive of a running session (see [Relation to ACP RFD #2161](#relation-to-acp-rfd-2161)):
| Extension | RFD |
| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` |
| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) |
| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) |
| `_meta.jetbrains.air.list.limit` | `session/list` `limit` |
| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` |
| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there |
| row `_meta.jetbrains.air.lastPromptAt`, `model`, `forkedFrom`, `state`, `lastTurnEndedAt`, `cost` | `SessionInfo` fields of the same names; the RFD `state` has no `error` |
| row `_meta.jetbrains.air.archived` | `SessionInfo.archived` (#2161) |
| `session_info_update` `_meta.jetbrains.air.archived` | `SessionInfoUpdate.archived` (#2161) |
| capability `sessionListSubscribe`, `_session/list/subscribe`, `_session/list/unsubscribe` | none |
| `_session/list/changes { subscriptionId, sessions, removed }` | none |
| `_session/archive`, `_session/unarchive` | `session/archive`, `session/unarchive` (#2161) |
| `_session/rename` | `session/set_title` (#1987) |
### Relation to ACP RFD #2161
The archive follows the semantics of the ACP "Session Archive and Unarchive" RFD (#2161): the archive state of every row, the state report in `session_info_update`, idempotency, `-32002` for unknown and deleted sessions, and an unchanged `updatedAt`.
It differs in transport, until the RFD lands in the SDK, in the list filter, and in execution:
- archive stops a session that runs on this connection, as `session/close` does, where #2161 leaves execution alone. The "Session list extensions" RFD proposes this change to #2161;
- the list parameter has three values, not two: `"unarchived"` is the RFD's `false` (and the default of both), `"all"` its `true`, and `"archived"`, the archived sessions only, has no RFD counterpart;
- the methods are `_session/archive` and `_session/unarchive`, not `session/archive` and `session/unarchive`;
- the capability is `sessionArchive` in `_meta.jetbrains.air.capabilities`, advertised together with `sessionIndex`, not `sessionCapabilities.archive`;
- the list parameter is `_meta.jetbrains.air.list.archived`, and the state is `_meta.jetbrains.air.archived` on rows and in `session_info_update`, not the `archived` fields;
- an AIR client without `sessionIndex` still archives with `session/delete` (see [Delete and close](#delete-and-close)), which the RFD asks clients not to do.
## Context compaction
The adapter implements the ACP session compaction RFD for a client that declares `clientCapabilities.session.compaction`.
@@ -1264,6 +1542,8 @@ It adds this object to the `session/fork` request:
When only one stored message has the fingerprint, the adapter uses that message.
- When no message matches, the request fails with `invalidParams`.
- The fork keeps the history up to that message.
- With or without this object, the forked session runs once the response arrives, so the client can prompt it without `session/load` or `session/resume`.
A later load replays its history into it, and a later resume returns it. A load or resume with other settings (`cwd`, MCP servers, additional directories, or the session options in `_meta`) restarts it, as for any loaded session.
## Presentation hints

View File

@@ -42,12 +42,12 @@ node dist/index.js
## Precedence
When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself.
An ACP caller can provide `settings` via `_meta.claudeCode.options.settings` in `session/new` or `session/load`, as an object or a path to a settings file. The adapter merges `CLAUDE_MODEL_CONFIG` into those settings, so a caller that only adds hooks keeps the model settings. When both set the same key, the caller wins; the merge is shallow, so a caller's `modelOverrides` replaces the env var's map.
| Source | Priority |
| -------------------------------------------- | ----------------------------------------------------- |
| `_meta.claudeCode.options.settings` (caller) | Highest — used if present |
| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings |
| Source | Priority |
| -------------------------------------------- | ---------------------------------------------- |
| `_meta.claudeCode.options.settings` (caller) | Highest — wins for each key it sets |
| `CLAUDE_MODEL_CONFIG` (env var) | Used for the keys that the caller does not set |
## Format details

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.88.0",
"version": "0.89.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.88.0",
"version": "0.89.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "1.8.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.88.0",
"version": "0.89.1",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,33 @@ import type {
import {
AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY,
clientSupportsAirCapability,
isAirClient,
} from "./air-extension.js";
export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
/**
* Temporary typed surface for agentclientprotocol/agent-client-protocol#1992.
* How a client gets native subagent sessions.
*
* The wire contract is already defined by the ACP draft, but the published
* TypeScript SDK does not contain it yet. Keep the compatibility boundary in
* this file so it can be replaced by SDK exports without changing lifecycle
* code when the draft ships.
* - `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
* as revised): `subagent_update` and `session_message`, which the SDK types.
* A client that declares `clientCapabilities.subagents` and is not AIR gets
* it. (ACP v2 clients do not get subagents yet.)
* - `air`: the earlier draft of that RFD, which AIR implements:
* `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it,
* whichever capability it declares (`docs/air-extensions.md`).
*/
export type SubagentForm = "rfd" | "air";
/** The form of native subagent sessions that the client gets, or none. */
export function subagentForm(capabilities?: ClientCapabilities | null): SubagentForm | undefined {
if (!clientSupportsSubagents(capabilities)) return undefined;
return isAirClient(capabilities) ? "air" : "rfd";
}
/**
* The earlier draft of the subagents RFD, which AIR implements and the SDK
* does not type.
*/
export type SubagentSessionCapabilities = {
cancel?: boolean;
@@ -116,7 +132,7 @@ export function clientSupportsSubagents(capabilities?: ClientCapabilities | null
return clientSupportsAirCapability(capabilities, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY);
}
/** The only cast needed until the TypeScript SDK publishes PR #1992. */
/** The cast that AIR's draft updates and the async task updates need. */
export function asSdkSessionNotification(
notification: AcpSessionNotification,
): SessionNotification {

View File

@@ -11,6 +11,29 @@ export const AIR_RAW_INPUT_RENDERING_CAPABILITY = "rawInputRendering";
* file, and AIR reads the plan from it.
*/
export const AIR_PLAN_FILE_CAPABILITY = "planFile";
/**
* The session index: a bounded, ordered `session/list` with row metadata, and
* `_session/rename`, `_session/archive`, `_session/unarchive` and the list
* subscription. Advertised only to a client that declares it.
*/
export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex";
/**
* The agent supports `_session/archive` and `_session/unarchive`. Advertised
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not
* declare it.
*/
export const AIR_SESSION_ARCHIVE_CAPABILITY = "sessionArchive";
/**
* The agent supports `_session/rename`. Advertised exactly when
* {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it.
*/
export const AIR_SESSION_RENAME_CAPABILITY = "sessionRename";
/**
* The agent supports `_session/list/subscribe`, `_session/list/unsubscribe`
* and `_session/list/changes`. Advertised exactly when
* {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it.
*/
export const AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY = "sessionListSubscribe";
/** The `_meta.jetbrains.air` keys that the ACP tool call contract defines. */
export const AIR_COMMAND_TITLE_KEY = "commandTitle";

View File

@@ -786,7 +786,7 @@ export function backgroundBashTaskFromToolResult(
content: unknown,
toolUseResult: unknown,
toolUses: Record<string, { name: string; input: unknown }>,
): AsyncTaskStarted | undefined {
): (AsyncTaskStarted & { taskId: string; toolCallId: string }) | undefined {
if (!Array.isArray(content)) return undefined;
const toolResults = content.flatMap(toolResultBlock);
const bashResults = toolResults.filter((result) => toolUses[result.toolUseId]?.name === "Bash");

View File

@@ -569,11 +569,52 @@ function textLines(text: string): string[] {
* its time budget.
*/
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
const hunks = diffHunks(oldText, newText);
const hunks = memoizedDiffHunks(oldText, newText);
if (!hunks || hunks.length === 0) return undefined;
return { change: oldText === null ? "create" : "update", hunks };
}
/** The number of recent line diffs that {@link memoizedDiffHunks} keeps. */
const DIFF_MEMO_SIZE = 4;
/** Recent line diffs not read again yet, the most recent last. A text of at
* most {@link MAX_PATCH_FILE_BYTES} is held until it is read again or later
* diffs push it out. */
const diffMemo: { oldText: string | null; newText: string; hunks: PatchHunk[] }[] = [];
/**
* {@link diffHunks} of a recent pair of texts again, without a second diff.
*
* The approval of an Edit or Write diffs the file against the predicted text,
* and the PostToolUse hook diffs the same texts once Claude wrote them. On a
* large file each diff can block the event loop for its whole time budget.
* Only a diff that completed is remembered: one that ran out of its budget
* says nothing about the texts, so a later call diffs them again. The hunks
* are shared, and no caller changes them.
*/
function memoizedDiffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
const index = diffMemo.findIndex(
(entry) => entry.oldText === oldText && entry.newText === newText,
);
if (index >= 0) {
// The hook is normally the last reader of a pair, so the entry goes: the
// memo does not hold two large texts longer than needed.
const [entry] = diffMemo.splice(index, 1);
return entry.hunks;
}
const hunks = diffHunks(oldText, newText);
if (hunks) {
diffMemo.push({ oldText, newText, hunks });
if (diffMemo.length > DIFF_MEMO_SIZE) diffMemo.shift();
}
return hunks;
}
/** Forget the remembered line diffs. For tests. */
export function clearDiffMemo(): void {
diffMemo.length = 0;
}
/**
* Reads a file as patch input.
*

View File

@@ -126,6 +126,38 @@ export function isExitPlanInterruptionResult(
);
}
/** The id of the plan that an approved ExitPlanMode publishes. Claude Code
* keeps one plan file per session, which a later plan mode rewrites, so each
* approval replaces the plan before it. The TodoWrite checklist is a
* separate plan. */
export const PLAN_MODE_PLAN_ID = "plan-mode";
/** The plan, trimmed, that a successful ExitPlanMode result approved: the plan
* of the structured `output`, which includes the user's edits, else the plan
* that Claude Code adds to the tool's `input`. Replay has only the input: the
* SDK's `getSessionMessages` drops the structured output that the transcript
* keeps. Undefined for a subagent's plan, one that waits for its team lead
* (both only known from the output), and an empty plan. */
export function approvedPlan(input: unknown, output: unknown): string | undefined {
const result = output as
{ plan?: unknown; isAgent?: unknown; awaitingLeaderApproval?: unknown } | undefined;
if (result?.isAgent === true || result?.awaitingLeaderApproval === true) return undefined;
const plan =
typeof result?.plan === "string"
? result.plan
: (input as { plan?: unknown } | undefined)?.plan;
return typeof plan === "string" && plan.trim() !== "" ? plan.trim() : undefined;
}
/** The update that shows an approved plan to a client that supports plan
* operations (the `plan` client capability, which every v2 client has). */
export function planModeUpdate(plan: string): SessionNotification["update"] {
return {
sessionUpdate: "plan_update",
plan: { type: "markdown", planId: PLAN_MODE_PLAN_ID, content: plan },
};
}
/** Claude wraps a rejected ExitPlanMode explanation in a Markdown code fence.
* Strip exactly one complete outer fence for that tool only. */
export function exitPlanModeRawOutput(toolName: string, content: unknown): unknown {
@@ -144,6 +176,8 @@ export type ExitPlanRestartHost<
"publishSessionState" | "continuationMessage"
> & {
sessionUpdate(notification: SessionNotification): Promise<void>;
/** Whether the client takes plan operations (see planModeUpdate). */
supportsPlanOperations(): boolean;
destroyReplacement(sessionId: string, session: Session): void;
settleCancelledTurn(oldSession: Session, turnSession: Session, turn: Turn): void;
settleFailedTurn(turnSession: Session, turn: Turn, error: unknown): void;
@@ -180,6 +214,12 @@ export class ExitPlanCoordinator<
sessionId: id,
update: { sessionUpdate: "config_option_update", configOptions },
});
// The approved plan becomes the session's plan once the fresh
// context that carries it out exists. Its ExitPlanMode ended with
// an error result, so toAcpNotifications did not publish it.
if (this.host.supportsPlanOperations()) {
await this.host.sessionUpdate({ sessionId: id, update: planModeUpdate(reset.plan) });
}
},
continuationMessage: (id, plan, promptUuid) => ({
type: "user",

View File

@@ -29,6 +29,10 @@ export type McpCommand =
* or `/mcp disable` with an optional server name or `all`. Any other `/mcp`
* argument gives null, and the prompt gets no replacement. */
export function parseMcpCommand(text: string): McpCommand | null {
// The first word is `/mcp` only when the text starts with it after the
// leading whitespace. Checking that first keeps a long prompt from being
// split into words. `trimStart` removes the characters that `\s` matches.
if (!text.trimStart().startsWith("/mcp")) return null;
const words = text.trim().split(/\s+/);
if (words[0] !== "/mcp") return null;
if (words.length === 1) return { action: "status" };

View File

@@ -1,6 +1,12 @@
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
import { RequestError, type SessionNotification } from "@agentclientprotocol/sdk";
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
type WorkState = NonNullable<
Extract<SessionNotification["update"], { sessionUpdate: "subagent_update" }>["state"]
>;
export type NativeSubagent = {
sessionId: string;
parentSessionId: string;
@@ -9,9 +15,32 @@ export type NativeSubagent = {
task: string;
/**
* The exact prompt of this generation, sent as `prompt` in
* `subagent_spawned`. It is absent when the adapter has no prompt.
* `subagent_spawned`, or as the child's first `session_message` in the RFD
* form. It is absent when the adapter has no prompt.
*/
prompt?: string;
/** The RFD form: the parent's label for the child, `subagent_update.title`. */
title?: string;
/** The RFD form: the child's role, its `subagent_type`, for the title. */
role?: string;
/** The RFD form: the child's purpose, `subagent_update.description`. */
description?: string;
/**
* The RFD form: the work state the client holds. The child is reusable, so
* `idle` is not terminal. AIR's draft uses {@link terminalState} instead.
*/
workState?: WorkState["state"];
/**
* The RFD form: the child's idle snapshot reports a failure without the
* SDK's error text, which a later event of the same failure may carry.
*/
missingError?: boolean;
/** The RFD form: the open permission and question requests of the child. */
openRequests?: number;
/** The RFD form: the ids of the prompt messages in the child's transcript. */
promptIds?: Set<string>;
/** The RFD form: the calls that delegated to the child, whose runs ended. */
endedRuns?: Set<string>;
announced?: boolean;
terminalState?: SubagentState;
/** Connection-local single-flight state; never serialized on the wire. */
@@ -29,6 +58,19 @@ export type NativeSubagentSession = {
type Publish = (notification: AcpSessionNotification) => Promise<void>;
type Logger = { log(message: string): void };
/** What the SDK says about the end of a task, beyond its status. */
export type TaskEnd = {
/** The SDK's text for a failure. */
error?: string;
/** The SDK's reason for an unusual end, such as `worker_restart`. */
reason?: string;
/**
* The adapter knows that the work ended, but not why: the stream ended, or
* the conversation was reset, with the task still running.
*/
unexplained?: boolean;
};
type TaskStarted = {
taskId: string;
toolUseId?: string | null;
@@ -56,6 +98,8 @@ const MAX_CHILD_TOOL_CALLS = 2048;
*/
export class NativeSubagentRuntime {
readonly enabled: boolean;
/** The form of the child sessions; see {@link SubagentForm}. */
readonly form: SubagentForm | undefined;
private readonly children: Map<string, NativeSubagent>;
private readonly taskByToolUse: Map<string, string>;
@@ -70,18 +114,22 @@ export class NativeSubagentRuntime {
*/
private readonly childByToolCall = new Map<string, NativeSubagent>();
private readonly taskFinishPromises = new Map<string, Promise<void>>();
/** The RFD form: the session that made each SendMessage call. */
private readonly senderByToolUse = new Map<string, string>();
private readonly generationByTaskId = new Map<string, number>();
private readonly pending = new Map<string, AcpSessionNotification[]>();
private pendingCount = 0;
/** `form` is undefined for a client without native subagent sessions. */
constructor(
enabled: boolean,
form: SubagentForm | undefined,
private readonly rootSessionId: string,
private readonly session: NativeSubagentSession,
private readonly publish: Publish,
private readonly logger: Logger,
) {
this.enabled = enabled;
this.form = form;
this.enabled = form !== undefined;
this.children = session.nativeSubagentsByTaskId ??= new Map();
this.taskByToolUse = session.nativeSubagentTaskIdByToolUseId ??= new Map();
this.parentByToolUse = session.nativeSubagentParentByToolUseId ??= new Map();
@@ -96,6 +144,16 @@ export class NativeSubagentRuntime {
notification: AcpSessionNotification,
deliver: Publish,
forcedSessionId?: string,
): Promise<AcpSessionNotification | null> {
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
if (routed && this.form === "rfd") this.rememberSendMessage(routed);
return routed;
}
private async routeUpdate(
notification: AcpSessionNotification,
deliver: Publish,
forcedSessionId?: string,
): Promise<AcpSessionNotification | null> {
const { update } = notification;
const claudeMeta = update._meta?.claudeCode as
@@ -116,28 +174,47 @@ export class NativeSubagentRuntime {
mergeSubagentIdentity(this.identityByToolUse.get(toolCallId), identity),
);
}
const parentChild = claudeMeta?.parentToolUseId
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
: undefined;
const parentSessionId = claudeMeta?.parentToolUseId
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)?.sessionId
? parentChild?.sessionId
: this.rootSessionId;
this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
// The RFD form: a call made inside a child that is not announced yet
// establishes no parent, because the RFD forbids announcing its child
// under the root or before that child. The update is buffered below
// under the child's tool use, and routed again, with the child as the
// parent, once the child is announced.
const parentPending =
this.form === "rfd" && !!claudeMeta?.parentToolUseId && !parentChild?.announced;
const child = this.childByParentToolUse.get(toolCallId);
if (child && !child.announced) {
child.parentSessionId = parentSessionId ?? this.rootSessionId;
applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
await announceNativeSubagent(child, this.publish);
for (const pending of this.takePending(toolCallId)) await deliver(pending);
}
if (!child && isFailedToolCallUpdate(update)) {
this.takePending(toolCallId);
const initial = this.controlByToolUse.get(toolCallId);
this.cleanupControl(toolCallId);
if (forcedSessionId) {
return { ...notification, sessionId: forcedSessionId };
if (!parentPending) {
this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
if (child && !child.announced) {
child.parentSessionId = parentSessionId ?? this.rootSessionId;
applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
await this.announce(child);
for (const pending of this.takePending(toolCallId)) await deliver(pending);
}
return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
}
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
if (this.form === "air") {
if (!child && isFailedToolCallUpdate(update)) {
this.takePending(toolCallId);
const initial = this.controlByToolUse.get(toolCallId);
this.cleanupControl(toolCallId);
if (forcedSessionId) {
return { ...notification, sessionId: forcedSessionId };
}
return failedControlFallback(
initial,
notification,
parentSessionId ?? this.rootSessionId,
);
}
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
}
// The RFD form: the Agent/Task tool call is the parent's record of the
// delegation, so it stays a tool call of the session that made it.
}
const toolCallId =
@@ -172,6 +249,19 @@ export class NativeSubagentRuntime {
async taskStarted(task: TaskStarted, deliver: Publish): Promise<void> {
if (!this.enabled) return;
const known = this.form === "rfd" ? this.children.get(task.taskId) : undefined;
if (known) {
// A child keeps its session for every delegation to it. The SDK starts
// a resumed agent again under the id of the call that resumed it.
await this.resumeChild(
known,
task.taskId,
promptText(task.prompt),
task.toolUseId ?? undefined,
deliver,
);
return;
}
if (!task.subagentType) {
if (task.toolUseId) {
this.takePending(task.toolUseId);
@@ -207,6 +297,11 @@ export class NativeSubagentRuntime {
identity?.description ?? task.description,
),
...promptField(promptText(task.prompt) ?? identity?.prompt),
...rfdLabels(
identity?.name,
identity?.subagentType ?? task.subagentType,
identity?.description ?? task.description,
),
},
!!knownParentSessionId || !task.toolUseId,
deliver,
@@ -219,6 +314,10 @@ export class NativeSubagentRuntime {
* running `task_updated` patch or a SendMessage `resumedAgentId` is the
* signal. A child that did not finish is not changed. The `prompt` is the
* SendMessage text that resumed the child, when the adapter knows it.
*
* In the RFD form, the running patch only reports that the child runs: its
* prompt is a guess (the latest SendMessage to the agent), and the
* `task_started` or the SendMessage result carries the delegation.
*/
async taskResumed(taskId: string, deliver: Publish, prompt?: string): Promise<void> {
if (!this.enabled) return;
@@ -226,6 +325,10 @@ export class NativeSubagentRuntime {
if (!previous) return;
const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
if (finishing) await finishing.catch(() => {});
if (this.form === "rfd") {
await this.resumeChild(previous, taskId, undefined, undefined, deliver);
return;
}
if (this.children.get(taskId) !== previous || previous.terminalState === undefined) return;
await this.openGeneration(
taskId,
@@ -242,27 +345,99 @@ export class NativeSubagentRuntime {
);
}
/**
* The SDK reports in a SendMessage result that the call `toolUseId`, with
* the message `prompt`, resumed the agent `taskId`. AIR's draft treats it as
* any resume signal. In the RFD form, the call is the delegation, which the
* child takes unless its run already ended: the result can arrive after it.
*/
async sendMessageResumed(
taskId: string,
deliver: Publish,
prompt?: string,
toolUseId?: string,
): Promise<void> {
if (this.form !== "rfd") return this.taskResumed(taskId, deliver, prompt);
const child = this.children.get(taskId);
if (!child) return;
const finishing = this.taskFinishPromises.get(taskId);
if (finishing) await finishing.catch(() => {});
if (this.isEndedDelegation(taskId, toolUseId)) return;
await this.resumeChild(child, taskId, promptText(prompt), toolUseId, deliver);
}
/**
* The RFD form: whether the run of the call `toolUseId` that delegated to
* the child `taskId` already ended. AIR's draft has no such runs.
*/
isEndedDelegation(taskId: string, toolUseId: string | null | undefined): boolean {
if (this.form !== "rfd" || !toolUseId) return false;
return this.children.get(taskId)?.endedRuns?.has(toolUseId) ?? false;
}
/**
* The RFD form: whether `toolUseId` is an earlier delegation of the child
* `taskId`, whose end does not end the current one. AIR's draft has no
* such delegations.
*/
isStaleEnd(taskId: string, toolUseId: string | null | undefined): boolean {
if (this.form !== "rfd" || !toolUseId) return false;
const child = this.children.get(taskId);
return (
!!child &&
child.parentToolUseId !== undefined &&
toolUseId !== child.parentToolUseId &&
this.childByParentToolUse.get(toolUseId) === child
);
}
/**
* Reports that the SDK ended the task's work. `end` has what the SDK says
* about it: its text for a failure, and its reason for an unusual end.
*/
async finishTask(
taskId: string,
status: unknown,
deliver: Publish,
toolUseId?: string | null,
end?: TaskEnd,
): Promise<void> {
if (!this.enabled) return;
const state = nativeSubagentState(status);
const child = toolUseId ? this.childByParentToolUse.get(toolUseId) : this.children.get(taskId);
if (child && toolUseId && this.taskByToolUse.get(toolUseId) !== taskId) return;
if (!state || !child || child.terminalState !== undefined) return;
if (!state || !child) return;
if (this.form === "rfd") {
if (this.isStaleEnd(taskId, toolUseId)) return;
if (!child.announced && !child.announcePromise) {
this.withdraw(taskId, child);
return;
}
}
const existing = this.taskFinishPromises.get(taskId);
if (existing) return existing;
if (existing) {
await existing;
if (this.form === "rfd") await this.supplyError(child, state, end?.error);
return;
}
if (this.form === "air" ? child.terminalState !== undefined : child.workState === "idle") {
if (this.form === "rfd") await this.supplyError(child, state, end?.error);
return;
}
const finish = Promise.resolve().then(async () => {
try {
await announceNativeSubagent(child, this.publish);
await this.announce(child);
if (child.parentToolUseId) {
for (const pending of this.takePending(child.parentToolUseId)) await deliver(pending);
}
await finishNativeSubagent(this.session, taskId, state, this.publish);
if (this.form === "rfd") {
await this.reportState(child, rfdEndState(state, end));
child.missingError = state === "failed" && !end?.error && !end?.unexplained;
if (child.parentToolUseId) (child.endedRuns ??= new Set()).add(child.parentToolUseId);
} else {
await finishNativeSubagent(this.session, taskId, state, this.publish);
}
} finally {
if (child.parentToolUseId) {
this.cleanupControl(child.parentToolUseId);
@@ -277,12 +452,20 @@ export class NativeSubagentRuntime {
}
}
/**
* Ends every child that still runs, because the stream ended or the
* conversation was reset. AIR's draft reports `state` for each. In the RFD
* form the cause of each child's end is unknown, so it is idle with no stop
* reason: a child that a cancel stopped already reported it from the SDK's
* events, and a background child that outlived the cancel was not cancelled.
*/
async finishAll(state: SubagentState, deliver: Publish): Promise<void> {
const errors: unknown[] = [];
const end = this.form === "rfd" ? { unexplained: true } : undefined;
try {
for (const taskId of [...this.children.keys()].reverse()) {
try {
await this.finishTask(taskId, state, deliver);
await this.finishTask(taskId, state, deliver, undefined, end);
} catch (error) {
errors.push(error);
}
@@ -298,20 +481,145 @@ export class NativeSubagentRuntime {
if (errors.length > 1) throw new AggregateError(errors, "Failed to finish native subagents");
}
/**
* The parent session got `session/cancel`. AIR's draft ends every child as
* cancelled here. The RFD form reports nothing: acknowledging a cancel is no
* evidence that a child stopped. Claude Code's interrupt stops the
* foreground children that the turn waits on and, currently, background
* children too, and the SDK's task events report each as cancelled. A child
* that keeps running keeps its open requests
* (agentclientprotocol/agent-client-protocol#2308).
*/
async parentCancelled(deliver: Publish): Promise<void> {
if (this.form === "air") await this.finishAll("cancelled", deliver);
}
/**
* Runs `request`, a permission or question request of the session
* `sessionId`. In the RFD form, a child waiting on it is `requires_action`
* until its last open request ends.
*/
async awaitingUser<T>(sessionId: string, request: () => Promise<T>): Promise<T> {
const child = this.form === "rfd" ? this.childOfSession(sessionId) : undefined;
if (!child?.announced) return request();
child.openRequests = (child.openRequests ?? 0) + 1;
if (child.openRequests === 1 && child.workState === "running") {
await this.reportState(child, { state: "requires_action" }).catch((error) =>
this.logger.log(`Failed to report that subagent ${child.sessionId} waits: ${error}`),
);
}
try {
return await request();
} finally {
child.openRequests = (child.openRequests ?? 1) - 1;
if (child.openRequests === 0 && child.workState === "requires_action") {
await this.reportState(child, { state: "running" }).catch((error) =>
this.logger.log(`Failed to report that subagent ${child.sessionId} runs: ${error}`),
);
}
}
}
discardPending(parentToolUseId: string): void {
this.takePending(parentToolUseId);
}
/**
* Routes an update to the child session. An update of a finished child is
* dropped: it never goes to the root session.
* The RFD form: the child runs again, and keeps its session. `toolUseId` is
* the call that delegated, usually a SendMessage: the SDK runs the resumed
* work under its id, so it becomes the child's current delegation, whose
* end ends the run, and its prompt is the message with that id. A child
* that is already working only takes the delegation; a prompt already in
* its transcript is not sent again. A child that was never announced is
* not changed.
*/
private async resumeChild(
child: NativeSubagent,
taskId: string,
prompt: string | undefined,
toolUseId: string | undefined,
deliver: Publish,
): Promise<void> {
if (!child.announced) return;
if (toolUseId && toolUseId !== child.parentToolUseId) {
this.taskByToolUse.set(toolUseId, taskId);
this.childByParentToolUse.set(toolUseId, child);
child.parentToolUseId = toolUseId;
}
if (!isWorking(child)) {
await this.reportState(child, {
state: child.openRequests ? "requires_action" : "running",
});
}
if (prompt && toolUseId) {
await publishPrompt(
child,
prompt,
toolUseId,
this.senderByToolUse.get(toolUseId),
this.publish,
);
}
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
}
/**
* The RFD form: a child that ends before its parent is known stays
* unexposed, since the RFD forbids guessing its parent. Its buffered updates
* are dropped, and it is forgotten, so a late frame does not announce it.
* (Buffering until that frame would also meet the RFD, but the frame comes
* before the task in the SDK's order, so a child ending without it is rare.)
*/
private withdraw(taskId: string, child: NativeSubagent): void {
this.children.delete(taskId);
if (child.parentToolUseId) {
this.takePending(child.parentToolUseId);
this.cleanupControl(child.parentToolUseId);
this.childByParentToolUse.delete(child.parentToolUseId);
this.taskByToolUse.delete(child.parentToolUseId);
}
}
/**
* The RFD form: a later event of a failure can carry the SDK's error text
* that the reported idle snapshot lacks.
*/
private async supplyError(
child: NativeSubagent,
state: SubagentState,
error: string | undefined,
): Promise<void> {
if (state !== "failed" || !error || !child.missingError) return;
await this.reportState(child, rfdEndState("failed", { error }));
}
/** Announces `child` in the client's form. A runtime without one never does. */
private async announce(child: NativeSubagent): Promise<void> {
if (this.form) await announceNativeSubagent(child, this.publish, this.form);
}
/** The RFD form: publishes the work state of `child` on its parent. */
private async reportState(child: NativeSubagent, state: WorkState): Promise<void> {
child.workState = state.state;
child.missingError = false;
await this.publish({
sessionId: child.parentSessionId,
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
});
}
/**
* Routes an update to the child session. It never goes to the root session:
* in AIR's draft, an update of a finished child is dropped.
*/
private toChild(
child: NativeSubagent,
notification: AcpSessionNotification,
toolCallId: string | undefined,
): AcpSessionNotification | null {
if (child.terminalState !== undefined || child.terminalPromise) {
// AIR's draft ends a child for good. In the RFD form an idle child is
// reusable, and its background work can still report.
if (this.form === "air" && (child.terminalState !== undefined || child.terminalPromise)) {
this.logger.log(
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
);
@@ -327,10 +635,11 @@ export class NativeSubagentRuntime {
* tool call when the work started. An async task can outlive that child: a
* subagent can start a background command and end its turn. So the route
* still sends `async_task_spawned`, `async_task_progress` and
* `async_task_state_update` to the child generation after it finished. The route drops every other update
* after the child finished. `undefined` means that the root session owns the
* tool call. `eagerSessionId` is the session where a permission request
* created the tool call before the stream routed it.
* `async_task_state_update` to the child generation after it finished. In
* AIR's draft, the route drops every other update after the child finished;
* in the RFD form, an idle child gets them all. `undefined` means that the
* root session owns the tool call. `eagerSessionId` is the session where a
* permission request created the tool call before the stream routed it.
*/
routeOfToolCall(
toolCallId: string,
@@ -349,6 +658,23 @@ export class NativeSubagentRuntime {
);
}
/**
* The RFD form: remembers the session that made a SendMessage call, the
* sender of the prompt when the call resumes a child.
*/
private rememberSendMessage(notification: AcpSessionNotification): void {
const { update } = notification;
if (update.sessionUpdate !== "tool_call") return;
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
if (claudeMeta?.toolName !== "SendMessage") return;
this.senderByToolUse.delete(update.toolCallId);
this.senderByToolUse.set(update.toolCallId, notification.sessionId);
if (this.senderByToolUse.size > MAX_CHILD_TOOL_CALLS) {
const oldest = this.senderByToolUse.keys().next().value;
if (oldest !== undefined) this.senderByToolUse.delete(oldest);
}
}
private rememberToolCallOwner(toolCallId: string, child: NativeSubagent): void {
this.childByToolCall.delete(toolCallId);
this.childByToolCall.set(toolCallId, child);
@@ -376,6 +702,7 @@ export class NativeSubagentRuntime {
this.controlByToolUse.clear();
this.childByParentToolUse.clear();
this.taskFinishPromises.clear();
this.senderByToolUse.clear();
this.generationByTaskId.clear();
this.pending.clear();
this.pendingCount = 0;
@@ -438,7 +765,14 @@ export class NativeSubagentRuntime {
previous: NativeSubagent | undefined,
fields: Pick<
NativeSubagent,
"parentSessionId" | "parentToolUseId" | "name" | "task" | "prompt"
| "parentSessionId"
| "parentToolUseId"
| "name"
| "task"
| "prompt"
| "title"
| "role"
| "description"
>,
announce: boolean,
deliver: Publish,
@@ -455,10 +789,20 @@ export class NativeSubagentRuntime {
this.controlByToolUse.delete(toolUseId);
}
if (!announce) return;
await announceNativeSubagent(child, this.publish);
await this.announce(child);
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
}
/**
* The ACP session id of a child: the SDK agent id, and in AIR's draft a
* generation suffix for each resume. The RFD needs it unique within the
* connection and never under two parents. Agent ids are random, and an
* agent keeps its parent, so only a fork could repeat one under another
* root. That holds today because the SDK's `forkSession` copies no
* `subagents/` history, so a fork cannot resume an agent of the original.
* If a fork ever can, the RFD requires remapping the copied ids, for
* example by deriving the child id from the root session id.
*/
private nextChildSessionId(taskId: string, previous: NativeSubagent | undefined): string {
if (!previous) {
this.generationByTaskId.set(taskId, 1);
@@ -470,13 +814,45 @@ export class NativeSubagentRuntime {
}
}
/**
* Announces the child on its parent, once, before any update of the child.
* The RFD form announces it as running and then sends the prompt as the
* first message of the child. The child gets no capabilities, so the client
* may not cancel or otherwise change it.
*/
export async function announceNativeSubagent(
child: NativeSubagent,
publish: Publish,
form: SubagentForm,
): Promise<void> {
if (child.announced) return;
if (child.announcePromise) return child.announcePromise;
const announce = Promise.resolve().then(async () => {
if (form === "rfd") {
child.workState = "running";
await publish({
sessionId: child.parentSessionId,
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
...(child.title ? { title: child.title } : {}),
...(child.description ? { description: child.description } : {}),
state: { state: "running" },
},
});
child.announced = true;
// The immediate parent made the Agent/Task call that launched the child.
if (child.prompt) {
await publishPrompt(
child,
child.prompt,
child.parentToolUseId,
child.parentSessionId,
publish,
);
}
return;
}
await publish({
sessionId: child.parentSessionId,
update: {
@@ -508,7 +884,7 @@ export async function finishNativeSubagent(
if (!child || child.terminalState !== undefined) return;
if (child.terminalPromise) return child.terminalPromise;
const finish = Promise.resolve().then(async () => {
await announceNativeSubagent(child, publish);
await announceNativeSubagent(child, publish, "air");
await publish({
sessionId: child.parentSessionId,
update: {
@@ -527,6 +903,98 @@ export async function finishNativeSubagent(
}
}
/**
* The RFD form: a message to the child, in the child's transcript (its
* incoming view). Its id is the id of the tool use that sent it, the
* Agent/Task call or a SendMessage, so a prompt whose id is already in the
* transcript is the same delegation and is not sent again. `sender` is the
* session that made that call, when known.
*/
async function publishPrompt(
child: NativeSubagent,
prompt: string,
toolUseId: string | undefined,
sender: string | undefined,
publish: Publish,
): Promise<void> {
child.promptIds ??= new Set();
const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
if (child.promptIds.has(messageId)) return;
child.promptIds.add(messageId);
await publish({
sessionId: child.sessionId,
update: {
sessionUpdate: "session_message",
messageId,
...(sender ? { senderSessionId: sender } : {}),
recipientSessionId: child.sessionId,
content: [{ type: "text", text: prompt }],
},
});
}
/** The RFD form: whether the child's foreground work is in progress. */
function isWorking(child: NativeSubagent): boolean {
return child.workState === "running" || child.workState === "requires_action";
}
/**
* The RFD form: the work state at the end of a child's work.
*
* A failure is idle with the `error` stop reason and, when the SDK gives its
* text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt
* response, so this is how a client learns that its work failed. The error is
* an internal error, as for a failed turn on the v2 surface.
*
* A task that a worker restart orphaned, or that was still running when the
* stream ended, is idle with no stop reason: its work ended, but the adapter
* did not see a cancellation or a failure, which the RFD forbids claiming.
*/
function rfdEndState(state: SubagentState, end: TaskEnd | undefined): WorkState {
if (end?.unexplained || end?.reason === "worker_restart") return { state: "idle" };
switch (state) {
case "completed":
return { state: "idle", stopReason: "end_turn" };
case "cancelled":
return { state: "idle", stopReason: "cancelled" };
case "failed":
return {
state: "idle",
stopReason: "error",
...(end?.error
? { error: { code: RequestError.internalError().code, message: end.error } }
: {}),
};
default:
// "disconnected": the child can no longer be observed.
return { state: "unknown" };
}
}
/**
* The RFD form: the parent's labels for a child. The title is the agent's
* name, with its role after it when both are known, else the role. The
* description is the task's short description, never the prompt.
*/
function rfdLabels(
name: unknown,
role: unknown,
description: unknown,
): { title?: string; role?: string; description?: string } {
const agentName = nonBlankString(name);
const agentRole = nonBlankString(role);
const title =
agentName && agentRole && agentName !== agentRole
? `${agentName} (${agentRole})`
: (agentName ?? agentRole);
const text = nonBlankString(description);
return {
...(title ? { title } : {}),
...(agentRole ? { role: agentRole } : {}),
...(text ? { description: text } : {}),
};
}
/**
* The agent id that a successful SendMessage result resumed. The SDK puts it
* in `tool_use_result.resumedAgentId` when a finished agent runs again.
@@ -547,17 +1015,34 @@ export function sendMessageResumePrompt(
agentId: string,
resultToolUseIds: readonly string[] = [],
): string | undefined {
return sendMessageResume(toolUses, agentId, resultToolUseIds)?.text;
}
/**
* The SendMessage call that resumed the agent `agentId`, and its text when it
* has one; see {@link sendMessageResumePrompt}. The call is known even
* without text, so a late result still names the delegation it reports.
*/
export function sendMessageResume(
toolUses: Record<string, { name: string; input: unknown } | undefined>,
agentId: string,
resultToolUseIds: readonly string[] = [],
): { toolUseId: string; text?: string } | undefined {
let withoutText: string | undefined;
for (const toolUseId of resultToolUseIds) {
const toolUse = toolUses[toolUseId];
if (toolUse?.name !== "SendMessage") continue;
const text = promptText((toolUse.input as { message?: unknown } | null)?.message);
if (text) return text;
if (text) return { toolUseId, text };
withoutText ??= toolUseId;
}
if (resultToolUseIds.length > 0) return undefined;
for (const toolUse of Object.values(toolUses).reverse()) {
if (resultToolUseIds.length > 0) return withoutText ? { toolUseId: withoutText } : undefined;
for (const [toolUseId, toolUse] of Object.entries(toolUses).reverse()) {
if (toolUse?.name !== "SendMessage") continue;
const input = toolUse.input as { to?: unknown; message?: unknown } | null;
if (input?.to === agentId) return promptText(input.message);
if (input?.to !== agentId) continue;
const text = promptText(input.message);
return text ? { toolUseId, text } : { toolUseId };
}
return undefined;
}
@@ -746,6 +1231,14 @@ function applySubagentIdentity(
child.task = subagentDescription(identity.prompt, identity.description);
}
child.prompt ??= identity.prompt;
const labels = rfdLabels(
identity.name,
identity.subagentType ?? child.role,
identity.description,
);
child.title = labels.title ?? child.title;
child.role = labels.role ?? child.role;
child.description = labels.description ?? child.description;
}
/** The prompt text unchanged, or `undefined` when it is not a non-blank string. */

View File

@@ -0,0 +1,130 @@
/**
* The `activity` and `cost` of a session list row.
*
* A session that this connection runs reports the SDK's own state, and
* `error` when it is idle and its last turn failed with an error (not a
* cancel). Another session reports what the CLI registry and the transcript
* tell:
*
* - no live process holds it: `idle`, or `error` when its last turn ended
* with an API error;
* - an interactive CLI that is not driven by an SDK holds it, and its registry
* status is newer than the transcript: the registry status (an idle one is
* `error` after such a turn);
* - otherwise the transcript tail: a finished turn is `idle` (`error` after
* an API error), an unfinished turn written in the last 10 minutes is
* `running`, and anything else has no known state.
*
* A user interrupt is no error. A new turn clears it.
*
* The SDK-driven CLIs (`entrypoint: sdk-*`, which includes the ones this
* adapter starts) keep `busy` in the registry long after a turn ends, so their
* registry status is not used.
*/
import type { LiveRecord } from "./live-registry.js";
import type { TranscriptFacts } from "./transcript-scan.js";
export type ActivityState = "running" | "idle" | "requires_action" | "error";
export type SessionActivity = {
state?: ActivityState;
lastTurnEndedAt?: string;
};
/** What this connection knows about a session it runs. */
export type OwnSessionState = {
state?: ActivityState;
/** Epoch ms of the end of the last turn. */
lastTurnEndedAt?: number;
/** `total_cost_usd` of the last result. */
costUsd?: number;
/** The last turn failed with an error (not a cancel). */
lastTurnFailed?: boolean;
/** The query of the session failed here at this time (epoch ms) and no
* longer runs: the session is reported as any other one, `error` until
* its transcript shows a prompt or a turn end after it. */
queryFailedAt?: number;
};
const RECENT_UNFINISHED_TURN_MS = 10 * 60 * 1000;
function registryState(status: string | undefined): ActivityState | undefined {
switch (status) {
case "busy":
case "shell":
return "running";
case "waiting":
return "requires_action";
case "idle":
return "idle";
default:
return undefined;
}
}
function usesRegistryStatus(record: LiveRecord, transcriptMtimeMs: number): boolean {
return (
record.kind === "interactive" &&
!(record.entrypoint ?? "").startsWith("sdk-") &&
record.statusUpdatedAt !== undefined &&
record.statusUpdatedAt > transcriptMtimeMs
);
}
function iso(ms: number | undefined): string | undefined {
return ms === undefined || !Number.isFinite(ms) ? undefined : new Date(ms).toISOString();
}
export function deriveActivity(input: {
own?: OwnSessionState;
live?: LiveRecord;
facts: TranscriptFacts;
transcriptMtimeMs: number;
now: number;
}): SessionActivity | undefined {
const { live, facts, transcriptMtimeMs, now } = input;
const failedAt = input.own?.queryFailedAt;
const own = failedAt === undefined ? input.own : undefined;
let state: ActivityState | undefined;
// A finished turn that ended with an API error, or this connection's query
// of the session failed and nothing was written since.
const failed =
(facts.turnState === "finished" && facts.lastTurnError === true) ||
(failedAt !== undefined &&
!((facts.lastPromptAt ?? 0) > failedAt) &&
!((facts.lastTurnEndedAt ?? 0) > failedAt));
if (own) {
state = own.state ?? "idle";
if (state === "idle" && own.lastTurnFailed) state = "error";
} else if (!live) {
state = failed ? "error" : "idle";
} else if (usesRegistryStatus(live, transcriptMtimeMs)) {
// A busy or waiting CLI wins; an idle one shows how its turn ended.
state = registryState(live.status);
if (state === "idle" && failed) state = "error";
} else if (failed) {
state = "error";
} else if (facts.turnState === "finished") {
state = "idle";
} else if (
facts.turnState === "unfinished" &&
now - transcriptMtimeMs < RECENT_UNFINISHED_TURN_MS
) {
state = "running";
}
const lastTurnEndedAt = iso(own?.lastTurnEndedAt ?? facts.lastTurnEndedAt);
if (state === undefined && lastTurnEndedAt === undefined) return undefined;
return {
...(state !== undefined && { state }),
...(lastTurnEndedAt !== undefined && { lastTurnEndedAt }),
};
}
/** The cost of a session this connection runs: the `total_cost_usd` the SDK
* gave in its last result. Only a positive amount; none for any other
* session. */
export function selectCost(own: OwnSessionState | undefined): number | undefined {
const amount = own?.costUsd;
return amount !== undefined && Number.isFinite(amount) && amount > 0 ? amount : undefined;
}

View File

@@ -0,0 +1,96 @@
/**
* The session archive, in the on-disk format of JetBrains AIR's own Claude
* integration: a session is archived when its title carries the
* `[archived] ` prefix, and archive, unarchive and rename append the title as
* a pair of records to the transcript:
*
* ```
* {"type":"custom-title","customTitle":"[archived] Title","sessionId":"…"}
* {"type":"agent-name","agentName":"[archived] Title","sessionId":"…"}
* ```
*
* The archive state follows AIR's native Claude provider (see
* {@link effectiveTitle}): the last agent name decides, else the custom title
* the SDK reports (`SDKSessionInfo.customTitle`). Archive writes the prefix
* and the current title without it; unarchive writes the title without the
* prefix. Both are cut to the CLI's 200-character title limit. The records
* are metadata: they move neither `updatedAt` nor the list order. The
* `claude --resume` picker shows the prefix.
*/
/** The title prefix of an archived session. */
export const ARCHIVED_TITLE_PREFIX = "[archived] ";
/** What the CLI keeps of a stored title: `title.slice(0, 200).trim()`. */
const CLI_TITLE_LIMIT = 200;
/** A title once normalized starts with the prefix: leading space (what a
* trim removes), the marker, a run of white space, then a title character.
* The white space AIR collapses is ASCII white space, as in Java's `\s`. */
const ARCHIVED_PATTERN = /^\s*\[archived\][ \t\n\v\f\r]+(?=[^ \t\n\v\f\r])/;
/** Every leading archive prefix at once. */
const ARCHIVED_PREFIXES = /^(?:\s*\[archived\][ \t\n\v\f\r]+(?=[^ \t\n\v\f\r]))+/;
const WHITESPACE_RUN = /[ \t\n\v\f\r]+/g;
/** Whitespace collapsed to single spaces and trimmed, as AIR stores a title. */
export function normalizeStoredTitle(title: string): string {
return title.replace(WHITESPACE_RUN, " ").trim();
}
/** Cut to what the CLI keeps of a title. */
function capTitle(title: string): string {
return title.length <= CLI_TITLE_LIMIT ? title : title.slice(0, CLI_TITLE_LIMIT).trim();
}
/** Whether a session name (see {@link effectiveTitle}) marks its session
* archived. */
export function isArchivedTitle(title: string | undefined): boolean {
return title !== undefined && ARCHIVED_PATTERN.test(title);
}
/** The title shown for a stored title: without the archive prefix,
* removed once, as AIR shows it. */
export function visibleTitle(title: string): string {
return title.replace(ARCHIVED_PATTERN, "");
}
/** The title of a session that has none at all, as AIR names it. */
export function defaultSessionTitle(sessionId: string): string {
return `Session ${sessionId.slice(0, 8)}`;
}
/** The title that `title` is stored as in the given archive state: the
* visible title, with the prefix when archived, normalized and cut to the
* CLI limit. */
export function storedTitle(title: string, archived: boolean, sessionId: string): string {
// Every prefix: a title stored twice prefixed must not stay archived.
const visible =
normalizeStoredTitle(title.replace(ARCHIVED_PREFIXES, "")) || defaultSessionTitle(sessionId);
return capTitle(archived ? ARCHIVED_TITLE_PREFIX + visible : visible);
}
/**
* The title of a session and its archive state, by the rule of AIR's native
* Claude provider: the session's name is its last agent name, else its custom
* title, and the session is archived exactly when that name starts with the
* prefix. The title is that agent name, else `summary`, the title the SDK
* gives the session; it is stored, so it may carry the prefix.
*/
export function effectiveTitle(
agentName: string | undefined,
{ customTitle, summary }: { customTitle?: string; summary?: string },
): { title: string | undefined; archived: boolean } {
return { title: agentName ?? summary, archived: isArchivedTitle(agentName ?? customTitle) };
}
/** The `custom-title` and `agent-name` records of `title`, one per line. */
export function titleRecords(sessionId: string, title: string): string {
return (
JSON.stringify({ type: "custom-title", customTitle: title, sessionId }) +
"\n" +
JSON.stringify({ type: "agent-name", agentName: title, sessionId }) +
"\n"
);
}

View File

@@ -0,0 +1,459 @@
/**
* The session index of one ACP connection: what `ClaudeAcpAgent` does for a
* client that declared `sessionIndex`, and the archive of an AIR client
* without it. The agent keeps thin call sites; everything else
* lives here.
*
* Wire contract: docs/air-extensions.md, "Session index".
*/
import {
RequestError,
type AgentApp,
type DeleteSessionRequest,
type DeleteSessionResponse,
type InitializeRequest,
type ListSessionsRequest,
type ListSessionsResponse,
} from "@agentclientprotocol/sdk";
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, Logger, Session } from "../acp-agent.js";
import {
AIR_SESSION_ARCHIVE_CAPABILITY,
AIR_SESSION_INDEX_CAPABILITY,
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
AIR_SESSION_RENAME_CAPABILITY,
clientSupportsAirCapability,
withAirMeta,
} from "../air-extension.js";
import type { OwnSessionState } from "./activity.js";
import { isArchivedTitle, storedTitle, visibleTitle } from "./archive-title.js";
import {
LIST_CHANGES_METHOD,
LIST_SUBSCRIBE_METHOD,
LIST_UNSUBSCRIBE_METHOD,
parseListSubscribeRequest,
parseListUnsubscribeRequest,
type ListSubscribeRequest,
type ListSubscribeResponse,
type ListUnsubscribeRequest,
} from "./list-subscriptions.js";
import {
archiveInsteadOfDelete,
archiveTo,
renameTo,
parseRenameSessionRequest,
parseSessionIdRequest,
SESSION_ARCHIVE_METHOD,
SESSION_RENAME_METHOD,
SESSION_UNARCHIVE_METHOD,
SessionIndexService,
type RenameSessionRequest,
type RetitleOptions,
type SessionIdRequest,
type TitleChange,
} from "./service.js";
/** What the session index keeps on each `Session`. */
export type SessionIndexFields = {
/** When the last turn ended (the last `session_state_changed: idle`), epoch
* ms. Reported as `lastTurnEndedAt` in the session index. */
lastTurnEndedAt?: number;
/** The last turn failed with an error, not a cancel; a new turn clears
* it. Reported as the `error` state in the session index. */
lastTurnFailed?: boolean;
/** `total_cost_usd` of the last result, reported as the cost of the session
* in the session index. */
lastTotalCostUsd?: number;
/** The query resumed a stored conversation (load, resume, fork): the
* session has a transcript, unlike a new one before its first turn. */
resumedFromHistory?: boolean;
};
/** Records the end of a turn at `session_state_changed`. */
export function noteSessionState(
session: Session,
previous: Session["lastSessionState"],
state: Session["lastSessionState"],
): void {
if (state === "idle" && previous !== "idle") session.lastTurnEndedAt = Date.now();
if (state === "running" && previous !== "running") session.lastTurnFailed = false;
}
/** What the connection needs from the agent. */
export type SessionIndexHost = {
/** Read when used: the agent may still be under construction. */
agent: {
readonly sessions: { [key: string]: Session };
readonly client: AcpClient;
readonly logger: Logger;
};
/** Whether the client is an AIR client. */
isAirClient(): boolean;
/** Cancels the running turns of a session loaded here and sends its CLI
* the interrupt, without awaiting the reply. */
interruptSession(sessionId: string): Promise<void>;
/** Closes a session loaded here, as `session/close` does. */
teardownSession(sessionId: string): Promise<void>;
};
type EmptyResponse = Record<string, never>;
export class SessionIndexConnection {
/**
* The session index of a client that declared `sessionIndex`. Undefined for
* every other client, which keeps the session list, delete and the watchers
* exactly as before.
*/
service?: SessionIndexService;
/** The archive behind `session/delete` of an AIR client without
* `sessionIndex`: the same writes, without a list or a watcher. */
private archiver?: SessionIndexService;
/** When the query of a session failed here, by session id. */
private readonly failedQueries = new Map<string, number>();
constructor(private readonly host: SessionIndexHost) {}
/** Sets the index up for the client of `initialize`. ACP v2 does not route
* the session index methods yet. */
negotiate(request: InitializeRequest, options: { v2: boolean }): void {
this.service?.dispose();
this.service = undefined;
if (
!options.v2 &&
this.host.isAirClient() &&
clientSupportsAirCapability(request.clientCapabilities, AIR_SESSION_INDEX_CAPABILITY)
) {
this.service = new SessionIndexService({
ownSessionState: (sessionId) => this.ownSessionState(sessionId),
notifyListChanges: (changes) =>
this.host.agent.client.extNotification(LIST_CHANGES_METHOD, changes),
logError: (message, error) =>
this.host.agent.logger.error(`[session-index] ${message}:`, error),
});
}
}
/** A stored title as the client shows it: an AIR client gets the title
* of an archived session without the archive prefix, as in its list;
* every other client the title as stored. */
clientTitle(title: string): string {
return this.host.isAirClient() && isArchivedTitle(title) ? visibleTitle(title) : title;
}
/** The AIR capabilities the agent advertises for the index: the index
* itself, archive, rename and the list subscription, all only to a client
* that declared `sessionIndex`. */
capabilities(): string[] {
return this.service
? [
AIR_SESSION_INDEX_CAPABILITY,
AIR_SESSION_ARCHIVE_CAPABILITY,
AIR_SESSION_RENAME_CAPABILITY,
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
]
: [];
}
dispose(): void {
this.service?.dispose();
}
/** The query of a session that ran here failed (not a cancel): its row
* shows `error` until its transcript changes. */
onQueryFailed(sessionId: string): void {
this.failedQueries.set(sessionId, Date.now());
if (this.failedQueries.size > 1024) {
this.failedQueries.delete(this.failedQueries.keys().next().value as string);
}
this.service?.ownSessionChanged(sessionId);
}
/** A turn of a session started here: an earlier failed query of it no
* longer counts. */
onTurnStarted(sessionId: string): void {
this.failedQueries.delete(sessionId);
}
onTeardown(sessionId: string): void {
// Its row now shows what the registry and the transcript tell.
this.service?.ownSessionChanged(sessionId);
}
/** A session this connection runs changed its state, turn end or cost:
* the list subscriptions send it at once. */
onOwnSessionChanged(sessionId: string): void {
this.service?.ownSessionChanged(sessionId);
}
/** `_session/list/subscribe`. */
subscribeList(params: ListSubscribeRequest): Promise<ListSubscribeResponse> {
return this.requireService(LIST_SUBSCRIBE_METHOD).subscribeList(params.cwd);
}
/** `_session/list/unsubscribe`. Idempotent. */
unsubscribeList(params: ListUnsubscribeRequest): EmptyResponse {
this.requireService(LIST_UNSUBSCRIBE_METHOD).unsubscribeList(params.subscriptionId);
return {};
}
/** `session/list` of a `sessionIndex` client; undefined for another one. */
list(params: ListSessionsRequest): Promise<ListSessionsResponse> | undefined {
return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId));
}
/** An AIR client archives with session/delete (see {@link deleteSession}):
* its archived sessions (an archived custom title) stay hidden from the
* old list, as when the delete removed them. */
hideArchived<T extends { customTitle?: string }>(sessions: T[]): T[] {
if (!this.host.isAirClient()) return sessions;
return sessions.filter((session) => !isArchivedTitle(session.customTitle));
}
/** `_session/rename`: names a session; no generated title replaces it.
* An archived session stays archived. */
async rename(request: RenameSessionRequest): Promise<EmptyResponse> {
const index = this.requireService(SESSION_RENAME_METHOD);
const { sessionId } = request;
// As the CLI keeps it, and never with the archive prefix: a rename does
// not change the archive state.
const title = storedTitle(request.title, false, sessionId);
// The title as stored: an archived one is cut with its prefix.
const shown = (stored: string | undefined) =>
stored === undefined ? title : visibleTitle(stored);
await this.retitle(index, sessionId, renameTo(title), shown, {
mayBeUnwritten: true,
sidecar: "always",
});
return {};
}
/** `_session/archive`: hides a session from the default list and stops
* it. Idempotent; the session need not be loaded. A session loaded here
* is interrupted, retitled through its CLI while that still runs, then
* closed as `session/close` closes it. */
async archive(params: SessionIdRequest): Promise<EmptyResponse> {
const index = this.requireService(SESSION_ARCHIVE_METHOD);
const { sessionId } = params;
const loaded = this.host.agent.sessions[sessionId] !== undefined;
// No turn goes on after the archive: the interrupt goes to the CLI ahead
// of the rename.
if (loaded) await this.host.interruptSession(sessionId);
await this.retitle(index, sessionId, archiveTo(true), undefined, {
mayBeUnwritten: this.isUnwrittenSession(sessionId),
sidecar: "existing",
});
if (loaded) {
await this.host.teardownSession(sessionId);
// The closed CLI may have lost or overwritten the agent name it was
// still to write: the transcripts are archived without it once more,
// as a best effort, since the CLI took the archived title.
try {
await index.retitle(sessionId, archiveTo(true), {
mayBeUnwritten: true,
sidecar: "existing",
});
} catch (error) {
this.host.agent.logger.error(
`[session-index] archiving ${sessionId} after its close failed:`,
error,
);
}
}
await this.reportArchived(sessionId, true, loaded);
return {};
}
/** `_session/unarchive`. Idempotent; it does not load the session. */
async unarchive(params: SessionIdRequest): Promise<EmptyResponse> {
const index = this.requireService(SESSION_UNARCHIVE_METHOD);
const { sessionId } = params;
await this.retitle(index, sessionId, archiveTo(false), undefined, {
mayBeUnwritten: this.isUnwrittenSession(sessionId),
sidecar: "existing",
});
await this.reportArchived(sessionId, false, this.host.agent.sessions[sessionId] !== undefined);
return {};
}
/**
* Retitles a session (see {@link SessionIndexService.retitle}). A session
* this connection runs is titled through its CLI (`rename_session`). A
* loaded session's title state takes the change as a client title, so no
* generated title replaces it; `publish` is the title it then publishes.
*/
private async retitle(
index: SessionIndexService,
sessionId: string,
change: TitleChange,
publish: Parameters<Session["titles"]["setExplicitTitle"]>[0],
options: Pick<RetitleOptions, "mayBeUnwritten" | "sidecar">,
): Promise<void> {
const session = this.host.agent.sessions[sessionId];
// Decided once a title generation in flight has ended: the query may
// have closed meanwhile.
const persist = () => {
const query = session?.query as
| (Query & { renameSession?: (title: string, sessionId?: string) => Promise<void> })
| undefined;
// A closed session's CLI is gone here: only a running query titles it.
const running = session !== undefined && !session.queryClosed;
if (running && typeof query?.renameSession !== "function") {
// Its CLI holds the title and would write it back over ours.
throw RequestError.invalidRequest(
{ sessionId },
"The Claude Code CLI of this session cannot change its title while it runs.",
);
}
const live = running
? {
cwd: session.cwd,
rename: (title: string) => query!.renameSession!(title, sessionId),
stored: () => session.titles.storedTitle,
remember: (title: string) => session.titles.rememberStoredTitle(title),
shown: session.titles.shownTitle,
}
: undefined;
return index.retitle(sessionId, change, {
...options,
// Without its CLI, a session needs its transcript.
mayBeUnwritten: live !== undefined && options.mayBeUnwritten,
live,
});
};
if (session) {
await session.titles.setExplicitTitle(publish, persist);
} else {
await persist();
}
}
/**
* `session/delete`:
* - A `sessionIndex` client deletes for real: every transcript, whoever
* else has the session open, as the CLI does.
* - Another AIR client uses delete to mark a session done, and may reopen
* it later: the adapter archives it instead (as `_session/archive` does,
* after the CLI closed), so the transcript survives.
* - Every other client: the SDK delete, as before.
*/
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
const loaded = this.host.agent.sessions[params.sessionId] !== undefined;
// Tear down any active in-memory state first so the on-disk file isn't
// recreated by an outstanding query writing to it.
if (loaded) {
await this.host.teardownSession(params.sessionId);
}
if (this.service) {
await this.service.delete(params.sessionId, loaded);
} else if (this.host.isAirClient()) {
await archiveInsteadOfDelete(params.sessionId, this.airArchiver());
} else {
await sdkDeleteSession(params.sessionId);
}
return {};
}
private airArchiver(): SessionIndexService {
this.archiver ??= new SessionIndexService({
logError: (message, error) =>
this.host.agent.logger.error(`[session-index] ${message}:`, error),
});
return this.archiver;
}
private requireService(method: string): SessionIndexService {
if (!this.service) throw RequestError.methodNotFound(method);
return this.service;
}
/** What the session index reports for a session this connection runs. */
private ownSessionState(sessionId: string): OwnSessionState | undefined {
const session = this.host.agent.sessions[sessionId];
if (!session || session.queryClosed) {
const failedAt = this.failedQueries.get(sessionId);
return failedAt === undefined ? undefined : { queryFailedAt: failedAt };
}
return {
state: session.lastSessionState,
lastTurnEndedAt: session.lastTurnEndedAt,
// A session loaded again after its query failed here shows the failure
// until a turn of it starts.
lastTurnFailed: session.lastTurnFailed || this.failedQueries.has(sessionId),
costUsd: session.lastTotalCostUsd,
};
}
/** A new session this connection runs that may have no transcript yet: it
* resumed no stored conversation and no turn of it has ended. A session
* whose query ended, or that has history, needs its transcript. */
private isUnwrittenSession(sessionId: string): boolean {
const session = this.host.agent.sessions[sessionId];
return (
session !== undefined &&
!session.queryClosed &&
!session.resumedFromHistory &&
session.lastTurnEndedAt === undefined
);
}
/** Tells the client the archive state of a session that was loaded on
* this connection (`session_info_update` with
* `_meta.jetbrains.air.archived`, RFD #2161's `archived` field), also
* once the archive closed it. */
private async reportArchived(
sessionId: string,
archived: boolean,
loaded: boolean,
): Promise<void> {
if (!loaded) return;
await this.host.agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "session_info_update",
_meta: withAirMeta(undefined, "archived", archived),
},
});
}
}
/** The agent methods behind the session index extension methods. */
type SessionIndexMethods = {
subscribeSessionList(params: ListSubscribeRequest): Promise<ListSubscribeResponse>;
unsubscribeSessionList(params: ListUnsubscribeRequest): Promise<EmptyResponse>;
renameSessionTitle(params: RenameSessionRequest): Promise<EmptyResponse>;
archiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
unarchiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
};
/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`,
* `_session/rename`, `_session/archive` and `_session/unarchive`. */
export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp {
return app
.onRequest<ListSubscribeRequest, ListSubscribeResponse>(
LIST_SUBSCRIBE_METHOD,
{ parse: parseListSubscribeRequest },
(ctx) => agent().subscribeSessionList(ctx.params),
)
.onRequest<ListUnsubscribeRequest, EmptyResponse>(
LIST_UNSUBSCRIBE_METHOD,
{ parse: parseListUnsubscribeRequest },
(ctx) => agent().unsubscribeSessionList(ctx.params),
)
.onRequest<RenameSessionRequest, EmptyResponse>(
SESSION_RENAME_METHOD,
{ parse: parseRenameSessionRequest },
(ctx) => agent().renameSessionTitle(ctx.params),
)
.onRequest<SessionIdRequest, EmptyResponse>(
SESSION_ARCHIVE_METHOD,
{ parse: parseSessionIdRequest },
(ctx) => agent().archiveSession(ctx.params),
)
.onRequest<SessionIdRequest, EmptyResponse>(
SESSION_UNARCHIVE_METHOD,
{ parse: parseSessionIdRequest },
(ctx) => agent().unarchiveSession(ctx.params),
);
}

View File

@@ -0,0 +1,129 @@
/**
* File system reads of the session index that stay cheap with thousands of
* transcripts: bounded batches of stats, and directory listings that are
* read again only when the directory changed.
*/
import * as fs from "node:fs";
/** Stats in flight at once, across all callers of {@link statFiles}. */
const STAT_CONCURRENCY = 512;
let statsInFlight = 0;
/** Queued stats, a FIFO read from `waitingHead` (no `shift()`: the queue
* can hold tens of thousands). */
let waitingStats: (() => void)[] = [];
let waitingHead = 0;
function nextWaitingStat(): (() => void) | undefined {
if (waitingHead >= waitingStats.length) return undefined;
const next = waitingStats[waitingHead++];
if (waitingHead > 1024 && waitingHead * 2 > waitingStats.length) {
waitingStats = waitingStats.slice(waitingHead);
waitingHead = 0;
}
return next;
}
/** Runs `stat` once fewer than {@link STAT_CONCURRENCY} are in flight. */
function statBounded(filePath: string, done: (stats: fs.Stats | undefined) => void): void {
const run = () => {
statsInFlight++;
fs.stat(filePath, (error, stats) => {
statsInFlight--;
nextWaitingStat()?.();
done(error ? undefined : stats);
});
};
if (statsInFlight < STAT_CONCURRENCY) run();
else waitingStats.push(run);
}
/**
* The stats of `filePaths` (undefined for one that is gone). A project
* directory holds thousands of transcripts: the callback API costs about
* half of one promise per file, and a bounded number in flight, shared by
* all callers, keeps each turn of the event loop short (dispatching
* thousands at once holds it for 100 ms and more). Unlike synchronous
* stats, a file system stall never holds the event loop.
*/
export function statFiles(filePaths: readonly string[]): Promise<(fs.Stats | undefined)[]> {
const result: (fs.Stats | undefined)[] = new Array(filePaths.length);
if (filePaths.length === 0) return Promise.resolve(result);
return new Promise((resolve) => {
let left = filePaths.length;
filePaths.forEach((filePath, i) => {
statBounded(filePath, (stats) => {
result[i] = stats;
if (--left === 0) resolve(result);
});
});
});
}
type Listing = {
mtimeMs: number;
/** A directory replaced by another one with the same mtime differs here. */
ino: number;
/** When the listing was read. */
readAt: number;
names: string[];
};
/** A listing read this soon after the directory changed is not trusted:
* some file systems keep the mtime in whole seconds, so another change in
* the same second would leave it unchanged. */
const MTIME_RESOLUTION_MS = 2000;
/**
* Directory listings, read again only when the directory's mtime changed
* (an entry was added, removed or renamed; an append to a file does not
* change it). A project directory with thousands of transcripts is then one
* stat instead of one `readdir` per list or mutation.
*/
export class DirListings {
private readonly listings = new Map<string, Listing>();
/** The entry names of `dir`, empty when it cannot be read. */
async names(dir: string): Promise<string[]> {
return (await this.listing(dir, await statOrUndefined(dir))).names;
}
/** The entry names of each of `dirs`, one bounded batch of stats. */
async namesOfAll(dirs: readonly string[]): Promise<string[][]> {
const stats = await statFiles(dirs);
return Promise.all(dirs.map(async (dir, i) => (await this.listing(dir, stats[i])).names));
}
private async listing(dir: string, stats: fs.Stats | undefined): Promise<Listing> {
if (!stats?.isDirectory()) {
this.listings.delete(dir);
return { mtimeMs: 0, ino: 0, readAt: 0, names: [] };
}
const cached = this.listings.get(dir);
if (
cached &&
cached.mtimeMs === stats.mtimeMs &&
cached.ino === stats.ino &&
cached.readAt - cached.mtimeMs > MTIME_RESOLUTION_MS
) {
return cached;
}
const readAt = Date.now();
let names: string[];
try {
names = await fs.promises.readdir(dir);
} catch {
return { mtimeMs: 0, ino: 0, readAt, names: [] };
}
const listing: Listing = { mtimeMs: stats.mtimeMs, ino: stats.ino, readAt, names };
this.listings.set(dir, listing);
return listing;
}
}
function statOrUndefined(target: string): Promise<fs.Stats | undefined> {
return fs.promises.stat(target).then(
(stats) => stats,
() => undefined,
);
}

View File

@@ -0,0 +1,65 @@
/**
* The client-set title of a session (`_session/rename`), as `SessionTitles`
* guards it against generated titles: once a client named the session, or
* while a rename is in flight, no generated title is adopted, and a rename
* waits for a generation in flight, whose persisted title would otherwise
* land after it.
*/
export class ExplicitTitle {
/** Set once a client named the session. Released by {@link reset}. */
private named = false;
/** Renames in flight. */
private pending = 0;
/** Renames started so far, to tell that one ran between two reads. */
private started = 0;
/** The title generation in flight. */
private generation?: Promise<void>;
/** Whether a generated title must not be adopted. */
get active(): boolean {
return this.named || this.pending > 0;
}
/** Whether a client named the session and no rename is in flight: a title
* stored since by someone else (a `/rename`) may still be adopted. */
get settledByClient(): boolean {
return this.named && this.pending === 0;
}
/** Changes with every rename that starts. */
get renames(): number {
return this.started;
}
reset(): void {
this.named = false;
}
/** Tracks a title generation until it ends. */
track(generation: Promise<void>): void {
this.generation = generation;
void generation.finally(() => {
if (this.generation === generation) this.generation = undefined;
});
}
/** Persists a client title after the generation in flight. `persist`
* tells whether the session is named now. When it fails or names nothing
* (an archive of an archived session), and no other rename named the
* session, `restore` puts the title state back, so a later turn may still
* generate a title. */
async apply(persist: () => Promise<boolean>, restore: () => void): Promise<void> {
this.pending++;
this.started++;
try {
await this.generation;
if (await persist()) this.named = true;
else if (!this.named && this.pending === 1) restore();
} catch (error) {
if (!this.named && this.pending === 1) restore();
throw error;
} finally {
this.pending--;
}
}
}

View File

@@ -0,0 +1,172 @@
/**
* The first prompt of a transcript, extracted the way the SDK does for a
* session title (its `rf`, `ti` and `jP` in `@anthropic-ai/claude-agent-sdk`).
* Part of the SDK's title rule that the session index applies itself (see
* `sdkTitles`).
*
* - Only main-chain user records count: no tool result, meta or compact
* summary record.
* - Each text (the string content, or every text block) is expanded from its
* `<pasted_content>` blocks and flattened to one line.
* - A slash command (`<command-name>/init</command-name>`) is only a fallback
* while a real prompt may follow; `<bash-input>x</bash-input>` is `! x`;
* a text that starts with another tag or with an interrupt marker is
* skipped.
* - A prompt longer than 200 characters is cut and gets an ellipsis.
* - Without any text, an image or a document prompt is `Image` or `Document`.
*/
const MAX_PROMPT_LENGTH = 200;
const COMMAND_NAME = /<command-name>(.*?)<\/command-name>/;
const BASH_INPUT = /<bash-input>([\s\S]*?)<\/bash-input>/;
const SKIPPED_TEXT = /^(?:\s*<[a-z][\w-]*[\s>]|\[Request interrupted by user[^\]]*\])/;
const PASTED_OPEN = '<pasted_content id="';
type Entry = Record<string, unknown>;
function isPastedId(value: string): boolean {
return /^[0-9a-f]{4}$/.test(value);
}
/** `text` with each `<pasted_content id="xxxx">` block replaced by its body. */
function expandPastedContent(text: string): string {
const parts: string[] = [];
let done = 0;
let from = 0;
let expanded = false;
for (;;) {
const open = text.indexOf(PASTED_OPEN, from);
if (open === -1) break;
const idStart = open + PASTED_OPEN.length;
const id = text.slice(idStart, idStart + 4);
if (!isPastedId(id) || !text.startsWith('">\n', idStart + 4)) {
from = idStart;
continue;
}
const bodyStart = idStart + 4 + 3;
const close = `</pasted_content id="${id}">`;
const closeAt = text.indexOf(`\n${close}`, bodyStart - 1) + 1;
if (closeAt === 0) break;
let start = open;
for (let i = 0; i < 2 && start > done && text[start - 1] === "\n"; i++) start--;
if (start > done) parts.push(text.slice(done, start));
done = closeAt + close.length;
for (let i = 0; i < 2 && text[done] === "\n"; i++) done++;
parts.push(text.slice(bodyStart, closeAt - 1));
expanded = true;
from = done;
}
if (!expanded) return text;
if (done < text.length) parts.push(text.slice(done));
return parts.join("");
}
/** At most `length` UTF-16 units, without a split surrogate pair. */
function cut(text: string, length: number): string {
if (text.length <= length) return text;
const head = text.slice(0, length);
const last = head.charCodeAt(length - 1);
return last >= 0xd800 && last <= 0xdbff ? head.slice(0, -1) : head;
}
/**
* The prompt of one user record, or undefined when it has none. A slash
* command is recorded in `state.commandFallback` instead.
*/
export function promptOf(entry: Entry, state: { commandFallback: string }): string | undefined {
if (entry.type !== "user") return undefined;
if (entry.isMeta === true || entry.isCompactSummary === true) return undefined;
const message = entry.message as { content?: unknown } | undefined;
if (!message) return undefined;
const content = message.content;
const texts: string[] = [];
if (typeof content === "string") {
texts.push(content);
} else if (Array.isArray(content)) {
for (const block of content) {
if (!block || typeof block !== "object") continue;
const { type, text } = block as { type?: unknown; text?: unknown };
if (type === "tool_result") return undefined;
if (type === "text" && typeof text === "string") texts.push(text);
}
}
for (const raw of texts) {
let text = expandPastedContent(raw).replaceAll("\n", " ").trim();
if (!text) continue;
const command = COMMAND_NAME.exec(text);
if (command) {
if (!state.commandFallback) state.commandFallback = command[1]!;
continue;
}
const bash = BASH_INPUT.exec(text);
if (bash) return `! ${bash[1]!.trim()}`;
if (SKIPPED_TEXT.test(text)) continue;
if (text.length > MAX_PROMPT_LENGTH) text = `${cut(text, MAX_PROMPT_LENGTH).trim()}…`;
return text;
}
return undefined;
}
/**
* Whether a user record is a prompt the user sent: one with prompt text
* ({@link promptOf}), or one with an image or a document, which the SDK
* titles `Image` or `Document` when it has no prompt text. A tool result, a meta or compact summary
* record, an interrupt and a slash command are none.
*/
export function isUserPrompt(entry: Entry): boolean {
if (promptOf(entry, { commandFallback: "" }) !== undefined) return true;
if (entry.type !== "user" || entry.isMeta === true || entry.isCompactSummary === true) {
return false;
}
const content = (entry.message as { content?: unknown } | undefined)?.content;
if (!Array.isArray(content)) return false;
let media = false;
for (const block of content) {
if (!block || typeof block !== "object") continue;
const { type } = block as { type?: unknown };
if (type === "tool_result") return false;
if (type === "image" || type === "document") media = true;
}
return media;
}
function isUserLine(line: string): boolean {
return line.includes('"type":"user"') || line.includes('"type": "user"');
}
/** The first prompt of the head, else the first slash command, else "". */
export function firstPrompt(head: string): string {
const state = { commandFallback: "" };
for (const line of head.split("\n")) {
if (!isUserLine(line)) continue;
if (line.includes('"tool_result"')) continue;
if (line.includes('"isMeta":true') || line.includes('"isMeta": true')) continue;
if (line.includes('"isCompactSummary":true') || line.includes('"isCompactSummary": true')) {
continue;
}
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!entry || typeof entry !== "object") continue;
const prompt = promptOf(entry as Entry, state);
if (prompt !== undefined) return prompt;
}
return state.commandFallback;
}
/** `Image` or `Document` for a first user record that holds one, else "". */
export function mediaPrompt(head: string): string {
for (const line of head.split("\n")) {
if (!isUserLine(line)) continue;
if (line.includes('"tool_result"')) continue;
if (line.includes('"isMeta":true') || line.includes('"isMeta": true')) continue;
if (line.includes('"type":"image"') || line.includes('"type": "image"')) return "Image";
if (line.includes('"type":"document"') || line.includes('"type": "document"')) {
return "Document";
}
}
return "";
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
/**
* The live Claude Code processes on this machine, from the CLI's registry.
*
* Each running CLI writes `<config>/sessions/<pid>.json` with its session id,
* kind, entrypoint and status. The format is not documented; the reader keeps
* to the fields it needs and applies the liveness rules of Claude Desktop:
*
* - only `<pid>.json` files are read; `.key` files and other names are never
* opened, and no file is ever written or deleted;
* - a record whose JSON does not parse is read once more, then skipped;
* - a record of another pid domain (another machine or pid namespace) is
* skipped;
* - the process must be alive: `kill(pid, 0)` succeeds or fails with `EPERM`;
* - a recorded `procStart` must match the start time of the live process,
* so a reused pid does not count;
* - a record older than 24 hours counts only when its `procStart` matches.
*
* A snapshot is cached for {@link SNAPSHOT_TTL_MS}. The records tell only the
* `state` of a session list row; no operation is refused for them.
*/
import { execFile } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { claudeConfigDir } from "../paths.js";
import { errorCode } from "./project-dirs.js";
const SNAPSHOT_TTL_MS = 5_000;
const STALE_RECORD_MS = 24 * 60 * 60 * 1000;
const PS_TIMEOUT_MS = 1_000;
const RECORD_FILE_PATTERN = /^(\d+)\.json$/;
/** How long a process start time read with `ps` serves a reader that asks
* for recent ones (see {@link LiveSessionRegistry.readFiles}). */
const RECENT_START_MS = 10_000;
export type LiveRecord = {
pid: number;
sessionId: string;
cwd?: string;
kind?: string;
entrypoint?: string;
status?: string;
statusUpdatedAt?: number;
};
export type LiveSnapshot = ReadonlyMap<string, LiveRecord>;
export type LiveRegistryDeps = {
/** The registry directory. Defaults to `<config>/sessions`. */
dir?: () => string;
now?: () => number;
/** Whether the process is alive. Defaults to `kill(pid, 0)`. */
isAlive?: (pid: number) => boolean;
/** The `ps -o lstart=` start time of each live pid. */
processStarts?: (pids: number[]) => Promise<Map<number, string>>;
/** This machine's pid domain, as the CLI computes it. */
pidDomain?: () => Promise<string>;
};
/** `<config>/sessions`, where each CLI process registers itself. */
export function liveRegistryDir(): string {
return path.join(claudeConfigDir(), "sessions");
}
function defaultIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return errorCode(error) === "EPERM";
}
}
/** The start times of `pids`, from one `ps` call, in the format the CLI
* records (`LC_ALL=C TZ=UTC ps -o lstart=`). */
function defaultProcessStarts(pids: number[]): Promise<Map<number, string>> {
const result = new Map<number, string>();
if (pids.length === 0 || process.platform === "win32") return Promise.resolve(result);
return new Promise((resolve) => {
execFile(
"ps",
["-o", "pid=,lstart=", "-p", pids.join(",")],
{ timeout: PS_TIMEOUT_MS, env: { ...process.env, LC_ALL: "C", TZ: "UTC" } },
(_error, stdout) => {
// `ps` exits 1 when one of the pids is gone; its output still holds
// the live ones.
for (const line of String(stdout ?? "").split("\n")) {
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
if (match) result.set(Number(match[1]), normalizeStart(match[2]!));
}
resolve(result);
},
);
});
}
function normalizeStart(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
let cachedPidDomain: Promise<string> | undefined;
/** The pid domain the CLI records: the platform on macOS, the machine id and
* pid namespace on Linux, the host name on Windows. */
function defaultPidDomain(): Promise<string> {
cachedPidDomain ??= (async () => {
if (process.platform === "win32") return `win32:${os.hostname().toLowerCase()}`;
if (process.platform !== "linux") return process.platform;
const [machineId, pidNamespace] = await Promise.all([
fs.readFile("/etc/machine-id", "utf8").then(
(value) => value.trim(),
() => "",
),
fs.readlink("/proc/self/ns/pid").catch(() => ""),
]);
return `linux:${machineId}:${pidNamespace}`;
})();
return cachedPidDomain;
}
type RawRecord = Record<string, unknown>;
async function readRecord(file: string): Promise<RawRecord | undefined> {
for (let attempt = 0; attempt < 2; attempt++) {
let text: string;
try {
text = await fs.readFile(file, "utf8");
} catch {
return undefined;
}
try {
const value: unknown = JSON.parse(text);
return value && typeof value === "object" && !Array.isArray(value)
? (value as RawRecord)
: undefined;
} catch {
// The CLI may be rewriting the file; read it once more.
}
}
return undefined;
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export class LiveSessionRegistry {
private cached?: { at: number; snapshot: Promise<LiveSnapshot> };
private readonly dir: () => string;
private readonly now: () => number;
private readonly isAlive: (pid: number) => boolean;
private readonly processStarts: (pids: number[]) => Promise<Map<number, string>>;
private readonly pidDomain: () => Promise<string>;
/** The start times `ps` gave, by pid, and when. */
private readonly starts = new Map<number, { start: string; at: number }>();
constructor(deps: LiveRegistryDeps = {}) {
this.dir = deps.dir ?? liveRegistryDir;
this.now = deps.now ?? Date.now;
this.isAlive = deps.isAlive ?? defaultIsAlive;
this.processStarts = deps.processStarts ?? defaultProcessStarts;
this.pidDomain = deps.pidDomain ?? defaultPidDomain;
}
/** The live records by session id. Cached for 5 s. */
snapshot(): Promise<LiveSnapshot> {
const now = this.now();
if (this.cached && now - this.cached.at < SNAPSHOT_TTL_MS) {
return this.cached.snapshot;
}
const snapshot = this.read()
.then(bySession)
.catch(() => new Map<string, LiveRecord>());
this.cached = { at: now, snapshot };
return snapshot;
}
/** Every live record, several per session when several processes hold it. */
private async read(): Promise<LiveRecord[]> {
let names: string[];
try {
names = await fs.readdir(this.dir());
} catch {
return [];
}
return [...(await this.readFiles(names)).values()].filter(
(record): record is LiveRecord => record !== undefined,
);
}
/**
* The live record of each of the registry files `names`, by name:
* undefined for a file that is gone or holds no live record. A name that
* is not `<pid>.json` (a `.key` file, a temporary file) is never opened and
* not in the result. The rules of {@link read} apply. `recentStarts` takes
* a start time that `ps` gave in the last 10 s when it matches the record,
* so a record that changes often does not run `ps` each time.
*/
async readFiles(
names: readonly string[],
options: { recentStarts?: boolean } = {},
): Promise<Map<string, LiveRecord | undefined>> {
const dir = this.dir();
const result = new Map<string, LiveRecord | undefined>();
const domain = await this.pidDomain();
const candidates: { name: string; pid: number; raw: RawRecord }[] = [];
await Promise.all(
names.map(async (name) => {
const match = RECORD_FILE_PATTERN.exec(name);
if (!match) return;
result.set(name, undefined);
const pid = Number(match[1]);
const raw = await readRecord(path.join(dir, name));
if (!raw || raw.pid !== pid || typeof raw.sessionId !== "string") return;
if (typeof raw.pidDomain === "string" && raw.pidDomain !== domain) return;
if (!this.isAlive(pid)) return;
candidates.push({ name, pid, raw });
}),
);
const now = this.now();
const starts = new Map<number, string>();
const needStarts: number[] = [];
for (const { pid, raw } of candidates) {
if (typeof raw.procStart !== "string") continue;
const recent = this.starts.get(pid);
// A start that differs may be a new process under the same pid.
if (
options.recentStarts &&
recent &&
now - recent.at < RECENT_START_MS &&
recent.start === normalizeStart(raw.procStart)
) {
starts.set(pid, recent.start);
} else {
needStarts.push(pid);
}
}
if (needStarts.length > 0) {
for (const [pid, start] of await this.processStarts(needStarts)) {
starts.set(pid, start);
this.starts.set(pid, { start, at: now });
}
}
for (const [pid, { at }] of this.starts) {
if (now - at >= RECENT_START_MS) this.starts.delete(pid);
}
for (const { name, pid, raw } of candidates) {
const recorded =
typeof raw.procStart === "string" ? normalizeStart(raw.procStart) : undefined;
const actual = starts.get(pid);
if (recorded !== undefined && actual !== undefined && recorded !== actual) continue;
const procStartMatches = recorded !== undefined && actual === recorded;
const lastSeen = Math.max(
finiteNumber(raw.updatedAt) ?? 0,
finiteNumber(raw.statusUpdatedAt) ?? 0,
finiteNumber(raw.startedAt) ?? 0,
);
if (now - lastSeen > STALE_RECORD_MS && !procStartMatches) continue;
result.set(name, {
pid,
sessionId: raw.sessionId as string,
cwd: optionalString(raw.cwd),
kind: optionalString(raw.kind),
entrypoint: optionalString(raw.entrypoint),
status: optionalString(raw.status),
statusUpdatedAt: finiteNumber(raw.statusUpdatedAt),
});
}
return result;
}
}
/** One record per session: the one with the newest status. */
export function bySession(records: Iterable<LiveRecord>): LiveSnapshot {
const result = new Map<string, LiveRecord>();
for (const record of records) {
const previous = result.get(record.sessionId);
if (!previous || (record.statusUpdatedAt ?? 0) > (previous.statusUpdatedAt ?? 0)) {
result.set(record.sessionId, record);
}
}
return result;
}

View File

@@ -0,0 +1,133 @@
/**
* Where Claude Code keeps the transcripts of a working directory.
*
* The CLI stores the transcripts of `cwd` in `<config>/projects/<encoded cwd>/`.
* The encoding replaces every character that is not ASCII alphanumeric with `-`.
* A name longer than {@link MAX_SANITIZED_LENGTH} is cut and gets a hash
* suffix; the CLI (Bun) and the SDK (Node) compute different hashes, so a long
* name is matched by its prefix, as the SDK does.
*
* The encoding is lossy (`/a/b-c` and `/a/b/c` share a directory), so a
* directory name is never decoded: a path belongs to a directory only when
* the path encodes to the name.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { claudeConfigDir } from "../paths.js";
/** The SDK cuts an encoded path at this length and appends a hash. */
const MAX_SANITIZED_LENGTH = 200;
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Whether `value` is a Claude session id (a UUID). Only such ids name a
* transcript, and only such ids are safe to use as a file name. */
export function isSessionId(value: unknown): value is string {
return typeof value === "string" && SESSION_ID_PATTERN.test(value);
}
/** `<config>/projects`, normalized like the SDK does. */
export function projectsRoot(): string {
return path.join(claudeConfigDir(), "projects").normalize("NFC");
}
function sanitize(value: string): string {
return value.replace(/[^a-zA-Z0-9]/g, "-");
}
/** The SDK's 32-bit string hash for long project names. */
function hashCode(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
}
return hash;
}
/** The project directory name of `cwd`, exactly as the SDK computes it. */
export function encodeProjectPath(cwd: string): string {
const sanitized = sanitize(normalizePath(cwd));
if (sanitized.length <= MAX_SANITIZED_LENGTH) return sanitized;
return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${Math.abs(hashCode(normalizePath(cwd))).toString(36)}`;
}
/** NFC on darwin, as the SDK normalizes paths there. */
export function normalizePath(value: string): string {
return process.platform === "darwin" ? value.normalize("NFC") : value;
}
function sameName(a: string, b: string): boolean {
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
}
/**
* Whether `dirName` is the project directory of `cwd`: the exact encoding, or,
* for a long path, a directory with the same cut prefix and any hash suffix.
*/
export function projectDirMatches(dirName: string, cwd: string): boolean {
if (sameName(dirName, encodeProjectPath(cwd))) return true;
const sanitized = sanitize(normalizePath(cwd));
if (sanitized.length <= MAX_SANITIZED_LENGTH) return false;
const prefix = `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-`;
const name = process.platform === "win32" ? dirName.toLowerCase() : dirName;
return name.startsWith(process.platform === "win32" ? prefix.toLowerCase() : prefix);
}
/** Whether a transcript's `cwd` belongs to `projectPath`, compared the way
* the SDK does: the full sanitized forms, ignoring case where the file
* system does. */
export function sameProjectPath(cwd: string, projectPath: string): boolean {
const a = sanitize(normalizePath(cwd));
const b = sanitize(normalizePath(projectPath));
return process.platform === "win32" || process.platform === "darwin"
? a.toLowerCase() === b.toLowerCase()
: a === b;
}
/** Whether `dirName` is the exact project directory of `cwd`, as the SDK
* finds it by `dir`: the exact encoding, on macOS in any case (the file
* system ignores it). */
export function isExactProjectDir(dirName: string, cwd: string): boolean {
const exact = encodeProjectPath(cwd);
if (sameName(dirName, exact)) return true;
return process.platform === "darwin" && dirName.toLowerCase() === exact.toLowerCase();
}
/** The real path of `cwd`, like the SDK resolves it before encoding. Falls
* back to `cwd` when it does not exist. */
export async function canonicalPath(cwd: string): Promise<string> {
try {
return normalizePath(await fs.realpath(cwd));
} catch {
return normalizePath(cwd);
}
}
/** The names of the project directories of `cwd` among `rootEntries`. */
export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] {
const exact = encodeProjectPath(cwd);
const sanitizedLength = sanitize(normalizePath(cwd)).length;
if (sanitizedLength <= MAX_SANITIZED_LENGTH) {
return rootEntries.filter((name) => sameName(name, exact));
}
return rootEntries.filter((name) => projectDirMatches(name, cwd));
}
/** `value` and each of its ancestors, nearest first. */
export function pathAndAncestors(value: string): string[] {
const result: string[] = [];
let current = value;
for (;;) {
result.push(current);
const parent = path.dirname(current);
if (parent === current) return result;
current = parent;
}
}
/** The `code` of a Node.js system error, if it has one. */
export function errorCode(error: unknown): string | undefined {
const code = (error as { code?: unknown } | null)?.code;
return typeof code === "string" ? code : undefined;
}

View File

@@ -0,0 +1,710 @@
/**
* The `sessionIndex` AIR extension: the session list, rename, archive and
* delete of a client that declared the capability.
*
* Wire contract: docs/air-extensions.md, "Session index". Everything here is
* reached only for a `sessionIndex` client, except the archive that an AIR
* client without the capability gets in place of a delete (see
* `ClaudeAcpAgent.deleteSession`).
*/
import { randomBytes } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
RequestError,
type ListSessionsRequest,
type ListSessionsResponse,
} from "@agentclientprotocol/sdk";
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
import { airExtensionMeta } from "../air-extension.js";
import { sanitizeTitle } from "../session-titles.js";
import type { OwnSessionState } from "./activity.js";
import {
effectiveTitle,
isArchivedTitle,
storedTitle,
titleRecords,
visibleTitle,
} from "./archive-title.js";
import {
ListSubscriptions,
type ListChanges,
type ListSubscribeResponse,
type ListSubscriptionDeps,
} from "./list-subscriptions.js";
import { LiveSessionRegistry } from "./live-registry.js";
import {
canonicalPath,
errorCode,
isExactProjectDir,
isSessionId,
projectDirMatches,
sameProjectPath,
} from "./project-dirs.js";
import {
readHeadTail,
readHeadTailOf,
sdkTitles,
tailCustomTitle,
transcriptAgentName,
transcriptProjectCwd,
} from "./transcript-scan.js";
import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT,
ARCHIVED_FILTERS,
readSidecarTitle,
SessionIndex,
type ArchivedFilter,
type ListCursor,
} from "./session-index.js";
import { sessionInfoOf } from "./session-info.js";
export const SESSION_RENAME_METHOD = "_session/rename";
export const SESSION_ARCHIVE_METHOD = "_session/archive";
export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
/** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
const RESOURCE_NOT_FOUND = -32002;
/** The cursor format. A cursor of another version is rejected. */
const CURSOR_VERSION = 5;
export type SessionIdRequest = { sessionId: string };
export type RenameSessionRequest = { sessionId: string; title: string };
export function sessionNotFound(sessionId: string): RequestError {
return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function parseSessionIdRequest(value: unknown): SessionIdRequest {
const sessionId = asRecord(value).sessionId;
if (typeof sessionId !== "string" || !sessionId.trim()) {
throw RequestError.invalidParams(undefined, "params require a non-empty sessionId");
}
return { sessionId: sessionId.trim() };
}
export function parseRenameSessionRequest(value: unknown): RenameSessionRequest {
const { sessionId } = parseSessionIdRequest(value);
const raw = asRecord(value).title;
const title = typeof raw === "string" ? sanitizeTitle(raw) : "";
if (!title) throw RequestError.invalidParams(undefined, "title must be a non-empty string");
return { sessionId, title };
}
type ListOptions = { limit: number; archived: ArchivedFilter; includeWorktrees: boolean };
/** What a cursor is bound to: the request values that select the rows. */
export type ListScope = {
cwd: string | null;
archived: ArchivedFilter;
includeWorktrees: boolean;
};
function archivedFilter(list: Record<string, unknown>): ArchivedFilter {
// Omitted or null is `unarchived`.
const value = list.archived ?? "unarchived";
if (!ARCHIVED_FILTERS.includes(value as ArchivedFilter)) {
throw RequestError.invalidParams(
{ archived: value },
'`_meta.jetbrains.air.list.archived` must be "unarchived", "archived" or "all"',
);
}
return value as ArchivedFilter;
}
function optionalBoolean(list: Record<string, unknown>, key: string): boolean {
// Omitted or null is false.
const value = list[key] ?? false;
if (typeof value !== "boolean") {
throw RequestError.invalidParams(
{ [key]: value },
`\`_meta.jetbrains.air.list.${key}\` must be a boolean`,
);
}
return value;
}
/** `_meta.jetbrains.air.list` of a list request. */
export function parseListOptions(meta: unknown): ListOptions {
const list = asRecord(airExtensionMeta(meta)?.list);
// Omitted or null is the default; an integer of at least 1 is clamped;
// anything else is invalid.
const raw = list.limit ?? DEFAULT_LIST_LIMIT;
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
throw RequestError.invalidParams(
{ limit: raw },
"`_meta.jetbrains.air.list.limit` must be an integer of at least 1",
);
}
return {
limit: Math.min(MAX_LIST_LIMIT, raw),
archived: archivedFilter(list),
includeWorktrees: optionalBoolean(list, "includeWorktrees"),
};
}
type CursorPayload = {
v: number;
u: number;
id: string;
cwd: string | null;
archived: ArchivedFilter;
worktrees: boolean;
};
export function encodeListCursor(cursor: ListCursor, scope: ListScope): string {
const payload: CursorPayload = {
v: CURSOR_VERSION,
u: cursor.orderAtMs,
id: cursor.sessionId,
cwd: scope.cwd,
archived: scope.archived,
worktrees: scope.includeWorktrees,
};
return Buffer.from(JSON.stringify(payload)).toString("base64url");
}
/** The position a cursor names. A cursor of another cwd or filter, or one
* this adapter did not issue, is rejected. */
export function decodeListCursor(cursor: string, scope: ListScope): ListCursor {
let payload: Partial<CursorPayload> | undefined;
try {
payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as CursorPayload;
} catch {
payload = undefined;
}
if (
!payload ||
payload.v !== CURSOR_VERSION ||
typeof payload.u !== "number" ||
typeof payload.id !== "string"
) {
throw RequestError.invalidParams(undefined, `Unknown session/list cursor: ${cursor}`);
}
if (
payload.cwd !== scope.cwd ||
payload.archived !== scope.archived ||
payload.worktrees !== scope.includeWorktrees
) {
throw RequestError.invalidParams(
undefined,
"The session/list cursor belongs to another cwd or filter",
);
}
return { orderAtMs: payload.u, sessionId: payload.id };
}
/** `<projectDir>/<sessionId>/custom-title.json` of a transcript. */
function sidecarPath(transcriptPath: string): string {
const sessionId = path.basename(transcriptPath, ".jsonl");
return path.join(path.dirname(transcriptPath), sessionId, "custom-title.json");
}
/** Writes `<projectDir>/<sessionId>/custom-title.json` the way the CLI's
* `/rename` does: file 0600 in a 0700 directory, replaced atomically. The
* temporary file has a random name of the CLI's `custom-title.json.tmp.*`
* pattern, and only a temporary file this call created is removed. */
export async function writeCustomTitleSidecar(transcriptPath: string, title: string) {
const target = sidecarPath(transcriptPath);
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
const temporary = `${target}.tmp.${randomBytes(8).toString("hex")}`;
const handle = await fs.open(temporary, "wx", 0o600);
try {
try {
await handle.writeFile(JSON.stringify({ customTitle: title }));
} finally {
await handle.close();
}
await fs.rename(temporary, target);
} catch (error) {
await fs.rm(temporary, { force: true });
throw error;
}
}
/** The last two non-empty lines of a file, from its last 64 KB, and
* whether the file ends with a newline. */
async function lastLines(
filePath: string,
): Promise<{ lines: string[]; last: string; endsWithNewline: boolean }> {
const handle = await fs.open(filePath, "r");
try {
const { size } = await handle.stat();
const length = Math.min(size, 64 * 1024);
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, size - length);
const text = buffer.toString("utf8");
const lines = text.split("\n").filter((line) => line.trim());
return {
lines: lines.slice(-2),
last: lines.at(-1) ?? "",
endsWithNewline: text.endsWith("\n"),
};
} finally {
await handle.close();
}
}
/** Appends the title records of `title` (see archive-title.ts) to a
* transcript whose last two records are not those already, whoever else
* has the session open, as the CLI's `/rename` does. A torn last line is
* closed first. */
async function ensureTitleRecords(filePath: string, sessionId: string, title: string) {
const records = titleRecords(sessionId, title);
const { lines, last, endsWithNewline } = await lastLines(filePath);
if (lines.join("\n") + "\n" === records) return;
const complete = endsWithNewline || last === "";
await fs.appendFile(filePath, `${complete ? "" : "\n"}${records}`);
}
/** Appends the `agent-name` record of `title` when the copy's agent name
* is another one, so its archive state follows `title`. Reads and appends
* through one handle. */
async function ensureAgentName(filePath: string, sessionId: string, title: string) {
const handle = await fs.open(filePath, "a+");
try {
const { size } = await handle.stat();
const headTail = await readHeadTailOf(handle, size);
const agentName = transcriptAgentName(headTail);
if (agentName === undefined || agentName === title) return;
const complete = size === 0 || headTail.tail.endsWith("\n");
const record = JSON.stringify({ type: "agent-name", agentName: title, sessionId });
await handle.appendFile(`${complete ? "" : "\n"}${record}\n`);
} finally {
await handle.close();
}
}
/** The title of one transcript copy as the list reads it (see
* {@link effectiveTitle}): its agent name, else the title the SDK reports.
* It carries the archive prefix only when the copy is archived: a generated
* title never archives a session. */
async function copyTitle(filePath: string, sessionId: string): Promise<string | undefined> {
const { size } = await fs.stat(filePath);
const headTail = await readHeadTail(filePath, size);
const sidecar =
tailCustomTitle(headTail) === undefined
? await readSidecarTitle(filePath, sessionId)
: undefined;
const { title, archived } = effectiveTitle(
transcriptAgentName(headTail),
sdkTitles(headTail, sidecar),
);
return title !== undefined && !archived && isArchivedTitle(title) ? visibleTitle(title) : title;
}
/** Rewrites the sidecar of a copy whose title is `current` when the
* sidecar says the other archive state (a sidecar write that failed after
* the records were appended). */
async function alignSidecar(
transcript: string,
current: string | undefined,
sessionId: string,
): Promise<void> {
if (current === undefined) return;
const sidecar = await readSidecarTitle(transcript, sessionId);
if (sidecar === undefined || isArchivedTitle(sidecar) === isArchivedTitle(current)) return;
await writeCustomTitleSidecar(
transcript,
storedTitle(current, isArchivedTitle(current), sessionId),
);
}
/** The title a change stores for a copy whose title is `current`
* (undefined: none yet), or undefined to leave the copy as it is. */
export type TitleChange = (current: string | undefined, sessionId: string) => string | undefined;
/** `_session/rename`: the new title, with the archive prefix on a copy
* that is archived, so a rename keeps the archive state. */
export function renameTo(title: string): TitleChange {
return (current, sessionId) =>
isArchivedTitle(current) ? storedTitle(title, true, sessionId) : title;
}
/** `_session/archive` and `_session/unarchive`: the current title with or
* without the archive prefix; a copy already in that state is left alone. */
export function archiveTo(archived: boolean): TitleChange {
return (current, sessionId) =>
isArchivedTitle(current) === archived
? undefined
: storedTitle(current ?? "", archived, sessionId);
}
/**
* Whether `transcript` lies in the project directory of one of `paths`, the
* one the CLI of that cwd writes: the exact encoding, or, for a long path
* whose name the CLI hashes differently, a directory with the cut prefix
* whose transcript belongs to the path. Another long path that shares the
* prefix does not count.
*/
async function isTranscriptOf(transcript: string, paths: readonly string[]): Promise<boolean> {
const dirName = path.basename(path.dirname(transcript));
if (paths.some((cwd) => isExactProjectDir(dirName, cwd))) return true;
if (!paths.some((cwd) => projectDirMatches(dirName, cwd))) return false;
try {
const { size } = await fs.stat(transcript);
const cwd = transcriptProjectCwd(await readHeadTail(transcript, size));
return cwd !== undefined && paths.some((projectPath) => sameProjectPath(cwd, projectPath));
} catch {
// Unreadable: not known to be the CLI's own copy.
return false;
}
}
function isSdkNotFound(error: unknown): boolean {
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
}
async function exists(target: string): Promise<boolean> {
try {
await fs.lstat(target);
return true;
} catch (error) {
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
throw error;
}
}
/** The error the SDK `deleteSession` gives for a session id it rejects or
* does not find, which a client without `sessionIndex` got before. */
function sdkDeleteError(sessionId: string): Error {
return new Error(
isSessionId(sessionId)
? `Session ${sessionId} not found in any project directory`
: `Invalid sessionId: ${sessionId}`,
);
}
/**
* `session/delete` of an AIR client without `sessionIndex`, which uses delete
* to mark a session done: archives it instead, so the transcript survives. A
* session without a transcript fails as the SDK delete did.
*/
export async function archiveInsteadOfDelete(
sessionId: string,
service: SessionIndexService,
): Promise<void> {
if (!isSessionId(sessionId)) throw sdkDeleteError(sessionId);
// The SDK delete skips empty transcripts, so they do not count.
const found = await service.index.findTranscripts(sessionId);
if (found.length === 0) throw sdkDeleteError(sessionId);
await service.retitle(sessionId, archiveTo(true), { sidecar: "existing" });
}
/** How {@link SessionIndexService.retitle} reaches the session. */
export type RetitleOptions = {
/** The CLI of the session runs here: it stores the title of its own
* transcript (`rename_session`). `stored`: the title last stored through
* it, which it holds; `shown`: the title last shown for a session that has
* neither that nor a transcript. */
live?: {
cwd: string;
rename: (title: string) => Promise<void>;
/** Read once the session's earlier title changes are done. */
stored: () => string | undefined;
/** Records a title the CLI took, before the next change starts. */
remember: (title: string) => void;
shown?: string;
};
/** A session without a transcript is not unknown: a new session this
* connection runs, or a live one being renamed. */
mayBeUnwritten?: boolean;
/** `always` writes the CLI title sidecar next to every titled copy;
* `existing` rewrites only one that is there already. */
sidecar: "always" | "existing";
};
export type SessionIndexDeps = {
deleteSession?: (sessionId: string) => Promise<void>;
registry?: LiveSessionRegistry;
now?: () => number;
/** What this connection knows of a session it runs. */
ownSessionState?: (sessionId: string) => OwnSessionState | undefined;
/** Sends `_session/list/changes`. */
notifyListChanges?: (changes: ListChanges) => Promise<void>;
/** The timing of the list subscriptions, for tests. */
listSubscriptionTiming?: Pick<
ListSubscriptionDeps,
"debounceMs" | "maxWaitMs" | "rescanMs" | "minSessionIntervalMs"
>;
logError: (message: string, error: unknown) => void;
};
export class SessionIndexService {
readonly index: SessionIndex;
readonly registry: LiveSessionRegistry;
private subscriptions?: ListSubscriptions;
/** Set for good by {@link dispose}: no subscription is made after it. */
private disposed = false;
/** The mutation in flight per session, which the next one waits for. */
private readonly mutations = new Map<string, Promise<unknown>>();
private readonly now: () => number;
private readonly deleteSession: (sessionId: string) => Promise<void>;
constructor(private readonly deps: SessionIndexDeps) {
this.index = new SessionIndex();
this.registry = deps.registry ?? new LiveSessionRegistry();
this.now = deps.now ?? Date.now;
this.deleteSession = deps.deleteSession ?? ((id) => sdkDeleteSession(id));
}
/** `session/list` of a `sessionIndex` client. `own` reports the sessions
* that this connection runs. */
async list(
params: ListSessionsRequest,
own: (sessionId: string) => OwnSessionState | undefined,
): Promise<ListSessionsResponse> {
const { limit, archived, includeWorktrees } = parseListOptions(params._meta);
const cwd = params.cwd ?? null;
const scope: ListScope = { cwd, archived, includeWorktrees };
const after =
params.cursor === null || params.cursor === undefined
? undefined
: decodeListCursor(params.cursor, scope);
// The live registry is read while the transcripts are.
const livePromise = this.registry.snapshot();
const { rows, hasMore } = await this.index.list({
cwd,
includeWorktrees,
limit,
archived,
after,
});
const live = await livePromise;
const now = this.now();
const sessions = rows.map((row) =>
sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now),
);
const last = rows[rows.length - 1];
return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
}
/** Runs `mutation` after the previous mutation of the session ended. */
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(mutation);
const settled = next.catch(() => undefined);
this.mutations.set(sessionId, settled);
void settled.then(() => {
if (this.mutations.get(sessionId) === settled) this.mutations.delete(sessionId);
});
return next;
}
/**
* Retitles every transcript of a session with `change`: rename, archive
* and unarchive. Returns the title stored through the CLI that runs here,
* else the title of the first copy that got one; undefined when nothing
* was stored.
*
* - The CLI runs here (`live`): it titles its own transcript and sidecar
* (`rename_session`), the only writer that cannot be overtaken by the
* title the CLI holds. The other copies get the records and the sidecar
* here, as a best effort once the CLI has the title: a failure is
* logged, not returned.
* - Otherwise every transcript gets the records (and the sidecar, see
* {@link RetitleOptions.sidecar}).
*
* As the CLI's `/rename`, the records are written whether or not another
* process has the session open.
*
* A session without a transcript is unknown (`-32002`), unless
* {@link RetitleOptions.mayBeUnwritten}.
*/
async retitle(
sessionId: string,
change: TitleChange,
options: RetitleOptions,
): Promise<string | undefined> {
const { live } = options;
if (!isSessionId(sessionId)) {
if (!live || !options.mayBeUnwritten) throw sessionNotFound(sessionId);
return this.exclusive(sessionId, async () => {
const title = change(live.stored() ?? live.shown, sessionId);
if (title !== undefined) {
await live.rename(title);
live.remember(title);
}
return title;
});
}
return this.exclusive(sessionId, async () => {
const transcripts = await this.index.findTranscripts(sessionId);
if (transcripts.length === 0 && !options.mayBeUnwritten) throw sessionNotFound(sessionId);
let stored: string | undefined;
// The cache is left as it is: the appended records change the size,
// and the next read keeps what the earlier scans found (a last prompt
// beyond the tail search).
if (live) {
const canonical = await canonicalPath(live.cwd);
const paths = [...new Set([live.cwd, canonical])];
const own: string[] = [];
for (const transcript of transcripts) {
if (await isTranscriptOf(transcript, paths)) own.push(transcript);
}
// The CLI writes the transcript of the resolved cwd.
const cli =
own.find((transcript) =>
isExactProjectDir(path.basename(path.dirname(transcript)), canonical),
) ?? own[0];
const others = transcripts.filter((transcript) => transcript !== cli);
// The transcript holds the current title, also one changed otherwise
// (a `/rename`); without one, the title last given to the CLI.
const primary = cli ?? others[0];
const read = primary ? await copyTitle(primary, sessionId) : undefined;
const current = read ?? live.stored() ?? live.shown;
stored = change(current, sessionId);
if (stored !== undefined) {
await live.rename(stored);
live.remember(stored);
// The CLI writes the custom title before it answers, the agent name
// only later. An older agent name decides the archive state first,
// so the copy gets the new one now.
// A best effort: the CLI took the title, and writes the agent name
// itself too.
if (cli) {
try {
await ensureAgentName(cli, sessionId, stored);
} catch (error) {
this.deps.logError(`writing the agent name of ${sessionId} failed`, error);
}
}
} else if (cli) {
await alignSidecar(cli, current, sessionId);
}
try {
await this.titleCopies(sessionId, others, change, options.sidecar);
} catch (error) {
this.deps.logError(`titling the other transcripts of ${sessionId} failed`, error);
}
} else {
stored = await this.titleCopies(sessionId, transcripts, change, options.sidecar);
}
return stored;
}).finally(() => {
// Whatever was written, also by a change that failed part way.
this.subscriptions?.sessionWritten(sessionId);
});
}
/** Appends the title records `change` gives each copy, then its sidecar.
* A copy already in the requested state gets nothing, but its sidecar is
* brought in line with it. Returns the title the first titled copy got. */
private async titleCopies(
sessionId: string,
transcripts: readonly string[],
change: TitleChange,
sidecar: RetitleOptions["sidecar"],
): Promise<string | undefined> {
let first: string | undefined;
for (const transcript of transcripts) {
const current = await copyTitle(transcript, sessionId);
const title = change(current, sessionId);
if (title === undefined) {
await alignSidecar(transcript, current, sessionId);
continue;
}
await ensureTitleRecords(transcript, sessionId, title);
first ??= title;
if (sidecar === "always" || (await exists(sidecarPath(transcript)))) {
await writeCustomTitleSidecar(transcript, title);
}
}
return first;
}
/** Deletes every transcript of the session, whoever else has it open, as
* the CLI does. `known`: the session was loaded here (and is torn down
* already), so a missing transcript is no error.
*
* The SDK deletes the first non-empty transcript it finds and its
* `<sessionId>/` directory, one copy per call; empty transcripts, which it
* skips, are removed here. */
async delete(sessionId: string, known: boolean): Promise<void> {
if (!isSessionId(sessionId)) throw sessionNotFound(sessionId);
await this.exclusive(sessionId, async () => {
// A session directory can outlive its transcript: a delete that removed
// the transcript and then failed on the directory.
const found = await this.index.scanSession(sessionId);
const all = found.transcripts.map(({ filePath }) => filePath);
if (all.length === 0 && found.sessionDirs.length === 0) {
// Unknown, unless it was loaded here.
if (known) return;
throw sessionNotFound(sessionId);
}
try {
// The SDK deletes one non-empty copy per call.
for (const { filePath } of found.transcripts.filter(({ size }) => size > 0)) {
try {
await this.deleteSession(sessionId);
} catch (error) {
// Only "not found" with that copy gone is a copy removed meanwhile.
if (isSdkNotFound(error) && !(await exists(filePath))) continue;
throw error;
}
}
// Empty transcripts, which the SDK skips, and session directories
// without a transcript.
for (const { filePath, size } of found.transcripts) {
if (size === 0) await fs.rm(filePath, { force: true });
}
for (const sessionDir of found.sessionDirs) {
if (await exists(`${sessionDir}.jsonl`)) continue;
await fs.rm(sessionDir, { recursive: true, force: true });
}
const left = await this.index.scanSession(sessionId);
const remaining = [
...left.transcripts.map(({ filePath }) => filePath),
...left.sessionDirs,
];
if (remaining.length > 0) {
throw new Error(`Session ${sessionId} was not deleted: ${remaining.join(", ")} remain`);
}
} finally {
this.index.invalidate(all);
this.subscriptions?.sessionWritten(sessionId);
}
});
}
/** `_session/list/subscribe`: pushes the changed rows of `cwd` (an
* absolute path) until {@link unsubscribeList} or {@link dispose}. */
subscribeList(cwd: string): Promise<ListSubscribeResponse> {
if (this.disposed) {
return Promise.reject(RequestError.internalError(undefined, "The connection is closed"));
}
this.subscriptions ??= new ListSubscriptions({
index: this.index,
registry: this.registry,
own: (sessionId) => this.deps.ownSessionState?.(sessionId),
notify: (changes) => this.deps.notifyListChanges?.(changes) ?? Promise.resolve(),
logError: this.deps.logError,
...this.deps.listSubscriptionTiming,
});
return this.subscriptions.subscribe(cwd);
}
/** `_session/list/unsubscribe`. Idempotent. */
unsubscribeList(subscriptionId: string): void {
this.subscriptions?.unsubscribe(subscriptionId);
}
/** A session this connection runs changed what its row shows. */
ownSessionChanged(sessionId: string): void {
this.subscriptions?.ownSessionChanged(sessionId);
}
dispose(): void {
this.disposed = true;
this.subscriptions?.dispose();
this.subscriptions = undefined;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
/**
* A session index row as the client sees it: the `SessionInfo` of
* `session/list` and of `_session/list/changes`, with the flat row fields of
* the RFDs in `_meta.jetbrains.air`.
*/
import type { SessionInfo } from "@agentclientprotocol/sdk";
import { withAirMeta } from "../air-extension.js";
import { deriveActivity, selectCost, type OwnSessionState } from "./activity.js";
import type { LiveRecord } from "./live-registry.js";
import type { IndexRow } from "./session-index.js";
function iso(ms: number | undefined): string | undefined {
return ms === undefined || !Number.isFinite(ms) ? undefined : new Date(ms).toISOString();
}
/** The `SessionInfo` of `row`. `own` is what this connection knows of a
* session it runs, `live` the registry record of the session. */
export function sessionInfoOf(
row: IndexRow,
own: OwnSessionState | undefined,
live: LiveRecord | undefined,
now: number,
): SessionInfo {
const activity = deriveActivity({
own,
live,
facts: row.facts,
transcriptMtimeMs: row.mtimeMs,
now,
});
const cost = selectCost(own);
const { facts } = row;
// The row fields of the session list extensions RFD and RFD #2161, flat;
// each is omitted when unknown, except `archived`.
const fields: Record<string, unknown> = {
archived: row.archived,
lastPromptAt: iso(facts.lastPromptAt),
model: facts.model,
forkedFrom: facts.forkedFrom,
state: activity?.state,
lastTurnEndedAt: activity?.lastTurnEndedAt,
cost: cost === undefined ? undefined : { amount: cost, currency: "USD" },
};
let meta: Record<string, unknown> | undefined;
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined) meta = withAirMeta(meta, key, value);
}
return {
sessionId: row.sessionId,
cwd: row.cwd,
title: row.title,
updatedAt: new Date(row.updatedAtMs).toISOString(),
_meta: meta,
};
}
/** What a change of the row is compared by: everything but `updatedAt`,
* which alone is no change. */
export function changeSignature(info: SessionInfo): string {
return JSON.stringify([info.sessionId, info.cwd, info.title, info._meta ?? null]);
}

View File

@@ -0,0 +1,631 @@
/**
* What the session list needs from a transcript beyond the SDK metadata.
*
* One open reads the first and the last {@link CHUNK_SIZE} bytes, the same
* window that the SDK reads. The tail gives the time of the last message, the
* end of the last turn. The head and the tail give the `cwd` candidates.
*
* A last message longer than the tail window leaves no message in it: then
* the tail window grows, up to {@link MAX_TAIL_SIZE}, until it holds one.
*/
import * as fs from "node:fs/promises";
import { firstPrompt, isUserPrompt, mediaPrompt, promptOf } from "./first-prompt.js";
const CHUNK_SIZE = 64 * 1024;
/** The largest tail window read to find the last message. */
const MAX_TAIL_SIZE = 4 * 1024 * 1024;
const TAIL_GROWTH = 4;
const FULL_SEARCH_CACHE_SIZE = 512;
/** By `path\0size`, the facts of the transcripts whose last
* {@link MAX_TAIL_SIZE} bytes lack the last message or prompt: a file of
* the same size is not searched again. */
const fullySearched = new Map<string, TranscriptFacts>();
export type TurnState = "finished" | "unfinished";
export type TranscriptFacts = {
/** Time of the last user or assistant message in the tail, epoch ms. */
lastMessageAt?: number;
/** Whether the last turn in the tail ended. Undefined when the tail shows
* no turn at all. */
turnState?: TurnState;
/** Time the last ended turn ended, epoch ms. */
lastTurnEndedAt?: number;
/** The last turn ended with an API error (an `isApiErrorMessage`
* assistant record), not a user interrupt, and nothing came after it. */
lastTurnError?: boolean;
/** The first `cwd` of the head. */
headCwd?: string;
/** The last `cwd` of the tail. */
tailCwd?: string;
/** Time of the last real user prompt in the tail (not a tool result, a
* meta record or a slash command), epoch ms. */
lastPromptAt?: number;
/** Whether `lastPromptAt` is final: the prompt was found, or the tail
* window searched up to {@link MAX_TAIL_SIZE} holds none. */
promptSearched?: boolean;
/** The model of the last assistant message in the tail. */
model?: string;
/** The session this one was forked from: the `forkedFrom.sessionId` that
* the SDK and CLI fork write on every copied record. */
forkedFrom?: string;
/** Whether the head or the tail holds a user or an assistant message. A
* transcript without one is a metadata-only stub. */
hasMessages: boolean;
};
export type HeadTail = {
head: string;
/** The last 64 KB from their first whole line on; the whole file when it
* fits in the head. */
tail: string;
/** The last 64 KB as the SDK searches them, with the cut line before
* `tail`; absent when that is `tail`. */
sdkTail?: string;
};
/** Facts that an earlier scan may have found before the tail window. */
const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt"] as const;
/** What an earlier scan of the same file found, at `size`. */
export type PreviousScan = { size: number } & Pick<
TranscriptFacts,
"lastPromptAt" | "promptSearched" | (typeof INHERITED_FACTS)[number]
>;
/** The last `window` bytes of a file of `size` bytes, without the first,
* cut line. The whole file when it fits. */
async function readTail(
handle: fs.FileHandle,
size: number,
window: number,
): Promise<{ raw: string; tail: string }> {
const length = Math.min(window, size);
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
const raw = buffer.toString("utf8", 0, bytesRead);
if (length === size) return { raw, tail: raw };
const newline = raw.indexOf("\n");
return { raw, tail: newline >= 0 ? raw.slice(newline + 1) : "" };
}
/** The first and the last 64 KB of the first `size` bytes of `filePath`:
* what was appended after the file was stat'ed is not read, so the result
* matches the size it is cached by. The tail is the head for a file that
* fits in one chunk. */
export async function readHeadTail(filePath: string, size: number): Promise<HeadTail> {
const handle = await fs.open(filePath, "r");
try {
return await readHeadTailOf(handle, size);
} finally {
await handle.close();
}
}
/** {@link readHeadTail} of a file that is open already. */
export async function readHeadTailOf(handle: fs.FileHandle, size: number): Promise<HeadTail> {
const buffer = Buffer.allocUnsafe(CHUNK_SIZE);
const first = await handle.read(buffer, 0, Math.min(CHUNK_SIZE, size), 0);
const head = buffer.toString("utf8", 0, first.bytesRead);
if (size <= CHUNK_SIZE) return { head, tail: head };
const { raw, tail } = await readTail(handle, size, CHUNK_SIZE);
return raw === tail ? { head, tail } : { head, tail, sdkTail: raw };
}
/**
* The facts of `filePath`, from its head and tail. When the transcript has
* messages but the tail window lacks the last message or the last user
* prompt (a long answer or tool output follows it), the window grows,
* reading only the new range each step, until it holds both or reaches
* {@link MAX_TAIL_SIZE}. The result of a full search is kept per
* `(path, size)`.
*
* `previous` is what an earlier scan of the same file found: when the file
* only grew by less than the tail window, and the tail holds no prompt, the
* appended bytes hold none either, so the earlier `lastPromptAt` stands.
*/
export async function scanTranscriptFile(
filePath: string,
size: number,
headTail?: HeadTail,
previous?: PreviousScan,
/** Tells a file replaced by another of the same size apart (its inode). */
identity?: number,
): Promise<TranscriptFacts> {
const read = headTail ?? (await readHeadTail(filePath, size));
const facts = scanTranscript(read);
// The file only grew by bytes that the tail covers, and the tail holds no
// prompt: the appended bytes hold none either, so the earlier search
// stands (its prompt, or that the last 4 MB held none).
const grownWithinTail =
previous !== undefined &&
size >= previous.size &&
size - Buffer.byteLength(read.tail) <= previous.size;
if (grownWithinTail) {
// What the earlier, possibly wider, scan found before the tail stands
// when the tail has nothing newer.
for (const key of INHERITED_FACTS) {
if (facts[key] === undefined && previous[key] !== undefined) {
(facts as Record<string, unknown>)[key] = previous[key];
}
}
}
if (facts.lastPromptAt !== undefined || size <= CHUNK_SIZE) {
facts.promptSearched = true;
} else if (grownWithinTail && (previous.lastPromptAt !== undefined || previous.promptSearched)) {
facts.lastPromptAt = previous.lastPromptAt;
facts.promptSearched = true;
}
const complete = (found: TranscriptFacts) =>
found.lastMessageAt !== undefined && found.promptSearched === true;
if (!facts.hasMessages || complete(facts) || size <= CHUNK_SIZE) return facts;
const key = `${filePath}\0${size}\0${identity ?? ""}`;
const searched = fullySearched.get(key);
if (searched) return searched;
let result = facts;
const handle = await fs.open(filePath, "r");
try {
// The bytes from `start` to the end, grown by reading the new range only.
let start = Math.max(0, size - CHUNK_SIZE);
const first = Buffer.allocUnsafe(size - start);
const { bytesRead: firstRead } = await handle.read(first, 0, first.length, start);
let bytes = first.subarray(0, firstRead);
for (let window = CHUNK_SIZE * TAIL_GROWTH; start > 0; window *= TAIL_GROWTH) {
const nextStart = Math.max(0, size - Math.min(window, MAX_TAIL_SIZE));
if (nextStart >= start) break;
const range = Buffer.allocUnsafe(start - nextStart);
const { bytesRead } = await handle.read(range, 0, range.length, nextStart);
bytes = Buffer.concat([range.subarray(0, bytesRead), bytes]);
start = nextStart;
let tail = bytes.toString("utf8");
if (start > 0) {
// The first line of the window is cut; drop it.
const newline = tail.indexOf("\n");
tail = newline >= 0 ? tail.slice(newline + 1) : "";
}
// A wider tail ends with the same records: what the narrow one found
// stays, and the wider one adds what lay before it.
const wider = scanTranscript(
{ head: read.head, tail },
{
wide: true,
known: { model: facts.model, tailCwd: facts.tailCwd },
},
);
const lastPromptAt = wider.lastPromptAt ?? facts.lastPromptAt;
result = {
...wider,
...(facts.model !== undefined && { model: facts.model }),
...(facts.tailCwd !== undefined && { tailCwd: facts.tailCwd }),
// A turn end the wider tail lacks may lie before it: the
// narrow scan inherited it from an earlier scan of this file.
lastTurnEndedAt: wider.lastTurnEndedAt ?? facts.lastTurnEndedAt,
hasMessages: facts.hasMessages,
lastPromptAt,
// Searched to the start of the file, or found.
promptSearched: facts.promptSearched || lastPromptAt !== undefined || start === 0,
};
if (complete(result)) return result;
}
} finally {
await handle.close();
}
// The whole window is searched: no prompt in it either.
result = { ...result, promptSearched: true };
fullySearched.set(key, result);
if (fullySearched.size > FULL_SEARCH_CACHE_SIZE) {
fullySearched.delete(fullySearched.keys().next().value as string);
}
return result;
}
const RELOCATED_MARKER = '"relocated"';
/** The cwd of the last `relocated` record of the tail: where the session
* was moved, whatever cwd its earlier messages carry. */
export function relocatedCwd(tail: string): string | undefined {
const lines = tail.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
if (!line.includes(RELOCATED_MARKER)) continue;
const entry = parseLine(line);
if (entry?.type === "relocated" && typeof entry.relocatedCwd === "string") {
return entry.relocatedCwd;
}
}
return undefined;
}
/** The cwd a transcript belongs to, as the SDK reads it: the last
* `relocated` record of the tail, else the first cwd of the head. */
export function transcriptProjectCwd({ head, tail }: HeadTail): string | undefined {
const relocated = relocatedCwd(tail);
if (relocated !== undefined) return relocated;
const headCwd = CWD_PATTERN.exec(head)?.[1];
return headCwd === undefined ? undefined : decodeJsonString(headCwd);
}
const MESSAGE_MARKERS = [
'"type":"user"',
'"type":"assistant"',
'"type": "user"',
'"type": "assistant"',
];
/** System records that the CLI writes when a turn ends. */
const TURN_END_SYSTEM_SUBTYPES = new Set(["stop_hook_summary", "turn_duration"]);
const INTERRUPT_PREFIX = "[Request interrupted by user";
type Entry = Record<string, unknown>;
function parseLine(line: string): Entry | undefined {
try {
const value: unknown = JSON.parse(line);
return value && typeof value === "object" && !Array.isArray(value)
? (value as Entry)
: undefined;
} catch {
return undefined;
}
}
function timestampOf(entry: Entry): number | undefined {
if (typeof entry.timestamp !== "string") return undefined;
const value = Date.parse(entry.timestamp);
return Number.isNaN(value) ? undefined : value;
}
function firstText(entry: Entry): string | undefined {
const message = entry.message as { content?: unknown } | undefined;
const content = message?.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
for (const block of content) {
if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
const text = (block as { text?: unknown }).text;
if (typeof text === "string") return text;
}
}
}
return undefined;
}
/**
* How a main-chain record bears on the end of the turn: `finished` for a
* record that ends a turn (an assistant `end_turn`, an API error, a user
* interrupt, the CLI's turn-end system records), `unfinished` for a record of
* a turn in progress, and undefined for a record that says nothing.
*/
export function turnEffect(entry: Entry): TurnState | undefined {
if (entry.isSidechain === true) return undefined;
switch (entry.type) {
case "assistant": {
if (entry.isApiErrorMessage === true) return "finished";
const stopReason = (entry.message as { stop_reason?: unknown } | undefined)?.stop_reason;
return stopReason === "end_turn" ? "finished" : "unfinished";
}
case "user": {
if (entry.isMeta === true) return undefined;
return firstText(entry)?.startsWith(INTERRUPT_PREFIX) ? "finished" : "unfinished";
}
case "system":
return TURN_END_SYSTEM_SUBTYPES.has(entry.subtype as string) ? "finished" : undefined;
default:
return undefined;
}
}
const FORKED_FROM_PATTERN =
/"forkedFrom":\s?\{"sessionId":\s?"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"/;
const CWD_PATTERN = /"cwd":\s?"((?:[^"\\]|\\.)*)"/;
function decodeJsonString(raw: string): string | undefined {
try {
const value: unknown = JSON.parse(`"${raw}"`);
return typeof value === "string" ? value : undefined;
} catch {
return undefined;
}
}
/** The facts of a transcript, from its head and its tail. */
export function scanTranscript(
{ head, tail }: HeadTail,
options: { wide?: boolean; known?: { model?: string; tailCwd?: string } } = {},
): TranscriptFacts {
const facts: TranscriptFacts = {
hasMessages: MESSAGE_MARKERS.some((marker) => head.includes(marker) || tail.includes(marker)),
};
const headCwd = CWD_PATTERN.exec(head)?.[1];
if (headCwd !== undefined) facts.headCwd = decodeJsonString(headCwd);
const forkedFrom = FORKED_FROM_PATTERN.exec(head)?.[1];
if (forkedFrom !== undefined) facts.forkedFrom = forkedFrom;
const lines = tail.split("\n");
let turnEndFound = false;
let messageFound = false;
let promptFound = false;
/** The last turn ended: is its end an API error? */
let checkingTurnError = false;
// A wide tail (see scanTranscriptFile) is searched for what the narrow
// one lacked: the last message, prompt and turn end, and the model and
// the cwd only when the narrow tail had none (its records can all be cut,
// e.g. one large tool result), so a long answer is not parsed line by line.
const wide = options.wide === true;
let modelFound = wide && options.known?.model !== undefined;
const searchCwd = !wide || options.known?.tailCwd === undefined;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
if (!line) continue;
const isTurnRecord =
line.includes('"user"') || line.includes('"assistant"') || line.includes('"system"');
const hasCwd = searchCwd && facts.tailCwd === undefined && line.includes('"cwd"');
if (!isTurnRecord && !hasCwd) continue;
// Everything has been found: stop parsing.
if (
turnEndFound &&
messageFound &&
promptFound &&
modelFound &&
!checkingTurnError &&
!hasCwd
) {
break;
}
// Once the last message and the turn state are known, only a line that
// can still tell something is parsed: lines are often large (tool
// results, long answers), and a long tail has thousands of them.
if (
messageFound &&
facts.turnState !== undefined &&
!hasCwd &&
!(!promptFound && mayBePrompt(line)) &&
!(!modelFound && hasType(line, "assistant")) &&
!(!turnEndFound && mayEndTurn(line)) &&
!(checkingTurnError && isTurnRecord)
) {
continue;
}
const entry = parseLine(line);
if (!entry) continue;
if (hasCwd && typeof entry.cwd === "string" && entry.cwd) facts.tailCwd = entry.cwd;
if (entry.isSidechain === true) continue;
if (!messageFound && (entry.type === "user" || entry.type === "assistant")) {
messageFound = true;
facts.lastMessageAt = timestampOf(entry);
}
if (!promptFound && entry.type === "user" && isUserPrompt(entry)) {
promptFound = true;
facts.lastPromptAt = timestampOf(entry);
}
if (!modelFound && entry.type === "assistant") {
const model = (entry.message as { model?: unknown } | undefined)?.model;
// The CLI marks the messages it makes up (API errors) `<synthetic>`.
if (typeof model === "string" && model && model !== "<synthetic>") {
modelFound = true;
facts.model = model;
}
}
const effect = turnEffect(entry);
if (effect === undefined) continue;
if (facts.turnState === undefined) {
facts.turnState = effect;
checkingTurnError = effect === "finished";
}
if (checkingTurnError) {
// The record that ended the last turn, behind the CLI's turn-end
// system records: an API error is an error.
if (entry.type === "assistant" && entry.isApiErrorMessage === true) {
facts.lastTurnError = true;
checkingTurnError = false;
} else if (entry.type !== "system") {
checkingTurnError = false;
}
}
if (effect === "finished" && !turnEndFound) {
turnEndFound = true;
facts.lastTurnEndedAt = timestampOf(entry);
}
}
return facts;
}
function hasType(line: string, type: string): boolean {
return line.includes(`"type":"${type}"`) || line.includes(`"type": "${type}"`);
}
/** Whether a line may be a user prompt: a user record that is no tool
* result, or one that carries an image or a document. */
function mayBePrompt(line: string): boolean {
if (!hasType(line, "user")) return false;
return !hasType(line, "tool_result") || hasType(line, "image") || hasType(line, "document");
}
/** Whether a line may end a turn (see {@link turnEffect}). */
function mayEndTurn(line: string): boolean {
return (
line.includes('"end_turn"') ||
line.includes('"isApiErrorMessage"') ||
line.includes(INTERRUPT_PREFIX) ||
[...TURN_END_SYSTEM_SUBTYPES].some((subtype) => line.includes(`"${subtype}"`))
);
}
/** Every `"key":"value"` string of `text`, in order, decoded. */
function stringFields(text: string, key: string): string[] {
const pattern = new RegExp(`"${key}":\\s?"((?:[^"\\\\]|\\\\.)*)"`, "g");
const values: string[] = [];
for (const match of text.matchAll(pattern)) {
const value = decodeJsonString(match[1]!);
if (value !== undefined) values.push(value);
}
return values;
}
const lastField = (text: string, key: string) => stringFields(text, key).at(-1) || undefined;
/** Whether the first record of the transcript is a sidechain one, which the
* SDK never lists. */
export function isSidechainTranscript(head: string): boolean {
const newline = head.indexOf("\n");
const first = newline >= 0 ? head.slice(0, newline) : head;
return first.includes('"isSidechain":true') || first.includes('"isSidechain": true');
}
/** The last `customTitle` of the tail, as the SDK reads it. */
export function tailCustomTitle({ tail, sdkTail }: HeadTail): string | undefined {
return lastField(sdkTail ?? tail, "customTitle");
}
/** The last non-blank top-level `agentName` of the records of `text`, the
* name `/rename`, a `rename_session` and AIR write with the custom title. */
function lastAgentName(text: string): string | undefined {
if (!text.includes('"agentName"')) return undefined;
const lines = text.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
if (!line.includes('"agentName"')) continue;
const value = parseLine(line)?.agentName;
if (typeof value === "string" && value.trim()) return value;
}
return undefined;
}
/** The last agent name of a transcript, as AIR's native Claude provider
* reads it: the top-level `agentName` of any record, from the tail, else
* from the head. */
export function transcriptAgentName({ head, tail }: HeadTail): string | undefined {
return lastAgentName(tail) ?? lastAgentName(head);
}
/** What the SDK reports as the titles of a session. */
export type SdkTitles = {
/** `SDKSessionInfo.customTitle`: the title the CLI or a client set. */
customTitle?: string;
/** `SDKSessionInfo.summary`: the custom title, else a generated one. */
summary?: string;
};
/**
* The titles the SDK gives a transcript, from its own head and tail, in the
* SDK's order: custom title (tail, the sidecar, head), AI title, then
* {@link generatedTitle}: what the SDK `getSessionInfo` reports for this
* file, without its lookup of the file. The sidecar title counts only when
* the tail has no custom title.
*/
export function sdkTitles(
{ head, tail: wholeLines, sdkTail }: HeadTail,
sidecarTitle?: string,
): SdkTitles {
const tail = sdkTail ?? wholeLines;
const customTitle =
lastField(tail, "customTitle") ??
sidecarTitle ??
lastField(head, "customTitle") ??
lastField(tail, "aiTitle") ??
lastField(head, "aiTitle");
return { customTitle, summary: customTitle ?? generatedTitle({ head, tail }) };
}
/** The title of a transcript without a custom title, in the SDK's order: AI
* title, last prompt, summary, first prompt. */
export function generatedTitle({ head, tail }: HeadTail): string | undefined {
return (
lastField(tail, "aiTitle") ??
lastField(head, "aiTitle") ??
lastField(tail, "lastPrompt") ??
lastField(tail, "summary") ??
(firstPrompt(head) || mediaPrompt(head) || undefined)
);
}
/** The time of the last `"timestamp"` in the last 64 KB of a file, cut
* line included, epoch ms: a record longer than that still ends with it. */
export async function lastTimestamp(filePath: string, size: number): Promise<number | undefined> {
const handle = await fs.open(filePath, "r");
let text: string;
try {
const length = Math.min(size, CHUNK_SIZE);
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
text = buffer.toString("utf8", 0, bytesRead);
} finally {
await handle.close();
}
for (const raw of stringFields(text, "timestamp").reverse()) {
const value = Date.parse(raw);
if (!Number.isNaN(value)) return value;
}
return undefined;
}
const CONTINUED_IN_MARKER = '"type":"continued-in"';
/**
* The session this transcript was continued in, as the SDK list reads it: the
* last `continued-in` record of the tail, unless a completed turn follows it
* (the session was resumed here again).
*/
export function continuedInSessionId(tail: string): string | undefined {
if (!tail.includes(CONTINUED_IN_MARKER)) return undefined;
const lines = tail.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
const isContinuation = line.includes(CONTINUED_IN_MARKER);
const isMessage = line.includes('"type":"user"') || line.includes('"type":"assistant"');
if (!isContinuation && !isMessage) continue;
const entry = parseLine(line);
if (!entry) continue;
if (isContinuation && entry.type === "continued-in") {
const id = entry.continuedInSessionId;
return typeof id === "string" && SESSION_ID.test(id) ? id : undefined;
}
if (entry.type === "assistant") {
const stopReason = (entry.message as { stop_reason?: unknown } | undefined)?.stop_reason;
if (entry.isApiErrorMessage !== true && typeof stopReason === "string") return undefined;
} else if (promptOf(entry, { commandFallback: "" }) !== undefined) {
return undefined;
}
}
return undefined;
}
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HISTORY_MARKER = '"parentUuid":';
const HISTORY_SCAN_LIMIT = 16 * 1024 * 1024;
const HISTORY_SCAN_CHUNK = 1024 * 1024;
/** Whether the transcript at `filePath` holds conversation history (a record
* with a `parentUuid`), as the SDK checks a continuation's successor. */
export async function hasHistory(filePath: string): Promise<boolean> {
let handle: fs.FileHandle;
try {
handle = await fs.open(filePath, "r");
} catch {
return false;
}
try {
const { size } = await handle.stat();
const buffer = Buffer.allocUnsafe(HISTORY_SCAN_CHUNK + HISTORY_MARKER.length);
let carry = 0;
for (let offset = 0; offset < Math.min(size, HISTORY_SCAN_LIMIT);) {
const { bytesRead } = await handle.read(buffer, carry, HISTORY_SCAN_CHUNK, offset);
if (bytesRead === 0) return false;
const end = carry + bytesRead;
if (buffer.subarray(0, end).includes(HISTORY_MARKER)) return true;
carry = Math.min(HISTORY_MARKER.length, end);
buffer.copyWithin(0, end - carry, end);
offset += bytesRead;
}
// Past the scan limit the SDK assumes history.
return size > HISTORY_SCAN_LIMIT;
} catch {
return false;
} finally {
await handle.close();
}
}

View File

@@ -0,0 +1,135 @@
/**
* The git worktrees of a working directory, read from the repository files.
*
* The SDK `listSessions({ dir })` includes the sessions of every worktree of
* the repository and runs `git worktree list` for that on each call. This
* module reads the same list without spawning git, the way the Codex TUI does:
* the main worktree is the parent of the common `.git` directory, and each
* linked worktree is named by `<common-dir>/worktrees/<name>/gitdir`.
* A worktree whose directory no longer exists is dropped. Nothing is cached:
* `git worktree move` rewrites a `gitdir` file without touching the
* `worktrees` directory, so a cache keyed on it would miss the move.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { normalizePath } from "./project-dirs.js";
async function statOrUndefined(target: string) {
try {
return await fs.stat(target);
} catch {
return undefined;
}
}
/** The git dir of the repository that contains `cwd`, or undefined. */
async function findGitDir(cwd: string): Promise<string | undefined> {
let current = path.resolve(cwd);
for (;;) {
const candidate = path.join(current, ".git");
const stats = await statOrUndefined(candidate);
if (stats?.isDirectory()) return candidate;
if (stats?.isFile()) {
try {
const text = await fs.readFile(candidate, "utf8");
const match = /^gitdir:\s*(.+)$/m.exec(text);
if (match) return path.resolve(current, match[1]!.trim());
} catch {
// An unreadable `.git` file is not a repository.
}
return undefined;
}
const parent = path.dirname(current);
if (parent === current) return undefined;
current = parent;
}
}
async function commonDirOf(gitDir: string): Promise<string> {
try {
const text = await fs.readFile(path.join(gitDir, "commondir"), "utf8");
const value = text.trim();
if (value) return path.resolve(gitDir, value);
} catch {
// No `commondir`: the git dir is the common dir.
}
return gitDir;
}
async function linkedWorktrees(commonDir: string): Promise<string[]> {
const worktreesDir = path.join(commonDir, "worktrees");
const stats = await statOrUndefined(worktreesDir);
if (!stats?.isDirectory()) return [];
let names: string[];
try {
names = await fs.readdir(worktreesDir);
} catch {
return [];
}
return (
await Promise.all(
names.map(async (name) => {
try {
const text = await fs.readFile(path.join(worktreesDir, name, "gitdir"), "utf8");
const gitFile = text.trim();
return gitFile ? path.dirname(path.resolve(worktreesDir, name, gitFile)) : undefined;
} catch {
return undefined;
}
}),
)
).filter((value): value is string => value !== undefined);
}
/** The root of the worktree that contains `cwd`: the directory of its
* `.git` (a directory in the main worktree, a file in a linked one). */
async function worktreeRootOf(cwd: string): Promise<string | undefined> {
let current = path.resolve(cwd);
for (;;) {
if (await statOrUndefined(path.join(current, ".git"))) return current;
const parent = path.dirname(current);
if (parent === current) return undefined;
current = parent;
}
}
/**
* The same subdirectory as `cwd` in every existing worktree of its
* repository (the main one and the linked ones), as the Codex TUI expands a
* cwd: for `/repo/packages/a`, `/wt1/packages/a` if it exists. For a cwd at a
* worktree root, the worktree roots. Empty outside a repository.
*/
export async function worktreeCounterparts(cwd: string): Promise<string[]> {
const root = await worktreeRootOf(cwd);
if (!root) return [];
const relative = path.relative(root, path.resolve(cwd));
const candidates = (await repositoryWorktrees(cwd)).map((worktree) =>
relative ? path.join(worktree, relative) : worktree,
);
const existing = await Promise.all(
candidates.map(async (candidate) =>
(await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined,
),
);
return [...new Set(existing.filter((value): value is string => value !== undefined))];
}
/**
* Every existing worktree of the repository that contains `cwd`: the main
* worktree and the linked ones. Empty outside a repository.
*/
export async function repositoryWorktrees(cwd: string): Promise<string[]> {
const gitDir = await findGitDir(cwd);
if (!gitDir) return [];
const commonDir = await commonDirOf(gitDir);
const candidates: string[] = [];
if (path.basename(commonDir) === ".git") candidates.push(path.dirname(commonDir));
candidates.push(...(await linkedWorktrees(commonDir)));
const existing = await Promise.all(
candidates.map(async (candidate) =>
(await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined,
),
);
return [...new Set(existing.filter((value): value is string => value !== undefined))];
}

View File

@@ -17,6 +17,7 @@
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk";
import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent, Session } from "./acp-agent.js";
import { ExplicitTitle } from "./session-index/explicit-title.js";
const MAX_TITLE_LENGTH = 256;
@@ -42,7 +43,22 @@ type TitleCapableQuery = Query & {
* unavailable or yields nothing. */
type TitleFallback = { title: string; lastModified: number };
/** How much of a long text {@link sanitizeTitle} reads first. Collapsed, it
* is longer than a title unless it is mostly whitespace. */
const TITLE_SCAN_LENGTH = 4096;
export function sanitizeTitle(text: string): string {
if (text.length > TITLE_SCAN_LENGTH) {
// A stored summary can be a whole first prompt of megabytes. The title
// depends only on the start of the text: collapsing the whitespace of a
// prefix gives a prefix of the collapsed text. Two more characters than a
// title hold at least one that is not a space (spaces are collapsed), so
// the full text is longer than a title and gets the same cut.
const head = text.slice(0, TITLE_SCAN_LENGTH).replace(/\s+/g, " ").trimStart();
if (head.length >= MAX_TITLE_LENGTH + 2) {
return head.slice(0, MAX_TITLE_LENGTH - 1) + "…";
}
}
// Replace newlines and collapse whitespace
const sanitized = text
.replace(/[\r\n]+/g, " ")
@@ -84,6 +100,27 @@ export class SessionTitles {
* one. Released by {@link reset}, and when generation yields nothing. */
private settled = false;
/** The title a client set, which no generated title replaces. */
private readonly explicit = new ExplicitTitle();
/** The title last stored through {@link setExplicitTitle}, as stored
* (with the archive prefix of an archived session): the title the CLI
* holds. */
private persistedTitle?: string;
/** Client title changes in flight, and the title state before the first. */
private explicitChanges = 0;
private beforeExplicit?: { settled: boolean; context: string | undefined };
get storedTitle(): string | undefined {
return this.persistedTitle;
}
/** The title last published, as the client shows it. */
get shownTitle(): string | undefined {
return this.lastTitle;
}
constructor(
private readonly agent: ClaudeAcpAgent,
private readonly sessionId: string,
@@ -115,8 +152,10 @@ export class SessionTitles {
* `conversation_reset`, which mounts a fresh transcript. */
reset(): void {
this.settled = false;
this.explicit.reset();
this.context = undefined;
this.lastTitle = undefined;
this.persistedTitle = undefined;
}
/** Turn-end title handling. `idle` is the SDK's turn-over signal, so it is
@@ -131,7 +170,24 @@ export class SessionTitles {
* not possible do we fall back to `summary`, which for an SDK-driven session
* is just the raw first prompt. */
async onTurnEnd(session: Session): Promise<void> {
if (this.explicit.settledByClient) {
// No title is generated over the one the client set, but a custom title
// stored since by someone else (a `/rename`) is adopted, as before.
const renames = this.explicit.renames;
const info = await this.readSessionInfo(session);
// No client change ran during the read, so the stored title is current;
// an unchanged one is not published again.
const stored = info?.customTitle;
if (stored && this.explicit.settledByClient && this.explicit.renames === renames) {
await this.publish(stored, info.lastModified);
}
return;
}
// While a client title change is in flight, the session info read below
// could still hold the previous title.
if (this.explicit.active) return;
const info = await this.readSessionInfo(session);
if (this.explicit.active) return;
if (info?.customTitle) {
this.settled = true;
@@ -147,9 +203,13 @@ export class SessionTitles {
if (this.canRequest(session)) {
this.settled = true;
void this.requestGenerateTitle(session, fallback).catch((error) => {
this.agent.logger.error(`Session ${this.sessionId}: session title update failed: ${error}`);
});
this.explicit.track(
this.requestGenerateTitle(session, fallback).catch((error) => {
this.agent.logger.error(
`Session ${this.sessionId}: session title update failed: ${error}`,
);
}),
);
return;
}
@@ -161,6 +221,54 @@ export class SessionTitles {
}
}
/** Applies a title that the client chose (a rename, an archive or an
* unarchive). Settles the title for good, waits for a generation in flight
* so that its persisted title cannot land after this one, persists the
* title with `persist`, which returns what it stored, if anything, and
* publishes `title` unless it is undefined. A change that stored and
* publishes nothing leaves the title state as it was. */
async setExplicitTitle(
publish: string | ((stored: string | undefined) => string) | undefined,
persist: () => Promise<string | undefined | void>,
): Promise<void> {
// The state before the first of overlapping changes, which a change that
// fails or names nothing puts back.
if (this.explicitChanges++ === 0) {
this.beforeExplicit = { settled: this.settled, context: this.context };
}
const previous = this.beforeExplicit!;
this.settled = true;
this.context = undefined;
let stored: string | undefined;
try {
await this.explicit.apply(
async () => {
stored = (await persist()) ?? undefined;
if (stored !== undefined) this.persistedTitle = stored;
return stored !== undefined || publish !== undefined;
},
() => {
this.settled = previous.settled;
this.context ??= previous.context;
},
);
} finally {
if (--this.explicitChanges === 0) this.beforeExplicit = undefined;
}
const title = typeof publish === "function" ? publish(stored) : publish;
if (title === undefined) return;
this.lastTitle = title;
await this.agent.client.sessionUpdate({
sessionId: this.sessionId,
update: { sessionUpdate: "session_info_update", title },
});
}
/** Records a title the CLI took, as soon as it took it. */
rememberStoredTitle(title: string): void {
this.persistedTitle = title;
}
/** Read the SDK's stored info for this session. A missing session file or read
* error is non-fatal: the title is best-effort and another turn will retry. */
private async readSessionInfo(session: Session): Promise<SDKSessionInfo | undefined> {
@@ -174,7 +282,7 @@ export class SessionTitles {
/** Notify the client of a title, unless it is the one we last sent. */
private async publish(rawTitle: string, lastModified: number): Promise<void> {
const title = sanitizeTitle(rawTitle);
const title = sanitizeTitle(this.agent.clientTitle?.(rawTitle) ?? rawTitle);
if (title === this.lastTitle) {
return;
}
@@ -226,8 +334,8 @@ export class SessionTitles {
}
// A session torn down or replaced while the title was in flight must not
// adopt it.
if (this.agent.sessions[this.sessionId] !== session) {
// adopt it, and a title the client set meanwhile wins.
if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
return;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,15 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"AskUserQuestion","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[{"content":{"text":"Which database?","type":"text"},"type":"content"}],"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","title":"Which database?","toolCallId":"toolu_ask"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Which database?","type":"text"},"type":"content"}],"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","title":"Which database?","toolCallId":"toolu_ask"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"elicitation/create","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"rawOutput":"User has answered your questions: \"Which database?\"=\"Postgres\".","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"rawOutput":"User has answered your questions: \"Which database?\"=\"Postgres\".","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,6 +1,6 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,6 +1,6 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,6 +1,6 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,18 +1,18 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"sleep 100","sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100"},"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100","description":"Wait"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"sleep 100"},"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"sleep 100","description":"Wait"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"Q29tbWFuZCBydW5uaW5nIGluIGJhY2tncm91bmQgd2l0aCBJRDogYmFzaF8xLiBPdXRwdXQgaXMgYmVpbmcgd3JpdHRlbiB0bzogL3RtcC90YXNrcy9iYXNoXzEub3V0cHV0LiBZb3Ugd2lsbCBiZSBub3RpZmllZCB3aGVuIGl0IGNvbXBsZXRlcy4="},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,17 +1,17 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"false","sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"false"},"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"false"},"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"RXhpdCBjb2RlIDEKYm9vbQ=="},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,22 +1,21 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"ls -la","sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"ls -la"},"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"ls -la"},"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls -la","description":"List files"},"status":"pending","title":"ls -la","toolCallId":"toolu_bash"},"type":"tool_call"},"title":"ls -la"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"YS50cwpiLnRz"},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"interrupted":false,"isImage":false,"stderr":"","stdout":"a.ts\nb.ts"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"Do","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"ne.","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}

View File

@@ -1,8 +1,8 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","error":"Context too small","sessionUpdate":"compaction_update","status":"failed"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,8 +1,8 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}

View File

@@ -1,8 +1,8 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}

View File

@@ -1,17 +1,18 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"locations":[{"path":"<cwd>/src/app.ts"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[{"content":{"text":"```\nThe user doesn't want to proceed with this tool use. The tool use was rejected.\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use. The tool use was rejected.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\nThe user doesn't want to proceed with this tool use. The tool use was rejected.\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use. The tool use was rejected.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,18 +1,19 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"locations":[{"path":"<cwd>/src/app.ts"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit","toolResponse":{"filePath":"<cwd>/src/app.ts","newString":"const value = 2;","oldString":"const value = 1;","originalFile":"line 1\nconst value = 1;\nline 3\n","replaceAll":false,"structuredPatch":[{"lines":[" line 1","-const value = 1;","+const value = 2;"," line 3"],"newLines":3,"newStart":1,"oldLines":3,"oldStart":1}],"userModified":false}}},"locations":[{"line":1,"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,16 +1,17 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"rawOutput":"User has approved your plan. You can now start coding.\n\n## Approved Plan:\n# Plan\n1. Do it","sessionUpdate":"tool_call_update","status":"completed","title":"Exited Plan Mode","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"User has approved your plan. You can now start coding.\n\n## Approved Plan:\n# Plan\n1. Do it","sessionUpdate":"tool_call_update","status":"completed","title":"Exited Plan Mode","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"plan":{"content":"# Plan\n1. Do it","planId":"plan-mode","type":"markdown"},"sessionUpdate":"plan_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,15 +1,15 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[{"content":{"text":"```\n```\nThe user doesn't want to proceed with this tool use.\n```\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\n```\nThe user doesn't want to proceed with this tool use.\n```\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,6 +1,6 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"/goal Ship the feature","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,19 +1,19 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"rawInput":{"pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep \"value\"","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"rawInput":{"path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep \"value\" src","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"rawInput":{"output_mode":"content","path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep \"value\"","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep \"value\" src","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"output_mode":"content","path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"rawInput":{"-n":true,"output_mode":"content","path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep -n \"value\" src","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"content":[{"content":{"text":"src/app.ts:2:const value = 1;","type":"text"},"type":"content"}],"rawOutput":"src/app.ts:2:const value = 1;","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Glob"}},"content":[],"kind":"search","locations":[],"name":"Glob","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Find","toolCallId":"toolu_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"-n":true,"output_mode":"content","path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep -n \"value\" src","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"src/app.ts:2:const value = 1;","type":"text"},"type":"content"}],"rawOutput":"src/app.ts:2:const value = 1;","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"search","locations":[],"name":"Glob","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Find","toolCallId":"toolu_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Glob"}},"rawInput":{"pattern":"**/*.ts"},"sessionUpdate":"tool_call_update","title":"Find `**/*.ts`","toolCallId":"toolu_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Glob"}},"content":[{"content":{"text":"src/app.ts\nsrc/b.ts","type":"text"},"type":"content"}],"rawOutput":"src/app.ts\nsrc/b.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"pattern":"**/*.ts"},"sessionUpdate":"tool_call_update","title":"Find `**/*.ts`","toolCallId":"toolu_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"src/app.ts\nsrc/b.ts","type":"text"},"type":"content"}],"rawOutput":"src/app.ts\nsrc/b.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,16 +1,15 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"}},"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"},"toolName":"mcp__docs__search"}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{"q":"acp"},"status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"},"type":"tool_call"},"title":"mcp__docs__search"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"}},"content":[{"content":{"text":"Found 2 documents.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Found 2 documents.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_mcp"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search","toolResponse":[{"text":"Found 2 documents.","type":"text"}]}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{"q":"acp"},"status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"},"type":"tool_call"},"title":"mcp__docs__search"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Found 2 documents.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Found 2 documents.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_mcp"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,8 +1,8 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"select"}}},"kind":"read","locations":[{"path":"/memory/a.md"},{"path":"/memory/b.md"}],"sessionUpdate":"tool_call_update","status":"completed","title":"Recalled 2 memories","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"synthesize"}}},"content":[{"content":{"text":"The user prefers tabs.","type":"text"},"type":"content"}],"kind":"read","sessionUpdate":"tool_call_update","status":"completed","title":"Recalled synthesized memory","toolCallId":"00000000-0000-4000-8000-000000000002"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"mode":"select"}}},"kind":"read","locations":[{"path":"/memory/a.md"},{"path":"/memory/b.md"}],"name":"memory_recall","sessionUpdate":"tool_call_update","status":"completed","title":"Recalled 2 memories","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"mode":"synthesize"}}},"content":[{"content":{"text":"The user prefers tabs.","type":"text"},"type":"content"}],"kind":"read","name":"memory_recall","sessionUpdate":"tool_call_update","status":"completed","title":"Recalled synthesized memory","toolCallId":"00000000-0000-4000-8000-000000000002"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,9 +1,9 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"SandboxNetworkAccess"}},"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call_update","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call_update","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"description":"Reason: Sandbox blocks network","options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"```json\n{\n \"host\": \"example.com\"\n}\n```","type":"text"},"type":"content"}],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"status":"pending","title":"example.com","toolCallId":"toolu_net"},"type":"tool_call"},"title":"example.com"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}

View File

@@ -1,18 +1,18 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"content":[],"kind":"other","name":"NotebookEdit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"NotebookEdit","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"rawInput":{"notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"rawInput":{"cell_id":"cell-1","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"rawInput":{"cell_id":"cell-1","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"NotebookEdit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"NotebookEdit","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"cell_id":"cell-1","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"cell_id":"cell-1","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[],"kind":"other","name":"NotebookEdit","rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"status":"pending","title":"NotebookEdit","toolCallId":"toolu_nb"},"type":"tool_call"},"title":"NotebookEdit"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"content":[{"content":{"text":"Updated cell cell-1 with print('hi')","type":"text"},"type":"content"}],"rawOutput":"Updated cell cell-1 with print('hi')","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Updated cell cell-1 with print('hi')","type":"text"},"type":"content"}],"rawOutput":"Updated cell cell-1 with print('hi')","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_nb"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,17 +1,17 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_denied","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"rm -rf /","sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"rm -rf /"},"sessionUpdate":"tool_call_update","title":"rm -rf /","toolCallId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"decisionReason":"Denied by rule Bash(rm:*)","decisionReasonType":"rule","message":"Permission to use Bash has been denied."}}},"content":[{"content":{"text":"Permission denied: Denied by rule Bash(rm:*)","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"rm -rf /"},"sessionUpdate":"tool_call_update","title":"rm -rf /","toolCallId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"decisionReasonType":"rule","message":"Permission to use Bash has been denied."}}},"content":[{"content":{"text":"Permission denied: Denied by rule Bash(rm:*)","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"UGVybWlzc2lvbiB0byB1c2UgQmFzaCBoYXMgYmVlbiBkZW5pZWQu"},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_denied","type":"terminal"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,8 +1,8 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"Work","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"ing.","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}

View File

@@ -1,13 +1,12 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Read"}},"locations":[{"line":1,"path":"<cwd>/a.ts"}],"rawInput":{"file_path":"<cwd>/a.ts"},"sessionUpdate":"tool_call_update","title":"Read a.ts","toolCallId":"toolu_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Read"}},"content":[{"content":{"text":"```\n1\tconst a = 1;\n```","type":"text"},"type":"content"}],"rawOutput":"1\tconst a = 1;\n<system-reminder>x</system-reminder>","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Read","toolResponse":{"file":{"content":"const a = 1;\n","filePath":"<cwd>/a.ts","numLines":1,"startLine":1,"totalLines":1},"type":"text"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/a.ts"}],"rawInput":{"file_path":"<cwd>/a.ts"},"sessionUpdate":"tool_call_update","title":"Read a.ts","toolCallId":"toolu_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\n1\tconst a = 1;\n```","type":"text"},"type":"content"}],"rawOutput":"1\tconst a = 1;\n<system-reminder>x</system-reminder>","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -6,14 +6,14 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"messageId":"msg_r1","sessionUpdate":"agent_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"ls","sessionUpdate":"terminal_update","terminalId":"toolu_r_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_r_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls","description":"List"},"sessionUpdate":"tool_call_update","status":"pending","title":"ls","toolCallId":"toolu_r_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"b","old_string":"a"},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_r_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[{"content":{"text":"Look around","type":"text"},"type":"content"}],"kind":"think","name":"Task","rawInput":{"description":"Explore","prompt":"Look around"},"sessionUpdate":"tool_call_update","status":"pending","title":"Explore","toolCallId":"toolu_r_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_r_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls","description":"List"},"sessionUpdate":"tool_call_update","status":"pending","title":"ls","toolCallId":"toolu_r_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"b","old_string":"a"},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_r_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Look around","type":"text"},"type":"content"}],"kind":"think","name":"Task","rawInput":{"description":"Explore","prompt":"Look around"},"sessionUpdate":"tool_call_update","status":"pending","title":"Explore","toolCallId":"toolu_r_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"YXBwLnRz"},"sessionUpdate":"terminal_update","terminalId":"toolu_r_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_r_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_r_bash","type":"terminal"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[{"content":{"text":"Report.","type":"text"},"type":"content"}],"rawOutput":"Report.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_r_bash","type":"terminal"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Report.","type":"text"},"type":"content"}],"rawOutput":"Report.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"messageId":"msg_r2","sessionUpdate":"agent_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}}
{"kind":"response","method":"session/resume","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}]}}

View File

@@ -1,13 +1,12 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Skill"}},"content":[],"kind":"other","name":"Skill","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Load skill","toolCallId":"toolu_skill"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"Skill","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Load skill","toolCallId":"toolu_skill"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Skill"}},"rawInput":{"skill":"commits"},"sessionUpdate":"tool_call_update","title":"Load skill: commits","toolCallId":"toolu_skill"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Skill"}},"rawOutput":"Launching skill: commits","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_skill"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Skill","toolResponse":{"commandName":"commits","success":true}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_skill"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"skill":"commits"},"sessionUpdate":"tool_call_update","title":"Load skill: commits","toolCallId":"toolu_skill"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"Launching skill: commits","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_skill"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,17 +1,17 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Background review"},"sessionUpdate":"tool_call_update","title":"Background review","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Background review","prompt":"Review the diff"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Background review","prompt":"Review the diff","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Background review"},"sessionUpdate":"tool_call_update","title":"Background review","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Background review","prompt":"Review the diff"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Background review","prompt":"Review the diff","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"Review the diff","type":"text"},"type":"content"}],"rawInput":{"description":"Background review","prompt":"Review the diff","run_in_background":true,"subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"Async agent launched successfully.","type":"text"},"type":"content"}],"rawOutput":"Async agent launched successfully.\nagentId: a2 (use SendMessage to continue)","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent","toolResponse":{"agentId":"a2","description":"Background review","isAsync":true,"outputFile":"/tmp/tasks/a2.output","prompt":"Review the diff","status":"async_launched"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Review the diff","type":"text"},"type":"content"}],"rawInput":{"description":"Background review","prompt":"Review the diff","run_in_background":true,"subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Async agent launched successfully.","type":"text"},"type":"content"}],"rawOutput":"Async agent launched successfully.\nagentId: a2 (use SendMessage to continue)","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"isAsync":true,"status":"async_launched"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}

View File

@@ -1,18 +1,18 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Check the logs"},"sessionUpdate":"tool_call_update","title":"Check the logs","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Check the logs","prompt":"Check logs"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Check the logs"},"sessionUpdate":"tool_call_update","title":"Check the logs","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Check the logs","prompt":"Check logs"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"Check logs","type":"text"},"type":"content"}],"rawInput":{"description":"Check the logs","prompt":"Check logs","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolName":"Read"}},"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"The logs are clean.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The logs are clean.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Read","toolResponse":{"elapsedTimeSeconds":4}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolName":"Read"}},"content":[{"content":{"text":"```\n1\tok\n```","type":"text"},"type":"content"}],"rawOutput":"1\tok","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Check logs","type":"text"},"type":"content"}],"rawInput":{"description":"Check the logs","prompt":"Check logs","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"The logs are clean.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The logs are clean.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_late"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolResponse":{"elapsedTimeSeconds":4}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\n1\tok\n```","type":"text"},"type":"content"}],"rawOutput":"1\tok","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_late_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,31 +1,31 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Fix the build"},"sessionUpdate":"tool_call_update","title":"Fix the build","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Fix the build","prompt":"Make the build pass"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Fix the build"},"sessionUpdate":"tool_call_update","title":"Fix the build","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Fix the build","prompt":"Make the build pass"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"Make the build pass","type":"text"},"type":"content"}],"rawInput":{"description":"Fix the build","prompt":"Make the build pass","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Make the build pass","type":"text"},"type":"content"}],"rawInput":{"description":"Fix the build","prompt":"Make the build pass","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"rawInput":{"pattern":"TODO"},"sessionUpdate":"tool_call_update","title":"grep \"TODO\"","toolCallId":"toolu_native_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"rawInput":{"path":"/src","pattern":"TODO"},"sessionUpdate":"tool_call_update","title":"grep \"TODO\" /src","toolCallId":"toolu_native_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"content":[{"content":{"text":"src/a.ts","type":"text"},"type":"content"}],"rawOutput":"src/a.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_native_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"pattern":"TODO"},"sessionUpdate":"tool_call_update","title":"grep \"TODO\"","toolCallId":"toolu_native_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"path":"/src","pattern":"TODO"},"sessionUpdate":"tool_call_update","title":"grep \"TODO\" /src","toolCallId":"toolu_native_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"src/a.ts","type":"text"},"type":"content"}],"rawOutput":"src/a.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_native_grep"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"make","sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"rawInput":{"command":"make"},"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"rawInput":{"command":"make","description":"Build"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"make"},"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"make","description":"Build"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"make","description":"Build"},"status":"pending","title":"make","toolCallId":"toolu_native_bash"},"type":"tool_call"},"title":"make"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"make","description":"Build"},"status":"pending","title":"make","toolCallId":"toolu_native_bash"},"type":"tool_call"},"title":"make"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"bWFrZTogKioqIEVycm9yIDI="},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"```\nThe build still fails.\n```","type":"text"},"type":"content"}],"rawOutput":[{"text":"The build still fails.","type":"text"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\nThe build still fails.\n```","type":"text"},"type":"content"}],"rawOutput":[{"text":"The build still fails.","type":"text"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,24 +1,24 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Plan the work"},"sessionUpdate":"tool_call_update","title":"Plan the work","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"rawInput":{"description":"Plan the work","prompt":"Plan it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Plan the work"},"sessionUpdate":"tool_call_update","title":"Plan the work","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Plan the work","prompt":"Plan it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"Plan it","type":"text"},"type":"content"}],"rawInput":{"description":"Plan the work","prompt":"Plan it","subagent_type":"Plan"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer","toolName":"Agent"}},"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer","toolName":"Agent"}},"rawInput":{"description":"Read the spec"},"sessionUpdate":"tool_call_update","title":"Read the spec","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer","toolName":"Agent"}},"rawInput":{"description":"Read the spec","prompt":"Read spec.md"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer","toolName":"Agent"}},"content":[{"content":{"text":"Read spec.md","type":"text"},"type":"content"}],"rawInput":{"description":"Read the spec","prompt":"Read spec.md","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner","toolName":"Read"}},"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner","toolName":"Read"}},"content":[{"content":{"text":"```\n1\tspec\n```","type":"text"},"type":"content"}],"rawOutput":"1\tspec","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer","toolName":"Agent"}},"content":[{"content":{"text":"The spec says X.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The spec says X.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Plan it","type":"text"},"type":"content"}],"rawInput":{"description":"Plan the work","prompt":"Plan it","subagent_type":"Plan"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Read the spec"},"sessionUpdate":"tool_call_update","title":"Read the spec","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Read the spec","prompt":"Read spec.md"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Read spec.md","type":"text"},"type":"content"}],"rawInput":{"description":"Read the spec","prompt":"Read spec.md","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\n1\tspec\n```","type":"text"},"type":"content"}],"rawOutput":"1\tspec","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"The spec says X.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The spec says X.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_inner"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":"Plan:","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":" do X.","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"Plan: do X.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Plan: do X.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Plan: do X.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Plan: do X.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_outer"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,43 +1,43 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[],"kind":"think","name":"Task","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"rawInput":{"description":"Explore code"},"sessionUpdate":"tool_call_update","title":"Explore code","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"rawInput":{"description":"Explore code","prompt":"Find the parser"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"think","name":"Task","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Explore code"},"sessionUpdate":"tool_call_update","title":"Explore code","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Explore code","prompt":"Find the parser"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[{"content":{"text":"Find the parser","type":"text"},"type":"content"}],"rawInput":{"description":"Explore code","prompt":"Find the parser","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Find the parser","type":"text"},"type":"content"}],"rawInput":{"description":"Explore code","prompt":"Find the parser","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"locations":[{"line":1,"path":"/x.ts"}],"rawInput":{"file_path":"/x.ts"},"sessionUpdate":"tool_call_update","title":"Read /x.ts","toolCallId":"toolu_sub_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task","toolResponse":{"elapsedTimeSeconds":3,"subagentType":"Explore"}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"content":[{"content":{"text":"```\n1\tparser\n```","type":"text"},"type":"content"}],"rawOutput":"1\tparser","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"/x.ts"}],"rawInput":{"file_path":"/x.ts"},"sessionUpdate":"tool_call_update","title":"Read /x.ts","toolCallId":"toolu_sub_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":3,"subagentType":"Explore"}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\n1\tparser\n```","type":"text"},"type":"content"}],"rawOutput":"1\tparser","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_read"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"rg parser","sessionUpdate":"terminal_update","terminalId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"rawInput":{"command":"rg parser"},"sessionUpdate":"tool_call_update","title":"rg parser","toolCallId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"rawInput":{"command":"rg parser","description":"Search"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"rg parser"},"sessionUpdate":"tool_call_update","title":"rg parser","toolCallId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"rg parser","description":"Search"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"rg parser","description":"Search"},"status":"pending","title":"rg parser","toolCallId":"toolu_sub_bash"},"type":"tool_call"},"title":"rg parser"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"rg parser","description":"Search"},"status":"pending","title":"rg parser","toolCallId":"toolu_sub_bash"},"type":"tool_call"},"title":"rg parser"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"eC50cw=="},"sessionUpdate":"terminal_update","terminalId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"locations":[{"path":"<cwd>/x.ts"}],"rawInput":{"file_path":"<cwd>/x.ts"},"sessionUpdate":"tool_call_update","title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/x.ts","old_string":"a"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/x.ts","new_string":"b","old_string":"a"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/x.ts"}],"rawInput":{"file_path":"<cwd>/x.ts"},"sessionUpdate":"tool_call_update","title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/x.ts","old_string":"a"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/x.ts","new_string":"b","old_string":"a"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"content":[],"kind":"edit","locations":[{"path":"<cwd>/x.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/x.ts","new_string":"b","old_string":"a"},"status":"pending","title":"Edit x.ts","toolCallId":"toolu_sub_edit"},"type":"tool_call"},"title":"Edit x.ts"}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"edit","locations":[{"path":"<cwd>/x.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/x.ts","new_string":"b","old_string":"a"},"status":"pending","title":"Edit x.ts","toolCallId":"toolu_sub_edit"},"type":"tool_call"},"title":"Edit x.ts"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"rawOutput":"The file has been updated.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit","toolResponse":{"filePath":"<cwd>/x.ts","newString":"b","oldString":"a","originalFile":"a\n","replaceAll":false,"structuredPatch":[{"lines":["-a","+b"],"newLines":1,"newStart":1,"oldLines":1,"oldStart":1}],"userModified":false}}},"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/x.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/x.ts <cwd>/x.ts\n--- <cwd>/x.ts\n+++ <cwd>/x.ts\n@@ -1 +1 @@\n-a\n+b\n"},"type":"diff"}],"locations":[{"line":1,"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"The file has been updated.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/x.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/x.ts <cwd>/x.ts\n--- <cwd>/x.ts\n+++ <cwd>/x.ts\n@@ -1 +1 @@\n-a\n+b\n"},"type":"diff"}],"locations":[{"line":1,"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Foun","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"d it.","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[{"content":{"text":"The parser is in x.ts.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The parser is in x.ts.\nagentId: a1 (use SendMessage to continue)","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task","toolResponse":{"agentId":"a1","content":[{"text":"The parser is in x.ts.","type":"text"}],"prompt":"Find the parser","status":"completed","totalDurationMs":10,"totalTokens":5,"totalToolUseCount":1}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"The parser is in x.ts.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The parser is in x.ts.\nagentId: a1 (use SendMessage to continue)","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"status":"completed"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,21 +1,21 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[],"kind":"think","name":"Task","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"rawInput":{"description":"Summarize"},"sessionUpdate":"tool_call_update","title":"Summarize","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"rawInput":{"description":"Summarize","prompt":"Summarize the repo"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"think","name":"Task","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Summarize"},"sessionUpdate":"tool_call_update","title":"Summarize","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"description":"Summarize","prompt":"Summarize the repo"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[{"content":{"text":"Summarize the repo","type":"text"},"type":"content"}],"rawInput":{"description":"Summarize","prompt":"Summarize the repo","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Summarize the repo","type":"text"},"type":"content"}],"rawInput":{"description":"Summarize","prompt":"Summarize the repo","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"two packages.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript","toolName":"Glob"}},"content":[],"kind":"search","locations":[],"name":"Glob","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Find","toolCallId":"toolu_transcript_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript","toolName":"Glob"}},"rawInput":{"pattern":"*"},"sessionUpdate":"tool_call_update","title":"Find `*`","toolCallId":"toolu_transcript_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript","toolName":"Glob"}},"content":[{"content":{"text":"a\nb","type":"text"},"type":"content"}],"rawOutput":"a\nb","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_transcript_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task"}},"content":[{"content":{"text":"Two packages.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Two packages.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":[],"kind":"search","locations":[],"name":"Glob","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Find","toolCallId":"toolu_transcript_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"pattern":"*"},"sessionUpdate":"tool_call_update","title":"Find `*`","toolCallId":"toolu_transcript_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"a\nb","type":"text"},"type":"content"}],"rawOutput":"a\nb","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_transcript_glob"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Two packages.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Two packages.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_transcript"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,8 +1,8 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"plan":{"entries":[{"content":"Write tests","priority":"medium","status":"pending"}],"planId":"plan","type":"items"},"sessionUpdate":"plan_update"}}}
@@ -15,8 +15,8 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 2","type":"text"}],"messageId":"<id-2>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-2>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"All ","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"done.","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}}

View File

@@ -1,17 +1,17 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskOutput"}},"content":[],"kind":"other","name":"TaskOutput","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"TaskOutput","toolCallId":"toolu_out"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskOutput"}},"rawInput":{"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_out"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"TaskOutput","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"TaskOutput","toolCallId":"toolu_out"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_out"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskOutput"}},"rawInput":{"block":true,"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_out"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskOutput"}},"content":[{"content":{"text":"<retrieval_status>success</retrieval_status>\n<output>done</output>","type":"text"},"type":"content"}],"rawOutput":"<retrieval_status>success</retrieval_status>\n<output>done</output>","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_out"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskStop"}},"content":[],"kind":"other","name":"TaskStop","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"TaskStop","toolCallId":"toolu_stop"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"block":true,"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_out"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"<retrieval_status>success</retrieval_status>\n<output>done</output>","type":"text"},"type":"content"}],"rawOutput":"<retrieval_status>success</retrieval_status>\n<output>done</output>","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_out"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"TaskStop","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"TaskStop","toolCallId":"toolu_stop"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskStop"}},"rawInput":{"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_stop"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskStop"}},"content":[{"content":{"text":"Successfully stopped task: bash_1","type":"text"},"type":"content"}],"rawOutput":"Successfully stopped task: bash_1","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_stop"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_stop"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Successfully stopped task: bash_1","type":"text"},"type":"content"}],"rawOutput":"Successfully stopped task: bash_1","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_stop"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,8 +1,8 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"Let me thin","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"k about it.","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}

View File

@@ -1,8 +1,8 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"plan":{"entries":[{"content":"Writing tests","priority":"medium","status":"in_progress"},{"content":"Ship","priority":"medium","status":"pending"}],"planId":"plan","type":"items"},"sessionUpdate":"plan_update"}}}

View File

@@ -1,19 +1,19 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_slow","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"terminalId":"toolu_slow","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"make","sessionUpdate":"terminal_update","terminalId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"make"},"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"elapsedTimeSeconds":1}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"elapsedTimeSeconds":2}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"elapsedTimeSeconds":3}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"make"},"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":1}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":2}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":3}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"YnVpbHQ="},"sessionUpdate":"terminal_update","terminalId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_slow"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,20 +1,20 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"content":[],"kind":"fetch","name":"WebFetch","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Fetch","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"rawInput":{"url":"https://example.com"},"sessionUpdate":"tool_call_update","title":"Fetch https://example.com","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"fetch","name":"WebFetch","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Fetch","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"url":"https://example.com"},"sessionUpdate":"tool_call_update","title":"Fetch https://example.com","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"allow_always","name":"Yes, and don't ask again for example.com","optionId":"allow-with-updates"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"kind":"fetch","name":"WebFetch","rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"status":"pending","title":"Fetch https://example.com","toolCallId":"toolu_fetch"},"type":"tool_call"},"title":"Fetch https://example.com"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"content":[{"content":{"text":"The page is an example.","type":"text"},"type":"content"}],"rawOutput":"The page is an example.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebSearch"}},"content":[],"kind":"fetch","name":"WebSearch","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Web search","toolCallId":"toolu_search"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"The page is an example.","type":"text"},"type":"content"}],"rawOutput":"The page is an example.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_fetch"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"fetch","name":"WebSearch","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Web search","toolCallId":"toolu_search"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebSearch"}},"rawInput":{"query":"acp protocol"},"sessionUpdate":"tool_call_update","title":"Search \"acp protocol\"","toolCallId":"toolu_search"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebSearch"}},"content":[{"content":{"text":"ACP (https://agentclientprotocol.com)\nA summary.","type":"text"},"type":"content"}],"rawOutput":"Web search results for query: \"acp protocol\"\n\nLinks: [...]","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_search"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"query":"acp protocol"},"sessionUpdate":"tool_call_update","title":"Search \"acp protocol\"","toolCallId":"toolu_search"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"ACP (https://agentclientprotocol.com)\nA summary.","type":"text"},"type":"content"}],"rawOutput":"Web search results for query: \"acp protocol\"\n\nLinks: [...]","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_search"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,14 +1,15 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"locations":[{"path":"<cwd>/old.ts"}],"rawInput":{"file_path":"<cwd>/old.ts"},"sessionUpdate":"tool_call_update","title":"Write old.ts","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"edit","locations":[],"name":"Write","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/old.ts"}],"rawInput":{"file_path":"<cwd>/old.ts"},"sessionUpdate":"tool_call_update","title":"Write old.ts","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/old.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawOutput":"The file <cwd>/old.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write","toolResponse":{"content":"export const x = 1;\n","filePath":"<cwd>/old.ts","originalFile":"export const x = 0;\n","structuredPatch":[{"lines":["-export const x = 0;","+export const x = 1;"],"newLines":1,"newStart":1,"oldLines":1,"oldStart":1}],"type":"update"}}},"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/old.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/old.ts <cwd>/old.ts\n--- <cwd>/old.ts\n+++ <cwd>/old.ts\n@@ -1 +1 @@\n-export const x = 0;\n+export const x = 1;\n"},"type":"diff"}],"locations":[{"line":1,"path":"<cwd>/old.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/old.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/old.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/old.ts <cwd>/old.ts\n--- <cwd>/old.ts\n+++ <cwd>/old.ts\n@@ -1 +1 @@\n-export const x = 0;\n+export const x = 1;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/old.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/old.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -1,17 +1,17 @@
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"locations":[{"path":"<cwd>/new.ts"}],"rawInput":{"file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","title":"Write new.ts","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"edit","locations":[],"name":"Write","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/new.ts"}],"rawInput":{"file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","title":"Write new.ts","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/new.ts"}],"name":"Write","rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"status":"pending","title":"Write new.ts","toolCallId":"toolu_write"},"type":"tool_call"},"title":"Write new.ts"}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"rawOutput":"File created successfully at: <cwd>/new.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write","toolResponse":{"content":"export const x = 1;\n","filePath":"<cwd>/new.ts","originalFile":null,"structuredPatch":[],"type":"create"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"File created successfully at: <cwd>/new.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}

View File

@@ -19,6 +19,9 @@
* - An `available_commands_update` also lists the `mcp` command of the
* adapter (see {@link ADAPTER_COMMANDS}), when origin/main did not list
* `mcp`. The adapter replaces the text of `/mcp` for every client.
* - The `initialize` response does not advertise
* `agentCapabilities.sessionCapabilities.subagents`: the subagents RFD has
* only the client capability, and only AIR's draft keeps the agent one.
* - A `terminal_exit` has a `null` exit code where origin/main sent a code
* that the tool result does not say (see `CommandOutput.exitCode`): 1 for a
* failure that names no code or an interrupted command, 0 for a
@@ -198,13 +201,29 @@ function isMeta(key: string): boolean {
return key.startsWith("_meta.");
}
/** `record` without the agent subagent capability, when it is an `initialize` response. */
function withoutAgentSubagentCapability(record: Recorded): Recorded {
if (record.kind !== "initialize") return record;
const payload = record.payload as Json;
const agent = payload.agentCapabilities as Json | undefined;
const session = agent?.sessionCapabilities as Json | undefined;
if (!session || !("subagents" in session)) return record;
return {
...record,
payload: {
...payload,
agentCapabilities: { ...agent, sessionCapabilities: without(session, "subagents") },
},
} as Recorded;
}
/**
* Returns the differences between the baseline and the current traffic that
* the compatibility rule does not allow. An empty list means compatible.
*/
export function compareWithBaseline(baseline: Recorded[], current: Recorded[]): string[] {
const expected = baseline
.map((record) => withoutAirOnlyKeys(record) as Recorded)
.map((record) => withoutAgentSubagentCapability(withoutAirOnlyKeys(record) as Recorded))
.filter((record) => {
const update = updateOf(record);
return !(update?.sessionUpdate === "session_info_update" && Object.keys(update).length === 1);

View File

@@ -98,7 +98,10 @@ type QueryOptions = {
input: Record<string, unknown>,
extra: Record<string, unknown>,
) => Promise<unknown>;
hooks?: Record<string, { hooks: ((input: any, id: string, opts: any) => Promise<unknown>)[] }[]>;
hooks?: Record<
string,
{ matcher?: string; hooks: ((input: any, id: string, opts: any) => Promise<unknown>)[] }[]
>;
};
/** What a scenario script reads and calls. */
@@ -116,8 +119,13 @@ export interface ScriptContext {
toolUseID: string,
extra?: Record<string, unknown>,
): Promise<unknown>;
/** Calls every hook callback that the agent registered for the event. */
/**
* Calls the hook callbacks that the agent registered for the event, whose
* `matcher`, like Claude Code's, matches the `tool_name` of the input.
*/
hook(event: string, input: Record<string, unknown>, toolUseID?: string): Promise<void>;
/** Calls the PreToolUse hooks of a tool use, which run before it runs. */
preToolUse(toolUseID: string, toolName: string, input: unknown): Promise<void>;
/** Calls the PostToolUse hooks of a tool use. */
postToolUse(
toolUseID: string,
@@ -194,6 +202,13 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
}),
async hook(event, input, toolUseID) {
for (const matcher of options.hooks?.[event] ?? []) {
if (
matcher.matcher &&
typeof input.tool_name === "string" &&
!new RegExp(`^(?:${matcher.matcher})$`).test(input.tool_name)
) {
continue;
}
for (const callback of matcher.hooks) {
await callback(
{ hook_event_name: event, session_id: sessionId, cwd: script.cwd, ...input },
@@ -203,6 +218,13 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
}
}
},
preToolUse(toolUseID, toolName, input) {
return ctx.hook(
"PreToolUse",
{ tool_name: toolName, tool_input: input, tool_use_id: toolUseID },
toolUseID,
);
},
postToolUse(toolUseID, toolName, input, response) {
return ctx.hook(
"PostToolUse",
@@ -960,6 +982,8 @@ export async function* toolCall(
) {
const parent = outcome.parent ?? null;
yield* assistantTurn(`msg_${tool.id}`, [{ type: "tool_use", ...tool }], parent);
// Claude Code runs the PreToolUse hooks before it asks for permission.
await ctx.preToolUse(tool.id, tool.name, tool.input);
if (outcome.ask) {
await ctx.canUseTool(
tool.name,

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import type { SessionNotification } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import {
ClearContextCoordinatorHost,
@@ -331,6 +332,7 @@ describe("ExitPlanCoordinator", () => {
const host = {
...baseHost,
sessionUpdate: vi.fn(async () => {}),
supportsPlanOperations: () => true,
destroyReplacement,
settleCancelledTurn,
settleFailedTurn: vi.fn(),
@@ -375,6 +377,7 @@ describe("ExitPlanCoordinator", () => {
const host = {
...baseHost,
sessionUpdate: vi.fn(async () => {}),
supportsPlanOperations: () => true,
destroyReplacement: vi.fn(),
settleCancelledTurn: vi.fn(),
settleFailedTurn,
@@ -383,6 +386,8 @@ describe("ExitPlanCoordinator", () => {
await coordinator.restart("public-session", oldSession, reset);
// The plan was never carried out, so it does not become the session's plan.
expect(host.sessionUpdate).not.toHaveBeenCalled();
expect(settleFailedTurn).toHaveBeenCalledWith(oldSession, turn, error);
expect(turn.carriedUsage).toBeUndefined();
expect(turn.carriedModelUsage).toBeUndefined();
@@ -390,6 +395,52 @@ describe("ExitPlanCoordinator", () => {
expect(oldSession.activeTurn).toBeNull();
expect(oldSession.turnQueue).toEqual([]);
});
it.each([
["publishes the approved plan after the session state, before the fresh context runs", true],
["publishes no plan to a client without plan operations", false],
])("%s", async (_, supportsPlanOperations) => {
const turn: ClearContextTurn = {
settled: false,
promptUuid: "00000000-0000-4000-8000-000000000000",
};
const reset = { toolUseId: "tool-plan", plan: "Ship it", mode: "auto" as const };
const oldSession = testSession({ activeTurn: turn, turnQueue: [turn] });
const freshSession = testSession();
const order: string[] = [];
const host = {
...testHost(oldSession, freshSession),
sessionUpdate: vi.fn(async ({ update }: SessionNotification) => {
order.push(
update.sessionUpdate === "plan_update"
? `plan_update ${JSON.stringify(update.plan)}`
: update.sessionUpdate,
);
}),
supportsPlanOperations: () => supportsPlanOperations,
destroyReplacement: vi.fn(),
settleCancelledTurn: vi.fn(),
settleFailedTurn: vi.fn(),
};
vi.mocked(freshSession.input.push).mockImplementation(() => {
order.push("continuation");
});
const coordinator = new ExitPlanCoordinator<TestSession, ClearContextTurn>(host);
await coordinator.restart("public-session", oldSession, reset);
expect(order).toEqual([
"current_mode_update",
"config_option_update",
...(supportsPlanOperations
? [
"plan_update " +
JSON.stringify({ type: "markdown", planId: "plan-mode", content: "Ship it" }),
]
: []),
"continuation",
]);
});
});
describe("observeExitPlanToolResults", () => {

View File

@@ -173,6 +173,18 @@ describe("createSession options merging", () => {
});
});
it("registers the PreToolUse hook of the change previews only on a v2 connection", async () => {
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
// A v1 client gets the input's diff, so Claude Code waits on no hook.
expect(capturedOptions!.hooks?.PreToolUse).toBeUndefined();
const v2Agent = new ClaudeAcpAgent(createMockClient(), undefined, { v2: true });
await v2Agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.hooks?.PreToolUse).toEqual([
{ matcher: "Edit|Write", hooks: [expect.any(Function)] },
]);
});
it("merges user-provided disallowedTools with ACP internal list", async () => {
await agent.newSession({
cwd: process.cwd(),
@@ -644,9 +656,71 @@ describe("createSession options merging", () => {
expect(capturedOptions!.settings).toBeUndefined();
});
it("ignores env var when _meta provides settings", async () => {
it("keeps model settings when session settings add hooks", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
availableModels: ["opus"],
});
const hooks = { PostToolUse: [{ hooks: [{ type: "command", command: "echo ready" }] }] };
await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: { claudeCode: { options: { settings: { hooks } } } },
});
expect(capturedOptions!.settings).toEqual({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
availableModels: ["opus"],
hooks,
});
// The CLI adds these hooks to the ones in the user and project settings.
expect(capturedOptions!.settingSources).toEqual(["user", "project", "local"]);
});
it("applies session hooks on session/load with model settings", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
const cwd = process.cwd();
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
const hooks = { Stop: [{ hooks: [{ type: "command", command: "echo done" }] }] };
await agent.loadSession({
sessionId,
cwd,
mcpServers: [],
_meta: { claudeCode: { options: { settings: { hooks } } } },
});
expect(capturedOptions!.resume).toBe(sessionId);
expect(capturedOptions!.settings).toEqual({ availableModels: ["opus"], hooks });
});
it("merges model settings with a session settings file", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
const settingsDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-settings-"));
const settingsFile = path.join(settingsDir, "settings.json");
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { Stop: [] } }));
try {
await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: { claudeCode: { options: { settings: settingsFile } } },
});
expect(capturedOptions!.settings).toEqual({
availableModels: ["opus"],
hooks: { Stop: [] },
});
} finally {
fs.rmSync(settingsDir, { recursive: true });
}
});
it("prefers session settings when both sources set the same field", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
availableModels: ["opus"],
});
await agent.newSession({
@@ -664,10 +738,10 @@ describe("createSession options merging", () => {
},
});
// _meta settings take precedence; env var is ignored entirely
expect(capturedOptions!.settings).toEqual({
model: "claude-sonnet-4-6",
modelOverrides: { "claude-opus-4-6": "meta-value" },
availableModels: ["opus"],
});
});

111
src/tests/diff-memo.test.ts Normal file
View File

@@ -0,0 +1,111 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as diff from "diff";
import { clearDiffMemo, patchUpdateFromDiffToolResponse, previewPatchContent } from "../diff.js";
vi.mock("diff", async (importOriginal) => {
const original = await importOriginal<typeof import("diff")>();
return { ...original, structuredPatch: vi.fn(original.structuredPatch) };
});
const structuredPatch = vi.mocked(diff.structuredPatch);
const tempDirectories: string[] = [];
beforeEach(() => {
clearDiffMemo();
structuredPatch.mockClear();
});
afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function temporaryFile(content: string): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "claude-acp-diff-memo-"));
tempDirectories.push(directory);
const filePath = path.join(directory, "file.txt");
await writeFile(filePath, content);
return filePath;
}
function patchText(content: unknown): string {
const block = Array.isArray(content) ? content[0] : content;
return (block._meta as any).jetbrains.air.diffPatch.text;
}
const before = Array.from({ length: 2000 }, (_, i) => `line ${i}\n`).join("");
const after = before.replace("line 1000\n", "line one thousand\n");
describe("line diff reuse between the approval and the PostToolUse hook", () => {
it("diffs an Edit once and gives the hook the same patch", async () => {
const filePath = await temporaryFile(before);
const preview = await previewPatchContent("Edit", {
file_path: filePath,
old_string: "line 1000\n",
new_string: "line one thousand\n",
});
expect(structuredPatch).toHaveBeenCalledTimes(1);
await writeFile(filePath, after);
const hook = await patchUpdateFromDiffToolResponse({
filePath,
originalFile: before,
oldString: "line 1000\n",
});
expect(structuredPatch).toHaveBeenCalledTimes(1);
expect(patchText(hook!.content)).toBe(patchText(preview));
expect(hook!.locations).toEqual([{ path: filePath, line: 998 }]);
});
it("diffs again when the written file differs from the preview", async () => {
const filePath = await temporaryFile(before);
await previewPatchContent("Edit", {
file_path: filePath,
old_string: "line 1000\n",
new_string: "line one thousand\n",
});
const written = before.replace("line 1000\n", "line 1000 changed later\n");
await writeFile(filePath, written);
const hook = await patchUpdateFromDiffToolResponse({ filePath, originalFile: before });
expect(structuredPatch).toHaveBeenCalledTimes(2);
expect(patchText(hook!.content)).toContain("+line 1000 changed later\n");
});
it("diffs again after a diff that ran out of its budget", async () => {
structuredPatch.mockImplementationOnce(() => undefined as never);
const filePath = await temporaryFile(before);
const input = { file_path: filePath, content: after };
const first = await previewPatchContent("Write", input);
const second = await previewPatchContent("Write", input);
expect(structuredPatch).toHaveBeenCalledTimes(2);
// Without a patch, the approval falls back to the standard diff.
expect(first).toEqual([{ type: "diff", path: filePath, oldText: before, newText: after }]);
// The second diff completes and gets the exact patch.
expect(patchText(second)).toContain("+line one thousand\n");
});
it("keeps only a few recent diffs, each until it is read again", async () => {
const filePath = await temporaryFile(before);
const write = (i: number) =>
previewPatchContent("Write", { file_path: filePath, content: `${before}tail ${i}\n` });
for (let i = 0; i < 6; i++) await write(i);
expect(structuredPatch).toHaveBeenCalledTimes(6);
// The most recent diffs are kept; the oldest were pushed out.
await write(5);
expect(structuredPatch).toHaveBeenCalledTimes(6);
await write(0);
expect(structuredPatch).toHaveBeenCalledTimes(7);
// A diff that was read again is gone.
await write(5);
expect(structuredPatch).toHaveBeenCalledTimes(8);
});
});

View File

@@ -0,0 +1,260 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import type { SessionNotification } from "@agentclientprotocol/sdk";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { Writable } from "node:stream";
import { EventLoopYielder, nodeToWebWritable, Pushable } from "../utils.js";
import {
mockSessionState,
successfulResultMessage,
userEcho,
wrapQuery,
} from "./session-doubles.js";
const sessionId = "test-session";
/** Busy-waits, so a test can make one message cost real time. */
function spin(ms: number): void {
const end = performance.now() + ms;
while (performance.now() < end) {
// spin
}
}
function textDelta(text: string) {
return {
type: "stream_event",
event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } },
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: sessionId,
};
}
/** A query whose whole answer is already buffered: every `next()` resolves at
* once, as the SDK queue does when the CLI is ahead of the adapter. */
function bufferedAnswer(chunks: string[]) {
return (input: Pushable<any>) =>
(async function* () {
const user = await input[Symbol.asyncIterator]().next();
yield userEcho(user.value);
for (const chunk of chunks) yield textDelta(chunk);
yield successfulResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
})();
}
function createAgent(
makeGenerator: (input: Pushable<any>) => AsyncGenerator<any>,
onUpdate: (notification: SessionNotification) => void,
) {
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: SessionNotification) => onUpdate(notification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
const input = new Pushable<any>();
agent.sessions[sessionId] = mockSessionState({
query: wrapQuery(makeGenerator(input)),
input,
});
return agent;
}
function chunkTexts(updates: SessionNotification[]): string[] {
return updates
.filter((n) => n.update.sessionUpdate === "agent_message_chunk")
.map((n) => (n.update as { content: { text: string } }).content.text);
}
describe("EventLoopYielder", () => {
it("lets a loop run on within its budget", () => {
let time = 0;
const yielder = new EventLoopYielder(8, () => time);
expect(yielder.maybeYield()).toBeUndefined();
time = 7;
expect(yielder.maybeYield()).toBeUndefined();
});
it("yields to the event loop past its budget", async () => {
let time = 0;
const yielder = new EventLoopYielder(8, () => time);
expect(yielder.maybeYield()).toBeUndefined();
let timerRan = false;
setImmediate(() => (timerRan = true));
time = 8;
const pause = yielder.maybeYield();
expect(pause).toBeInstanceOf(Promise);
await pause;
expect(timerRan).toBe(true);
});
it("starts a new budget once the event loop has turned", async () => {
let time = 0;
const yielder = new EventLoopYielder(8, () => time);
yielder.maybeYield();
time = 20;
await yielder.maybeYield();
// The pause let the event loop turn, so the budget starts again here.
expect(yielder.maybeYield()).toBeUndefined();
time = 27;
expect(yielder.maybeYield()).toBeUndefined();
time = 28;
expect(yielder.maybeYield()).toBeInstanceOf(Promise);
});
it("does not yield for a loop that waits on I/O between items", async () => {
let time = 0;
const yielder = new EventLoopYielder(8, () => time);
for (let i = 0; i < 5; i++) {
expect(yielder.maybeYield()).toBeUndefined();
time += 20;
// A wait on I/O lets the event loop run its phases. A timer alone can
// resume within the same timers phase on a slow machine, before the
// check phase that ends the slice, so the wait ends in that phase.
await new Promise((resolve) => setTimeout(resolve, 1));
await new Promise((resolve) => setImmediate(resolve));
}
});
});
describe("consumer under a buffered backlog", () => {
it("lets timers run while it drains, and keeps every update in order", async () => {
const chunks = Array.from({ length: 30 }, (_, i) => `chunk-${i} `);
const updates: SessionNotification[] = [];
let chunksBeforeTimer: number | undefined;
let timerArmed = false;
const agent = createAgent(bufferedAnswer(chunks), (notification) => {
updates.push(notification);
if (notification.update.sessionUpdate !== "agent_message_chunk") return;
if (!timerArmed) {
timerArmed = true;
setTimeout(() => (chunksBeforeTimer = chunkTexts(updates).length), 0);
}
// Each chunk costs 2 ms: the backlog takes far longer than one budget.
spin(2);
});
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "go" }] });
expect(response.stopReason).toBe("end_turn");
expect(chunkTexts(updates)).toEqual(chunks);
expect(chunksBeforeTimer).toBeDefined();
expect(chunksBeforeTimer!).toBeLessThan(chunks.length);
});
it("runs a cancel that arrives mid-backlog before the backlog ends", async () => {
const chunks = Array.from({ length: 30 }, (_, i) => `chunk-${i} `);
const updates: SessionNotification[] = [];
let chunksAtCancel: number | undefined;
let cancelScheduled = false;
const agent = createAgent(bufferedAnswer(chunks), (notification) => {
updates.push(notification);
if (notification.update.sessionUpdate !== "agent_message_chunk") return;
if (!cancelScheduled) {
cancelScheduled = true;
// A `session/cancel` arrives as I/O, so it can only run in a new macrotask.
setImmediate(() => {
chunksAtCancel = chunkTexts(updates).length;
void agent.cancel({ sessionId });
});
}
spin(2);
});
const query = agent.sessions[sessionId].query as unknown as {
interrupt: ReturnType<typeof vi.fn>;
};
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "go" }] });
expect(chunksAtCancel).toBeDefined();
expect(chunksAtCancel!).toBeLessThan(chunks.length);
expect(query.interrupt).toHaveBeenCalled();
// The turn ends cancelled. The chunks that were already buffered still
// reach the client, in order, as they did before the cancel.
expect(response.stopReason).toBe("cancelled");
expect(chunkTexts(updates)).toEqual(chunks);
});
});
describe("consumer teardown under a buffered backlog", () => {
it("settles the turn when the session closes mid-backlog", async () => {
const chunks = Array.from({ length: 30 }, (_, i) => `chunk-${i} `);
const updates: SessionNotification[] = [];
let closeScheduled = false;
let chunksAtClose: number | undefined;
let closed: Promise<unknown> | undefined;
const agent = createAgent(bufferedAnswer(chunks), (notification) => {
updates.push(notification);
if (notification.update.sessionUpdate !== "agent_message_chunk") return;
if (!closeScheduled) {
closeScheduled = true;
setImmediate(() => {
chunksAtClose = chunkTexts(updates).length;
closed = agent.closeSession({ sessionId });
});
}
spin(2);
});
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "go" }] });
expect(chunksAtClose).toBeDefined();
expect(chunksAtClose!).toBeLessThan(chunks.length);
expect(response.stopReason).toBe("cancelled");
await closed;
expect(agent.sessions[sessionId]).toBeUndefined();
// Whatever reached the client is a prefix of the answer, in order, and
// the rest of the backlog is not forwarded after the close.
const received = chunkTexts(updates);
expect(received.length).toBeLessThan(chunks.length);
expect(received).toEqual(chunks.slice(0, received.length));
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
expect(chunkTexts(updates)).toEqual(received);
});
});
describe("nodeToWebWritable", () => {
it("writes the bytes of each chunk, including a view into a larger buffer", async () => {
const received: Buffer[] = [];
const sink = new Writable({
write(chunk: Buffer, _encoding, callback) {
received.push(Buffer.from(chunk));
callback();
},
});
const writer = nodeToWebWritable(sink).getWriter();
const backing = new TextEncoder().encode("xxhello\nworld\nyy");
await writer.write(backing.subarray(2, 8));
await writer.write(new TextEncoder().encode("world\n"));
expect(Buffer.concat(received).toString()).toBe("hello\nworld\n");
});
it("gives an object-mode stream a Buffer copy", async () => {
const received: unknown[] = [];
const sink = new Writable({
objectMode: true,
write(chunk, _encoding, callback) {
received.push(chunk);
callback();
},
});
const bytes = new TextEncoder().encode("hi");
await nodeToWebWritable(sink).getWriter().write(bytes);
expect(Buffer.isBuffer(received[0])).toBe(true);
expect(received[0]).not.toBe(bytes);
expect((received[0] as Buffer).toString()).toBe("hi");
});
it("rejects the write that fails", async () => {
const sink = new Writable({
write(_chunk, _encoding, callback) {
callback(new Error("EPIPE"));
},
});
sink.on("error", () => {});
const writer = nodeToWebWritable(sink).getWriter();
await expect(writer.write(new Uint8Array([1]))).rejects.toThrow("EPIPE");
});
});

View File

@@ -223,6 +223,16 @@ describe("parseMcpCommand", () => {
expect(parseMcpCommand(text)).toBeNull();
}
});
it("reads past any leading whitespace, as before the prefix check", () => {
expect(parseMcpCommand("\n\t\u00a0\ufeff /mcp enable my\nserver ")).toEqual({
action: "enable",
all: false,
server: "my server",
});
expect(parseMcpCommand("\u2028/mcp")).toEqual({ action: "status" });
expect(parseMcpCommand(`hello ${"word ".repeat(100_000)}/mcp`)).toBeNull();
});
});
const LONG_ERROR =

View File

@@ -7,6 +7,7 @@ import {
NativeSubagentRuntime,
NativeSubagentSession,
resumedNativeSubagentId,
sendMessageResume,
sendMessageResumePrompt,
} from "../native-subagents.js";
@@ -68,8 +69,8 @@ describe("NativeSubagentRuntime lifecycle", () => {
await release.promise;
});
const first = announceNativeSubagent(value, publish);
const second = announceNativeSubagent(value, publish);
const first = announceNativeSubagent(value, publish, "air");
const second = announceNativeSubagent(value, publish, "air");
await Promise.resolve();
expect(publish).toHaveBeenCalledTimes(1);
@@ -101,7 +102,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
});
it("falls back to one ordinary failed tool call when no child ever starts", async () => {
const runtime = new NativeSubagentRuntime(true, "root", {}, async () => {}, { log: () => {} });
const runtime = new NativeSubagentRuntime("air", "root", {}, async () => {}, { log: () => {} });
await expect(
runtime.route(control("tool_call", "pending"), async () => {}),
@@ -124,7 +125,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
});
it("creates a schema-valid non-native failed tool call without a cached initial call", async () => {
const runtime = new NativeSubagentRuntime(true, "root", {}, async () => {}, { log: () => {} });
const runtime = new NativeSubagentRuntime("air", "root", {}, async () => {}, { log: () => {} });
const fallback = await runtime.route(
{
@@ -166,7 +167,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
const session = sessionWith(outer);
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
session,
async (notification) => {
@@ -211,7 +212,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
const published: AcpSessionNotification[] = [];
const delivered: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -258,7 +259,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
it("creates a distinct ACP lifecycle when the SDK restarts the same task id", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -332,7 +333,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
it("announces resumed subagent generation immediately when SendMessage restarts the task (#1158)", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -393,7 +394,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
it("announces a resumed nested subagent under a live ancestor when its parent has finished", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -439,7 +440,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
): Promise<Record<string, unknown>> {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -507,7 +508,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
const published: AcpSessionNotification[] = [];
const logged: string[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -622,10 +623,22 @@ describe("NativeSubagentRuntime lifecycle", () => {
expect(sendMessageResumePrompt(toolUses, "worker-3")).toBeUndefined();
});
it("names the SendMessage call that resumed the agent even without a text", () => {
const toolUses = {
blank: { name: "SendMessage", input: { to: "worker-1", message: " " } },
bash: { name: "Bash", input: { command: "ls" } },
};
expect(sendMessageResume(toolUses, "worker-1", ["blank"])).toEqual({ toolUseId: "blank" });
expect(sendMessageResume(toolUses, "worker-1")).toEqual({ toolUseId: "blank" });
expect(sendMessageResume(toolUses, "worker-1", ["bash"])).toBeUndefined();
expect(sendMessageResumePrompt(toolUses, "worker-1", ["blank"])).toBeUndefined();
});
it("keeps the work of a child tool call in the generation that started it", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -729,3 +742,514 @@ describe("NativeSubagentRuntime lifecycle", () => {
});
});
});
/**
* The subagents RFD (agentclientprotocol/agent-client-protocol#1992, as
* revised), for a client that declares `subagents` and is not AIR.
*/
describe("NativeSubagentRuntime in the RFD form", () => {
const launch = (
toolCallId: string,
input: Record<string, unknown>,
parentToolUseId?: string,
): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "tool_call",
toolCallId,
title: "Agent",
status: "pending",
rawInput: input,
_meta: {
claudeCode: { toolName: "Agent", ...(parentToolUseId ? { parentToolUseId } : {}) },
},
},
}) as AcpSessionNotification;
const output = (parentToolUseId: string): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "working" },
_meta: { claudeCode: { parentToolUseId } },
},
}) as AcpSessionNotification;
/** A SendMessage call, of the root or of the child that `parentToolUseId` launched. */
const sendMessage = (toolCallId: string, parentToolUseId?: string): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "tool_call",
toolCallId,
title: "SendMessage",
status: "pending",
_meta: {
claudeCode: {
toolName: "SendMessage",
...(parentToolUseId ? { parentToolUseId } : {}),
},
},
},
}) as AcpSessionNotification;
/** A runtime whose child `worker-1` was launched by `launch-1` and runs. */
async function runningWorker(input: Record<string, unknown> = {}) {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
"rfd",
"root",
{},
async (notification) => {
published.push(notification);
},
{ log: () => {} },
);
const control = await runtime.route(
launch("launch-1", { description: "Fix the build", prompt: "Make it pass", ...input }),
async () => {},
);
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
async () => {},
);
return { runtime, published, control };
}
const states = (published: AcpSessionNotification[]) =>
published.flatMap(({ update }) =>
update.sessionUpdate === "subagent_update" && update.state ? [update.state] : [],
);
it("keeps the Agent tool call as a tool call of the session that made it", async () => {
const { runtime, control } = await runningWorker();
expect(control).toMatchObject({ sessionId: "root", update: { toolCallId: "launch-1" } });
// A nested launch is a tool call of the child that made it.
await expect(
runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {}),
).resolves.toMatchObject({ sessionId: "worker-1", update: { toolCallId: "launch-2" } });
});
it("announces the child with the parent's labels, then sends it the prompt", async () => {
const { published } = await runningWorker({ name: "builder" });
expect(published).toEqual([
{
sessionId: "root",
update: {
sessionUpdate: "subagent_update",
sessionId: "worker-1",
title: "builder (Explore)",
description: "Fix the build",
state: { state: "running" },
},
},
{
sessionId: "worker-1",
update: {
sessionUpdate: "session_message",
messageId: "launch-1",
senderSessionId: "root",
recipientSessionId: "worker-1",
content: [{ type: "text", text: "Make it pass" }],
},
},
]);
});
it("titles a child without a name by its role", async () => {
const { published } = await runningWorker();
expect(published[0]?.update).toMatchObject({ title: "Explore", description: "Fix the build" });
});
it("keeps the session of an idle child, which a resume runs again", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {});
// An idle child is reusable, so its updates still go to it.
await expect(runtime.route(output("launch-1"), async () => {})).resolves.toMatchObject({
sessionId: "worker-1",
});
await runtime.route(sendMessage("send-1"), async () => {});
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
// A repeated resume signal of the running child changes nothing.
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
await runtime.finishTask("worker-1", "completed", async () => {});
expect(states(published)).toEqual([
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
]);
expect(published.filter(({ update }) => update.sessionUpdate === "session_message")).toEqual([
expect.objectContaining({ sessionId: "worker-1" }),
{
sessionId: "worker-1",
update: {
sessionUpdate: "session_message",
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "worker-1",
content: [{ type: "text", text: "Check the tests too" }],
},
},
]);
expect(
published.some(({ update }) => "sessionId" in update && update.sessionId !== "worker-1"),
).toBe(false);
});
it("moves the work of a resume to the child, under the id of the call that resumed it", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.route(sendMessage("send-1"), async () => {});
// The resumed run's output can arrive before the SDK's task_started.
await expect(runtime.route(output("send-1"), async () => {})).resolves.toBeNull();
const delivered: AcpSessionNotification[] = [];
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "send-1", prompt: "Check the tests too" },
async (notification) => {
delivered.push(notification);
},
);
// The resume signal of the SendMessage result repeats it, and changes nothing.
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
// The waiting output is delivered for routing again, which now finds the child.
expect(delivered).toEqual([output("send-1")]);
await expect(runtime.route(output("send-1"), async () => {})).resolves.toMatchObject({
sessionId: "worker-1",
});
// The end of the first delegation, again, does not end the second.
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
expect(states(published).at(-1)).toEqual({ state: "running" });
await runtime.finishTask("worker-1", "completed", async () => {}, "send-1");
expect(states(published)).toEqual([
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
]);
expect(
published
.filter(({ update }) => update.sessionUpdate === "session_message")
.map(({ update }) => (update as { messageId: string }).messageId),
).toEqual(["launch-1", "send-1"]);
});
it("ignores a SendMessage result that arrives after the run it started ended", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "send-1", prompt: "Check the tests too" },
async () => {},
);
// The terminal patch carries no tool id; the notification repeats it.
await runtime.finishTask("worker-1", "failed", async () => {});
await runtime.finishTask("worker-1", "failed", async () => {}, "send-1");
expect(runtime.isEndedDelegation("worker-1", "send-1")).toBe(true);
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
expect(states(published)).toEqual([
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
{ state: "running" },
{ state: "idle", stopReason: "error" },
]);
});
it("ends a run under an id the child used before, which the SDK falls back to", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {});
await runtime.finishTask("worker-1", "completed", async () => {}, "send-1");
// A resume that no call made runs under the launch's id again.
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "launch-1" }, async () => {});
expect(runtime.isStaleEnd("worker-1", "send-1")).toBe(true);
expect(runtime.isStaleEnd("worker-1", "launch-1")).toBe(false);
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" });
expect(states(published)).toHaveLength(6);
});
it("reports a running patch as running, without its guessed prompt or call", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskResumed("worker-1", async () => {}, "An old message");
expect(states(published).at(-1)).toEqual({ state: "running" });
expect(
published.filter(({ update }) => update.sessionUpdate === "session_message"),
).toHaveLength(1);
expect(runtime.isStaleEnd("worker-1", "launch-1")).toBe(false);
});
it("resumes a child with an open request as requires_action", async () => {
const { runtime, published } = await runningWorker();
let answer!: () => void;
const request = runtime.awaitingUser(
"worker-1",
() => new Promise<void>((resolve) => (answer = resolve)),
);
await Promise.resolve();
// The work ends while the request is open; then a resume runs it again.
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {});
answer();
await request;
expect(states(published)).toEqual([
{ state: "running" },
{ state: "requires_action" },
{ state: "idle", stopReason: "end_turn" },
{ state: "requires_action" },
{ state: "running" },
]);
});
it("names the session that sent a resume's prompt, and no sender when it is unknown", async () => {
const { runtime, published } = await runningWorker();
// worker-1 launches worker-2, which the root then resumes.
await runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {});
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
async () => {},
);
await runtime.finishTask("worker-2", "completed", async () => {}, "launch-2");
await runtime.route(sendMessage("send-root"), async () => {});
await runtime.sendMessageResumed("worker-2", async () => {}, "From the root", "send-root");
await runtime.finishTask("worker-2", "completed", async () => {}, "send-root");
// worker-1 resumes it too.
await runtime.route(sendMessage("send-worker", "launch-1"), async () => {});
await runtime.sendMessageResumed("worker-2", async () => {}, "From worker-1", "send-worker");
await runtime.finishTask("worker-2", "completed", async () => {}, "send-worker");
// A call the runtime never routed has no known sender.
await runtime.sendMessageResumed("worker-2", async () => {}, "From someone", "send-unknown");
const senders = published.flatMap(({ sessionId, update }) =>
sessionId === "worker-2" && update.sessionUpdate === "session_message"
? [[update.messageId, update.senderSessionId]]
: [],
);
expect(senders).toEqual([
["send-root", "root"],
["send-worker", "worker-1"],
["send-unknown", undefined],
]);
});
it("keeps a nested child that ends before its parent is known unexposed", async () => {
const { runtime, published } = await runningWorker();
// worker-2's spawning frame has not arrived, so its parent is unknown.
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
async () => {},
);
await expect(runtime.route(output("launch-2"), async () => {})).resolves.toBeNull();
// A resume signal does not give the unexposed child another call.
await runtime.taskStarted({ taskId: "worker-2", toolUseId: "send-9" }, async () => {});
await runtime.finishTask("worker-2", "completed", async () => {});
// The frame arrives late: it is the tool call of worker-1 that made it.
await expect(
runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {}),
).resolves.toMatchObject({ sessionId: "worker-1" });
expect(
published.some(
({ update }) =>
(update.sessionUpdate === "subagent_update" && update.sessionId === "worker-2") ||
(update.sessionUpdate === "session_message" && update.recipientSessionId === "worker-2"),
),
).toBe(false);
expect(published.some(({ sessionId }) => sessionId === "worker-2")).toBe(false);
});
describe("a nested child whose own parent is not announced yet", () => {
/** A runtime, the updates it sends in order, and the deliver function of
* the agent, which routes a buffered update again (as `sendUpdate` does). */
function setup() {
const sent: AcpSessionNotification[] = [];
const runtime: NativeSubagentRuntime = new NativeSubagentRuntime(
"rfd",
"root",
{},
async (notification) => {
sent.push(notification);
},
{ log: () => {} },
);
const deliver = async (notification: AcpSessionNotification): Promise<void> => {
const routed = await runtime.route(notification, deliver);
if (routed) sent.push(routed);
};
return { runtime, sent, deliver };
}
/** The announcements, as [parent, child], and the sessions that got
* traffic before each was announced. */
function check(sent: AcpSessionNotification[]) {
const announced = new Set(["root"]);
const early: string[] = [];
const announcements: [string, string][] = [];
for (const { sessionId, update } of sent) {
if (!announced.has(sessionId)) early.push(sessionId);
if (update.sessionUpdate === "subagent_update" && !announced.has(update.sessionId)) {
announced.add(update.sessionId);
announcements.push([sessionId, update.sessionId]);
}
}
return { announcements, early };
}
it("announces it after that parent, when the parent's task started before its launch frame", async () => {
const { runtime, sent, deliver } = setup();
// worker-1 is known, but its launch frame has not come: not announced.
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
await deliver(launch("launch-2", { description: "Dig" }, "launch-1"));
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
deliver,
);
await deliver(launch("launch-1", { description: "Fix the build" }));
expect(check(sent)).toEqual({
announcements: [
["root", "worker-1"],
["worker-1", "worker-2"],
],
early: [],
});
});
it("never announces it under the root, when its parent is not known yet", async () => {
const { runtime, sent, deliver } = setup();
await deliver(launch("launch-2", { description: "Dig" }, "launch-1"));
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
deliver,
);
await deliver(launch("launch-1", { description: "Fix the build" }));
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
expect(check(sent)).toEqual({
announcements: [
["root", "worker-1"],
["worker-1", "worker-2"],
],
early: [],
});
});
});
it("keeps the role in the title when a late frame names the child", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
"rfd",
"root",
{},
async (notification) => {
published.push(notification);
},
{ log: () => {} },
);
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
async () => {},
);
await runtime.route(launch("launch-1", { name: "builder" }), async () => {});
expect(published[0]?.update).toMatchObject({ title: "builder (Explore)" });
});
it("reports nothing when the parent is cancelled, until the SDK ends the child", async () => {
const { runtime, published } = await runningWorker();
await runtime.parentCancelled(async () => {});
expect(states(published)).toEqual([{ state: "running" }]);
await runtime.finishTask("worker-1", "killed", async () => {});
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "cancelled" });
});
it("ends the children that still run with the stream as idle, with no reason it did not see", async () => {
// After a cancel, a child that still runs is a background child that
// outlived it: the stream's end is no evidence that it was cancelled.
const cancelled = await runningWorker();
await cancelled.runtime.finishAll("cancelled", async () => {});
expect(states(cancelled.published).at(-1)).toEqual({ state: "idle" });
const failed = await runningWorker();
await failed.runtime.finishAll("failed", async () => {});
expect(states(failed.published).at(-1)).toEqual({ state: "idle" });
// A later error event does not turn that end into a failure.
await failed.runtime.finishTask("worker-1", "failed", async () => {}, undefined, {
error: "boom",
});
expect(states(failed.published)).toHaveLength(2);
});
it("keeps a SendMessage result without a text from resuming a run that ended", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {});
await runtime.finishTask("worker-1", "completed", async () => {}, "send-1");
expect(runtime.isEndedDelegation("worker-1", "send-1")).toBe(true);
await runtime.sendMessageResumed("worker-1", async () => {}, undefined, "send-1");
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" });
});
it("reports a child that can no longer be observed as unknown", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "disconnected", async () => {});
expect(states(published).at(-1)).toEqual({ state: "unknown" });
});
it("reports a child as requires_action while its requests are open", async () => {
const { runtime, published } = await runningWorker();
const first = Promise.withResolvers<string>();
const second = Promise.withResolvers<string>();
const answers = Promise.all([
runtime.awaitingUser("worker-1", () => first.promise),
runtime.awaitingUser("worker-1", () => second.promise),
]);
await vi.waitFor(() => expect(states(published)).toHaveLength(2));
first.resolve("allow");
await first.promise;
expect(states(published).at(-1)).toEqual({ state: "requires_action" });
second.resolve("deny");
await expect(answers).resolves.toEqual(["allow", "deny"]);
expect(states(published)).toEqual([
{ state: "running" },
{ state: "requires_action" },
{ state: "running" },
]);
// The root session's own requests change no child.
await runtime.awaitingUser("root", async () => "allow");
expect(states(published)).toHaveLength(3);
});
it("does not report running again for a child that ended while it waited", async () => {
const { runtime, published } = await runningWorker();
const answer = Promise.withResolvers<string>();
const request = runtime.awaitingUser("worker-1", () => answer.promise);
await vi.waitFor(() => expect(states(published)).toHaveLength(2));
await runtime.finishTask("worker-1", "completed", async () => {});
answer.resolve("allow");
await request;
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" });
});
});

View File

@@ -0,0 +1,202 @@
/**
* Session hooks against the real Claude Code binary. A local stub of the
* Messages API answers every turn, so the test needs no network or account.
* The CLI merges the settings tiers, so a mock SDK cannot show that the user
* and project hooks still run next to the hooks of `_meta.claudeCode.options`.
*/
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import type { NewSessionRequest } from "@agentclientprotocol/sdk";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
function sse(type: string, data: object): string {
return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
}
const REPLY =
sse("message_start", {
message: {
id: "msg_stub",
type: "message",
role: "assistant",
model: "claude-sonnet-4-5",
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 0 },
},
}) +
sse("content_block_start", { index: 0, content_block: { type: "text", text: "" } }) +
sse("content_block_delta", { index: 0, delta: { type: "text_delta", text: "ok" } }) +
sse("content_block_stop", { index: 0 }) +
sse("message_delta", {
delta: { stop_reason: "end_turn", stop_sequence: null },
usage: { output_tokens: 1 },
}) +
sse("message_stop", {});
describe("session hooks with the Claude Code binary", () => {
let root: string;
let cwd: string;
let home: string;
let log: string;
let server: http.Server;
let agent: ClaudeAcpAgent;
let env: Record<string, string | undefined>;
let settingsFiles: Record<string, string>;
let logCursor = 0;
/** A SessionStart hook that writes `tag` to the log. */
function hooks(tag: string) {
return {
SessionStart: [{ hooks: [{ type: "command", command: `echo ${tag} >> "${log}"` }] }],
};
}
function meta(tag?: string): NewSessionRequest["_meta"] {
return { claudeCode: { options: { env, ...(tag && { settings: { hooks: hooks(tag) } }) } } };
}
/** The hooks that ran since the previous call. Hooks of one event run in parallel. */
async function hooksSinceLastCheck(): Promise<string[]> {
const lines = (await readFile(log, "utf8").catch(() => "")).split("\n").filter(Boolean);
const added = lines.slice(logCursor);
logCursor = lines.length;
return added.sort();
}
/** A turn finishes after the SessionStart hooks, and it persists the session for a load. */
async function finishTurn(sessionId: string): Promise<void> {
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
expect(response.stopReason).toBe("end_turn");
}
async function readSettingsFiles(): Promise<Record<string, string>> {
const files: Record<string, string> = {};
for (const dir of [path.join(home, ".claude"), path.join(cwd, ".claude")]) {
for (const name of await readdir(dir)) {
if (name.startsWith("settings")) {
files[path.join(dir, name)] = await readFile(path.join(dir, name), "utf8");
}
}
}
return files;
}
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), "claude-acp-session-hooks-"));
cwd = path.join(root, "workspace");
home = path.join(root, "home");
log = path.join(root, "hooks.log");
await mkdir(path.join(cwd, ".claude"), { recursive: true });
await mkdir(path.join(home, ".claude"), { recursive: true });
await writeFile(
path.join(home, ".claude", "settings.json"),
JSON.stringify({ hooks: hooks("user") }),
);
await writeFile(
path.join(cwd, ".claude", "settings.json"),
JSON.stringify({ hooks: hooks("project") }),
);
settingsFiles = await readSettingsFiles();
// vitest.config.ts points every test at `/usr/bin/false` in place of the
// CLI. This file opts in to the real binary, and isolates the agent process
// too: it reads user settings and runs `claude auth status` itself.
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", undefined);
vi.stubEnv("HOME", home);
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(home, ".claude"));
server = http.createServer((req, res) => {
req.resume();
req.on("end", () => {
if (req.url?.startsWith("/v1/messages/count_tokens")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ input_tokens: 1 }));
} else if (req.url?.startsWith("/v1/messages")) {
res.writeHead(200, { "content-type": "text/event-stream" });
res.end(REPLY);
} else {
res.writeHead(200);
res.end();
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
env = {
...process.env,
HOME: home,
CLAUDE_CONFIG_DIR: path.join(home, ".claude"),
ANTHROPIC_API_KEY: "sk-ant-test",
ANTHROPIC_BASE_URL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
};
const client = {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} as unknown as AcpClient;
agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
});
afterAll(async () => {
await agent?.dispose();
vi.unstubAllEnvs();
await new Promise((resolve) => server?.close(resolve));
// The CLI may still write the transcript of the last session after dispose.
if (root) await rm(root, { recursive: true, force: true, maxRetries: 10 });
});
// With CLAUDE_MODEL_CONFIG, the adapter passes the merged settings inline.
for (const modelConfig of [
undefined,
'{"modelOverrides":{"claude-opus-4-6":"claude-opus-4-6"}}',
]) {
describe(modelConfig ? "with CLAUDE_MODEL_CONFIG" : "without CLAUDE_MODEL_CONFIG", () => {
const original = process.env.CLAUDE_MODEL_CONFIG;
beforeEach(() => {
if (modelConfig) process.env.CLAUDE_MODEL_CONFIG = modelConfig;
else delete process.env.CLAUDE_MODEL_CONFIG;
});
afterEach(() => {
if (original === undefined) delete process.env.CLAUDE_MODEL_CONFIG;
else process.env.CLAUDE_MODEL_CONFIG = original;
});
it("runs the user, project and IDE hooks of one event, only in their session", async () => {
const withHooks = await agent.newSession({ cwd, mcpServers: [], _meta: meta("ide") });
await finishTurn(withHooks.sessionId);
expect(await hooksSinceLastCheck()).toEqual(["ide", "project", "user"]);
const withoutHooks = await agent.newSession({ cwd, mcpServers: [], _meta: meta() });
await finishTurn(withoutHooks.sessionId);
expect(await hooksSinceLastCheck()).toEqual(["project", "user"]);
expect(await readSettingsFiles()).toEqual(settingsFiles);
}, 60_000);
it("replaces and removes IDE hooks on session/load", async () => {
const { sessionId } = await agent.newSession({
cwd,
mcpServers: [],
_meta: meta("ide-old"),
});
await finishTurn(sessionId);
expect(await hooksSinceLastCheck()).toEqual(["ide-old", "project", "user"]);
await agent.loadSession({ sessionId, cwd, mcpServers: [], _meta: meta("ide-new") });
await finishTurn(sessionId);
expect(await hooksSinceLastCheck()).toEqual(["ide-new", "project", "user"]);
await agent.loadSession({ sessionId, cwd, mcpServers: [], _meta: meta() });
await finishTurn(sessionId);
expect(await hooksSinceLastCheck()).toEqual(["project", "user"]);
expect(await readSettingsFiles()).toEqual(settingsFiles);
}, 60_000);
});
}
});

View File

@@ -0,0 +1,954 @@
import { describe, expect, it } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { deriveActivity, selectCost } from "../session-index/activity.js";
import { LiveSessionRegistry } from "../session-index/live-registry.js";
import {
encodeProjectPath,
isSessionId,
pathAndAncestors,
projectDirMatches,
} from "../session-index/project-dirs.js";
import {
decodeListCursor,
encodeListCursor,
parseListOptions,
parseRenameSessionRequest,
type ListScope,
} from "../session-index/service.js";
import {
effectiveTitle,
isArchivedTitle,
storedTitle,
titleRecords,
visibleTitle,
} from "../session-index/archive-title.js";
import {
continuedInSessionId,
readHeadTail,
scanTranscript,
scanTranscriptFile,
sdkTitles,
transcriptAgentName,
type TranscriptFacts,
} from "../session-index/transcript-scan.js";
import { firstPrompt } from "../session-index/first-prompt.js";
import { DirListings, statFiles } from "../session-index/dir-listing.js";
import { SessionIndex } from "../session-index/session-index.js";
const SESSION = "11111111-1111-4111-8111-111111111111";
const lines = (...entries: object[]) => entries.map((entry) => JSON.stringify(entry)).join("\n");
const scan = (text: string) => scanTranscript({ head: text, tail: text });
const user = (text: string, timestamp: string, extra: object = {}) => ({
type: "user",
sessionId: SESSION,
timestamp,
message: { role: "user", content: text },
...extra,
});
const assistant = (stopReason: string | null, timestamp: string, extra: object = {}) => ({
type: "assistant",
sessionId: SESSION,
timestamp,
message: { role: "assistant", content: [{ type: "text", text: "ok" }], stop_reason: stopReason },
...extra,
});
describe("project directory encoding", () => {
it("matches the SDK encoding and never decodes", () => {
expect(encodeProjectPath("/Users/me/repo.x")).toBe("-Users-me-repo-x");
expect(projectDirMatches("-Users-me-repo-x", "/Users/me/repo.x")).toBe(true);
expect(projectDirMatches("-Users-me-repo-x", "/Users/me/repo")).toBe(false);
});
it("matches a long path by its cut prefix, whatever the hash", () => {
const long = `/${"a".repeat(250)}`;
const encoded = encodeProjectPath(long);
expect(encoded.length).toBeGreaterThan(200);
expect(projectDirMatches(`${encoded.slice(0, 200)}-otherhash`, long)).toBe(true);
});
it("lists a path and its ancestors", () => {
expect(pathAndAncestors("/a/b")).toEqual(["/a/b", "/a", "/"]);
});
it("accepts only UUID session ids", () => {
expect(isSessionId(SESSION)).toBe(true);
expect(isSessionId("../etc/passwd")).toBe(false);
expect(isSessionId("agent-123")).toBe(false);
});
});
describe("transcript scan", () => {
it("reads a small transcript only up to the size it was stat'ed at", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "head-tail-"));
const file = path.join(dir, "t.jsonl");
const size = Buffer.byteLength("first\n");
await fs.writeFile(file, "first\nappended later\n");
expect(await readHeadTail(file, size)).toEqual({ head: "first\n", tail: "first\n" });
await fs.rm(dir, { recursive: true, force: true });
});
it("sees a finished turn at an assistant end_turn", () => {
const facts = scan(
lines(user("hi", "2026-01-01T00:00:00Z"), assistant("end_turn", "2026-01-01T00:00:05Z")),
);
expect(facts.turnState).toBe("finished");
expect(facts.lastTurnEndedAt).toBe(Date.parse("2026-01-01T00:00:05Z"));
expect(facts.lastMessageAt).toBe(Date.parse("2026-01-01T00:00:05Z"));
expect(facts.hasMessages).toBe(true);
});
it("sees a turn end at an interrupt, an API error, and the turn-end system records", () => {
expect(
scan(lines(user("[Request interrupted by user]", "2026-01-01T00:00:00Z"))).turnState,
).toBe("finished");
expect(
scan(lines(assistant(null, "2026-01-01T00:00:00Z", { isApiErrorMessage: true }))).turnState,
).toBe("finished");
const hook = scan(
lines(assistant("tool_use", "2026-01-01T00:00:00Z"), {
type: "system",
subtype: "stop_hook_summary",
timestamp: "2026-01-01T00:00:09Z",
}),
);
expect(hook.turnState).toBe("finished");
expect(hook.lastTurnEndedAt).toBe(Date.parse("2026-01-01T00:00:09Z"));
});
it("sees an unfinished turn and keeps the previous turn end", () => {
const facts = scan(
lines(
assistant("end_turn", "2026-01-01T00:00:01Z"),
user("next", "2026-01-01T00:00:02Z"),
assistant("tool_use", "2026-01-01T00:00:03Z"),
{ type: "custom-title", customTitle: "x", sessionId: SESSION },
),
);
expect(facts.turnState).toBe("unfinished");
expect(facts.lastTurnEndedAt).toBe(Date.parse("2026-01-01T00:00:01Z"));
expect(facts.lastMessageAt).toBe(Date.parse("2026-01-01T00:00:03Z"));
});
it("ignores sidechain and meta records", () => {
const facts = scan(
lines(
assistant("end_turn", "2026-01-01T00:00:01Z"),
user("meta", "2026-01-01T00:00:02Z", { isMeta: true }),
assistant("tool_use", "2026-01-01T00:00:03Z", { isSidechain: true }),
),
);
expect(facts.turnState).toBe("finished");
});
it("reads no cost from a transcript", () => {
const facts = scan(
lines(user("hello", "2026-01-01T00:00:01.000Z"), {
type: "cost-state",
sessionId: SESSION,
totalCostUSD: 1.5,
}),
);
expect(facts).not.toHaveProperty("costUsd");
});
it("reports the head cwd and the last tail cwd, and a stub without messages", () => {
const facts = scanTranscript({
head: lines({ type: "queue-operation" }, { type: "attachment", cwd: "/repo" }),
tail: lines({ type: "attachment", cwd: "/repo/sub" }, { type: "last-prompt" }),
});
expect(facts.headCwd).toBe("/repo");
expect(facts.tailCwd).toBe("/repo/sub");
expect(facts.hasMessages).toBe(false);
});
it('takes a prompt whose text is "tool_result" for a prompt', () => {
const facts = scan(
lines(
user("tool_result", "2026-01-01T00:00:01.000Z"),
assistant("end_turn", "2026-01-01T00:00:02.000Z"),
),
);
expect(facts.lastPromptAt).toBe(Date.parse("2026-01-01T00:00:01.000Z"));
});
it("finds the model and cwd before a tail that is one large tool result", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "si-scan-"));
try {
const file = path.join(dir, `${SESSION}.jsonl`);
const text =
lines(
user("hello", "2026-01-01T00:00:01.000Z", { cwd: "/repo" }),
assistant("tool_use", "2026-01-01T00:00:02.000Z", {
cwd: "/repo",
message: {
role: "assistant",
model: "claude-model-x",
stop_reason: "tool_use",
content: [],
},
}),
{
type: "user",
sessionId: SESSION,
timestamp: "2026-01-01T00:00:03.000Z",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "t1", content: "x".repeat(200_000) }],
},
},
) + "\n";
await fs.writeFile(file, text);
const facts = await scanTranscriptFile(file, Buffer.byteLength(text));
expect(facts.model).toBe("claude-model-x");
expect(facts.tailCwd).toBe("/repo");
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
});
describe("activity", () => {
const now = Date.parse("2026-01-01T01:00:00Z");
const facts = (overrides: Partial<TranscriptFacts> = {}): TranscriptFacts => ({
hasMessages: true,
...overrides,
});
it("reports the SDK state of a session this connection runs", () => {
expect(
deriveActivity({
own: { state: "requires_action", lastTurnEndedAt: now - 1000 },
facts: facts({ lastTurnEndedAt: 1 }),
transcriptMtimeMs: now,
now,
}),
).toEqual({ state: "requires_action", lastTurnEndedAt: new Date(now - 1000).toISOString() });
expect(deriveActivity({ own: {}, facts: facts(), transcriptMtimeMs: now, now })).toEqual({
state: "idle",
});
});
it("is error after a turn that ended with an API error, until a new turn", () => {
const errorFacts = scan(
lines(
user("hi", "2026-01-01T00:00:00Z"),
assistant(null, "2026-01-01T00:00:05Z", { isApiErrorMessage: true }),
),
);
expect(errorFacts.lastTurnError).toBe(true);
const at = { transcriptMtimeMs: now, now };
expect(deriveActivity({ facts: errorFacts, ...at })?.state).toBe("error");
// A user interrupt is no error.
const interrupted = scan(
lines(
user("hi", "2026-01-01T00:00:00Z"),
user("[Request interrupted by user]", "2026-01-01T00:00:05Z"),
),
);
expect(interrupted.lastTurnError).toBeUndefined();
expect(deriveActivity({ facts: interrupted, ...at })?.state).toBe("idle");
// A prompt after the error starts a new turn.
const again = scan(
lines(
user("hi", "2026-01-01T00:00:00Z"),
assistant(null, "2026-01-01T00:00:05Z", { isApiErrorMessage: true }),
user("again", "2026-01-01T00:00:09Z"),
),
);
expect(again.lastTurnError).toBeUndefined();
expect(deriveActivity({ facts: again, ...at })?.state).toBe("idle");
// A busy or waiting CLI wins; an idle one shows the error.
const live = (status: string) => ({
pid: 1,
sessionId: "s",
kind: "interactive",
entrypoint: "cli",
status,
statusUpdatedAt: now,
});
const held = { facts: errorFacts, transcriptMtimeMs: now - 1, now };
expect(deriveActivity({ live: live("busy"), ...held })?.state).toBe("running");
expect(deriveActivity({ live: live("waiting"), ...held })?.state).toBe("requires_action");
expect(deriveActivity({ live: live("idle"), ...held })?.state).toBe("error");
// A session that runs here: its own failed turn, while idle.
expect(
deriveActivity({ own: { state: "idle", lastTurnFailed: true }, facts: facts(), ...at })
?.state,
).toBe("error");
expect(
deriveActivity({ own: { state: "running", lastTurnFailed: true }, facts: facts(), ...at })
?.state,
).toBe("running");
expect(deriveActivity({ own: { state: "idle" }, facts: errorFacts, ...at })?.state).toBe(
"idle",
);
// The API error behind the CLI's turn-end records.
const behindTurnEnd = scan(
lines(
user("hi", "2026-01-01T00:00:00Z"),
assistant(null, "2026-01-01T00:00:05Z", { isApiErrorMessage: true }),
{ type: "system", subtype: "turn_duration", timestamp: "2026-01-01T00:00:06Z" },
{ type: "system", subtype: "stop_hook_summary", timestamp: "2026-01-01T00:00:06Z" },
),
);
expect(behindTurnEnd.lastTurnError).toBe(true);
// A query that failed here: error until the transcript changes, and a
// busy CLI still wins.
const unfinished = facts({ turnState: "unfinished" });
const failedHere = { own: { queryFailedAt: now }, facts: unfinished, now };
expect(deriveActivity({ ...failedHere, transcriptMtimeMs: now - 1 })?.state).toBe("error");
expect(deriveActivity({ ...failedHere, transcriptMtimeMs: now + 60_000 })?.state).toBe("error");
// A turn that ended after the failure (another process resumed it).
const resumed = facts({ turnState: "finished", lastTurnEndedAt: now + 2000 });
expect(
deriveActivity({
own: { queryFailedAt: now },
facts: resumed,
transcriptMtimeMs: now + 2000,
now,
})?.state,
).toBe("idle");
expect(
deriveActivity({ ...failedHere, live: live("busy"), transcriptMtimeMs: now - 1 })?.state,
).toBe("running");
});
it("is idle when no live process holds the session", () => {
expect(
deriveActivity({ facts: facts({ turnState: "unfinished" }), transcriptMtimeMs: now, now }),
).toEqual({ state: "idle" });
});
it("uses the registry status of an interactive CLI newer than the transcript", () => {
const live = {
pid: 1,
sessionId: "s",
kind: "interactive",
entrypoint: "cli",
status: "waiting",
statusUpdatedAt: now,
};
expect(deriveActivity({ live, facts: facts(), transcriptMtimeMs: now - 1, now })?.state).toBe(
"requires_action",
);
expect(
deriveActivity({
live: { ...live, status: "busy" },
facts: facts(),
transcriptMtimeMs: now - 1,
now,
})?.state,
).toBe("running");
// Older than the transcript: the tail decides.
expect(
deriveActivity({
live,
facts: facts({ turnState: "finished" }),
transcriptMtimeMs: now + 1,
now,
})?.state,
).toBe("idle");
});
it("reads the tail for an SDK-driven CLI, whose registry stays busy", () => {
const live = {
pid: 1,
sessionId: "s",
kind: "interactive",
entrypoint: "sdk-ts",
status: "busy",
statusUpdatedAt: now,
};
const base = { live, transcriptMtimeMs: now - 60_000, now };
expect(deriveActivity({ ...base, facts: facts({ turnState: "finished" }) })?.state).toBe(
"idle",
);
expect(deriveActivity({ ...base, facts: facts({ turnState: "unfinished" }) })?.state).toBe(
"running",
);
// An unfinished turn that has not written for 10 minutes has no known state.
expect(
deriveActivity({
...base,
transcriptMtimeMs: now - 11 * 60_000,
facts: facts({ turnState: "unfinished" }),
}),
).toBeUndefined();
});
});
describe("cost", () => {
it("is the SDK's amount of a session that runs here, and only a positive one", () => {
expect(selectCost({ costUsd: 2 })).toBe(2);
expect(selectCost({ costUsd: 0 })).toBeUndefined();
expect(selectCost({ costUsd: Number.NaN })).toBeUndefined();
// No SDK result yet, or a session that does not run here: no cost.
expect(selectCost({ state: "idle" })).toBeUndefined();
expect(selectCost(undefined)).toBeUndefined();
});
});
describe("list request parsing", () => {
const meta = (list: object) => ({ jetbrains: { air: { version: 1, list } } });
it("defaults and clamps the limit", () => {
expect(parseListOptions(undefined)).toEqual({
limit: 50,
archived: "unarchived",
includeWorktrees: false,
});
expect(parseListOptions(meta({ limit: null })).limit).toBe(50);
expect(parseListOptions(meta({ limit: 1 })).limit).toBe(1);
expect(parseListOptions(meta({ limit: 1000 })).limit).toBe(200);
for (const limit of ["1", 1.5, 0, -3, Number.NaN, Number.POSITIVE_INFINITY, true, {}]) {
expect(() => parseListOptions(meta({ limit }))).toThrow(
expect.objectContaining({ code: -32602 }),
);
}
});
it("takes includeWorktrees as a boolean and archived as a filter value, null as the default", () => {
expect(parseListOptions(meta({ includeWorktrees: true })).includeWorktrees).toBe(true);
expect(parseListOptions(meta({ includeWorktrees: null })).includeWorktrees).toBe(false);
expect(() => parseListOptions(meta({ includeWorktrees: "yes" }))).toThrow(
expect.objectContaining({ code: -32602 }),
);
for (const archived of ["unarchived", "archived", "all"]) {
expect(parseListOptions(meta({ archived })).archived).toBe(archived);
}
expect(parseListOptions(meta({ archived: null })).archived).toBe("unarchived");
expect(parseListOptions(meta({})).archived).toBe("unarchived");
for (const archived of [true, false, "only", "ALL", "Archived", "", 1, "true", {}, []]) {
expect(() => parseListOptions(meta({ archived }))).toThrow(
expect.objectContaining({ code: -32602 }),
);
}
});
it("round-trips a cursor and rejects one of another scope", () => {
const scope: ListScope = { cwd: "/repo", archived: "unarchived", includeWorktrees: false };
const cursor = encodeListCursor({ orderAtMs: 5, sessionId: SESSION }, scope);
expect(decodeListCursor(cursor, scope)).toEqual({ orderAtMs: 5, sessionId: SESSION });
for (const archived of ["archived", "all"] as const) {
expect(() => decodeListCursor(cursor, { ...scope, archived })).toThrow(
expect.objectContaining({ code: -32602 }),
);
const other = encodeListCursor({ orderAtMs: 5, sessionId: SESSION }, { ...scope, archived });
expect(decodeListCursor(other, { ...scope, archived })).toEqual({
orderAtMs: 5,
sessionId: SESSION,
});
expect(() => decodeListCursor(other, scope)).toThrow(
expect.objectContaining({ code: -32602 }),
);
}
expect(() => decodeListCursor(cursor, { ...scope, includeWorktrees: true })).toThrow(
expect.objectContaining({ code: -32602 }),
);
expect(() => decodeListCursor(cursor, { ...scope, cwd: "/other" })).toThrow(
expect.objectContaining({ code: -32602 }),
);
expect(() => decodeListCursor("offset:1000", scope)).toThrow(
expect.objectContaining({ code: -32602 }),
);
// Cursors of earlier format versions.
for (const v of [3, 4]) {
const legacy = Buffer.from(
JSON.stringify({ v, u: 5, id: SESSION, cwd: "/repo", archived: false, worktrees: false }),
).toString("base64url");
expect(() => decodeListCursor(legacy, scope)).toThrow(
expect.objectContaining({ code: -32602 }),
);
}
});
it("validates and truncates a rename title", () => {
expect(() => parseRenameSessionRequest({ sessionId: SESSION, title: " " })).toThrow(
expect.objectContaining({ code: -32602 }),
);
expect(() => parseRenameSessionRequest({ sessionId: SESSION })).toThrow(
expect.objectContaining({ code: -32602 }),
);
const { title } = parseRenameSessionRequest({ sessionId: SESSION, title: "x".repeat(300) });
expect(title).toHaveLength(256);
});
});
describe("live registry", () => {
const now = Date.parse("2026-01-02T00:00:00Z");
async function registryWith(records: Record<string, object | string>) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "live-registry-"));
for (const [name, record] of Object.entries(records)) {
await fs.writeFile(
path.join(dir, name),
typeof record === "string" ? record : JSON.stringify(record),
);
}
return dir;
}
it("keeps live, matching records and applies the liveness rules", async () => {
const dir = await registryWith({
"10.json": {
pid: 10,
sessionId: "live",
procStart: "Mon Jan 1 00:00:00 2026",
updatedAt: now,
},
"11.json": { pid: 11, sessionId: "dead", updatedAt: now },
"12.json": {
pid: 12,
sessionId: "reused",
procStart: "Sun Jan 1 00:00:00 2025",
updatedAt: now,
},
"13.json": { pid: 13, sessionId: "stale", updatedAt: now - 25 * 3600_000 },
"14.json": {
pid: 14,
sessionId: "old-but-proven",
procStart: "Tue Jan 2 00:00:00 2024",
updatedAt: 0,
},
"15.json": { pid: 15, sessionId: "foreign", pidDomain: "elsewhere", updatedAt: now },
"16.json": "{ not json",
"17.json": { pid: 99, sessionId: "wrong-name", updatedAt: now },
"18.abcdef.key": "secret",
});
const registry = new LiveSessionRegistry({
dir: () => dir,
now: () => now,
isAlive: (pid) => pid !== 11,
processStarts: async () =>
new Map([
[10, "Mon Jan 1 00:00:00 2026"],
[12, "Mon Jan 1 00:00:00 2026"],
[14, "Tue Jan 2 00:00:00 2024"],
]),
pidDomain: async () => "darwin",
});
const snapshot = await registry.snapshot();
expect([...snapshot.keys()].sort()).toEqual(["live", "old-but-proven"]);
expect(snapshot.get("live")?.pid).toBe(10);
await fs.rm(dir, { recursive: true, force: true });
});
it("reads single records, never a .key file, and reuses a recent start time on request", async () => {
const dir = await registryWith({
"10.json": {
pid: 10,
sessionId: "live",
procStart: "Mon Jan 1 00:00:00 2026",
updatedAt: now,
},
"10.json.tmp": "{}",
"18.abcdef.key": "secret",
});
const asked: number[][] = [];
const registry = new LiveSessionRegistry({
dir: () => dir,
now: () => now,
isAlive: () => true,
processStarts: async (pids) => {
asked.push(pids);
return new Map([[10, "Mon Jan 1 00:00:00 2026"]]);
},
pidDomain: async () => "darwin",
});
const read = await registry.readFiles(["10.json", "10.json.tmp", "18.abcdef.key", "11.json"]);
expect([...read.keys()].sort()).toEqual(["10.json", "11.json"]);
expect(read.get("10.json")?.sessionId).toBe("live");
expect(read.get("11.json")).toBeUndefined();
await registry.readFiles(["10.json"], { recentStarts: true });
expect(asked).toEqual([[10]]);
// Without recentStarts, `ps` is asked again.
await registry.readFiles(["10.json"]);
expect(asked).toEqual([[10], [10]]);
await fs.rm(dir, { recursive: true, force: true });
});
it("is empty when the registry does not exist", async () => {
const registry = new LiveSessionRegistry({ dir: () => "/nonexistent/registry" });
expect((await registry.snapshot()).size).toBe(0);
});
});
describe("first prompt, as the SDK extracts it", () => {
const userLine = (content: unknown, extra: object = {}) =>
JSON.stringify({
type: "user",
sessionId: SESSION,
message: { role: "user", content },
...extra,
});
it("keeps a slash command only as a fallback for a real prompt", () => {
const init = userLine(
"<command-message>init</command-message>\n<command-name>/init</command-name>",
);
expect(firstPrompt([init, userLine("Explain the parser")].join("\n"))).toBe(
"Explain the parser",
);
expect(firstPrompt(init)).toBe("/init");
expect(sdkTitles({ head: init, tail: init }).summary).toBe("/init");
});
it("skips tags, interrupts, meta, compact summaries and tool results", () => {
const head = [
userLine("<local-command-stdout>ok</local-command-stdout>"),
userLine("[Request interrupted by user]"),
userLine("Hidden", { isMeta: true }),
userLine("Summary", { isCompactSummary: true }),
userLine([{ type: "tool_result", tool_use_id: "t", content: "x" }]),
userLine([
{ type: "text", text: "<system-reminder>r</system-reminder>" },
{ type: "text", text: "Second block\nprompt" },
]),
].join("\n");
expect(firstPrompt(head)).toBe("Second block prompt");
});
it("renders bash input, expands pasted content, and cuts at 200 characters", () => {
expect(firstPrompt(userLine("<bash-input> ls -la </bash-input>"))).toBe("! ls -la");
const pasted =
'Fix this\n<pasted_content id="0a1f">\nstack trace\n</pasted_content id="0a1f">\n';
expect(firstPrompt(userLine(pasted))).toBe("Fix thisstack trace");
expect(firstPrompt(userLine("y".repeat(300)))).toBe(`${"y".repeat(200)}…`);
});
it("titles an image-only first prompt", () => {
const head = userLine([{ type: "image", source: { type: "base64", data: "x" } }]);
expect(sdkTitles({ head, tail: head }).summary).toBe("Image");
});
});
describe("continued-in", () => {
const successor = "22222222-2222-4222-8222-222222222222";
const continued = { type: "continued-in", continuedInSessionId: successor };
it("counts only a real prompt or a finished answer after it as a resume", () => {
expect(continuedInSessionId(lines(user("x", "2026-01-01T00:00:00Z"), continued))).toBe(
successor,
);
// A meta record or a slash command after it is no resume.
expect(
continuedInSessionId(
lines(
continued,
user("<command-name>/status</command-name>", "2026-01-01T00:00:01Z"),
user("note", "2026-01-01T00:00:02Z", { isMeta: true }),
),
),
).toBe(successor);
expect(
continuedInSessionId(lines(continued, user("Go on", "2026-01-01T00:00:03Z"))),
).toBeUndefined();
expect(
continuedInSessionId(lines(continued, assistant("end_turn", "2026-01-01T00:00:04Z"))),
).toBeUndefined();
});
});
describe("row facts of the tail and head", () => {
it("takes lastPromptAt from the last real prompt only", () => {
const facts = scan(
lines(
user("Fix it", "2026-01-01T00:00:00Z"),
assistant("tool_use", "2026-01-01T00:00:01Z"),
{
type: "user",
sessionId: SESSION,
timestamp: "2026-01-01T00:00:02Z",
message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t" }] },
},
user("<command-name>/status</command-name>", "2026-01-01T00:00:03Z"),
user("note", "2026-01-01T00:00:04Z", { isMeta: true }),
user("[Request interrupted by user]", "2026-01-01T00:00:05Z"),
assistant("end_turn", "2026-01-01T00:00:06Z"),
),
);
expect(facts.lastPromptAt).toBe(Date.parse("2026-01-01T00:00:00Z"));
});
it("takes the model of the last real assistant message", () => {
const facts = scan(
lines(
assistant("end_turn", "2026-01-01T00:00:01Z", {
message: { role: "assistant", model: "claude-a", content: [], stop_reason: "end_turn" },
}),
assistant("end_turn", "2026-01-01T00:00:02Z", {
isApiErrorMessage: true,
message: {
role: "assistant",
model: "<synthetic>",
content: [],
stop_reason: "end_turn",
},
}),
),
);
expect(facts.model).toBe("claude-a");
expect(scan(lines(user("x", "2026-01-01T00:00:00Z"))).model).toBeUndefined();
});
it("knows forkedFrom only from a fork's records", () => {
const parent = "33333333-3333-4333-8333-333333333333";
expect(
scan(
lines(
user("x", "2026-01-01T00:00:00Z", {
forkedFrom: { sessionId: parent, messageUuid: "m" },
}),
),
).forkedFrom,
).toBe(parent);
expect(scan(lines(user("x", "2026-01-01T00:00:00Z"))).forkedFrom).toBeUndefined();
});
});
describe("lastPromptAt of a media prompt", () => {
const media = (type: "image" | "document", timestamp: string, extra: object = {}) => ({
type: "user",
sessionId: SESSION,
timestamp,
message: {
role: "user",
content: [{ type, source: { type: "base64", media_type: "x", data: "x" } }],
},
...extra,
});
it("counts an image or a document as a prompt", () => {
for (const type of ["image", "document"] as const) {
const facts = scan(
lines(
user("Fix it", "2026-01-01T00:00:00Z"),
assistant("end_turn", "2026-01-01T00:00:01Z"),
media(type, "2026-01-01T00:00:02Z"),
assistant("end_turn", "2026-01-01T00:00:03Z"),
),
);
expect(facts.lastPromptAt).toBe(Date.parse("2026-01-01T00:00:02Z"));
}
});
it("does not count media in a tool result or a meta record", () => {
const facts = scan(
lines(
user("Fix it", "2026-01-01T00:00:00Z"),
{
type: "user",
sessionId: SESSION,
timestamp: "2026-01-01T00:00:02Z",
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "t", content: [{ type: "image", source: {} }] },
],
},
},
media("image", "2026-01-01T00:00:03Z", { isMeta: true }),
),
);
expect(facts.lastPromptAt).toBe(Date.parse("2026-01-01T00:00:00Z"));
});
});
describe("directory listings and stats", () => {
it("re-reads a listing when an entry is added or removed, not otherwise", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "listing-"));
const listings = new DirListings();
await fs.writeFile(path.join(dir, "a.jsonl"), "x");
// A directory changed in the last seconds is read again each time: its
// mtime may not show a second change in the same second.
const old = new Date(Date.now() - 60_000);
await fs.utimes(dir, old, old);
expect(await listings.names(dir)).toEqual(["a.jsonl"]);
await fs.writeFile(path.join(dir, "hidden.jsonl"), "x");
await fs.utimes(dir, old, old);
// Same mtime: the cached listing stands (an append changes none either).
expect(await listings.names(dir)).toEqual(["a.jsonl"]);
await fs.rm(path.join(dir, "hidden.jsonl"));
await fs.writeFile(path.join(dir, "B.jsonl"), "x");
const later = new Date(Date.now() - 30_000);
await fs.utimes(dir, later, later);
expect((await listings.names(dir)).sort()).toEqual(["B.jsonl", "a.jsonl"]);
// A change just now is seen at once.
await fs.rm(path.join(dir, "a.jsonl"));
expect(await listings.names(dir)).toEqual(["B.jsonl"]);
await fs.writeFile(path.join(dir, "c.jsonl"), "x");
expect((await listings.names(dir)).sort()).toEqual(["B.jsonl", "c.jsonl"]);
await fs.rm(dir, { recursive: true, force: true });
expect(await listings.names(dir)).toEqual([]);
});
it("re-reads a directory replaced by another with the same mtime", async () => {
const parent = await fs.mkdtemp(path.join(os.tmpdir(), "listing-swap-"));
const dir = path.join(parent, "project");
const other = path.join(parent, "restored");
await fs.mkdir(dir);
await fs.mkdir(other);
await fs.writeFile(path.join(dir, "a.jsonl"), "x");
await fs.writeFile(path.join(other, "b.jsonl"), "x");
const old = new Date(Date.now() - 60_000);
await fs.utimes(dir, old, old);
await fs.utimes(other, old, old);
const listings = new DirListings();
expect(await listings.names(dir)).toEqual(["a.jsonl"]);
await fs.rm(dir, { recursive: true });
await fs.rename(other, dir);
await fs.utimes(dir, old, old);
expect(await listings.names(dir)).toEqual(["b.jsonl"]);
await fs.rm(parent, { recursive: true, force: true });
});
it("stats many files in order, bounded, and reports missing ones", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "stats-"));
const files = await Promise.all(
Array.from({ length: 1500 }, async (_, i) => {
const file = path.join(dir, `${i}.txt`);
await fs.writeFile(file, "x".repeat(i % 7));
return file;
}),
);
const [all, again] = await Promise.all([
statFiles([...files, path.join(dir, "missing")]),
statFiles(files.slice(0, 10)),
]);
expect(all.slice(0, 1500).map((stats) => stats?.size)).toEqual(files.map((_, i) => i % 7));
expect(all[1500]).toBeUndefined();
expect(again.map((stats) => stats?.size)).toEqual(files.slice(0, 10).map((_, i) => i % 7));
await fs.rm(dir, { recursive: true, force: true });
});
});
describe("session id lookup", () => {
it("matches a session id exactly, in no other case", async () => {
const config = await fs.mkdtemp(path.join(os.tmpdir(), "si-exact-id-"));
const previous = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = config;
try {
const dir = path.join(config, "projects", "-repo");
const id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
await fs.mkdir(path.join(dir, id), { recursive: true });
const file = path.join(dir, `${id}.jsonl`);
await fs.writeFile(file, lines(user("hi", "2026-01-01T00:00:00.000Z")) + "\n");
const index = new SessionIndex();
expect(await index.findTranscripts(id)).toEqual([file]);
expect(await index.scanSession(id)).toEqual({
transcripts: [{ filePath: file, size: expect.any(Number) }],
sessionDirs: [path.join(dir, id)],
});
const upper = id.toUpperCase();
expect(await index.findTranscripts(upper)).toEqual([]);
expect(await index.scanSession(upper)).toEqual({ transcripts: [], sessionDirs: [] });
} finally {
if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = previous;
await fs.rm(config, { recursive: true, force: true });
}
});
});
describe("archive titles (AIR's Claude format)", () => {
const id = "0b0e6c2e-1d55-4c1f-9b4e-2b1d7f6f1a11";
it("tells an archived title by its prefix once normalized", () => {
for (const title of ["[archived] Done", " [archived]\n Done", "[archived]\tx"]) {
expect(isArchivedTitle(title)).toBe(true);
}
for (const title of [undefined, "Done", "[archived]", "[archived] ", "x [archived] y"]) {
expect(isArchivedTitle(title)).toBe(false);
}
expect(visibleTitle("[archived] Done")).toBe("Done");
});
it("stores the visible title normalized, with the prefix when archived, cut to 200", () => {
expect(storedTitle(" Fix\n the parser ", true, id)).toBe("[archived] Fix the parser");
expect(storedTitle("[archived] Fix it", false, id)).toBe("Fix it");
expect(storedTitle("[archived] Fix it", true, id)).toBe("[archived] Fix it");
expect(storedTitle("", true, id)).toBe("[archived] Session 0b0e6c2e");
expect(storedTitle(`A${" ".repeat(5000)}B`, true, id)).toBe("[archived] A B");
expect(storedTitle("[archived] [archived] X", false, id)).toBe("X");
expect(storedTitle("[archived] ".repeat(10_000) + "X", true, id)).toBe("[archived] X");
expect(storedTitle(`${"a".repeat(188)} ${"b".repeat(20)}`, true, id)).toBe(
`[archived] ${"a".repeat(188)}`,
);
});
it("writes the custom-title and agent-name records with AIR's fields", () => {
expect(titleRecords(id, "[archived] T")).toBe(
`{"type":"custom-title","customTitle":"[archived] T","sessionId":"${id}"}\n` +
`{"type":"agent-name","agentName":"[archived] T","sessionId":"${id}"}\n`,
);
});
it("takes the custom title the SDK reports, never an agent name", () => {
const lines = (...records: object[]) => records.map((r) => JSON.stringify(r)).join("\n");
const text = lines(
{ type: "custom-title", customTitle: "Custom" },
{ type: "agent-name", agentName: "[archived] Agent" },
);
expect(sdkTitles({ head: text, tail: text })).toEqual({
customTitle: "Custom",
summary: "Custom",
});
// The tail wins over the sidecar, the sidecar over the head.
const head = lines({ type: "custom-title", customTitle: "Head" });
const tail = lines({ type: "custom-title", customTitle: "Tail" });
expect(sdkTitles({ head, tail }, "Sidecar").customTitle).toBe("Tail");
expect(sdkTitles({ head, tail: "" }, "Sidecar").customTitle).toBe("Sidecar");
expect(sdkTitles({ head, tail: "" }).customTitle).toBe("Head");
// A generated title is no custom title.
const prompt = lines(user("[archived] Looks archived", "2026-01-01T00:00:00Z"));
expect(sdkTitles({ head: prompt, tail: prompt })).toEqual({
customTitle: undefined,
summary: "[archived] Looks archived",
});
});
it("reads the last top-level agent name of the tail, else of the head", () => {
const lines = (...records: object[]) => records.map((r) => JSON.stringify(r)).join("\n");
const head = lines({ type: "agent-name", agentName: "Head" });
const tail = lines(
{ type: "agent-name", agentName: "First" },
{ type: "agent-name", agentName: "Last" },
{ type: "agent-name", agentName: " " },
{ type: "user", toolUseResult: { agentName: "Nested" } },
{ type: "custom-title", customTitle: "Custom" },
);
expect(transcriptAgentName({ head, tail })).toBe("Last");
expect(
transcriptAgentName({ head, tail: lines({ type: "custom-title", customTitle: "C" }) }),
).toBe("Head");
expect(transcriptAgentName({ head: "", tail: "" })).toBeUndefined();
});
it("decides the archive state by the agent name first, else the custom title", () => {
// The agent name with the prefix, the custom title without: archived.
expect(
effectiveTitle("[archived] Agent", { customTitle: "Custom", summary: "Custom" }),
).toEqual({ title: "[archived] Agent", archived: true });
// The agent name without the prefix, the custom title with it: not archived.
expect(
effectiveTitle("Agent", { customTitle: "[archived] Custom", summary: "[archived] Custom" }),
).toEqual({ title: "Agent", archived: false });
// No agent name: the custom title decides.
expect(
effectiveTitle(undefined, { customTitle: "[archived] Custom", summary: "[archived] Custom" }),
).toEqual({ title: "[archived] Custom", archived: true });
expect(effectiveTitle(undefined, { customTitle: "Custom", summary: "Custom" })).toEqual({
title: "Custom",
archived: false,
});
// A generated title is no name.
expect(effectiveTitle(undefined, { summary: "[archived] Prompt" })).toEqual({
title: "[archived] Prompt",
archived: false,
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { appendTitleContext } from "../session-titles.js";
import { appendTitleContext, sanitizeTitle } from "../session-titles.js";
import { Pushable } from "../utils.js";
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
import {
@@ -17,6 +17,36 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
return { ...actual, getSessionInfo: vi.fn() };
});
describe("sanitizeTitle", () => {
/** The sanitizer before it learned to read only the start of a long text. */
function fullScanTitle(text: string): string {
const sanitized = text
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return sanitized.length <= 256 ? sanitized : sanitized.slice(0, 255) + "…";
}
it("gives a long text the same title as a scan of the whole text", () => {
const texts = [
"lorem ipsum dolor sit amet\n".repeat(400_000),
" \n\t".repeat(3000) + "word ".repeat(2000),
// The cut of the scanned prefix falls inside a run of whitespace.
"x".repeat(300) + " ".repeat(5000) + "tail",
// Mostly whitespace: the prefix collapses below a title's length.
"a ".repeat(100) + " ".repeat(10_000) + "b".repeat(300),
" ".repeat(10_000) + "short",
"\u00a0\ufeff".repeat(2100) + "😀".repeat(2000),
"y".repeat(4095) + "😀".repeat(10),
"z".repeat(257) + " ".repeat(4096),
"z".repeat(256) + " ".repeat(4096),
];
for (const text of texts) {
expect(sanitizeTitle(text)).toBe(fullScanTitle(text));
}
});
});
describe("SDK title generation contract", () => {
// Smoke test for the one undeclared SDK method this feature rests on:
// `generateSessionTitle` is on the runtime `Query` class but absent from

View File

@@ -5,7 +5,7 @@ import * as path from "node:path";
import type { ClientCapabilities } from "@agentclientprotocol/sdk";
import { AcpClient, toAcpNotifications, ToolUseCache } from "../acp-agent.js";
import { ClientCapabilities as ToolCallCapabilities } from "../tool-calls/client-capabilities.js";
import { backgroundedBashToolCall } from "../tool-calls/background.js";
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
import { ChangedMetaFilter } from "../tool-calls/changed-meta-filter.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
@@ -738,4 +738,29 @@ describe("background Bash", () => {
it("sends no marker to a client without the asyncTasks capability", () => {
expect(backgroundedBashToolCall(completed, new Set(["t"]), false)).toBe(completed);
});
const exit = { terminal_id: "t", exit_code: null, signal: null };
it("keeps the terminal of a command in the background running", () => {
const withExit = {
...completed,
update: {
...completed.update,
_meta: { claudeCode: { toolName: "Bash" }, terminal_exit: exit },
},
};
expect(withRunningTerminal(withExit, "t")).toEqual({
...completed,
update: { ...completed.update, _meta: { claudeCode: { toolName: "Bash" } } },
});
expect(withRunningTerminal(withExit, "other")).toBe(withExit);
});
it("leaves out a `_meta` that held only the terminal's exit, which v2 would store as empty", () => {
const onlyExit = {
...completed,
update: { ...completed.update, _meta: { terminal_exit: exit } },
};
expect(withRunningTerminal(onlyExit, "t")).toEqual(completed);
});
});

View File

@@ -3820,6 +3820,63 @@ describe("tool_result_meta non-execution stamping", () => {
});
});
it("reports an interrupted command as cancelled to a v2 client, and as completed to every other client", () => {
const status = (interrupted: boolean, toolCallCapabilities?: ToolCallCapabilities) =>
toAcpNotifications(
[{ type: "tool_result", tool_use_id: "toolu_bash", content: "partial" }] as any,
"user",
"test-session",
{ toolu_bash: bashToolUse },
mockClient,
mockLogger,
{
toolUseResult: { stdout: "partial", stderr: "", interrupted, isImage: false },
...(toolCallCapabilities ? { toolCallCapabilities } : {}),
},
).at(-1)?.update;
const v2 = ToolCallCapabilities.from({}, { v2: true });
expect(status(true, v2)).toMatchObject({ status: "cancelled" });
expect(status(false, v2)).toMatchObject({ status: "completed" });
// v1 has no cancelled status.
expect(status(true)).toMatchObject({ status: "completed" });
expect(status(true, ToolCallCapabilities.from(AIR_CLIENT))).toMatchObject({
status: "completed",
});
});
it("closes a tool call that Claude dropped before it ran as cancelled on v2, and as failed elsewhere", () => {
const unfinished = (ran: boolean, capabilities?: ToolCallCapabilities) =>
new AcpToolCallRenderer(capabilities).unfinished({ toolCallId: "toolu_bash", ran });
const v2 = ToolCallCapabilities.from({}, { v2: true });
expect(unfinished(false, v2)).toEqual({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_bash",
status: "cancelled",
content: [
{
type: "content",
content: { type: "text", text: "Claude stopped this tool call before it ran." },
},
],
});
// A tool that ran but returned no result failed, also on v2.
expect(unfinished(true, v2)).toMatchObject({
status: "failed",
content: [
{
content: { text: "Claude ended the turn without returning a result for this tool." },
},
],
});
// v1 has no cancelled status.
expect(unfinished(false)).toMatchObject({ status: "failed" });
expect(unfinished(false, ToolCallCapabilities.from(AIR_CLIENT))).toMatchObject({
status: "failed",
});
});
it("reports an aborted run of a tool call that only a permission request surfaced as cancelled on v2", () => {
// TodoWrite renders as a plan, so only the permission flow emits its tool call.
const todoWrite = { type: "tool_use" as const, id: "toolu_todo", name: "TodoWrite", input: {} };
@@ -4609,3 +4666,102 @@ describe("NotebookEdit", () => {
expect(notifications[0].update).not.toHaveProperty("rawOutput");
});
});
// A plan that the user approves through ExitPlanMode becomes the session's
// markdown plan for a client that supports plan operations.
describe("toAcpNotifications - the plan of an approved ExitPlanMode", () => {
const mockClient = {} as AcpClient;
const mockLogger: Logger = { log: () => {}, error: () => {} };
/** The plan updates for the result of an ExitPlanMode call with `input`. */
function planUpdates(
result: Record<string, unknown> = {},
options: Parameters<typeof toAcpNotifications>[6] = {},
input: Record<string, unknown> = { plan: "Implement it", planFilePath: "/plans/p.md" },
) {
return toAcpNotifications(
[
{
type: "tool_result",
tool_use_id: "toolu_plan",
content: "User has approved your plan. You can now start coding.",
...result,
},
] as any,
"user",
"test-session",
{ toolu_plan: { type: "tool_use", id: "toolu_plan", name: "ExitPlanMode", input } },
mockClient,
mockLogger,
{ clientCapabilities: { plan: {} }, ...options },
)
.filter(({ update }) => update.sessionUpdate === "plan_update")
.map(({ update }) => update);
}
const markdownPlan = (content: string) => ({
sessionUpdate: "plan_update",
plan: { type: "markdown", planId: "plan-mode", content },
});
it("shows the plan of the structured result, which includes the user's edits", () => {
const toolUseResult = { plan: "Implement it carefully", isAgent: false, planWasEdited: true };
expect(planUpdates({}, { toolUseResult })).toEqual([markdownPlan("Implement it carefully")]);
});
it("shows the plan of the input, trimmed, when there is no structured result, as on replay", () => {
expect(planUpdates({}, {}, { plan: "\nImplement it\n" })).toEqual([
markdownPlan("Implement it"),
]);
});
it("shows the plan of the input when the structured result has none", () => {
const toolUseResult = { plan: null, isAgent: false };
expect(planUpdates({}, { toolUseResult })).toEqual([markdownPlan("Implement it")]);
});
it("shows the plan after the tool call's result", () => {
const toolUseResult = { plan: "Implement it", isAgent: false };
const kinds = toAcpNotifications(
[{ type: "tool_result", tool_use_id: "toolu_plan", content: "Approved." }] as any,
"user",
"test-session",
{
toolu_plan: {
type: "tool_use",
id: "toolu_plan",
name: "ExitPlanMode",
input: { plan: "Implement it" },
},
},
mockClient,
mockLogger,
{ clientCapabilities: { plan: {} }, toolUseResult },
).map(({ update }) => update.sessionUpdate);
expect(kinds).toEqual(["tool_call_update", "plan_update"]);
});
it("shows nothing to a client without plan operations", () => {
expect(planUpdates({}, { clientCapabilities: {} })).toEqual([]);
});
it("shows nothing for a plan the user rejected", () => {
expect(planUpdates({ is_error: true, content: "The user chose to keep planning." })).toEqual(
[],
);
});
it("shows nothing for a subagent's plan", () => {
expect(planUpdates({}, { parentToolUseId: "toolu_agent" })).toEqual([]);
expect(planUpdates({}, { toolUseResult: { plan: "Implement it", isAgent: true } })).toEqual([]);
});
it("shows nothing for a plan that waits for its team lead", () => {
const toolUseResult = { plan: "Implement it", isAgent: false, awaitingLeaderApproval: true };
expect(planUpdates({}, { toolUseResult })).toEqual([]);
});
it("shows nothing for an empty plan", () => {
expect(planUpdates({}, {}, { plan: " " })).toEqual([]);
});
});

View File

@@ -6,7 +6,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk";
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import type { TurnEvents } from "../turn-events.js";
import type { ForegroundEvents, TurnEvents } from "../turn-events.js";
import { DEFAULT_CONTEXT_USAGE } from "./helpers.js";
import { randomUUID } from "node:crypto";
@@ -137,8 +137,6 @@ function recordEvents(log: string[] = [], label?: string) {
const push = (entry: string) => log.push(label ? `${label} ${entry}` : entry);
const events: TurnEvents = {
inserted: (messageId) => push(`inserted ${messageId}`),
awaitingUser: () => push("awaitingUser"),
resumed: () => push("resumed"),
ended: (outcome) => {
push(`ended ${outcome.stopReason}`);
done.resolve();
@@ -199,24 +197,37 @@ describe("turn events", () => {
let backgroundAnswer: PromiseWithResolvers<void>;
/** Sees each session update; the agent waits until it returns. */
let onSessionUpdate: (notification: SessionNotification) => Promise<void> | void;
/** What the agent reported of the session's foreground work. */
let foreground: string[];
beforeEach(async () => {
vi.resetModules();
permissionRequests = [];
backgroundAnswer = Promise.withResolvers<void>();
onSessionUpdate = () => {};
foreground = [];
const { ClaudeAcpAgent } = await import("../acp-agent.js");
agent = new ClaudeAcpAgent({
sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification),
extNotification: async () => {},
requestPermission: async (request: RequestPermissionRequest) => {
permissionRequests.push(request);
await new Promise((resolve) => setTimeout(resolve, 1));
if (request.toolCall.toolCallId === "toolu_background") await backgroundAnswer.promise;
const allow = request.options.find((option) => option.kind === "allow_once");
return { outcome: { outcome: "selected", optionId: allow!.optionId } };
},
} as unknown as AcpClient);
const foregroundEvents: ForegroundEvents = {
running: () => foreground.push("running"),
awaitingUser: () => foreground.push("awaitingUser"),
ended: (outcome) => foreground.push(`ended ${outcome.stopReason}`),
failed: () => foreground.push("failed"),
};
agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification),
extNotification: async () => {},
requestPermission: async (request: RequestPermissionRequest) => {
permissionRequests.push(request);
await new Promise((resolve) => setTimeout(resolve, 1));
if (request.toolCall.toolCallId === "toolu_background") await backgroundAnswer.promise;
const allow = request.options.find((option) => option.kind === "allow_once");
return { outcome: { outcome: "selected", optionId: allow!.optionId } };
},
} as unknown as AcpClient,
undefined,
{ foreground: () => foregroundEvents },
);
});
afterEach(async () => {
@@ -347,7 +358,78 @@ describe("turn events", () => {
await turn.done;
expect(permissionRequests).toHaveLength(1);
expect(turn.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
expect(turn.log.slice(1)).toEqual(["ended end_turn"]);
expect(foreground).toEqual(["running", "awaitingUser", "running", "ended end_turn"]);
});
it("reports waiting on a request that Claude Code opens for a started turn before its echo", async () => {
let whileOpen: string[] = [];
scriptQuery(async function* (input, options) {
const { value } = await input.next();
yield lifecycle(value.uuid, "started", options.sessionId!);
// Claude Code asks for the prompt before the consumer reaches its echo.
// The user answers it only once the test resolves `backgroundAnswer`.
const decision = options.canUseTool!(
"Bash",
{ command: "ls" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
);
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
whileOpen = [...foreground];
backgroundAnswer.resolve();
await decision;
yield echo(value, options.sessionId!);
yield {
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_background", content: "README.md" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
yield result(options.sessionId!);
await input.next();
});
const sessionId = await newSession();
const turn = recordEvents();
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
await turn.done;
expect(whileOpen).toEqual(["running", "awaitingUser"]);
expect(foreground).toEqual(["running", "awaitingUser", "running", "ended end_turn"]);
});
it("reports work that starts while a request is open as waiting at once", async () => {
let atStart: string[] = [];
scriptQuery(async function* (input, options) {
const { value } = await input.next();
// A request that no work opened, such as a background task's. The user
// answers it only once the test resolves `backgroundAnswer`.
const background = options.canUseTool!(
"Bash",
{ command: "sleep 60" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
);
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
yield lifecycle(value.uuid, "started", options.sessionId!);
// Before the prompt's echo, which would report the request too.
atStart = [...foreground];
backgroundAnswer.resolve();
await background;
yield echo(value, options.sessionId!);
yield result(options.sessionId!);
await input.next();
});
const sessionId = await newSession();
const turn = recordEvents();
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
await turn.done;
expect(atStart).toEqual(["running", "awaitingUser"]);
});
it("awaits the user while any request in the session is open, also one opened before the turn", async () => {
@@ -368,13 +450,13 @@ describe("turn events", () => {
);
},
async function* (options) {
atStart = [...second.log];
atStart = [...foreground];
await options.canUseTool(
"Bash",
{ command: "ls" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
);
afterOwnAnswer = [...second.log];
afterOwnAnswer = [...foreground];
await backgroundRequest;
yield {
type: "user",
@@ -399,11 +481,19 @@ describe("turn events", () => {
await second.done;
expect(first.log.slice(1)).toEqual(["ended end_turn"]);
// Waiting from insertion, through its own answered request, until the
// earlier request is answered too.
expect(atStart.slice(1)).toEqual(["awaitingUser"]);
expect(afterOwnAnswer!.slice(1)).toEqual(["awaitingUser"]);
expect(second.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
// The first turn's work ended before the background request opened, and
// the second turn waits from insertion, through its own answered request,
// until the earlier request is answered too.
const firstWork = ["running", "ended end_turn"];
expect(atStart).toEqual([...firstWork, "running", "awaitingUser"]);
expect(afterOwnAnswer).toEqual([...firstWork, "running", "awaitingUser"]);
expect(foreground).toEqual([
...firstWork,
"running",
"awaitingUser",
"running",
"ended end_turn",
]);
});
it("ends a queued turn that is cancelled before it is inserted, without inserting it", async () => {

View File

@@ -42,3 +42,33 @@ export function backgroundedBashToolCall(
},
};
}
/**
* Keeps the display terminal of a command that went to the background running:
* drops `terminal_exit` from the tool result update of `toolCallId`.
*
* The Bash call returns as soon as the command detaches, so that exit would
* mark the terminal as ended while the command still runs. The consumer sends
* the exit when the command's task ends (see `Session.backgroundTerminals`).
*/
export function withRunningTerminal(
notification: SessionNotification,
toolCallId: string | undefined,
): SessionNotification {
const update = notification.update;
if (
toolCallId === undefined ||
update.sessionUpdate !== "tool_call_update" ||
update.toolCallId !== toolCallId ||
!update._meta ||
!("terminal_exit" in update._meta)
) {
return notification;
}
const meta = { ...update._meta };
delete meta.terminal_exit;
const next: typeof update = { ...update, _meta: meta };
// An empty `_meta` would clear what a v2 client stores for the tool call.
if (Object.keys(meta).length === 0) delete next._meta;
return { ...notification, update: next };
}

View File

@@ -44,11 +44,13 @@ export type ToolUpdateMeta = {
claudeCode?: {
/* The name of the tool that was used in Claude Code. Also carried as the
standard ACP `name` field on the initial `tool_call`. A progress beat
for AIR leaves it out when the adapter does not know the tool. */
for AIR leaves it out when the adapter does not know the tool. A v2
client gets it as `name` alone (see `V2ToolCallMeta`). */
toolName?: string;
/* The structured output provided by Claude Code. For an AIR client, a
PostToolUse update carries only the `status` and `isAsync` markers of
the tool_response: the tool output itself is in the tool-call content. */
/* The structured output provided by Claude Code. For an AIR or a v2
client, a PostToolUse update carries only the `status` and `isAsync`
markers of the tool_response: the tool output itself is in the
tool-call content. */
toolResponse?: unknown;
/* For a tool call made inside a subagent: the tool_use id of the
Agent/Task call that spawned the subagent. Mirrors the SDK's
@@ -151,14 +153,31 @@ export class AcpToolCallRenderer {
}
/**
* The status of a tool result. `nonExecution` is why Claude Code stamped an
* error result instead of the tool's output. A run that was aborted is
* `cancelled` for a v2 client, which has that status; every other client
* gets `failed`, with the kind in `_meta.claudeCode.nonExecutionKind`.
* Whether the client keeps a tool call's `_meta` across reports: AIR merges
* its keys, and v2 stores it. Such a client gets no `toolResponse` text that
* the tool call's content already shows, which it would otherwise keep.
*/
resultStatus(isError: boolean, nonExecution: ToolResultMeta | undefined): ToolCallStatus {
if (!isError) return "completed";
return this.capabilities.v2 && isAbortedToolResult(nonExecution) ? V2_CANCELLED : "failed";
private get keepsMeta(): boolean {
return this.capabilities.air.client || this.capabilities.v2;
}
/**
* The status of a tool result. Claude Code reports a run that it aborted in
* one of two ways: an error result whose `nonExecution` kind says so (a
* cancelled turn), or a command result that says `interrupted` (for example,
* a steering message landed just before the command started). Such a run is
* `cancelled` for a v2 client, which has that status. Every other client gets
* `failed` for the error, with the kind in `_meta.claudeCode.nonExecutionKind`,
* and `completed` for the command.
*/
resultStatus(
isError: boolean,
nonExecution: ToolResultMeta | undefined,
interrupted = false,
): ToolCallStatus {
const aborted = interrupted || (isError && isAbortedToolResult(nonExecution));
if (this.capabilities.v2 && aborted) return V2_CANCELLED;
return isError ? "failed" : "completed";
}
/** The facts of a tool use. */
@@ -332,7 +351,14 @@ export class AcpToolCallRenderer {
/** The result fields of a tool result, before the status and the tool name. */
resultFields(toolUse: ToolUse, result: ResultBlock, structured?: unknown): RenderedResult {
const facts = this.resultFacts(toolUse, result, structured);
return this.fieldsOfResult(toolUse, result, this.resultFacts(toolUse, result, structured));
}
private fieldsOfResult(
toolUse: ToolUse,
result: ResultBlock,
facts: ToolResultFacts,
): RenderedResult {
const { command, ...fields } = facts;
if (!command) return fields;
// The terminal was announced under the tool_use id (see `toolCall`), so the
@@ -397,7 +423,8 @@ export class AcpToolCallRenderer {
result: ResultBlock,
options: { structured?: unknown; nonExecution?: ToolResultMeta } = {},
): ToolCallUpdate[] {
const { _meta: resultMeta, ...fields } = this.resultFields(toolUse, result, options.structured);
const facts = this.resultFacts(toolUse, result, options.structured);
const { _meta: resultMeta, ...fields } = this.fieldsOfResult(toolUse, result, facts);
// AIR shows a read or a search that names a path as the list of viewed
// files. That view does not show the text of the result, so it stays out.
const viewedFiles =
@@ -445,7 +472,11 @@ export class AcpToolCallRenderer {
} satisfies ToolUpdateMeta,
toolCallId: toolUse.id,
sessionUpdate: "tool_call_update",
status: this.resultStatus(result.is_error === true, options.nonExecution),
status: this.resultStatus(
result.is_error === true,
options.nonExecution,
facts.command?.interrupted === true,
),
...(rawOutput !== undefined ? { rawOutput } : {}),
...(planFilePath ? { rawInput: planFileInput(toolUse.input, planFilePath) } : {}),
...fields,
@@ -486,7 +517,8 @@ export class AcpToolCallRenderer {
/**
* The report of a PostToolUse hook: the final change of an edit tool, and
* the `status` and `isAsync` markers of the `tool_response`. The rest of the
* `tool_response` repeats output that the content already carries.
* `tool_response` repeats output that the content already carries, such as
* the whole text of a written file.
*/
async hookResult(
toolUse: Pick<ToolUse, "id" | "name">,
@@ -497,7 +529,7 @@ export class AcpToolCallRenderer {
const change = reporter.hookResult
? await reporter.hookResult(toolResponse, { cwd, capabilities: this.capabilities })
: {};
if (!this.capabilities.air.client) {
if (!this.keepsMeta) {
// The upstream shape: the whole tool_response, on every hook.
return {
_meta: { claudeCode: { toolResponse, toolName: toolUse.name } } satisfies ToolUpdateMeta,
@@ -567,9 +599,10 @@ export class AcpToolCallRenderer {
message?: string;
}): ToolCallUpdate {
const reason = denial.decisionReason ?? denial.message;
// AIR gets the SDK message only when it differs from the reason, which
// the content shows. Every other client gets the upstream toolResponse.
const extraMessage = !this.capabilities.air.client
// AIR and v2 get the SDK message only when it differs from the reason,
// which the content shows. Every other client gets the upstream
// toolResponse.
const extraMessage = !this.keepsMeta
? { decisionReason: denial.decisionReason, message: denial.message }
: denial.decisionReason !== undefined &&
denial.message !== undefined &&
@@ -591,6 +624,29 @@ export class AcpToolCallRenderer {
};
}
/**
* The report that closes a tool call when the turn ends without its result.
* If Claude dropped it before it ran (`ran` is false: its tool_use never
* reached a complete assistant message, for example because Claude switched
* to a steering message), a v2 client gets `cancelled` and every other
* client `failed`, since v1 has no `cancelled`. If Claude Code ran it but
* returned no result, it failed.
*/
unfinished(call: { toolCallId: string; ran: boolean }): ToolCallUpdate {
return {
sessionUpdate: "tool_call_update",
toolCallId: call.toolCallId,
status: !call.ran && this.capabilities.v2 ? V2_CANCELLED : "failed",
content: [
textContent(
call.ran
? "Claude ended the turn without returning a result for this tool."
: "Claude stopped this tool call before it ran.",
),
],
};
}
/**
* The report of a tool progress beat. The field tracker sends the
* `in_progress` status once, so a later beat carries only the progress.

View File

@@ -1,10 +1,11 @@
/**
* A prompt turn as the adapter understands it, independent of the ACP version.
* A prompt turn and the foreground work of a session, as the adapter
* understands them, independent of the ACP version.
*
* The agent reports each turn through {@link TurnEvents}, and each ACP version
* maps the events to its own messages. ACP v1 answers `session/prompt` when the
* turn ends or fails. ACP v2 answers it when the prompt is inserted, and
* reports the rest as session state.
* The agent reports how each prompt is answered through {@link TurnEvents}:
* ACP v1 answers `session/prompt` when the turn ends or fails, and ACP v2 when
* the prompt is inserted. ACP v2 also reports the session's work as state,
* which the agent reports through {@link ForegroundEvents}.
*
* The agent calls these synchronously while it updates its own state, so a
* handler must not throw, nor call back into the agent before it returns.
@@ -23,18 +24,6 @@ export interface TurnEvents {
* `/compact`), it is the command's result, after any output it streamed.
*/
inserted(messageId: string): void;
/**
* The turn waits for the user: a permission request or a question is open in
* the session. Requests do not say which prompt they are for, so this
* includes one opened before the turn was inserted. Only reported after
* {@link inserted}.
*/
awaitingUser(): void;
/**
* The turn works again: no request is open any more. A turn that ends or
* fails while it waits reports that instead.
*/
resumed(): void;
/**
* The turn ended. A queued turn that is cancelled before it is inserted
* ends too, without {@link inserted}; Claude Code may still run its prompt.
@@ -48,6 +37,40 @@ export interface TurnEvents {
failed(error: unknown): void;
}
/**
* The foreground work of a session: what a client would wait for before it
* sends the next prompt. One piece of work runs at a time, and the next starts
* with {@link running}. The work is either:
*
* - A prompt's turn, from {@link TurnEvents.inserted} until its result, or
* until it fails or is cancelled. A turn that v1 holds open for the
* background subagents it spawned (`Turn.deferredSettle`) ends its work at
* its result all the same: the hold only delays v1's answer.
* - A model cycle that Claude Code starts on its own while no work runs: for
* example, its follow-up after a background command or subagent finished,
* or its answer to a message from another session. It runs until its result.
*
* A prompt that Claude Code takes in while a cycle runs takes the cycle over:
* the cycle reports nothing more, and the turn reports from there.
*/
export interface ForegroundEvents {
/** The work started, or works again after it waited for the user. */
running(): void;
/**
* The work waits for the user: a permission request or a question is open in
* the session. Requests do not say which work they are for, so this includes
* one opened before the work started.
*/
awaitingUser(): void;
/** The work ended. */
ended(outcome: TurnOutcome): void;
/**
* The work failed with `error`, the JSON-RPC error that v1 answers the
* prompt with.
*/
failed(error: unknown): void;
}
/** Why a turn ended. Every ACP version has these reasons. */
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";

View File

@@ -45,12 +45,20 @@ export class Pushable<T> implements AsyncIterable<T> {
}
}
// Helper to convert Node.js streams to Web Streams
// Helper to convert Node.js streams to Web Streams. A byte stream gets each
// chunk without a copy, so the caller must not change a chunk until its write
// resolves.
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
nodeStream.write(Buffer.from(chunk), (err) => {
// A Uint8Array is written as it is: Node wraps it in a Buffer view
// without a copy. The ACP encoder hands each message a fresh array,
// so nothing changes the bytes while the write is pending. An
// object-mode stream would get the array itself, so it keeps
// getting a Buffer copy, as before.
const data = nodeStream.writableObjectMode ? Buffer.from(chunk) : chunk;
nodeStream.write(data, (err) => {
if (err) {
reject(err);
} else {
@@ -84,6 +92,58 @@ export function unreachable(value: never, logger: Logger = console) {
logger.error(`Unexpected case: ${valueAsString}`);
}
/**
* Yields to the event loop when the current macrotask has run for too long.
*
* An await of an already-resolved promise continues in a microtask, so a loop
* that drains a backlog of buffered messages never lets timers or I/O run:
* incoming requests, `session/cancel` among them, wait until the backlog is
* gone. A loop calls {@link maybeYield} once per item. Within the budget it
* returns undefined and the loop goes on at once. Past the budget it returns a
* promise that resolves in the check phase of the event loop, after pending
* I/O has run.
*
* The budget is measured from the first call since the event loop last
* reached its check phase, so a loop that already waits on real I/O never
* yields, and all loops that share one instance share one budget per
* event-loop iteration.
*
* A test that fakes `setImmediate` and drives such a loop for longer than the
* budget must advance the fake timers, or the loop waits for them.
*/
export class EventLoopYielder {
/** Whether a check-phase marker is pending. While it is, the event loop has
* not reached its check phase since {@link sliceStart}. */
private markerPending = false;
private sliceStart = 0;
constructor(
private readonly budgetMs: number,
private readonly now: () => number = () => performance.now(),
) {}
maybeYield(): Promise<void> | undefined {
const time = this.now();
if (!this.markerPending) {
this.markerPending = true;
this.sliceStart = time;
setImmediate(() => {
this.markerPending = false;
});
return undefined;
}
if (time - this.sliceStart < this.budgetMs) return undefined;
return new Promise<void>((resolve) => setImmediate(resolve));
}
}
/** The longest stretch, in milliseconds, that a message loop keeps the event
* loop before it yields. */
const MESSAGE_LOOP_BUDGET_MS = 8;
/** The yielder shared by the loops that forward messages to the client. */
export const messageLoopYielder = new EventLoopYielder(MESSAGE_LOOP_BUDGET_MS);
export function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}

View File

@@ -19,9 +19,9 @@ import type { AcpSessionNotification } from "../acp-subagents.js";
import type { AcpPermissionRequest } from "../permissions/presentation.js";
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
import { v1PermissionResponse, v2PermissionRequest } from "./permission.js";
import { v2Prompt } from "./prompt.js";
import { v2Foreground, v2Prompt } from "./prompt.js";
import {
v1ForkSessionRequests,
v1ForkSessionRequest,
v1NewSessionRequest,
v1RestoreSessionRequest,
v1SetSessionConfigOptionRequest,
@@ -32,6 +32,7 @@ import {
} from "./session.js";
import { v2SessionUpdate } from "./session-update.js";
import { V2Terminals } from "./terminal.js";
import { V2ToolCallMeta } from "./tool-call-meta.js";
/**
* The ACP v2 surface, for one connection.
@@ -52,7 +53,13 @@ export function v2AgentApp(
.agent({ name: "claude-code-acp" })
.onConnect((connection) => {
client = new V2ClientConnection(connection.client, logger ?? console);
agent = new ClaudeAcpAgent(client, logger, { v2: true });
agent = new ClaudeAcpAgent(client, logger, {
v2: true,
foreground: (sessionId) =>
v2Foreground((update) => {
void client.send({ sessionId, update });
}),
});
onAgent(agent);
})
.onRequest(v2.methods.agent.initialize, async ({ params }) =>
@@ -87,17 +94,18 @@ export function v2AgentApp(
};
})
.onRequest(v2.methods.agent.session.fork, async ({ params }) => {
const requests = v1ForkSessionRequests(params);
const { sessionId } = await agent.unstable_forkSession(requests.fork);
// The agent starts the forked session, so the client can prompt it.
const response = await agent.unstable_forkSession(v1ForkSessionRequest(params));
return {
sessionId,
...v2ResumeSessionResponse(await agent.resumeSession(requests.resume(sessionId))),
...(await setupCommands(agent, sessionId, logger)),
sessionId: response.sessionId,
...v2ResumeSessionResponse(response),
...(await setupCommands(agent, response.sessionId, logger)),
};
})
.onRequest(v2.methods.agent.session.close, async ({ params }) => {
const response = await agent.closeSession(params);
client.terminals.forget(params.sessionId);
client.toolCallMeta.forget(params.sessionId);
return response;
})
.onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params))
@@ -151,6 +159,9 @@ class V2ClientConnection implements AcpClient {
/** The display terminals of the commands that the agent runs. */
readonly terminals = new V2Terminals();
/** The `_meta` that the client stores for each tool call. */
readonly toolCallMeta = new V2ToolCallMeta();
constructor(
private readonly ctx: v2.AgentContext,
private readonly logger: Logger,
@@ -211,7 +222,8 @@ class V2ClientConnection implements AcpClient {
/**
* The v2 updates of a v1 update: for a tool call report, the updates of its
* terminal first, then the report itself (see {@link V2Terminals}).
* terminal first (see {@link V2Terminals}), then the report itself, with the
* `_meta` that the client stores (see {@link V2ToolCallMeta}).
*/
private v2Updates(
sessionId: string,
@@ -222,7 +234,9 @@ class V2ClientConnection implements AcpClient {
return v2Update ? [v2Update] : [];
}
const { terminal, report } = this.terminals.split(sessionId, update);
const reportUpdate = report && v2SessionUpdate(report);
if (!report) return terminal;
const starts = report.sessionUpdate === "tool_call";
const reportUpdate = v2SessionUpdate(this.toolCallMeta.apply(sessionId, report, starts));
return [...terminal, ...(reportUpdate ? [reportUpdate] : [])];
}
@@ -246,9 +260,11 @@ class V2ClientConnection implements AcpClient {
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
// The subject is an update of the tool call, with its stored `_meta`.
const toolCall = this.toolCallMeta.subject(params.sessionId, params.toolCall);
const response = await this.ctx.request(
v2.methods.client.session.requestPermission,
v2PermissionRequest(params),
v2PermissionRequest({ ...params, toolCall }),
{ cancellationSignal: signal },
);
return v1PermissionResponse(response);

View File

@@ -32,6 +32,10 @@ export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRe
// compaction as a compaction entity rather than as a "Compact
// conversation" tool call, also on replay.
session: { configOptions: { boolean: {} }, notices: {}, compaction: {} },
// Every v2 client takes `plan_update`, with the draft's markdown and file
// plans, and `plan_removed`; v1 asks for the `plan` marker. With it, the
// agent shows an approved ExitPlanMode plan as a markdown plan.
plan: {},
// v1 marks terminal auth support with a boolean, v2 with an object.
auth: {
terminal: capabilities?.auth?.terminal != null,
@@ -89,7 +93,7 @@ export function v2InitializeResponse(response: InitializeResponse): v2.Initializ
* - The session baseline: `session/new`, `list`, `resume` (with replay),
* `close`, `prompt`, `cancel`, and `update`.
* - `session/delete`, `session/fork` (which returns a session the client can
* prompt, see `v1ForkSessionRequests`), and `additionalDirectories`.
* prompt at once), and `additionalDirectories`.
* - The prompt content and MCP transports that the agent's v1 `initialize`
* lists: images, embedded context, and HTTP servers. Stdio servers are in the
* v1 baseline; v2 has no SSE transport.

View File

@@ -13,31 +13,19 @@ import {
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import type { ClaudeAcpAgent } from "../acp-agent.js";
import type { StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
import type { ForegroundEvents, StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
/**
* Starts a turn for `params`, and answers when Claude Code takes the prompt
* in. `send` delivers a session update of the prompt's session; it must not
* throw, and must deliver updates in the order it is called, also relative to
* the agent's own updates.
* in, with the id of the user message it became. `send` delivers a session
* update of the prompt's session; it must not throw, and must deliver updates
* in the order it is called, also relative to the agent's own updates.
*
* - Taken in: the prompt's `user_message`, then `running`, then the answer.
* - Waiting on a permission request or a question: `requires_action`, and
* `running` once none is open.
* - Ended: `idle` with the stop reason, usage, and `_meta` of the turn.
* - Failed after it was taken in: `idle` with the `error` stop reason and the
* JSON-RPC error that v1 answers the prompt with, so a client can show its
* message and handle its code as for an error response (for example,
* `-32000` starts the client's sign-in).
* - Taken in: the prompt's `user_message`, then the answer. From then on, the
* turn is the session's foreground work, which reports its states (see
* {@link v2Foreground}).
* - Ended or failed before it was taken in: the answer is a JSON-RPC error,
* `-32800` for a prompt that a cancel ended in the queue.
*
* Deferred decision: a turn whose background subagents still run ends only
* when they finish (`Turn.deferredSettle`), so `idle` waits for them too. The
* hold exists for v1, whose clients stop listening at the prompt response.
* v2 allows background updates while idle, so it could report `idle` at the
* turn's result instead, with a turn event for the hold. To be decided with
* async tasks (step 6 in `docs/acp-v2.md`).
*/
export function v2Prompt(
agent: ClaudeAcpAgent,
@@ -50,40 +38,21 @@ export function v2Prompt(
inserted(messageId) {
inserted = true;
send({ sessionUpdate: "user_message", messageId, content: params.prompt });
send({ sessionUpdate: "state_update", state: "running" });
resolve({ messageId });
},
awaitingUser() {
send({ sessionUpdate: "state_update", state: "requires_action" });
},
resumed() {
send({ sessionUpdate: "state_update", state: "running" });
},
ended(outcome) {
if (inserted) {
send(idle(outcome));
} else if (outcome.stopReason === "cancelled") {
reject(RequestError.requestCancelled());
} else {
reject(
RequestError.internalError(
{ stopReason: outcome.stopReason },
"The turn ended before Claude Code took the prompt in",
),
);
}
if (inserted) return;
reject(
outcome.stopReason === "cancelled"
? RequestError.requestCancelled()
: RequestError.internalError(
{ stopReason: outcome.stopReason },
"The turn ended before Claude Code took the prompt in",
),
);
},
failed(error) {
if (!inserted) {
reject(error);
return;
}
send({
sessionUpdate: "state_update",
state: "idle",
stopReason: "error",
error: jsonRpcError(error),
});
if (!inserted) reject(error);
},
};
let request: PromptRequest;
@@ -97,6 +66,49 @@ export function v2Prompt(
});
}
/**
* Reports the foreground work of a session (see {@link ForegroundEvents}) as
* its `state_update`s. `send` delivers a session update of that session, as
* for {@link v2Prompt}.
*
* - Started: `running`. For a prompt's turn, right after its `user_message`.
* - Waiting on a permission request or a question: `requires_action`, and
* `running` once none is open.
* - Ended: `idle` with the stop reason, usage, and `_meta` of the work.
* - Failed: `idle` with the `error` stop reason and the JSON-RPC error that v1
* answers the prompt with, so a client can show its message and handle its
* code as for an error response (for example, `-32000` starts the client's
* sign-in).
*
* The work is a prompt's turn or a model cycle that Claude Code started on its
* own, such as its follow-up after a background subagent finished. A turn's
* work ends at its result, also when v1 holds the turn's answer for the
* background subagents it spawned (`Turn.deferredSettle`): v2 allows
* background updates while idle, so the subagents' output and requests arrive
* after `idle`, and their follow-up is a cycle of its own.
*/
export function v2Foreground(send: (update: v2.SessionUpdate) => void): ForegroundEvents {
return {
running() {
send({ sessionUpdate: "state_update", state: "running" });
},
awaitingUser() {
send({ sessionUpdate: "state_update", state: "requires_action" });
},
ended(outcome) {
send(idle(outcome));
},
failed(error) {
send({
sessionUpdate: "state_update",
state: "idle",
stopReason: "error",
error: jsonRpcError(error),
});
},
};
}
/** The JSON-RPC error that v1 answers a failed prompt with. */
function jsonRpcError(error: unknown): { code: number; message: string; data?: unknown } {
if (error instanceof RequestError) {

View File

@@ -55,6 +55,10 @@ export function v2SessionUpdate(
plan: { type: "items", planId: SESSION_PLAN_ID, entries: update.entries },
...(update._meta != null ? { _meta: update._meta } : {}),
};
case "plan_update":
case "plan_removed":
// v1's draft plan operations have v2's shape.
return update;
case "current_mode_update":
// v2 has no modes: the mode is the `mode` config option. Every mode
// change also reaches the client as a `config_option_update` or in the

View File

@@ -60,28 +60,12 @@ export function v1RestoreSessionRequest(
}
/**
* The v1 requests that a v2 `session/fork` means.
*
* The agent's v1 fork copies the transcript into a new session and returns
* only its id; a v1 client then resumes or loads it. A v2 fork returns the new
* session with its config options, like `session/new`, so the client can
* prompt it. The v2 surface therefore forks, then resumes the fork without
* replay, with the request's `cwd`, `additionalDirectories`, and
* `mcpServers`. The request's `_meta` goes to the fork alone.
* The v1 request of a v2 `session/fork`. In both versions the agent's fork
* returns a session that the client can prompt, with its config options.
*/
export function v1ForkSessionRequests(params: v2.ForkSessionRequest): {
fork: ForkSessionRequest;
resume: (sessionId: string) => ResumeSessionRequest;
} {
const { mcpServers, _meta, ...request } = params;
const session = {
...request,
...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}),
};
return {
fork: { ...session, ...(_meta != null ? { _meta } : {}) },
resume: (sessionId) => ({ ...session, sessionId }),
};
export function v1ForkSessionRequest(params: v2.ForkSessionRequest): ForkSessionRequest {
const { mcpServers, ...request } = params;
return { ...request, ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}) };
}
/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */

104
src/v2/tool-call-meta.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* The `_meta` of the tool calls of one ACP v2 connection, as the client stores
* it.
*
* The agent gives each v1 tool call report the `_meta` keys of that report.
* An AIR client merges them by key (see `ToolCallFieldTracker`), and another
* v1 client reads them per report. A v2 client stores a tool call's `_meta`,
* and a report that carries `_meta` replaces it. So the earlier keys must
* travel again, or the `{ toolName }` of a tool's result would replace, for
* example, the reason of the denial before it.
*
* {@link V2ToolCallMeta} therefore keeps the merged `_meta` of each tool call,
* with the `claudeCode` keys merged one by one and every other key replaced
* whole, as `ToolCallFieldTracker` models it. A report that changes it carries
* all of it; a report that changes nothing carries no `_meta`.
*
* `claudeCode.toolName` repeats the name of the tool, which v2 has a field
* for. It becomes the `name` of a tool call that has none yet, and is left out
* of the stored `_meta`. Subagent routing reads it before this, on the agent's
* v1 reports.
*/
import type { ToolCall, ToolCallUpdate } from "@agentclientprotocol/sdk";
type Meta = Record<string, unknown>;
/** The number of tool calls whose `_meta` a connection remembers. */
const TOOL_CALL_LIMIT = 2048;
export class V2ToolCallMeta {
/** The stored `_meta` of each tool call, by session and tool call, oldest first. */
private readonly calls = new Map<string, { meta: Meta; named: boolean }>();
/**
* `report` with the tool call's whole `_meta` when the report changes it,
* without `_meta` when it does not, and with `name` when it names the tool
* call for the first time. `starts` says that the report starts the tool
* call (a v1 `tool_call`), which forgets what an earlier one stored.
*/
apply<Report extends ToolCall | ToolCallUpdate>(
sessionId: string,
report: Report,
starts: boolean,
): Report {
const key = `${sessionId} ${report.toolCallId}`;
const call = (!starts && this.calls.get(key)) || { meta: {}, named: false };
this.remember(key, call);
const { _meta, ...fields } = report;
const result = fields as Report;
const { toolName, ...claudeCode } = asRecord(_meta?.claudeCode) ?? {};
if (report.name != null) {
call.named = true;
} else if (!call.named && typeof toolName === "string") {
result.name = toolName;
call.named = true;
}
const meta: Meta = { ...call.meta };
for (const [name, value] of Object.entries(_meta ?? {})) {
if (name !== "claudeCode") meta[name] = value;
}
if (Object.keys(claudeCode).length > 0) {
meta.claudeCode = { ...asRecord(call.meta.claudeCode), ...claudeCode };
}
if (JSON.stringify(meta) !== JSON.stringify(call.meta)) {
call.meta = meta;
result._meta = meta;
}
return result;
}
/**
* The tool call of a permission request's subject: like {@link apply}, but
* with the tool call's whole `_meta` even when the subject changes nothing,
* since a client can show the request from the request alone.
*/
subject<Report extends ToolCall | ToolCallUpdate>(sessionId: string, toolCall: Report): Report {
const result = this.apply(sessionId, toolCall, false);
const stored = this.calls.get(`${sessionId} ${toolCall.toolCallId}`)?.meta ?? {};
if (result._meta === undefined && Object.keys(stored).length > 0) result._meta = stored;
return result;
}
/** Forgets the tool calls of a session, when it closes. */
forget(sessionId: string): void {
for (const key of this.calls.keys()) {
if (key.startsWith(`${sessionId} `)) this.calls.delete(key);
}
}
/** Stores the entry as the most recent one, and forgets the oldest one past the limit. */
private remember(key: string, call: { meta: Meta; named: boolean }): void {
this.calls.delete(key);
this.calls.set(key, call);
if (this.calls.size > TOOL_CALL_LIMIT) {
const oldest = this.calls.keys().next().value;
if (oldest !== undefined) this.calls.delete(oldest);
}
}
}
function asRecord(value: unknown): Meta | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Meta) : undefined;
}