mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-12 04:12:45 +00:00
Compare commits
30 Commits
v0.88.1-pr
...
feat/subag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7184fd3cfe | ||
|
|
9382e10f2c | ||
|
|
4b67682ff9 | ||
|
|
529718af76 | ||
|
|
69cd49afeb | ||
|
|
30ad5f7372 | ||
|
|
383bc7dcf0 | ||
|
|
9db8a28b64 | ||
|
|
1137df33aa | ||
|
|
d074558e4a | ||
|
|
14b2233192 | ||
|
|
c22ec1d189 | ||
|
|
b007ac7c45 | ||
|
|
412da2789a | ||
|
|
2a8417ab89 | ||
|
|
fe8b69edd4 | ||
|
|
b3bed2f763 | ||
|
|
77910dea74 | ||
|
|
231bbd7dd6 | ||
|
|
4e1fc1e8df | ||
|
|
92abb8fd4d | ||
|
|
96b6b4f478 | ||
|
|
03d84f6663 | ||
|
|
764c5abdf1 | ||
|
|
5588538fe2 | ||
|
|
fd41b69e05 | ||
|
|
bce759a258 | ||
|
|
f826b6025e | ||
|
|
020dab8dce | ||
|
|
e34c447e96 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.88.0"
|
||||
".": "0.89.1"
|
||||
}
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
37
README.md
37
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1262
src/acp-agent.ts
1262
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
43
src/diff.ts
43
src/diff.ts
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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. */
|
||||
|
||||
130
src/session-index/activity.ts
Normal file
130
src/session-index/activity.ts
Normal 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;
|
||||
}
|
||||
96
src/session-index/archive-title.ts
Normal file
96
src/session-index/archive-title.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
459
src/session-index/connection.ts
Normal file
459
src/session-index/connection.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
129
src/session-index/dir-listing.ts
Normal file
129
src/session-index/dir-listing.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
65
src/session-index/explicit-title.ts
Normal file
65
src/session-index/explicit-title.ts
Normal 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--;
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/session-index/first-prompt.ts
Normal file
172
src/session-index/first-prompt.ts
Normal 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 "";
|
||||
}
|
||||
1267
src/session-index/list-subscriptions.ts
Normal file
1267
src/session-index/list-subscriptions.ts
Normal file
File diff suppressed because it is too large
Load Diff
288
src/session-index/live-registry.ts
Normal file
288
src/session-index/live-registry.ts
Normal 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;
|
||||
}
|
||||
133
src/session-index/project-dirs.ts
Normal file
133
src/session-index/project-dirs.ts
Normal 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;
|
||||
}
|
||||
710
src/session-index/service.ts
Normal file
710
src/session-index/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1051
src/session-index/session-index.ts
Normal file
1051
src/session-index/session-index.ts
Normal file
File diff suppressed because it is too large
Load Diff
62
src/session-index/session-info.ts
Normal file
62
src/session-index/session-info.ts
Normal 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]);
|
||||
}
|
||||
631
src/session-index/transcript-scan.ts
Normal file
631
src/session-index/transcript-scan.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
135
src/session-index/worktrees.ts
Normal file
135
src/session-index/worktrees.ts
Normal 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))];
|
||||
}
|
||||
@@ -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
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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"}]}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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
111
src/tests/diff-memo.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
260
src/tests/event-loop-yield.test.ts
Normal file
260
src/tests/event-loop-yield.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
202
src/tests/session-hooks.test.ts
Normal file
202
src/tests/session-hooks.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
954
src/tests/session-index-units.test.ts
Normal file
954
src/tests/session-index-units.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
4346
src/tests/session-index.test.ts
Normal file
4346
src/tests/session-index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
64
src/utils.ts
64
src/utils.ts
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
108
src/v2/prompt.ts
108
src/v2/prompt.ts
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
104
src/v2/tool-call-meta.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user