mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: add the AIR sessionIndex extension for fast session lists (#1268)
## Summary
Adds the opt-in AIR `sessionIndex` extension. With it, an AIR client can
show the latest sessions of a project quickly, with their state, and can
rename, archive and unarchive them over ACP. The full contract is in
`docs/air-extensions.md` ("Session index").
The extension follows ACP RFDs. The transport differs: it uses
`_meta.jetbrains.air` request options and row fields, `_session/*`
methods, and AIR capabilities that are not advertised under ACP v2. The
`archived` list filter also differs (see below).
- **Session list extensions**
(agentclientprotocol/agent-client-protocol#2323):
- `limit`, ordered by last user activity (`lastPromptAt ?? updatedAt`),
and `includeWorktrees`.
- Row fields `lastPromptAt`, `model` and `forkedFrom`.
- `state`: `running`, `idle`, `requires_action`, and `error`, which the
RFD lacks.
- `lastTurnEndedAt`.
- `cost`: the SDK's `total_cost_usd` for a session that runs on this
connection only. No cost is read from transcripts.
- Live updates come from an AIR list subscription (below).
- **Session archive** (agentclientprotocol/agent-client-protocol#2161):
- `archived` is on every row, `session_info_update` is sent on a change,
and an unknown session is `-32002`.
- The `archived` list filter has three values: `"unarchived"` (the
default), `"archived"` (archived sessions only) and `"all"` (both, in
one order). #2161 has a boolean here. Its `false` is `"unarchived"`, its
`true` is `"all"`, and `"archived"` has no counterpart.
- **Capabilities**: an AIR client that declares `sessionIndex` gets
these in `_meta.jetbrains.air.capabilities`. The client declares only
`sessionIndex`.
- `sessionIndex`.
- `sessionArchive`: `_session/archive` and `_session/unarchive`. The RFD
capability is `archive` (#2161).
- `sessionRename`: `_session/rename`. The RFD has capability `setTitle`
and method `session/set_title`
(agentclientprotocol/agent-client-protocol#1987).
- `sessionListSubscribe`: `_session/list/subscribe`,
`_session/list/unsubscribe` and `_session/list/changes`. No RFD
counterpart.
### Clients without `sessionIndex`
Clients that don't declare `sessionIndex`, which means non-AIR clients
and released AIR builds, keep the current behaviour. They get the same
SDK `listSessions` path, page size, cursor and initialize capabilities,
with no watchers and no notifications. There are two deliberate
deviations, both strictly safer:
1. **`session/delete` from an AIR client without `sessionIndex`.**
Released AIR builds use delete as "Done". Instead of deleting the
transcript (data loss), the adapter archives the session in AIR's own
Claude format (below). Archived sessions are hidden from that client's
list, so "Done" looks the same and nothing is lost. An unknown session
keeps the SDK's error. Other clients get the SDK delete, as before.
2. **`session/close`** of a session that is not loaded here returns `{}`
instead of throwing.
### Sessions open in another process
`session/load`, `session/resume` and `session/prompt` work on a session
that another live Claude Code process has open, for every client, as the
Claude CLI does. Rename, archive, unarchive and delete also go ahead,
writing records or deleting transcripts as the CLI's `/rename` and
delete do. The other process keeps running.
### Archive format and rule
The archive state uses AIR's native Claude archive format
(`ClaudeStoreThreadBackend.appendTitleRecords`, ADR 0140), so AIR's
native path and the adapter read and write the same state.
- **Archive** appends `{"type":"custom-title","customTitle":"[archived]
<title>",…}` and `{"type":"agent-name","agentName":"[archived]
<title>",…}` to every transcript copy. **Unarchive** appends the same
pair without the prefix. Titles are cut to the CLI's 200 characters.
- **Archived is decided by the agent name first**, as in AIR's native
Claude provider. A session's name is its last top-level `agentName`,
else the custom title the SDK reports. The session is archived exactly
when that name starts with `[archived] `. The listed title comes from
the same source, shown without the prefix.
- Title records are metadata: `updatedAt` and the list order do not
move.
### Rename, archive and delete
- **Rename** writes the same title pair. An archived session keeps its
prefix. A session running here is renamed through its CLI's
`rename_session` control request, serialised with auto-titling. Other
sessions get the records plus the `custom-title.json` sidecar the CLI
writes. No generated title replaces a client title, but a title stored
later by someone else (a `/rename`) is still adopted.
- **Archiving stops the running session.** `_session/archive` of a
session loaded on this connection does four things:
1. It interrupts the running turn.
2. It retitles the session through its CLI. The adapter also appends the
matching `agent-name` record to the CLI's transcript, because the CLI
writes that record only later.
3. It closes the session as `session/close` does.
4. It archives the transcripts once more if the closed CLI left them
unarchived.
The client then gets `session_info_update` with `archived: true`.
Unarchive does not reopen the session. A session that is not loaded here
is archived by writing the title records.
- **Delete** for a `sessionIndex` client deletes every copy of the
session and its `<sessionId>/` directories. A session running here is
closed first.
### Implementation (`src/session-index/`)
- **Index**:
- Reads the project dirs with `readdir` + `stat`, and keeps a 2000-entry
metadata LRU keyed by (path, mtime, size).
- Cache misses are read in parallel through `getSessionInfo`, plus a
growing tail read (64 KB → 4 MB) for the last prompt, last message, turn
end and agent name.
- SDK semantics are kept: sidecar titles, `continued-in`, long-path
hashes, darwin case, and first-prompt extraction (ported, and
fuzz-compared with the SDK).
- The `cwd` scope follows the SDK `listSessions`. Worktrees expand to
the same relative subdirectory, and a session copied to several project
directories is listed from its newest copy.
- The keyset cursor is bound to `cwd`, `archived` and
`includeWorktrees`. Session ids match exactly.
- **State**:
- For sessions of this process, the SDK state.
- For other sessions, the Claude Code live registry
(`~/.claude/sessions/<pid>.json`, with pid + procStart liveness and
Claude Desktop's rules) or the transcript tail. The registry is read
only for the row state.
- `error` means the session is not running and its last turn ended with
an error, not a cancel.
Documented in `docs/air-extensions.md`, with an extension → RFD mapping
table and the remaining differences.
### Measured on a real `~/.claude` (233 transcripts, read-only)
| Case | New path | Old SDK path |
|---|---|---|
| cwd, limit 50, cold process | ~80 ms | 78–156 ms (all rows) |
| cwd, limit 50, warm | 1.3–4 ms | — |
## Session list subscription
Replaces polling the first page. It is only for a client that declared
`sessionIndex`; the agent advertises `sessionListSubscribe` with it,
never under ACP v2.
- **Subscribe**: `_session/list/subscribe { cwd }` returns `{
subscriptionId }`. `cwd` must be absolute, else `-32602`; other params
are ignored.
- The scope is `cwd` and the same subdirectory in every linked worktree,
in any archive state, without sidechains.
- **Changes**: `_session/list/changes { subscriptionId, sessions,
removed }`.
- `sessions` holds the full `session/list` row of each session that
appeared, or whose title, `lastPromptAt`, `state`, `lastTurnEndedAt`,
`cost`, `model`, `forkedFrom` or `archived` changed since the last row
sent.
- The first change of a session after subscribe is sent in full. After
that, a change of `updatedAt` alone sends nothing.
- `removed` lists sessions that were deleted or moved out of scope.
- Archive and unarchive are row changes.
- A session is sent at most once a second, with one notification per
subscription per pass.
- **Unsubscribe**: `_session/list/unsubscribe { subscriptionId }`
returns `{}` and is idempotent.
- A subscription lives until unsubscribe or until the connection closes.
- A connection has at most 128 subscriptions. The 129th is `-32602` with
`data.reason: "too_many_subscriptions"`.
- Subscriptions of the same `cwd` share one watch and are notified
independently.
- **Delivery** is best effort, with no resync and no sequence numbers. A
rescan every 10 s (and 1 s after a project directory watch opens) sends
whatever differs.
- **Implementation** (`list-subscriptions.ts`, `session-info.ts`):
- A non-recursive `fs.watch` on the scope's project dirs, the registry
and the projects root.
- A transcript event re-reads only that transcript, and a registry event
reads only that `<pid>.json`. Sessions running here push their SDK state
at once.
- Subscribe reads no session row. The rescan reads only transcripts
whose stat changed or whose read failed.
### Measured: one subscription on the 5,200-session project (Docker
`node:22`, 2 CPU, 2 GB, shared host)
300 s of churn: 20 sessions appending 10/s each (updatedAt-only),
registry status flips every 200 ms, and a session created every 2 s and
deleted 10 s later.
| | Old `list_changed` watcher | Subscription |
|---|---|---|
| Agent CPU p50 / max | 19% / — | 12.9% / 49% |
| Event loop p99 (p50 / max of 1 s windows) | — | 16 / 31 ms; worst
single delay 87 ms |
| Change latency p50 / p95 | 950 ms (hint) | create 291 / 570 ms, delete
279 / 506 ms; registry state 902 / 990 ms* |
| Notifications / min | — | 90 (896 rows, 0 rows for updatedAt-only
appends) |
| RSS | flat ~160 MB | 159 → 198 MB (heap flat ~50 MB) |
\* The registry flips the same 5 sessions every 200 ms, so each is held
to the once-a-second limit. Latency is measured from the first unsent
change.
These numbers were measured with an earlier subscribe that read every
row up front. Subscribe now reads no rows, so that one-off cost no
longer occurs. The lazy subscription has not been re-measured yet.
## Load test: 10,000 sessions (Docker, 2 CPU, 2 GB)
Calibrated data: 10k transcripts, 15 GB, 36k subagent files, and one
5,200-session project. Sizes match a real profile (p50 311K / p90 2.67M
/ p99 20.5M).
| Scenario (limit 50) | Before | After |
|---|---|---|
| Big project, first page cold / warm p50 | out of memory | 225 / 19 ms
|
| No cwd, cold / warm p50 | 1,722 / 178 ms | 209 / 45 ms |
| Page through 4,637 rows | 457 s | 11.4 s cold, 2.9 s warm |
| Delete p50 / 100 concurrent | 23.8 ms / 4.4 s | 7.8 ms / 0.8 s |
| 300 s of churn (60k changes) | out of memory after 3 s | list p50 54
ms, 0 stalls >50 ms, RSS flat ~160 MB |
## Streaming: keep the event loop responsive under a backlog
Under a backlog of buffered SDK messages, the adapter could freeze its
event loop for seconds. Every `query.next()` on a non-empty SDK queue
resolves in a microtask, so `runConsumer` forwarded the whole backlog
without reaching a macrotask boundary. Incoming JSON-RPC, including
`session/cancel`, waited until the backlog drained.
1. **Consumer yields to the event loop.** A shared `EventLoopYielder`
(`src/utils.ts`) gives the event loop a turn via `setImmediate` once the
current iteration has run for 8 ms. Order, content and cancel semantics
are unchanged.
2. **No per-message copy in `nodeToWebWritable`** for byte streams.
Object-mode streams keep the copy.
3. **Diff memo.** The approval preview and the PostToolUse patch no
longer diff the same texts twice. Timed-out diffs are not remembered.
4. **Prefix guards** in `parseMcpCommand` and `sanitizeTitle`. Both give
identical output.
5. **Replay yields between history messages**, with the same budget.
With 10 sessions × 1 MB, the max event-loop delay went from 4205 ms to
1626 ms, and ping p95 from 2793 ms to 262 ms (interleaved A/B, Docker
`node:22`, 2 CPU). Single-stream throughput is unchanged. Remaining
stalls are inside the Claude SDK and GC.
## Test plan
- [x] Rebased on `origin/main`. `npm run check`, `npm run build` and
`npm run test:run` pass.
- [x] Regression tests for the unchanged non-`sessionIndex` paths.
- [x] Regression tests for every review finding.
- [x] Reviews: Codex (gpt-6-astra) and a Claude review subagent, run as
independent passes against `origin/main` until a round found nothing of
normal or higher severity.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ It describes only this adapter.
|
|||||||
- [Agent file-change report](#agent-file-change-report)
|
- [Agent file-change report](#agent-file-change-report)
|
||||||
- [Session failure](#session-failure)
|
- [Session failure](#session-failure)
|
||||||
- [Native subagent sessions](#native-subagent-sessions)
|
- [Native subagent sessions](#native-subagent-sessions)
|
||||||
|
- [Session index](#session-index)
|
||||||
- [Context compaction](#context-compaction)
|
- [Context compaction](#context-compaction)
|
||||||
- [Question custom answers](#question-custom-answers)
|
- [Question custom answers](#question-custom-answers)
|
||||||
- [Session fork point](#session-fork-point)
|
- [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.
|
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.
|
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.
|
The response to a client that is not AIR has no `_meta.jetbrains` key.
|
||||||
|
|
||||||
### Capabilities
|
### Capabilities
|
||||||
|
|
||||||
| Capability | Advertised | What the adapter does when the client declares it | Section |
|
| 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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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`.
|
The adapter ignores `planContentDelta`.
|
||||||
Claude does not stream a plan, so the adapter never sends `contentDelta` (see [Plan file](#plan-file)).
|
Claude does not stream a plan, so the adapter never sends `contentDelta` (see [Plan file](#plan-file)).
|
||||||
@@ -1177,6 +1182,262 @@ This section covers only the AIR bridge.
|
|||||||
Child interactions stay on the root session.
|
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.
|
- 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
|
## Context compaction
|
||||||
|
|
||||||
The adapter implements the ACP session compaction RFD for a client that declares `clientCapabilities.session.compaction`.
|
The adapter implements the ACP session compaction RFD for a client that declares `clientCapabilities.session.compaction`.
|
||||||
|
|||||||
110
src/acp-agent.ts
110
src/acp-agent.ts
@@ -61,7 +61,6 @@ import {
|
|||||||
AccountInfo,
|
AccountInfo,
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
CanUseTool,
|
CanUseTool,
|
||||||
deleteSession,
|
|
||||||
FastModeDisabledReason,
|
FastModeDisabledReason,
|
||||||
FastModeState,
|
FastModeState,
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
@@ -132,6 +131,12 @@ import {
|
|||||||
clientSupportsAirCapability,
|
clientSupportsAirCapability,
|
||||||
withAirMeta,
|
withAirMeta,
|
||||||
} from "./air-extension.js";
|
} from "./air-extension.js";
|
||||||
|
import * as sessionIndex from "./session-index/connection.js";
|
||||||
|
import type { RenameSessionRequest, SessionIdRequest } from "./session-index/service.js";
|
||||||
|
import type {
|
||||||
|
ListSubscribeRequest,
|
||||||
|
ListUnsubscribeRequest,
|
||||||
|
} from "./session-index/list-subscriptions.js";
|
||||||
import {
|
import {
|
||||||
AsyncTaskRuntime,
|
AsyncTaskRuntime,
|
||||||
backgroundBashTaskFromToolResult,
|
backgroundBashTaskFromToolResult,
|
||||||
@@ -268,6 +273,7 @@ import {
|
|||||||
} from "./tool-calls/renderer.js";
|
} from "./tool-calls/renderer.js";
|
||||||
import { resolveSkillPath } from "./tool-calls/reporters/interaction.js";
|
import { resolveSkillPath } from "./tool-calls/reporters/interaction.js";
|
||||||
import {
|
import {
|
||||||
|
messageLoopYielder,
|
||||||
nodeToWebReadable,
|
nodeToWebReadable,
|
||||||
nodeToWebWritable,
|
nodeToWebWritable,
|
||||||
Pushable,
|
Pushable,
|
||||||
@@ -1291,7 +1297,7 @@ export type Session = {
|
|||||||
needsSignOutRespawn?: boolean;
|
needsSignOutRespawn?: boolean;
|
||||||
/** The in-flight recreation, so turns that arrive together share one. */
|
/** The in-flight recreation, so turns that arrive together share one. */
|
||||||
signOutRespawn?: Promise<void>;
|
signOutRespawn?: Promise<void>;
|
||||||
};
|
} & sessionIndex.SessionIndexFields;
|
||||||
|
|
||||||
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
|
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
|
||||||
* model did on its own (a task-notification followup, a peer/coordinator/
|
* model did on its own (a task-notification followup, a peer/coordinator/
|
||||||
@@ -2532,6 +2538,13 @@ export class ClaudeAcpAgent {
|
|||||||
* express: see {@link ToolCallClientCapabilities.v2}.
|
* express: see {@link ToolCallClientCapabilities.v2}.
|
||||||
*/
|
*/
|
||||||
private readonly v2: boolean;
|
private readonly v2: boolean;
|
||||||
|
/** The session index (docs/air-extensions.md, "Session index"). */
|
||||||
|
private readonly sessionIndex = new sessionIndex.SessionIndexConnection({
|
||||||
|
agent: this,
|
||||||
|
isAirClient: () => this.toolCallCapabilities.air.client,
|
||||||
|
interruptSession: (sessionId) => this.cancelTurns({ sessionId }, { awaitInterrupt: false }),
|
||||||
|
teardownSession: (sessionId) => this.teardownSession(sessionId),
|
||||||
|
});
|
||||||
|
|
||||||
constructor(client: AcpClient, logger?: Logger, options: { v2?: boolean } = {}) {
|
constructor(client: AcpClient, logger?: Logger, options: { v2?: boolean } = {}) {
|
||||||
this.v2 = options.v2 ?? false;
|
this.v2 = options.v2 ?? false;
|
||||||
@@ -2594,6 +2607,7 @@ export class ClaudeAcpAgent {
|
|||||||
this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities, {
|
this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities, {
|
||||||
v2: this.v2,
|
v2: this.v2,
|
||||||
});
|
});
|
||||||
|
this.sessionIndex.negotiate(request, { v2: this.v2 });
|
||||||
|
|
||||||
// Learn the auth identity in the background: `initialize` never waits on
|
// Learn the auth identity in the background: `initialize` never waits on
|
||||||
// the CLI probe, and no snapshot rides in its response. When the probe
|
// the CLI probe, and no snapshot rides in its response. When the probe
|
||||||
@@ -2775,6 +2789,7 @@ export class ClaudeAcpAgent {
|
|||||||
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
||||||
AIR_DIFF_PATCH_CAPABILITY,
|
AIR_DIFF_PATCH_CAPABILITY,
|
||||||
AIR_PLAN_FILE_CAPABILITY,
|
AIR_PLAN_FILE_CAPABILITY,
|
||||||
|
...this.sessionIndex.capabilities(),
|
||||||
),
|
),
|
||||||
AIR_GOAL_KEY,
|
AIR_GOAL_KEY,
|
||||||
{
|
{
|
||||||
@@ -2902,6 +2917,8 @@ export class ClaudeAcpAgent {
|
|||||||
* reads the start and the end only of the transcripts of the page.
|
* reads the start and the end only of the transcripts of the page.
|
||||||
*/
|
*/
|
||||||
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||||
|
const indexed = this.sessionIndex.list(params);
|
||||||
|
if (indexed) return indexed;
|
||||||
const offset = sessionListOffset(params.cursor);
|
const offset = sessionListOffset(params.cursor);
|
||||||
// One more session than the page tells whether a next page exists.
|
// One more session than the page tells whether a next page exists.
|
||||||
const sdkSessions = await listSessions({
|
const sdkSessions = await listSessions({
|
||||||
@@ -2909,10 +2926,11 @@ export class ClaudeAcpAgent {
|
|||||||
limit: SESSION_LIST_PAGE_SIZE + 1,
|
limit: SESSION_LIST_PAGE_SIZE + 1,
|
||||||
offset,
|
offset,
|
||||||
});
|
});
|
||||||
const sessions = [];
|
const page = this.sessionIndex.hideArchived(sdkSessions.slice(0, SESSION_LIST_PAGE_SIZE));
|
||||||
for (const session of sdkSessions.slice(0, SESSION_LIST_PAGE_SIZE)) {
|
const visible = [];
|
||||||
|
for (const session of page) {
|
||||||
if (!session.cwd) continue;
|
if (!session.cwd) continue;
|
||||||
sessions.push({
|
visible.push({
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
cwd: session.cwd,
|
cwd: session.cwd,
|
||||||
title: sanitizeTitle(session.summary),
|
title: sanitizeTitle(session.summary),
|
||||||
@@ -2920,8 +2938,32 @@ export class ClaudeAcpAgent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return sdkSessions.length > SESSION_LIST_PAGE_SIZE
|
return sdkSessions.length > SESSION_LIST_PAGE_SIZE
|
||||||
? { sessions, nextCursor: `offset:${offset + SESSION_LIST_PAGE_SIZE}` }
|
? { sessions: visible, nextCursor: `offset:${offset + SESSION_LIST_PAGE_SIZE}` }
|
||||||
: { sessions };
|
: { sessions: visible };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored session title as this client shows it (see
|
||||||
|
* {@link sessionIndex.SessionIndexConnection.clientTitle}). */
|
||||||
|
clientTitle(title: string): string {
|
||||||
|
return this.sessionIndex.clientTitle(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`,
|
||||||
|
* `_session/archive`, `_session/unarchive`. */
|
||||||
|
async subscribeSessionList(params: ListSubscribeRequest) {
|
||||||
|
return this.sessionIndex.subscribeList(params);
|
||||||
|
}
|
||||||
|
async unsubscribeSessionList(params: ListUnsubscribeRequest) {
|
||||||
|
return this.sessionIndex.unsubscribeList(params);
|
||||||
|
}
|
||||||
|
renameSessionTitle(params: RenameSessionRequest) {
|
||||||
|
return this.sessionIndex.rename(params);
|
||||||
|
}
|
||||||
|
archiveSession(params: SessionIdRequest) {
|
||||||
|
return this.sessionIndex.archive(params);
|
||||||
|
}
|
||||||
|
unarchiveSession(params: SessionIdRequest) {
|
||||||
|
return this.sessionIndex.unarchive(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4755,6 +4797,11 @@ export class ClaudeAcpAgent {
|
|||||||
error: unknown,
|
error: unknown,
|
||||||
title?: string,
|
title?: string,
|
||||||
) => {
|
) => {
|
||||||
|
if (!session.cancelled) {
|
||||||
|
// The turn ends with an error: its list row shows `error`.
|
||||||
|
session.lastTurnFailed = true;
|
||||||
|
this.sessionIndex.onOwnSessionChanged(params.sessionId);
|
||||||
|
}
|
||||||
if (session.activeTurn && !session.activeTurn.settled) {
|
if (session.activeTurn && !session.activeTurn.settled) {
|
||||||
await compaction.interrupt();
|
await compaction.interrupt();
|
||||||
}
|
}
|
||||||
@@ -4831,6 +4878,14 @@ export class ClaudeAcpAgent {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
// A backlog of buffered SDK messages drains without a macrotask
|
||||||
|
// boundary (each await resolves in a microtask), which would starve
|
||||||
|
// timers and incoming requests, `session/cancel` among them, until
|
||||||
|
// the backlog is gone. Give the event loop a turn when this stretch
|
||||||
|
// has run past its budget. Messages keep their order: the next one is
|
||||||
|
// pulled only after the pause.
|
||||||
|
const pause = messageLoopYielder.maybeYield();
|
||||||
|
if (pause) await pause;
|
||||||
pendingNext ??= session.query
|
pendingNext ??= session.query
|
||||||
.next()
|
.next()
|
||||||
.then((result) => ({ kind: "message" as const, result }));
|
.then((result) => ({ kind: "message" as const, result }));
|
||||||
@@ -5232,6 +5287,9 @@ export class ClaudeAcpAgent {
|
|||||||
case "session_state_changed": {
|
case "session_state_changed": {
|
||||||
const previousState = session.lastSessionState;
|
const previousState = session.lastSessionState;
|
||||||
session.lastSessionState = message.state;
|
session.lastSessionState = message.state;
|
||||||
|
sessionIndex.noteSessionState(session, previousState, message.state);
|
||||||
|
if (message.state === "running") this.sessionIndex.onTurnStarted(params.sessionId);
|
||||||
|
this.sessionIndex.onOwnSessionChanged(params.sessionId);
|
||||||
if (
|
if (
|
||||||
message.state === "running" &&
|
message.state === "running" &&
|
||||||
previousState !== "running" &&
|
previousState !== "running" &&
|
||||||
@@ -6099,6 +6157,8 @@ export class ClaudeAcpAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session.lastTotalCostUsd = message.total_cost_usd;
|
||||||
|
this.sessionIndex.onOwnSessionChanged(params.sessionId);
|
||||||
// Send usage_update notification
|
// Send usage_update notification
|
||||||
if (lastAssistantTotalUsage !== null) {
|
if (lastAssistantTotalUsage !== null) {
|
||||||
await sendUpdate({
|
await sendUpdate({
|
||||||
@@ -6330,6 +6390,12 @@ export class ClaudeAcpAgent {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An error result ends the turn with an error, unless it was
|
||||||
|
// cancelled: the session's list row shows `error` once idle.
|
||||||
|
session.lastTurnFailed =
|
||||||
|
!session.cancelled && (message.is_error || message.subtype !== "success");
|
||||||
|
this.sessionIndex.onOwnSessionChanged(params.sessionId);
|
||||||
|
|
||||||
if (!message.is_error && lastAssistantModel !== null) {
|
if (!message.is_error && lastAssistantModel !== null) {
|
||||||
const activeTurnId = session.activeTurn?.promptUuid;
|
const activeTurnId = session.activeTurn?.promptUuid;
|
||||||
await sessionFailures.clear(
|
await sessionFailures.clear(
|
||||||
@@ -7160,6 +7226,10 @@ export class ClaudeAcpAgent {
|
|||||||
// inline via failActive and never reach here. Reject every in-flight turn;
|
// inline via failActive and never reach here. Reject every in-flight turn;
|
||||||
// if the process is gone, tear the session down so the client starts fresh.
|
// if the process is gone, tear the session down so the client starts fresh.
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!session.cancelled) {
|
||||||
|
session.lastTurnFailed = true;
|
||||||
|
this.sessionIndex.onQueryFailed(params.sessionId);
|
||||||
|
}
|
||||||
const processDied =
|
const processDied =
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
(message.includes("ProcessTransport") ||
|
(message.includes("ProcessTransport") ||
|
||||||
@@ -7638,6 +7708,10 @@ export class ClaudeAcpAgent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.queryClosed = true;
|
session.queryClosed = true;
|
||||||
|
// Its list row now shows what the registry and the transcript tell.
|
||||||
|
for (const [sessionId, candidate] of Object.entries(this.sessions)) {
|
||||||
|
if (candidate === session) this.sessionIndex.onOwnSessionChanged(sessionId);
|
||||||
|
}
|
||||||
session.consumer = undefined;
|
session.consumer = undefined;
|
||||||
session.contextCompaction = undefined;
|
session.contextCompaction = undefined;
|
||||||
session.settingsManager.dispose();
|
session.settingsManager.dispose();
|
||||||
@@ -7686,6 +7760,7 @@ export class ClaudeAcpAgent {
|
|||||||
session.nativeSubagentRuntime?.clear();
|
session.nativeSubagentRuntime?.clear();
|
||||||
session.asyncTaskRuntime?.clear();
|
session.asyncTaskRuntime?.clear();
|
||||||
delete this.sessions[sessionId];
|
delete this.sessions[sessionId];
|
||||||
|
this.sessionIndex.onTeardown(sessionId);
|
||||||
const ended = await raceTimeoutAndAbort(
|
const ended = await raceTimeoutAndAbort(
|
||||||
turnsEnded,
|
turnsEnded,
|
||||||
TEARDOWN_TURN_END_TIMEOUT_MS,
|
TEARDOWN_TURN_END_TIMEOUT_MS,
|
||||||
@@ -7700,25 +7775,22 @@ export class ClaudeAcpAgent {
|
|||||||
|
|
||||||
/** Tear down all active sessions. Called when the ACP connection closes. */
|
/** Tear down all active sessions. Called when the ACP connection closes. */
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
|
this.sessionIndex.dispose();
|
||||||
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
|
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Idempotent: a session that is not loaded here is already closed. */
|
||||||
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||||
if (!this.sessions[params.sessionId]) {
|
if (!this.sessions[params.sessionId]) {
|
||||||
throw new Error("Session not found");
|
return {};
|
||||||
}
|
}
|
||||||
await this.teardownSession(params.sessionId);
|
await this.teardownSession(params.sessionId);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per client: see {@link sessionIndex.SessionIndexConnection.deleteSession}. */
|
||||||
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
||||||
// Tear down any active in-memory state first so the on-disk file isn't
|
return this.sessionIndex.deleteSession(params);
|
||||||
// recreated by an outstanding query writing to it.
|
|
||||||
if (this.sessions[params.sessionId]) {
|
|
||||||
await this.teardownSession(params.sessionId);
|
|
||||||
}
|
|
||||||
await deleteSession(params.sessionId);
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
||||||
@@ -8046,6 +8118,10 @@ export class ClaudeAcpAgent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const replayMessage = async (message: SessionMessage): Promise<void> => {
|
const replayMessage = async (message: SessionMessage): Promise<void> => {
|
||||||
|
// A long history is sent in order, but with event-loop turns in between,
|
||||||
|
// so other sessions and incoming requests are not starved meanwhile.
|
||||||
|
const pause = messageLoopYielder.maybeYield();
|
||||||
|
if (pause) await pause;
|
||||||
if (pending?.stopped || isReplayHiddenMetaMessage(message)) {
|
if (pending?.stopped || isReplayHiddenMetaMessage(message)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9978,6 +10054,7 @@ export class ClaudeAcpAgent {
|
|||||||
query: q,
|
query: q,
|
||||||
input: input,
|
input: input,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
|
resumedFromHistory: creationOpts.resume !== undefined,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
sessionFingerprint: computeSessionFingerprint(params),
|
sessionFingerprint: computeSessionFingerprint(params),
|
||||||
creationParams: params,
|
creationParams: params,
|
||||||
@@ -11673,7 +11750,7 @@ export function v1AgentApp(
|
|||||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||||
): AgentApp {
|
): AgentApp {
|
||||||
let agent!: ClaudeAcpAgent;
|
let agent!: ClaudeAcpAgent;
|
||||||
return acpAgent({ name: "claude-code-acp" })
|
const app = acpAgent({ name: "claude-code-acp" })
|
||||||
.onConnect((connection) => {
|
.onConnect((connection) => {
|
||||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
||||||
onAgent(agent);
|
onAgent(agent);
|
||||||
@@ -11712,6 +11789,7 @@ export function v1AgentApp(
|
|||||||
{ parse: parseGoalRequest },
|
{ parse: parseGoalRequest },
|
||||||
(ctx) => agent.goal(ctx.params),
|
(ctx) => agent.goal(ctx.params),
|
||||||
);
|
);
|
||||||
|
return sessionIndex.onSessionIndexRequests(app, () => agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serves ACP v1 on stdio. */
|
/** Serves ACP v1 on stdio. */
|
||||||
|
|||||||
@@ -11,6 +11,29 @@ export const AIR_RAW_INPUT_RENDERING_CAPABILITY = "rawInputRendering";
|
|||||||
* file, and AIR reads the plan from it.
|
* file, and AIR reads the plan from it.
|
||||||
*/
|
*/
|
||||||
export const AIR_PLAN_FILE_CAPABILITY = "planFile";
|
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. */
|
/** The `_meta.jetbrains.air` keys that the ACP tool call contract defines. */
|
||||||
export const AIR_COMMAND_TITLE_KEY = "commandTitle";
|
export const AIR_COMMAND_TITLE_KEY = "commandTitle";
|
||||||
|
|||||||
43
src/diff.ts
43
src/diff.ts
@@ -569,11 +569,52 @@ function textLines(text: string): string[] {
|
|||||||
* its time budget.
|
* its time budget.
|
||||||
*/
|
*/
|
||||||
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
|
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
|
||||||
const hunks = diffHunks(oldText, newText);
|
const hunks = memoizedDiffHunks(oldText, newText);
|
||||||
if (!hunks || hunks.length === 0) return undefined;
|
if (!hunks || hunks.length === 0) return undefined;
|
||||||
return { change: oldText === null ? "create" : "update", 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a file as patch input.
|
* Reads a file as patch input.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export type McpCommand =
|
|||||||
* or `/mcp disable` with an optional server name or `all`. Any other `/mcp`
|
* or `/mcp disable` with an optional server name or `all`. Any other `/mcp`
|
||||||
* argument gives null, and the prompt gets no replacement. */
|
* argument gives null, and the prompt gets no replacement. */
|
||||||
export function parseMcpCommand(text: string): McpCommand | null {
|
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+/);
|
const words = text.trim().split(/\s+/);
|
||||||
if (words[0] !== "/mcp") return null;
|
if (words[0] !== "/mcp") return null;
|
||||||
if (words.length === 1) return { action: "status" };
|
if (words.length === 1) return { action: "status" };
|
||||||
|
|||||||
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--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/session-index/first-prompt.ts
Normal file
171
src/session-index/first-prompt.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 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`).
|
||||||
|
* Used when the SDK `getSessionInfo` read another copy of the session.
|
||||||
|
*
|
||||||
|
* - 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 "";
|
||||||
|
}
|
||||||
1243
src/session-index/list-subscriptions.ts
Normal file
1243
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;
|
||||||
|
}
|
||||||
704
src/session-index/service.ts
Normal file
704
src/session-index/service.ts
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
getSessionInfo as sdkGetSessionInfo,
|
||||||
|
} 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,
|
||||||
|
} 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 GetSessionInfo,
|
||||||
|
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.tail) === 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 = {
|
||||||
|
getSessionInfo?: GetSessionInfo;
|
||||||
|
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>;
|
||||||
|
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(deps.getSessionInfo ?? sdkGetSessionInfo);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_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,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
939
src/session-index/session-index.ts
Normal file
939
src/session-index/session-index.ts
Normal file
@@ -0,0 +1,939 @@
|
|||||||
|
/**
|
||||||
|
* The session list of a `sessionIndex` client.
|
||||||
|
*
|
||||||
|
* Each list enumerates the transcripts of the requested cwd and of every
|
||||||
|
* worktree of its repository: one `readdir` per project directory and one
|
||||||
|
* `stat` per transcript, no long-lived cache. The metadata of a transcript is
|
||||||
|
* cached by `(path, mtime, size)` in an LRU and read on a miss with the SDK
|
||||||
|
* `getSessionInfo` (titles, sidecar) plus one head and tail read for
|
||||||
|
* what the SDK does not report (see {@link scanTranscript}). Misses are read in
|
||||||
|
* parallel batches.
|
||||||
|
*
|
||||||
|
* Order is the last user activity descending, then session id: the time of
|
||||||
|
* the last real user prompt (`lastPromptAt`), else `updatedAt`. `updatedAt`
|
||||||
|
* is the time of the last message, capped at the transcript mtime, so a
|
||||||
|
* rename or another metadata record does not move a session up, and the
|
||||||
|
* order key is never later than it. Candidates are read in mtime order: a
|
||||||
|
* candidate's order key is at most its mtime, so the scan stops as soon as
|
||||||
|
* the page is full and the next mtime is older than the key of the last row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import type { SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import { sanitizeTitle } from "../session-titles.js";
|
||||||
|
import { effectiveTitle, visibleTitle } from "./archive-title.js";
|
||||||
|
import {
|
||||||
|
canonicalPath,
|
||||||
|
encodeProjectPath,
|
||||||
|
isExactProjectDir,
|
||||||
|
isSessionId,
|
||||||
|
normalizePath,
|
||||||
|
pathAndAncestors,
|
||||||
|
projectDirMatches,
|
||||||
|
projectDirsOf,
|
||||||
|
projectsRoot,
|
||||||
|
sameProjectPath,
|
||||||
|
} from "./project-dirs.js";
|
||||||
|
import {
|
||||||
|
continuedInSessionId,
|
||||||
|
hasHistory,
|
||||||
|
isSidechainTranscript,
|
||||||
|
lastTimestamp,
|
||||||
|
readHeadTail,
|
||||||
|
relocatedCwd,
|
||||||
|
scanTranscriptFile,
|
||||||
|
sdkTitles,
|
||||||
|
tailCustomTitle,
|
||||||
|
transcriptAgentName,
|
||||||
|
transcriptProjectCwd,
|
||||||
|
type HeadTail,
|
||||||
|
type PreviousScan,
|
||||||
|
type SdkTitles,
|
||||||
|
type TranscriptFacts,
|
||||||
|
} from "./transcript-scan.js";
|
||||||
|
import { DirListings, statFiles } from "./dir-listing.js";
|
||||||
|
import { worktreeCounterparts } from "./worktrees.js";
|
||||||
|
|
||||||
|
export const DEFAULT_LIST_LIMIT = 50;
|
||||||
|
export const MAX_LIST_LIMIT = 200;
|
||||||
|
/** Entries of the metadata cache: a few hundred bytes each, so the
|
||||||
|
* transcripts of a large history (tens of thousands) stay cached and deep
|
||||||
|
* pages do not read them again. */
|
||||||
|
const METADATA_CACHE_SIZE = 50_000;
|
||||||
|
const READ_BATCH_SIZE = 16;
|
||||||
|
/** Transcripts read at most per directory to recover a sibling's cwd. */
|
||||||
|
const MAX_CWD_PROBES_PER_DIR = 64;
|
||||||
|
/** How long a directory that gave no cwd is not probed again. */
|
||||||
|
const NO_CWD_RETRY_MS = 60_000;
|
||||||
|
|
||||||
|
/** Which sessions a list holds by archive state: `unarchived` the
|
||||||
|
* unarchived ones only, `archived` the archived ones only, `all` both in one
|
||||||
|
* order. */
|
||||||
|
export type ArchivedFilter = "unarchived" | "archived" | "all";
|
||||||
|
|
||||||
|
export const ARCHIVED_FILTERS: readonly ArchivedFilter[] = ["unarchived", "archived", "all"];
|
||||||
|
|
||||||
|
/** Whether `filter` keeps a session of that archive state. */
|
||||||
|
export function archivedFilterKeeps(filter: ArchivedFilter, archived: boolean): boolean {
|
||||||
|
return filter === "all" || (filter === "archived") === archived;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One transcript file found by the enumeration. */
|
||||||
|
export type TranscriptCandidate = {
|
||||||
|
sessionId: string;
|
||||||
|
filePath: string;
|
||||||
|
dirName: string;
|
||||||
|
/** The requested path (cwd or worktree) whose project directory this is. */
|
||||||
|
projectPath?: string;
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
/** Tells a replaced file from one that grew. */
|
||||||
|
ino: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The cached metadata of one transcript. */
|
||||||
|
type TranscriptMetadata = {
|
||||||
|
/** The visible title: without the archive prefix. */
|
||||||
|
title: string;
|
||||||
|
/** The custom title the SDK reports carries the archive prefix. */
|
||||||
|
archived: boolean;
|
||||||
|
/** The cwd read from the transcript, when it encodes to the directory name. */
|
||||||
|
fileCwd?: string;
|
||||||
|
/** The cwd the SDK list checks the transcript by: the last relocation,
|
||||||
|
* else the first cwd of the head. */
|
||||||
|
projectCwd?: string;
|
||||||
|
/** The session this transcript was continued in, from its tail. */
|
||||||
|
continuedIn?: string;
|
||||||
|
updatedAtMs: number;
|
||||||
|
facts: TranscriptFacts;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IndexRow = {
|
||||||
|
sessionId: string;
|
||||||
|
cwd: string;
|
||||||
|
title: string;
|
||||||
|
updatedAtMs: number;
|
||||||
|
/** The order key: the last user activity, `lastPromptAt`, else
|
||||||
|
* `updatedAt`. Never later than `updatedAt`, so never later than the
|
||||||
|
* transcript mtime. */
|
||||||
|
orderAtMs: number;
|
||||||
|
facts: TranscriptFacts;
|
||||||
|
mtimeMs: number;
|
||||||
|
archived: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A keyset position: the order key and the session id of the last row. */
|
||||||
|
export type ListCursor = { orderAtMs: number; sessionId: string };
|
||||||
|
|
||||||
|
export type ListQuery = {
|
||||||
|
cwd?: string | null;
|
||||||
|
/** Also list the sessions of the existing linked worktrees of `cwd`. */
|
||||||
|
includeWorktrees?: boolean;
|
||||||
|
limit: number;
|
||||||
|
archived: ArchivedFilter;
|
||||||
|
after?: ListCursor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetSessionInfo = (
|
||||||
|
sessionId: string,
|
||||||
|
options: { dir?: string },
|
||||||
|
) => Promise<SDKSessionInfo | undefined>;
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
ino: number;
|
||||||
|
metadata: TranscriptMetadata | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A small LRU: a `Map` keeps insertion order, and a hit is re-inserted. */
|
||||||
|
class Lru<K, V> {
|
||||||
|
private readonly entries = new Map<K, V>();
|
||||||
|
constructor(private readonly capacity: number) {}
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const value = this.entries.get(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
this.entries.delete(key);
|
||||||
|
this.entries.set(key, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
this.entries.delete(key);
|
||||||
|
this.entries.set(key, value);
|
||||||
|
if (this.entries.size > this.capacity) {
|
||||||
|
this.entries.delete(this.entries.keys().next().value as K);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(key: K): void {
|
||||||
|
this.entries.delete(key);
|
||||||
|
}
|
||||||
|
/** The value of `key`, without making it recent. */
|
||||||
|
peek(key: K): V | undefined {
|
||||||
|
return this.entries.get(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRows(a: { orderAtMs: number; sessionId: string }, b: typeof a): number {
|
||||||
|
if (a.orderAtMs !== b.orderAtMs) return b.orderAtMs - a.orderAtMs;
|
||||||
|
return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resolved = { candidate: TranscriptCandidate; metadata: TranscriptMetadata };
|
||||||
|
|
||||||
|
function toRow({ candidate, metadata }: Resolved, cwd: string): IndexRow {
|
||||||
|
return {
|
||||||
|
archived: metadata.archived,
|
||||||
|
sessionId: candidate.sessionId,
|
||||||
|
cwd,
|
||||||
|
title: metadata.title,
|
||||||
|
updatedAtMs: metadata.updatedAtMs,
|
||||||
|
orderAtMs: Math.min(metadata.facts.lastPromptAt ?? metadata.updatedAtMs, metadata.updatedAtMs),
|
||||||
|
facts: metadata.facts,
|
||||||
|
mtimeMs: candidate.mtimeMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAfter(row: { orderAtMs: number; sessionId: string }, cursor: ListCursor): boolean {
|
||||||
|
return compareRows(row, cursor) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function byMtimeDescending(a: TranscriptCandidate, b: TranscriptCandidate): number {
|
||||||
|
if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
||||||
|
return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The title of the CLI's `custom-title.json` sidecar of a transcript. */
|
||||||
|
export async function readSidecarTitle(
|
||||||
|
filePath: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const text = await fs.readFile(
|
||||||
|
path.join(path.dirname(filePath), sessionId, "custom-title.json"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const title = (JSON.parse(text) as { customTitle?: unknown }).customTitle;
|
||||||
|
return typeof title === "string" && title.trim() ? title : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On macOS the SDK opens `<projects>/<encoded cwd>` through the file system,
|
||||||
|
* which ignores case on the usual volumes: a directory whose name differs in
|
||||||
|
* case only (a repository renamed in case) is the project directory then.
|
||||||
|
* Returns that entry of `rootEntries` when the file system resolves the
|
||||||
|
* exact name to it.
|
||||||
|
*/
|
||||||
|
async function caseInsensitiveProjectDir(
|
||||||
|
projectPath: string,
|
||||||
|
rootEntries: readonly string[],
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (process.platform !== "darwin") return undefined;
|
||||||
|
const exact = encodeProjectPath(projectPath);
|
||||||
|
if (rootEntries.includes(exact)) return undefined;
|
||||||
|
const lower = exact.toLowerCase();
|
||||||
|
const variant = rootEntries.find((name) => name.toLowerCase() === lower);
|
||||||
|
if (!variant) return undefined;
|
||||||
|
try {
|
||||||
|
return (await fs.stat(path.join(projectsRoot(), exact))).isDirectory() ? variant : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the encoding of `cwd` names the directory `dirName` on disk. */
|
||||||
|
async function sameDirOnDisk(dirName: string, cwd: string): Promise<boolean> {
|
||||||
|
const root = projectsRoot();
|
||||||
|
try {
|
||||||
|
const [listed, resolved] = await Promise.all([
|
||||||
|
fs.stat(path.join(root, dirName)),
|
||||||
|
fs.stat(path.join(root, encodeProjectPath(cwd))),
|
||||||
|
]);
|
||||||
|
return listed.isDirectory() && listed.ino === resolved.ino && listed.dev === resolved.dev;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether paths are compared ignoring case, as the SDK does on macOS and
|
||||||
|
* Windows. */
|
||||||
|
const CASE_INSENSITIVE_PATHS = process.platform === "darwin" || process.platform === "win32";
|
||||||
|
|
||||||
|
/** A path as the SDK compares it: forward slashes, in any case where the
|
||||||
|
* file system ignores it. */
|
||||||
|
function comparable(value: string): string {
|
||||||
|
const slashed = normalizePath(value).replaceAll("\\", "/");
|
||||||
|
return CASE_INSENSITIVE_PATHS ? slashed.toLowerCase() : slashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `a` and `b` are different paths that encode to the same project
|
||||||
|
* directory name (`/ws/app.v2` and `/ws/app-v2`). */
|
||||||
|
function collides(a: string, b: string): boolean {
|
||||||
|
return sameProjectPath(a, b) && comparable(a) !== comparable(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path the SDK does not resolve: a `..` segment, or a UNC or device path. */
|
||||||
|
function unresolvable(value: string): boolean {
|
||||||
|
return /(^|[\\/])\.\.([\\/]|$)/.test(value) || /^[\\/]{2}/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which transcripts of the project directories of `paths` (every one
|
||||||
|
* without `paths`) the SDK `listSessions` of those paths lists. It leaves out
|
||||||
|
* only a transcript whose cwd (see {@link TranscriptMetadata.projectCwd}) is
|
||||||
|
* another path that encodes to the same directory and still resolves on disk
|
||||||
|
* to such another path; one whose cwd is gone, or lies in one of `paths`, is
|
||||||
|
* listed.
|
||||||
|
*/
|
||||||
|
function scopeOf(
|
||||||
|
paths: readonly string[] | undefined,
|
||||||
|
): (candidate: TranscriptCandidate, metadata: TranscriptMetadata) => Promise<boolean> {
|
||||||
|
if (!paths) return async () => true;
|
||||||
|
const own = paths.map(comparable);
|
||||||
|
const resolved = new Map<string, Promise<boolean>>();
|
||||||
|
const hidden = async (cwd: string, projectPath: string): Promise<boolean> => {
|
||||||
|
if (!collides(cwd, projectPath) || unresolvable(cwd) || unresolvable(projectPath)) return false;
|
||||||
|
let real: string;
|
||||||
|
try {
|
||||||
|
real = normalizePath(await fs.realpath(cwd));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return collides(real, await canonicalPath(projectPath));
|
||||||
|
};
|
||||||
|
return async ({ projectPath }, { projectCwd }) => {
|
||||||
|
if (projectPath === undefined || projectCwd === undefined) return true;
|
||||||
|
const cwd = comparable(projectCwd);
|
||||||
|
if (
|
||||||
|
own.some(
|
||||||
|
(ownPath) =>
|
||||||
|
cwd === ownPath || cwd.startsWith(ownPath.endsWith("/") ? ownPath : `${ownPath}/`),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const key = `${projectCwd}\0${projectPath}`;
|
||||||
|
let result = resolved.get(key);
|
||||||
|
if (!result) {
|
||||||
|
result = hidden(projectCwd, projectPath).then((isHidden) => !isHidden);
|
||||||
|
resolved.set(key, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionIndex {
|
||||||
|
private readonly listings = new DirListings();
|
||||||
|
private readonly metadata = new Lru<string, CacheEntry>(METADATA_CACHE_SIZE);
|
||||||
|
/** The metadata reads in flight, by path: a list and a subscription that
|
||||||
|
* want the same transcript at the same stat read it once. */
|
||||||
|
private readonly reading = new Map<
|
||||||
|
string,
|
||||||
|
{ key: string; promise: Promise<TranscriptMetadata | null> }
|
||||||
|
>();
|
||||||
|
/** A cwd that encodes to each project directory name, learned from its
|
||||||
|
* transcripts; it recovers the cwd of a sibling that has none. */
|
||||||
|
private readonly dirCwds = new Map<string, string>();
|
||||||
|
/** The project cwd of the transcripts of prefix-matched long directories. */
|
||||||
|
private readonly transcriptCwds = new Lru<
|
||||||
|
string,
|
||||||
|
{ mtimeMs: number; size: number; cwd: string | undefined }
|
||||||
|
>(METADATA_CACHE_SIZE);
|
||||||
|
/** Directories whose transcripts gave no cwd, by the directory mtime and
|
||||||
|
* when that was learned. */
|
||||||
|
private readonly dirsWithoutCwd = new Map<string, { mtimeMs: number; at: number }>();
|
||||||
|
|
||||||
|
constructor(private readonly getSessionInfo: GetSessionInfo) {}
|
||||||
|
|
||||||
|
/** The paths whose sessions a list of `cwd` shows: `cwd`, and with
|
||||||
|
* `includeWorktrees` the same subdirectory of every other existing
|
||||||
|
* worktree of its repository (the worktree roots for a cwd at the root). */
|
||||||
|
async listedPaths(cwd: string, includeWorktrees: boolean): Promise<string[]> {
|
||||||
|
const canonical = await canonicalPath(cwd);
|
||||||
|
if (!includeWorktrees) return [canonical];
|
||||||
|
return [...new Set([canonical, ...(await worktreeCounterparts(canonical))])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The project directories (names under the projects root) of `paths`.
|
||||||
|
* A long path is matched by its cut prefix, which other long paths may
|
||||||
|
* share: such a directory counts only when one of its transcripts belongs
|
||||||
|
* to the path, as the SDK checks. */
|
||||||
|
async projectDirs(paths: readonly string[]): Promise<{ dirName: string; projectPath: string }[]> {
|
||||||
|
const rootEntries = await this.listings.names(projectsRoot());
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: { dirName: string; projectPath: string }[] = [];
|
||||||
|
for (const projectPath of paths) {
|
||||||
|
const names = projectDirsOf(projectPath, rootEntries);
|
||||||
|
const caseVariant = await caseInsensitiveProjectDir(projectPath, rootEntries);
|
||||||
|
if (caseVariant && !names.includes(caseVariant)) names.unshift(caseVariant);
|
||||||
|
for (const dirName of names) {
|
||||||
|
if (seen.has(dirName)) continue;
|
||||||
|
if (
|
||||||
|
dirName !== caseVariant &&
|
||||||
|
dirName !== encodeProjectPath(projectPath) &&
|
||||||
|
!(await this.longDirBelongsTo(dirName, projectPath))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(dirName);
|
||||||
|
result.push({ dirName, projectPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a transcript of the prefix-matched `dirName` belongs to
|
||||||
|
* `projectPath`. The cwd of each transcript is cached by its
|
||||||
|
* `(path, mtime, size)`, so a transcript that changes is read again. */
|
||||||
|
private async longDirBelongsTo(dirName: string, projectPath: string): Promise<boolean> {
|
||||||
|
const dir = path.join(projectsRoot(), dirName);
|
||||||
|
for (const name of await this.listings.names(dir)) {
|
||||||
|
if (!name.endsWith(".jsonl")) continue;
|
||||||
|
const filePath = path.join(dir, name);
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
if (!stats.isFile()) continue;
|
||||||
|
let cached = this.transcriptCwds.get(filePath);
|
||||||
|
if (!cached || cached.mtimeMs !== stats.mtimeMs || cached.size !== stats.size) {
|
||||||
|
cached = {
|
||||||
|
mtimeMs: stats.mtimeMs,
|
||||||
|
size: stats.size,
|
||||||
|
cwd: transcriptProjectCwd(await readHeadTail(filePath, stats.size)),
|
||||||
|
};
|
||||||
|
this.transcriptCwds.set(filePath, cached);
|
||||||
|
}
|
||||||
|
if (cached.cwd && sameProjectPath(cached.cwd, projectPath)) return true;
|
||||||
|
} catch {
|
||||||
|
// Gone or unreadable: look at the next one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every non-empty transcript file of `paths` (without, of all projects),
|
||||||
|
* every copy of a session: a list shows the newest one that has a row
|
||||||
|
* (see {@link collectRows}), as the SDK does. */
|
||||||
|
async enumerateFiles(paths?: readonly string[]): Promise<TranscriptCandidate[]> {
|
||||||
|
const root = projectsRoot();
|
||||||
|
const dirs: { dirName: string; projectPath?: string }[] = paths
|
||||||
|
? await this.projectDirs(paths)
|
||||||
|
: (await this.listings.names(root)).map((dirName) => ({ dirName }));
|
||||||
|
// All directories' names first, then one bounded run of stats.
|
||||||
|
const listed = await this.listings.namesOfAll(
|
||||||
|
dirs.map(({ dirName }) => path.join(root, dirName)),
|
||||||
|
);
|
||||||
|
const files = dirs.flatMap(({ dirName, projectPath }, i) =>
|
||||||
|
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))).map(
|
||||||
|
(name) => ({ name, filePath: path.join(root, dirName, name), dirName, projectPath }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const stats = await statFiles(files.map(({ filePath }) => filePath));
|
||||||
|
const all: TranscriptCandidate[] = [];
|
||||||
|
files.forEach(({ name, filePath, dirName, projectPath }, i) => {
|
||||||
|
const fileStats = stats[i];
|
||||||
|
if (!fileStats?.isFile() || fileStats.size === 0) return;
|
||||||
|
all.push({
|
||||||
|
sessionId: name.slice(0, -6),
|
||||||
|
filePath,
|
||||||
|
dirName,
|
||||||
|
projectPath,
|
||||||
|
mtimeMs: fileStats.mtimeMs,
|
||||||
|
size: fileStats.size,
|
||||||
|
ino: fileStats.ino,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every transcript (with its size) and every `<sessionId>/` directory
|
||||||
|
* (sidecar, subagent transcripts) of the session, in any project
|
||||||
|
* directory, by the exact id. One pass over the (cached) project directory
|
||||||
|
* listings.
|
||||||
|
*/
|
||||||
|
async scanSession(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<{ transcripts: { filePath: string; size: number }[]; sessionDirs: string[] }> {
|
||||||
|
if (!isSessionId(sessionId)) return { transcripts: [], sessionDirs: [] };
|
||||||
|
const root = projectsRoot();
|
||||||
|
const dirs = (await this.listings.names(root)).map((dirName) => path.join(root, dirName));
|
||||||
|
const listed = await this.listings.namesOfAll(dirs);
|
||||||
|
const transcriptName = `${sessionId}.jsonl`;
|
||||||
|
const transcriptPaths = dirs.flatMap((dir, i) =>
|
||||||
|
listed[i]!.includes(transcriptName) ? [path.join(dir, transcriptName)] : [],
|
||||||
|
);
|
||||||
|
const dirPaths = dirs.flatMap((dir, i) =>
|
||||||
|
listed[i]!.includes(sessionId) ? [path.join(dir, sessionId)] : [],
|
||||||
|
);
|
||||||
|
const [transcriptStats, dirStats] = await Promise.all([
|
||||||
|
statFiles(transcriptPaths),
|
||||||
|
Promise.all(dirPaths.map((dir) => fs.lstat(dir).catch(() => undefined))),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
transcripts: transcriptPaths.flatMap((filePath, i) => {
|
||||||
|
const stats = transcriptStats[i];
|
||||||
|
return stats?.isFile() ? [{ filePath, size: stats.size }] : [];
|
||||||
|
}),
|
||||||
|
sessionDirs: dirPaths.filter((_, i) => dirStats[i]?.isDirectory()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every transcript file of `sessionId` (see {@link scanSession}). Empty
|
||||||
|
* files only with `includeEmpty`. */
|
||||||
|
async findTranscripts(
|
||||||
|
sessionId: string,
|
||||||
|
options: { includeEmpty?: boolean } = {},
|
||||||
|
): Promise<string[]> {
|
||||||
|
const { transcripts } = await this.scanSession(sessionId);
|
||||||
|
return transcripts
|
||||||
|
.filter(({ size }) => size > 0 || options.includeEmpty)
|
||||||
|
.map(({ filePath }) => filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every `<sessionId>/` directory of the session (see {@link scanSession}). */
|
||||||
|
async findSessionDirs(sessionId: string): Promise<string[]> {
|
||||||
|
return (await this.scanSession(sessionId)).sessionDirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `candidate` was read at its stat: its metadata is cached, also
|
||||||
|
* when it holds no row. False after a failed read. */
|
||||||
|
isRead(candidate: TranscriptCandidate): boolean {
|
||||||
|
const cached = this.metadata.peek(candidate.filePath);
|
||||||
|
return (
|
||||||
|
cached !== undefined &&
|
||||||
|
cached.mtimeMs === candidate.mtimeMs &&
|
||||||
|
cached.size === candidate.size &&
|
||||||
|
cached.ino === candidate.ino
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session that the cached metadata of `filePath` says it was
|
||||||
|
* continued in. */
|
||||||
|
continuedIn(filePath: string): string | undefined {
|
||||||
|
return this.metadata.peek(filePath)?.metadata?.continuedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the cached metadata of `filePaths`. */
|
||||||
|
invalidate(filePaths: readonly string[]): void {
|
||||||
|
for (const filePath of filePaths) this.metadata.delete(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page of rows, plus whether more rows follow the page. */
|
||||||
|
async list(query: ListQuery): Promise<{ rows: IndexRow[]; hasMore: boolean }> {
|
||||||
|
const paths = query.cwd
|
||||||
|
? await this.listedPaths(query.cwd, query.includeWorktrees ?? false)
|
||||||
|
: undefined;
|
||||||
|
const after = query.after;
|
||||||
|
const enumerated = (await this.enumerateFiles(paths)).sort(byMtimeDescending);
|
||||||
|
// A page after a cursor skips, without reading them, the sessions whose
|
||||||
|
// newest transcript has a cached order key before the cursor. The
|
||||||
|
// archive state comes from the title, so the archive filter applies once
|
||||||
|
// a transcript is read.
|
||||||
|
const skipped = new Set<string>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const candidate of enumerated) {
|
||||||
|
if (seen.has(candidate.sessionId)) continue;
|
||||||
|
seen.add(candidate.sessionId);
|
||||||
|
if (after && this.cachedBefore(candidate, after)) skipped.add(candidate.sessionId);
|
||||||
|
}
|
||||||
|
const candidates = enumerated.filter((candidate) => !skipped.has(candidate.sessionId));
|
||||||
|
// One row more than the page tells whether a next page exists, so a
|
||||||
|
// cursor never leads to an empty page.
|
||||||
|
const rows = await this.collectRows(candidates, {
|
||||||
|
inScope: scopeOf(paths),
|
||||||
|
archived: query.archived,
|
||||||
|
after,
|
||||||
|
wanted: query.limit + 1,
|
||||||
|
unread: (read) => enumerated.filter((candidate) => !read.has(candidate)),
|
||||||
|
});
|
||||||
|
return { rows: rows.slice(0, query.limit), hasMore: rows.length > query.limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows of `candidates`, one per session (its newest transcript that has
|
||||||
|
* a row), any archive state, as a list of `paths` shows them: a candidate
|
||||||
|
* without a row (no title, a sidechain, continued elsewhere, another path
|
||||||
|
* that shares the project directory) is left out. `siblings` are the other
|
||||||
|
* transcripts of the candidates' directories, read only when a candidate
|
||||||
|
* has no cwd of its own. The metadata cache makes an unchanged transcript
|
||||||
|
* free.
|
||||||
|
*/
|
||||||
|
async rowsOf(
|
||||||
|
paths: readonly string[],
|
||||||
|
candidates: readonly TranscriptCandidate[],
|
||||||
|
siblings: () => readonly TranscriptCandidate[],
|
||||||
|
): Promise<IndexRow[]> {
|
||||||
|
return this.collectRows(candidates, {
|
||||||
|
inScope: scopeOf(paths),
|
||||||
|
archived: "all",
|
||||||
|
wanted: Infinity,
|
||||||
|
unread: (read) => siblings().filter((candidate) => !read.has(candidate)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows of `candidates` (as {@link rowsOf}) that the metadata cache
|
||||||
|
* alone gives, read from it at once, and the ids of the sessions it gives
|
||||||
|
* no answer for: a transcript of it has nothing cached at its stat, no cwd
|
||||||
|
* known, or is continued in another session (whether the successor hides
|
||||||
|
* it depends on the successor now). Reads no transcript.
|
||||||
|
*/
|
||||||
|
async cachedRowsOf(
|
||||||
|
paths: readonly string[],
|
||||||
|
candidates: readonly TranscriptCandidate[],
|
||||||
|
): Promise<{ rows: IndexRow[]; unknown: Set<string> }> {
|
||||||
|
const unknown = new Set<string>();
|
||||||
|
const resolved: { item: Resolved; cwd: string }[] = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const id = candidate.sessionId;
|
||||||
|
const cached = this.metadata.peek(candidate.filePath);
|
||||||
|
if (
|
||||||
|
!cached ||
|
||||||
|
cached.mtimeMs !== candidate.mtimeMs ||
|
||||||
|
cached.size !== candidate.size ||
|
||||||
|
cached.ino !== candidate.ino ||
|
||||||
|
cached.metadata?.continuedIn
|
||||||
|
) {
|
||||||
|
unknown.add(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!cached.metadata) continue;
|
||||||
|
const cwd = cached.metadata.fileCwd ?? this.fallbackCwd(candidate);
|
||||||
|
if (cwd) resolved.push({ item: { candidate, metadata: cached.metadata }, cwd });
|
||||||
|
else unknown.add(id);
|
||||||
|
}
|
||||||
|
const inScope = scopeOf(paths);
|
||||||
|
const newest = new Map<string, { row: IndexRow; mtimeMs: number }>();
|
||||||
|
for (const { item, cwd } of resolved) {
|
||||||
|
const id = item.candidate.sessionId;
|
||||||
|
if (unknown.has(id) || !(await inScope(item.candidate, item.metadata))) continue;
|
||||||
|
const previous = newest.get(id);
|
||||||
|
if (previous && previous.mtimeMs >= item.candidate.mtimeMs) continue;
|
||||||
|
newest.set(id, { row: toRow(item, cwd), mtimeMs: item.candidate.mtimeMs });
|
||||||
|
}
|
||||||
|
return { rows: [...newest.values()].map(({ row }) => row), unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `candidates` in order into rows, one per session, sorted. A
|
||||||
|
* session copied to several transcripts shows its newest one that has a
|
||||||
|
* row, as the SDK list does. With a finite `wanted`, stops once `wanted`
|
||||||
|
* rows are certain to come first (a candidate's order key is at most its
|
||||||
|
* mtime, so `candidates` must be in mtime order then).
|
||||||
|
*/
|
||||||
|
private async collectRows(
|
||||||
|
candidates: readonly TranscriptCandidate[],
|
||||||
|
options: {
|
||||||
|
inScope: (candidate: TranscriptCandidate, metadata: TranscriptMetadata) => Promise<boolean>;
|
||||||
|
/** The rows kept by archive state. */
|
||||||
|
archived: ArchivedFilter;
|
||||||
|
after?: ListCursor;
|
||||||
|
wanted: number;
|
||||||
|
/** The transcripts not read, which may supply a sibling's cwd. */
|
||||||
|
unread: (read: ReadonlySet<TranscriptCandidate>) => readonly TranscriptCandidate[];
|
||||||
|
},
|
||||||
|
): Promise<IndexRow[]> {
|
||||||
|
const { inScope, archived, after, wanted } = options;
|
||||||
|
/** The row of each session (null: left out by the archive filter or
|
||||||
|
* the cursor), and the mtime of the transcript it is from. */
|
||||||
|
const bySession = new Map<string, { row: IndexRow | null; mtimeMs: number }>();
|
||||||
|
const rows = () =>
|
||||||
|
[...bySession.values()].flatMap(({ row }) => (row ? [row] : [])).sort(compareRows);
|
||||||
|
// Read transcripts without a cwd of their own: a sibling of the same
|
||||||
|
// directory may supply it, whichever batch it is read in.
|
||||||
|
let pending: Resolved[] = [];
|
||||||
|
const accept = async (resolved: Resolved, cwd: string) => {
|
||||||
|
// The SDK leaves out another path that encodes to the same project
|
||||||
|
// directory (see scopeOf).
|
||||||
|
if (!(await inScope(resolved.candidate, resolved.metadata))) return;
|
||||||
|
const { sessionId, mtimeMs } = resolved.candidate;
|
||||||
|
const previous = bySession.get(sessionId);
|
||||||
|
if (previous && previous.mtimeMs >= mtimeMs) return;
|
||||||
|
const row = toRow(resolved, cwd);
|
||||||
|
// A newer copy that the filter or the cursor leaves out still hides
|
||||||
|
// an older one.
|
||||||
|
const kept = archivedFilterKeeps(archived, row.archived) && (!after || isAfter(row, after));
|
||||||
|
bySession.set(sessionId, { row: kept ? row : null, mtimeMs });
|
||||||
|
};
|
||||||
|
const settlePending = async () => {
|
||||||
|
const left: Resolved[] = [];
|
||||||
|
for (const resolved of pending) {
|
||||||
|
const cwd = this.fallbackCwd(resolved.candidate);
|
||||||
|
if (cwd) await accept(resolved, cwd);
|
||||||
|
else left.push(resolved);
|
||||||
|
}
|
||||||
|
pending = left;
|
||||||
|
};
|
||||||
|
const read = new Set<TranscriptCandidate>();
|
||||||
|
let index = 0;
|
||||||
|
while (index < candidates.length) {
|
||||||
|
if (bySession.size >= wanted) {
|
||||||
|
const sorted = rows();
|
||||||
|
// A candidate's order key is at most its mtime.
|
||||||
|
if (sorted.length >= wanted && candidates[index]!.mtimeMs < sorted[wanted - 1]!.orderAtMs) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const batch = candidates.slice(index, index + READ_BATCH_SIZE);
|
||||||
|
index += batch.length;
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
batch.map(async (candidate) => {
|
||||||
|
read.add(candidate);
|
||||||
|
const metadata = await this.metadataOf(candidate);
|
||||||
|
if (!metadata || (await this.continuedElsewhere(candidate, metadata))) return undefined;
|
||||||
|
return { candidate, metadata };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const item of resolved) {
|
||||||
|
if (!item) continue;
|
||||||
|
const cwd = item.metadata.fileCwd ?? this.fallbackCwd(item.candidate);
|
||||||
|
if (cwd) await accept(item, cwd);
|
||||||
|
else pending.push(item);
|
||||||
|
}
|
||||||
|
await settlePending();
|
||||||
|
}
|
||||||
|
if (pending.length > 0) {
|
||||||
|
// A row recovered after the scan stopped still sorts into the page: it
|
||||||
|
// was read before the stop, and more rows only raise the bound. A
|
||||||
|
// sibling that the archive filter or the cursor leaves out may still
|
||||||
|
// supply the cwd.
|
||||||
|
await this.learnDirCwds(
|
||||||
|
new Set(pending.map(({ candidate }) => candidate.dirName)),
|
||||||
|
options.unread(read),
|
||||||
|
);
|
||||||
|
await settlePending();
|
||||||
|
}
|
||||||
|
return rows();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the cached metadata of `candidate`, still current, places it
|
||||||
|
* at or before `cursor`. */
|
||||||
|
private cachedBefore(candidate: TranscriptCandidate, cursor: ListCursor): boolean {
|
||||||
|
const cached = this.metadata.peek(candidate.filePath);
|
||||||
|
if (
|
||||||
|
!cached?.metadata ||
|
||||||
|
cached.mtimeMs !== candidate.mtimeMs ||
|
||||||
|
cached.size !== candidate.size ||
|
||||||
|
cached.ino !== candidate.ino
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { metadata } = cached;
|
||||||
|
const orderAtMs = Math.min(
|
||||||
|
metadata.facts.lastPromptAt ?? metadata.updatedAtMs,
|
||||||
|
metadata.updatedAtMs,
|
||||||
|
);
|
||||||
|
return !isAfter({ orderAtMs, sessionId: candidate.sessionId }, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the unread transcripts of `dirNames` until each directory has a
|
||||||
|
* known cwd, at most {@link MAX_CWD_PROBES_PER_DIR} per directory. A
|
||||||
|
* directory that gives none is not read again for a while, unless it
|
||||||
|
* changes. */
|
||||||
|
private async learnDirCwds(
|
||||||
|
dirNames: ReadonlySet<string>,
|
||||||
|
unread: readonly TranscriptCandidate[],
|
||||||
|
): Promise<void> {
|
||||||
|
const root = projectsRoot();
|
||||||
|
const now = Date.now();
|
||||||
|
const mtimes = new Map<string, number>();
|
||||||
|
for (const dirName of dirNames) {
|
||||||
|
const mtimeMs = await fs.stat(path.join(root, dirName)).then(
|
||||||
|
(stats) => stats.mtimeMs,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
if (mtimeMs === undefined) continue;
|
||||||
|
const known = this.dirsWithoutCwd.get(dirName);
|
||||||
|
if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS) continue;
|
||||||
|
mtimes.set(dirName, mtimeMs);
|
||||||
|
}
|
||||||
|
const probes = new Map<string, number>();
|
||||||
|
let remaining = unread.filter((candidate) => {
|
||||||
|
if (!mtimes.has(candidate.dirName)) return false;
|
||||||
|
const count = probes.get(candidate.dirName) ?? 0;
|
||||||
|
if (count >= MAX_CWD_PROBES_PER_DIR) return false;
|
||||||
|
probes.set(candidate.dirName, count + 1);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
remaining = remaining.filter((candidate) => !this.dirCwds.has(candidate.dirName));
|
||||||
|
const batch = remaining.slice(0, READ_BATCH_SIZE);
|
||||||
|
remaining = remaining.slice(batch.length);
|
||||||
|
await Promise.all(batch.map((candidate) => this.metadataOf(candidate)));
|
||||||
|
}
|
||||||
|
for (const [dirName, mtimeMs] of mtimes) {
|
||||||
|
if (!this.dirCwds.has(dirName)) this.dirsWithoutCwd.set(dirName, { mtimeMs, at: now });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The requested path of the directory, else a sibling's cwd. */
|
||||||
|
private fallbackCwd(candidate: TranscriptCandidate): string | undefined {
|
||||||
|
// A directory found for the requested path belongs to it, also one that
|
||||||
|
// differs in case only (see caseInsensitiveProjectDir).
|
||||||
|
if (
|
||||||
|
candidate.projectPath &&
|
||||||
|
(projectDirMatches(candidate.dirName, candidate.projectPath) ||
|
||||||
|
isExactProjectDir(candidate.dirName, candidate.projectPath))
|
||||||
|
) {
|
||||||
|
return candidate.projectPath;
|
||||||
|
}
|
||||||
|
return this.dirCwds.get(candidate.dirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async metadataOf(candidate: TranscriptCandidate): Promise<TranscriptMetadata | null> {
|
||||||
|
const cached = this.metadata.get(candidate.filePath);
|
||||||
|
if (
|
||||||
|
cached &&
|
||||||
|
cached.mtimeMs === candidate.mtimeMs &&
|
||||||
|
cached.size === candidate.size &&
|
||||||
|
cached.ino === candidate.ino
|
||||||
|
) {
|
||||||
|
if (cached.metadata?.fileCwd) this.dirCwds.set(candidate.dirName, cached.metadata.fileCwd);
|
||||||
|
return cached.metadata;
|
||||||
|
}
|
||||||
|
const key = `${candidate.mtimeMs}:${candidate.size}:${candidate.ino}`;
|
||||||
|
const inFlight = this.reading.get(candidate.filePath);
|
||||||
|
if (inFlight?.key === key) return inFlight.promise;
|
||||||
|
const promise = this.readAndCache(candidate, cached);
|
||||||
|
this.reading.set(candidate.filePath, { key, promise });
|
||||||
|
void promise.finally(() => {
|
||||||
|
if (this.reading.get(candidate.filePath)?.promise === promise) {
|
||||||
|
this.reading.delete(candidate.filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readAndCache(
|
||||||
|
candidate: TranscriptCandidate,
|
||||||
|
cached: CacheEntry | undefined,
|
||||||
|
): Promise<TranscriptMetadata | null> {
|
||||||
|
let metadata: TranscriptMetadata | null;
|
||||||
|
try {
|
||||||
|
// The transcript grew since: an earlier scan may still know its last
|
||||||
|
// prompt.
|
||||||
|
metadata = await this.readMetadata(
|
||||||
|
candidate,
|
||||||
|
cached && cached.ino === candidate.ino
|
||||||
|
? { ...cached.metadata?.facts, size: cached.size }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Unreadable now (deleted, permissions): skip it and retry next time.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.metadata.set(candidate.filePath, {
|
||||||
|
mtimeMs: candidate.mtimeMs,
|
||||||
|
size: candidate.size,
|
||||||
|
ino: candidate.ino,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readMetadata(
|
||||||
|
candidate: TranscriptCandidate,
|
||||||
|
previous?: PreviousScan,
|
||||||
|
): Promise<TranscriptMetadata | null> {
|
||||||
|
const headTail = await readHeadTail(candidate.filePath, candidate.size);
|
||||||
|
if (isSidechainTranscript(headTail.head)) return null;
|
||||||
|
const facts = await scanTranscriptFile(
|
||||||
|
candidate.filePath,
|
||||||
|
candidate.size,
|
||||||
|
headTail,
|
||||||
|
previous,
|
||||||
|
candidate.ino,
|
||||||
|
);
|
||||||
|
if (!facts.hasMessages) return null;
|
||||||
|
// The last relocation names the session's cwd, as the SDK reads it; the
|
||||||
|
// messages before it keep the old one.
|
||||||
|
const fileCwd = await this.recoverCwd(candidate.dirName, [
|
||||||
|
relocatedCwd(headTail.tail),
|
||||||
|
facts.headCwd,
|
||||||
|
...(facts.tailCwd ? pathAndAncestors(facts.tailCwd) : []),
|
||||||
|
]);
|
||||||
|
if (fileCwd) this.dirCwds.set(candidate.dirName, fileCwd);
|
||||||
|
const sdk = await this.titlesOf(candidate, headTail, fileCwd);
|
||||||
|
// No title at all: the SDK does not list it either.
|
||||||
|
if (!sdk.summary) return null;
|
||||||
|
// Archived by its name alone: the agent name, else the custom title.
|
||||||
|
const { title = sdk.summary, archived } = effectiveTitle(transcriptAgentName(headTail), sdk);
|
||||||
|
// A last message longer than the tail search still ends with its
|
||||||
|
// timestamp; the mtime moves with every metadata record.
|
||||||
|
const lastMessageAt =
|
||||||
|
facts.lastMessageAt ??
|
||||||
|
(await lastTimestamp(candidate.filePath, candidate.size)) ??
|
||||||
|
candidate.mtimeMs;
|
||||||
|
const continuedIn = continuedInSessionId(headTail.tail);
|
||||||
|
const projectCwd = transcriptProjectCwd(headTail);
|
||||||
|
return {
|
||||||
|
title: sanitizeTitle(archived ? visibleTitle(title) : title),
|
||||||
|
archived,
|
||||||
|
...(fileCwd && { fileCwd }),
|
||||||
|
...(projectCwd && { projectCwd }),
|
||||||
|
...(continuedIn && { continuedIn }),
|
||||||
|
updatedAtMs: Math.min(lastMessageAt, candidate.mtimeMs),
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The titles the SDK reports for the listed transcript (`customTitle` and
|
||||||
|
* `summary` of `getSessionInfo`). The SDK reads the first copy of the
|
||||||
|
* session that its search finds; its answer is used only when that copy is
|
||||||
|
* the listed file (same size and mtime), else the same titles are taken
|
||||||
|
* from the listed file itself (see {@link sdkTitles}).
|
||||||
|
*/
|
||||||
|
private async titlesOf(
|
||||||
|
candidate: TranscriptCandidate,
|
||||||
|
headTail: HeadTail,
|
||||||
|
fileCwd: string | undefined,
|
||||||
|
): Promise<SdkTitles> {
|
||||||
|
const dir = [fileCwd, candidate.projectPath].find(
|
||||||
|
(cwd) => cwd !== undefined && isExactProjectDir(candidate.dirName, cwd),
|
||||||
|
);
|
||||||
|
const info = await this.getSessionInfo(candidate.sessionId, dir ? { dir } : {}).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
info &&
|
||||||
|
info.fileSize === candidate.size &&
|
||||||
|
info.lastModified === Math.trunc(candidate.mtimeMs)
|
||||||
|
) {
|
||||||
|
return { customTitle: info.customTitle, summary: info.summary };
|
||||||
|
}
|
||||||
|
const sidecar =
|
||||||
|
tailCustomTitle(headTail.tail) === undefined
|
||||||
|
? await readSidecarTitle(candidate.filePath, candidate.sessionId)
|
||||||
|
: undefined;
|
||||||
|
return sdkTitles(headTail, sidecar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a successor of a continued transcript holds history: the SDK
|
||||||
|
* list then hides the predecessor. */
|
||||||
|
private async continuedElsewhere(candidate: TranscriptCandidate, metadata: TranscriptMetadata) {
|
||||||
|
if (!metadata.continuedIn) return false;
|
||||||
|
return hasHistory(path.join(path.dirname(candidate.filePath), `${metadata.continuedIn}.jsonl`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first candidate that encodes to `dirName`, or, on macOS, that
|
||||||
|
* encodes to a name the file system resolves to `dirName` (it differs in
|
||||||
|
* case only). A directory name is never decoded. */
|
||||||
|
private async recoverCwd(
|
||||||
|
dirName: string,
|
||||||
|
candidates: (string | undefined)[],
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate || !path.isAbsolute(candidate)) continue;
|
||||||
|
if (projectDirMatches(dirName, candidate)) return candidate;
|
||||||
|
if (isExactProjectDir(dirName, candidate) && (await sameDirOnDisk(dirName, candidate))) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
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]);
|
||||||
|
}
|
||||||
614
src/session-index/transcript-scan.ts
Normal file
614
src/session-index/transcript-scan.ts
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
/**
|
||||||
|
* 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; tail: 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<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;
|
||||||
|
const newline = raw.indexOf("\n");
|
||||||
|
return 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 };
|
||||||
|
return { head, tail: await readTail(handle, size, CHUNK_SIZE) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: string): string | undefined {
|
||||||
|
return lastField(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}. For a transcript that the SDK `getSessionInfo`
|
||||||
|
* does not read (another copy of the session comes first in its search).
|
||||||
|
* The sidecar title counts only when the tail has no custom title.
|
||||||
|
*/
|
||||||
|
export function sdkTitles({ head, tail }: HeadTail, sidecarTitle?: string): SdkTitles {
|
||||||
|
const customTitle =
|
||||||
|
lastField(tail, "customTitle") ??
|
||||||
|
sidecarTitle ??
|
||||||
|
lastField(head, "customTitle") ??
|
||||||
|
lastField(tail, "aiTitle") ??
|
||||||
|
lastField(head, "aiTitle");
|
||||||
|
return { customTitle, summary: customTitle ?? generatedTitle({ head, tail }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The title of a transcript without a custom title, in the SDK's order: AI
|
||||||
|
* title, last prompt, summary, first prompt. */
|
||||||
|
export function generatedTitle({ head, tail }: HeadTail): string | undefined {
|
||||||
|
return (
|
||||||
|
lastField(tail, "aiTitle") ??
|
||||||
|
lastField(head, "aiTitle") ??
|
||||||
|
lastField(tail, "lastPrompt") ??
|
||||||
|
lastField(tail, "summary") ??
|
||||||
|
(firstPrompt(head) || mediaPrompt(head) || undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The time of the last `"timestamp"` in the last 64 KB of a file, cut
|
||||||
|
* line included, epoch ms: a record longer than that still ends with it. */
|
||||||
|
export async function lastTimestamp(filePath: string, size: number): Promise<number | undefined> {
|
||||||
|
const handle = await fs.open(filePath, "r");
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
const length = Math.min(size, CHUNK_SIZE);
|
||||||
|
const buffer = Buffer.allocUnsafe(length);
|
||||||
|
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
||||||
|
text = buffer.toString("utf8", 0, bytesRead);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
for (const raw of stringFields(text, "timestamp").reverse()) {
|
||||||
|
const value = Date.parse(raw);
|
||||||
|
if (!Number.isNaN(value)) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTINUED_IN_MARKER = '"type":"continued-in"';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session this transcript was continued in, as the SDK list reads it: the
|
||||||
|
* last `continued-in` record of the tail, unless a completed turn follows it
|
||||||
|
* (the session was resumed here again).
|
||||||
|
*/
|
||||||
|
export function continuedInSessionId(tail: string): string | undefined {
|
||||||
|
if (!tail.includes(CONTINUED_IN_MARKER)) return undefined;
|
||||||
|
const lines = tail.split("\n");
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
const line = lines[i]!;
|
||||||
|
const isContinuation = line.includes(CONTINUED_IN_MARKER);
|
||||||
|
const isMessage = line.includes('"type":"user"') || line.includes('"type":"assistant"');
|
||||||
|
if (!isContinuation && !isMessage) continue;
|
||||||
|
const entry = parseLine(line);
|
||||||
|
if (!entry) continue;
|
||||||
|
if (isContinuation && entry.type === "continued-in") {
|
||||||
|
const id = entry.continuedInSessionId;
|
||||||
|
return typeof id === "string" && SESSION_ID.test(id) ? id : undefined;
|
||||||
|
}
|
||||||
|
if (entry.type === "assistant") {
|
||||||
|
const stopReason = (entry.message as { stop_reason?: unknown } | undefined)?.stop_reason;
|
||||||
|
if (entry.isApiErrorMessage !== true && typeof stopReason === "string") return undefined;
|
||||||
|
} else if (promptOf(entry, { commandFallback: "" }) !== undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const HISTORY_MARKER = '"parentUuid":';
|
||||||
|
const HISTORY_SCAN_LIMIT = 16 * 1024 * 1024;
|
||||||
|
const HISTORY_SCAN_CHUNK = 1024 * 1024;
|
||||||
|
|
||||||
|
/** Whether the transcript at `filePath` holds conversation history (a record
|
||||||
|
* with a `parentUuid`), as the SDK checks a continuation's successor. */
|
||||||
|
export async function hasHistory(filePath: string): Promise<boolean> {
|
||||||
|
let handle: fs.FileHandle;
|
||||||
|
try {
|
||||||
|
handle = await fs.open(filePath, "r");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { size } = await handle.stat();
|
||||||
|
const buffer = Buffer.allocUnsafe(HISTORY_SCAN_CHUNK + HISTORY_MARKER.length);
|
||||||
|
let carry = 0;
|
||||||
|
for (let offset = 0; offset < Math.min(size, HISTORY_SCAN_LIMIT);) {
|
||||||
|
const { bytesRead } = await handle.read(buffer, carry, HISTORY_SCAN_CHUNK, offset);
|
||||||
|
if (bytesRead === 0) return false;
|
||||||
|
const end = carry + bytesRead;
|
||||||
|
if (buffer.subarray(0, end).includes(HISTORY_MARKER)) return true;
|
||||||
|
carry = Math.min(HISTORY_MARKER.length, end);
|
||||||
|
buffer.copyWithin(0, end - carry, end);
|
||||||
|
offset += bytesRead;
|
||||||
|
}
|
||||||
|
// Past the scan limit the SDK assumes history.
|
||||||
|
return size > HISTORY_SCAN_LIMIT;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/session-index/worktrees.ts
Normal file
135
src/session-index/worktrees.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* The git worktrees of a working directory, read from the repository files.
|
||||||
|
*
|
||||||
|
* The SDK `listSessions({ dir })` includes the sessions of every worktree of
|
||||||
|
* the repository and runs `git worktree list` for that on each call. This
|
||||||
|
* module reads the same list without spawning git, the way the Codex TUI does:
|
||||||
|
* the main worktree is the parent of the common `.git` directory, and each
|
||||||
|
* linked worktree is named by `<common-dir>/worktrees/<name>/gitdir`.
|
||||||
|
* A worktree whose directory no longer exists is dropped. Nothing is cached:
|
||||||
|
* `git worktree move` rewrites a `gitdir` file without touching the
|
||||||
|
* `worktrees` directory, so a cache keyed on it would miss the move.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { normalizePath } from "./project-dirs.js";
|
||||||
|
|
||||||
|
async function statOrUndefined(target: string) {
|
||||||
|
try {
|
||||||
|
return await fs.stat(target);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The git dir of the repository that contains `cwd`, or undefined. */
|
||||||
|
async function findGitDir(cwd: string): Promise<string | undefined> {
|
||||||
|
let current = path.resolve(cwd);
|
||||||
|
for (;;) {
|
||||||
|
const candidate = path.join(current, ".git");
|
||||||
|
const stats = await statOrUndefined(candidate);
|
||||||
|
if (stats?.isDirectory()) return candidate;
|
||||||
|
if (stats?.isFile()) {
|
||||||
|
try {
|
||||||
|
const text = await fs.readFile(candidate, "utf8");
|
||||||
|
const match = /^gitdir:\s*(.+)$/m.exec(text);
|
||||||
|
if (match) return path.resolve(current, match[1]!.trim());
|
||||||
|
} catch {
|
||||||
|
// An unreadable `.git` file is not a repository.
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) return undefined;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commonDirOf(gitDir: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const text = await fs.readFile(path.join(gitDir, "commondir"), "utf8");
|
||||||
|
const value = text.trim();
|
||||||
|
if (value) return path.resolve(gitDir, value);
|
||||||
|
} catch {
|
||||||
|
// No `commondir`: the git dir is the common dir.
|
||||||
|
}
|
||||||
|
return gitDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkedWorktrees(commonDir: string): Promise<string[]> {
|
||||||
|
const worktreesDir = path.join(commonDir, "worktrees");
|
||||||
|
const stats = await statOrUndefined(worktreesDir);
|
||||||
|
if (!stats?.isDirectory()) return [];
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await fs.readdir(worktreesDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
names.map(async (name) => {
|
||||||
|
try {
|
||||||
|
const text = await fs.readFile(path.join(worktreesDir, name, "gitdir"), "utf8");
|
||||||
|
const gitFile = text.trim();
|
||||||
|
return gitFile ? path.dirname(path.resolve(worktreesDir, name, gitFile)) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((value): value is string => value !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The root of the worktree that contains `cwd`: the directory of its
|
||||||
|
* `.git` (a directory in the main worktree, a file in a linked one). */
|
||||||
|
async function worktreeRootOf(cwd: string): Promise<string | undefined> {
|
||||||
|
let current = path.resolve(cwd);
|
||||||
|
for (;;) {
|
||||||
|
if (await statOrUndefined(path.join(current, ".git"))) return current;
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) return undefined;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same subdirectory as `cwd` in every existing worktree of its
|
||||||
|
* repository (the main one and the linked ones), as the Codex TUI expands a
|
||||||
|
* cwd: for `/repo/packages/a`, `/wt1/packages/a` if it exists. For a cwd at a
|
||||||
|
* worktree root, the worktree roots. Empty outside a repository.
|
||||||
|
*/
|
||||||
|
export async function worktreeCounterparts(cwd: string): Promise<string[]> {
|
||||||
|
const root = await worktreeRootOf(cwd);
|
||||||
|
if (!root) return [];
|
||||||
|
const relative = path.relative(root, path.resolve(cwd));
|
||||||
|
const candidates = (await repositoryWorktrees(cwd)).map((worktree) =>
|
||||||
|
relative ? path.join(worktree, relative) : worktree,
|
||||||
|
);
|
||||||
|
const existing = await Promise.all(
|
||||||
|
candidates.map(async (candidate) =>
|
||||||
|
(await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return [...new Set(existing.filter((value): value is string => value !== undefined))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every existing worktree of the repository that contains `cwd`: the main
|
||||||
|
* worktree and the linked ones. Empty outside a repository.
|
||||||
|
*/
|
||||||
|
export async function repositoryWorktrees(cwd: string): Promise<string[]> {
|
||||||
|
const gitDir = await findGitDir(cwd);
|
||||||
|
if (!gitDir) return [];
|
||||||
|
const commonDir = await commonDirOf(gitDir);
|
||||||
|
const candidates: string[] = [];
|
||||||
|
if (path.basename(commonDir) === ".git") candidates.push(path.dirname(commonDir));
|
||||||
|
candidates.push(...(await linkedWorktrees(commonDir)));
|
||||||
|
const existing = await Promise.all(
|
||||||
|
candidates.map(async (candidate) =>
|
||||||
|
(await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return [...new Set(existing.filter((value): value is string => value !== undefined))];
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk";
|
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk";
|
||||||
import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type { ClaudeAcpAgent, Session } from "./acp-agent.js";
|
import type { ClaudeAcpAgent, Session } from "./acp-agent.js";
|
||||||
|
import { ExplicitTitle } from "./session-index/explicit-title.js";
|
||||||
|
|
||||||
const MAX_TITLE_LENGTH = 256;
|
const MAX_TITLE_LENGTH = 256;
|
||||||
|
|
||||||
@@ -42,7 +43,22 @@ type TitleCapableQuery = Query & {
|
|||||||
* unavailable or yields nothing. */
|
* unavailable or yields nothing. */
|
||||||
type TitleFallback = { title: string; lastModified: number };
|
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 {
|
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
|
// Replace newlines and collapse whitespace
|
||||||
const sanitized = text
|
const sanitized = text
|
||||||
.replace(/[\r\n]+/g, " ")
|
.replace(/[\r\n]+/g, " ")
|
||||||
@@ -84,6 +100,27 @@ export class SessionTitles {
|
|||||||
* one. Released by {@link reset}, and when generation yields nothing. */
|
* one. Released by {@link reset}, and when generation yields nothing. */
|
||||||
private settled = false;
|
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(
|
constructor(
|
||||||
private readonly agent: ClaudeAcpAgent,
|
private readonly agent: ClaudeAcpAgent,
|
||||||
private readonly sessionId: string,
|
private readonly sessionId: string,
|
||||||
@@ -115,8 +152,10 @@ export class SessionTitles {
|
|||||||
* `conversation_reset`, which mounts a fresh transcript. */
|
* `conversation_reset`, which mounts a fresh transcript. */
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.settled = false;
|
this.settled = false;
|
||||||
|
this.explicit.reset();
|
||||||
this.context = undefined;
|
this.context = undefined;
|
||||||
this.lastTitle = undefined;
|
this.lastTitle = undefined;
|
||||||
|
this.persistedTitle = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Turn-end title handling. `idle` is the SDK's turn-over signal, so it is
|
/** 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
|
* not possible do we fall back to `summary`, which for an SDK-driven session
|
||||||
* is just the raw first prompt. */
|
* is just the raw first prompt. */
|
||||||
async onTurnEnd(session: Session): Promise<void> {
|
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);
|
const info = await this.readSessionInfo(session);
|
||||||
|
if (this.explicit.active) return;
|
||||||
|
|
||||||
if (info?.customTitle) {
|
if (info?.customTitle) {
|
||||||
this.settled = true;
|
this.settled = true;
|
||||||
@@ -147,9 +203,13 @@ export class SessionTitles {
|
|||||||
if (this.canRequest(session)) {
|
if (this.canRequest(session)) {
|
||||||
this.settled = true;
|
this.settled = true;
|
||||||
|
|
||||||
void this.requestGenerateTitle(session, fallback).catch((error) => {
|
this.explicit.track(
|
||||||
this.agent.logger.error(`Session ${this.sessionId}: session title update failed: ${error}`);
|
this.requestGenerateTitle(session, fallback).catch((error) => {
|
||||||
});
|
this.agent.logger.error(
|
||||||
|
`Session ${this.sessionId}: session title update failed: ${error}`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
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
|
/** 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. */
|
* error is non-fatal: the title is best-effort and another turn will retry. */
|
||||||
private async readSessionInfo(session: Session): Promise<SDKSessionInfo | undefined> {
|
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. */
|
/** Notify the client of a title, unless it is the one we last sent. */
|
||||||
private async publish(rawTitle: string, lastModified: number): Promise<void> {
|
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) {
|
if (title === this.lastTitle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -226,8 +334,8 @@ export class SessionTitles {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A session torn down or replaced while the title was in flight must not
|
// A session torn down or replaced while the title was in flight must not
|
||||||
// adopt it.
|
// adopt it, and a title the client set meanwhile wins.
|
||||||
if (this.agent.sessions[this.sessionId] !== session) {
|
if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10790,12 +10790,13 @@ describe("session/close", () => {
|
|||||||
expect(session.abortController.signal.aborted).toBe(true);
|
expect(session.abortController.signal.aborted).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when closing a non-existent session", async () => {
|
it("is idempotent for a session that is not loaded here", async () => {
|
||||||
const agent = createMockAgent();
|
const agent = createMockAgent();
|
||||||
|
|
||||||
await expect(agent.closeSession({ sessionId: "non-existent" })).rejects.toThrow(
|
await expect(agent.closeSession({ sessionId: "non-existent" })).resolves.toEqual({});
|
||||||
"Session not found",
|
injectSession(agent, "session-1");
|
||||||
);
|
await agent.closeSession({ sessionId: "session-1" });
|
||||||
|
await expect(agent.closeSession({ sessionId: "session-1" })).resolves.toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not affect other sessions when closing one", async () => {
|
it("should not affect other sessions when closing one", async () => {
|
||||||
|
|||||||
111
src/tests/diff-memo.test.ts
Normal file
111
src/tests/diff-memo.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as diff from "diff";
|
||||||
|
import { clearDiffMemo, patchUpdateFromDiffToolResponse, previewPatchContent } from "../diff.js";
|
||||||
|
|
||||||
|
vi.mock("diff", async (importOriginal) => {
|
||||||
|
const original = await importOriginal<typeof import("diff")>();
|
||||||
|
return { ...original, structuredPatch: vi.fn(original.structuredPatch) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const structuredPatch = vi.mocked(diff.structuredPatch);
|
||||||
|
const tempDirectories: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearDiffMemo();
|
||||||
|
structuredPatch.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function temporaryFile(content: string): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "claude-acp-diff-memo-"));
|
||||||
|
tempDirectories.push(directory);
|
||||||
|
const filePath = path.join(directory, "file.txt");
|
||||||
|
await writeFile(filePath, content);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchText(content: unknown): string {
|
||||||
|
const block = Array.isArray(content) ? content[0] : content;
|
||||||
|
return (block._meta as any).jetbrains.air.diffPatch.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = Array.from({ length: 2000 }, (_, i) => `line ${i}\n`).join("");
|
||||||
|
const after = before.replace("line 1000\n", "line one thousand\n");
|
||||||
|
|
||||||
|
describe("line diff reuse between the approval and the PostToolUse hook", () => {
|
||||||
|
it("diffs an Edit once and gives the hook the same patch", async () => {
|
||||||
|
const filePath = await temporaryFile(before);
|
||||||
|
const preview = await previewPatchContent("Edit", {
|
||||||
|
file_path: filePath,
|
||||||
|
old_string: "line 1000\n",
|
||||||
|
new_string: "line one thousand\n",
|
||||||
|
});
|
||||||
|
expect(structuredPatch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await writeFile(filePath, after);
|
||||||
|
const hook = await patchUpdateFromDiffToolResponse({
|
||||||
|
filePath,
|
||||||
|
originalFile: before,
|
||||||
|
oldString: "line 1000\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(structuredPatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(patchText(hook!.content)).toBe(patchText(preview));
|
||||||
|
expect(hook!.locations).toEqual([{ path: filePath, line: 998 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("diffs again when the written file differs from the preview", async () => {
|
||||||
|
const filePath = await temporaryFile(before);
|
||||||
|
await previewPatchContent("Edit", {
|
||||||
|
file_path: filePath,
|
||||||
|
old_string: "line 1000\n",
|
||||||
|
new_string: "line one thousand\n",
|
||||||
|
});
|
||||||
|
const written = before.replace("line 1000\n", "line 1000 changed later\n");
|
||||||
|
await writeFile(filePath, written);
|
||||||
|
|
||||||
|
const hook = await patchUpdateFromDiffToolResponse({ filePath, originalFile: before });
|
||||||
|
|
||||||
|
expect(structuredPatch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(patchText(hook!.content)).toContain("+line 1000 changed later\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("diffs again after a diff that ran out of its budget", async () => {
|
||||||
|
structuredPatch.mockImplementationOnce(() => undefined as never);
|
||||||
|
const filePath = await temporaryFile(before);
|
||||||
|
const input = { file_path: filePath, content: after };
|
||||||
|
|
||||||
|
const first = await previewPatchContent("Write", input);
|
||||||
|
const second = await previewPatchContent("Write", input);
|
||||||
|
|
||||||
|
expect(structuredPatch).toHaveBeenCalledTimes(2);
|
||||||
|
// Without a patch, the approval falls back to the standard diff.
|
||||||
|
expect(first).toEqual([{ type: "diff", path: filePath, oldText: before, newText: after }]);
|
||||||
|
// The second diff completes and gets the exact patch.
|
||||||
|
expect(patchText(second)).toContain("+line one thousand\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only a few recent diffs, each until it is read again", async () => {
|
||||||
|
const filePath = await temporaryFile(before);
|
||||||
|
const write = (i: number) =>
|
||||||
|
previewPatchContent("Write", { file_path: filePath, content: `${before}tail ${i}\n` });
|
||||||
|
for (let i = 0; i < 6; i++) await write(i);
|
||||||
|
expect(structuredPatch).toHaveBeenCalledTimes(6);
|
||||||
|
// The most recent diffs are kept; the oldest were pushed out.
|
||||||
|
await write(5);
|
||||||
|
expect(structuredPatch).toHaveBeenCalledTimes(6);
|
||||||
|
await write(0);
|
||||||
|
expect(structuredPatch).toHaveBeenCalledTimes(7);
|
||||||
|
// A diff that was read again is gone.
|
||||||
|
await write(5);
|
||||||
|
expect(structuredPatch).toHaveBeenCalledTimes(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
260
src/tests/event-loop-yield.test.ts
Normal file
260
src/tests/event-loop-yield.test.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||||
|
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||||
|
import { Writable } from "node:stream";
|
||||||
|
import { EventLoopYielder, nodeToWebWritable, Pushable } from "../utils.js";
|
||||||
|
import {
|
||||||
|
mockSessionState,
|
||||||
|
successfulResultMessage,
|
||||||
|
userEcho,
|
||||||
|
wrapQuery,
|
||||||
|
} from "./session-doubles.js";
|
||||||
|
|
||||||
|
const sessionId = "test-session";
|
||||||
|
|
||||||
|
/** Busy-waits, so a test can make one message cost real time. */
|
||||||
|
function spin(ms: number): void {
|
||||||
|
const end = performance.now() + ms;
|
||||||
|
while (performance.now() < end) {
|
||||||
|
// spin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textDelta(text: string) {
|
||||||
|
return {
|
||||||
|
type: "stream_event",
|
||||||
|
event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } },
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A query whose whole answer is already buffered: every `next()` resolves at
|
||||||
|
* once, as the SDK queue does when the CLI is ahead of the adapter. */
|
||||||
|
function bufferedAnswer(chunks: string[]) {
|
||||||
|
return (input: Pushable<any>) =>
|
||||||
|
(async function* () {
|
||||||
|
const user = await input[Symbol.asyncIterator]().next();
|
||||||
|
yield userEcho(user.value);
|
||||||
|
for (const chunk of chunks) yield textDelta(chunk);
|
||||||
|
yield successfulResultMessage();
|
||||||
|
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgent(
|
||||||
|
makeGenerator: (input: Pushable<any>) => AsyncGenerator<any>,
|
||||||
|
onUpdate: (notification: SessionNotification) => void,
|
||||||
|
) {
|
||||||
|
const agent = new ClaudeAcpAgent(
|
||||||
|
{
|
||||||
|
sessionUpdate: async (notification: SessionNotification) => onUpdate(notification),
|
||||||
|
} as unknown as AcpClient,
|
||||||
|
{ log: () => {}, error: () => {} },
|
||||||
|
);
|
||||||
|
const input = new Pushable<any>();
|
||||||
|
agent.sessions[sessionId] = mockSessionState({
|
||||||
|
query: wrapQuery(makeGenerator(input)),
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkTexts(updates: SessionNotification[]): string[] {
|
||||||
|
return updates
|
||||||
|
.filter((n) => n.update.sessionUpdate === "agent_message_chunk")
|
||||||
|
.map((n) => (n.update as { content: { text: string } }).content.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EventLoopYielder", () => {
|
||||||
|
it("lets a loop run on within its budget", () => {
|
||||||
|
let time = 0;
|
||||||
|
const yielder = new EventLoopYielder(8, () => time);
|
||||||
|
expect(yielder.maybeYield()).toBeUndefined();
|
||||||
|
time = 7;
|
||||||
|
expect(yielder.maybeYield()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yields to the event loop past its budget", async () => {
|
||||||
|
let time = 0;
|
||||||
|
const yielder = new EventLoopYielder(8, () => time);
|
||||||
|
expect(yielder.maybeYield()).toBeUndefined();
|
||||||
|
let timerRan = false;
|
||||||
|
setImmediate(() => (timerRan = true));
|
||||||
|
time = 8;
|
||||||
|
const pause = yielder.maybeYield();
|
||||||
|
expect(pause).toBeInstanceOf(Promise);
|
||||||
|
await pause;
|
||||||
|
expect(timerRan).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new budget once the event loop has turned", async () => {
|
||||||
|
let time = 0;
|
||||||
|
const yielder = new EventLoopYielder(8, () => time);
|
||||||
|
yielder.maybeYield();
|
||||||
|
time = 20;
|
||||||
|
await yielder.maybeYield();
|
||||||
|
// The pause let the event loop turn, so the budget starts again here.
|
||||||
|
expect(yielder.maybeYield()).toBeUndefined();
|
||||||
|
time = 27;
|
||||||
|
expect(yielder.maybeYield()).toBeUndefined();
|
||||||
|
time = 28;
|
||||||
|
expect(yielder.maybeYield()).toBeInstanceOf(Promise);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not yield for a loop that waits on I/O between items", async () => {
|
||||||
|
let time = 0;
|
||||||
|
const yielder = new EventLoopYielder(8, () => time);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
expect(yielder.maybeYield()).toBeUndefined();
|
||||||
|
time += 20;
|
||||||
|
// A wait on I/O lets the event loop run its phases. A timer alone can
|
||||||
|
// resume within the same timers phase on a slow machine, before the
|
||||||
|
// check phase that ends the slice, so the wait ends in that phase.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consumer under a buffered backlog", () => {
|
||||||
|
it("lets timers run while it drains, and keeps every update in order", async () => {
|
||||||
|
const chunks = Array.from({ length: 30 }, (_, i) => `chunk-${i} `);
|
||||||
|
const updates: SessionNotification[] = [];
|
||||||
|
let chunksBeforeTimer: number | undefined;
|
||||||
|
let timerArmed = false;
|
||||||
|
const agent = createAgent(bufferedAnswer(chunks), (notification) => {
|
||||||
|
updates.push(notification);
|
||||||
|
if (notification.update.sessionUpdate !== "agent_message_chunk") return;
|
||||||
|
if (!timerArmed) {
|
||||||
|
timerArmed = true;
|
||||||
|
setTimeout(() => (chunksBeforeTimer = chunkTexts(updates).length), 0);
|
||||||
|
}
|
||||||
|
// Each chunk costs 2 ms: the backlog takes far longer than one budget.
|
||||||
|
spin(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "go" }] });
|
||||||
|
|
||||||
|
expect(response.stopReason).toBe("end_turn");
|
||||||
|
expect(chunkTexts(updates)).toEqual(chunks);
|
||||||
|
expect(chunksBeforeTimer).toBeDefined();
|
||||||
|
expect(chunksBeforeTimer!).toBeLessThan(chunks.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs a cancel that arrives mid-backlog before the backlog ends", async () => {
|
||||||
|
const chunks = Array.from({ length: 30 }, (_, i) => `chunk-${i} `);
|
||||||
|
const updates: SessionNotification[] = [];
|
||||||
|
let chunksAtCancel: number | undefined;
|
||||||
|
let cancelScheduled = false;
|
||||||
|
const agent = createAgent(bufferedAnswer(chunks), (notification) => {
|
||||||
|
updates.push(notification);
|
||||||
|
if (notification.update.sessionUpdate !== "agent_message_chunk") return;
|
||||||
|
if (!cancelScheduled) {
|
||||||
|
cancelScheduled = true;
|
||||||
|
// A `session/cancel` arrives as I/O, so it can only run in a new macrotask.
|
||||||
|
setImmediate(() => {
|
||||||
|
chunksAtCancel = chunkTexts(updates).length;
|
||||||
|
void agent.cancel({ sessionId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
spin(2);
|
||||||
|
});
|
||||||
|
const query = agent.sessions[sessionId].query as unknown as {
|
||||||
|
interrupt: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "go" }] });
|
||||||
|
|
||||||
|
expect(chunksAtCancel).toBeDefined();
|
||||||
|
expect(chunksAtCancel!).toBeLessThan(chunks.length);
|
||||||
|
expect(query.interrupt).toHaveBeenCalled();
|
||||||
|
// The turn ends cancelled. The chunks that were already buffered still
|
||||||
|
// reach the client, in order, as they did before the cancel.
|
||||||
|
expect(response.stopReason).toBe("cancelled");
|
||||||
|
expect(chunkTexts(updates)).toEqual(chunks);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consumer teardown under a buffered backlog", () => {
|
||||||
|
it("settles the turn when the session closes mid-backlog", async () => {
|
||||||
|
const chunks = Array.from({ length: 30 }, (_, i) => `chunk-${i} `);
|
||||||
|
const updates: SessionNotification[] = [];
|
||||||
|
let closeScheduled = false;
|
||||||
|
let chunksAtClose: number | undefined;
|
||||||
|
let closed: Promise<unknown> | undefined;
|
||||||
|
const agent = createAgent(bufferedAnswer(chunks), (notification) => {
|
||||||
|
updates.push(notification);
|
||||||
|
if (notification.update.sessionUpdate !== "agent_message_chunk") return;
|
||||||
|
if (!closeScheduled) {
|
||||||
|
closeScheduled = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
chunksAtClose = chunkTexts(updates).length;
|
||||||
|
closed = agent.closeSession({ sessionId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
spin(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "go" }] });
|
||||||
|
|
||||||
|
expect(chunksAtClose).toBeDefined();
|
||||||
|
expect(chunksAtClose!).toBeLessThan(chunks.length);
|
||||||
|
expect(response.stopReason).toBe("cancelled");
|
||||||
|
await closed;
|
||||||
|
expect(agent.sessions[sessionId]).toBeUndefined();
|
||||||
|
// Whatever reached the client is a prefix of the answer, in order, and
|
||||||
|
// the rest of the backlog is not forwarded after the close.
|
||||||
|
const received = chunkTexts(updates);
|
||||||
|
expect(received.length).toBeLessThan(chunks.length);
|
||||||
|
expect(received).toEqual(chunks.slice(0, received.length));
|
||||||
|
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
expect(chunkTexts(updates)).toEqual(received);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nodeToWebWritable", () => {
|
||||||
|
it("writes the bytes of each chunk, including a view into a larger buffer", async () => {
|
||||||
|
const received: Buffer[] = [];
|
||||||
|
const sink = new Writable({
|
||||||
|
write(chunk: Buffer, _encoding, callback) {
|
||||||
|
received.push(Buffer.from(chunk));
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const writer = nodeToWebWritable(sink).getWriter();
|
||||||
|
const backing = new TextEncoder().encode("xxhello\nworld\nyy");
|
||||||
|
await writer.write(backing.subarray(2, 8));
|
||||||
|
await writer.write(new TextEncoder().encode("world\n"));
|
||||||
|
expect(Buffer.concat(received).toString()).toBe("hello\nworld\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an object-mode stream a Buffer copy", async () => {
|
||||||
|
const received: unknown[] = [];
|
||||||
|
const sink = new Writable({
|
||||||
|
objectMode: true,
|
||||||
|
write(chunk, _encoding, callback) {
|
||||||
|
received.push(chunk);
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const bytes = new TextEncoder().encode("hi");
|
||||||
|
await nodeToWebWritable(sink).getWriter().write(bytes);
|
||||||
|
expect(Buffer.isBuffer(received[0])).toBe(true);
|
||||||
|
expect(received[0]).not.toBe(bytes);
|
||||||
|
expect((received[0] as Buffer).toString()).toBe("hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the write that fails", async () => {
|
||||||
|
const sink = new Writable({
|
||||||
|
write(_chunk, _encoding, callback) {
|
||||||
|
callback(new Error("EPIPE"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sink.on("error", () => {});
|
||||||
|
const writer = nodeToWebWritable(sink).getWriter();
|
||||||
|
await expect(writer.write(new Uint8Array([1]))).rejects.toThrow("EPIPE");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -223,6 +223,16 @@ describe("parseMcpCommand", () => {
|
|||||||
expect(parseMcpCommand(text)).toBeNull();
|
expect(parseMcpCommand(text)).toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads past any leading whitespace, as before the prefix check", () => {
|
||||||
|
expect(parseMcpCommand("\n\t\u00a0\ufeff /mcp enable my\nserver ")).toEqual({
|
||||||
|
action: "enable",
|
||||||
|
all: false,
|
||||||
|
server: "my server",
|
||||||
|
});
|
||||||
|
expect(parseMcpCommand("\u2028/mcp")).toEqual({ action: "status" });
|
||||||
|
expect(parseMcpCommand(`hello ${"word ".repeat(100_000)}/mcp`)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const LONG_ERROR =
|
const LONG_ERROR =
|
||||||
|
|||||||
954
src/tests/session-index-units.test.ts
Normal file
954
src/tests/session-index-units.test.ts
Normal file
@@ -0,0 +1,954 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { deriveActivity, selectCost } from "../session-index/activity.js";
|
||||||
|
import { LiveSessionRegistry } from "../session-index/live-registry.js";
|
||||||
|
import {
|
||||||
|
encodeProjectPath,
|
||||||
|
isSessionId,
|
||||||
|
pathAndAncestors,
|
||||||
|
projectDirMatches,
|
||||||
|
} from "../session-index/project-dirs.js";
|
||||||
|
import {
|
||||||
|
decodeListCursor,
|
||||||
|
encodeListCursor,
|
||||||
|
parseListOptions,
|
||||||
|
parseRenameSessionRequest,
|
||||||
|
type ListScope,
|
||||||
|
} from "../session-index/service.js";
|
||||||
|
import {
|
||||||
|
effectiveTitle,
|
||||||
|
isArchivedTitle,
|
||||||
|
storedTitle,
|
||||||
|
titleRecords,
|
||||||
|
visibleTitle,
|
||||||
|
} from "../session-index/archive-title.js";
|
||||||
|
import {
|
||||||
|
continuedInSessionId,
|
||||||
|
readHeadTail,
|
||||||
|
scanTranscript,
|
||||||
|
scanTranscriptFile,
|
||||||
|
sdkTitles,
|
||||||
|
transcriptAgentName,
|
||||||
|
type TranscriptFacts,
|
||||||
|
} from "../session-index/transcript-scan.js";
|
||||||
|
import { firstPrompt } from "../session-index/first-prompt.js";
|
||||||
|
import { DirListings, statFiles } from "../session-index/dir-listing.js";
|
||||||
|
import { SessionIndex } from "../session-index/session-index.js";
|
||||||
|
|
||||||
|
const SESSION = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const lines = (...entries: object[]) => entries.map((entry) => JSON.stringify(entry)).join("\n");
|
||||||
|
const scan = (text: string) => scanTranscript({ head: text, tail: text });
|
||||||
|
|
||||||
|
const user = (text: string, timestamp: string, extra: object = {}) => ({
|
||||||
|
type: "user",
|
||||||
|
sessionId: SESSION,
|
||||||
|
timestamp,
|
||||||
|
message: { role: "user", content: text },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const assistant = (stopReason: string | null, timestamp: string, extra: object = {}) => ({
|
||||||
|
type: "assistant",
|
||||||
|
sessionId: SESSION,
|
||||||
|
timestamp,
|
||||||
|
message: { role: "assistant", content: [{ type: "text", text: "ok" }], stop_reason: stopReason },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project directory encoding", () => {
|
||||||
|
it("matches the SDK encoding and never decodes", () => {
|
||||||
|
expect(encodeProjectPath("/Users/me/repo.x")).toBe("-Users-me-repo-x");
|
||||||
|
expect(projectDirMatches("-Users-me-repo-x", "/Users/me/repo.x")).toBe(true);
|
||||||
|
expect(projectDirMatches("-Users-me-repo-x", "/Users/me/repo")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a long path by its cut prefix, whatever the hash", () => {
|
||||||
|
const long = `/${"a".repeat(250)}`;
|
||||||
|
const encoded = encodeProjectPath(long);
|
||||||
|
expect(encoded.length).toBeGreaterThan(200);
|
||||||
|
expect(projectDirMatches(`${encoded.slice(0, 200)}-otherhash`, long)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a path and its ancestors", () => {
|
||||||
|
expect(pathAndAncestors("/a/b")).toEqual(["/a/b", "/a", "/"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only UUID session ids", () => {
|
||||||
|
expect(isSessionId(SESSION)).toBe(true);
|
||||||
|
expect(isSessionId("../etc/passwd")).toBe(false);
|
||||||
|
expect(isSessionId("agent-123")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transcript scan", () => {
|
||||||
|
it("reads a small transcript only up to the size it was stat'ed at", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "head-tail-"));
|
||||||
|
const file = path.join(dir, "t.jsonl");
|
||||||
|
const size = Buffer.byteLength("first\n");
|
||||||
|
await fs.writeFile(file, "first\nappended later\n");
|
||||||
|
expect(await readHeadTail(file, size)).toEqual({ head: "first\n", tail: "first\n" });
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sees a finished turn at an assistant end_turn", () => {
|
||||||
|
const facts = scan(
|
||||||
|
lines(user("hi", "2026-01-01T00:00:00Z"), assistant("end_turn", "2026-01-01T00:00:05Z")),
|
||||||
|
);
|
||||||
|
expect(facts.turnState).toBe("finished");
|
||||||
|
expect(facts.lastTurnEndedAt).toBe(Date.parse("2026-01-01T00:00:05Z"));
|
||||||
|
expect(facts.lastMessageAt).toBe(Date.parse("2026-01-01T00:00:05Z"));
|
||||||
|
expect(facts.hasMessages).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sees a turn end at an interrupt, an API error, and the turn-end system records", () => {
|
||||||
|
expect(
|
||||||
|
scan(lines(user("[Request interrupted by user]", "2026-01-01T00:00:00Z"))).turnState,
|
||||||
|
).toBe("finished");
|
||||||
|
expect(
|
||||||
|
scan(lines(assistant(null, "2026-01-01T00:00:00Z", { isApiErrorMessage: true }))).turnState,
|
||||||
|
).toBe("finished");
|
||||||
|
const hook = scan(
|
||||||
|
lines(assistant("tool_use", "2026-01-01T00:00:00Z"), {
|
||||||
|
type: "system",
|
||||||
|
subtype: "stop_hook_summary",
|
||||||
|
timestamp: "2026-01-01T00:00:09Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(hook.turnState).toBe("finished");
|
||||||
|
expect(hook.lastTurnEndedAt).toBe(Date.parse("2026-01-01T00:00:09Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sees an unfinished turn and keeps the previous turn end", () => {
|
||||||
|
const facts = scan(
|
||||||
|
lines(
|
||||||
|
assistant("end_turn", "2026-01-01T00:00:01Z"),
|
||||||
|
user("next", "2026-01-01T00:00:02Z"),
|
||||||
|
assistant("tool_use", "2026-01-01T00:00:03Z"),
|
||||||
|
{ type: "custom-title", customTitle: "x", sessionId: SESSION },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(facts.turnState).toBe("unfinished");
|
||||||
|
expect(facts.lastTurnEndedAt).toBe(Date.parse("2026-01-01T00:00:01Z"));
|
||||||
|
expect(facts.lastMessageAt).toBe(Date.parse("2026-01-01T00:00:03Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores sidechain and meta records", () => {
|
||||||
|
const facts = scan(
|
||||||
|
lines(
|
||||||
|
assistant("end_turn", "2026-01-01T00:00:01Z"),
|
||||||
|
user("meta", "2026-01-01T00:00:02Z", { isMeta: true }),
|
||||||
|
assistant("tool_use", "2026-01-01T00:00:03Z", { isSidechain: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(facts.turnState).toBe("finished");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads no cost from a transcript", () => {
|
||||||
|
const facts = scan(
|
||||||
|
lines(user("hello", "2026-01-01T00:00:01.000Z"), {
|
||||||
|
type: "cost-state",
|
||||||
|
sessionId: SESSION,
|
||||||
|
totalCostUSD: 1.5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(facts).not.toHaveProperty("costUsd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the head cwd and the last tail cwd, and a stub without messages", () => {
|
||||||
|
const facts = scanTranscript({
|
||||||
|
head: lines({ type: "queue-operation" }, { type: "attachment", cwd: "/repo" }),
|
||||||
|
tail: lines({ type: "attachment", cwd: "/repo/sub" }, { type: "last-prompt" }),
|
||||||
|
});
|
||||||
|
expect(facts.headCwd).toBe("/repo");
|
||||||
|
expect(facts.tailCwd).toBe("/repo/sub");
|
||||||
|
expect(facts.hasMessages).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a prompt whose text is "tool_result" for a prompt', () => {
|
||||||
|
const facts = scan(
|
||||||
|
lines(
|
||||||
|
user("tool_result", "2026-01-01T00:00:01.000Z"),
|
||||||
|
assistant("end_turn", "2026-01-01T00:00:02.000Z"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(facts.lastPromptAt).toBe(Date.parse("2026-01-01T00:00:01.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the model and cwd before a tail that is one large tool result", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "si-scan-"));
|
||||||
|
try {
|
||||||
|
const file = path.join(dir, `${SESSION}.jsonl`);
|
||||||
|
const text =
|
||||||
|
lines(
|
||||||
|
user("hello", "2026-01-01T00:00:01.000Z", { cwd: "/repo" }),
|
||||||
|
assistant("tool_use", "2026-01-01T00:00:02.000Z", {
|
||||||
|
cwd: "/repo",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
model: "claude-model-x",
|
||||||
|
stop_reason: "tool_use",
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sessionId: SESSION,
|
||||||
|
timestamp: "2026-01-01T00:00:03.000Z",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "tool_result", tool_use_id: "t1", content: "x".repeat(200_000) }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
) + "\n";
|
||||||
|
await fs.writeFile(file, text);
|
||||||
|
const facts = await scanTranscriptFile(file, Buffer.byteLength(text));
|
||||||
|
expect(facts.model).toBe("claude-model-x");
|
||||||
|
expect(facts.tailCwd).toBe("/repo");
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activity", () => {
|
||||||
|
const now = Date.parse("2026-01-01T01:00:00Z");
|
||||||
|
const facts = (overrides: Partial<TranscriptFacts> = {}): TranscriptFacts => ({
|
||||||
|
hasMessages: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the SDK state of a session this connection runs", () => {
|
||||||
|
expect(
|
||||||
|
deriveActivity({
|
||||||
|
own: { state: "requires_action", lastTurnEndedAt: now - 1000 },
|
||||||
|
facts: facts({ lastTurnEndedAt: 1 }),
|
||||||
|
transcriptMtimeMs: now,
|
||||||
|
now,
|
||||||
|
}),
|
||||||
|
).toEqual({ state: "requires_action", lastTurnEndedAt: new Date(now - 1000).toISOString() });
|
||||||
|
expect(deriveActivity({ own: {}, facts: facts(), transcriptMtimeMs: now, now })).toEqual({
|
||||||
|
state: "idle",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is error after a turn that ended with an API error, until a new turn", () => {
|
||||||
|
const errorFacts = scan(
|
||||||
|
lines(
|
||||||
|
user("hi", "2026-01-01T00:00:00Z"),
|
||||||
|
assistant(null, "2026-01-01T00:00:05Z", { isApiErrorMessage: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(errorFacts.lastTurnError).toBe(true);
|
||||||
|
const at = { transcriptMtimeMs: now, now };
|
||||||
|
expect(deriveActivity({ facts: errorFacts, ...at })?.state).toBe("error");
|
||||||
|
// A user interrupt is no error.
|
||||||
|
const interrupted = scan(
|
||||||
|
lines(
|
||||||
|
user("hi", "2026-01-01T00:00:00Z"),
|
||||||
|
user("[Request interrupted by user]", "2026-01-01T00:00:05Z"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(interrupted.lastTurnError).toBeUndefined();
|
||||||
|
expect(deriveActivity({ facts: interrupted, ...at })?.state).toBe("idle");
|
||||||
|
// A prompt after the error starts a new turn.
|
||||||
|
const again = scan(
|
||||||
|
lines(
|
||||||
|
user("hi", "2026-01-01T00:00:00Z"),
|
||||||
|
assistant(null, "2026-01-01T00:00:05Z", { isApiErrorMessage: true }),
|
||||||
|
user("again", "2026-01-01T00:00:09Z"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(again.lastTurnError).toBeUndefined();
|
||||||
|
expect(deriveActivity({ facts: again, ...at })?.state).toBe("idle");
|
||||||
|
// A busy or waiting CLI wins; an idle one shows the error.
|
||||||
|
const live = (status: string) => ({
|
||||||
|
pid: 1,
|
||||||
|
sessionId: "s",
|
||||||
|
kind: "interactive",
|
||||||
|
entrypoint: "cli",
|
||||||
|
status,
|
||||||
|
statusUpdatedAt: now,
|
||||||
|
});
|
||||||
|
const held = { facts: errorFacts, transcriptMtimeMs: now - 1, now };
|
||||||
|
expect(deriveActivity({ live: live("busy"), ...held })?.state).toBe("running");
|
||||||
|
expect(deriveActivity({ live: live("waiting"), ...held })?.state).toBe("requires_action");
|
||||||
|
expect(deriveActivity({ live: live("idle"), ...held })?.state).toBe("error");
|
||||||
|
// A session that runs here: its own failed turn, while idle.
|
||||||
|
expect(
|
||||||
|
deriveActivity({ own: { state: "idle", lastTurnFailed: true }, facts: facts(), ...at })
|
||||||
|
?.state,
|
||||||
|
).toBe("error");
|
||||||
|
expect(
|
||||||
|
deriveActivity({ own: { state: "running", lastTurnFailed: true }, facts: facts(), ...at })
|
||||||
|
?.state,
|
||||||
|
).toBe("running");
|
||||||
|
expect(deriveActivity({ own: { state: "idle" }, facts: errorFacts, ...at })?.state).toBe(
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
// The API error behind the CLI's turn-end records.
|
||||||
|
const behindTurnEnd = scan(
|
||||||
|
lines(
|
||||||
|
user("hi", "2026-01-01T00:00:00Z"),
|
||||||
|
assistant(null, "2026-01-01T00:00:05Z", { isApiErrorMessage: true }),
|
||||||
|
{ type: "system", subtype: "turn_duration", timestamp: "2026-01-01T00:00:06Z" },
|
||||||
|
{ type: "system", subtype: "stop_hook_summary", timestamp: "2026-01-01T00:00:06Z" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(behindTurnEnd.lastTurnError).toBe(true);
|
||||||
|
// A query that failed here: error until the transcript changes, and a
|
||||||
|
// busy CLI still wins.
|
||||||
|
const unfinished = facts({ turnState: "unfinished" });
|
||||||
|
const failedHere = { own: { queryFailedAt: now }, facts: unfinished, now };
|
||||||
|
expect(deriveActivity({ ...failedHere, transcriptMtimeMs: now - 1 })?.state).toBe("error");
|
||||||
|
expect(deriveActivity({ ...failedHere, transcriptMtimeMs: now + 60_000 })?.state).toBe("error");
|
||||||
|
// A turn that ended after the failure (another process resumed it).
|
||||||
|
const resumed = facts({ turnState: "finished", lastTurnEndedAt: now + 2000 });
|
||||||
|
expect(
|
||||||
|
deriveActivity({
|
||||||
|
own: { queryFailedAt: now },
|
||||||
|
facts: resumed,
|
||||||
|
transcriptMtimeMs: now + 2000,
|
||||||
|
now,
|
||||||
|
})?.state,
|
||||||
|
).toBe("idle");
|
||||||
|
expect(
|
||||||
|
deriveActivity({ ...failedHere, live: live("busy"), transcriptMtimeMs: now - 1 })?.state,
|
||||||
|
).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idle when no live process holds the session", () => {
|
||||||
|
expect(
|
||||||
|
deriveActivity({ facts: facts({ turnState: "unfinished" }), transcriptMtimeMs: now, now }),
|
||||||
|
).toEqual({ state: "idle" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the registry status of an interactive CLI newer than the transcript", () => {
|
||||||
|
const live = {
|
||||||
|
pid: 1,
|
||||||
|
sessionId: "s",
|
||||||
|
kind: "interactive",
|
||||||
|
entrypoint: "cli",
|
||||||
|
status: "waiting",
|
||||||
|
statusUpdatedAt: now,
|
||||||
|
};
|
||||||
|
expect(deriveActivity({ live, facts: facts(), transcriptMtimeMs: now - 1, now })?.state).toBe(
|
||||||
|
"requires_action",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
deriveActivity({
|
||||||
|
live: { ...live, status: "busy" },
|
||||||
|
facts: facts(),
|
||||||
|
transcriptMtimeMs: now - 1,
|
||||||
|
now,
|
||||||
|
})?.state,
|
||||||
|
).toBe("running");
|
||||||
|
// Older than the transcript: the tail decides.
|
||||||
|
expect(
|
||||||
|
deriveActivity({
|
||||||
|
live,
|
||||||
|
facts: facts({ turnState: "finished" }),
|
||||||
|
transcriptMtimeMs: now + 1,
|
||||||
|
now,
|
||||||
|
})?.state,
|
||||||
|
).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the tail for an SDK-driven CLI, whose registry stays busy", () => {
|
||||||
|
const live = {
|
||||||
|
pid: 1,
|
||||||
|
sessionId: "s",
|
||||||
|
kind: "interactive",
|
||||||
|
entrypoint: "sdk-ts",
|
||||||
|
status: "busy",
|
||||||
|
statusUpdatedAt: now,
|
||||||
|
};
|
||||||
|
const base = { live, transcriptMtimeMs: now - 60_000, now };
|
||||||
|
expect(deriveActivity({ ...base, facts: facts({ turnState: "finished" }) })?.state).toBe(
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
expect(deriveActivity({ ...base, facts: facts({ turnState: "unfinished" }) })?.state).toBe(
|
||||||
|
"running",
|
||||||
|
);
|
||||||
|
// An unfinished turn that has not written for 10 minutes has no known state.
|
||||||
|
expect(
|
||||||
|
deriveActivity({
|
||||||
|
...base,
|
||||||
|
transcriptMtimeMs: now - 11 * 60_000,
|
||||||
|
facts: facts({ turnState: "unfinished" }),
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cost", () => {
|
||||||
|
it("is the SDK's amount of a session that runs here, and only a positive one", () => {
|
||||||
|
expect(selectCost({ costUsd: 2 })).toBe(2);
|
||||||
|
expect(selectCost({ costUsd: 0 })).toBeUndefined();
|
||||||
|
expect(selectCost({ costUsd: Number.NaN })).toBeUndefined();
|
||||||
|
// No SDK result yet, or a session that does not run here: no cost.
|
||||||
|
expect(selectCost({ state: "idle" })).toBeUndefined();
|
||||||
|
expect(selectCost(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list request parsing", () => {
|
||||||
|
const meta = (list: object) => ({ jetbrains: { air: { version: 1, list } } });
|
||||||
|
|
||||||
|
it("defaults and clamps the limit", () => {
|
||||||
|
expect(parseListOptions(undefined)).toEqual({
|
||||||
|
limit: 50,
|
||||||
|
archived: "unarchived",
|
||||||
|
includeWorktrees: false,
|
||||||
|
});
|
||||||
|
expect(parseListOptions(meta({ limit: null })).limit).toBe(50);
|
||||||
|
expect(parseListOptions(meta({ limit: 1 })).limit).toBe(1);
|
||||||
|
expect(parseListOptions(meta({ limit: 1000 })).limit).toBe(200);
|
||||||
|
for (const limit of ["1", 1.5, 0, -3, Number.NaN, Number.POSITIVE_INFINITY, true, {}]) {
|
||||||
|
expect(() => parseListOptions(meta({ limit }))).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes includeWorktrees as a boolean and archived as a filter value, null as the default", () => {
|
||||||
|
expect(parseListOptions(meta({ includeWorktrees: true })).includeWorktrees).toBe(true);
|
||||||
|
expect(parseListOptions(meta({ includeWorktrees: null })).includeWorktrees).toBe(false);
|
||||||
|
expect(() => parseListOptions(meta({ includeWorktrees: "yes" }))).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
for (const archived of ["unarchived", "archived", "all"]) {
|
||||||
|
expect(parseListOptions(meta({ archived })).archived).toBe(archived);
|
||||||
|
}
|
||||||
|
expect(parseListOptions(meta({ archived: null })).archived).toBe("unarchived");
|
||||||
|
expect(parseListOptions(meta({})).archived).toBe("unarchived");
|
||||||
|
for (const archived of [true, false, "only", "ALL", "Archived", "", 1, "true", {}, []]) {
|
||||||
|
expect(() => parseListOptions(meta({ archived }))).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a cursor and rejects one of another scope", () => {
|
||||||
|
const scope: ListScope = { cwd: "/repo", archived: "unarchived", includeWorktrees: false };
|
||||||
|
const cursor = encodeListCursor({ orderAtMs: 5, sessionId: SESSION }, scope);
|
||||||
|
expect(decodeListCursor(cursor, scope)).toEqual({ orderAtMs: 5, sessionId: SESSION });
|
||||||
|
for (const archived of ["archived", "all"] as const) {
|
||||||
|
expect(() => decodeListCursor(cursor, { ...scope, archived })).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
const other = encodeListCursor({ orderAtMs: 5, sessionId: SESSION }, { ...scope, archived });
|
||||||
|
expect(decodeListCursor(other, { ...scope, archived })).toEqual({
|
||||||
|
orderAtMs: 5,
|
||||||
|
sessionId: SESSION,
|
||||||
|
});
|
||||||
|
expect(() => decodeListCursor(other, scope)).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(() => decodeListCursor(cursor, { ...scope, includeWorktrees: true })).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
expect(() => decodeListCursor(cursor, { ...scope, cwd: "/other" })).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
expect(() => decodeListCursor("offset:1000", scope)).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
// Cursors of earlier format versions.
|
||||||
|
for (const v of [3, 4]) {
|
||||||
|
const legacy = Buffer.from(
|
||||||
|
JSON.stringify({ v, u: 5, id: SESSION, cwd: "/repo", archived: false, worktrees: false }),
|
||||||
|
).toString("base64url");
|
||||||
|
expect(() => decodeListCursor(legacy, scope)).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates and truncates a rename title", () => {
|
||||||
|
expect(() => parseRenameSessionRequest({ sessionId: SESSION, title: " " })).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
expect(() => parseRenameSessionRequest({ sessionId: SESSION })).toThrow(
|
||||||
|
expect.objectContaining({ code: -32602 }),
|
||||||
|
);
|
||||||
|
const { title } = parseRenameSessionRequest({ sessionId: SESSION, title: "x".repeat(300) });
|
||||||
|
expect(title).toHaveLength(256);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("live registry", () => {
|
||||||
|
const now = Date.parse("2026-01-02T00:00:00Z");
|
||||||
|
|
||||||
|
async function registryWith(records: Record<string, object | string>) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "live-registry-"));
|
||||||
|
for (const [name, record] of Object.entries(records)) {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, name),
|
||||||
|
typeof record === "string" ? record : JSON.stringify(record),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps live, matching records and applies the liveness rules", async () => {
|
||||||
|
const dir = await registryWith({
|
||||||
|
"10.json": {
|
||||||
|
pid: 10,
|
||||||
|
sessionId: "live",
|
||||||
|
procStart: "Mon Jan 1 00:00:00 2026",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
"11.json": { pid: 11, sessionId: "dead", updatedAt: now },
|
||||||
|
"12.json": {
|
||||||
|
pid: 12,
|
||||||
|
sessionId: "reused",
|
||||||
|
procStart: "Sun Jan 1 00:00:00 2025",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
"13.json": { pid: 13, sessionId: "stale", updatedAt: now - 25 * 3600_000 },
|
||||||
|
"14.json": {
|
||||||
|
pid: 14,
|
||||||
|
sessionId: "old-but-proven",
|
||||||
|
procStart: "Tue Jan 2 00:00:00 2024",
|
||||||
|
updatedAt: 0,
|
||||||
|
},
|
||||||
|
"15.json": { pid: 15, sessionId: "foreign", pidDomain: "elsewhere", updatedAt: now },
|
||||||
|
"16.json": "{ not json",
|
||||||
|
"17.json": { pid: 99, sessionId: "wrong-name", updatedAt: now },
|
||||||
|
"18.abcdef.key": "secret",
|
||||||
|
});
|
||||||
|
const registry = new LiveSessionRegistry({
|
||||||
|
dir: () => dir,
|
||||||
|
now: () => now,
|
||||||
|
isAlive: (pid) => pid !== 11,
|
||||||
|
processStarts: async () =>
|
||||||
|
new Map([
|
||||||
|
[10, "Mon Jan 1 00:00:00 2026"],
|
||||||
|
[12, "Mon Jan 1 00:00:00 2026"],
|
||||||
|
[14, "Tue Jan 2 00:00:00 2024"],
|
||||||
|
]),
|
||||||
|
pidDomain: async () => "darwin",
|
||||||
|
});
|
||||||
|
const snapshot = await registry.snapshot();
|
||||||
|
expect([...snapshot.keys()].sort()).toEqual(["live", "old-but-proven"]);
|
||||||
|
expect(snapshot.get("live")?.pid).toBe(10);
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads single records, never a .key file, and reuses a recent start time on request", async () => {
|
||||||
|
const dir = await registryWith({
|
||||||
|
"10.json": {
|
||||||
|
pid: 10,
|
||||||
|
sessionId: "live",
|
||||||
|
procStart: "Mon Jan 1 00:00:00 2026",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
"10.json.tmp": "{}",
|
||||||
|
"18.abcdef.key": "secret",
|
||||||
|
});
|
||||||
|
const asked: number[][] = [];
|
||||||
|
const registry = new LiveSessionRegistry({
|
||||||
|
dir: () => dir,
|
||||||
|
now: () => now,
|
||||||
|
isAlive: () => true,
|
||||||
|
processStarts: async (pids) => {
|
||||||
|
asked.push(pids);
|
||||||
|
return new Map([[10, "Mon Jan 1 00:00:00 2026"]]);
|
||||||
|
},
|
||||||
|
pidDomain: async () => "darwin",
|
||||||
|
});
|
||||||
|
const read = await registry.readFiles(["10.json", "10.json.tmp", "18.abcdef.key", "11.json"]);
|
||||||
|
expect([...read.keys()].sort()).toEqual(["10.json", "11.json"]);
|
||||||
|
expect(read.get("10.json")?.sessionId).toBe("live");
|
||||||
|
expect(read.get("11.json")).toBeUndefined();
|
||||||
|
await registry.readFiles(["10.json"], { recentStarts: true });
|
||||||
|
expect(asked).toEqual([[10]]);
|
||||||
|
// Without recentStarts, `ps` is asked again.
|
||||||
|
await registry.readFiles(["10.json"]);
|
||||||
|
expect(asked).toEqual([[10], [10]]);
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty when the registry does not exist", async () => {
|
||||||
|
const registry = new LiveSessionRegistry({ dir: () => "/nonexistent/registry" });
|
||||||
|
expect((await registry.snapshot()).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("first prompt, as the SDK extracts it", () => {
|
||||||
|
const userLine = (content: unknown, extra: object = {}) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: "user",
|
||||||
|
sessionId: SESSION,
|
||||||
|
message: { role: "user", content },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a slash command only as a fallback for a real prompt", () => {
|
||||||
|
const init = userLine(
|
||||||
|
"<command-message>init</command-message>\n<command-name>/init</command-name>",
|
||||||
|
);
|
||||||
|
expect(firstPrompt([init, userLine("Explain the parser")].join("\n"))).toBe(
|
||||||
|
"Explain the parser",
|
||||||
|
);
|
||||||
|
expect(firstPrompt(init)).toBe("/init");
|
||||||
|
expect(sdkTitles({ head: init, tail: init }).summary).toBe("/init");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips tags, interrupts, meta, compact summaries and tool results", () => {
|
||||||
|
const head = [
|
||||||
|
userLine("<local-command-stdout>ok</local-command-stdout>"),
|
||||||
|
userLine("[Request interrupted by user]"),
|
||||||
|
userLine("Hidden", { isMeta: true }),
|
||||||
|
userLine("Summary", { isCompactSummary: true }),
|
||||||
|
userLine([{ type: "tool_result", tool_use_id: "t", content: "x" }]),
|
||||||
|
userLine([
|
||||||
|
{ type: "text", text: "<system-reminder>r</system-reminder>" },
|
||||||
|
{ type: "text", text: "Second block\nprompt" },
|
||||||
|
]),
|
||||||
|
].join("\n");
|
||||||
|
expect(firstPrompt(head)).toBe("Second block prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders bash input, expands pasted content, and cuts at 200 characters", () => {
|
||||||
|
expect(firstPrompt(userLine("<bash-input> ls -la </bash-input>"))).toBe("! ls -la");
|
||||||
|
const pasted =
|
||||||
|
'Fix this\n<pasted_content id="0a1f">\nstack trace\n</pasted_content id="0a1f">\n';
|
||||||
|
expect(firstPrompt(userLine(pasted))).toBe("Fix thisstack trace");
|
||||||
|
expect(firstPrompt(userLine("y".repeat(300)))).toBe(`${"y".repeat(200)}…`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("titles an image-only first prompt", () => {
|
||||||
|
const head = userLine([{ type: "image", source: { type: "base64", data: "x" } }]);
|
||||||
|
expect(sdkTitles({ head, tail: head }).summary).toBe("Image");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("continued-in", () => {
|
||||||
|
const successor = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const continued = { type: "continued-in", continuedInSessionId: successor };
|
||||||
|
|
||||||
|
it("counts only a real prompt or a finished answer after it as a resume", () => {
|
||||||
|
expect(continuedInSessionId(lines(user("x", "2026-01-01T00:00:00Z"), continued))).toBe(
|
||||||
|
successor,
|
||||||
|
);
|
||||||
|
// A meta record or a slash command after it is no resume.
|
||||||
|
expect(
|
||||||
|
continuedInSessionId(
|
||||||
|
lines(
|
||||||
|
continued,
|
||||||
|
user("<command-name>/status</command-name>", "2026-01-01T00:00:01Z"),
|
||||||
|
user("note", "2026-01-01T00:00:02Z", { isMeta: true }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(successor);
|
||||||
|
expect(
|
||||||
|
continuedInSessionId(lines(continued, user("Go on", "2026-01-01T00:00:03Z"))),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
continuedInSessionId(lines(continued, assistant("end_turn", "2026-01-01T00:00:04Z"))),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("row facts of the tail and head", () => {
|
||||||
|
it("takes lastPromptAt from the last real prompt only", () => {
|
||||||
|
const facts = scan(
|
||||||
|
lines(
|
||||||
|
user("Fix it", "2026-01-01T00:00:00Z"),
|
||||||
|
assistant("tool_use", "2026-01-01T00:00:01Z"),
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sessionId: SESSION,
|
||||||
|
timestamp: "2026-01-01T00:00:02Z",
|
||||||
|
message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t" }] },
|
||||||
|
},
|
||||||
|
user("<command-name>/status</command-name>", "2026-01-01T00:00:03Z"),
|
||||||
|
user("note", "2026-01-01T00:00:04Z", { isMeta: true }),
|
||||||
|
user("[Request interrupted by user]", "2026-01-01T00:00:05Z"),
|
||||||
|
assistant("end_turn", "2026-01-01T00:00:06Z"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(facts.lastPromptAt).toBe(Date.parse("2026-01-01T00:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the model of the last real assistant message", () => {
|
||||||
|
const facts = scan(
|
||||||
|
lines(
|
||||||
|
assistant("end_turn", "2026-01-01T00:00:01Z", {
|
||||||
|
message: { role: "assistant", model: "claude-a", content: [], stop_reason: "end_turn" },
|
||||||
|
}),
|
||||||
|
assistant("end_turn", "2026-01-01T00:00:02Z", {
|
||||||
|
isApiErrorMessage: true,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
model: "<synthetic>",
|
||||||
|
content: [],
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(facts.model).toBe("claude-a");
|
||||||
|
expect(scan(lines(user("x", "2026-01-01T00:00:00Z"))).model).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("knows forkedFrom only from a fork's records", () => {
|
||||||
|
const parent = "33333333-3333-4333-8333-333333333333";
|
||||||
|
expect(
|
||||||
|
scan(
|
||||||
|
lines(
|
||||||
|
user("x", "2026-01-01T00:00:00Z", {
|
||||||
|
forkedFrom: { sessionId: parent, messageUuid: "m" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).forkedFrom,
|
||||||
|
).toBe(parent);
|
||||||
|
expect(scan(lines(user("x", "2026-01-01T00:00:00Z"))).forkedFrom).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lastPromptAt of a media prompt", () => {
|
||||||
|
const media = (type: "image" | "document", timestamp: string, extra: object = {}) => ({
|
||||||
|
type: "user",
|
||||||
|
sessionId: SESSION,
|
||||||
|
timestamp,
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type, source: { type: "base64", media_type: "x", data: "x" } }],
|
||||||
|
},
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts an image or a document as a prompt", () => {
|
||||||
|
for (const type of ["image", "document"] as const) {
|
||||||
|
const facts = scan(
|
||||||
|
lines(
|
||||||
|
user("Fix it", "2026-01-01T00:00:00Z"),
|
||||||
|
assistant("end_turn", "2026-01-01T00:00:01Z"),
|
||||||
|
media(type, "2026-01-01T00:00:02Z"),
|
||||||
|
assistant("end_turn", "2026-01-01T00:00:03Z"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(facts.lastPromptAt).toBe(Date.parse("2026-01-01T00:00:02Z"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count media in a tool result or a meta record", () => {
|
||||||
|
const facts = scan(
|
||||||
|
lines(
|
||||||
|
user("Fix it", "2026-01-01T00:00:00Z"),
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sessionId: SESSION,
|
||||||
|
timestamp: "2026-01-01T00:00:02Z",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_result", tool_use_id: "t", content: [{ type: "image", source: {} }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media("image", "2026-01-01T00:00:03Z", { isMeta: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(facts.lastPromptAt).toBe(Date.parse("2026-01-01T00:00:00Z"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("directory listings and stats", () => {
|
||||||
|
it("re-reads a listing when an entry is added or removed, not otherwise", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "listing-"));
|
||||||
|
const listings = new DirListings();
|
||||||
|
await fs.writeFile(path.join(dir, "a.jsonl"), "x");
|
||||||
|
// A directory changed in the last seconds is read again each time: its
|
||||||
|
// mtime may not show a second change in the same second.
|
||||||
|
const old = new Date(Date.now() - 60_000);
|
||||||
|
await fs.utimes(dir, old, old);
|
||||||
|
expect(await listings.names(dir)).toEqual(["a.jsonl"]);
|
||||||
|
await fs.writeFile(path.join(dir, "hidden.jsonl"), "x");
|
||||||
|
await fs.utimes(dir, old, old);
|
||||||
|
// Same mtime: the cached listing stands (an append changes none either).
|
||||||
|
expect(await listings.names(dir)).toEqual(["a.jsonl"]);
|
||||||
|
await fs.rm(path.join(dir, "hidden.jsonl"));
|
||||||
|
await fs.writeFile(path.join(dir, "B.jsonl"), "x");
|
||||||
|
const later = new Date(Date.now() - 30_000);
|
||||||
|
await fs.utimes(dir, later, later);
|
||||||
|
expect((await listings.names(dir)).sort()).toEqual(["B.jsonl", "a.jsonl"]);
|
||||||
|
// A change just now is seen at once.
|
||||||
|
await fs.rm(path.join(dir, "a.jsonl"));
|
||||||
|
expect(await listings.names(dir)).toEqual(["B.jsonl"]);
|
||||||
|
await fs.writeFile(path.join(dir, "c.jsonl"), "x");
|
||||||
|
expect((await listings.names(dir)).sort()).toEqual(["B.jsonl", "c.jsonl"]);
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
expect(await listings.names(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads a directory replaced by another with the same mtime", async () => {
|
||||||
|
const parent = await fs.mkdtemp(path.join(os.tmpdir(), "listing-swap-"));
|
||||||
|
const dir = path.join(parent, "project");
|
||||||
|
const other = path.join(parent, "restored");
|
||||||
|
await fs.mkdir(dir);
|
||||||
|
await fs.mkdir(other);
|
||||||
|
await fs.writeFile(path.join(dir, "a.jsonl"), "x");
|
||||||
|
await fs.writeFile(path.join(other, "b.jsonl"), "x");
|
||||||
|
const old = new Date(Date.now() - 60_000);
|
||||||
|
await fs.utimes(dir, old, old);
|
||||||
|
await fs.utimes(other, old, old);
|
||||||
|
const listings = new DirListings();
|
||||||
|
expect(await listings.names(dir)).toEqual(["a.jsonl"]);
|
||||||
|
await fs.rm(dir, { recursive: true });
|
||||||
|
await fs.rename(other, dir);
|
||||||
|
await fs.utimes(dir, old, old);
|
||||||
|
expect(await listings.names(dir)).toEqual(["b.jsonl"]);
|
||||||
|
await fs.rm(parent, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stats many files in order, bounded, and reports missing ones", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "stats-"));
|
||||||
|
const files = await Promise.all(
|
||||||
|
Array.from({ length: 1500 }, async (_, i) => {
|
||||||
|
const file = path.join(dir, `${i}.txt`);
|
||||||
|
await fs.writeFile(file, "x".repeat(i % 7));
|
||||||
|
return file;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const [all, again] = await Promise.all([
|
||||||
|
statFiles([...files, path.join(dir, "missing")]),
|
||||||
|
statFiles(files.slice(0, 10)),
|
||||||
|
]);
|
||||||
|
expect(all.slice(0, 1500).map((stats) => stats?.size)).toEqual(files.map((_, i) => i % 7));
|
||||||
|
expect(all[1500]).toBeUndefined();
|
||||||
|
expect(again.map((stats) => stats?.size)).toEqual(files.slice(0, 10).map((_, i) => i % 7));
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session id lookup", () => {
|
||||||
|
it("matches a session id exactly, in no other case", async () => {
|
||||||
|
const config = await fs.mkdtemp(path.join(os.tmpdir(), "si-exact-id-"));
|
||||||
|
const previous = process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = config;
|
||||||
|
try {
|
||||||
|
const dir = path.join(config, "projects", "-repo");
|
||||||
|
const id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||||
|
await fs.mkdir(path.join(dir, id), { recursive: true });
|
||||||
|
const file = path.join(dir, `${id}.jsonl`);
|
||||||
|
await fs.writeFile(file, lines(user("hi", "2026-01-01T00:00:00.000Z")) + "\n");
|
||||||
|
const index = new SessionIndex(async () => undefined);
|
||||||
|
expect(await index.findTranscripts(id)).toEqual([file]);
|
||||||
|
expect(await index.scanSession(id)).toEqual({
|
||||||
|
transcripts: [{ filePath: file, size: expect.any(Number) }],
|
||||||
|
sessionDirs: [path.join(dir, id)],
|
||||||
|
});
|
||||||
|
const upper = id.toUpperCase();
|
||||||
|
expect(await index.findTranscripts(upper)).toEqual([]);
|
||||||
|
expect(await index.scanSession(upper)).toEqual({ transcripts: [], sessionDirs: [] });
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
else process.env.CLAUDE_CONFIG_DIR = previous;
|
||||||
|
await fs.rm(config, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("archive titles (AIR's Claude format)", () => {
|
||||||
|
const id = "0b0e6c2e-1d55-4c1f-9b4e-2b1d7f6f1a11";
|
||||||
|
|
||||||
|
it("tells an archived title by its prefix once normalized", () => {
|
||||||
|
for (const title of ["[archived] Done", " [archived]\n Done", "[archived]\tx"]) {
|
||||||
|
expect(isArchivedTitle(title)).toBe(true);
|
||||||
|
}
|
||||||
|
for (const title of [undefined, "Done", "[archived]", "[archived] ", "x [archived] y"]) {
|
||||||
|
expect(isArchivedTitle(title)).toBe(false);
|
||||||
|
}
|
||||||
|
expect(visibleTitle("[archived] Done")).toBe("Done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the visible title normalized, with the prefix when archived, cut to 200", () => {
|
||||||
|
expect(storedTitle(" Fix\n the parser ", true, id)).toBe("[archived] Fix the parser");
|
||||||
|
expect(storedTitle("[archived] Fix it", false, id)).toBe("Fix it");
|
||||||
|
expect(storedTitle("[archived] Fix it", true, id)).toBe("[archived] Fix it");
|
||||||
|
expect(storedTitle("", true, id)).toBe("[archived] Session 0b0e6c2e");
|
||||||
|
expect(storedTitle(`A${" ".repeat(5000)}B`, true, id)).toBe("[archived] A B");
|
||||||
|
expect(storedTitle("[archived] [archived] X", false, id)).toBe("X");
|
||||||
|
expect(storedTitle("[archived] ".repeat(10_000) + "X", true, id)).toBe("[archived] X");
|
||||||
|
expect(storedTitle(`${"a".repeat(188)} ${"b".repeat(20)}`, true, id)).toBe(
|
||||||
|
`[archived] ${"a".repeat(188)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the custom-title and agent-name records with AIR's fields", () => {
|
||||||
|
expect(titleRecords(id, "[archived] T")).toBe(
|
||||||
|
`{"type":"custom-title","customTitle":"[archived] T","sessionId":"${id}"}\n` +
|
||||||
|
`{"type":"agent-name","agentName":"[archived] T","sessionId":"${id}"}\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the custom title the SDK reports, never an agent name", () => {
|
||||||
|
const lines = (...records: object[]) => records.map((r) => JSON.stringify(r)).join("\n");
|
||||||
|
const text = lines(
|
||||||
|
{ type: "custom-title", customTitle: "Custom" },
|
||||||
|
{ type: "agent-name", agentName: "[archived] Agent" },
|
||||||
|
);
|
||||||
|
expect(sdkTitles({ head: text, tail: text })).toEqual({
|
||||||
|
customTitle: "Custom",
|
||||||
|
summary: "Custom",
|
||||||
|
});
|
||||||
|
// The tail wins over the sidecar, the sidecar over the head.
|
||||||
|
const head = lines({ type: "custom-title", customTitle: "Head" });
|
||||||
|
const tail = lines({ type: "custom-title", customTitle: "Tail" });
|
||||||
|
expect(sdkTitles({ head, tail }, "Sidecar").customTitle).toBe("Tail");
|
||||||
|
expect(sdkTitles({ head, tail: "" }, "Sidecar").customTitle).toBe("Sidecar");
|
||||||
|
expect(sdkTitles({ head, tail: "" }).customTitle).toBe("Head");
|
||||||
|
// A generated title is no custom title.
|
||||||
|
const prompt = lines(user("[archived] Looks archived", "2026-01-01T00:00:00Z"));
|
||||||
|
expect(sdkTitles({ head: prompt, tail: prompt })).toEqual({
|
||||||
|
customTitle: undefined,
|
||||||
|
summary: "[archived] Looks archived",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the last top-level agent name of the tail, else of the head", () => {
|
||||||
|
const lines = (...records: object[]) => records.map((r) => JSON.stringify(r)).join("\n");
|
||||||
|
const head = lines({ type: "agent-name", agentName: "Head" });
|
||||||
|
const tail = lines(
|
||||||
|
{ type: "agent-name", agentName: "First" },
|
||||||
|
{ type: "agent-name", agentName: "Last" },
|
||||||
|
{ type: "agent-name", agentName: " " },
|
||||||
|
{ type: "user", toolUseResult: { agentName: "Nested" } },
|
||||||
|
{ type: "custom-title", customTitle: "Custom" },
|
||||||
|
);
|
||||||
|
expect(transcriptAgentName({ head, tail })).toBe("Last");
|
||||||
|
expect(
|
||||||
|
transcriptAgentName({ head, tail: lines({ type: "custom-title", customTitle: "C" }) }),
|
||||||
|
).toBe("Head");
|
||||||
|
expect(transcriptAgentName({ head: "", tail: "" })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decides the archive state by the agent name first, else the custom title", () => {
|
||||||
|
// The agent name with the prefix, the custom title without: archived.
|
||||||
|
expect(
|
||||||
|
effectiveTitle("[archived] Agent", { customTitle: "Custom", summary: "Custom" }),
|
||||||
|
).toEqual({ title: "[archived] Agent", archived: true });
|
||||||
|
// The agent name without the prefix, the custom title with it: not archived.
|
||||||
|
expect(
|
||||||
|
effectiveTitle("Agent", { customTitle: "[archived] Custom", summary: "[archived] Custom" }),
|
||||||
|
).toEqual({ title: "Agent", archived: false });
|
||||||
|
// No agent name: the custom title decides.
|
||||||
|
expect(
|
||||||
|
effectiveTitle(undefined, { customTitle: "[archived] Custom", summary: "[archived] Custom" }),
|
||||||
|
).toEqual({ title: "[archived] Custom", archived: true });
|
||||||
|
expect(effectiveTitle(undefined, { customTitle: "Custom", summary: "Custom" })).toEqual({
|
||||||
|
title: "Custom",
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
// A generated title is no name.
|
||||||
|
expect(effectiveTitle(undefined, { summary: "[archived] Prompt" })).toEqual({
|
||||||
|
title: "[archived] Prompt",
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
4105
src/tests/session-index.test.ts
Normal file
4105
src/tests/session-index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||||
import { appendTitleContext } from "../session-titles.js";
|
import { appendTitleContext, sanitizeTitle } from "../session-titles.js";
|
||||||
import { Pushable } from "../utils.js";
|
import { Pushable } from "../utils.js";
|
||||||
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +17,36 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
|||||||
return { ...actual, getSessionInfo: vi.fn() };
|
return { ...actual, getSessionInfo: vi.fn() };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sanitizeTitle", () => {
|
||||||
|
/** The sanitizer before it learned to read only the start of a long text. */
|
||||||
|
function fullScanTitle(text: string): string {
|
||||||
|
const sanitized = text
|
||||||
|
.replace(/[\r\n]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return sanitized.length <= 256 ? sanitized : sanitized.slice(0, 255) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
it("gives a long text the same title as a scan of the whole text", () => {
|
||||||
|
const texts = [
|
||||||
|
"lorem ipsum dolor sit amet\n".repeat(400_000),
|
||||||
|
" \n\t".repeat(3000) + "word ".repeat(2000),
|
||||||
|
// The cut of the scanned prefix falls inside a run of whitespace.
|
||||||
|
"x".repeat(300) + " ".repeat(5000) + "tail",
|
||||||
|
// Mostly whitespace: the prefix collapses below a title's length.
|
||||||
|
"a ".repeat(100) + " ".repeat(10_000) + "b".repeat(300),
|
||||||
|
" ".repeat(10_000) + "short",
|
||||||
|
"\u00a0\ufeff".repeat(2100) + "😀".repeat(2000),
|
||||||
|
"y".repeat(4095) + "😀".repeat(10),
|
||||||
|
"z".repeat(257) + " ".repeat(4096),
|
||||||
|
"z".repeat(256) + " ".repeat(4096),
|
||||||
|
];
|
||||||
|
for (const text of texts) {
|
||||||
|
expect(sanitizeTitle(text)).toBe(fullScanTitle(text));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("SDK title generation contract", () => {
|
describe("SDK title generation contract", () => {
|
||||||
// Smoke test for the one undeclared SDK method this feature rests on:
|
// Smoke test for the one undeclared SDK method this feature rests on:
|
||||||
// `generateSessionTitle` is on the runtime `Query` class but absent from
|
// `generateSessionTitle` is on the runtime `Query` class but absent from
|
||||||
|
|||||||
64
src/utils.ts
64
src/utils.ts
@@ -45,12 +45,20 @@ export class Pushable<T> implements AsyncIterable<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to convert Node.js streams to Web Streams
|
// Helper to convert Node.js streams to Web Streams. A byte stream gets each
|
||||||
|
// chunk without a copy, so the caller must not change a chunk until its write
|
||||||
|
// resolves.
|
||||||
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
|
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
|
||||||
return new WritableStream<Uint8Array>({
|
return new WritableStream<Uint8Array>({
|
||||||
write(chunk) {
|
write(chunk) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
nodeStream.write(Buffer.from(chunk), (err) => {
|
// A Uint8Array is written as it is: Node wraps it in a Buffer view
|
||||||
|
// without a copy. The ACP encoder hands each message a fresh array,
|
||||||
|
// so nothing changes the bytes while the write is pending. An
|
||||||
|
// object-mode stream would get the array itself, so it keeps
|
||||||
|
// getting a Buffer copy, as before.
|
||||||
|
const data = nodeStream.writableObjectMode ? Buffer.from(chunk) : chunk;
|
||||||
|
nodeStream.write(data, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
@@ -84,6 +92,58 @@ export function unreachable(value: never, logger: Logger = console) {
|
|||||||
logger.error(`Unexpected case: ${valueAsString}`);
|
logger.error(`Unexpected case: ${valueAsString}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yields to the event loop when the current macrotask has run for too long.
|
||||||
|
*
|
||||||
|
* An await of an already-resolved promise continues in a microtask, so a loop
|
||||||
|
* that drains a backlog of buffered messages never lets timers or I/O run:
|
||||||
|
* incoming requests, `session/cancel` among them, wait until the backlog is
|
||||||
|
* gone. A loop calls {@link maybeYield} once per item. Within the budget it
|
||||||
|
* returns undefined and the loop goes on at once. Past the budget it returns a
|
||||||
|
* promise that resolves in the check phase of the event loop, after pending
|
||||||
|
* I/O has run.
|
||||||
|
*
|
||||||
|
* The budget is measured from the first call since the event loop last
|
||||||
|
* reached its check phase, so a loop that already waits on real I/O never
|
||||||
|
* yields, and all loops that share one instance share one budget per
|
||||||
|
* event-loop iteration.
|
||||||
|
*
|
||||||
|
* A test that fakes `setImmediate` and drives such a loop for longer than the
|
||||||
|
* budget must advance the fake timers, or the loop waits for them.
|
||||||
|
*/
|
||||||
|
export class EventLoopYielder {
|
||||||
|
/** Whether a check-phase marker is pending. While it is, the event loop has
|
||||||
|
* not reached its check phase since {@link sliceStart}. */
|
||||||
|
private markerPending = false;
|
||||||
|
private sliceStart = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly budgetMs: number,
|
||||||
|
private readonly now: () => number = () => performance.now(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
maybeYield(): Promise<void> | undefined {
|
||||||
|
const time = this.now();
|
||||||
|
if (!this.markerPending) {
|
||||||
|
this.markerPending = true;
|
||||||
|
this.sliceStart = time;
|
||||||
|
setImmediate(() => {
|
||||||
|
this.markerPending = false;
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (time - this.sliceStart < this.budgetMs) return undefined;
|
||||||
|
return new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The longest stretch, in milliseconds, that a message loop keeps the event
|
||||||
|
* loop before it yields. */
|
||||||
|
const MESSAGE_LOOP_BUDGET_MS = 8;
|
||||||
|
|
||||||
|
/** The yielder shared by the loops that forward messages to the client. */
|
||||||
|
export const messageLoopYielder = new EventLoopYielder(MESSAGE_LOOP_BUDGET_MS);
|
||||||
|
|
||||||
export function sleep(time: number): Promise<void> {
|
export function sleep(time: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, time));
|
return new Promise((resolve) => setTimeout(resolve, time));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user