mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
49 Commits
v0.85.1-pr
...
v0.89.2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77910dea74 | ||
|
|
231bbd7dd6 | ||
|
|
4e1fc1e8df | ||
|
|
92abb8fd4d | ||
|
|
96b6b4f478 | ||
|
|
03d84f6663 | ||
|
|
15034659f7 | ||
|
|
55c3106c60 | ||
|
|
060bdb212a | ||
|
|
446a7217fa | ||
|
|
908c994901 | ||
|
|
966be7abe6 | ||
|
|
d43fec3fa3 | ||
|
|
0304f743f8 | ||
|
|
6a162baf8b | ||
|
|
b2dbc8f5a1 | ||
|
|
390b0b9f8b | ||
|
|
0724b17c58 | ||
|
|
0e83a06c4a | ||
|
|
7b5c61a4ed | ||
|
|
5598efbc8a | ||
|
|
07c7ed3752 | ||
|
|
3a61172e19 | ||
|
|
4ddda8f37e | ||
|
|
96acfeccf9 | ||
|
|
52a92be959 | ||
|
|
8589a9cf15 | ||
|
|
a44c486019 | ||
|
|
d599333b72 | ||
|
|
b1d6cfc6c9 | ||
|
|
3d9bae941c | ||
|
|
98b17c28c8 | ||
|
|
22139f73c4 | ||
|
|
bd8a161c2e | ||
|
|
63aa3b8428 | ||
|
|
b9a6ec00a6 | ||
|
|
c1c8c0377d | ||
|
|
23b8ebf8c2 | ||
|
|
2b7fab7d98 | ||
|
|
d3e673b263 | ||
|
|
d83f28a403 | ||
|
|
ba5ed170ae | ||
|
|
d24272c6b0 | ||
|
|
190a00f68f | ||
|
|
ddd5f54187 | ||
|
|
3d4017b15a | ||
|
|
6b69852cfc | ||
|
|
c60fa48fd8 | ||
|
|
686c0c99b3 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.85.0"
|
||||
".": "0.89.1"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ npm run format # prettier --write
|
||||
CI runs `format:check`, `lint`, `build` and `test:run`. Run `npm run check` before
|
||||
opening a PR.
|
||||
|
||||
## Code comments
|
||||
|
||||
Keep comments straightforward, concise, and concrete. Say what the code does and
|
||||
why, in plain terms: name the actual state, call, or failure involved instead of
|
||||
alluding to it. Prefer a short concrete example over an abstract description.
|
||||
Don't use vague phrases like "keeps it honest" or "the residual case" that make
|
||||
the reader reconstruct the meaning.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Squash merges use the PR title as the commit subject, and release-please parses it
|
||||
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -1,5 +1,78 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1276](https://github.com/agentclientprotocol/claude-agent-acp/issues/1276)) ([0304f74](https://github.com/agentclientprotocol/claude-agent-acp/commit/0304f743f85e2505ad5a67c1fe7b7d24f2f9ad23))
|
||||
|
||||
## [0.87.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.86.0...v0.87.0) (2026-10-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump the minor group with 2 updates ([#1260](https://github.com/agentclientprotocol/claude-agent-acp/issues/1260)) ([0724b17](https://github.com/agentclientprotocol/claude-agent-acp/commit/0724b17c581e30b1242cfbec7bce44c21df99525))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* async task routing, Monitor tasks, structured task ids, and abandoned tool calls ([#1249](https://github.com/agentclientprotocol/claude-agent-acp/issues/1249)) ([0e83a06](https://github.com/agentclientprotocol/claude-agent-acp/commit/0e83a06c4a8825ba654bddf1ab909d1a773942e2))
|
||||
* settle prompts folded into a task-notification cycle ([#1233](https://github.com/agentclientprotocol/claude-agent-acp/issues/1233)) ([390b0b9](https://github.com/agentclientprotocol/claude-agent-acp/commit/390b0b9f8bf0cc0aa7d9273a7b614172c916d3a6))
|
||||
|
||||
## [0.86.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.1...v0.86.0) (2026-10-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* answer /mcp in chat with the MCP server status and a reconnect ([#1218](https://github.com/agentclientprotocol/claude-agent-acp/issues/1218)) ([190a00f](https://github.com/agentclientprotocol/claude-agent-acp/commit/190a00f68f773315e93630464397944e645616ff))
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.287 ([#1224](https://github.com/agentclientprotocol/claude-agent-acp/issues/1224)) ([ddd5f54](https://github.com/agentclientprotocol/claude-agent-acp/commit/ddd5f541879118edb18df7ff09cd6682d455c710))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* count a subagent's permission requests as the turn waiting on the user ([#1241](https://github.com/agentclientprotocol/claude-agent-acp/issues/1241)) ([d599333](https://github.com/agentclientprotocol/claude-agent-acp/commit/d599333b722a951553c0efd40a76559b1f32e6ab))
|
||||
* drop queued prompts a cancel ends from Claude Code's queue ([#1228](https://github.com/agentclientprotocol/claude-agent-acp/issues/1228)) ([d83f28a](https://github.com/agentclientprotocol/claude-agent-acp/commit/d83f28a4035c5a5e54950fd58c7b73bfc2313664))
|
||||
* end a cancelled turn when the force-cancel fires during an update ([#1243](https://github.com/agentclientprotocol/claude-agent-acp/issues/1243)) ([a44c486](https://github.com/agentclientprotocol/claude-agent-acp/commit/a44c486019e98ad478c549c4902e7092f1418d0b))
|
||||
* end a closed session's turns before session/close answers ([#1242](https://github.com/agentclientprotocol/claude-agent-acp/issues/1242)) ([b1d6cfc](https://github.com/agentclientprotocol/claude-agent-acp/commit/b1d6cfc6c9fc9ec397e607b68ac100f4d48745b3))
|
||||
* end a held turn cancelled mid-followup at its interrupt's idle ([#1229](https://github.com/agentclientprotocol/claude-agent-acp/issues/1229)) ([d3e673b](https://github.com/agentclientprotocol/claude-agent-acp/commit/d3e673b263cf47603a35a50bb3da8b483199f690))
|
||||
* excessive message/token_count API calls when probing context window ([#1258](https://github.com/agentclientprotocol/claude-agent-acp/issues/1258)) ([5598efb](https://github.com/agentclientprotocol/claude-agent-acp/commit/5598efbc8ab16720ea79f5c97db40771ad0b317b))
|
||||
* replay a session with a Write on the experimental ACP v2 surface ([#1238](https://github.com/agentclientprotocol/claude-agent-acp/issues/1238)) ([22139f7](https://github.com/agentclientprotocol/claude-agent-acp/commit/22139f73c48e621752089a91fb095269493896b1))
|
||||
* report a command's exit code only when the tool result says it ([#1252](https://github.com/agentclientprotocol/claude-agent-acp/issues/1252)) ([52a92be](https://github.com/agentclientprotocol/claude-agent-acp/commit/52a92be959f7f8ea90ab08d54bae6900596ab435))
|
||||
* restore main-thread agent config option ([#1250](https://github.com/agentclientprotocol/claude-agent-acp/issues/1250)) ([8589a9c](https://github.com/agentclientprotocol/claude-agent-acp/commit/8589a9cf150d8deca6d2f8ef24404d927b3d4828))
|
||||
|
||||
## [0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1) (2026-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* answer session/close without waiting for the interrupt reply ([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216)) ([0c862e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c862e29496bb018e7a4400697bd2315dfd5b845))
|
||||
* fail turns with unfinished tools ([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212)) ([7f8d547](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f8d5471e24b981b129b3c9c2d6736c4ba3f8011))
|
||||
* restore background task stops on replay instead of showing them as prompts ([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205)) ([4ffa625](https://github.com/agentclientprotocol/claude-agent-acp/commit/4ffa625977af7e85526b9a3098f4dee5844467b4))
|
||||
|
||||
## [0.85.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.84.0...v0.85.0) (2026-10-01)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- Interactive (and background) terminals
|
||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
||||
- Client MCP servers
|
||||
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
|
||||
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)
|
||||
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/air-extensions.md#recommended-config-values)
|
||||
|
||||
@@ -24,6 +24,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 +136,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)).
|
||||
@@ -164,25 +169,27 @@ Every payload goes into `_meta.jetbrains.air`, next to `version: 1`.
|
||||
The adapter merges a payload into an existing `_meta` and keeps the other namespaces.
|
||||
The adapter sends these keys only to an AIR client. "AIR" in the Gate column means an AIR client without a further capability.
|
||||
|
||||
| Key | Message and field path | Shape | Gate |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `capabilities` | `initialize` response `_meta.jetbrains.air` | string array | AIR |
|
||||
| `goal` | `initialize` response `_meta.jetbrains.air` | `{version: 1, controlMethod, actions}` | AIR |
|
||||
| `goal` | `session_info_update._meta.jetbrains.air` | goal snapshot or `null` | AIR |
|
||||
| `diffPatch` | tool call and permission request `content[]` of type `diff`, `_meta.jetbrains.air` | `{version: 1, format: "git_patch", text}` | `diffPatch` |
|
||||
| `permission` | `session/request_permission` request `_meta.jetbrains.air` | `{version: 1, title, description?, defaultToNo?}` | AIR |
|
||||
| `recommendedValue` | `model` and effort config options, `_meta.jetbrains.air` | option value string | `recommendedValue` |
|
||||
| `asyncTasks` | Bash `tool_call_update._meta.jetbrains.air` | `{backgrounded: true}` | `asyncTasks` |
|
||||
| `agentFileChangeReportRequest` | `session/prompt` request `_meta.jetbrains.air` (client to agent) | `{version: 1, requestId}` | `agentFileChangeReport` |
|
||||
| `agentFileChangeReport` | `session_info_update._meta.jetbrains.air` | report object | `agentFileChangeReport` |
|
||||
| `sessionFailure` | `session_info_update._meta.jetbrains.air` or `PromptResponse._meta.jetbrains.air` | failure record | `sessionFailure` |
|
||||
| `commandTitle` | Bash and PowerShell tool call `_meta.jetbrains.air` | the `description` input string | AIR |
|
||||
| `subagent` | Agent and Task tool call `_meta.jetbrains.air` | `true` | AIR |
|
||||
| `skill` | Skill tool call `_meta.jetbrains.air` | `{name, path?}` | AIR |
|
||||
| `contextCompaction` | compaction tool call or `compaction_update`, `_meta.jetbrains.air` | `{version: 1, trigger?, preTokens?, postTokens?, durationMs?, error?}` | AIR |
|
||||
| `customAnswer` | elicitation schema property `_meta.jetbrains.air` | `{questionId, isCustomAnswer: true}` | AIR, when the client supports form elicitation |
|
||||
| `kind` | session mode `_meta.jetbrains.air` and mode config option value `_meta.jetbrains.air` | `standard`, `plan`, `auto_review`, or `full_access` | AIR |
|
||||
| `fork` | `session/fork` request `_meta.jetbrains.air` (client to agent) | `{version: 1, messageId, messageFingerprint?, messageOccurrence?}` | none |
|
||||
| Key | Message and field path | Shape | Gate |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `capabilities` | `initialize` response `_meta.jetbrains.air` | string array | AIR |
|
||||
| `goal` | `initialize` response `_meta.jetbrains.air` | `{version: 1, controlMethod, actions}` | AIR |
|
||||
| `goal` | `session_info_update._meta.jetbrains.air` | goal snapshot or `null` | AIR |
|
||||
| `diffPatch` | tool call and permission request `content[]` of type `diff`, `_meta.jetbrains.air` | `{version: 1, format: "git_patch", text}` | `diffPatch` |
|
||||
| `permission` | `session/request_permission` request `_meta.jetbrains.air` | `{version: 1, title, description?, defaultToNo?}` | AIR |
|
||||
| `recommendedValue` | `model` and effort config options, `_meta.jetbrains.air` | option value string | `recommendedValue` |
|
||||
| `asyncTasks` | Bash `tool_call_update._meta.jetbrains.air` | `{backgrounded: true}` | `asyncTasks` |
|
||||
| `agentFileChangeReportRequest` | `session/prompt` request `_meta.jetbrains.air` (client to agent) | `{version: 1, requestId}` | `agentFileChangeReport` |
|
||||
| `agentFileChangeReport` | `session_info_update._meta.jetbrains.air` | report object | `agentFileChangeReport` |
|
||||
| `sessionFailure` | `session_info_update._meta.jetbrains.air` or `PromptResponse._meta.jetbrains.air` | failure record | `sessionFailure` |
|
||||
| `commandTitle` | Bash and PowerShell tool call `_meta.jetbrains.air` | the `description` input string | AIR |
|
||||
| `subagent` | Agent and Task tool call `_meta.jetbrains.air` | `true` | AIR |
|
||||
| `skill` | Skill tool call `_meta.jetbrains.air` | `{name, path?}` | AIR |
|
||||
| `contextCompaction` | compaction tool call or `compaction_update`, `_meta.jetbrains.air` | `{version: 1, trigger?, preTokens?, postTokens?, durationMs?, error?}` | AIR |
|
||||
| `customAnswer` | elicitation schema property `_meta.jetbrains.air` | `{questionId, isCustomAnswer: true}` | AIR, when the client supports form elicitation |
|
||||
| `kind` | session mode `_meta.jetbrains.air` and mode config option value `_meta.jetbrains.air` | `standard`, `plan`, `auto_review`, or `full_access` | AIR |
|
||||
| `kind` | available command `_meta.jetbrains.air` of a skill | `skill` | AIR, when the skill's `SKILL.md` is found; not for a built-in command or an MCP prompt |
|
||||
| `skillPath` | available command `_meta.jetbrains.air` of a skill | absolute path of the skill's `SKILL.md` | AIR, together with `kind: "skill"` |
|
||||
| `fork` | `session/fork` request `_meta.jetbrains.air` (client to agent) | `{version: 1, messageId, messageFingerprint?, messageOccurrence?}` | none |
|
||||
|
||||
## JetBrains shared keys
|
||||
|
||||
@@ -858,10 +865,17 @@ The live test only initializes the SDK. It sends no model prompt.
|
||||
|
||||
## Async tasks
|
||||
|
||||
Claude can run work in the background, for example a backgrounded Bash command, a workflow, or a monitor.
|
||||
Claude can run work in the background, for example a backgrounded Bash command or a workflow.
|
||||
The adapter publishes that work as async tasks when the client declares `asyncTasks`.
|
||||
Without the capability, the adapter sends no async task update.
|
||||
A subagent task (`local_agent`) is not an async task. Native subagent sessions report it.
|
||||
A task that a `Monitor` tool call started is not an async task either.
|
||||
Monitor streams its output to the model only, so the client gets nothing for it.
|
||||
The SDK reports such a task as `local_bash`, so the adapter finds it by the tool of its tool call.
|
||||
The `Monitor` tool call stays in the transcript.
|
||||
The adapter also ignores a `local_monitor` task.
|
||||
A task that a subagent tool call started goes to the subagent session.
|
||||
Its spawn, progress and state updates go there also after the subagent finished.
|
||||
|
||||
### Updates
|
||||
|
||||
@@ -872,12 +886,27 @@ A subagent task (`local_agent`) is not an async task. Native subagent sessions r
|
||||
- `async_task_state_update` carries `state` (`running`, `paused`, `completed`, `failed`, or `stopped`) and an optional `summary`.
|
||||
- The Bash `tool_call_update` of a backgrounded command carries `_meta.jetbrains.air.asyncTasks.backgrounded: true`.
|
||||
The card then shows backgrounded work instead of finished work.
|
||||
Only structured data sets the marker: the `backgroundTaskId` of the tool result, a known background task of the tool call, or a `run_in_background` input.
|
||||
|
||||
### Task id and output path
|
||||
|
||||
An async task comes only from structured data.
|
||||
The sources are `tool_use_result.backgroundTaskId` and the SDK `task_started`, `task_updated`, and `task_notification`.
|
||||
The text of a tool result never creates a task, because a foreground command can print any text.
|
||||
The output path comes from `output_file` of the SDK events when they have it.
|
||||
Otherwise the adapter reads the text of the tool result of the task's own tool call.
|
||||
It takes only an absolute path that ends with `tasks/<task id>.output`, followed by a period, whitespace or the end of the text.
|
||||
The path starts after the nearest `": "` before that end on the same line, so it can hold spaces.
|
||||
A path never holds the end of another path.
|
||||
A POSIX, a Windows drive, and a UNC path count. A path across a line does not.
|
||||
The tool result can come before or after the SDK names the task.
|
||||
A subagent tool result has no `tool_use_result`, so this text is the only path source there.
|
||||
|
||||
### Tool call of a task
|
||||
|
||||
The adapter sends `async_task_spawned` only after it knows the tool call that started the task.
|
||||
The SDK gives that id as `tool_use_id` of `task_started`, `task_progress`, or `task_notification`.
|
||||
The Bash result of a backgrounded command also gives it.
|
||||
The structured Bash result of a backgrounded command also gives it.
|
||||
Until the id arrives, the adapter holds the task. It sends no update of a held task.
|
||||
The progress and state updates of a held task follow its spawn, in their order.
|
||||
A held task gets its spawn without `toolCallId` when it ends first, or when the prompt result ends the turn.
|
||||
@@ -1153,6 +1182,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`.
|
||||
|
||||
480
package-lock.json
generated
480
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.85.0",
|
||||
"version": "0.89.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.85.0",
|
||||
"version": "0.89.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.6.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.286",
|
||||
"@agentclientprotocol/sdk": "1.8.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.293",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
@@ -18,15 +18,15 @@
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@anthropic-ai/sdk": "0.132.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.0",
|
||||
"@typescript-eslint/parser": "8.71.0",
|
||||
"@types/node": "26.6.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.1",
|
||||
"@typescript-eslint/parser": "8.71.1",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.11.0",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
@@ -39,31 +39,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.6.0.tgz",
|
||||
"integrity": "sha512-XxXrmX7aZkDgOB0Rg9cu+ZFyiUUc5lF2n9seO3Gc4OR+MTdfZOwIqF6m3LvsmmM8K3qgPmXkDH8/IFM2u9vdcQ==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.8.0.tgz",
|
||||
"integrity": "sha512-fbP7qHZsFyTJVT0/RPrcWJ8An26qLXHEuGCHHEKDyxD1xpXV+Ejyh2LAE6vBAFePkpVjZeeohnbgvuEhZ7wLMg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.286.tgz",
|
||||
"integrity": "sha512-InL/UNmRGSwBM/81PME0J0TZDsDBBlweWqRZgq2XSViSIg2hBi8nIL8j9Hm6MHRH85wgDJQE5n6Vo/r9hIO0NQ==",
|
||||
"version": "0.3.293",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.293.tgz",
|
||||
"integrity": "sha512-DVfn46SmpfB7xaKuUHsAC+qwY27hOLHNbvF7FoNOAXLZPRTeCr68SjhBjlFOtk+jz5Nf7pfUwnIUDFcdVih6Dg==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.286"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.293",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.293",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.293",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.293",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.293",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.293",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.293",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.293"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -72,9 +72,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.286.tgz",
|
||||
"integrity": "sha512-gkxWcJ+Z23UxwghI1V3dL09PkELIZmB2vPelR8XsdfhS+yP1KvoW7FThvRLojcxXb3fj0ddYawjQSQYIkXFbxw==",
|
||||
"version": "0.3.293",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.293.tgz",
|
||||
"integrity": "sha512-E2rwiiHr3LwYzBuTROVDpJkts4QmYCrW39ZIN1qixVSNQhSEiz5LjaKxzrwq0xgPgQDU0vd2fLMG1k72BgD24w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -85,9 +85,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.286.tgz",
|
||||
"integrity": "sha512-eMdni7sy1ud2IISI4QSsfVBCxotzSe62zCGdXISejL9MxDIwcRgEGZpO5OV+j7t8mNGMTe6Opl1t/3Z/1RUJaQ==",
|
||||
"version": "0.3.293",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.293.tgz",
|
||||
"integrity": "sha512-UJD8kX1XDhMqs/0LLxD012h+S9U3Q8XPbRSNcnPQEMNAoEF+lh898xPbVnkUgPKl3jUOUyqYRwFRwDiUJD+GJg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -98,15 +98,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.286.tgz",
|
||||
"integrity": "sha512-3h+WWGek9beZ6i1qbIjjwYEFbs46D6qumjSBc6cLsEvnufcoi7mc0iBwbFSh9yRb3upxoi4ZTJdl53T3gLTdUQ==",
|
||||
"version": "0.3.293",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.293.tgz",
|
||||
"integrity": "sha512-B3M5gw6pPH/ckMtrLtGg4ZY1adLVnPoXkEV+Ruu0j/gdFzwuLWR64Spa0RCsbo7BXVN6w5NTqkuZk+HBfsPUZA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -114,15 +111,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.286.tgz",
|
||||
"integrity": "sha512-fG8Cqx53jkFyEL86ETA0tdLH3p06yFQKHcoxnMpiU+VmJ5g6uGFhsUZbJ6gFvuT3EgyMcNI2kZwhCCDF/DTnpQ==",
|
||||
"version": "0.3.293",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.293.tgz",
|
||||
"integrity": "sha512-l5Nfwox69bBMEccSQfPQFOq9hbSqQIMsi4H/UXaMSsqgnxunXuKEBXb5klXZJwk4vUt/NzeqAjB/UFbKqW29HQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -130,15 +124,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.286.tgz",
|
||||
"integrity": "sha512-kNczbWhWJ1G8sPRZd4Nsx/Ozr/kx16lT2NGp/EEmTV4Hn732xfLkUOlg+tFcS78i6lOPBknjDB/cz/J5Ha4wDQ==",
|
||||
"version": "0.3.293",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.293.tgz",
|
||||
"integrity": "sha512-MtqXROSQiuqOCNXfC4k3/3DVfgTNLEdaT5jp/omaZeiPtnRtfCz2KTtBTVG4IwbEDFaKsqfzPsPFzN7X6avwkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -146,15 +137,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.286.tgz",
|
||||
"integrity": "sha512-WeO/wG2uPh95BhOQi1IsIECdW5gJgC1Q9xtGw4RjV04it1fBMAbUe7aVwP4DQgH8PHnmduA7dRnLtbnZnrGsjg==",
|
||||
"version": "0.3.293",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.293.tgz",
|
||||
"integrity": "sha512-5N30l2Y17ZV9q0TGCMXtrkTleX+RVtUMoOL+48qQ7ZrKWGYYBtVDS59cIXDv+TAGqoyYVmklw0W2Sy4dA40BwQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -162,9 +150,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.286.tgz",
|
||||
"integrity": "sha512-N4p7Gw5Qg3q9+Y5sEht1cIHHZkmBAUfWXJnqfmDD7N8twqF3XNVK3w65hDLqli4i0ttuZu79bGmRGkgNGm9Imw==",
|
||||
"version": "0.3.293",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.293.tgz",
|
||||
"integrity": "sha512-WvPf8cXna446bTx2U4WVQc5+iUgDn94ve8aauvWwcG8n9oa+1wNCKNGFxk8HvS7DQJhF8dfrCbcF068uNFqYNw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -175,9 +163,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.286.tgz",
|
||||
"integrity": "sha512-pg35GRPBKyviod0i8Z3EVMzDnTiiiucuWUbyH1bVIFFN0UWCQQ+PRUJ15qrPKjL6+vlFxOX2ei9FfsWYjGYZwA==",
|
||||
"version": "0.3.293",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.293.tgz",
|
||||
"integrity": "sha512-SVaIDHtpCwM0r0m9jzue1U7yktIZ1eJhR1O3e0UCHYTJluaIXty5m6DzqhG4rjnEoNlEAl9rQLqPigS/R5+IiQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -188,9 +176,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.131.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz",
|
||||
"integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==",
|
||||
"version": "0.132.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.132.1.tgz",
|
||||
"integrity": "sha512-Dg+Rd0effi+4FyNwgEx6JXl9COefsZ3C7BOPSKYIWboQOEIi9HXtu1WBdND0DIgMF7Qt1vuCSjsbs7sjUkzJuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
@@ -209,9 +197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -370,16 +358,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz",
|
||||
"integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz",
|
||||
"integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
"hono": "^4 || ^5.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
@@ -501,9 +489,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz",
|
||||
"integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==",
|
||||
"version": "1.32.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz",
|
||||
"integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -542,9 +530,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.152.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
|
||||
"integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
|
||||
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -553,9 +541,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
|
||||
"integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
|
||||
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -571,9 +559,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -589,9 +577,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -607,9 +595,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
|
||||
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -625,9 +613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
|
||||
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -643,9 +631,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
|
||||
"integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
|
||||
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -661,16 +649,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -682,16 +667,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
|
||||
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -703,16 +685,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -724,16 +703,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -745,16 +721,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -766,16 +739,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
|
||||
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -787,9 +757,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -805,9 +775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
|
||||
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -823,9 +793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
|
||||
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -929,9 +899,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz",
|
||||
"integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==",
|
||||
"version": "26.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
|
||||
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -939,17 +909,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz",
|
||||
"integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz",
|
||||
"integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.71.0",
|
||||
"@typescript-eslint/type-utils": "8.71.0",
|
||||
"@typescript-eslint/utils": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0",
|
||||
"@typescript-eslint/scope-manager": "8.71.1",
|
||||
"@typescript-eslint/type-utils": "8.71.1",
|
||||
"@typescript-eslint/utils": "8.71.1",
|
||||
"@typescript-eslint/visitor-keys": "8.71.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -962,22 +932,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.71.0",
|
||||
"@typescript-eslint/parser": "^8.71.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz",
|
||||
"integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz",
|
||||
"integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/typescript-estree": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0",
|
||||
"@typescript-eslint/scope-manager": "8.71.1",
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/typescript-estree": "8.71.1",
|
||||
"@typescript-eslint/visitor-keys": "8.71.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -993,14 +963,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz",
|
||||
"integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz",
|
||||
"integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.71.0",
|
||||
"@typescript-eslint/types": "^8.71.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.71.1",
|
||||
"@typescript-eslint/types": "^8.71.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1015,14 +985,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz",
|
||||
"integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz",
|
||||
"integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0"
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/visitor-keys": "8.71.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1033,9 +1003,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz",
|
||||
"integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz",
|
||||
"integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1050,15 +1020,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz",
|
||||
"integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz",
|
||||
"integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/typescript-estree": "8.71.0",
|
||||
"@typescript-eslint/utils": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/typescript-estree": "8.71.1",
|
||||
"@typescript-eslint/utils": "8.71.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -1075,9 +1045,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz",
|
||||
"integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz",
|
||||
"integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1089,16 +1059,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz",
|
||||
"integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz",
|
||||
"integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.71.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0",
|
||||
"@typescript-eslint/project-service": "8.71.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.71.1",
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/visitor-keys": "8.71.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -1117,16 +1087,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz",
|
||||
"integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz",
|
||||
"integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/typescript-estree": "8.71.0"
|
||||
"@typescript-eslint/scope-manager": "8.71.1",
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/typescript-estree": "8.71.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1141,13 +1111,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz",
|
||||
"integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==",
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz",
|
||||
"integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1235,9 +1205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
|
||||
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -1677,9 +1647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.11.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
|
||||
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
|
||||
"version": "10.12.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz",
|
||||
"integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -2005,9 +1975,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
|
||||
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
|
||||
"version": "8.7.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
|
||||
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -2316,9 +2286,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.12",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz",
|
||||
"integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==",
|
||||
"version": "4.13.13",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
|
||||
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2371,9 +2341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
|
||||
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
|
||||
"version": "7.0.12",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
|
||||
"integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2398,9 +2368,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
|
||||
"integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2669,9 +2639,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2694,9 +2661,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2719,9 +2683,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2744,9 +2705,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2822,9 +2780,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
|
||||
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2925,9 +2883,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3164,9 +3122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"version": "8.5.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3185,9 +3143,9 @@
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.18",
|
||||
"nanoid": "^3.3.19",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -3324,14 +3282,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
|
||||
"integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
|
||||
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.152.0",
|
||||
"@oxc-project/types": "=0.153.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3341,21 +3299,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.12",
|
||||
"@rolldown/binding-android-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-x64": "1.2.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.12"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.13",
|
||||
"@rolldown/binding-android-arm64": "1.2.13",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.13",
|
||||
"@rolldown/binding-darwin-x64": "1.2.13",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.13",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.13",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.13",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.13",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.13",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.13"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -3585,9 +3543,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
|
||||
"integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz",
|
||||
"integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3809,9 +3767,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
|
||||
"integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
|
||||
"version": "8.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz",
|
||||
"integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
|
||||
16
package.json
16
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.85.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",
|
||||
@@ -65,21 +65,21 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.6.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.286",
|
||||
"@agentclientprotocol/sdk": "1.8.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.293",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@anthropic-ai/sdk": "0.132.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.0",
|
||||
"@typescript-eslint/parser": "8.71.0",
|
||||
"@types/node": "26.6.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.1",
|
||||
"@typescript-eslint/parser": "8.71.1",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.11.0",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
|
||||
1646
src/acp-agent.ts
1646
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions";
|
||||
export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks";
|
||||
export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure";
|
||||
export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue";
|
||||
export const AIR_CUSTOM_INSTRUCTIONS_CAPABILITY = "customInstructions";
|
||||
export const AIR_DIFF_PATCH_CAPABILITY = "diffPatch";
|
||||
/** AIR renders `rawInput` itself and needs no display copy of the input. */
|
||||
export const AIR_RAW_INPUT_RENDERING_CAPABILITY = "rawInputRendering";
|
||||
@@ -10,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";
|
||||
@@ -18,6 +42,8 @@ export const AIR_SKILL_KEY = "skill";
|
||||
export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction";
|
||||
export const AIR_GOAL_KEY = "goal";
|
||||
export const AIR_KIND_KEY = "kind";
|
||||
/** The absolute path of the `SKILL.md` of a skill command in `available_commands_update`. */
|
||||
export const AIR_SKILL_PATH_KEY = "skillPath";
|
||||
export const AIR_PERMISSION_KEY = "permission";
|
||||
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
|
||||
/**
|
||||
@@ -118,6 +144,13 @@ export function clientSupportsAirCapability(capabilities: unknown, capability: s
|
||||
);
|
||||
}
|
||||
|
||||
/** Read custom instructions from `session/new` metadata. */
|
||||
export function airCustomInstructions(meta: unknown): string | undefined {
|
||||
const air = airExtensionMeta(meta);
|
||||
const instructions = air?.[AIR_CUSTOM_INSTRUCTIONS_CAPABILITY];
|
||||
return typeof instructions === "string" ? instructions : undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
||||
@@ -43,6 +43,8 @@ type AsyncTask = {
|
||||
|
||||
type TaskIdentity = { taskId?: unknown; task_id?: unknown };
|
||||
|
||||
const MAX_TOOL_RESULT_PATHS = 500;
|
||||
|
||||
export type AsyncTaskStarted = TaskIdentity & {
|
||||
taskType?: unknown;
|
||||
task_type?: unknown;
|
||||
@@ -112,6 +114,13 @@ export class AsyncTaskRuntime {
|
||||
* a command was backgrounded can arrive after its terminal SDK edge.
|
||||
*/
|
||||
private readonly tasks = new Map<string, AsyncTask>();
|
||||
/**
|
||||
* The task output paths in the text of each tool result, by tool call
|
||||
* id. A task whose id and tool call are known takes its output path from
|
||||
* here. The tool result can come before or after the SDK names the task.
|
||||
* The oldest entries are dropped.
|
||||
*/
|
||||
private readonly outputPathsByToolCall = new Map<string, string[]>();
|
||||
|
||||
constructor(
|
||||
readonly enabled: boolean,
|
||||
@@ -120,10 +129,13 @@ export class AsyncTaskRuntime {
|
||||
/** `notices`: the client can present `notice` updates, so the stop
|
||||
* acknowledgement need not be a transcript line. `routeOf`: the route of
|
||||
* the tasks that a tool call in another session started, for example in
|
||||
* a native subagent session. */
|
||||
* a native subagent session. `toolNameOf`: the name of the tool of a tool
|
||||
* call, or `undefined` when the tool call is unknown. A task that a
|
||||
* `Monitor` tool call started is ignored. */
|
||||
private readonly options: {
|
||||
notices?: boolean;
|
||||
routeOf?: (toolCallId: string) => Route | undefined;
|
||||
toolNameOf?: (toolCallId: string) => string | undefined;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
@@ -132,8 +144,8 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,8 +175,8 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -330,8 +342,9 @@ export class AsyncTaskRuntime {
|
||||
live.add(taskId);
|
||||
const task = this.task(taskId);
|
||||
if (task.ignored || isTerminal(task.state)) continue;
|
||||
if (field(item, "taskType", "task_type") === "local_agent") {
|
||||
task.ignored = true;
|
||||
const levelTaskType = field(item, "taskType", "task_type");
|
||||
if (levelTaskType === "local_agent" || levelTaskType === "local_monitor") {
|
||||
this.ignore(task);
|
||||
continue;
|
||||
}
|
||||
if (!task.startedObserved) {
|
||||
@@ -355,8 +368,10 @@ export class AsyncTaskRuntime {
|
||||
) {
|
||||
// This replace-semantics level is itself the authoritative liveness
|
||||
// boundary. Close immediately so a lost terminal edge cannot leave a
|
||||
// permanent running card; a following task_notification may correct
|
||||
// the best-effort stopped state to completed/failed.
|
||||
// permanent running card. Since SDK 0.3.292 a finishing task's level
|
||||
// follows its terminal edges, so this is a lost or late edge; a late
|
||||
// task_notification still corrects the best-effort stopped state to
|
||||
// completed/failed.
|
||||
await this.finish(task, "stopped", undefined, "level");
|
||||
}
|
||||
}
|
||||
@@ -423,6 +438,41 @@ export class AsyncTaskRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a task of the tool call went to the background, so the client gets it. */
|
||||
isBackgroundedToolCall(toolCallId: string): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.toolCallId === toolCallId && !task.ignored && (task.announced || task.held)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the tool results in `content` for the output path of a known task.
|
||||
* Only a path that ends with `tasks/<task id>.output` in the result of
|
||||
* the task's own tool call counts. A tool result never creates a task.
|
||||
*/
|
||||
async toolResults(content: unknown): Promise<void> {
|
||||
if (!this.enabled || !Array.isArray(content)) return;
|
||||
for (const result of content.flatMap(toolResultBlock)) {
|
||||
const text = textContent(result.content);
|
||||
const paths = text ? taskOutputPaths(text) : [];
|
||||
if (paths.length === 0) continue;
|
||||
this.outputPathsByToolCall.delete(result.toolUseId);
|
||||
this.outputPathsByToolCall.set(result.toolUseId, paths);
|
||||
if (this.outputPathsByToolCall.size > MAX_TOOL_RESULT_PATHS) {
|
||||
const oldest = this.outputPathsByToolCall.keys().next().value;
|
||||
if (oldest !== undefined) this.outputPathsByToolCall.delete(oldest);
|
||||
}
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.toolCallId !== result.toolUseId || task.ignored || task.outputFilePath) continue;
|
||||
this.mergeToolResultPath(task);
|
||||
if (task.outputFilePath && task.announced) await this.publishMetadata(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the spawn of each held task without a tool call id. The prompt
|
||||
* result ends the model turn, and the result of the tool call that started a
|
||||
@@ -437,6 +487,18 @@ export class AsyncTaskRuntime {
|
||||
|
||||
clear(): void {
|
||||
this.tasks.clear();
|
||||
this.outputPathsByToolCall.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a task that the client never sees. Only a task that is not announced
|
||||
* can become ignored, so the client never holds a task without its end.
|
||||
*/
|
||||
private ignore(task: AsyncTask): void {
|
||||
if (task.announced) return;
|
||||
task.ignored = true;
|
||||
task.held = false;
|
||||
task.deferred = [];
|
||||
}
|
||||
|
||||
private task(taskId: string): AsyncTask {
|
||||
@@ -484,6 +546,7 @@ export class AsyncTaskRuntime {
|
||||
this.mergeOutputFilePath(task, message);
|
||||
const toolCallId = nonBlankString(field(message, "toolCallId", "tool_use_id"));
|
||||
if (toolCallId) task.toolCallId = toolCallId;
|
||||
this.mergeToolResultPath(task);
|
||||
}
|
||||
|
||||
/** Keeps the first tool call id that a later SDK message brings. */
|
||||
@@ -492,6 +555,17 @@ export class AsyncTaskRuntime {
|
||||
value: { toolCallId?: unknown; tool_use_id?: unknown },
|
||||
): void {
|
||||
task.toolCallId ??= nonBlankString(field(value, "toolCallId", "tool_use_id"));
|
||||
this.mergeToolResultPath(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the output path from the result of the task's tool call when no
|
||||
* structured source gave one. A structured path later replaces it.
|
||||
*/
|
||||
private mergeToolResultPath(task: AsyncTask): void {
|
||||
if (task.outputFilePath || !task.toolCallId) return;
|
||||
const paths = this.outputPathsByToolCall.get(task.toolCallId);
|
||||
if (paths) task.outputFilePath = outputFilePathOf(paths, task.id);
|
||||
}
|
||||
|
||||
/** Sends an update of an announced task. A held task sends it after its spawn. */
|
||||
@@ -535,12 +609,22 @@ export class AsyncTaskRuntime {
|
||||
* backgrounded command. A held task gets its spawn without the id when it
|
||||
* ends first, or when the prompt result ends the model turn
|
||||
* ({@link releaseHeld}). No guess by the command text binds a task.
|
||||
*
|
||||
* A task that a `Monitor` tool call started is ignored: Monitor streams its
|
||||
* output to the model only, so the task has nothing for the client. The
|
||||
* tool_use streams before the tool runs, so the tool name is known when the
|
||||
* spawn has the tool call id.
|
||||
*/
|
||||
private async announce(
|
||||
task: AsyncTask,
|
||||
options: { withoutToolCall?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (task.announced || task.ignored) return;
|
||||
const toolName = task.toolCallId ? this.options.toolNameOf?.(task.toolCallId) : undefined;
|
||||
if (toolName !== undefined && HIDDEN_TASK_TOOLS.has(toolName)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
if (!task.toolCallId && !options.withoutToolCall && !isTerminal(task.state)) {
|
||||
task.held = true;
|
||||
return;
|
||||
@@ -578,8 +662,9 @@ export class AsyncTaskRuntime {
|
||||
): Promise<void> {
|
||||
if (task.ignored) return;
|
||||
if (isTerminal(task.state)) {
|
||||
// A level event may precede the authoritative terminal edge. Correct its
|
||||
// best-effort stopped state if that edge later arrives.
|
||||
// A level event can precede a late terminal edge (older CLIs sent the
|
||||
// level first). Correct its best-effort stopped state if that edge
|
||||
// later arrives.
|
||||
if (task.terminalSource !== "level" || source !== "event") return;
|
||||
}
|
||||
const previous = {
|
||||
@@ -692,7 +777,11 @@ function recordPublished(task: AsyncTask, fields: Record<string, unknown>): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Recovers background Bash lifecycle data exposed only on its tool result. */
|
||||
/**
|
||||
* Recovers a background Bash task from the structured `backgroundTaskId` of
|
||||
* its tool result. The text of a tool result never gives a task id: a
|
||||
* foreground command can print any text.
|
||||
*/
|
||||
export function backgroundBashTaskFromToolResult(
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
@@ -704,12 +793,7 @@ export function backgroundBashTaskFromToolResult(
|
||||
if (bashResults.length === 0) return undefined;
|
||||
|
||||
const backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
|
||||
const distinctTaskIds = new Set([
|
||||
...backgroundResults.map((result) => result.taskId),
|
||||
...bashResults
|
||||
.map((result) => backgroundTaskIdFromText(result.content))
|
||||
.filter((taskId): taskId is string => taskId !== undefined),
|
||||
]);
|
||||
const distinctTaskIds = new Set(backgroundResults.map((result) => result.taskId));
|
||||
if (distinctTaskIds.size !== 1) return undefined;
|
||||
const taskId = [...distinctTaskIds][0];
|
||||
if (!taskId) return undefined;
|
||||
@@ -743,15 +827,36 @@ export function backgroundBashTaskFromToolResult(
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundTaskIdFromText(content: unknown): string | undefined {
|
||||
const text = textContent(content);
|
||||
if (!text) return undefined;
|
||||
const marker = "Command running in background with ID: ";
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) return undefined;
|
||||
const valueStart = start + marker.length;
|
||||
const valueEnd = text.indexOf(".", valueStart);
|
||||
return valueEnd < 0 ? undefined : nonBlankString(text.slice(valueStart, valueEnd));
|
||||
/**
|
||||
* The Bash tool calls in `content` whose command went to the background. Only
|
||||
* structured data counts: the task from the structured tool result, a task
|
||||
* that the runtime knows for the tool call, or a `run_in_background` input of
|
||||
* a call that did not fail.
|
||||
*/
|
||||
export function backgroundedBashToolCallIds(
|
||||
content: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
runtime: AsyncTaskRuntime,
|
||||
structuredTask?: AsyncTaskStarted,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (!runtime.enabled || !Array.isArray(content)) return ids;
|
||||
const structuredToolCallId = structuredTask
|
||||
? nonBlankString(field(structuredTask, "toolCallId", "tool_use_id"))
|
||||
: undefined;
|
||||
for (const result of content.flatMap(toolResultBlock)) {
|
||||
const toolUse = toolUses[result.toolUseId];
|
||||
if (toolUse?.name !== "Bash") continue;
|
||||
const input = isRecord(toolUse.input) ? toolUse.input : undefined;
|
||||
if (
|
||||
result.toolUseId === structuredToolCallId ||
|
||||
runtime.isBackgroundedToolCall(result.toolUseId) ||
|
||||
(input?.run_in_background === true && !result.isError)
|
||||
) {
|
||||
ids.add(result.toolUseId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function structuredBackgroundResults(
|
||||
@@ -783,27 +888,73 @@ function structuredBackgroundResults(
|
||||
return results;
|
||||
}
|
||||
|
||||
function toolResultBlock(value: unknown): { toolUseId: string; content?: unknown }[] {
|
||||
function toolResultBlock(
|
||||
value: unknown,
|
||||
): { toolUseId: string; content?: unknown; isError: boolean }[] {
|
||||
if (!isRecord(value) || value.type !== "tool_result") return [];
|
||||
const toolUseId = nonBlankString(field(value, "toolUseId", "tool_use_id"));
|
||||
return toolUseId ? [{ toolUseId, content: value.content }] : [];
|
||||
return toolUseId
|
||||
? [
|
||||
{
|
||||
toolUseId,
|
||||
content: value.content,
|
||||
isError: field(value, "isError", "is_error") === true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
function asyncTaskOutputFilePath(content: unknown, taskId: string): string | undefined {
|
||||
const text = textContent(content);
|
||||
if (!text) return undefined;
|
||||
const marker = "Output is being written to: ";
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) return undefined;
|
||||
const valueStart = start + marker.length;
|
||||
const valueEnd = text.indexOf(". You will be notified", valueStart);
|
||||
if (valueEnd < 0) return undefined;
|
||||
const path = text.slice(valueStart, valueEnd).trim();
|
||||
const expectedPosixSuffix = `/tasks/${taskId}.output`;
|
||||
const expectedWindowsSuffix = `\\tasks\\${taskId}.output`;
|
||||
return path.endsWith(expectedPosixSuffix) || path.endsWith(expectedWindowsSuffix)
|
||||
? path
|
||||
: undefined;
|
||||
return text ? outputFilePathOf(taskOutputPaths(text), taskId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a task output path: `tasks/<id>.output`, followed by a sentence
|
||||
* period, whitespace or the end of the text. Claude Code writes the output of
|
||||
* a background task there.
|
||||
*/
|
||||
const TASK_OUTPUT_SUFFIX = /[/\\]tasks[/\\][^\s/\\]+?\.output(?=\.(?!\w)|\s|$)/g;
|
||||
/** An absolute POSIX, Windows drive or UNC path. */
|
||||
const ABSOLUTE_PATH = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/;
|
||||
|
||||
/**
|
||||
* The task output paths in a tool result text. Each text of Claude Code puts
|
||||
* the path after "Output is being written to: ", so a path starts after the
|
||||
* nearest `": "` before its end. A drive colon has no space after it, so a
|
||||
* path can hold spaces and a drive. The search for the start stops at a line
|
||||
* break and at the end of the previous path end, so a path never crosses a
|
||||
* line or holds another path. Each character is read at most once, so the
|
||||
* parse is linear in the text length. A path that is not absolute does not
|
||||
* count.
|
||||
*/
|
||||
function taskOutputPaths(text: string): string[] {
|
||||
const paths: string[] = [];
|
||||
let previousEnd = 0;
|
||||
for (const match of text.matchAll(TASK_OUTPUT_SUFFIX)) {
|
||||
const end = match.index + match[0].length;
|
||||
let start = -1;
|
||||
for (let i = match.index - 1; i >= previousEnd; i--) {
|
||||
const char = text[i];
|
||||
if (char === "\n" || char === "\r") break;
|
||||
if (char === ":" && text[i + 1] === " ") {
|
||||
start = i + 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
previousEnd = end;
|
||||
if (start < 0) continue;
|
||||
const path = text.slice(start, end);
|
||||
if (ABSOLUTE_PATH.test(path)) paths.push(path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** The output path of the task `taskId` among the paths, if one names it. */
|
||||
function outputFilePathOf(paths: readonly string[], taskId: string): string | undefined {
|
||||
const posix = `/tasks/${taskId}.output`;
|
||||
const windows = `\\tasks\\${taskId}.output`;
|
||||
return paths.find((path) => path.endsWith(posix) || path.endsWith(windows));
|
||||
}
|
||||
|
||||
function textContent(value: unknown): string | undefined {
|
||||
@@ -834,6 +985,13 @@ function isBackgroundTask(isBackgrounded: unknown, taskType: unknown): boolean {
|
||||
return taskType !== "local_bash" && taskType !== "local_agent";
|
||||
}
|
||||
|
||||
/** The tools whose async tasks the client never sees. */
|
||||
const HIDDEN_TASK_TOOLS = new Set(["Monitor"]);
|
||||
|
||||
function isMonitorTask(message: AsyncTaskStarted): boolean {
|
||||
return field(message, "taskType", "task_type") === "local_monitor";
|
||||
}
|
||||
|
||||
function isSubagentTask(message: AsyncTaskStarted): boolean {
|
||||
return (
|
||||
field(message, "subagentType", "subagent_type") !== undefined ||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PermissionMode,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
|
||||
export type ClearContextReset = {
|
||||
toolUseId: string;
|
||||
@@ -37,6 +37,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
|
||||
accumulatedModelUsage?: Record<string, Usage>;
|
||||
models: { currentModelId: string };
|
||||
configOptions: SessionConfigOption[];
|
||||
currentAgent: string;
|
||||
fastModeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
@@ -88,6 +89,7 @@ function restartParams<Session extends ClearContextSession>(session: Session): N
|
||||
...(session.models.currentModelId !== DEFAULT_MODEL_ID
|
||||
? { model: session.models.currentModelId }
|
||||
: {}),
|
||||
...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}),
|
||||
...(session.effortPinnedLevel !== undefined
|
||||
? { effort: session.effortPinnedLevel as EffortLevel }
|
||||
: {}),
|
||||
|
||||
228
src/diff.ts
228
src/diff.ts
@@ -1,4 +1,5 @@
|
||||
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { structuredPatch } from "diff";
|
||||
import { constants } from "node:fs";
|
||||
import { type FileHandle, open } from "node:fs/promises";
|
||||
@@ -73,6 +74,28 @@ interface EditPreviewInput {
|
||||
/** The kind of file change that a git patch describes. */
|
||||
type FileChange = "create" | "update";
|
||||
|
||||
/** A file change and the hunks of its git patch. */
|
||||
interface FilePatch {
|
||||
change: FileChange;
|
||||
hunks: PatchHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff that carries an exact patch: AIR's `diffPatch` extension of a v1
|
||||
* diff, or an ACP v2 diff.
|
||||
*/
|
||||
export type PatchForm = "air" | "v2";
|
||||
|
||||
/**
|
||||
* How a git patch names its file. `git` is git's own form: the leading slash
|
||||
* dropped and the `a/` and `b/` prefixes added. `absolute` is the form of an
|
||||
* ACP v2 `git_patch`, whose paths must be absolute: the path itself.
|
||||
*/
|
||||
type GitPatchPaths = "git" | "absolute";
|
||||
|
||||
/** An ACP v2 diff, as tool call content. */
|
||||
type V2DiffContent = v2.Diff & { type: "diff" };
|
||||
|
||||
/**
|
||||
* Builds the exact patch shown before Claude runs an Edit or Write tool.
|
||||
*
|
||||
@@ -85,8 +108,8 @@ type FileChange = "create" | "update";
|
||||
* normalizes quotes or fails.
|
||||
*
|
||||
* A Write always gets content that shows the change: the patch, the standard
|
||||
* diff of a text that cannot have an exact patch, or a notice that the Write
|
||||
* overwrites a file that the adapter cannot show.
|
||||
* diff (in v2, the change alone) of a text that cannot have an exact patch, or
|
||||
* a notice that the Write overwrites a file that the adapter cannot show.
|
||||
*
|
||||
* The preview uses the tool input as it is. Claude can still remove the
|
||||
* trailing whitespace of a line before it writes. The PostToolUse hook then
|
||||
@@ -96,6 +119,7 @@ export async function previewPatchContent(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
cwd?: string,
|
||||
form: PatchForm = "air",
|
||||
): Promise<ToolCallContent[] | undefined> {
|
||||
if (toolName === "Edit") {
|
||||
const edit = input as EditPreviewInput;
|
||||
@@ -119,7 +143,7 @@ export async function previewPatchContent(
|
||||
if (oldText !== null && (oldText.trim() !== "" || oldText.includes("\uFEFF"))) {
|
||||
return undefined;
|
||||
}
|
||||
return optionalContent(filePatch(filePath, oldText, newString)?.content);
|
||||
return exactPatch(filePath, oldText, newString, form);
|
||||
}
|
||||
if (oldText === null) return undefined;
|
||||
const occurrences = oldText.split(oldString).length - 1;
|
||||
@@ -128,7 +152,7 @@ export async function previewPatchContent(
|
||||
return undefined;
|
||||
}
|
||||
const newText = replacedText(oldText, oldString, newString, edit.replace_all === true);
|
||||
return optionalContent(filePatch(filePath, oldText, newText)?.content);
|
||||
return exactPatch(filePath, oldText, newText, form);
|
||||
}
|
||||
|
||||
if (toolName === "Write") {
|
||||
@@ -154,11 +178,13 @@ export async function previewPatchContent(
|
||||
}
|
||||
if (oldText === content) return undefined;
|
||||
const standard: ToolCallContent[] = [
|
||||
{ type: "diff", path: write.file_path, oldText, newText: content },
|
||||
form === "v2"
|
||||
? v2DiffContent(filePath, oldText === null ? "create" : "update")
|
||||
: { type: "diff", path: write.file_path, oldText, newText: content },
|
||||
];
|
||||
if (!isPatchableText(content)) return standard;
|
||||
// A dense change can exceed the diff budget. The approval then shows the standard diff.
|
||||
return optionalContent(filePatch(filePath, oldText, content)?.content) ?? standard;
|
||||
return exactPatch(filePath, oldText, content, form) ?? standard;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -217,6 +243,7 @@ function replacedText(
|
||||
*/
|
||||
export async function patchUpdateFromDiffToolResponse(
|
||||
toolResponse: unknown,
|
||||
form: PatchForm = "air",
|
||||
): Promise<{ content: ToolCallContent[]; locations: ToolCallLocation[] } | undefined> {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return undefined;
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
@@ -249,10 +276,10 @@ export async function patchUpdateFromDiffToolResponse(
|
||||
const newText = await readPatchSource(response.filePath);
|
||||
if (typeof newText !== "string" || newText.startsWith("\uFEFF")) return undefined;
|
||||
if (response.type === "create" && newText !== response.content) return undefined;
|
||||
const patch = filePatch(response.filePath, oldText, newText);
|
||||
const patch = filePatch(oldText, newText);
|
||||
if (!patch) return undefined;
|
||||
return {
|
||||
content: [patch.content],
|
||||
content: [patchContent(response.filePath, patch, form)],
|
||||
// A created file keeps the location of its Write tool call.
|
||||
locations:
|
||||
oldText === null
|
||||
@@ -341,27 +368,83 @@ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a finished Edit or Write: the exact patch of
|
||||
* {@link patchUpdateFromDiffToolResponse} when it can be built, else the
|
||||
* change with a patch from what the toolResponse holds.
|
||||
*
|
||||
* The fallback patch of an update comes from the `structuredPatch`, which is
|
||||
* display data (see {@link patchUpdateFromDiffToolResponse}). That is enough
|
||||
* for a v2 patch, which is renderable text, not a patch to apply. An update
|
||||
* without hunks, such as one whose previous content was too large to diff,
|
||||
* gets the change alone. An Edit with an empty `old_string` reports the same
|
||||
* response for a created file and a filled empty one, so it gets no diff.
|
||||
*/
|
||||
export async function v2UpdateFromDiffToolResponse(toolResponse: unknown): Promise<{
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
}> {
|
||||
const exact = await patchUpdateFromDiffToolResponse(toolResponse, "v2");
|
||||
if (exact) return exact;
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
const filePath = response.filePath;
|
||||
if (typeof filePath !== "string") return {};
|
||||
if (response.type === "create") {
|
||||
const hunk =
|
||||
typeof response.content === "string" && isPatchableText(response.content)
|
||||
? wholeFileHunk(response.content)
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "create", hunk && [hunk])],
|
||||
locations: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
|
||||
return {};
|
||||
}
|
||||
// A Write whose content is the previous text changed nothing.
|
||||
if (response.type === "update" && response.content === response.originalFile) return {};
|
||||
const hunks =
|
||||
Array.isArray(response.structuredPatch) && response.structuredPatch.length > 0
|
||||
? response.structuredPatch
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "update", hunks)],
|
||||
locations: hunks
|
||||
? hunks.map(({ newStart }) => ({ path: filePath, line: newStart }))
|
||||
: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of one git patch for `filePath`.
|
||||
*
|
||||
* The headers follow `git diff`: the path loses its leading slash, gets the
|
||||
* `a/` and `b/` prefixes, and is quoted when git would quote it. A created
|
||||
* file gets its mode line and a `/dev/null` side.
|
||||
* The headers follow `git diff`: the path is quoted when git would quote it,
|
||||
* and a created file gets its mode line and a `/dev/null` side. With `git`
|
||||
* paths, the path loses its leading slash and gets the `a/` and `b/` prefixes.
|
||||
*/
|
||||
export function gitPatchText(filePath: string, change: FileChange, hunks: PatchHunk[]): string {
|
||||
export function gitPatchText(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks: PatchHunk[],
|
||||
paths: GitPatchPaths = "git",
|
||||
): string {
|
||||
return [
|
||||
...gitPatchHeader(filePath, change),
|
||||
...gitPatchHeader(filePath, change, paths),
|
||||
...hunks.flatMap((hunk) => [hunkHeader(hunk), ...hunk.lines]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function gitPatchHeader(filePath: string, change: FileChange): string[] {
|
||||
// git drops one leading slash of an absolute path; a Windows path gets
|
||||
// forward slashes so the header names the same file on every platform.
|
||||
const name = filePath.replaceAll("\\", "/").replace(/^\/+/u, "");
|
||||
const oldName = quoteGitPath("a/", name);
|
||||
const newName = quoteGitPath("b/", name);
|
||||
function gitPatchHeader(filePath: string, change: FileChange, paths: GitPatchPaths): string[] {
|
||||
// A Windows path gets forward slashes so the header names the same file on
|
||||
// every platform.
|
||||
const slashed = filePath.replaceAll("\\", "/");
|
||||
// git drops one leading slash of an absolute path.
|
||||
const name = paths === "git" ? slashed.replace(/^\/+/u, "") : slashed;
|
||||
const oldName = quoteGitPath(paths === "git" ? "a/" : "", name);
|
||||
const newName = quoteGitPath(paths === "git" ? "b/" : "", name);
|
||||
// git ends a ---/+++ name that contains a space with a tab, for GNU patch.
|
||||
const tab = name.includes(" ") ? "\t" : "";
|
||||
return [
|
||||
@@ -481,18 +564,55 @@ function textLines(text: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* The git patch of a file change and its hunks. Returns undefined when
|
||||
* nothing changed or the diff runs out of its time budget.
|
||||
* The patch of a change from `oldText`, null for a missing file, to
|
||||
* `newText`. Returns undefined when nothing changed or the diff runs out of
|
||||
* its time budget.
|
||||
*/
|
||||
function filePatch(
|
||||
filePath: string,
|
||||
oldText: string | null,
|
||||
newText: string,
|
||||
): { content: ToolCallContent; hunks: PatchHunk[] } | undefined {
|
||||
const hunks = diffHunks(oldText, newText);
|
||||
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
|
||||
const hunks = memoizedDiffHunks(oldText, newText);
|
||||
if (!hunks || hunks.length === 0) return undefined;
|
||||
const change = oldText === null ? "create" : "update";
|
||||
return { content: patchContent(filePath, gitPatchText(filePath, change, hunks)), hunks };
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,8 +673,22 @@ function isPatchableText(text: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function optionalContent(content: ToolCallContent | undefined): ToolCallContent[] | undefined {
|
||||
return content ? [content] : undefined;
|
||||
/** The exact patch content of a change, or undefined when it has no patch. */
|
||||
function exactPatch(
|
||||
filePath: string,
|
||||
oldText: string | null,
|
||||
newText: string,
|
||||
form: PatchForm,
|
||||
): ToolCallContent[] | undefined {
|
||||
const patch = filePatch(oldText, newText);
|
||||
return patch && [patchContent(filePath, patch, form)];
|
||||
}
|
||||
|
||||
/** The content that carries a patch, in the given form. */
|
||||
function patchContent(filePath: string, patch: FilePatch, form: PatchForm): ToolCallContent {
|
||||
return form === "v2"
|
||||
? v2DiffContent(filePath, patch.change, patch.hunks)
|
||||
: airPatchContent(filePath, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,7 +697,7 @@ function optionalContent(content: ToolCallContent | undefined): ToolCallContent[
|
||||
* `oldText: null` and `newText: ""` only satisfy the ACP schema. The adapter
|
||||
* sends this form only to a client that advertised `diffPatch`.
|
||||
*/
|
||||
function patchContent(filePath: string, text: string): ToolCallContent {
|
||||
function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCallContent {
|
||||
return {
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
@@ -572,11 +706,39 @@ function patchContent(filePath: string, text: string): ToolCallContent {
|
||||
_meta: withAirMeta(undefined, AIR_DIFF_PATCH_CAPABILITY, {
|
||||
version: 1,
|
||||
format: "git_patch",
|
||||
text,
|
||||
text: gitPatchText(filePath, change, hunks),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a change to the text file `filePath`: the change and,
|
||||
* given hunks, its git patch.
|
||||
*
|
||||
* The agent builds tool call content in v1 types, and v1 cannot express a v2
|
||||
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
|
||||
* diff, and only a v2 client gets one: the v2 surface sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
export function v2DiffContent(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks?: PatchHunk[],
|
||||
): ToolCallContent {
|
||||
const fileChange: v2.DiffChange =
|
||||
change === "create"
|
||||
? { operation: "add", path: filePath, fileType: "text" }
|
||||
: { operation: "modify", path: filePath, fileType: "text" };
|
||||
const diff: V2DiffContent = {
|
||||
type: "diff",
|
||||
changes: [fileChange],
|
||||
...(hunks?.length
|
||||
? { patch: { format: "git_patch", text: gitPatchText(filePath, change, hunks, "absolute") } }
|
||||
: {}),
|
||||
};
|
||||
return diff as unknown as ToolCallContent;
|
||||
}
|
||||
|
||||
function resolveToolPath(filePath: string, cwd?: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd ?? process.cwd(), filePath);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
/**
|
||||
* `--hide-claude-auth`: this integration must never bill a claude.ai
|
||||
* subscription. The flag hides the claude.ai login method from `initialize`
|
||||
* and makes the agent behave like a logged-out CLI whenever a subscription
|
||||
* would pay for a turn. `acp-agent.ts` decides *when* the guard applies (flag
|
||||
* on, no provider override) and calls in here for the *what*.
|
||||
* subscription. Under the flag the agent hides the claude.ai login method from
|
||||
* `initialize`, and refuses with `authRequired` every session and every turn
|
||||
* that a subscription would pay for. `acp-agent.ts` decides when the guard
|
||||
* applies (flag on, no provider override); this module decides what it refuses.
|
||||
*
|
||||
* The account the CLI reports at `initialize` is the source of truth. The SDK
|
||||
* memoizes it, so it is a constant for the life of a query, and two layers
|
||||
* keep it honest:
|
||||
* The guard judges the `AccountInfo` that the SDK caches at `initialize`. That
|
||||
* cache never changes during the life of a query, but the CLI itself picks up
|
||||
* a login or logout made elsewhere. So the cached account can go stale: a
|
||||
* session started on an API key keeps passing the guard after the user swaps
|
||||
* the key for a claude.ai login, and the subscription pays.
|
||||
*
|
||||
* 1. Session creation refuses an account that holds no credential this
|
||||
* integration accepts. A logged-out session never exists, so the client's
|
||||
* sign-in always leads to a new session with a fresh `initialize`. The
|
||||
* per-turn guard repeats the check on the same cached account, which costs
|
||||
* microseconds and covers a session created under a provider override that
|
||||
* `providers/disable` later removed.
|
||||
* 2. A sign-out during the session ends the query. The next turn recreates it,
|
||||
* so the next `initialize` reports the real account and layer 1 judges it.
|
||||
* The recreation resumes the stored conversation. When the CLI never wrote
|
||||
* one, because the first turn was the one that signed out, the recreation
|
||||
* starts a fresh query under the same session id instead.
|
||||
* `authStatus` cannot fix this: it only reports to the client, and the guard
|
||||
* does not read it. The agent therefore recreates the query whenever the
|
||||
* account may have changed, and the new `initialize` gives the guard a current
|
||||
* account. Two signals trigger it:
|
||||
*
|
||||
* A file-based credential replaced by a subscription between two turns is seen
|
||||
* by the CLI probe the agent fires at the START of every user prompt. That read
|
||||
* is never awaited, so it usually lands mid-turn; all it does there is mark the
|
||||
* session. The guard consumes the mark at the turn boundary — the next prompt
|
||||
* recreates the query and layer 1 judges the account the new `initialize`
|
||||
* reports — so a probe never interrupts a running turn. A swap that happens
|
||||
* after the last probe stays unseen until the next prompt reads the store
|
||||
* again. Closing that window would cost a blocking read per turn.
|
||||
* - A turn fails with `auth_required`, which means a sign-out. The query is
|
||||
* closed at once and recreated on the next turn.
|
||||
* - The `claude auth status` check that runs at the start of every prompt (the
|
||||
* same check that feeds `authStatus`) reports a different kind of identity
|
||||
* than the session started with. The session is flagged and recreated at the
|
||||
* next turn boundary, so a running turn is never interrupted.
|
||||
*
|
||||
* The recreation resumes the conversation, or starts a new one under the same
|
||||
* session id when the CLI never saved it. Session creation also refuses an
|
||||
* account with no credential this integration accepts. So a signed-out session
|
||||
* never exists, and a sign-in always leads to a fresh `initialize`.
|
||||
*
|
||||
* The check is not awaited, so a credential swapped after it ran stays unseen
|
||||
* until the next prompt. Awaiting it would add a CLI run to every turn.
|
||||
*/
|
||||
|
||||
import { RequestError } from "@agentclientprotocol/sdk";
|
||||
|
||||
392
src/mcp-command.ts
Normal file
392
src/mcp-command.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import type { AvailableCommand } from "@agentclientprotocol/sdk";
|
||||
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { escapeMarkdown } from "./usage-markdown.js";
|
||||
import { raceTimeoutAndAbort } from "./utils.js";
|
||||
|
||||
/** The `/mcp` entry of `available_commands_update`. The adapter shows the
|
||||
* servers as a list, and runs a reconnect, an enable, or a disable through
|
||||
* the SDK control API. */
|
||||
export const MCP_AVAILABLE_COMMAND: AvailableCommand = {
|
||||
name: "mcp",
|
||||
description: "Show the MCP servers and their status, or reconnect, enable, or disable a server",
|
||||
input: { hint: "[reconnect|enable|disable [<server>|all]]" },
|
||||
};
|
||||
|
||||
/** The time that the status read after a `/mcp` command can take. */
|
||||
const MCP_STATUS_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The time that one reconnect, enable, or disable can take. */
|
||||
const MCP_ACTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** A `/mcp` prompt that gets a replacement for its Claude Code text. `all` is
|
||||
* true for `all` and for a reconnect without a server name, as in Claude
|
||||
* Code. `server` is undefined for an enable or a disable without a name. */
|
||||
export type McpCommand =
|
||||
| { action: "status" }
|
||||
| { action: "reconnect" | "enable" | "disable"; all: boolean; server?: string };
|
||||
|
||||
/** Parse a prompt that is exactly `/mcp`, or `/mcp reconnect`, `/mcp enable`,
|
||||
* 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" };
|
||||
const action = words[1]?.toLowerCase();
|
||||
if (action !== "reconnect" && action !== "enable" && action !== "disable") return null;
|
||||
const server = words.slice(2).join(" ");
|
||||
if (server === "all" || (server === "" && action === "reconnect")) {
|
||||
return { action, all: true };
|
||||
}
|
||||
return server === "" ? { action, all: false } : { action, all: false, server };
|
||||
}
|
||||
|
||||
/** Read the MCP server status for the list after a `/mcp` command. Resolves
|
||||
* to null when the read fails, takes too long, or `signal` aborts. Then the
|
||||
* turn keeps the text of Claude Code. */
|
||||
export async function readMcpServerStatus(
|
||||
query: Pick<Query, "mcpServerStatus">,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpServerStatus[] | null> {
|
||||
if (signal.aborted) return null;
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(
|
||||
query.mcpServerStatus(),
|
||||
MCP_STATUS_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
if (outcome.type === "timeout") {
|
||||
logError("The MCP server status read timed out; keeping the Claude Code text of /mcp");
|
||||
}
|
||||
return outcome.type === "done" ? outcome.value : null;
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
logError(`The MCP server status read failed; keeping the Claude Code text of /mcp: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The known statuses in the order of the groups and of the summary counts.
|
||||
* A disabled server is not a group. It is on one line at the end. */
|
||||
const GROUP_ORDER: McpServerStatus["status"][] = ["failed", "needs-auth", "pending", "connected"];
|
||||
|
||||
const GROUP_TITLES: Partial<Record<McpServerStatus["status"], string>> = {
|
||||
failed: "Failed",
|
||||
"needs-auth": "Needs authentication",
|
||||
pending: "Connecting",
|
||||
connected: "Connected",
|
||||
};
|
||||
|
||||
const SUMMARY_LABELS: Record<McpServerStatus["status"], string> = {
|
||||
connected: "connected",
|
||||
failed: "failed",
|
||||
"needs-auth": "need authentication",
|
||||
pending: "connecting",
|
||||
disabled: "disabled",
|
||||
};
|
||||
|
||||
const KNOWN_STATUSES: string[] = [...GROUP_ORDER, "disabled"];
|
||||
|
||||
const MAX_ERROR_LENGTH = 160;
|
||||
|
||||
/** The longest segment that can be a wrapper. A longer segment is content. */
|
||||
const MAX_WRAPPER_LENGTH = 60;
|
||||
|
||||
/** True for a server that a `/mcp reconnect` without a name must retry. */
|
||||
function needsReconnect(status: McpServerStatus): boolean {
|
||||
return (
|
||||
status.status === "failed" || status.status === "pending" || status.status === "needs-auth"
|
||||
);
|
||||
}
|
||||
|
||||
/** The text as a Markdown code span. The fence is longer than the longest
|
||||
* backtick run in the text, so the span shows the text unchanged. The user
|
||||
* can copy a server name from it into `/mcp reconnect`. */
|
||||
function codeSpan(text: string): string {
|
||||
const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
||||
const fence = "`".repeat(longestRun + 1);
|
||||
// CommonMark strips one space from each end of a span, so a pad keeps the text unchanged.
|
||||
const pad = text.startsWith("`") || text.endsWith("`") || /^ .*[^ ].* $/.test(text) ? " " : "";
|
||||
return `${fence}${pad}${text}${pad}${fence}`;
|
||||
}
|
||||
|
||||
function isErrorCode(segment: string): boolean {
|
||||
return /^-?\d+$/.test(segment);
|
||||
}
|
||||
|
||||
/** True for a segment that only wraps the real error, such as
|
||||
* `MCP startup failed`, `McpError`, `MCP error -32001`, an error code, or a
|
||||
* Rust or TypeScript type path. */
|
||||
function isWrapperSegment(segment: string): boolean {
|
||||
return (
|
||||
isErrorCode(segment) ||
|
||||
segment.includes("::") ||
|
||||
(segment.length <= MAX_WRAPPER_LENGTH &&
|
||||
!/[.!?]/.test(segment) &&
|
||||
/(failed|error|exception)(\s+-?\d+)?$/i.test(segment))
|
||||
);
|
||||
}
|
||||
|
||||
/** Cut the text to `MAX_ERROR_LENGTH` code points, so a surrogate pair stays whole. */
|
||||
function truncate(text: string): string {
|
||||
const chars = Array.from(text);
|
||||
if (chars.length <= MAX_ERROR_LENGTH) return text;
|
||||
const cut = chars.slice(0, MAX_ERROR_LENGTH - 1).join("");
|
||||
return `${cut.trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** Make an MCP error short and readable for one list item. The result has
|
||||
* no leading wrapper segments, at most two sentences, and at most
|
||||
* `MAX_ERROR_LENGTH` code points. A wrapper before an error code stays, so
|
||||
* an error code is never alone. The result is empty for an error with only
|
||||
* whitespace. */
|
||||
export function cleanMcpError(error: string): string {
|
||||
const segments = error.replace(/\s+/g, " ").trim().split(": ");
|
||||
let start = 0;
|
||||
while (
|
||||
start < segments.length - 1 &&
|
||||
isWrapperSegment(segments[start]!) &&
|
||||
!segments.slice(start + 1).every(isErrorCode)
|
||||
) {
|
||||
start++;
|
||||
}
|
||||
const content = segments.slice(start).join(": ");
|
||||
return truncate(
|
||||
content
|
||||
.split(/(?<=[.!?]) /)
|
||||
.slice(0, 2)
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
function toolCount(status: McpServerStatus): string | undefined {
|
||||
if (!status.tools) return undefined;
|
||||
return status.tools.length === 1 ? "1 tool" : `${status.tools.length} tools`;
|
||||
}
|
||||
|
||||
/** The server name as inline code. Claude Code keys the servers by name, so
|
||||
* two servers have the same name only from different scopes. Then the
|
||||
* scope tells them apart. */
|
||||
function serverName(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const origin = status.scope ?? status.source;
|
||||
return duplicates.has(status.name) && origin
|
||||
? `${codeSpan(status.name)} (${escapeMarkdown(origin)})`
|
||||
: codeSpan(status.name);
|
||||
}
|
||||
|
||||
function serverItem(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const error = status.error ? cleanMcpError(status.error) : "";
|
||||
const details = [error === "" ? undefined : escapeMarkdown(error), toolCount(status)]
|
||||
.filter((detail) => detail !== undefined)
|
||||
.join("; ");
|
||||
const name = serverName(status, duplicates);
|
||||
return details === "" ? `- ${name}` : `- ${name}: ${details}`;
|
||||
}
|
||||
|
||||
function summaryLine(statuses: McpServerStatus[]): string {
|
||||
// The counts follow the group order. An unknown status also counts, so the counts add up to the total.
|
||||
const counts = [...GROUP_ORDER, ...unknownStatuses(statuses), "disabled"]
|
||||
.map((state) => ({
|
||||
state,
|
||||
count: statuses.filter((status) => status.status === state).length,
|
||||
}))
|
||||
.filter(({ count }) => count > 0);
|
||||
const parts = counts.map(({ state, count }) =>
|
||||
count === 1 && state === "needs-auth"
|
||||
? "1 needs authentication"
|
||||
: `${count} ${SUMMARY_LABELS[state as McpServerStatus["status"]] ?? escapeMarkdown(state)}`,
|
||||
);
|
||||
return `**MCP servers:** ${statuses.length} (${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
/** The statuses that this adapter does not know, in the SDK order. The SDK
|
||||
* can report a new status before the adapter knows it. */
|
||||
function unknownStatuses(statuses: McpServerStatus[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
statuses
|
||||
.map((status) => status.status as string)
|
||||
.filter((state) => !KNOWN_STATUSES.includes(state)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** The group title of a status that the adapter does not know. It starts with a capital letter, as a known title does. */
|
||||
function unknownTitle(state: string): string {
|
||||
return escapeMarkdown(state.charAt(0).toUpperCase() + state.slice(1));
|
||||
}
|
||||
|
||||
/** Markdown for `/mcp`: a summary line, one group of servers for each
|
||||
* status, and the disabled servers on one line. A list reads better than a
|
||||
* table in a narrow chat. */
|
||||
export function formatMcpStatus(statuses: McpServerStatus[]): string {
|
||||
if (statuses.length === 0) return "No MCP servers are configured.";
|
||||
const blocks: string[] = [];
|
||||
const names = statuses.map((status) => status.name);
|
||||
const duplicates = new Set(names.filter((name, index) => names.indexOf(name) !== index));
|
||||
blocks.push(summaryLine(statuses));
|
||||
const groups: [string, string][] = [
|
||||
...GROUP_ORDER.map((state): [string, string] => [state, GROUP_TITLES[state]!]),
|
||||
...unknownStatuses(statuses).map((state): [string, string] => [state, unknownTitle(state)]),
|
||||
];
|
||||
for (const [state, title] of groups) {
|
||||
const members = statuses.filter((status) => status.status === state);
|
||||
if (members.length === 0) continue;
|
||||
blocks.push(
|
||||
[`**${title}**`, ...members.map((status) => serverItem(status, duplicates))].join("\n"),
|
||||
);
|
||||
}
|
||||
const disabled = statuses.filter((status) => status.status === "disabled");
|
||||
if (disabled.length > 0) {
|
||||
blocks.push(
|
||||
`**Disabled:** ${disabled.map((status) => serverName(status, duplicates)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (statuses.some(needsReconnect)) {
|
||||
blocks.push(
|
||||
"Run `/mcp reconnect <server>` to reconnect one server, or `/mcp reconnect` to reconnect every server that is not connected and not disabled.",
|
||||
);
|
||||
}
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
/** The SDK query methods that `/mcp` uses. */
|
||||
export type McpControlQuery = Pick<
|
||||
Query,
|
||||
"mcpServerStatus" | "reconnectMcpServer" | "toggleMcpServer"
|
||||
>;
|
||||
|
||||
/** The result of {@link runMcpCommand}. `markdown` is null when the turn
|
||||
* keeps the text of Claude Code. `reconnected` holds the servers that the
|
||||
* adapter tried to reconnect. */
|
||||
export type McpCommandOutcome = { markdown: string | null; reconnected: string[] };
|
||||
|
||||
type CallResult = { type: "done" } | { type: "failed"; error: string } | { type: "aborted" };
|
||||
|
||||
/** Wait for one control call. The wait stops when `signal` aborts or the
|
||||
* call takes more than {@link MCP_ACTION_TIMEOUT_MS}. */
|
||||
async function awaitCall(call: () => Promise<void>, signal: AbortSignal): Promise<CallResult> {
|
||||
if (signal.aborted) return { type: "aborted" };
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(call(), MCP_ACTION_TIMEOUT_MS, signal);
|
||||
if (outcome.type === "timeout") return { type: "failed", error: "The request timed out." };
|
||||
return outcome.type === "done" ? { type: "done" } : outcome;
|
||||
} catch (error) {
|
||||
return signal.aborted
|
||||
? { type: "aborted" }
|
||||
: { type: "failed", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_DONE = { reconnect: "Reconnected", enable: "Enabled", disable: "Disabled" };
|
||||
|
||||
/** The Claude Code sentence that refuses an action in SDK mode. */
|
||||
const UNAVAILABLE_SENTENCE = /aren't available in this session/i;
|
||||
|
||||
/** A Claude Code sentence that points to a terminal, which an ACP client
|
||||
* does not have. */
|
||||
function isTerminalSentence(sentence: string): boolean {
|
||||
return /in the terminal/i.test(sentence) || sentence.includes("`/mcp reconnect all` here");
|
||||
}
|
||||
|
||||
function sentences(text: string): string[] {
|
||||
return text
|
||||
.trim()
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.filter((sentence) => sentence !== "");
|
||||
}
|
||||
|
||||
/** The servers that the action changes. A named server is always a target,
|
||||
* so the control API reports its own error. */
|
||||
function actionTargets(
|
||||
command: Extract<McpCommand, { all: boolean }>,
|
||||
statuses: McpServerStatus[],
|
||||
): string[] {
|
||||
if (!command.all) return command.server === undefined ? [] : [command.server];
|
||||
const names = statuses
|
||||
.filter((status) => {
|
||||
if (command.action === "reconnect") return needsReconnect(status);
|
||||
if (command.action === "enable") return status.status === "disabled";
|
||||
return status.status !== "disabled";
|
||||
})
|
||||
.map((status) => status.name);
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
function nothingToDoLine(action: McpCommand["action"]): string {
|
||||
if (action === "enable") return "Every MCP server is already enabled.";
|
||||
if (action === "disable") return "Every MCP server is already disabled.";
|
||||
return "Every MCP server is connected or disabled. There is nothing to reconnect.";
|
||||
}
|
||||
|
||||
/** Run a `/mcp` command after Claude Code sent `originalOutput`, its text for
|
||||
* the command. Claude Code refuses a reconnect, an enable, and a disable in
|
||||
* SDK mode, so the adapter runs them through the SDK control API. The result
|
||||
* of each server goes above the server list. An error of one server does
|
||||
* not stop the others. When the first status read fails or `signal`
|
||||
* aborts, the turn keeps the text of Claude Code. */
|
||||
export async function runMcpCommand(
|
||||
query: McpControlQuery,
|
||||
command: McpCommand,
|
||||
originalOutput: string,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpCommandOutcome> {
|
||||
const reconnected: string[] = [];
|
||||
const keep: McpCommandOutcome = { markdown: null, reconnected };
|
||||
if (command.action === "status") {
|
||||
// The list replaces the Claude Code summary line.
|
||||
const statuses = await readMcpServerStatus(query, signal, logError);
|
||||
return { markdown: statuses ? formatMcpStatus(statuses) : null, reconnected };
|
||||
}
|
||||
const before = await readMcpServerStatus(query, signal, logError);
|
||||
if (!before) return keep;
|
||||
|
||||
const cliSentences = sentences(originalOutput).filter(
|
||||
(sentence) => !isTerminalSentence(sentence),
|
||||
);
|
||||
const cliNamesUnknown = cliSentences.some((sentence) => /no MCP server named/i.test(sentence));
|
||||
const results: string[] = [];
|
||||
if (!command.all && command.server === undefined) {
|
||||
results.push(`Name a server or \`all\`, for example \`/mcp ${command.action} <server>\`.`);
|
||||
}
|
||||
const known = new Set(before.map((status) => status.name));
|
||||
const targets = actionTargets(command, before);
|
||||
if (command.all && targets.length === 0) results.push(nothingToDoLine(command.action));
|
||||
for (const server of targets) {
|
||||
if (!known.has(server)) {
|
||||
// Claude Code checks the name first. Its sentence then stays.
|
||||
if (!cliNamesUnknown) results.push(`There's no MCP server named ${codeSpan(server)}.`);
|
||||
continue;
|
||||
}
|
||||
if (command.action === "reconnect") reconnected.push(server);
|
||||
const call =
|
||||
command.action === "reconnect"
|
||||
? () => query.reconnectMcpServer(server)
|
||||
: () => query.toggleMcpServer(server, command.action === "enable");
|
||||
const result = await awaitCall(call, signal);
|
||||
if (result.type === "aborted") return keep;
|
||||
if (result.type === "done") {
|
||||
results.push(`- ${ACTION_DONE[command.action]} ${codeSpan(server)}`);
|
||||
} else {
|
||||
const error = cleanMcpError(result.error);
|
||||
const failure = `- Couldn't ${command.action} ${codeSpan(server)}`;
|
||||
results.push(error === "" ? failure : `${failure}: ${escapeMarkdown(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
const kept = cliSentences.filter((sentence) => !UNAVAILABLE_SENTENCE.test(sentence));
|
||||
if (kept.length > 0) blocks.push(kept.join(" "));
|
||||
if (results.length > 0) blocks.push(results.join("\n"));
|
||||
const after = await readMcpServerStatus(query, signal, logError);
|
||||
if (signal.aborted) return keep;
|
||||
if (after) blocks.push(formatMcpStatus(after));
|
||||
return { markdown: blocks.join("\n\n"), reconnected };
|
||||
}
|
||||
@@ -324,10 +324,13 @@ export class NativeSubagentRuntime {
|
||||
/**
|
||||
* The route of the work that a child tool call started, for example an async
|
||||
* task. The route sends each update to the child generation that owned the
|
||||
* tool call when the work started, and drops the update after that 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.
|
||||
* 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.
|
||||
*/
|
||||
routeOfToolCall(
|
||||
toolCallId: string,
|
||||
@@ -337,7 +340,13 @@ export class NativeSubagentRuntime {
|
||||
const owner =
|
||||
this.childByToolCall.get(toolCallId) ??
|
||||
(eagerSessionId ? this.childOfSession(eagerSessionId) : undefined);
|
||||
return owner && ((notification) => this.toChild(owner, notification, undefined));
|
||||
return (
|
||||
owner &&
|
||||
((notification) =>
|
||||
isLateAsyncTaskUpdate(notification)
|
||||
? { ...notification, sessionId: owner.sessionId }
|
||||
: this.toChild(owner, notification, undefined))
|
||||
);
|
||||
}
|
||||
|
||||
private rememberToolCallOwner(toolCallId: string, child: NativeSubagent): void {
|
||||
@@ -581,6 +590,20 @@ export function isNativeSubagentControlTool(toolName: unknown): boolean {
|
||||
return toolName === "Agent" || toolName === "Task";
|
||||
}
|
||||
|
||||
/**
|
||||
* An async task update that may reach a child generation after it finished.
|
||||
* The spawn is one of them: a held task can get its tool call id, and so its
|
||||
* spawn, after the child finished.
|
||||
*/
|
||||
function isLateAsyncTaskUpdate(notification: AcpSessionNotification): boolean {
|
||||
const kind = notification.update.sessionUpdate;
|
||||
return (
|
||||
kind === "async_task_spawned" ||
|
||||
kind === "async_task_progress" ||
|
||||
kind === "async_task_state_update"
|
||||
);
|
||||
}
|
||||
|
||||
function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boolean {
|
||||
return (
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
|
||||
@@ -3,13 +3,26 @@ import { AIR_PERMISSION_KEY, withAirMeta } from "../air-extension.js";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
|
||||
|
||||
/**
|
||||
* A permission request as the agent builds it. For a v2 client it also
|
||||
* carries the heading and the explanation of the prompt, which ACP v2 sends as
|
||||
* the request's `title` and `description`. v1 has no field for them; AIR gets
|
||||
* them in `_meta`.
|
||||
*/
|
||||
export type AcpPermissionRequest = RequestPermissionRequest & {
|
||||
/** The heading of the prompt. It is not the title of the tool call. */
|
||||
title?: string;
|
||||
/** Why the permission is asked. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export interface ClaudePermissionPresentationInput {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
toolUseID: string;
|
||||
cwd?: string;
|
||||
capabilities?: ClientCapabilities;
|
||||
/** The exact patch of the change to approve, for a negotiated `diffPatch` client. */
|
||||
/** The exact patch of the change to approve, for a negotiated `diffPatch` client or a v2 client. */
|
||||
previewContent?: ToolCallContent[];
|
||||
blockedPath?: string;
|
||||
title?: string;
|
||||
@@ -50,10 +63,13 @@ function compactText(value: unknown): string | undefined {
|
||||
* adapter emits the `tool_call` before the request, so the client already
|
||||
* holds the rest. The request adds only what it shows new: the exact preview
|
||||
* patch, and the blocked path when the tool call has no such location.
|
||||
*
|
||||
* The heading and the explanation of the prompt go to AIR in `_meta`, and to a
|
||||
* v2 client as `title` and `description`.
|
||||
*/
|
||||
export function buildClaudePermissionPresentation(
|
||||
value: ClaudePermissionPresentationInput,
|
||||
): Pick<RequestPermissionRequest, "toolCall" | "_meta"> {
|
||||
): Pick<AcpPermissionRequest, "toolCall" | "_meta" | "title" | "description"> {
|
||||
const capabilities = value.capabilities ?? new ClientCapabilities();
|
||||
const renderer = new AcpToolCallRenderer(capabilities);
|
||||
const toolUse = { id: value.toolUseID, name: value.toolName, input: value.input };
|
||||
@@ -116,5 +132,6 @@ export function buildClaudePermissionPresentation(
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,31 +68,46 @@ export async function readResumedSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** What a resume restores from the end of the local transcript. */
|
||||
export type ResumedTail = {
|
||||
model?: string;
|
||||
/** The last permission mode of the main thread. See {@link TailScan}. */
|
||||
permissionMode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the resume model of a session from the end of its local transcript.
|
||||
* Read the resume model and the permission mode of a session from the end
|
||||
* of its local transcript.
|
||||
*
|
||||
* A resume needs only the model, not the messages. The transcript file is
|
||||
* read backwards until the last real assistant record of the main thread, so
|
||||
* the cost does not grow with the length of the session. A session without a
|
||||
* local transcript file falls back to {@link readResumedSession}.
|
||||
* A resume needs only these two values, not the messages. The transcript file
|
||||
* is read backwards until both are known, so the cost does not grow with the
|
||||
* length of the session. A session without a local transcript file falls back
|
||||
* to {@link readResumedSession} for the model.
|
||||
*
|
||||
* A caller that already has the model passes `withModel: false`. The read then
|
||||
* looks for the permission mode only and has no fallback.
|
||||
*/
|
||||
export async function readResumedModel(
|
||||
export async function readResumedTail(
|
||||
sessionId: string,
|
||||
logger?: ResumeLogger,
|
||||
): Promise<string | undefined> {
|
||||
withModel = true,
|
||||
): Promise<ResumedTail> {
|
||||
const timing = new SessionTiming(logger, "models", sessionId);
|
||||
try {
|
||||
const filePath = await findTranscript(sessionId);
|
||||
if (filePath) {
|
||||
const model = await lastAssistantModel(filePath);
|
||||
timing.phase("read-transcript-tail", ` model=${model ?? "unknown"}`);
|
||||
return model;
|
||||
const tail = await lastTailRecords(filePath, withModel);
|
||||
timing.phase(
|
||||
"read-transcript-tail",
|
||||
` model=${tail.model ?? "unknown"} permissionMode=${tail.permissionMode ?? "unknown"}`,
|
||||
);
|
||||
return tail;
|
||||
}
|
||||
} catch (error) {
|
||||
timing.phase("read-transcript-tail", " outcome=error");
|
||||
logger?.error(`Failed to read the transcript tail of resumed session ${sessionId}:`, error);
|
||||
}
|
||||
return (await readResumedSession(sessionId, logger)).model;
|
||||
return withModel ? { model: (await readResumedSession(sessionId, logger)).model } : {};
|
||||
}
|
||||
|
||||
/** The local transcript of a session in any project directory, as the SDK looks it up. */
|
||||
@@ -116,9 +131,11 @@ export async function findTranscript(sessionId: string): Promise<string | undefi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The model of the last real assistant record of the main thread in a JSONL transcript. */
|
||||
async function lastAssistantModel(filePath: string): Promise<string | undefined> {
|
||||
/** The model of the last real assistant record and the last permission mode
|
||||
* of the main thread in a JSONL transcript. */
|
||||
async function lastTailRecords(filePath: string, withModel: boolean): Promise<ResumedTail> {
|
||||
const handle = await open(filePath, "r");
|
||||
const scan = new TailScan(withModel);
|
||||
try {
|
||||
let end = (await handle.stat()).size;
|
||||
// The bytes of the line whose start is not read yet, in file order.
|
||||
@@ -128,31 +145,99 @@ async function lastAssistantModel(filePath: string): Promise<string | undefined>
|
||||
let chunk = Buffer.alloc(end - start);
|
||||
await handle.read(chunk, 0, chunk.length, start);
|
||||
for (let at = chunk.lastIndexOf(0x0a); at >= 0; at = chunk.lastIndexOf(0x0a)) {
|
||||
const model = assistantModelOfLine(Buffer.concat([chunk.subarray(at + 1), ...pending]));
|
||||
if (model) return model;
|
||||
if (scan.visit(Buffer.concat([chunk.subarray(at + 1), ...pending]))) return scan.tail;
|
||||
pending = [];
|
||||
chunk = chunk.subarray(0, at);
|
||||
}
|
||||
pending.unshift(chunk);
|
||||
end = start;
|
||||
}
|
||||
return assistantModelOfLine(Buffer.concat(pending));
|
||||
scan.visit(Buffer.concat(pending));
|
||||
return scan.tail;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assistantModelOfLine(line: Buffer): string | undefined {
|
||||
// Most lines are not assistant records. The check skips their JSON parse.
|
||||
if (line.indexOf('"assistant"') < 0) return undefined;
|
||||
type TranscriptRecord = {
|
||||
type?: unknown;
|
||||
isSidechain?: unknown;
|
||||
message?: { model?: unknown };
|
||||
permissionMode?: unknown;
|
||||
origin?: { kind?: unknown };
|
||||
attachment?: { type?: unknown };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the lines of a transcript from the end and keeps what a resume needs.
|
||||
*
|
||||
* Claude Code records the permission mode on each prompt record of type `user`.
|
||||
* The `permission-mode` records of the CLI metadata block are not used: they
|
||||
* can disagree with the prompts, and the SDK does not write them.
|
||||
* The SDK writes no record when it leaves plan mode through ExitPlanMode.
|
||||
* It writes a `plan_mode_exit` attachment only, without the new mode. Thus a
|
||||
* `plan` mode before a later plan exit is not the current mode, and the scan
|
||||
* gives no mode.
|
||||
*
|
||||
* The mode search stops at the first mode record or at the last human prompt.
|
||||
* Each human prompt of a current Claude Code has the mode, so a prompt without
|
||||
* it means that the transcript does not record modes. A compaction does not
|
||||
* stop the search: the mode of the prompt before it is still current.
|
||||
*/
|
||||
class TailScan {
|
||||
readonly tail: ResumedTail = {};
|
||||
private modeDone = false;
|
||||
private planExited = false;
|
||||
|
||||
constructor(private readonly withModel: boolean) {}
|
||||
|
||||
/** Visit the previous line. Return true when the scan has all it needs. */
|
||||
visit(line: Buffer): boolean {
|
||||
let parsed: TranscriptRecord | null | undefined;
|
||||
// Most lines have no marker of interest. The check skips their JSON parse.
|
||||
const record = (marker: string): TranscriptRecord | undefined => {
|
||||
if (line.indexOf(marker) < 0) return undefined;
|
||||
if (parsed === undefined) parsed = parseMainThreadRecord(line);
|
||||
return parsed ?? undefined;
|
||||
};
|
||||
if (this.withModel && this.tail.model === undefined) {
|
||||
const entry = record('"assistant"');
|
||||
if (entry?.type === "assistant") this.tail.model = concreteModel(entry.message?.model);
|
||||
}
|
||||
if (!this.modeDone) this.visitForMode(record);
|
||||
return (!this.withModel || this.tail.model !== undefined) && this.modeDone;
|
||||
}
|
||||
|
||||
private visitForMode(record: (marker: string) => TranscriptRecord | undefined): void {
|
||||
const modeEntry = record('"permissionMode"');
|
||||
if (
|
||||
modeEntry?.type === "user" &&
|
||||
typeof modeEntry.permissionMode === "string" &&
|
||||
modeEntry.permissionMode.trim() !== ""
|
||||
) {
|
||||
const mode = modeEntry.permissionMode.trim();
|
||||
this.modeDone = true;
|
||||
if (!(this.planExited && mode === "plan")) this.tail.permissionMode = mode;
|
||||
return;
|
||||
}
|
||||
if (record('"plan_mode_exit"')?.attachment?.type === "plan_mode_exit") {
|
||||
this.planExited = true;
|
||||
return;
|
||||
}
|
||||
const human = record('"human"');
|
||||
if (human?.type === "user" && human.origin?.kind === "human") this.modeDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The parsed record of one line, or null for a subagent record or a bad line. */
|
||||
function parseMainThreadRecord(line: Buffer): TranscriptRecord | null {
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line.toString("utf8"));
|
||||
} catch {
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") return undefined;
|
||||
const record = entry as { type?: unknown; isSidechain?: unknown; message?: { model?: unknown } };
|
||||
if (record.type !== "assistant" || record.isSidechain === true) return undefined;
|
||||
return concreteModel(record.message?.model);
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const record = entry as TranscriptRecord;
|
||||
return record.isSidechain === true ? null : record;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ export function serveAcp(
|
||||
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
|
||||
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
|
||||
return {
|
||||
// The router types `closed` as optional, but always sets it.
|
||||
closed: closed!,
|
||||
closed,
|
||||
dispose: async () => {
|
||||
await agent?.dispose();
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** Shared sentinels and ids for session preferences managed by the adapter. */
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
|
||||
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))];
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
Query,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_KIND_KEY, airOnlyMeta } from "./air-extension.js";
|
||||
import { resolvePermissionMode } from "./permissions/modes.js";
|
||||
import {
|
||||
noticeOrTranscriptUpdate,
|
||||
noticeTranscriptText,
|
||||
@@ -74,6 +75,37 @@ type InitializeSessionModeParams = {
|
||||
allowBypass: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission mode a new Claude query starts in.
|
||||
*
|
||||
* An explicit mode wins. A resume or a load continues in the mode of the last
|
||||
* user record of the transcript. If that mode is unknown or not available now,
|
||||
* for example bypass, the session starts in `defaultMode` of the settings.
|
||||
*/
|
||||
export function resolveInitialPermissionMode(
|
||||
sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown },
|
||||
logger: { error: (...args: unknown[]) => void },
|
||||
allowBypass: boolean,
|
||||
): PermissionMode {
|
||||
if (sources.explicit !== undefined) {
|
||||
return resolvePermissionMode(sources.explicit, logger, allowBypass);
|
||||
}
|
||||
if (sources.resumed !== undefined) {
|
||||
let rejected = false;
|
||||
const mode = resolvePermissionMode(
|
||||
sources.resumed,
|
||||
{ error: () => (rejected = true) },
|
||||
allowBypass,
|
||||
);
|
||||
if (!rejected) return mode;
|
||||
logger.error(
|
||||
`Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` +
|
||||
`not available in this session.`,
|
||||
);
|
||||
}
|
||||
return resolvePermissionMode(sources.defaultMode, logger, allowBypass);
|
||||
}
|
||||
|
||||
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
|
||||
export class SessionModeManager<S extends SessionMode> {
|
||||
constructor(private readonly options: SessionModeManagerOptions<S>) {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -399,6 +399,142 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
describe("on resume", () => {
|
||||
const sessionId = "resumed-mode-session";
|
||||
|
||||
async function setup(records: unknown[]) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "plan" } }),
|
||||
);
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
const transcriptDir = path.join(tempDir, "projects", "-project");
|
||||
await fs.promises.mkdir(transcriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(transcriptDir, `${sessionId}.jsonl`),
|
||||
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
return { agent, projectDir, getCapturedOptions };
|
||||
}
|
||||
|
||||
async function resume(records: unknown[], meta: object = {}) {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup(records);
|
||||
const response = await (agent as any).createSession(
|
||||
{ cwd: projectDir, mcpServers: [], _meta: { disableBuiltInTools: true, ...meta } },
|
||||
{ resume: sessionId },
|
||||
);
|
||||
return { permissionMode: getCapturedOptions().permissionMode, response };
|
||||
}
|
||||
|
||||
let previousUuid: string | null = null;
|
||||
const user = (permissionMode?: string) => {
|
||||
const uuid = crypto.randomUUID();
|
||||
const record = {
|
||||
type: "user",
|
||||
uuid,
|
||||
parentUuid: previousUuid,
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
isSidechain: false,
|
||||
origin: { kind: "human" },
|
||||
message: { role: "user", content: "hello" },
|
||||
...(permissionMode ? { permissionMode } : {}),
|
||||
};
|
||||
previousUuid = uuid;
|
||||
return record;
|
||||
};
|
||||
|
||||
it("continues in the last permission mode of the transcript", async () => {
|
||||
const { permissionMode, response } = await resume([user("plan"), user("acceptEdits")]);
|
||||
|
||||
expect(permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when the transcript has no mode", async () => {
|
||||
const { permissionMode, response } = await resume([user()]);
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when bypass is not available now", async () => {
|
||||
const { permissionMode, response } = await resume([user("bypassPermissions")], {
|
||||
claudeCode: { options: { allowDangerouslySkipPermissions: false } },
|
||||
});
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("continues in the last permission mode on session/load", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([
|
||||
user("plan"),
|
||||
user("acceptEdits"),
|
||||
]);
|
||||
|
||||
const response = await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes?.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("keeps the live mode when it recreates the session", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]);
|
||||
const params = { sessionId, cwd: projectDir, _meta: { disableBuiltInTools: true } };
|
||||
await agent.resumeSession({ ...params, mcpServers: [] });
|
||||
await agent.setSessionMode({ sessionId, modeId: "default" });
|
||||
|
||||
// Other MCP servers change the fingerprint, so the agent recreates the query.
|
||||
const response = await agent.resumeSession({
|
||||
...params,
|
||||
mcpServers: [{ name: "other", command: "true", args: [], env: [] }],
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes?.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("keeps the live mode when a sign-out respawn starts a fresh conversation", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]);
|
||||
await agent.resumeSession({
|
||||
sessionId,
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
await agent.setSessionMode({ sessionId, modeId: "default" });
|
||||
const { RequestError } = await import("@agentclientprotocol/sdk");
|
||||
const createSession = (agent as any).createSession.bind(agent);
|
||||
const creations: object[] = [];
|
||||
(agent as any).createSession = async (params: object, options: { resume?: string }) => {
|
||||
creations.push(options);
|
||||
// The CLI never wrote the conversation, so the resume fails.
|
||||
if (options.resume) throw RequestError.resourceNotFound(sessionId);
|
||||
return createSession(params, options);
|
||||
};
|
||||
|
||||
await (agent as any).recreateSignedOutQuery(sessionId, (agent as any).sessions[sessionId]);
|
||||
|
||||
expect(creations).toEqual([
|
||||
{ resume: sessionId, permissionMode: "default" },
|
||||
{ reuseSessionId: sessionId, permissionMode: "default" },
|
||||
]);
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'default' when permissions.defaultMode is invalid", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -60,4 +60,39 @@ describe("compareWithBaseline", () => {
|
||||
expect.stringMatching(/^origin\/main did not send /u),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("available_commands_update", () => {
|
||||
const commands = (...names: string[]) =>
|
||||
update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: names.map((name) => ({ name, description: name })),
|
||||
});
|
||||
|
||||
it("accepts the mcp command of the adapter", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("compact", "mcp")])).toEqual([]);
|
||||
expect(compareWithBaseline([commands()], [commands("mcp")])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports any other extra command", () => {
|
||||
expect(compareWithBaseline([commands()], [commands("mcp", "other")])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"name":"other"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a dropped command", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("mcp")])).toContainEqual(
|
||||
expect.stringMatching(/^origin\/main sent .*"name":"compact"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a changed mcp command that origin/main listed", () => {
|
||||
const changed = update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "mcp", description: "other" }],
|
||||
});
|
||||
expect(compareWithBaseline([commands("mcp")], [changed])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"description":"other"/u),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
171
src/tests/acp-scenarios-v2.test.ts
Normal file
171
src/tests/acp-scenarios-v2.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2
|
||||
* surface: through the protocol router, with the SDK's v2 client.
|
||||
*
|
||||
* - Status: each scenario passes, every prompt turn ending with a stop reason,
|
||||
* unless {@link V2_STOPS} names why it stops. Every scenario passes today;
|
||||
* a scenario that the v2 surface cannot run yet goes in the table.
|
||||
* - Schema: every message the agent sends, also in a scenario that stops, is
|
||||
* valid against the draft v2 schema of the SDK.
|
||||
* - Tool calls: the first report of each tool call has a title. v2 has no
|
||||
* `tool_call`, so a report of a tool call that the client has not seen
|
||||
* creates it, where v1 drops it.
|
||||
* - Golden files: `acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
|
||||
* passing scenario. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u`
|
||||
* to update them.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import {
|
||||
resetIds,
|
||||
runScenarioV2,
|
||||
type ScenarioRunV2,
|
||||
type WireRecorded,
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import { canonical } from "./acp-scenarios/compare.js";
|
||||
import { validateV2Message } from "./acp-scenarios/schema.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// The recordings replace only the ids that the run generated, so the harness
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
const id = actual.randomUUID(...args);
|
||||
harness.noteGeneratedId(id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PASSES = "passes";
|
||||
|
||||
/**
|
||||
* The scenarios that stop on the v2 surface, each with the reason, which names
|
||||
* the step of `docs/acp-v2.md` that removes it. Every other scenario passes.
|
||||
*/
|
||||
const V2_STOPS: Record<string, string> = {};
|
||||
|
||||
const statusOf = (scenario: string) => V2_STOPS[scenario] ?? PASSES;
|
||||
|
||||
const runs = new Map<string, ScenarioRunV2>();
|
||||
let configDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// A run must not depend on the machine: no remote login, no Claude CLI, no
|
||||
// user settings, and the bypass mode also for root.
|
||||
for (const name of ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER"]) {
|
||||
vi.stubEnv(name, "");
|
||||
}
|
||||
vi.stubEnv("CLAUDE_CODE_REMOTE", "");
|
||||
vi.stubEnv("ANTHROPIC_MODEL", "");
|
||||
vi.stubEnv("IS_SANDBOX", "1");
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { acpProtocolRouter } = await import("../serve.js");
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
const quiet = { log: () => {}, error: () => {} };
|
||||
runs.set(
|
||||
scenario.name,
|
||||
await runScenarioV2(
|
||||
(stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream),
|
||||
scenario,
|
||||
),
|
||||
);
|
||||
await Promise.all(agents.map((agent) => agent.dispose()));
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(scenario: string): ScenarioRunV2 {
|
||||
const recorded = runs.get(scenario);
|
||||
if (!recorded) throw new Error(`no v2 run of ${scenario}`);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function toJsonLines(recorded: WireRecorded[]): string {
|
||||
return recorded.map((record) => `${canonical(record)}\n`).join("");
|
||||
}
|
||||
|
||||
describe("ACP v2 scenarios", () => {
|
||||
it("pass, or stop as the table says", () => {
|
||||
const table = (status: (scenario: string) => string) =>
|
||||
Object.fromEntries(SCENARIOS.map((scenario) => [scenario.name, status(scenario.name)]));
|
||||
expect(table((scenario) => run(scenario).status)).toEqual(table(statusOf));
|
||||
});
|
||||
|
||||
describe("schema check", () => {
|
||||
const chunk = (update: Record<string, unknown>): WireRecorded => ({
|
||||
kind: "notification",
|
||||
method: "session/update",
|
||||
payload: { sessionId: "s", update: { sessionUpdate: "agent_message_chunk", ...update } },
|
||||
});
|
||||
|
||||
it("rejects a message that breaks the v2 schema", () => {
|
||||
const content = { type: "text", text: "hi" };
|
||||
expect(validateV2Message(chunk({ messageId: "m", content }))).toEqual([]);
|
||||
// v2 requires the id of the message a chunk belongs to.
|
||||
expect(validateV2Message(chunk({ content }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
expect(run(scenario).raw.flatMap((message) => validateV2Message(message))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool calls are reported with a title first", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
const reported = new Set<string>();
|
||||
const untitled: string[] = [];
|
||||
for (const message of run(scenario).raw) {
|
||||
if (message.kind !== "notification" || message.method !== "session/update") continue;
|
||||
const { sessionId, update } = message.payload as {
|
||||
sessionId: string;
|
||||
update: { sessionUpdate: string; toolCallId?: string; title?: unknown };
|
||||
};
|
||||
if (update.sessionUpdate !== "tool_call_update") continue;
|
||||
const key = `${sessionId} ${update.toolCallId}`;
|
||||
if (!reported.has(key) && typeof update.title !== "string") untitled.push(key);
|
||||
reported.add(key);
|
||||
}
|
||||
expect(untitled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2 golden files", () => {
|
||||
const passing = SCENARIOS.map((scenario) => scenario.name).filter(
|
||||
(scenario) => statusOf(scenario) === PASSES,
|
||||
);
|
||||
it.each(passing)("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_1","canStop":true,"description":"Build assets","name":"Build assets","outputFilePath":"/tmp/tasks/workflow_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"monitor_1","canStop":true,"description":"Watch the logs","name":"Watch the logs","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"monitor","toolCallId":"toolu_monitor"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"monitor_1","sessionUpdate":"async_task_progress","summary":"first line","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"monitor_1","sessionUpdate":"async_task_progress","summary":"second line","usage":{"durationMs":6,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","canStop":true,"description":"Watch the logs","name":"Watch the logs","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow","toolCallId":"toolu_workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"first line","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"second line","usage":{"durationMs":6,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_2","canStop":true,"description":"Build assets","name":"Build assets","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bg"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"error":"Context too small","version":1}}}},"content":[{"content":{"text":"Compaction failed: Context too small","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1}}}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"goal":{"controlMethod":"_session/goal","objective":"Ship the feature","status":"active"},"version":1}}},"sessionUpdate":"session_info_update"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"grep \"value\"","toolCallId":"toolu_grep"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"},"is_mcp_tool_call":true},"content":[],"kind":"other","name":"mcp__docs__search","sessionUpdate":"tool_call","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","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","status":"completed","title":"Recalled 2 memories","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","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","status":"completed","title":"Recalled synthesized memory","toolCallId":"00000000-0000-4000-8000-000000000002"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"SandboxNetworkAccess"}},"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Reason: Sandbox blocks network","title":"example.com","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"host":"example.com"},"title":"example.com","toolCallId":"toolu_net"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"content":[],"kind":"edit","locations":[],"name":"NotebookEdit","sessionUpdate":"tool_call","status":"pending","title":"Edit notebook","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/n.ipynb"}],"sessionUpdate":"tool_call_update","title":"Edit cell in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_denied"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Work","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"ing.","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}}
|
||||
@@ -12,4 +12,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"<session>:replay-subagent:toolu_r_task"}}}
|
||||
{"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"newSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Skill"}},"content":[],"kind":"other","name":"Skill","sessionUpdate":"tool_call","status":"pending","title":"Load skill","toolCallId":"toolu_skill"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent","toolResponse":{"isAsync":true,"status":"async_launched"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Check the logs","prompt":"Check logs","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_late","task":"Check logs"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Fix the build","prompt":"Make the build pass","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_n","task":"Make the build pass"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Plan the work","prompt":"Plan it","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_outer","task":"Plan it"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Explore code","prompt":"Find the parser","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_1","task":"Find the parser"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Summarize","prompt":"Summarize the repo","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_t","task":"Summarize the repo"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Write tests","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskOutput"}},"content":[],"kind":"other","name":"TaskOutput","sessionUpdate":"tool_call","status":"pending","title":"TaskOutput","toolCallId":"toolu_out"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Let me thin","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"k about it.","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Writing tests","priority":"medium","status":"in_progress"},{"content":"Ship","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_slow"}},"content":[{"terminalId":"toolu_slow","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"content":[],"kind":"fetch","name":"WebFetch","sessionUpdate":"tool_call","status":"pending","title":"Fetch","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"Fetch https://example.com","toolCallId":"toolu_fetch"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","sessionUpdate":"tool_call","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/old.ts"}],"sessionUpdate":"tool_call_update","title":"Write old.ts","toolCallId":"toolu_write"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","sessionUpdate":"tool_call","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/new.ts"}],"sessionUpdate":"tool_call_update","title":"Write new.ts","toolCallId":"toolu_write"}}}
|
||||
|
||||
15
src/tests/acp-scenarios/__snapshots__/v2/ask-user-question.jsonl
generated
Normal file
15
src/tests/acp-scenarios/__snapshots__/v2/ask-user-question.jsonl
generated
Normal file
@@ -0,0 +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":"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":{"_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":{"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":{"_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
src/tests/acp-scenarios/__snapshots__/v2/async-task-ends-before-tool-id.jsonl
generated
Normal file
6
src/tests/acp-scenarios/__snapshots__/v2/async-task-ends-before-tool-id.jsonl
generated
Normal file
@@ -0,0 +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":{"_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
src/tests/acp-scenarios/__snapshots__/v2/async-task-held-until-tool-id.jsonl
generated
Normal file
6
src/tests/acp-scenarios/__snapshots__/v2/async-task-held-until-tool-id.jsonl
generated
Normal file
@@ -0,0 +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":{"_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
src/tests/acp-scenarios/__snapshots__/v2/async-task-released-at-turn-end.jsonl
generated
Normal file
6
src/tests/acp-scenarios/__snapshots__/v2/async-task-released-at-turn-end.jsonl
generated
Normal file
@@ -0,0 +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":{"_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}}}}
|
||||
18
src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl
generated
Normal file
18
src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl
generated
Normal file
@@ -0,0 +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":{"_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":{"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":{"_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":{"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":{"_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}}}}
|
||||
17
src/tests/acp-scenarios/__snapshots__/v2/bash-error.jsonl
generated
Normal file
17
src/tests/acp-scenarios/__snapshots__/v2/bash-error.jsonl
generated
Normal file
@@ -0,0 +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":{"_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":{"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":{"_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":{"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":{"_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}}}}
|
||||
24
src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl
generated
Normal file
24
src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl
generated
Normal file
@@ -0,0 +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":{"_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":{"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":{"_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":{"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":{"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}}}
|
||||
{"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}}}}
|
||||
8
src/tests/acp-scenarios/__snapshots__/v2/compaction-failed-legacy.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/v2/compaction-failed-legacy.jsonl
generated
Normal file
@@ -0,0 +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":{"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}}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/v2/compaction-legacy.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/v2/compaction-legacy.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"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":{"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"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"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}}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/v2/compaction-update.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/v2/compaction-update.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"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":{"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"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"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}}}}
|
||||
18
src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl
generated
Normal file
18
src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl
generated
Normal file
@@ -0,0 +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":{"_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":{"_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":{"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":{"_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
src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl
generated
Normal file
19
src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl
generated
Normal file
@@ -0,0 +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":{"_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":{"_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":{"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":{"_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}}}}
|
||||
16
src/tests/acp-scenarios/__snapshots__/v2/exit-plan-approve.jsonl
generated
Normal file
16
src/tests/acp-scenarios/__snapshots__/v2/exit-plan-approve.jsonl
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{"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":"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":{"_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":{"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":{"_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}}}}
|
||||
15
src/tests/acp-scenarios/__snapshots__/v2/exit-plan-reject.jsonl
generated
Normal file
15
src/tests/acp-scenarios/__snapshots__/v2/exit-plan-reject.jsonl
generated
Normal file
@@ -0,0 +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":"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":{"_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":{"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":{"_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
src/tests/acp-scenarios/__snapshots__/v2/goal.jsonl
generated
Normal file
6
src/tests/acp-scenarios/__snapshots__/v2/goal.jsonl
generated
Normal file
@@ -0,0 +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":{"_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
src/tests/acp-scenarios/__snapshots__/v2/grep-and-glob.jsonl
generated
Normal file
19
src/tests/acp-scenarios/__snapshots__/v2/grep-and-glob.jsonl
generated
Normal file
@@ -0,0 +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":{"_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":{"_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":{"_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":{"_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}}}}
|
||||
16
src/tests/acp-scenarios/__snapshots__/v2/mcp-tool.jsonl
generated
Normal file
16
src/tests/acp-scenarios/__snapshots__/v2/mcp-tool.jsonl
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{"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"},"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}}}}
|
||||
8
src/tests/acp-scenarios/__snapshots__/v2/memory-recall.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/v2/memory-recall.jsonl
generated
Normal file
@@ -0,0 +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":{"_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}}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/v2/network-permission.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/v2/network-permission.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"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":"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"}}}
|
||||
{"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}}}}
|
||||
18
src/tests/acp-scenarios/__snapshots__/v2/notebook-edit.jsonl
generated
Normal file
18
src/tests/acp-scenarios/__snapshots__/v2/notebook-edit.jsonl
generated
Normal file
@@ -0,0 +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":{"_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":{"_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":{"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":{"_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}}}}
|
||||
17
src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl
generated
Normal file
17
src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl
generated
Normal file
@@ -0,0 +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":{"_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":{"_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":{"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":{"_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}}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user