diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 685444c6..63ea2934 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -24,6 +24,7 @@ It describes only this adapter. - [Agent file-change report](#agent-file-change-report) - [Session failure](#session-failure) - [Native subagent sessions](#native-subagent-sessions) +- [Session index](#session-index) - [Context compaction](#context-compaction) - [Question custom answers](#question-custom-answers) - [Session fork point](#session-fork-point) @@ -135,22 +136,26 @@ The `initialize` response of an AIR client carries the agent side of the extensi } ``` -The agent list does not depend on the capabilities that AIR declares. -An extension is active only when the client declared its capability. +The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename` and `sessionListSubscribe`, only for a client that declared `sessionIndex`. +An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename` and `sessionListSubscribe`. The response to a client that is not AIR has no `_meta.jetbrains` key. ### Capabilities -| Capability | Advertised | What the adapter does when the client declares it | Section | -| ------------------------ | ---------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `diffPatch` | yes | Sends an exact Git patch in the diff block of an Edit or a Write. | [Diff patch](#diff-patch) | -| `sessionFailure` | yes | Sends warnings and errors as typed transcript records. | [Session failure](#session-failure) | -| `recommendedValue` | yes | Replaces the `default` model and effort rows with concrete values and a recommendation. | [Recommended config values](#recommended-config-values) | -| `asyncTasks` | yes | Publishes background work that is not a subagent as async tasks. | [Async tasks](#async-tasks) | -| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) | -| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) | -| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) | -| `rawInputRendering` | no | Sends no display copy of readable input in `content`. The client renders `rawInput`. | [Tool call contract](#tool-call-contract) | +| Capability | Advertised | What the adapter does when the client declares it | Section | +| ------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | +| `diffPatch` | yes | Sends an exact Git patch in the diff block of an Edit or a Write. | [Diff patch](#diff-patch) | +| `sessionFailure` | yes | Sends warnings and errors as typed transcript records. | [Session failure](#session-failure) | +| `recommendedValue` | yes | Replaces the `default` model and effort rows with concrete values and a recommendation. | [Recommended config values](#recommended-config-values) | +| `asyncTasks` | yes | Publishes background work that is not a subagent as async tasks. | [Async tasks](#async-tasks) | +| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) | +| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) | +| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) | +| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) | +| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) | +| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) | +| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) | +| `rawInputRendering` | no | Sends no display copy of readable input in `content`. The client renders `rawInput`. | [Tool call contract](#tool-call-contract) | The adapter ignores `planContentDelta`. Claude does not stream a plan, so the adapter never sends `contentDelta` (see [Plan file](#plan-file)). @@ -1177,6 +1182,262 @@ This section covers only the AIR bridge. Child interactions stay on the root session. - A client that uses the older `_meta["subagent-transcript"]` capability or the `forwardSubagentText` session option keeps the flat child transcript. +## Session index + +The session index makes `session/list` a bounded, ordered page with row metadata, and adds rename, archive and a list subscription. + +### Activation + +AIR declares `sessionIndex` in `_meta.jetbrains.air.capabilities`. +Unlike the other capabilities, the agent advertises `sessionIndex` only to a client that declared it, and never under ACP v2. +Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`) and `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`). +The client does not declare these three; declaring them without `sessionIndex` enables nothing. +Everything in this section applies only to such a client, except where [Delete and close](#delete-and-close) says otherwise. +A client without the capability gets the `session/list` of before: the SDK `listSessions` call, pages of 1000 by file mtime, `offset:N` cursors, and no row `_meta`. +It gets no new notification, and the adapter starts no watcher for it. +The new methods answer it with `-32601`. + +### List request + +```json +{ + "cwd": "/Users/me/repo", + "cursor": null, + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "list": { "limit": 50, "includeWorktrees": false, "archived": "unarchived" } + } + } + } +} +``` + +- `limit` is an integer of at least 1; omitted or `null` is 50. The adapter clamps it to 200 and never returns more. Another value (a string, a fraction, 0 or less) is `-32602`. +- `includeWorktrees` is a boolean, `false` when omitted or `null`. Another value is `-32602`. + Without it, the page holds the sessions that the SDK `listSessions` lists for `cwd`: the transcripts of its project directory (`cwd` with symlinks resolved, encoded as the CLI names the directory). + As in the SDK, a transcript there whose cwd (its last `relocated` cwd, else its first `cwd`) is another path that encodes to the same name (`/ws/app.v2` and `/ws/app-v2`, `/a/b` and `/a-b`) is left out only while that path still exists and resolves to another path; once it is gone, the session is listed under `cwd`. + With `true`, the page also holds the sessions of the same subdirectory of `cwd` in every other existing worktree of its repository, as the Codex TUI expands a cwd: for `/repo/packages/a`, `/wt1/packages/a` if that directory exists. For a cwd at a worktree root, that is the worktree roots. +- `archived` and `includeWorktrees` treat `null` as omitted. +- `archived` is one of three strings: `"unarchived"` lists the unarchived sessions only, `"archived"` the archived sessions only, and `"all"` both together, in one order. + Omitted or `null` is `"unarchived"`. Any other value, including a boolean, is `-32602`. + The filter applies before pagination. +- Without `cwd` the page holds the sessions of all projects. + +### List response + +- Rows are ordered by the last user activity descending, then by session id: `lastPromptAt`, else `updatedAt` for a row without it. + A session the agent kept working on after the last prompt does not move up, as in Codex Desktop and the AIR session tree. + With `archived: "all"`, archived and unarchived rows merge on the same key. +- Every row has `updatedAt`: the time of the last message of any kind, capped at the transcript mtime. + A rename, an archive, or another metadata record moves neither `updatedAt` nor the order. +- The cursor is opaque. It holds the position (the order key and session id of the last row) and the `cwd`, `includeWorktrees` and `archived` it was issued for; another value of any of them rejects it with `-32602`. `limit` may change from page to page. +- A deleted session is never listed, whatever `archived` says. +- A page with `nextCursor` is never empty, and a page never repeats a row of the pages before it. +- With `includeWorktrees`, the worktrees come from `/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 (``) 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 `/sessions/.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), `/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 `/` directory, also one left without a transcript by an earlier failed delete. Anything left behind fails the request. A session that runs here is closed first. As with the CLI, the session is deleted also when another Claude Code process has it open. An unknown session is `-32002`. +- an AIR client without `sessionIndex`: archives the session instead of deleting it (the title records of [Archive](#archive)), because AIR uses delete for "Done" and can reopen the session. A session that runs here is closed first. The list hides archived sessions from such a client too: those whose custom title, as the SDK list reports it, is archived. A session without a transcript fails with the error of the SDK delete, as before. + A `sessionIndex` client sees such a session as archived (`archived: true`), as AIR's native Claude path does. +- any other client: the SDK delete, as before. + +`session/close` of a session that is not loaded returns `{}` for every client. + +`session/load`, `session/resume`, `session/prompt`, rename, archive, unarchive and delete all work on a session that another live Claude Code process has open, for every client, as the Claude CLI does. Archive stops only a session that runs on this connection. + +### Errors + +| Case | Error | +| ---------------------------- | ----------------------------------- | +| Unknown session | `-32002` with `data: { sessionId }` | +| Invalid parameters or cursor | `-32602` | + +### Relation to the RFDs + +The session index is the `_meta` form of two ACP RFDs: "Session list extensions: limit, order, row metadata and change hints" and "Session Archive and Unarchive" (#2161). +Names and semantics follow them; the transport differs, and so do the `archived` list filter and the archive of a running session (see [Relation to ACP RFD #2161](#relation-to-acp-rfd-2161)): + +| Extension | RFD | +| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` | +| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) | +| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) | +| `_meta.jetbrains.air.list.limit` | `session/list` `limit` | +| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` | +| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there | +| row `_meta.jetbrains.air.lastPromptAt`, `model`, `forkedFrom`, `state`, `lastTurnEndedAt`, `cost` | `SessionInfo` fields of the same names; the RFD `state` has no `error` | +| row `_meta.jetbrains.air.archived` | `SessionInfo.archived` (#2161) | +| `session_info_update` `_meta.jetbrains.air.archived` | `SessionInfoUpdate.archived` (#2161) | +| capability `sessionListSubscribe`, `_session/list/subscribe`, `_session/list/unsubscribe` | none | +| `_session/list/changes { subscriptionId, sessions, removed }` | none | +| `_session/archive`, `_session/unarchive` | `session/archive`, `session/unarchive` (#2161) | +| `_session/rename` | `session/set_title` (#1987) | + +### Relation to ACP RFD #2161 + +The archive follows the semantics of the ACP "Session Archive and Unarchive" RFD (#2161): the archive state of every row, the state report in `session_info_update`, idempotency, `-32002` for unknown and deleted sessions, and an unchanged `updatedAt`. +It differs in transport, until the RFD lands in the SDK, in the list filter, and in execution: + +- archive stops a session that runs on this connection, as `session/close` does, where #2161 leaves execution alone. The "Session list extensions" RFD proposes this change to #2161; + +- the list parameter has three values, not two: `"unarchived"` is the RFD's `false` (and the default of both), `"all"` its `true`, and `"archived"`, the archived sessions only, has no RFD counterpart; +- the methods are `_session/archive` and `_session/unarchive`, not `session/archive` and `session/unarchive`; +- the capability is `sessionArchive` in `_meta.jetbrains.air.capabilities`, advertised together with `sessionIndex`, not `sessionCapabilities.archive`; +- the list parameter is `_meta.jetbrains.air.list.archived`, and the state is `_meta.jetbrains.air.archived` on rows and in `session_info_update`, not the `archived` fields; +- an AIR client without `sessionIndex` still archives with `session/delete` (see [Delete and close](#delete-and-close)), which the RFD asks clients not to do. + ## Context compaction The adapter implements the ACP session compaction RFD for a client that declares `clientCapabilities.session.compaction`. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 9c8871f8..4714df2e 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -61,7 +61,6 @@ import { AccountInfo, AgentInfo, CanUseTool, - deleteSession, FastModeDisabledReason, FastModeState, getSessionMessages, @@ -132,6 +131,12 @@ import { clientSupportsAirCapability, withAirMeta, } 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 { AsyncTaskRuntime, backgroundBashTaskFromToolResult, @@ -268,6 +273,7 @@ import { } from "./tool-calls/renderer.js"; import { resolveSkillPath } from "./tool-calls/reporters/interaction.js"; import { + messageLoopYielder, nodeToWebReadable, nodeToWebWritable, Pushable, @@ -1291,7 +1297,7 @@ export type Session = { needsSignOutRespawn?: boolean; /** The in-flight recreation, so turns that arrive together share one. */ signOutRespawn?: Promise; -}; +} & sessionIndex.SessionIndexFields; /** Result-message origin kinds that mark an AUTONOMOUS cycle — work the * model did on its own (a task-notification followup, a peer/coordinator/ @@ -2532,6 +2538,13 @@ export class ClaudeAcpAgent { * express: see {@link ToolCallClientCapabilities.v2}. */ 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 } = {}) { this.v2 = options.v2 ?? false; @@ -2594,6 +2607,7 @@ export class ClaudeAcpAgent { this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities, { v2: this.v2, }); + this.sessionIndex.negotiate(request, { v2: this.v2 }); // Learn the auth identity in the background: `initialize` never waits on // 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_DIFF_PATCH_CAPABILITY, AIR_PLAN_FILE_CAPABILITY, + ...this.sessionIndex.capabilities(), ), AIR_GOAL_KEY, { @@ -2902,6 +2917,8 @@ export class ClaudeAcpAgent { * reads the start and the end only of the transcripts of the page. */ async listSessions(params: ListSessionsRequest): Promise { + const indexed = this.sessionIndex.list(params); + if (indexed) return indexed; const offset = sessionListOffset(params.cursor); // One more session than the page tells whether a next page exists. const sdkSessions = await listSessions({ @@ -2909,10 +2926,11 @@ export class ClaudeAcpAgent { limit: SESSION_LIST_PAGE_SIZE + 1, offset, }); - const sessions = []; - for (const session of sdkSessions.slice(0, SESSION_LIST_PAGE_SIZE)) { + const page = this.sessionIndex.hideArchived(sdkSessions.slice(0, SESSION_LIST_PAGE_SIZE)); + const visible = []; + for (const session of page) { if (!session.cwd) continue; - sessions.push({ + visible.push({ sessionId: session.sessionId, cwd: session.cwd, title: sanitizeTitle(session.summary), @@ -2920,8 +2938,32 @@ export class ClaudeAcpAgent { }); } return sdkSessions.length > SESSION_LIST_PAGE_SIZE - ? { sessions, nextCursor: `offset:${offset + SESSION_LIST_PAGE_SIZE}` } - : { sessions }; + ? { sessions: visible, nextCursor: `offset:${offset + SESSION_LIST_PAGE_SIZE}` } + : { 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, 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) { await compaction.interrupt(); } @@ -4831,6 +4878,14 @@ export class ClaudeAcpAgent { try { 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 .next() .then((result) => ({ kind: "message" as const, result })); @@ -5232,6 +5287,9 @@ export class ClaudeAcpAgent { case "session_state_changed": { const previousState = session.lastSessionState; 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 ( message.state === "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 if (lastAssistantTotalUsage !== null) { await sendUpdate({ @@ -6330,6 +6390,12 @@ export class ClaudeAcpAgent { 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) { const activeTurnId = session.activeTurn?.promptUuid; await sessionFailures.clear( @@ -7160,6 +7226,10 @@ export class ClaudeAcpAgent { // 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. const message = error instanceof Error ? error.message : String(error); + if (!session.cancelled) { + session.lastTurnFailed = true; + this.sessionIndex.onQueryFailed(params.sessionId); + } const processDied = error instanceof Error && (message.includes("ProcessTransport") || @@ -7638,6 +7708,10 @@ export class ClaudeAcpAgent { return; } 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.contextCompaction = undefined; session.settingsManager.dispose(); @@ -7686,6 +7760,7 @@ export class ClaudeAcpAgent { session.nativeSubagentRuntime?.clear(); session.asyncTaskRuntime?.clear(); delete this.sessions[sessionId]; + this.sessionIndex.onTeardown(sessionId); const ended = await raceTimeoutAndAbort( turnsEnded, TEARDOWN_TURN_END_TIMEOUT_MS, @@ -7700,25 +7775,22 @@ export class ClaudeAcpAgent { /** Tear down all active sessions. Called when the ACP connection closes. */ async dispose(): Promise { + this.sessionIndex.dispose(); 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 { if (!this.sessions[params.sessionId]) { - throw new Error("Session not found"); + return {}; } await this.teardownSession(params.sessionId); return {}; } + /** Per client: see {@link sessionIndex.SessionIndexConnection.deleteSession}. */ async deleteSession(params: DeleteSessionRequest): Promise { - // Tear down any active in-memory state first so the on-disk file isn't - // recreated by an outstanding query writing to it. - if (this.sessions[params.sessionId]) { - await this.teardownSession(params.sessionId); - } - await deleteSession(params.sessionId); - return {}; + return this.sessionIndex.deleteSession(params); } async setSessionMode(params: SetSessionModeRequest): Promise { @@ -8046,6 +8118,10 @@ export class ClaudeAcpAgent { }; const replayMessage = async (message: SessionMessage): Promise => { + // 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)) { return; } @@ -9978,6 +10054,7 @@ export class ClaudeAcpAgent { query: q, input: input, cancelled: false, + resumedFromHistory: creationOpts.resume !== undefined, cwd: params.cwd, sessionFingerprint: computeSessionFingerprint(params), creationParams: params, @@ -11673,7 +11750,7 @@ export function v1AgentApp( onAgent: (agent: ClaudeAcpAgent) => void, ): AgentApp { let agent!: ClaudeAcpAgent; - return acpAgent({ name: "claude-code-acp" }) + const app = acpAgent({ name: "claude-code-acp" }) .onConnect((connection) => { agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger); onAgent(agent); @@ -11712,6 +11789,7 @@ export function v1AgentApp( { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params), ); + return sessionIndex.onSessionIndexRequests(app, () => agent); } /** Serves ACP v1 on stdio. */ diff --git a/src/air-extension.ts b/src/air-extension.ts index 76aec58f..c4bc7418 100644 --- a/src/air-extension.ts +++ b/src/air-extension.ts @@ -11,6 +11,29 @@ export const AIR_RAW_INPUT_RENDERING_CAPABILITY = "rawInputRendering"; * file, and AIR reads the plan from it. */ export const AIR_PLAN_FILE_CAPABILITY = "planFile"; +/** + * The session index: a bounded, ordered `session/list` with row metadata, and + * `_session/rename`, `_session/archive`, `_session/unarchive` and the list + * subscription. Advertised only to a client that declares it. + */ +export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex"; +/** + * The agent supports `_session/archive` and `_session/unarchive`. Advertised + * exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not + * declare it. + */ +export const AIR_SESSION_ARCHIVE_CAPABILITY = "sessionArchive"; +/** + * The agent supports `_session/rename`. Advertised exactly when + * {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it. + */ +export const AIR_SESSION_RENAME_CAPABILITY = "sessionRename"; +/** + * The agent supports `_session/list/subscribe`, `_session/list/unsubscribe` + * and `_session/list/changes`. Advertised exactly when + * {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it. + */ +export const AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY = "sessionListSubscribe"; /** The `_meta.jetbrains.air` keys that the ACP tool call contract defines. */ export const AIR_COMMAND_TITLE_KEY = "commandTitle"; diff --git a/src/diff.ts b/src/diff.ts index 5e6e486b..5eeeaa67 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -569,11 +569,52 @@ function textLines(text: string): string[] { * its time budget. */ function filePatch(oldText: string | null, newText: string): FilePatch | undefined { - const hunks = diffHunks(oldText, newText); + const hunks = memoizedDiffHunks(oldText, newText); if (!hunks || hunks.length === 0) return undefined; return { change: oldText === null ? "create" : "update", hunks }; } +/** The number of recent line diffs that {@link memoizedDiffHunks} keeps. */ +const DIFF_MEMO_SIZE = 4; + +/** Recent line diffs not read again yet, the most recent last. A text of at + * most {@link MAX_PATCH_FILE_BYTES} is held until it is read again or later + * diffs push it out. */ +const diffMemo: { oldText: string | null; newText: string; hunks: PatchHunk[] }[] = []; + +/** + * {@link diffHunks} of a recent pair of texts again, without a second diff. + * + * The approval of an Edit or Write diffs the file against the predicted text, + * and the PostToolUse hook diffs the same texts once Claude wrote them. On a + * large file each diff can block the event loop for its whole time budget. + * Only a diff that completed is remembered: one that ran out of its budget + * says nothing about the texts, so a later call diffs them again. The hunks + * are shared, and no caller changes them. + */ +function memoizedDiffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined { + const index = diffMemo.findIndex( + (entry) => entry.oldText === oldText && entry.newText === newText, + ); + if (index >= 0) { + // The hook is normally the last reader of a pair, so the entry goes: the + // memo does not hold two large texts longer than needed. + const [entry] = diffMemo.splice(index, 1); + return entry.hunks; + } + const hunks = diffHunks(oldText, newText); + if (hunks) { + diffMemo.push({ oldText, newText, hunks }); + if (diffMemo.length > DIFF_MEMO_SIZE) diffMemo.shift(); + } + return hunks; +} + +/** Forget the remembered line diffs. For tests. */ +export function clearDiffMemo(): void { + diffMemo.length = 0; +} + /** * Reads a file as patch input. * diff --git a/src/mcp-command.ts b/src/mcp-command.ts index f1014e5e..fbc9e796 100644 --- a/src/mcp-command.ts +++ b/src/mcp-command.ts @@ -29,6 +29,10 @@ export type McpCommand = * or `/mcp disable` with an optional server name or `all`. Any other `/mcp` * argument gives null, and the prompt gets no replacement. */ export function parseMcpCommand(text: string): McpCommand | null { + // The first word is `/mcp` only when the text starts with it after the + // leading whitespace. Checking that first keeps a long prompt from being + // split into words. `trimStart` removes the characters that `\s` matches. + if (!text.trimStart().startsWith("/mcp")) return null; const words = text.trim().split(/\s+/); if (words[0] !== "/mcp") return null; if (words.length === 1) return { action: "status" }; diff --git a/src/session-index/activity.ts b/src/session-index/activity.ts new file mode 100644 index 00000000..2669cb02 --- /dev/null +++ b/src/session-index/activity.ts @@ -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; +} diff --git a/src/session-index/archive-title.ts b/src/session-index/archive-title.ts new file mode 100644 index 00000000..7748a6f7 --- /dev/null +++ b/src/session-index/archive-title.ts @@ -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" + ); +} diff --git a/src/session-index/connection.ts b/src/session-index/connection.ts new file mode 100644 index 00000000..1eb82160 --- /dev/null +++ b/src/session-index/connection.ts @@ -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; + /** Closes a session loaded here, as `session/close` does. */ + teardownSession(sessionId: string): Promise; +}; + +type EmptyResponse = Record; + +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(); + + 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 { + 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 | 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(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 { + 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 { + 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 { + 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[0], + options: Pick, + ): Promise { + 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 }) + | 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 { + 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 { + 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; + unsubscribeSessionList(params: ListUnsubscribeRequest): Promise; + renameSessionTitle(params: RenameSessionRequest): Promise; + archiveSession(params: SessionIdRequest): Promise; + unarchiveSession(params: SessionIdRequest): Promise; +}; + +/** 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( + LIST_SUBSCRIBE_METHOD, + { parse: parseListSubscribeRequest }, + (ctx) => agent().subscribeSessionList(ctx.params), + ) + .onRequest( + LIST_UNSUBSCRIBE_METHOD, + { parse: parseListUnsubscribeRequest }, + (ctx) => agent().unsubscribeSessionList(ctx.params), + ) + .onRequest( + SESSION_RENAME_METHOD, + { parse: parseRenameSessionRequest }, + (ctx) => agent().renameSessionTitle(ctx.params), + ) + .onRequest( + SESSION_ARCHIVE_METHOD, + { parse: parseSessionIdRequest }, + (ctx) => agent().archiveSession(ctx.params), + ) + .onRequest( + SESSION_UNARCHIVE_METHOD, + { parse: parseSessionIdRequest }, + (ctx) => agent().unarchiveSession(ctx.params), + ); +} diff --git a/src/session-index/dir-listing.ts b/src/session-index/dir-listing.ts new file mode 100644 index 00000000..3719b58a --- /dev/null +++ b/src/session-index/dir-listing.ts @@ -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(); + + /** The entry names of `dir`, empty when it cannot be read. */ + async names(dir: string): Promise { + 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 { + 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 { + 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 { + return fs.promises.stat(target).then( + (stats) => stats, + () => undefined, + ); +} diff --git a/src/session-index/explicit-title.ts b/src/session-index/explicit-title.ts new file mode 100644 index 00000000..0537c337 --- /dev/null +++ b/src/session-index/explicit-title.ts @@ -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; + + /** 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 { + 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, restore: () => void): Promise { + 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--; + } + } +} diff --git a/src/session-index/first-prompt.ts b/src/session-index/first-prompt.ts new file mode 100644 index 00000000..65f5e39c --- /dev/null +++ b/src/session-index/first-prompt.ts @@ -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 + * `` blocks and flattened to one line. + * - A slash command (`/init`) is only a fallback + * while a real prompt may follow; `x` 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>/; +const BASH_INPUT = /([\s\S]*?)<\/bash-input>/; +const SKIPPED_TEXT = /^(?:\s*<[a-z][\w-]*[\s>]|\[Request interrupted by user[^\]]*\])/; +const PASTED_OPEN = '` 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 = ``; + 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 ""; +} diff --git a/src/session-index/list-subscriptions.ts b/src/session-index/list-subscriptions.ts new file mode 100644 index 00000000..34dfcbf9 --- /dev/null +++ b/src/session-index/list-subscriptions.ts @@ -0,0 +1,1243 @@ +/** + * `_session/list/subscribe`: pushes the changed rows of a cwd's session list + * to a `sessionIndex` client. + * + * A subscription covers the sessions of its cwd and of the same subdirectory + * in every linked worktree (the `includeWorktrees` scope of the list), in any + * archive state. All subscriptions of one cwd share one {@link ScopeWatch}: + * a non-recursive `fs.watch` on each project directory of the scope, the + * stats of its transcripts, and the row of each session read since. The + * registry and the projects root are watched once per connection. + * + * Subscribe reads no row: it opens the watchers, stats the transcripts and + * reads the registry. A row is read on the first + * event of its session and sent unconditionally; later rows are compared + * with it. + * + * - A transcript event names the file: only that file is stat'ed and, when + * it changed, its metadata read again (the index's metadata cache and tail + * scan). + * - A registry event names the record: only that record is read, and only + * the sessions whose record changed get their `state` recomputed. + * - A session this connection runs reports its SDK state at once. + * + * Events are coalesced: 150 ms after the last one, at most 1 s after the + * first. Each subscription compares a recomputed row with the last row it + * sent; a change of `updatedAt` alone is no change. A session is sent at most + * once a second per subscription. A rescan every 10 s stats the scope again + * and reads the transcripts whose stat changed (or whose last read + * failed), which covers events `fs.watch` missed; + * the state of a live session ages from the metadata the index holds. + * + * Delivery is best effort: there is no resync and no sequence number; the + * client also reads the list now and then. + */ + +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { RequestError, type SessionInfo } from "@agentclientprotocol/sdk"; +import type { OwnSessionState } from "./activity.js"; +import { + bySession, + liveRegistryDir, + type LiveRecord, + type LiveSessionRegistry, + type LiveSnapshot, +} from "./live-registry.js"; +import { isExactProjectDir, isSessionId, projectDirMatches, projectsRoot } from "./project-dirs.js"; +import { type IndexRow, type SessionIndex, type TranscriptCandidate } from "./session-index.js"; +import { changeSignature, sessionInfoOf } from "./session-info.js"; + +export const LIST_SUBSCRIBE_METHOD = "_session/list/subscribe"; +export const LIST_UNSUBSCRIBE_METHOD = "_session/list/unsubscribe"; +export const LIST_CHANGES_METHOD = "_session/list/changes"; + +/** Subscriptions per connection. */ +export const MAX_SUBSCRIPTIONS = 128; +const DEBOUNCE_MS = 150; +const MAX_WAIT_MS = 1_000; +const RESCAN_MS = 10_000; +/** When a rescan follows the opening of a directory watcher. */ +const SETTLE_MS = 1_000; +/** The least time between two changes of one session to one subscription. */ +const MIN_SESSION_INTERVAL_MS = 1_000; + +export type ListSubscribeRequest = { cwd: string }; +export type ListSubscribeResponse = { subscriptionId: string }; +export type ListUnsubscribeRequest = { subscriptionId: string }; +export type ListChanges = { subscriptionId: string; sessions: SessionInfo[]; removed: string[] }; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** `{ cwd }`: an absolute path. Other parameters are ignored. */ +export function parseListSubscribeRequest(value: unknown): ListSubscribeRequest { + const cwd = asRecord(value).cwd; + if (typeof cwd !== "string" || !path.isAbsolute(cwd)) { + throw RequestError.invalidParams({ cwd }, "params require an absolute cwd"); + } + return { cwd }; +} + +/** `{ subscriptionId }`. */ +export function parseListUnsubscribeRequest(value: unknown): ListUnsubscribeRequest { + const subscriptionId = asRecord(value).subscriptionId; + if (typeof subscriptionId !== "string") { + throw RequestError.invalidParams(undefined, "params require a subscriptionId"); + } + return { subscriptionId }; +} + +export type ListSubscriptionDeps = { + index: Pick< + SessionIndex, + | "listedPaths" + | "projectDirs" + | "enumerateFiles" + | "rowsOf" + | "cachedRowsOf" + | "continuedIn" + | "isRead" + >; + registry: Pick; + /** What this connection knows of a session it runs. */ + own: (sessionId: string) => OwnSessionState | undefined; + notify: (changes: ListChanges) => Promise; + logError: (message: string, error: unknown) => void; + now?: () => number; + debounceMs?: number; + maxWaitMs?: number; + rescanMs?: number; + minSessionIntervalMs?: number; +}; + +type Row = { row: IndexRow; info: SessionInfo; signature: string }; + +type WatchedDir = { projectPath: string; watcher: fs.FSWatcher | null; identity?: string }; + +type Subscription = { + id: string; + watch: ScopeWatch; + /** When it subscribed, on the {@link ListSubscriptions.seq} clock. */ + since: number; + /** What was last sent per session: the row's signature, or null for a + * removal. A session without an entry was never sent: its next change is + * sent whatever it is. */ + sent: Map; + sentAt: Map; + /** Sessions to compare with `sent` at the next delivery. */ + pending: Set; + /** Fires when a session held back by the interval may be sent. */ + timer?: ReturnType; +}; + +/** The watching of one cwd, shared by its subscriptions. */ +type ScopeWatch = { + key: string; + cwd: string; + paths: string[]; + /** Project directory name → the scope path it belongs to, its watcher, + * and the directory the watcher watches. */ + dirs: Map; + /** Every non-empty transcript file of the scope, as last stat'ed. */ + files: Map; + /** The file paths of each session. */ + filesById: Map>; + /** The sessions whose first transcript appeared after + * the watch was ready, on the {@link ListSubscriptions.seq} clock: a + * subscription older than that never had them listed. */ + born: Map; + /** The current row of each session in the scope that was + * read since the watch started. */ + rows: Map; + subscriptions: Set; + started: Promise; + /** The transcripts are stat'ed and the registry read. */ + ready: boolean; + /** When it became ready: about when its client listed the sessions. */ + readyAt: number; + closed: boolean; + dirtyFiles: Set; + /** Sessions whose row is resolved again (from the cache when unchanged). */ + dirtyRows: Set; + /** Sessions whose row is presented again: their state may have changed. */ + dirtyStates: Set; + rescan: boolean; + /** The session each session read here is continued in, by its metadata: a change of the successor may show or hide it. */ + successorOf: Map; + /** Live continued sessions that a seed found hidden by their successor, + * with the stats of both then: not checked again until one changes. */ + hiddenSeeds: Map; + /** The scope paths the rows were last resolved against. */ + resolvedPaths: string; + /** Sessions whose transcript could not be read: kept as they were, and + * read again by the next rescan. */ + unreadable: Set; + debounce?: ReturnType; + maxWait?: ReturnType; + /** A rescan soon after a directory watcher opened: a new watcher may miss + * the events of its first moments. */ + settle?: ReturnType; + /** The work in flight; the next one runs after it. */ + working?: Promise; + /** Work was asked for while some ran. */ + again: boolean; + /** ...and it included the transcripts. */ + againFiles: boolean; +}; + +/** A non-recursive watch of `dir`, or null when it cannot be watched (it + * does not exist yet). `onError` runs when the watch fails later (the + * directory was removed): the watcher is closed then. */ +function watchDir( + dir: string, + onEvent: (filename: string | null) => void, + onError: () => void, +): fs.FSWatcher | null { + try { + const watcher = fs.watch(dir, { persistent: false }, (_event, filename) => + onEvent(filename === null || filename === undefined ? null : String(filename)), + ); + watcher.on("error", () => { + watcher.close(); + onError(); + }); + return watcher; + } catch { + // A directory that does not exist yet is covered by the rescan. + return null; + } +} + +const RECORD_FILE = /^\d+\.json$/; + +/** Which directory a path is now (device and inode), or undefined. A + * directory replaced by another one keeps no watcher of the old one. */ +function dirIdentity(dir: string): string | undefined { + try { + const stats = fs.statSync(dir); + return stats.isDirectory() ? `${stats.dev}:${stats.ino}` : undefined; + } catch { + return undefined; + } +} + +/** The scope paths as one comparable value. */ +function scopeKey(paths: readonly string[]): string { + return [...paths].sort().join("\0"); +} + +/** The session id of a transcript file name, else undefined. */ +function transcriptId(filename: string): string | undefined { + if (!filename.endsWith(".jsonl")) return undefined; + const id = filename.slice(0, -6); + return isSessionId(id) ? id : undefined; +} + +export class ListSubscriptions { + private readonly subscriptions = new Map(); + /** Sessions whose row shows this connection's SDK state: the rescan + * presents them again, in case a change of it was not reported. */ + private readonly ownShown = new Set(); + private readonly watches = new Map(); + private readonly shared = new Map(); + /** The directory each shared watcher watches (see {@link dirIdentity}). */ + private readonly sharedIdentity = new Map(); + /** The nearest existing ancestor of each shared directory that does not + * exist yet, watched for its creation. */ + private readonly ancestors = new Map(); + private rescanTimer?: ReturnType; + /** The live records by registry file name. */ + private liveFiles = new Map(); + private live: LiveSnapshot = new Map(); + private registryLoaded?: Promise; + private registryNames = new Set(); + private registryFull = false; + private registryDebounce?: ReturnType; + private registryMaxWait?: ReturnType; + private registryWorking?: Promise; + private disposed = false; + /** Orders subscriptions and session births (see {@link ScopeWatch.born}). */ + private seq = 0; + private readonly now: () => number; + private readonly debounceMs: number; + private readonly maxWaitMs: number; + private readonly rescanMs: number; + private readonly minIntervalMs: number; + + constructor(private readonly deps: ListSubscriptionDeps) { + this.now = deps.now ?? Date.now; + this.debounceMs = deps.debounceMs ?? DEBOUNCE_MS; + this.maxWaitMs = deps.maxWaitMs ?? MAX_WAIT_MS; + this.rescanMs = deps.rescanMs ?? RESCAN_MS; + this.minIntervalMs = deps.minSessionIntervalMs ?? MIN_SESSION_INTERVAL_MS; + } + + /** Subscribes to the sessions of `cwd`. Changes are tracked from the + * return on. Reads no row (see {@link createWatch}). */ + async subscribe(cwd: string): Promise { + if (this.disposed) throw RequestError.internalError(undefined, "The connection is closed"); + if (this.subscriptions.size >= MAX_SUBSCRIPTIONS) { + throw RequestError.invalidParams( + { reason: "too_many_subscriptions", max: MAX_SUBSCRIPTIONS }, + `At most ${MAX_SUBSCRIPTIONS} session list subscriptions per connection`, + ); + } + const key = path.resolve(cwd); + let watch = this.watches.get(key); + if (!watch) { + watch = this.createWatch(key); + this.watches.set(key, watch); + this.ensureShared(); + } + const subscription: Subscription = { + id: randomUUID(), + watch, + since: ++this.seq, + sent: new Map(), + sentAt: new Map(), + pending: new Set(), + }; + // Counted at once, so concurrent subscribes cannot pass the limit. + this.subscriptions.set(subscription.id, subscription); + watch.subscriptions.add(subscription); + try { + await watch.started; + } catch (error) { + this.unsubscribe(subscription.id); + throw error; + } + // The connection closed meanwhile. + if (!this.subscriptions.has(subscription.id)) { + throw RequestError.internalError(undefined, "The connection is closed"); + } + return { subscriptionId: subscription.id }; + } + + /** Idempotent: an unknown id is no error. */ + unsubscribe(subscriptionId: string): void { + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) return; + this.subscriptions.delete(subscriptionId); + if (subscription.timer) clearTimeout(subscription.timer); + subscription.timer = undefined; + const { watch } = subscription; + watch.subscriptions.delete(subscription); + if (watch.subscriptions.size === 0) this.closeWatch(watch); + } + + /** A session this connection runs changed its state, turn end or cost. */ + ownSessionChanged(id: string): void { + for (const watch of this.watches.values()) { + if (watch.ready && watch.rows.has(id)) { + watch.dirtyStates.add(id); + this.schedule(watch, "states"); + } else if (!watch.ready || watch.filesById.has(id)) { + // Its first event here: the row is read. + watch.dirtyRows.add(id); + this.schedule(watch, "now"); + } + } + } + + dispose(): void { + this.disposed = true; + for (const id of [...this.subscriptions.keys()]) this.unsubscribe(id); + this.stopShared(); + } + + /** The open watchers and armed timers, for tests and diagnostics. */ + openHandles(): { watches: number; watchers: number; timers: number } { + let watchers = this.shared.size + this.ancestors.size; + let timers = [this.rescanTimer, this.registryDebounce, this.registryMaxWait].filter( + Boolean, + ).length; + for (const watch of this.watches.values()) { + for (const { watcher } of watch.dirs.values()) if (watcher) watchers++; + if (watch.debounce) timers++; + if (watch.maxWait) timers++; + if (watch.settle) timers++; + for (const subscription of watch.subscriptions) if (subscription.timer) timers++; + } + return { watches: this.watches.size, watchers, timers }; + } + + private createWatch(key: string): ScopeWatch { + const watch: ScopeWatch = { + key, + cwd: key, + paths: [], + dirs: new Map(), + files: new Map(), + filesById: new Map(), + born: new Map(), + rows: new Map(), + subscriptions: new Set(), + started: Promise.resolve(), + ready: false, + readyAt: 0, + closed: false, + dirtyFiles: new Set(), + dirtyRows: new Set(), + dirtyStates: new Set(), + rescan: false, + successorOf: new Map(), + hiddenSeeds: new Map(), + unreadable: new Set(), + resolvedPaths: "", + again: false, + againFiles: false, + }; + // When subscribe returns, the watchers are open; no row is read. What it + // keeps, all without reading a transcript: + // - the stats of the transcripts, opened after the watchers: a deletion + // or a move out of the scope is a stat that went, the rescan reads only + // a transcript whose stat changed, and a session's other copies are + // known when its row is read; + // - the registry: a later change of it is an event of the sessions whose + // record changed. + watch.started = this.refreshDirs(watch).then(async () => { + const files = await this.deps.index.enumerateFiles(watch.paths); + await this.registryLoaded; + if (watch.closed) return; + this.setFiles(watch, files); + this.learnSuccessors(watch); + watch.resolvedPaths = scopeKey(watch.paths); + // The rows of the live sessions a list read: their state ages from now. + const seeded = await this.liveSeeds(watch, new Set()); + if (watch.closed) return; + watch.ready = true; + watch.readyAt = this.now(); + for (const row of seeded) { + watch.rows.set(row.sessionId, this.present(row, watch.readyAt)); + } + // Events of the start, held until now. + if ( + watch.dirtyFiles.size > 0 || + watch.dirtyRows.size > 0 || + watch.dirtyStates.size > 0 || + watch.rescan + ) { + this.schedule(watch, "debounced"); + } + }); + void watch.started.catch(() => this.closeWatch(watch)); + return watch; + } + + private closeWatch(watch: ScopeWatch): void { + if (watch.closed) return; + watch.closed = true; + for (const subscription of watch.subscriptions) { + this.subscriptions.delete(subscription.id); + if (subscription.timer) clearTimeout(subscription.timer); + } + watch.subscriptions.clear(); + for (const { watcher } of watch.dirs.values()) watcher?.close(); + watch.dirs.clear(); + if (watch.debounce) clearTimeout(watch.debounce); + if (watch.maxWait) clearTimeout(watch.maxWait); + if (watch.settle) clearTimeout(watch.settle); + watch.debounce = watch.maxWait = watch.settle = undefined; + watch.rows.clear(); + watch.files.clear(); + watch.filesById.clear(); + watch.born.clear(); + watch.successorOf.clear(); + watch.hiddenSeeds.clear(); + if (this.watches.get(watch.key) === watch) this.watches.delete(watch.key); + if (this.watches.size === 0) this.stopShared(); + } + + /** The registry and the projects root, watched once for all cwds, and the + * rescan. */ + private ensureShared(): void { + if (this.disposed || this.watches.size === 0) return; + const watchers: [string, (filename: string | null) => void][] = [ + [liveRegistryDir(), (filename) => this.onRegistryEvent(filename)], + [projectsRoot(), (filename) => this.onProjectsRootEvent(filename)], + ]; + const missing: string[] = []; + let created = false; + for (const [dir, onEvent] of watchers) { + const identity = dirIdentity(dir); + const existing = this.shared.get(dir); + if (existing && this.sharedIdentity.get(dir) === identity) continue; + if (existing) { + // Replaced or gone: watch what is there now. + existing.close(); + this.shared.delete(dir); + } + this.sharedIdentity.set(dir, identity); + const watcher = watchDir(dir, onEvent, () => { + // Watched again, or its ancestor, by the next rescan. + if (this.shared.get(dir) === watcher) this.shared.delete(dir); + }); + if (watcher) { + this.shared.set(dir, watcher); + created = true; + } else { + missing.push(dir); + } + } + this.watchAncestors(missing); + if (created && this.registryLoaded) { + // What the new directory got before its watcher opened. + this.registryFull = true; + this.scheduleRegistry(true); + for (const watch of this.watches.values()) { + if (!watch.ready) continue; + watch.rescan = true; + this.schedule(watch, "now"); + } + } + this.registryLoaded ??= this.readRegistry(true).then( + () => undefined, + (error: unknown) => this.deps.logError("session list registry read failed", error), + ); + if (!this.rescanTimer) { + this.rescanTimer = setInterval(() => this.rescanAll(), this.rescanMs); + this.rescanTimer.unref?.(); + } + } + + /** Watches the nearest existing ancestor of each of `missing` (a config + * directory without `projects` or `sessions` yet): any event there checks + * again. */ + private watchAncestors(missing: readonly string[]): void { + const wanted = new Set(); + for (const dir of missing) { + let ancestor = path.dirname(dir); + while (!fs.existsSync(ancestor) && path.dirname(ancestor) !== ancestor) { + ancestor = path.dirname(ancestor); + } + wanted.add(ancestor); + } + for (const [ancestor, watcher] of this.ancestors) { + if (wanted.has(ancestor)) continue; + watcher.close(); + this.ancestors.delete(ancestor); + } + for (const ancestor of wanted) { + if (this.ancestors.has(ancestor)) continue; + const watcher = watchDir( + ancestor, + () => this.ensureShared(), + () => { + if (this.ancestors.get(ancestor) === watcher) this.ancestors.delete(ancestor); + }, + ); + if (watcher) this.ancestors.set(ancestor, watcher); + } + } + + private stopShared(): void { + for (const watcher of this.shared.values()) watcher.close(); + this.shared.clear(); + this.sharedIdentity.clear(); + this.ownShown.clear(); + for (const watcher of this.ancestors.values()) watcher.close(); + this.ancestors.clear(); + if (this.rescanTimer) clearInterval(this.rescanTimer); + if (this.registryDebounce) clearTimeout(this.registryDebounce); + if (this.registryMaxWait) clearTimeout(this.registryMaxWait); + this.rescanTimer = this.registryDebounce = this.registryMaxWait = undefined; + this.registryLoaded = undefined; + this.registryNames.clear(); + this.registryFull = false; + this.liveFiles = new Map(); + this.live = new Map(); + } + + private rescanAll(): void { + // Directories that did not exist before may now. + this.ensureShared(); + this.registryFull = true; + this.scheduleRegistry(true); + for (const watch of this.watches.values()) { + if (!watch.ready) continue; + watch.rescan = true; + this.schedule(watch, "now"); + } + } + + // --- Events ------------------------------------------------------------- + + private onTranscriptEvent(watch: ScopeWatch, dirName: string, filename: string | null): void { + if (watch.closed) return; + if (filename === null) { + watch.rescan = true; + } else { + const id = transcriptId(filename); + if (id === undefined) return; + // Before the watch is ready, held until it is. + watch.dirtyFiles.add(path.join(projectsRoot(), dirName, filename)); + } + this.schedule(watch, "debounced"); + } + + /** A project directory that a scope lacked may have been created. */ + private onProjectsRootEvent(filename: string | null): void { + if (filename === null) return; + // A project directory of a scope was made, removed or replaced: the + // rescan watches what is there now. + for (const watch of this.watches.values()) { + if ( + watch.paths.some( + (cwd) => isExactProjectDir(filename, cwd) || projectDirMatches(filename, cwd), + ) + ) { + watch.rescan = true; + this.schedule(watch, "debounced"); + } + } + } + + private onRegistryEvent(filename: string | null): void { + if (filename === null) this.registryFull = true; + else if (RECORD_FILE.test(filename)) this.registryNames.add(filename); + else return; + this.scheduleRegistry(false); + } + + // --- Scheduling --------------------------------------------------------- + + /** + * Runs the work of `watch`: `debounced` after the quiet period, `now` at + * once, and `states` at once for the recomputed states only, leaving the + * transcript events to their quiet period (a registry that changes every + * 200 ms then does not read the transcripts that often). + */ + private schedule(watch: ScopeWatch, mode: "debounced" | "now" | "states"): void { + if (watch.closed) return; + if (mode === "states") { + void this.work(watch, false); + return; + } + if (mode === "now") { + this.clearWatchTimers(watch); + void this.work(watch, true); + return; + } + if (watch.debounce) clearTimeout(watch.debounce); + watch.debounce = setTimeout(() => this.schedule(watch, "now"), this.debounceMs); + watch.debounce.unref?.(); + if (!watch.maxWait) { + watch.maxWait = setTimeout(() => this.schedule(watch, "now"), this.maxWaitMs); + watch.maxWait.unref?.(); + } + } + + private clearWatchTimers(watch: ScopeWatch): void { + if (watch.debounce) clearTimeout(watch.debounce); + if (watch.maxWait) clearTimeout(watch.maxWait); + watch.debounce = watch.maxWait = undefined; + } + + private scheduleRegistry(now: boolean): void { + if (this.disposed || this.watches.size === 0) return; + const run = () => { + if (this.registryDebounce) clearTimeout(this.registryDebounce); + if (this.registryMaxWait) clearTimeout(this.registryMaxWait); + this.registryDebounce = this.registryMaxWait = undefined; + void this.registryWork(); + }; + if (now) return run(); + if (this.registryDebounce) clearTimeout(this.registryDebounce); + this.registryDebounce = setTimeout(run, this.debounceMs); + this.registryDebounce.unref?.(); + if (!this.registryMaxWait) { + this.registryMaxWait = setTimeout(run, this.maxWaitMs); + this.registryMaxWait.unref?.(); + } + } + + // --- Registry ----------------------------------------------------------- + + private async registryWork(): Promise { + if (this.registryWorking) { + // The running read picks the new names up when it ends. + return; + } + this.registryWorking = (async () => { + try { + await this.registryLoaded; + while (this.registryFull || this.registryNames.size > 0) { + const full = this.registryFull; + this.registryFull = false; + const affected = await this.readRegistry(full); + this.onLiveChanged(affected); + } + } catch (error) { + this.deps.logError("session list registry read failed", error); + } finally { + this.registryWorking = undefined; + } + })(); + await this.registryWorking; + } + + /** Reads the changed registry records (all with `full`) and returns the + * ids of the sessions whose record changed. */ + private async readRegistry(full: boolean): Promise> { + let names: string[]; + if (full) { + this.registryNames.clear(); + try { + names = await fsp.readdir(liveRegistryDir()); + } catch { + names = []; + } + } else { + names = [...this.registryNames]; + this.registryNames.clear(); + } + const read = await this.deps.registry.readFiles(names, { recentStarts: true }); + const affected = new Set(); + const key = (record: LiveRecord | undefined) => + record + ? `${record.sessionId}\0${record.status}\0${record.statusUpdatedAt}\0${record.kind}\0${record.entrypoint}` + : ""; + const next = full ? new Map() : new Map(this.liveFiles); + const before = this.liveFiles; + const changedNames = full ? new Set([...before.keys(), ...read.keys()]) : read.keys(); + for (const name of changedNames) { + const previous = before.get(name); + const record = read.get(name); + if (record) next.set(name, record); + else next.delete(name); + if (key(previous) !== key(record)) { + if (previous) affected.add(previous.sessionId); + if (record) affected.add(record.sessionId); + } + } + this.liveFiles = next; + this.live = bySession(next.values()); + return affected; + } + + private onLiveChanged(affected: ReadonlySet): void { + if (affected.size === 0) return; + for (const watch of this.watches.values()) { + let states = false; + let rows = false; + for (const id of affected) { + if (watch.ready && watch.rows.has(id)) { + watch.dirtyStates.add(id); + states = true; + } else if (!watch.ready || watch.filesById.has(id)) { + // Its first event here: the row is read. + watch.dirtyRows.add(id); + rows = true; + } + } + if (states) this.schedule(watch, "states"); + if (rows) this.schedule(watch, "debounced"); + } + } + + // --- Scope work --------------------------------------------------------- + + /** Reads the scope paths and watches their project directories. */ + private async refreshDirs(watch: ScopeWatch): Promise { + const paths = await this.deps.index.listedPaths(watch.cwd, true); + const dirs = await this.deps.index.projectDirs(paths); + if (watch.closed) return; + watch.paths = paths; + const wanted = new Map(dirs.map(({ dirName, projectPath }) => [dirName, projectPath])); + let opened = false; + for (const [dirName, entry] of watch.dirs) { + if (wanted.has(dirName)) continue; + entry.watcher?.close(); + watch.dirs.delete(dirName); + } + for (const [dirName, projectPath] of wanted) { + const dir = path.join(projectsRoot(), dirName); + const identity = dirIdentity(dir); + let entry = watch.dirs.get(dirName); + if (entry?.watcher && entry.identity === identity) { + entry.projectPath = projectPath; + continue; + } + if (entry?.watcher) { + // Replaced (renamed away and made again): watch the new one. + entry.watcher.close(); + watch.dirs.delete(dirName); + entry = undefined; + } + const created: WatchedDir = { projectPath, watcher: null, identity }; + created.watcher = watchDir( + dir, + (filename) => this.onTranscriptEvent(watch, dirName, filename), + () => { + // Opened again by the next rescan, when the directory is back. + created.watcher = null; + }, + ); + watch.dirs.set(dirName, created); + // Not for a watcher opened again after an error, which could repeat. + if (created.watcher && !entry) opened = true; + } + if (opened && !watch.settle) { + watch.settle = setTimeout(() => { + watch.settle = undefined; + watch.rescan = true; + this.schedule(watch, "now"); + }, SETTLE_MS); + watch.settle.unref?.(); + } + } + + private setFiles(watch: ScopeWatch, files: readonly TranscriptCandidate[]): void { + const before = watch.filesById; + watch.files = new Map(); + watch.filesById = new Map(); + for (const file of files) this.putFile(watch, file, before); + } + + /** `known`: the sessions with a transcript before; another one is born. */ + private putFile( + watch: ScopeWatch, + file: TranscriptCandidate, + known: ReadonlyMap = watch.filesById, + ): void { + watch.files.set(file.filePath, file); + const id = file.sessionId; + if (watch.ready && !known.has(id)) watch.born.set(id, ++this.seq); + let paths = watch.filesById.get(id); + if (!paths) watch.filesById.set(id, (paths = new Set())); + paths.add(file.filePath); + } + + private dropFile(watch: ScopeWatch, filePath: string, id: string): void { + watch.files.delete(filePath); + const paths = watch.filesById.get(id); + paths?.delete(filePath); + if (paths?.size === 0) watch.filesById.delete(id); + } + + private present(row: IndexRow, now: number): Row { + const own = this.deps.own(row.sessionId); + const id = row.sessionId; + if (own) this.ownShown.add(id); + else this.ownShown.delete(id); + const info = sessionInfoOf(row, own, this.live.get(row.sessionId), now); + return { row, info, signature: changeSignature(info) }; + } + + /** Runs one pass of the pending work of `watch`, one at a time. `files`: + * the transcript events and the rescan too, not only the states. */ + private async work(watch: ScopeWatch, files: boolean): Promise { + // Before the transcripts are stat'ed, the events wait: the start runs a + // pass for them. + if (!watch.ready || watch.closed) return; + if (watch.working) { + watch.again = true; + watch.againFiles ||= files; + return; + } + const working = (async () => { + let withFiles = files; + do { + withFiles ||= watch.againFiles; + watch.again = watch.againFiles = false; + try { + await this.pass(watch, withFiles); + } catch (error) { + this.deps.logError(`session list change check of ${watch.cwd} failed`, error); + } + withFiles = false; + } while (watch.again && !watch.closed); + })(); + watch.working = working; + try { + await working; + } finally { + if (watch.working === working) watch.working = undefined; + } + } + + private async pass(watch: ScopeWatch, files: boolean): Promise { + const rescan = files && watch.rescan; + const dirtyFiles = files ? [...watch.dirtyFiles] : []; + const rowIds = new Set(files ? watch.dirtyRows : []); + const stateIds = new Set(watch.dirtyStates); + /** Sessions whose state may have aged: sent only when it changed. */ + const agedIds = new Set(); + /** The scope paths and the transcripts of each session read again, as + * they were before this pass. */ + const priorPaths = watch.paths; + const priorFiles = new Map(); + const remember = (id: string) => { + if (!priorFiles.has(id)) priorFiles.set(id, this.filesOf(watch, id)); + }; + if (files) { + watch.rescan = false; + watch.dirtyFiles.clear(); + watch.dirtyRows.clear(); + } + watch.dirtyStates.clear(); + + let resolved = new Map(); + /** Rows of live sessions not read here, from the metadata the index + * holds: their state ages from now on. */ + let seeded: IndexRow[] = []; + /** Sessions that may have had a row before this pass. */ + let shownBefore = new Set(); + try { + if (rescan) { + await this.refreshDirs(watch); + const files = await this.deps.index.enumerateFiles(watch.paths); + if (watch.closed) return; + // The sessions of a transcript that is new, changed or gone are + // read again; one whose stat did not change is not read. + const before = new Map(watch.files); + for (const file of files) { + const old = before.get(file.filePath); + before.delete(file.filePath); + if ( + !old || + old.mtimeMs !== file.mtimeMs || + old.size !== file.size || + old.ino !== file.ino + ) { + rowIds.add(file.sessionId); + } + } + for (const gone of before.values()) rowIds.add(gone.sessionId); + for (const id of rowIds) remember(id); + this.setFiles(watch, files); + this.learnSuccessors(watch); + if (scopeKey(watch.paths) !== watch.resolvedPaths) { + // A worktree came or went: a transcript that did not change may now + // be in the scope or out of it (another path of the same project + // directory). Resolved again: the sessions read here, and those whose + // metadata the index holds (a list read them); both without reading + // a transcript. + for (const id of watch.rows.keys()) rowIds.add(id); + for (const file of files) { + if (this.deps.index.isRead(file)) rowIds.add(file.sessionId); + } + } + // A transcript that could not be read before. + for (const id of watch.unreadable) rowIds.add(id); + watch.unreadable.clear(); + // Only the state of a session that a live process holds depends on + // the time (an unfinished turn ages). One without a row read here + // gets it from the metadata the index holds (a list read it), not + // from its transcript; if it has none, the client did not list it. + for (const id of this.live.keys()) { + if (watch.rows.has(id)) agedIds.add(id); + } + seeded = await this.liveSeeds(watch, rowIds); + if (watch.closed) return; + for (const id of this.ownShown) if (watch.rows.has(id)) agedIds.add(id); + } else if (dirtyFiles.length > 0) { + const stats = await Promise.all( + dirtyFiles.map((filePath) => fsp.stat(filePath).catch(() => undefined)), + ); + if (watch.closed) return; + dirtyFiles.forEach((filePath, i) => { + const filename = path.basename(filePath); + const dirName = path.basename(path.dirname(filePath)); + const id = transcriptId(filename)!; + rowIds.add(id); + remember(id); + const dir = watch.dirs.get(dirName); + const fileStats = stats[i]; + if (!dir || !fileStats?.isFile() || fileStats.size === 0) { + this.dropFile(watch, filePath, id); + return; + } + this.putFile(watch, { + sessionId: filename.slice(0, -6), + filePath, + dirName, + projectPath: dir.projectPath, + mtimeMs: fileStats.mtimeMs, + size: fileStats.size, + ino: fileStats.ino, + }); + }); + } + + if (rowIds.size > 0) { + // A session continued in a changed one may be shown or hidden now. + // Which sessions those are is known from the reads here and from + // the metadata the index holds: a session that no list or event read + // was not shown. + const successors = new Set(rowIds); + for (const [id, successor] of watch.successorOf) { + if (successors.has(successor)) rowIds.add(id); + } + for (const file of watch.files.values()) { + const successor = this.deps.index.continuedIn(file.filePath); + if (successor && successors.has(successor)) rowIds.add(file.sessionId); + } + for (const id of rowIds) remember(id); + // Before the read below replaces the metadata the index holds. + shownBefore = await this.shownBefore(watch, priorPaths, rowIds, priorFiles); + if (watch.closed) return; + const read: TranscriptCandidate[] = []; + for (const id of rowIds) { + for (const filePath of watch.filesById.get(id) ?? []) { + const file = watch.files.get(filePath); + if (file) read.push(file); + } + } + const rows = await this.deps.index.rowsOf(watch.paths, read, () => [ + ...watch.files.values(), + ]); + if (watch.closed) return; + resolved = new Map(rows.map((row) => [row.sessionId, row])); + for (const file of read) { + const id = file.sessionId; + const successor = this.deps.index.continuedIn(file.filePath); + if (successor) watch.successorOf.set(id, successor); + else if (this.deps.index.isRead(file)) watch.successorOf.delete(id); + } + // A transcript there whose read failed is no removal: it keeps its row + // until a rescan reads it. + for (const file of read) { + const id = file.sessionId; + if (!resolved.has(id) && !this.deps.index.isRead(file)) { + watch.unreadable.add(id); + rowIds.delete(id); + } + } + } + } catch (error) { + // What this pass took is left for the next one: the file stats may + // have moved on, so the sessions are resolved again by id. + for (const filePath of dirtyFiles) watch.dirtyFiles.add(filePath); + for (const id of rowIds) watch.dirtyRows.add(id); + for (const id of stateIds) watch.dirtyStates.add(id); + if (rescan) watch.rescan = true; + throw error; + } + if (rescan) watch.resolvedPaths = scopeKey(watch.paths); + + // From here on synchronous: the rows change and are delivered at once. + const now = this.now(); + /** The changed sessions. */ + const changed = new Set(); + /** Sessions without a row now. */ + const absent = new Set(); + for (const id of rowIds) { + const row = resolved.get(id); + if (row) { + watch.rows.set(id, this.present(row, now)); + changed.add(id); + continue; + } + const previous = watch.rows.get(id); + watch.rows.delete(id); + if (!previous && !shownBefore.has(id)) continue; + changed.add(id); + absent.add(id); + } + for (const row of seeded) { + const id = row.sessionId; + if (rowIds.has(id) || watch.rows.has(id)) continue; + // As the client listed it, about when the watch became ready: a state + // that aged since is sent below. + watch.rows.set(id, this.present(row, watch.readyAt)); + agedIds.add(id); + } + for (const id of new Set([...stateIds, ...agedIds])) { + if (rowIds.has(id)) continue; + const current = watch.rows.get(id); + if (!current) continue; + const next = this.present(current.row, now); + watch.rows.set(id, next); + // An event of the session, or a state that aged. + if (stateIds.has(id) || next.signature !== current.signature) { + changed.add(id); + } + } + for (const subscription of watch.subscriptions) { + for (const id of changed) { + const born = watch.born.get(id); + if ( + absent.has(id) && + !subscription.sent.has(id) && + born !== undefined && + born > subscription.since + ) { + // Born after the subscription and never sent: nothing to remove. + subscription.pending.delete(id); + continue; + } + subscription.pending.add(id); + } + this.deliver(subscription); + } + // A session without a transcript now, shown or not: a removal already + // sent for it is forgotten too. + for (const id of rowIds) { + if (watch.filesById.has(id)) continue; + watch.born.delete(id); + watch.successorOf.delete(id); + watch.hiddenSeeds.delete(id); + for (const subscription of watch.subscriptions) { + if (subscription.sent.get(id) === null && !subscription.pending.has(id)) { + subscription.sent.delete(id); + } + } + } + } + + /** Records which session each transcript is continued in, by the metadata + * the index holds now (a list read it): kept when the index evicts it. + * Reads nothing. */ + private learnSuccessors(watch: ScopeWatch): void { + for (const file of watch.files.values()) { + const successor = this.deps.index.continuedIn(file.filePath); + if (successor) watch.successorOf.set(file.sessionId, successor); + } + } + + /** + * The rows of the live sessions of the scope without a row here (and not + * in `except`), from the metadata the index holds (a list read them), not + * from their transcripts; one it holds nothing for the client did not + * list. Reads no transcript: a continued one only checks its successor. + */ + private async liveSeeds(watch: ScopeWatch, except: ReadonlySet): Promise { + const read: TranscriptCandidate[] = []; + for (const id of this.live.keys()) { + if (!watch.rows.has(id) && !except.has(id)) read.push(...this.filesOf(watch, id)); + } + if (read.length === 0) return []; + const cached = await this.deps.index.cachedRowsOf(watch.paths, read); + // A continued one is shown while its successor has no history: its + // cached metadata and that check, made again only when the stat of + // either changed. + const statOf = (file: TranscriptCandidate | undefined) => + file ? `${file.mtimeMs}:${file.size}:${file.ino}` : "-"; + const continued: { file: TranscriptCandidate; key: string }[] = []; + for (const file of read) { + const id = file.sessionId; + const successor = this.deps.index.continuedIn(file.filePath); + if (!cached.unknown.has(id) || !this.deps.index.isRead(file) || !successor) continue; + const successorFile = watch.files.get( + path.join(path.dirname(file.filePath), `${successor}.jsonl`), + ); + const key = `${statOf(file)}|${statOf(successorFile)}`; + if (watch.hiddenSeeds.get(id) !== key) continued.push({ file, key }); + } + if (continued.length === 0) return cached.rows; + const rows = await this.deps.index.rowsOf( + watch.paths, + continued.map(({ file }) => file), + () => [], + ); + const shown = new Set(rows.map((row) => row.sessionId)); + for (const { file, key } of continued) { + const id = file.sessionId; + if (shown.has(id)) watch.hiddenSeeds.delete(id); + else watch.hiddenSeeds.set(id, key); + } + return [...cached.rows, ...rows]; + } + + /** The transcripts of a session the watch knows. */ + private filesOf(watch: ScopeWatch, id: string): TranscriptCandidate[] { + const files: TranscriptCandidate[] = []; + for (const filePath of watch.filesById.get(id) ?? []) { + const file = watch.files.get(filePath); + if (file) files.push(file); + } + return files; + } + + /** + * Which of `ids`, without a row read here, may have had a row in the scope + * before: those that the metadata the index holds for their earlier + * transcripts (a list read them) gives a row, and those it gives no answer + * for (unknown: a removal is sent once). Not a session without a + * transcript before, nor one known to have had no row (another path of + * the project directory, a hidden session). Reads no transcript. + */ + private async shownBefore( + watch: ScopeWatch, + paths: readonly string[], + ids: ReadonlySet, + priorFiles: ReadonlyMap, + ): Promise> { + const candidates: TranscriptCandidate[] = []; + for (const id of ids) { + if (!watch.rows.has(id)) candidates.push(...(priorFiles.get(id) ?? [])); + } + if (candidates.length === 0) return new Set(); + const { rows, unknown } = await this.deps.index.cachedRowsOf(paths, candidates); + for (const row of rows) unknown.add(row.sessionId); + return unknown; + } + + /** Sends the pending sessions of `subscription` that differ from what it + * last sent (a session never sent differs), each at most once per + * interval, in one notification. */ + private deliver(subscription: Subscription): void { + const { watch } = subscription; + if (watch.closed) return; + if (subscription.timer) clearTimeout(subscription.timer); + subscription.timer = undefined; + const now = this.now(); + const sessions: SessionInfo[] = []; + const removed: string[] = []; + let nextAt = Infinity; + for (const id of subscription.pending) { + const current = watch.rows.get(id); + const sent = subscription.sent.get(id); + const unchanged = current ? sent === current.signature : sent === null; + if (unchanged) { + subscription.pending.delete(id); + continue; + } + const dueAt = (subscription.sentAt.get(id) ?? -Infinity) + this.minIntervalMs; + if (dueAt > now) { + nextAt = Math.min(nextAt, dueAt); + continue; + } + subscription.pending.delete(id); + subscription.sentAt.set(id, now); + if (!current) { + removed.push(id); + // A session with a transcript but no row (hidden, or of another + // path) is remembered as removed; one without a transcript is + // forgotten. + if (watch.filesById.has(id)) { + subscription.sent.set(id, null); + } else { + subscription.sent.delete(id); + } + continue; + } + sessions.push(current.info); + subscription.sent.set(id, current.signature); + } + this.forgetOldSends(subscription, now); + if (nextAt !== Infinity) { + subscription.timer = setTimeout(() => this.deliver(subscription), nextAt - now); + subscription.timer.unref?.(); + } + if (sessions.length === 0 && removed.length === 0) return; + void this.deps + .notify({ subscriptionId: subscription.id, sessions, removed }) + .catch((error) => this.deps.logError("session list change notification failed", error)); + } + + /** Drops send times older than the interval: they hold nothing back. */ + private forgetOldSends(subscription: Subscription, now: number): void { + if (subscription.sentAt.size < 1024) return; + for (const [id, at] of subscription.sentAt) { + if (now - at >= this.minIntervalMs) subscription.sentAt.delete(id); + } + } +} diff --git a/src/session-index/live-registry.ts b/src/session-index/live-registry.ts new file mode 100644 index 00000000..13e4023b --- /dev/null +++ b/src/session-index/live-registry.ts @@ -0,0 +1,288 @@ +/** + * The live Claude Code processes on this machine, from the CLI's registry. + * + * Each running CLI writes `/sessions/.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 `.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; + +export type LiveRegistryDeps = { + /** The registry directory. Defaults to `/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>; + /** This machine's pid domain, as the CLI computes it. */ + pidDomain?: () => Promise; +}; + +/** `/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> { + const result = new Map(); + 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 | 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 { + 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; + +async function readRecord(file: string): Promise { + 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 }; + private readonly dir: () => string; + private readonly now: () => number; + private readonly isAlive: (pid: number) => boolean; + private readonly processStarts: (pids: number[]) => Promise>; + private readonly pidDomain: () => Promise; + /** The start times `ps` gave, by pid, and when. */ + private readonly starts = new Map(); + + 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 { + 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()); + this.cached = { at: now, snapshot }; + return snapshot; + } + + /** Every live record, several per session when several processes hold it. */ + private async read(): Promise { + 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 `.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> { + const dir = this.dir(); + const result = new Map(); + 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(); + 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): LiveSnapshot { + const result = new Map(); + 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; +} diff --git a/src/session-index/project-dirs.ts b/src/session-index/project-dirs.ts new file mode 100644 index 00000000..14ade477 --- /dev/null +++ b/src/session-index/project-dirs.ts @@ -0,0 +1,133 @@ +/** + * Where Claude Code keeps the transcripts of a working directory. + * + * The CLI stores the transcripts of `cwd` in `/projects//`. + * 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); +} + +/** `/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 { + 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; +} diff --git a/src/session-index/service.ts b/src/session-index/service.ts new file mode 100644 index 00000000..5fff02c7 --- /dev/null +++ b/src/session-index/service.ts @@ -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 { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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): 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, 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 | 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 }; +} + +/** `//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 `//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 { + 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 { + 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 { + 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 { + 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 { + 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; + /** 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; + 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; + 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>(); + private readonly now: () => number; + private readonly deleteSession: (sessionId: string) => Promise; + + 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 { + 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(sessionId: string, mutation: () => Promise): Promise { + 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 { + 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 { + 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 + * `/` directory, one copy per call; empty transcripts, which it + * skips, are removed here. */ + async delete(sessionId: string, known: boolean): Promise { + 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 { + 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; + } +} diff --git a/src/session-index/session-index.ts b/src/session-index/session-index.ts new file mode 100644 index 00000000..5f892c38 --- /dev/null +++ b/src/session-index/session-index.ts @@ -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; + +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 { + private readonly entries = new Map(); + 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 { + 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 `/` 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 { + 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 { + 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 { + if (!paths) return async () => true; + const own = paths.map(comparable); + const resolved = new Map>(); + const hidden = async (cwd: string, projectPath: string): Promise => { + 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(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 } + >(); + /** 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(); + /** 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(); + + 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 { + 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(); + 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 { + 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 { + 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 `/` 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 { + const { transcripts } = await this.scanSession(sessionId); + return transcripts + .filter(({ size }) => size > 0 || options.includeEmpty) + .map(({ filePath }) => filePath); + } + + /** Every `/` directory of the session (see {@link scanSession}). */ + async findSessionDirs(sessionId: string): Promise { + 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(); + const seen = new Set(); + 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 { + 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 }> { + const unknown = new Set(); + 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(); + 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; + /** 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) => readonly TranscriptCandidate[]; + }, + ): Promise { + 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(); + 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(); + 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, + unread: readonly TranscriptCandidate[], + ): Promise { + const root = projectsRoot(); + const now = Date.now(); + const mtimes = new Map(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/session-index/session-info.ts b/src/session-index/session-info.ts new file mode 100644 index 00000000..ede3eda2 --- /dev/null +++ b/src/session-index/session-info.ts @@ -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 = { + 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 | 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]); +} diff --git a/src/session-index/transcript-scan.ts b/src/session-index/transcript-scan.ts new file mode 100644 index 00000000..6feed7f5 --- /dev/null +++ b/src/session-index/transcript-scan.ts @@ -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(); + +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 { + 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 { + 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 { + 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 { + 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)[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; + +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) ``. + if (typeof model === "string" && model && model !== "") { + 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 { + 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 { + 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(); + } +} diff --git a/src/session-index/worktrees.ts b/src/session-index/worktrees.ts new file mode 100644 index 00000000..d71b4c8a --- /dev/null +++ b/src/session-index/worktrees.ts @@ -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 `/worktrees//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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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))]; +} diff --git a/src/session-titles.ts b/src/session-titles.ts index 8c2d2134..aa796cc9 100644 --- a/src/session-titles.ts +++ b/src/session-titles.ts @@ -17,6 +17,7 @@ import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk"; import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk"; import type { ClaudeAcpAgent, Session } from "./acp-agent.js"; +import { ExplicitTitle } from "./session-index/explicit-title.js"; const MAX_TITLE_LENGTH = 256; @@ -42,7 +43,22 @@ type TitleCapableQuery = Query & { * unavailable or yields nothing. */ type TitleFallback = { title: string; lastModified: number }; +/** How much of a long text {@link sanitizeTitle} reads first. Collapsed, it + * is longer than a title unless it is mostly whitespace. */ +const TITLE_SCAN_LENGTH = 4096; + export function sanitizeTitle(text: string): string { + if (text.length > TITLE_SCAN_LENGTH) { + // A stored summary can be a whole first prompt of megabytes. The title + // depends only on the start of the text: collapsing the whitespace of a + // prefix gives a prefix of the collapsed text. Two more characters than a + // title hold at least one that is not a space (spaces are collapsed), so + // the full text is longer than a title and gets the same cut. + const head = text.slice(0, TITLE_SCAN_LENGTH).replace(/\s+/g, " ").trimStart(); + if (head.length >= MAX_TITLE_LENGTH + 2) { + return head.slice(0, MAX_TITLE_LENGTH - 1) + "…"; + } + } // Replace newlines and collapse whitespace const sanitized = text .replace(/[\r\n]+/g, " ") @@ -84,6 +100,27 @@ export class SessionTitles { * one. Released by {@link reset}, and when generation yields nothing. */ private settled = false; + /** The title a client set, which no generated title replaces. */ + private readonly explicit = new ExplicitTitle(); + + /** The title last stored through {@link setExplicitTitle}, as stored + * (with the archive prefix of an archived session): the title the CLI + * holds. */ + private persistedTitle?: string; + + /** Client title changes in flight, and the title state before the first. */ + private explicitChanges = 0; + private beforeExplicit?: { settled: boolean; context: string | undefined }; + + get storedTitle(): string | undefined { + return this.persistedTitle; + } + + /** The title last published, as the client shows it. */ + get shownTitle(): string | undefined { + return this.lastTitle; + } + constructor( private readonly agent: ClaudeAcpAgent, private readonly sessionId: string, @@ -115,8 +152,10 @@ export class SessionTitles { * `conversation_reset`, which mounts a fresh transcript. */ reset(): void { this.settled = false; + this.explicit.reset(); this.context = undefined; this.lastTitle = undefined; + this.persistedTitle = undefined; } /** Turn-end title handling. `idle` is the SDK's turn-over signal, so it is @@ -131,7 +170,24 @@ export class SessionTitles { * not possible do we fall back to `summary`, which for an SDK-driven session * is just the raw first prompt. */ async onTurnEnd(session: Session): Promise { + if (this.explicit.settledByClient) { + // No title is generated over the one the client set, but a custom title + // stored since by someone else (a `/rename`) is adopted, as before. + const renames = this.explicit.renames; + const info = await this.readSessionInfo(session); + // No client change ran during the read, so the stored title is current; + // an unchanged one is not published again. + const stored = info?.customTitle; + if (stored && this.explicit.settledByClient && this.explicit.renames === renames) { + await this.publish(stored, info.lastModified); + } + return; + } + // While a client title change is in flight, the session info read below + // could still hold the previous title. + if (this.explicit.active) return; const info = await this.readSessionInfo(session); + if (this.explicit.active) return; if (info?.customTitle) { this.settled = true; @@ -147,9 +203,13 @@ export class SessionTitles { if (this.canRequest(session)) { this.settled = true; - void this.requestGenerateTitle(session, fallback).catch((error) => { - this.agent.logger.error(`Session ${this.sessionId}: session title update failed: ${error}`); - }); + this.explicit.track( + this.requestGenerateTitle(session, fallback).catch((error) => { + this.agent.logger.error( + `Session ${this.sessionId}: session title update failed: ${error}`, + ); + }), + ); return; } @@ -161,6 +221,54 @@ export class SessionTitles { } } + /** Applies a title that the client chose (a rename, an archive or an + * unarchive). Settles the title for good, waits for a generation in flight + * so that its persisted title cannot land after this one, persists the + * title with `persist`, which returns what it stored, if anything, and + * publishes `title` unless it is undefined. A change that stored and + * publishes nothing leaves the title state as it was. */ + async setExplicitTitle( + publish: string | ((stored: string | undefined) => string) | undefined, + persist: () => Promise, + ): Promise { + // The state before the first of overlapping changes, which a change that + // fails or names nothing puts back. + if (this.explicitChanges++ === 0) { + this.beforeExplicit = { settled: this.settled, context: this.context }; + } + const previous = this.beforeExplicit!; + this.settled = true; + this.context = undefined; + let stored: string | undefined; + try { + await this.explicit.apply( + async () => { + stored = (await persist()) ?? undefined; + if (stored !== undefined) this.persistedTitle = stored; + return stored !== undefined || publish !== undefined; + }, + () => { + this.settled = previous.settled; + this.context ??= previous.context; + }, + ); + } finally { + if (--this.explicitChanges === 0) this.beforeExplicit = undefined; + } + const title = typeof publish === "function" ? publish(stored) : publish; + if (title === undefined) return; + this.lastTitle = title; + await this.agent.client.sessionUpdate({ + sessionId: this.sessionId, + update: { sessionUpdate: "session_info_update", title }, + }); + } + + /** Records a title the CLI took, as soon as it took it. */ + rememberStoredTitle(title: string): void { + this.persistedTitle = title; + } + /** Read the SDK's stored info for this session. A missing session file or read * error is non-fatal: the title is best-effort and another turn will retry. */ private async readSessionInfo(session: Session): Promise { @@ -174,7 +282,7 @@ export class SessionTitles { /** Notify the client of a title, unless it is the one we last sent. */ private async publish(rawTitle: string, lastModified: number): Promise { - const title = sanitizeTitle(rawTitle); + const title = sanitizeTitle(this.agent.clientTitle?.(rawTitle) ?? rawTitle); if (title === this.lastTitle) { return; } @@ -226,8 +334,8 @@ export class SessionTitles { } // A session torn down or replaced while the title was in flight must not - // adopt it. - if (this.agent.sessions[this.sessionId] !== session) { + // adopt it, and a title the client set meanwhile wins. + if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) { return; } diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 2f61d6af..ccf9de04 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -10790,12 +10790,13 @@ describe("session/close", () => { 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(); - await expect(agent.closeSession({ sessionId: "non-existent" })).rejects.toThrow( - "Session not found", - ); + await expect(agent.closeSession({ sessionId: "non-existent" })).resolves.toEqual({}); + 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 () => { diff --git a/src/tests/diff-memo.test.ts b/src/tests/diff-memo.test.ts new file mode 100644 index 00000000..1c0eb3c1 --- /dev/null +++ b/src/tests/diff-memo.test.ts @@ -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(); + 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 { + 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); + }); +}); diff --git a/src/tests/event-loop-yield.test.ts b/src/tests/event-loop-yield.test.ts new file mode 100644 index 00000000..1b51a91f --- /dev/null +++ b/src/tests/event-loop-yield.test.ts @@ -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) => + (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) => AsyncGenerator, + onUpdate: (notification: SessionNotification) => void, +) { + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (notification: SessionNotification) => onUpdate(notification), + } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + const input = new Pushable(); + 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; + }; + + 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 | 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"); + }); +}); diff --git a/src/tests/mcp-command.test.ts b/src/tests/mcp-command.test.ts index 19475ff8..7787ac0c 100644 --- a/src/tests/mcp-command.test.ts +++ b/src/tests/mcp-command.test.ts @@ -223,6 +223,16 @@ describe("parseMcpCommand", () => { expect(parseMcpCommand(text)).toBeNull(); } }); + + it("reads past any leading whitespace, as before the prefix check", () => { + expect(parseMcpCommand("\n\t\u00a0\ufeff /mcp enable my\nserver ")).toEqual({ + action: "enable", + all: false, + server: "my server", + }); + expect(parseMcpCommand("\u2028/mcp")).toEqual({ action: "status" }); + expect(parseMcpCommand(`hello ${"word ".repeat(100_000)}/mcp`)).toBeNull(); + }); }); const LONG_ERROR = diff --git a/src/tests/session-index-units.test.ts b/src/tests/session-index-units.test.ts new file mode 100644 index 00000000..b04b308c --- /dev/null +++ b/src/tests/session-index-units.test.ts @@ -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 => ({ + 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) { + 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( + "init\n/init", + ); + 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("ok"), + 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: "r" }, + { 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(" ls -la "))).toBe("! ls -la"); + const pasted = + 'Fix this\n\nstack trace\n\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("/status", "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("/status", "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: "", + 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, + }); + }); +}); diff --git a/src/tests/session-index.test.ts b/src/tests/session-index.test.ts new file mode 100644 index 00000000..610a743d --- /dev/null +++ b/src/tests/session-index.test.ts @@ -0,0 +1,4105 @@ +/** + * The `sessionIndex` AIR extension against a real projects directory in a + * temporary `CLAUDE_CONFIG_DIR`. The SDK session functions are the real ones, + * wrapped in spies. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as fsSync from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + deleteSession, + forkSession as sdkForkSession, + getSessionInfo, + listSessions, +} from "@anthropic-ai/claude-agent-sdk"; +import { spawn } from "node:child_process"; +import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js"; +import type { SessionInfo } from "@agentclientprotocol/sdk"; +import { encodeProjectPath } from "../session-index/project-dirs.js"; +import { + archiveInsteadOfDelete, + SessionIndexService, + writeCustomTitleSidecar, +} from "../session-index/service.js"; +import { scanTranscriptFile } from "../session-index/transcript-scan.js"; +import { repositoryWorktrees } from "../session-index/worktrees.js"; +import { + ListSubscriptions, + MAX_SUBSCRIPTIONS, + parseListSubscribeRequest, + parseListUnsubscribeRequest, + type ListChanges, +} from "../session-index/list-subscriptions.js"; +import { SessionTitles } from "../session-titles.js"; +import { initializeClient } from "./helpers.js"; +import { + mockSessionState, + successfulResultMessage, + userEcho, + wrapQuery, +} from "./session-doubles.js"; +import { Pushable } from "../utils.js"; + +vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deleteSession: vi.fn(actual.deleteSession), + getSessionInfo: vi.fn(actual.getSessionInfo), + listSessions: vi.fn(actual.listSessions), + }; +}); + +const air = (...capabilities: string[]) => ({ + _meta: { jetbrains: { air: { version: 1, capabilities } } }, +}); +const listMeta = (list: object) => ({ jetbrains: { air: { version: 1, list } } }); + +let configDir: string; +let workspace: string; +let previousConfigDir: string | undefined; + +beforeEach(async () => { + previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + configDir = fsSync.realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "session-index-cfg-"))); + workspace = fsSync.realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "session-index-ws-"))); + process.env.CLAUDE_CONFIG_DIR = configDir; + vi.clearAllMocks(); +}); + +afterEach(async () => { + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + await fs.rm(configDir, { recursive: true, force: true }); + await fs.rm(workspace, { recursive: true, force: true }); +}); + +type TranscriptOptions = { + sessionId?: string; + cwd?: string; + /** The `cwd` written in the records; defaults to `cwd`. Null writes none. */ + recordCwd?: string | null; + prompt?: string; + lastMessageAt?: number; + mtimeMs?: number; + stopReason?: string; + costUsd?: number; + sidechain?: boolean; + /** A first prompt of this many characters, to push the head past 64 KB. */ + hugePrompt?: number; + trailer?: object[]; + /** The project directory name; defaults to the encoding of `cwd`. */ + dirName?: string; + /** The model of the assistant message. */ + model?: string; + /** The `forkedFrom.sessionId` that a fork writes on every record. */ + forkedFrom?: string; +}; + +async function writeTranscript(options: TranscriptOptions): Promise<{ id: string; file: string }> { + const id = options.sessionId ?? randomUUID(); + const cwd = options.cwd ?? workspace; + const recordCwd = options.recordCwd === undefined ? cwd : options.recordCwd; + const at = options.lastMessageAt ?? Date.parse("2026-01-01T00:00:00Z"); + const dir = path.join(configDir, "projects", options.dirName ?? encodeProjectPath(cwd)); + await fs.mkdir(dir, { recursive: true }); + const common = { + sessionId: id, + isSidechain: options.sidechain ?? false, + ...(recordCwd !== null && { cwd: recordCwd }), + ...(options.forkedFrom && { + forkedFrom: { sessionId: options.forkedFrom, messageUuid: randomUUID() }, + }), + }; + const prompt = options.hugePrompt ? "p".repeat(options.hugePrompt) : (options.prompt ?? "Fix it"); + const entries = [ + { + ...common, + type: "user", + uuid: randomUUID(), + timestamp: new Date(at - 1000).toISOString(), + message: { role: "user", content: prompt }, + }, + { + ...common, + type: "assistant", + uuid: randomUUID(), + timestamp: new Date(at).toISOString(), + message: { + role: "assistant", + ...(options.model && { model: options.model }), + content: [{ type: "text", text: "Done" }], + stop_reason: options.stopReason ?? "end_turn", + }, + }, + ...(options.costUsd !== undefined + ? [{ type: "cost-state", sessionId: id, totalCostUSD: options.costUsd }] + : []), + ...(options.trailer ?? []), + ]; + const file = path.join(dir, `${id}.jsonl`); + await fs.writeFile(file, entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n"); + const mtime = (options.mtimeMs ?? at + 500) / 1000; + await fs.utimes(file, mtime, mtime); + return { id, file }; +} + +function createAgent(options: { v2?: boolean } = {}) { + const notifications: { method: string; params: Record }[] = []; + const updates: unknown[] = []; + const client = { + sessionUpdate: async (update: unknown) => { + updates.push(update); + }, + extNotification: async (method: string, params: Record) => { + notifications.push({ method, params }); + }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }, options); + return { agent, notifications, updates }; +} + +async function indexAgent() { + const created = createAgent(); + await initializeClient(created.agent, air("sessionIndex")); + return created; +} + +/** The `custom-title` and `agent-name` records that title a session. */ +const titleRecords = (sessionId: string, title: string) => [ + { type: "custom-title", customTitle: title, sessionId }, + { type: "agent-name", agentName: title, sessionId }, +]; + +/** The last `count` records of a transcript. */ +async function lastRecords(file: string, count = 2): Promise { + const lines = (await fs.readFile(file, "utf8")).trim().split("\n"); + return lines.slice(-count).map((line) => JSON.parse(line)); +} + +/** A `rename_session` of a CLI: it writes the custom title before it + * answers (the agent name follows later, and is left out). */ +const cliRename = (file: string, sessionId: string) => + vi.fn(async (title: string) => { + await fs.appendFile( + file, + JSON.stringify({ type: "custom-title", customTitle: title, sessionId }) + "\n", + ); + }); + +/** A session loaded here whose CLI runs: `query` gets the interrupt and + * close that an archive, which stops the session, calls. */ +function runningSession( + overrides: Record, + agent: ClaudeAcpAgent, + sessionId: string, +): any { + return mockSessionState( + { + ...overrides, + query: { interrupt: vi.fn(async () => {}), close: vi.fn(), ...overrides.query }, + input: { end: vi.fn() }, + }, + agent, + sessionId, + ); +} + +const airCapabilities = (response: { _meta?: Record | null }) => + (response._meta as any)?.jetbrains?.air?.capabilities as string[] | undefined; + +describe("sessionIndex negotiation", () => { + const indexCapabilities = [ + "sessionIndex", + "sessionArchive", + "sessionRename", + "sessionListSubscribe", + ]; + const baseline = [ + "sessionFailure", + "agentFileChangeReport", + "nativeSubagentSessions", + "asyncTasks", + "customInstructions", + "recommendedValue", + "diffPatch", + "planFile", + ]; + + it("is advertised with sessionArchive and sessionRename only to an AIR client that declares it", async () => { + const declared = await createAgent().agent.initialize({ + protocolVersion: 1, + clientCapabilities: air("sessionIndex"), + }); + expect(airCapabilities(declared)).toEqual([...baseline, ...indexCapabilities]); + + const undeclared = await createAgent().agent.initialize({ + protocolVersion: 1, + clientCapabilities: air("diffPatch"), + }); + expect(airCapabilities(undeclared)).toEqual(baseline); + + // Declaring archive or rename alone enables nothing. + const withoutIndex = await createAgent().agent.initialize({ + protocolVersion: 1, + clientCapabilities: air("sessionArchive", "sessionRename"), + }); + expect(airCapabilities(withoutIndex)).toEqual(baseline); + + const nonAir = await createAgent().agent.initialize({ + protocolVersion: 1, + clientCapabilities: {}, + }); + expect(nonAir._meta).toEqual({ steering: { supported: true } }); + }); + + it("is not advertised under ACP v2", async () => { + const response = await createAgent({ v2: true }).agent.initialize({ + protocolVersion: 1, + clientCapabilities: air("sessionIndex"), + }); + for (const capability of indexCapabilities) { + expect(airCapabilities(response)).not.toContain(capability); + } + }); + + it("answers method-not-found to the new methods without the capability", async () => { + // Declaring sessionArchive or sessionRename without sessionIndex enables + // nothing either. + for (const capabilities of [[], ["sessionArchive", "sessionRename"]]) { + const { agent } = createAgent(); + await initializeClient(agent, air(...capabilities)); + const sessionId = randomUUID(); + await expect(agent.renameSessionTitle({ sessionId, title: "x" })).rejects.toMatchObject({ + code: -32601, + }); + await expect(agent.archiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 }); + await expect(agent.unarchiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 }); + } + }); +}); + +describe("session/list of a sessionIndex client", () => { + const base = Date.parse("2026-03-01T00:00:00Z"); + + it("orders by the last message time, honours the limit, and pages with a cursor", async () => { + const ids: string[] = []; + for (let i = 0; i < 5; i++) { + ids.push((await writeTranscript({ lastMessageAt: base - i * 60_000 })).id); + } + // Touched last (a rename, a metadata record) but its last message is old. + const touched = await writeTranscript({ + lastMessageAt: base - 10 * 60_000, + mtimeMs: base + 60_000, + }); + const { agent } = await indexAgent(); + + const first = await agent.listSessions({ cwd: workspace, _meta: listMeta({ limit: 3 }) }); + expect(first.sessions.map((s) => s.sessionId)).toEqual(ids.slice(0, 3)); + expect(first.sessions[0]!.updatedAt).toBe(new Date(base).toISOString()); + expect(first.nextCursor).toBeDefined(); + + const second = await agent.listSessions({ + cwd: workspace, + cursor: first.nextCursor, + _meta: listMeta({ limit: 3 }), + }); + expect(second.sessions.map((s) => s.sessionId)).toEqual([...ids.slice(3), touched.id]); + expect(second.nextCursor).toBeUndefined(); + + await expect( + agent.listSessions({ + cwd: workspace, + cursor: first.nextCursor, + _meta: listMeta({ archived: "all" }), + }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it("never returns a cursor to an empty page", async () => { + await writeTranscript({ lastMessageAt: base }); + await writeTranscript({ lastMessageAt: base - 1000 }); + // Sorted after both, but not listable. + await writeTranscript({ lastMessageAt: base - 2000, sidechain: true }); + const { agent } = await indexAgent(); + const page = await agent.listSessions({ cwd: workspace, _meta: listMeta({ limit: 2 }) }); + expect(page.sessions).toHaveLength(2); + expect(page.nextCursor).toBeUndefined(); + }); + + it("stops reading once the page is full and older transcripts cannot rank higher", async () => { + for (let i = 0; i < 60; i++) await writeTranscript({ lastMessageAt: base - i * 60_000 }); + const { agent } = await indexAgent(); + const page = await agent.listSessions({ cwd: workspace, _meta: listMeta({ limit: 5 }) }); + expect(page.sessions).toHaveLength(5); + expect(vi.mocked(getSessionInfo).mock.calls.length).toBeLessThanOrEqual(16); + // A second list reads nothing again. + vi.mocked(getSessionInfo).mockClear(); + await agent.listSessions({ cwd: workspace, _meta: listMeta({ limit: 5 }) }); + expect(getSessionInfo).not.toHaveBeenCalled(); + }); + + it("lists unarchived sessions by default, archived ones only, or all in one order", async () => { + const newest = await writeTranscript({ lastMessageAt: base }); + const archived = await writeTranscript({ lastMessageAt: base - 1000 }); + const oldest = await writeTranscript({ lastMessageAt: base - 2000 }); + const { agent } = await indexAgent(); + await agent.archiveSession({ sessionId: archived.id }); + const rows = (page: { sessions: { sessionId: string; _meta?: unknown }[] }) => + page.sessions.map((s) => [s.sessionId, (s._meta as any).jetbrains.air.archived]); + + for (const archivedParam of [undefined, null, "unarchived"]) { + const page = await agent.listSessions({ + cwd: workspace, + ...(archivedParam !== undefined && { _meta: listMeta({ archived: archivedParam }) }), + }); + expect(rows(page)).toEqual([ + [newest.id, false], + [oldest.id, false], + ]); + } + const all = await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: "all" }) }); + expect(rows(all)).toEqual([ + [newest.id, false], + [archived.id, true], + [oldest.id, false], + ]); + // Archiving did not move the session: its updatedAt is its last message. + expect(all.sessions[1]!.updatedAt).toBe(new Date(base - 1000).toISOString()); + const onlyArchived = await agent.listSessions({ + cwd: workspace, + _meta: listMeta({ archived: "archived" }), + }); + expect(rows(onlyArchived)).toEqual([[archived.id, true]]); + + // The filter applies before pagination; a cursor keeps its value. + const first = await agent.listSessions({ + cwd: workspace, + _meta: listMeta({ archived: "all", limit: 2 }), + }); + expect(first.sessions.map((s) => s.sessionId)).toEqual([newest.id, archived.id]); + const second = await agent.listSessions({ + cwd: workspace, + cursor: first.nextCursor, + _meta: listMeta({ archived: "all", limit: 2 }), + }); + expect(second.sessions.map((s) => s.sessionId)).toEqual([oldest.id]); + for (const other of [undefined, "unarchived", "archived"]) { + await expect( + agent.listSessions({ + cwd: workspace, + cursor: first.nextCursor, + _meta: listMeta({ limit: 2, ...(other && { archived: other }) }), + }), + ).rejects.toMatchObject({ code: -32602 }); + } + for (const invalid of [true, false, "only", 1]) { + await expect( + agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: invalid }) }), + ).rejects.toMatchObject({ code: -32602 }); + } + }); + + it("pages the archived sessions alone, and binds the cursor to that filter", async () => { + const ids: string[] = []; + for (let i = 0; i < 5; i++) + ids.push((await writeTranscript({ lastMessageAt: base - i * 1000 })).id); + const { agent } = await indexAgent(); + for (const id of [ids[0]!, ids[2]!, ids[4]!]) await agent.archiveSession({ sessionId: id }); + const first = await agent.listSessions({ + cwd: workspace, + _meta: listMeta({ archived: "archived", limit: 2 }), + }); + expect(first.sessions.map((s) => s.sessionId)).toEqual([ids[0], ids[2]]); + expect(first.sessions.every((s) => (s._meta as any).jetbrains.air.archived === true)).toBe( + true, + ); + const second = await agent.listSessions({ + cwd: workspace, + cursor: first.nextCursor, + _meta: listMeta({ archived: "archived", limit: 2 }), + }); + expect(second.sessions.map((s) => s.sessionId)).toEqual([ids[4]]); + expect(second.nextCursor).toBeUndefined(); + await expect( + agent.listSessions({ + cwd: workspace, + cursor: first.nextCursor, + _meta: listMeta({ archived: "all", limit: 2 }), + }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it("lists a session copied to two project directories once, from its newest copy, as the SDK", async () => { + const other = path.join(workspace, "sub"); + await fs.mkdir(other); + const larger = await writeTranscript({ + cwd: workspace, + lastMessageAt: base, + prompt: "Larger copy", + trailer: [{ type: "last-prompt", lastPrompt: "x".repeat(1000), sessionId: "" }], + }); + await writeTranscript({ + sessionId: larger.id, + cwd: other, + lastMessageAt: base + 5000, + prompt: "Newer copy", + }); + const sdk = await listSessions(); + expect(sdk.map((s) => [s.sessionId, s.cwd])).toEqual([[larger.id, other]]); + const { agent } = await indexAgent(); + const page = await agent.listSessions({ _meta: listMeta({ limit: 10 }) }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd, s.title])).toEqual([ + [larger.id, other, sdk[0]!.summary], + ]); + }); + + it("includes the sessions of existing linked worktrees on request, with their own cwd", async () => { + const repo = path.join(workspace, "repo"); + const linked = path.join(workspace, "linked"); + const gone = path.join(workspace, "gone"); + await fs.mkdir(path.join(repo, ".git", "worktrees", "linked"), { recursive: true }); + await fs.mkdir(path.join(repo, ".git", "worktrees", "gone"), { recursive: true }); + await fs.mkdir(linked); + await fs.writeFile( + path.join(repo, ".git", "worktrees", "linked", "gitdir"), + `${path.join(linked, ".git")}\n`, + ); + await fs.writeFile( + path.join(repo, ".git", "worktrees", "gone", "gitdir"), + `${path.join(gone, ".git")}\n`, + ); + await fs.writeFile( + path.join(linked, ".git"), + `gitdir: ${path.join(repo, ".git", "worktrees", "linked")}\n`, + ); + await fs.writeFile(path.join(repo, ".git", "worktrees", "linked", "commondir"), "../..\n"); + const main = await writeTranscript({ cwd: repo, lastMessageAt: base }); + const inLinked = await writeTranscript({ cwd: linked, lastMessageAt: base - 1000 }); + await writeTranscript({ cwd: gone, lastMessageAt: base - 2000 }); + const { agent } = await indexAgent(); + + // Worktrees are opt-in: without it, a cwd lists its own sessions. + expect((await agent.listSessions({ cwd: repo })).sessions.map((s) => s.sessionId)).toEqual([ + main.id, + ]); + expect((await agent.listSessions({ cwd: linked })).sessions.map((s) => s.sessionId)).toEqual([ + inLinked.id, + ]); + for (const cwd of [repo, linked]) { + const page = await agent.listSessions({ + cwd, + _meta: listMeta({ includeWorktrees: true }), + }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([ + [main.id, repo], + [inLinked.id, linked], + ]); + } + // A cursor is bound to the worktree scope. + const first = await agent.listSessions({ + cwd: repo, + _meta: listMeta({ includeWorktrees: true, limit: 1 }), + }); + expect(first.nextCursor).toBeDefined(); + await expect( + agent.listSessions({ cwd: repo, cursor: first.nextCursor, _meta: listMeta({ limit: 1 }) }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it("recovers the cwd of a session whose head has none", async () => { + // A first prompt beyond 64 KB pushes the first cwd out of the head; the + // tail cwd is a subdirectory of the project. + const sub = path.join(workspace, "pkg"); + const bigPrompt = await writeTranscript({ + recordCwd: null, + hugePrompt: 70_000, + lastMessageAt: base, + trailer: [ + { type: "attachment", cwd: sub, sessionId: "x" }, + { type: "last-prompt", lastPrompt: "Big paste", sessionId: "x" }, + ], + }); + const noCwd = await writeTranscript({ recordCwd: null, lastMessageAt: base - 1000 }); + const { agent } = await indexAgent(); + + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([ + [bigPrompt.id, workspace], + [noCwd.id, workspace], + ]); + // Without a requested cwd, a sibling of the same directory supplies it. + const all = await agent.listSessions({}); + expect(all.sessions.map((s) => s.cwd)).toEqual([workspace, workspace]); + }); + + it("recovers a cwd from a sibling that the archive filter leaves out", async () => { + const other = path.join(workspace, "other"); + const withCwd = await writeTranscript({ cwd: other, lastMessageAt: base }); + const noCwd = await writeTranscript({ + cwd: other, + recordCwd: null, + lastMessageAt: base - 1000, + }); + const rows = (page: { sessions: { sessionId: string; cwd: string }[] }) => + page.sessions.map((s) => [s.sessionId, s.cwd]); + + // The cwd-less session is archived, its sibling is not. + const archiving = await indexAgent(); + await archiving.agent.archiveSession({ sessionId: noCwd.id }); + const archivedOnly = await ( + await indexAgent() + ).agent.listSessions({ _meta: listMeta({ archived: "archived" }) }); + expect(rows(archivedOnly)).toEqual([[noCwd.id, other]]); + + // And the reverse: the sibling with the cwd is archived. + await archiving.agent.unarchiveSession({ sessionId: noCwd.id }); + await archiving.agent.archiveSession({ sessionId: withCwd.id }); + const unarchivedOnly = await (await indexAgent()).agent.listSessions({}); + expect(rows(unarchivedOnly)).toEqual([[noCwd.id, other]]); + }); + + it("reports the row fields of the RFDs, flat", async () => { + const parent = randomUUID(); + // A cost record in a transcript is not read: a row has a cost only from + // the SDK of a session that runs here. + const session = await writeTranscript({ + lastMessageAt: base, + costUsd: 1.25, + model: "claude-opus-5-5", + forkedFrom: parent, + }); + await writeTranscript({ lastMessageAt: base - 1000 }); + const { agent } = await indexAgent(); + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions[0]).toEqual({ + sessionId: session.id, + cwd: workspace, + title: "Fix it", + updatedAt: new Date(base).toISOString(), + _meta: { + jetbrains: { + air: { + version: 1, + archived: false, + // The prompt is the first record, a second before the answer. + lastPromptAt: new Date(base - 1000).toISOString(), + model: "claude-opus-5-5", + forkedFrom: parent, + state: "idle", + lastTurnEndedAt: new Date(base).toISOString(), + }, + }, + }, + }); + const other = (page.sessions[1]!._meta as any).jetbrains.air; + for (const omitted of ["cost", "model", "forkedFrom", "activity", "usage"]) { + expect(other).not.toHaveProperty(omitted); + } + }); + + it("reports the live state and cost of a session this connection runs", async () => { + const session = await writeTranscript({ lastMessageAt: base, costUsd: 1 }); + const { agent } = await indexAgent(); + agent.sessions[session.id] = mockSessionState( + { lastSessionState: "running", lastTotalCostUsd: 3 }, + agent, + session.id, + ) as any; + const page = await agent.listSessions({ cwd: workspace }); + const meta = (page.sessions[0]!._meta as any).jetbrains.air; + expect(meta.state).toBe("running"); + expect(meta.cost).toEqual({ amount: 3, currency: "USD" }); + }); + + it("has no cost unless the SDK gave one for a session that runs here", async () => { + const running = await writeTranscript({ lastMessageAt: base, costUsd: 4 }); + const closed = await writeTranscript({ lastMessageAt: base - 1000, costUsd: 5 }); + const { agent } = await indexAgent(); + // Running, but no SDK result yet. + agent.sessions[running.id] = mockSessionState( + { lastSessionState: "running" }, + agent, + running.id, + ) as any; + // An SDK result, but the query closed: the session no longer runs here. + agent.sessions[closed.id] = mockSessionState( + { lastSessionState: "idle", lastTotalCostUsd: 2, queryClosed: true }, + agent, + closed.id, + ) as any; + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions.map((s) => s.sessionId)).toEqual([running.id, closed.id]); + for (const row of page.sessions) { + expect((row._meta as any).jetbrains.air).not.toHaveProperty("cost"); + } + // Its first SDK result gives the row a cost. + agent.sessions[running.id]!.lastTotalCostUsd = 0.5; + const after = await agent.listSessions({ cwd: workspace }); + expect((after.sessions[0]!._meta as any).jetbrains.air.cost).toEqual({ + amount: 0.5, + currency: "USD", + }); + }); +}); + +describe("old session/list path", () => { + it("calls the SDK exactly as before for a client without sessionIndex", async () => { + const session = await writeTranscript({}); + + const plain = createAgent().agent; + await initializeClient(plain, {}); + const page = await plain.listSessions({ cwd: workspace }); + expect(page.sessions.map((s) => s.sessionId)).toEqual([session.id]); + expect(page.sessions[0]).not.toHaveProperty("_meta"); + expect(vi.mocked(listSessions).mock.calls).toEqual([ + [{ dir: workspace, limit: 1001, offset: 0 }], + ]); + + // AIR without sessionIndex: the same SDK call. + vi.mocked(listSessions).mockClear(); + const airAgent = createAgent().agent; + await initializeClient(airAgent, air()); + expect( + (await airAgent.listSessions({ cwd: workspace })).sessions.map((s) => s.sessionId), + ).toEqual([session.id]); + expect(vi.mocked(listSessions).mock.calls).toEqual([ + [{ dir: workspace, limit: 1001, offset: 0 }], + ]); + }); + + it("hides a session with an archived title from AIR without sessionIndex only", async () => { + const archived = await writeTranscript({ + lastMessageAt: Date.parse("2026-01-02T00:00:00Z"), + trailer: [{ type: "custom-title", customTitle: "[archived] Done", sessionId: "" }], + }); + const open = await writeTranscript({}); + const airAgent = createAgent().agent; + await initializeClient(airAgent, air()); + expect( + (await airAgent.listSessions({ cwd: workspace })).sessions.map((s) => s.sessionId), + ).toEqual([open.id]); + const plain = createAgent().agent; + await initializeClient(plain, {}); + expect( + (await plain.listSessions({ cwd: workspace })).sessions.map((s) => [s.sessionId, s.title]), + ).toEqual([ + [archived.id, "[archived] Done"], + [open.id, "Fix it"], + ]); + }); +}); + +describe("_session/rename", () => { + it("renames a session that is not loaded: title records and the sidecar", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + await agent.renameSessionTitle({ sessionId: session.id, title: "New name" }); + + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "New name")); + const sidecar = path.join(path.dirname(session.file), session.id, "custom-title.json"); + expect(JSON.parse(await fs.readFile(sidecar, "utf8"))).toEqual({ customTitle: "New name" }); + expect((await fs.stat(sidecar)).mode & 0o777).toBe(0o600); + expect((await fs.stat(path.dirname(sidecar))).mode & 0o777).toBe(0o700); + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions[0]!.title).toBe("New name"); + }); + + it("refuses an unknown session", async () => { + const { agent } = await indexAgent(); + await expect( + agent.renameSessionTitle({ sessionId: randomUUID(), title: "x" }), + ).rejects.toMatchObject({ code: -32002 }); + }); + + it("renames a session that another process holds, as the CLI does", async () => { + const session = await writeTranscript({}); + await registerHolder(process.pid, session.id); + const { agent } = await indexAgent(); + await agent.renameSessionTitle({ sessionId: session.id, title: "Held" }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "Held")); + }); + + it("renames a session loaded here through its CLI and publishes the title", async () => { + const session = await writeTranscript({}); + const { agent, updates } = await indexAgent(); + const rename = vi.fn(async () => {}); + agent.sessions[session.id] = mockSessionState( + { cwd: workspace, query: { renameSession: rename } }, + agent, + session.id, + ) as any; + const before = await fs.readFile(session.file, "utf8"); + await agent.renameSessionTitle({ sessionId: session.id, title: "Live title" }); + expect(rename).toHaveBeenCalledWith("Live title", session.id); + expect(await fs.readFile(session.file, "utf8")).toBe(before); + expect(updates).toContainEqual({ + sessionId: session.id, + update: { sessionUpdate: "session_info_update", title: "Live title" }, + }); + }); + + it("waits for a title generation in flight, and the generated title never wins", async () => { + const order: string[] = []; + let finishGeneration!: (title: string) => void; + const updates: any[] = []; + const agent: any = { + client: { sessionUpdate: async (update: unknown) => updates.push(update) }, + logger: { error: () => {} }, + sessions: {}, + }; + const titles = new SessionTitles(agent, "s1"); + const session: any = { + queryClosed: false, + cancelled: false, + cwd: "/nowhere", + query: { + generateSessionTitle: () => + new Promise((resolve) => { + finishGeneration = (title) => { + order.push("generated"); + resolve(title); + }; + }), + }, + }; + agent.sessions.s1 = session; + titles.onPrompt([{ type: "text", text: "Please refactor the parser" }]); + vi.mocked(getSessionInfo).mockResolvedValueOnce(undefined); + await titles.onTurnEnd(session); + + const renamed = titles.setExplicitTitle("Mine", async () => { + order.push("renamed"); + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(order).toEqual([]); + finishGeneration("Generated"); + await renamed; + expect(order).toEqual(["generated", "renamed"]); + const titlesSent = updates.map((update) => update.update.title); + expect(titlesSent).toEqual(["Mine"]); + }); +}); + +describe("_session/archive and _session/unarchive", () => { + it("append AIR's archive title records, are idempotent and keep updatedAt", async () => { + const session = await writeTranscript({ prompt: "Fix the\nparser" }); + const { agent } = await indexAgent(); + const [before] = (await agent.listSessions({ cwd: workspace })).sessions; + const read = () => fs.readFile(session.file, "utf8"); + + await agent.archiveSession({ sessionId: session.id }); + expect(await lastRecords(session.file)).toEqual( + titleRecords(session.id, "[archived] Fix the parser"), + ); + const archived = await read(); + await agent.archiveSession({ sessionId: session.id }); + expect(await read()).toBe(archived); + const [row] = ( + await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: "archived" }) }) + ).sessions; + expect(row).toMatchObject({ title: "Fix the parser", updatedAt: before!.updatedAt }); + expect((row!._meta as any).jetbrains.air.archived).toBe(true); + // AIR writes no sidecar for an archive. + expect(fsSync.existsSync(path.join(path.dirname(session.file), session.id))).toBe(false); + + await agent.unarchiveSession({ sessionId: session.id }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "Fix the parser")); + const unarchived = await read(); + await agent.unarchiveSession({ sessionId: session.id }); + expect(await read()).toBe(unarchived); + expect((await agent.listSessions({ cwd: workspace })).sessions).toEqual([ + { ...before, title: "Fix the parser" }, + ]); + }); + + it("read a session archived by AIR, and rename it with the prefix kept", async () => { + const session = await writeTranscript({ + trailer: [ + // An older explicit name, which the later agent name outranks. + { type: "custom-title", customTitle: "Old", sessionId: randomUUID() }, + ...titleRecords("ignored", "[archived] Done work").map((record) => ({ + ...record, + sessionId: undefined, + })), + ], + }); + const { agent } = await indexAgent(); + const page = (archived: string) => + agent.listSessions({ cwd: workspace, _meta: listMeta({ archived }) }); + expect((await page("unarchived")).sessions).toEqual([]); + expect((await page("archived")).sessions.map((s) => s.title)).toEqual(["Done work"]); + + await agent.renameSessionTitle({ sessionId: session.id, title: "Renamed" }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "[archived] Renamed")); + expect( + JSON.parse( + await fs.readFile( + path.join(path.dirname(session.file), session.id, "custom-title.json"), + "utf8", + ), + ), + ).toEqual({ customTitle: "[archived] Renamed" }); + expect((await page("archived")).sessions.map((s) => s.title)).toEqual(["Renamed"]); + }); + + it("read the archive state from the agent name first, else the custom title, as AIR does", async () => { + // The agent name has the prefix, the custom title not: archived. + const byAgentName = await writeTranscript({ + lastMessageAt: Date.parse("2026-01-03T00:00:00Z"), + trailer: [ + { type: "custom-title", customTitle: "Open", sessionId: "" }, + { type: "agent-name", agentName: "[archived] Open", sessionId: "" }, + ], + }); + // The custom title has the prefix, the agent name not: not archived, + // whichever record comes last. + const byTitle = await writeTranscript({ + lastMessageAt: Date.parse("2026-01-02T00:00:00Z"), + trailer: [ + { type: "agent-name", agentName: "Named", sessionId: "" }, + { type: "custom-title", customTitle: "[archived] Named", sessionId: "" }, + ], + }); + // No agent name: the custom title decides. + const byCustomTitle = await writeTranscript({ + lastMessageAt: Date.parse("2026-01-01T00:00:00Z"), + trailer: [{ type: "custom-title", customTitle: "[archived] Done", sessionId: "" }], + }); + // A prompt is no name: it never archives a session. + const byPrompt = await writeTranscript({ + lastMessageAt: Date.parse("2025-12-31T00:00:00Z"), + prompt: "[archived] Looks done", + }); + const { agent } = await indexAgent(); + const rows = async (archived: string) => + (await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived }) })).sessions.map( + (s) => [s.sessionId, s.title, airRow(s).archived], + ); + expect(await rows("archived")).toEqual([ + [byAgentName.id, "Open", true], + [byCustomTitle.id, "Done", true], + ]); + expect(await rows("unarchived")).toEqual([ + [byTitle.id, "Named", false], + [byPrompt.id, "[archived] Looks done", false], + ]); + // Archive and unarchive take the title and the state as listed, and + // write both records. + await agent.archiveSession({ sessionId: byTitle.id }); + expect(await lastRecords(byTitle.file)).toEqual(titleRecords(byTitle.id, "[archived] Named")); + await agent.archiveSession({ sessionId: byPrompt.id }); + expect(await lastRecords(byPrompt.file)).toEqual( + titleRecords(byPrompt.id, "[archived] Looks done"), + ); + // A rename of a session archived by its agent name keeps the prefix. + await agent.renameSessionTitle({ sessionId: byAgentName.id, title: "Renamed" }); + expect(await lastRecords(byAgentName.file)).toEqual( + titleRecords(byAgentName.id, "[archived] Renamed"), + ); + await agent.unarchiveSession({ sessionId: byAgentName.id }); + expect(await lastRecords(byAgentName.file)).toEqual(titleRecords(byAgentName.id, "Renamed")); + await agent.unarchiveSession({ sessionId: byCustomTitle.id }); + expect(await lastRecords(byCustomTitle.file)).toEqual(titleRecords(byCustomTitle.id, "Done")); + expect(await rows("archived")).toEqual([ + [byTitle.id, "Named", true], + [byPrompt.id, "Looks done", true], + ]); + }); + + it("cut the archived title to the CLI's 200 characters", async () => { + const long = "t".repeat(195); + const session = await writeTranscript({ + trailer: [{ type: "custom-title", customTitle: long, sessionId: "" }], + }); + const { agent } = await indexAgent(); + await agent.archiveSession({ sessionId: session.id }); + const [record] = (await lastRecords(session.file)) as { customTitle: string }[]; + expect(record!.customTitle).toBe(`[archived] ${long}`.slice(0, 200)); + }); + + it("ignore a legacy archive marker file", async () => { + const session = await writeTranscript({}); + const marker = path.join(configDir, "acp", "archived", session.id); + await fs.mkdir(path.dirname(marker), { recursive: true }); + await fs.writeFile(marker, ""); + const { agent } = await indexAgent(); + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions.map((s) => s.sessionId)).toEqual([session.id]); + expect((page.sessions[0]!._meta as any).jetbrains.air.archived).toBe(false); + await agent.archiveSession({ sessionId: session.id }); + expect(fsSync.existsSync(marker)).toBe(true); + }); + + it("refuses an unknown session", async () => { + const { agent } = await indexAgent(); + await expect(agent.archiveSession({ sessionId: randomUUID() })).rejects.toMatchObject({ + code: -32002, + }); + await expect(agent.unarchiveSession({ sessionId: "not-a-uuid" })).rejects.toMatchObject({ + code: -32002, + }); + }); +}); + +describe("session/delete per client", () => { + it("deletes the transcript for a sessionIndex client", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + await agent.archiveSession({ sessionId: session.id }); + await agent.deleteSession({ sessionId: session.id }); + expect(fsSync.existsSync(session.file)).toBe(false); + await expect(agent.deleteSession({ sessionId: session.id })).rejects.toMatchObject({ + code: -32002, + }); + }); + + it("deletes a session that another process holds, as the CLI does", async () => { + const session = await writeTranscript({}); + await registerHolder(process.pid, session.id); + const { agent } = await indexAgent(); + await agent.deleteSession({ sessionId: session.id }); + expect(fsSync.existsSync(session.file)).toBe(false); + }); + + it("archives instead of deleting for an AIR client without sessionIndex", async () => { + const session = await writeTranscript({}); + const { agent } = createAgent(); + await initializeClient(agent, air()); + await agent.deleteSession({ sessionId: session.id }); + expect(deleteSession).not.toHaveBeenCalled(); + expect(fsSync.existsSync(session.file)).toBe(true); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "[archived] Fix it")); + expect((await agent.listSessions({ cwd: workspace })).sessions).toEqual([]); + }); + + it("archives for an AIR client without sessionIndex a session held elsewhere", async () => { + const session = await writeTranscript({}); + await registerHolder(process.pid, session.id); + const { agent } = createAgent(); + await initializeClient(agent, air()); + await agent.deleteSession({ sessionId: session.id }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "[archived] Fix it")); + }); + + it("deletes with the SDK for a client that is not AIR", async () => { + const session = await writeTranscript({}); + const { agent } = createAgent(); + await initializeClient(agent, {}); + await agent.deleteSession({ sessionId: session.id }); + expect(deleteSession).toHaveBeenCalledWith(session.id); + expect(fsSync.existsSync(session.file)).toBe(false); + expect(fsSync.existsSync(path.join(configDir, "acp"))).toBe(false); + }); +}); + +/** Registers `pid` as a live CLI holding `sessionId`. */ +async function registerHolder(pid: number, sessionId: string, extra: object = {}) { + await fs.mkdir(path.join(configDir, "sessions"), { recursive: true }); + await fs.writeFile( + path.join(configDir, "sessions", `${pid}.json`), + JSON.stringify({ pid, sessionId, updatedAt: Date.now(), ...extra }), + ); +} + +/** Lets the watchers of a subscription settle. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 300)); + +async function waitFor(predicate: () => boolean, timeoutMs = 8000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) return false; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return true; +} + +/** An interactive CLI record whose status is newer than any transcript. */ +const liveCli = (status: string) => ({ + kind: "interactive", + entrypoint: "cli", + status, + statusUpdatedAt: Date.now() + 60_000, +}); + +function promptRecord(sessionId: string, text: string, at = Date.now()): string { + return JSON.stringify({ + type: "user", + sessionId, + cwd: workspace, + uuid: randomUUID(), + timestamp: new Date(at).toISOString(), + message: { role: "user", content: text }, + }); +} + +function assistantRecord(sessionId: string, stopReason: string | null, at = Date.now()): string { + return JSON.stringify({ + type: "assistant", + sessionId, + cwd: workspace, + uuid: randomUUID(), + timestamp: new Date(at).toISOString(), + message: { role: "assistant", content: [{ type: "text", text: "z" }], stop_reason: stopReason }, + }); +} + +const airRow = (info: { _meta?: Record | null }) => + (info._meta as any)?.jetbrains?.air as Record; + +describe("_session/list/subscribe", () => { + const changesOf = (notifications: { method: string; params: Record }[]) => + notifications + .filter((n) => n.method === "_session/list/changes") + .map((n) => n.params as unknown as ListChanges); + + async function subscribedAgent(cwd = workspace) { + const created = await indexAgent(); + const { subscriptionId } = await created.agent.subscribeSessionList({ cwd }); + await settle(); + const changes = () => changesOf(created.notifications); + const rowsOf = (sessionId: string) => + changes().flatMap((change) => change.sessions.filter((s) => s.sessionId === sessionId)); + return { ...created, subscriptionId, changes, rowsOf }; + } + + const handlesOf = (agent: ClaudeAcpAgent) => + (agent as any).sessionIndex.service.subscriptions as ListSubscriptions | undefined; + + it("takes an absolute cwd and ignores other parameters", () => { + for (const params of [{}, { cwd: 1 }, { cwd: "relative/dir" }, { cwd: "" }, null]) { + expect(() => parseListSubscribeRequest(params)).toThrow( + expect.objectContaining({ code: -32602 }), + ); + } + expect(parseListSubscribeRequest({ cwd: "/repo", archived: "all", limit: 3 })).toEqual({ + cwd: "/repo", + }); + expect(() => parseListUnsubscribeRequest({})).toThrow( + expect.objectContaining({ code: -32602 }), + ); + }); + + it("answers method-not-found without sessionIndex", async () => { + const { agent } = createAgent(); + await initializeClient(agent, air()); + await expect(agent.subscribeSessionList({ cwd: workspace })).rejects.toMatchObject({ + code: -32601, + }); + await expect(agent.unsubscribeSessionList({ subscriptionId: "x" })).rejects.toMatchObject({ + code: -32601, + }); + }); + + it("refuses the 129th subscription of a connection", async () => { + const { agent } = await indexAgent(); + const ids: string[] = []; + for (let i = 0; i < MAX_SUBSCRIPTIONS; i++) { + ids.push((await agent.subscribeSessionList({ cwd: workspace })).subscriptionId); + } + expect(new Set(ids).size).toBe(MAX_SUBSCRIPTIONS); + await expect(agent.subscribeSessionList({ cwd: workspace })).rejects.toMatchObject({ + code: -32602, + data: { reason: "too_many_subscriptions" }, + }); + expect(await agent.unsubscribeSessionList({ subscriptionId: ids[0]! })).toEqual({}); + // Idempotent, and an unknown id is no error. + expect(await agent.unsubscribeSessionList({ subscriptionId: ids[0]! })).toEqual({}); + expect(await agent.unsubscribeSessionList({ subscriptionId: "unknown" })).toEqual({}); + await agent.subscribeSessionList({ cwd: workspace }); + // One watch of the cwd for all of them. + expect(handlesOf(agent)!.openHandles().watches).toBe(1); + await agent.dispose(); + }); + + it("sends the archive state by the agent name first, else the custom title", async () => { + const { rowsOf, agent } = await subscribedAgent(); + const byAgentName = await writeTranscript({ + trailer: [ + { type: "custom-title", customTitle: "Open", sessionId: "" }, + { type: "agent-name", agentName: "[archived] Open", sessionId: "" }, + ], + }); + const byTitle = await writeTranscript({ + trailer: [ + { type: "agent-name", agentName: "Named", sessionId: "" }, + { type: "custom-title", customTitle: "[archived] Named", sessionId: "" }, + ], + }); + const byCustomTitle = await writeTranscript({ + trailer: [{ type: "custom-title", customTitle: "[archived] Done", sessionId: "" }], + }); + const ids = [byAgentName.id, byTitle.id, byCustomTitle.id]; + expect(await waitFor(() => ids.every((id) => rowsOf(id).length > 0))).toBe(true); + const last = (id: string) => rowsOf(id).at(-1)!; + expect(ids.map((id) => [last(id).title, airRow(last(id)).archived])).toEqual([ + ["Open", true], + ["Named", false], + ["Done", true], + ]); + await agent.dispose(); + }); + + it("sends a created session, a prompt and a turn end, but not updatedAt alone", async () => { + const { rowsOf, changes, subscriptionId, agent } = await subscribedAgent(); + const session = await writeTranscript({ prompt: "First", lastMessageAt: Date.now() - 5000 }); + expect(await waitFor(() => rowsOf(session.id).length === 1)).toBe(true); + expect(changes()[0]).toMatchObject({ subscriptionId, removed: [] }); + expect(rowsOf(session.id)[0]).toMatchObject({ title: "First", cwd: workspace }); + expect(airRow(rowsOf(session.id)[0]!)).toMatchObject({ archived: false, state: "idle" }); + + const promptAt = Date.now() - 1000; + await fs.appendFile(session.file, promptRecord(session.id, "Second", promptAt) + "\n"); + expect(await waitFor(() => rowsOf(session.id).length === 2)).toBe(true); + expect(airRow(rowsOf(session.id)[1]!).lastPromptAt).toBe(new Date(promptAt).toISOString()); + + const endedAt = Date.now() - 500; + await fs.appendFile(session.file, assistantRecord(session.id, "end_turn", endedAt) + "\n"); + expect(await waitFor(() => rowsOf(session.id).length === 3)).toBe(true); + expect(airRow(rowsOf(session.id)[2]!).lastTurnEndedAt).toBe(new Date(endedAt).toISOString()); + + // A message that changes nothing but updatedAt. + await fs.appendFile(session.file, assistantRecord(session.id, null) + "\n"); + await new Promise((resolve) => setTimeout(resolve, 2500)); + expect(rowsOf(session.id)).toHaveLength(3); + await agent.dispose(); + }, 60_000); + + it("sends a state flip of the registry, a rename, archive, unarchive and a delete", async () => { + const session = await writeTranscript({ prompt: "Old title" }); + const { rowsOf, changes, agent } = await subscribedAgent(); + // The last row sent satisfies `check` (the watch may also report the + // transcript's creation, just before subscribe, as its first change). + const lastIs = (check: (row: SessionInfo) => boolean) => + waitFor(() => { + const row = rowsOf(session.id).at(-1); + return row !== undefined && check(row); + }); + + await registerHolder(process.pid, session.id, liveCli("busy")); + expect(await lastIs((row) => airRow(row).state === "running")).toBe(true); + await registerHolder(process.pid, session.id, liveCli("waiting")); + expect(await lastIs((row) => airRow(row).state === "requires_action")).toBe(true); + await fs.rm(path.join(configDir, "sessions", `${process.pid}.json`)); + expect(await lastIs((row) => airRow(row).state === "idle")).toBe(true); + + await agent.renameSessionTitle({ sessionId: session.id, title: "New title" }); + expect(await lastIs((row) => row.title === "New title")).toBe(true); + + await agent.archiveSession({ sessionId: session.id }); + expect(await lastIs((row) => airRow(row).archived === true)).toBe(true); + await agent.unarchiveSession({ sessionId: session.id }); + expect(await lastIs((row) => airRow(row).archived === false)).toBe(true); + + await agent.deleteSession({ sessionId: session.id }); + expect(await waitFor(() => changes().some((c) => c.removed.includes(session.id)))).toBe(true); + await agent.dispose(); + }, 60_000); + + it("sends the error state of a turn that ended with an API error, and clears it", async () => { + const session = await writeTranscript({}); + const { rowsOf, agent } = await subscribedAgent(); + await fs.appendFile( + session.file, + JSON.stringify({ + type: "assistant", + sessionId: session.id, + cwd: workspace, + uuid: randomUUID(), + timestamp: new Date().toISOString(), + isApiErrorMessage: true, + message: { role: "assistant", content: [{ type: "text", text: "API Error" }] }, + }) + "\n", + ); + const state = (value: string) => () => + rowsOf(session.id).some((row) => airRow(row).state === value); + expect(await waitFor(state("error"))).toBe(true); + const listed = await agent.listSessions({ cwd: workspace }); + expect(airRow(listed.sessions[0]!).state).toBe("error"); + await fs.appendFile(session.file, promptRecord(session.id, "Try again") + "\n"); + expect(await waitFor(() => airRow(rowsOf(session.id).at(-1)!).state === "idle")).toBe(true); + await agent.dispose(); + }); + + it("keeps the error of a query that failed here after it closes", async () => { + const session = await writeTranscript({}); + const { rowsOf, agent } = await subscribedAgent(); + const loaded = mockSessionState({}, agent, session.id) as any; + loaded.lastSessionState = "running"; + loaded.input = { end: () => {} }; + loaded.query = { close: () => {} }; + agent.sessions[session.id] = loaded; + (agent as any).sessionIndex.onQueryFailed(session.id); + (agent as any).closeQueryStream(loaded); + const last = () => rowsOf(session.id).at(-1); + expect(await waitFor(() => last() !== undefined && airRow(last()!).state === "error")).toBe( + true, + ); + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(airRow(last()!).state).toBe("error"); + const listed = await agent.listSessions({ cwd: workspace }); + expect(airRow(listed.sessions[0]!).state).toBe("error"); + // Loaded again: still the failure until a turn starts. + const reloaded = mockSessionState({}, agent, session.id) as any; + reloaded.lastSessionState = "idle"; + agent.sessions[session.id] = reloaded; + const relisted = async () => + airRow((await agent.listSessions({ cwd: workspace })).sessions[0]!).state; + expect(await relisted()).toBe("error"); + (agent as any).sessionIndex.onTurnStarted(session.id); + expect(await relisted()).toBe("idle"); + delete agent.sessions[session.id]; + await agent.dispose(); + }); + + it("clears the failed turn of a session that runs here when a new turn starts", async () => { + const { noteSessionState } = await import("../session-index/connection.js"); + const session = { lastTurnFailed: true } as any; + noteSessionState(session, "idle", "running"); + expect(session.lastTurnFailed).toBe(false); + }); + + it("sends the SDK state of a session this connection runs at once, and its close", async () => { + const session = await writeTranscript({}); + const { rowsOf, agent } = await subscribedAgent(); + // Past the interval of a row sent for the creation of the transcript. + await new Promise((resolve) => setTimeout(resolve, 1100)); + const loaded = mockSessionState({}, agent, session.id) as any; + loaded.lastSessionState = "running"; + agent.sessions[session.id] = loaded; + const started = Date.now(); + (agent as any).sessionIndex.onOwnSessionChanged(session.id); + const running = () => rowsOf(session.id).some((row) => airRow(row).state === "running"); + expect(await waitFor(running)).toBe(true); + expect(Date.now() - started).toBeLessThan(150); + // The query closes: the row shows the transcript's state again. + await new Promise((resolve) => setTimeout(resolve, 1100)); + loaded.input = { end: () => {} }; + loaded.query = { close: () => {} }; + (agent as any).closeQueryStream(loaded); + const idle = () => rowsOf(session.id).some((row) => airRow(row).state === "idle"); + expect(await waitFor(idle)).toBe(true); + delete agent.sessions[session.id]; + await agent.dispose(); + }); + + it("coalesces the changes of a session to one a second", async () => { + const session = await writeTranscript({}); + const service = new SessionIndexService({ logError: () => {} }); + const sent: { at: number; changes: ListChanges }[] = []; + const subscriptions = new ListSubscriptions({ + index: service.index, + registry: service.registry, + own: () => undefined, + notify: async (changes) => { + sent.push({ at: Date.now(), changes }); + }, + logError: () => {}, + debounceMs: 30, + maxWaitMs: 100, + minSessionIntervalMs: 500, + }); + await subscriptions.subscribe(workspace); + await settle(); + // A new prompt every 40 ms for 1.6 s: each one changes lastPromptAt. + const start = Date.now(); + let lastAt = 0; + while (Date.now() - start < 1600) { + lastAt = Date.now() - 100_000; + await fs.appendFile(session.file, promptRecord(session.id, "again", lastAt) + "\n"); + await new Promise((resolve) => setTimeout(resolve, 40)); + } + await new Promise((resolve) => setTimeout(resolve, 800)); + const times = sent + .filter(({ changes }) => changes.sessions.some((s) => s.sessionId === session.id)) + .map(({ at }) => at); + expect(times.length).toBeGreaterThanOrEqual(3); + expect(times.length).toBeLessThanOrEqual(5); + for (let i = 1; i < times.length; i++) { + expect(times[i]! - times[i - 1]!).toBeGreaterThanOrEqual(480); + } + // The last sent row is the last prompt. + const lastRow = sent.at(-1)!.changes.sessions.at(-1)!; + expect(airRow(lastRow).lastPromptAt).toBe(new Date(lastAt).toISOString()); + subscriptions.dispose(); + }, 10_000); + + it("shares one watcher between two subscriptions of a cwd, each notified", async () => { + const { agent, notifications } = await indexAgent(); + const handles = () => handlesOf(agent)!.openHandles(); + const first = await agent.subscribeSessionList({ cwd: workspace }); + const session = await writeTranscript({}); + await settle(); + const one = handles(); + const second = await agent.subscribeSessionList({ cwd: workspace }); + expect(handles()).toMatchObject({ watches: 1, watchers: one.watchers }); + const notifiedIds = () => + changesOf(notifications) + .filter((c) => c.sessions.length > 0) + .map((c) => c.subscriptionId); + + await fs.appendFile(session.file, promptRecord(session.id, "Again") + "\n"); + expect( + await waitFor( + () => + notifiedIds().includes(first.subscriptionId) && + notifiedIds().includes(second.subscriptionId), + ), + ).toBe(true); + + await agent.unsubscribeSessionList(first); + expect(handles()).toMatchObject({ watches: 1, watchers: one.watchers }); + const before = notifiedIds().length; + await new Promise((resolve) => setTimeout(resolve, 1100)); + await fs.appendFile(session.file, promptRecord(session.id, "Once more") + "\n"); + expect(await waitFor(() => notifiedIds().length > before)).toBe(true); + expect(notifiedIds().slice(before)).toEqual([second.subscriptionId]); + + await agent.unsubscribeSessionList(second); + expect(handles()).toEqual({ watches: 0, watchers: 0, timers: 0 }); + await agent.dispose(); + }, 10_000); + + it("leaves no watcher or timer after the connection closes", async () => { + const session = await writeTranscript({}); + const { agent } = await subscribedAgent(); + const subscriptions = handlesOf(agent)!; + expect(subscriptions.openHandles().watchers).toBeGreaterThan(0); + // A change in flight when the connection closes. + await fs.appendFile(session.file, promptRecord(session.id, "Late") + "\n"); + await agent.dispose(); + expect(subscriptions.openHandles()).toEqual({ watches: 0, watchers: 0, timers: 0 }); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(subscriptions.openHandles()).toEqual({ watches: 0, watchers: 0, timers: 0 }); + }); + + it("covers the same subdirectory of every linked worktree", async () => { + const repo = path.join(workspace, "repo"); + const linked = path.join(workspace, "linked"); + const meta = path.join(repo, ".git", "worktrees", "linked"); + await fs.mkdir(meta, { recursive: true }); + await fs.mkdir(linked, { recursive: true }); + await fs.writeFile(path.join(meta, "gitdir"), `${path.join(linked, ".git")}\n`); + await fs.writeFile(path.join(linked, ".git"), `gitdir: ${meta}\n`); + await fs.writeFile(path.join(meta, "commondir"), "../..\n"); + // The worktree's project directory exists before the subscription. + await writeTranscript({ cwd: linked }); + const { rowsOf, changes, agent } = await subscribedAgent(repo); + const inLinked = await writeTranscript({ cwd: linked }); + const inRepo = await writeTranscript({ cwd: repo }); + const elsewhere = await writeTranscript({ cwd: workspace }); + expect( + await waitFor(() => rowsOf(inLinked.id).length === 1 && rowsOf(inRepo.id).length === 1), + ).toBe(true); + expect(rowsOf(inLinked.id)[0]!.cwd).toBe(linked); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(changes().flatMap((c) => c.sessions.map((s) => s.sessionId))).not.toContain( + elsewhere.id, + ); + await agent.dispose(); + }); + + /** A subscription manager over a real index whose first read of rows is slow. */ + function slowFirstRows( + delayMs: number, + options: { + minSessionIntervalMs?: number; + rescanMs?: number; + failFirstRead?: boolean; + now?: () => number; + } = {}, + ) { + const service = new SessionIndexService({ logError: () => {} }); + const sent: { at: number; changes: ListChanges }[] = []; + const rowsRead: string[][] = []; + let slow = true; + let failing = false; + const index = { + listedPaths: service.index.listedPaths.bind(service.index), + projectDirs: service.index.projectDirs.bind(service.index), + enumerateFiles: service.index.enumerateFiles.bind(service.index), + continuedIn: service.index.continuedIn.bind(service.index), + cachedRowsOf: service.index.cachedRowsOf.bind(service.index), + isRead: (candidate: Parameters[0]) => + failing ? false : service.index.isRead(candidate), + rowsOf: async (...args: Parameters) => { + rowsRead.push(args[1].map((candidate) => candidate.sessionId)); + if (slow) { + slow = false; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + // A first read that fails: no row, and nothing cached. + if (options.failFirstRead) { + failing = true; + setTimeout(() => (failing = false), 100); + return []; + } + } + return service.index.rowsOf(...args); + }, + }; + const subscriptions = new ListSubscriptions({ + index, + registry: service.registry, + own: () => undefined, + notify: async (changes) => { + sent.push({ at: Date.now(), changes }); + }, + logError: () => {}, + minSessionIntervalMs: options.minSessionIntervalMs, + now: options.now, + rescanMs: options.rescanMs, + }); + /** What a client reads with session/list before it subscribes. */ + const list = (cwd = workspace) => service.index.list({ cwd, limit: 50, archived: "all" }); + return { subscriptions, sent, rowsRead, list, service }; + } + + it("hides a listed session continued in a successor that got history during a read", async () => { + const successorId = randomUUID(); + const predecessor = await writeTranscript({ + trailer: [{ type: "continued-in", continuedInSessionId: successorId }], + }); + const stub = await writeTranscript({ sessionId: successorId }); + const { subscriptions, sent, list } = slowFirstRows(400); + await list(); + await subscriptions.subscribe(workspace); + await fs.appendFile( + stub.file, + JSON.stringify({ type: "user", parentUuid: null, sessionId: successorId, cwd: workspace }) + + "\n", + ); + expect( + await waitFor(() => sent.some(({ changes }) => changes.removed.includes(predecessor.id))), + ).toBe(true); + subscriptions.dispose(); + }); + + it("shows a session continued in a successor deleted after it was listed", async () => { + const successorId = randomUUID(); + const predecessor = await writeTranscript({ + trailer: [{ type: "continued-in", continuedInSessionId: successorId }], + }); + const successor = await writeTranscript({ sessionId: successorId }); + await fs.appendFile( + successor.file, + JSON.stringify({ type: "user", parentUuid: null, sessionId: successorId, cwd: workspace }) + + "\n", + ); + // The successor has history: the list hides the predecessor. + const { subscriptions, sent, list } = slowFirstRows(400); + expect((await list()).rows.map((row) => row.sessionId)).toEqual([successorId]); + await subscriptions.subscribe(workspace); + await fs.rm(successor.file); + const shown = () => + sent.some(({ changes }) => changes.sessions.some((s) => s.sessionId === predecessor.id)); + expect(await waitFor(shown)).toBe(true); + subscriptions.dispose(); + }); + + it("reads again a transcript whose first read failed", async () => { + const session = await writeTranscript({}); + const { subscriptions, sent } = slowFirstRows(0, { rescanMs: 500, failFirstRead: true }); + await subscriptions.subscribe(workspace); + await fs.appendFile(session.file, promptRecord(session.id, "Next") + "\n"); + const shown = () => + sent.some(({ changes }) => changes.sessions.some((s) => s.sessionId === session.id)); + expect(await waitFor(shown)).toBe(true); + subscriptions.dispose(); + }); + + it("sends a registry change that lands while the registry is read first", async () => { + const session = await writeTranscript({}); + await registerHolder(process.pid, session.id, liveCli("idle")); + const service = new SessionIndexService({ logError: () => {} }); + let first = true; + const registry = { + readFiles: async (...args: Parameters) => { + if (first) { + first = false; + await new Promise((resolve) => setTimeout(resolve, 300)); + } + return service.registry.readFiles(...args); + }, + }; + const sent: ListChanges[] = []; + const subscriptions = new ListSubscriptions({ + index: service.index, + registry, + own: () => undefined, + notify: async (changes) => { + sent.push(changes); + }, + logError: () => {}, + }); + await subscriptions.subscribe(workspace); + await registerHolder(process.pid, session.id, liveCli("busy")); + const running = () => + sent.some((c) => + c.sessions.some((s) => s.sessionId === session.id && airRow(s).state === "running"), + ); + expect(await waitFor(running)).toBe(true); + subscriptions.dispose(); + }); + + it("sends the sessions of a worktree linked after subscribe that shares the directory", async () => { + const repo = path.join(workspace, "repo.a"); + const linked = path.join(workspace, "repo-a"); + expect(encodeProjectPath(repo)).toBe(encodeProjectPath(linked)); + await fs.mkdir(path.join(repo, ".git"), { recursive: true }); + await fs.mkdir(linked, { recursive: true }); + const inLinked = await writeTranscript({ cwd: linked }); + const { subscriptions, sent, list } = slowFirstRows(0, { rescanMs: 400 }); + // The client's list of the repository read the transcript of the other path. + await list(repo); + await subscriptions.subscribe(repo); + await settle(); + expect(sent).toEqual([]); + // `git worktree add` of the other path. + const meta = path.join(repo, ".git", "worktrees", "linked"); + await fs.mkdir(meta, { recursive: true }); + await fs.writeFile(path.join(meta, "gitdir"), `${path.join(linked, ".git")}\n`); + await fs.writeFile(path.join(meta, "commondir"), "../..\n"); + await fs.writeFile(path.join(linked, ".git"), `gitdir: ${meta}\n`); + const shown = () => + sent.some(({ changes }) => changes.sessions.some((s) => s.sessionId === inLinked.id)); + expect(await waitFor(shown)).toBe(true); + subscriptions.dispose(); + }); + + it("watches a project directory that was replaced", async () => { + const session = await writeTranscript({}); + const dir = path.dirname(session.file); + const { subscriptions, sent } = slowFirstRows(0); + await subscriptions.subscribe(workspace); + await settle(); + await fs.rename(dir, `${dir}.old`); + await fs.mkdir(dir); + await fs.copyFile(path.join(`${dir}.old`, path.basename(session.file)), session.file); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const before = sent.length; + await fs.appendFile(session.file, promptRecord(session.id, "In the new directory") + "\n"); + expect(await waitFor(() => sent.length > before, 3000)).toBe(true); + subscriptions.dispose(); + await fs.rm(`${dir}.old`, { recursive: true, force: true }); + }); + + it("removes a session deleted after the first read took its row", async () => { + const session = await writeTranscript({}); + const { subscriptions, sent } = slowFirstRows(400); + await subscriptions.subscribe(workspace); + // Its row is read (the delay is inside the first read), then it goes. + await new Promise((resolve) => setTimeout(resolve, 100)); + await fs.appendFile(session.file, promptRecord(session.id, "Changed") + "\n"); + await new Promise((resolve) => setTimeout(resolve, 450)); + await fs.rm(session.file); + expect( + await waitFor(() => sent.some(({ changes }) => changes.removed.includes(session.id))), + ).toBe(true); + subscriptions.dispose(); + }); + + it("sends a rename made while the first read runs", async () => { + const session = await writeTranscript({ prompt: "Before" }); + const { subscriptions, sent } = slowFirstRows(400); + await subscriptions.subscribe(workspace); + await fs.appendFile( + session.file, + JSON.stringify({ type: "custom-title", customTitle: "After", sessionId: session.id }) + "\n", + ); + await writeCustomTitleSidecar(session.file, "After"); + const renamed = () => + sent.some(({ changes }) => + changes.sessions.some((s) => s.sessionId === session.id && s.title === "After"), + ); + expect(await waitFor(renamed)).toBe(true); + subscriptions.dispose(); + }); + + it("reads no transcript to subscribe, nor on a rescan of unchanged ones", async () => { + const sessions: { id: string; file: string }[] = []; + for (let i = 0; i < 12; i++) sessions.push(await writeTranscript({})); + // Past the events of these writes, which a new watch may still get. + await new Promise((resolve) => setTimeout(resolve, 1000)); + const { subscriptions, sent, rowsRead, service } = slowFirstRows(0, { rescanMs: 300 }); + vi.mocked(getSessionInfo).mockClear(); + await subscriptions.subscribe(workspace); + // Past the rescan after the watchers opened, and a few periodic ones. + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(rowsRead).toEqual([]); + expect(sent).toEqual([]); + expect(vi.mocked(getSessionInfo)).not.toHaveBeenCalled(); + const files = await service.index.enumerateFiles([workspace]); + expect(files).toHaveLength(12); + expect(files.filter((file) => service.index.isRead(file))).toEqual([]); + + await fs.appendFile(sessions[4]!.file, promptRecord(sessions[4]!.id, "Next") + "\n"); + expect(await waitFor(() => sent.length > 0)).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + // Only the changed session was read, by its event and by none of the rescans. + expect(new Set(rowsRead.flat())).toEqual(new Set([sessions[4]!.id])); + expect( + (await service.index.enumerateFiles([workspace])) + .filter((file) => service.index.isRead(file)) + .map((file) => file.sessionId), + ).toEqual([sessions[4]!.id]); + subscriptions.dispose(); + }); + + it("sends the first change of a session in full, then not updatedAt alone", async () => { + const session = await writeTranscript({ prompt: "Before", lastMessageAt: Date.now() - 60_000 }); + const { subscriptions, sent } = slowFirstRows(0, { minSessionIntervalMs: 300 }); + await subscriptions.subscribe(workspace); + await settle(); + // A message that changes nothing but updatedAt: the first change is sent. + await fs.appendFile(session.file, assistantRecord(session.id, null) + "\n"); + expect(await waitFor(() => sent.length === 1)).toBe(true); + expect(sent[0]!.changes.sessions.map((s) => [s.sessionId, s.title])).toEqual([ + [session.id, "Before"], + ]); + // Another one is no change. + await new Promise((resolve) => setTimeout(resolve, 500)); + await fs.appendFile(session.file, assistantRecord(session.id, null) + "\n"); + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(sent).toHaveLength(1); + subscriptions.dispose(); + }); + + it.each([ + { continued: false, beforeRescan: false, evicted: false }, + { continued: false, beforeRescan: true, evicted: false }, + { continued: false, beforeRescan: true, evicted: true }, + { continued: true, beforeRescan: false, evicted: false }, + ])( + "sends the state of a listed live session that ages without an event (%o)", + async ({ continued, beforeRescan, evicted }) => { + const id = randomUUID(); + // An unfinished turn of an SDK session in another process; continued + // in a session that has no transcript, so the list still shows it. + const session = await writeTranscript({ + sessionId: id, + mtimeMs: Date.now(), + trailer: [ + JSON.parse(promptRecord(id, "Working", Date.now())), + ...(continued ? [{ type: "continued-in", continuedInSessionId: randomUUID() }] : []), + ], + }); + await registerHolder(process.pid, session.id, { entrypoint: "sdk-ts" }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + let offset = 0; + const { subscriptions, sent, list, rowsRead, service } = slowFirstRows(0, { + rescanMs: 300, + now: () => Date.now() + offset, + }); + const listed = (await list()).rows; + expect(listed.map((row) => row.sessionId)).toEqual([session.id]); + await subscriptions.subscribe(workspace); + // The index drops its metadata before the first rescan. + if (evicted) service.index.invalidate([session.file]); + if (!beforeRescan) { + await new Promise((resolve) => setTimeout(resolve, 800)); + expect(sent).toEqual([]); + } + // Past the time an unfinished turn counts as running. + offset = 11 * 60 * 1000; + expect(await waitFor(() => sent.length > 0)).toBe(true); + const row = sent[0]!.changes.sessions[0]!; + expect(row.sessionId).toBe(session.id); + expect(airRow(row).state).toBeUndefined(); + // From the metadata the list read: no transcript read for the row. + if (!continued) expect(rowsRead).toEqual([]); + subscriptions.dispose(); + }, + ); + + it("removes a sent continued session whose cached metadata went when its successor gets history", async () => { + const successorId = randomUUID(); + const predecessor = await writeTranscript({ + trailer: [{ type: "continued-in", continuedInSessionId: successorId }], + }); + const stub = await writeTranscript({ sessionId: successorId }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const { subscriptions, sent, service } = slowFirstRows(0, { minSessionIntervalMs: 0 }); + await subscriptions.subscribe(workspace); + await settle(); + // An event of it that leaves it continued. + await fs.appendFile( + predecessor.file, + JSON.stringify({ type: "continued-in", continuedInSessionId: successorId }) + "\n", + ); + const shown = () => + sent.some(({ changes }) => changes.sessions.some((s) => s.sessionId === predecessor.id)); + expect(await waitFor(shown)).toBe(true); + // The index no longer holds its metadata (evicted). + service.index.invalidate([predecessor.file]); + await fs.appendFile( + stub.file, + JSON.stringify({ type: "user", parentUuid: null, sessionId: successorId, cwd: workspace }) + + "\n", + ); + expect( + await waitFor(() => sent.some(({ changes }) => changes.removed.includes(predecessor.id))), + ).toBe(true); + subscriptions.dispose(); + }, 15_000); + + it("removes a listed continued session whose cached metadata went when its successor gets history", async () => { + const successorId = randomUUID(); + const predecessor = await writeTranscript({ + trailer: [{ type: "continued-in", continuedInSessionId: successorId }], + }); + const stub = await writeTranscript({ sessionId: successorId }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const { subscriptions, sent, service, list } = slowFirstRows(0); + expect((await list()).rows.map((row) => row.sessionId)).toContain(predecessor.id); + await subscriptions.subscribe(workspace); + await settle(); + // The index no longer holds its metadata (evicted), and no event read it. + service.index.invalidate([predecessor.file]); + await fs.appendFile( + stub.file, + JSON.stringify({ type: "user", parentUuid: null, sessionId: successorId, cwd: workspace }) + + "\n", + ); + expect( + await waitFor(() => sent.some(({ changes }) => changes.removed.includes(predecessor.id))), + ).toBe(true); + subscriptions.dispose(); + }, 15_000); + + it("checks a live continued session hidden by its successor once, not on every rescan", async () => { + const successorId = randomUUID(); + const predecessor = await writeTranscript({ + mtimeMs: Date.now(), + trailer: [{ type: "continued-in", continuedInSessionId: successorId }], + }); + await writeTranscript({ + sessionId: successorId, + trailer: [{ type: "user", parentUuid: null, sessionId: successorId, cwd: workspace }], + }); + await registerHolder(process.pid, predecessor.id, { entrypoint: "sdk-ts" }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const { subscriptions, sent, list, rowsRead } = slowFirstRows(0, { rescanMs: 300 }); + expect((await list()).rows.map((row) => row.sessionId)).toEqual([successorId]); + await subscriptions.subscribe(workspace); + await new Promise((resolve) => setTimeout(resolve, 1800)); + expect(rowsRead.flat().filter((id) => id === predecessor.id)).toHaveLength(1); + expect(sent).toEqual([]); + subscriptions.dispose(); + }); + + it("forgets a removal it sent once the transcript is deleted", async () => { + const successorId = randomUUID(); + const predecessor = await writeTranscript({ + trailer: [{ type: "continued-in", continuedInSessionId: successorId }], + }); + const stub = await writeTranscript({ sessionId: successorId }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const { subscriptions, sent } = slowFirstRows(0, { minSessionIntervalMs: 0 }); + await subscriptions.subscribe(workspace); + await settle(); + const subscription = () => [...(subscriptions as any).subscriptions.values()][0]; + await fs.appendFile( + predecessor.file, + JSON.stringify({ type: "continued-in", continuedInSessionId: successorId }) + "\n", + ); + const shown = () => + sent.some(({ changes }) => changes.sessions.some((s) => s.sessionId === predecessor.id)); + expect(await waitFor(shown)).toBe(true); + // Hidden by its successor: removed, and remembered as removed. + await fs.appendFile( + stub.file, + JSON.stringify({ type: "user", parentUuid: null, sessionId: successorId, cwd: workspace }) + + "\n", + ); + expect( + await waitFor(() => sent.some(({ changes }) => changes.removed.includes(predecessor.id))), + ).toBe(true); + expect(subscription().sent.get(predecessor.id)).toBeNull(); + await fs.rm(predecessor.file); + expect(await waitFor(() => !subscription().sent.has(predecessor.id))).toBe(true); + subscriptions.dispose(); + }, 15_000); + + it("forgets a hidden session created after subscribe once it is deleted", async () => { + const { subscriptions, sent } = slowFirstRows(0); + await subscriptions.subscribe(workspace); + await settle(); + const watch = () => [...(subscriptions as any).watches.values()][0]; + const hidden = await writeTranscript({ sidechain: true }); + expect(await waitFor(() => watch().born.has(hidden.id))).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 400)); + await fs.rm(hidden.file); + expect(await waitFor(() => !watch().born.has(hidden.id))).toBe(true); + expect(sent).toEqual([]); + subscriptions.dispose(); + }); + + it("keeps a row whose transcript cannot be read for now, and reads it again", async () => { + const session = await writeTranscript({}); + const { subscriptions, sent } = slowFirstRows(0, { rescanMs: 700 }); + await subscriptions.subscribe(workspace); + await settle(); + await fs.appendFile(session.file, promptRecord(session.id, "Unreadable") + "\n"); + await fs.chmod(session.file, 0o000); + try { + await new Promise((resolve) => setTimeout(resolve, 1200)); + expect(sent.flatMap(({ changes }) => changes.removed)).toEqual([]); + } finally { + await fs.chmod(session.file, 0o644); + } + expect(await waitFor(() => sent.some(({ changes }) => changes.sessions.length > 0))).toBe(true); + expect(sent.flatMap(({ changes }) => changes.removed)).toEqual([]); + subscriptions.dispose(); + }); + + it("does not read an archived session on a rescan", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + await agent.archiveSession({ sessionId: session.id }); + // Past the events of the archive, which a new watch may still get. + await new Promise((resolve) => setTimeout(resolve, 1000)); + const { subscriptions, rowsRead } = slowFirstRows(0, { rescanMs: 300 }); + await subscriptions.subscribe(workspace); + await new Promise((resolve) => setTimeout(resolve, 1800)); + // Several rescans since, and nothing changed. + expect(rowsRead).toEqual([]); + subscriptions.dispose(); + }); + + it("keeps working after events that arrive while the first read runs", async () => { + const session = await writeTranscript({}); + const { subscriptions, sent } = slowFirstRows(600); + await subscriptions.subscribe(workspace); + // Past the quiet period: the first read runs. + await fs.appendFile(session.file, promptRecord(session.id, "During") + "\n"); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(await waitFor(() => sent.length === 1)).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 1100)); + await fs.appendFile(session.file, promptRecord(session.id, "After") + "\n"); + expect(await waitFor(() => sent.length === 2)).toBe(true); + subscriptions.dispose(); + }); + + it("removes a session never sent when its transcript is deleted", async () => { + const session = await writeTranscript({}); + const { subscriptions, sent } = slowFirstRows(400); + await subscriptions.subscribe(workspace); + await fs.rm(session.file); + expect(await waitFor(() => sent.some(({ changes }) => changes.removed.length > 0))).toBe(true); + expect(sent.flatMap(({ changes }) => changes.removed)).toEqual([session.id]); + subscriptions.dispose(); + }); + + it("holds a removal back for the interval of the session", async () => { + const session = await writeTranscript({}); + const { subscriptions, sent } = slowFirstRows(0, { minSessionIntervalMs: 800 }); + await subscriptions.subscribe(workspace); + await settle(); + await fs.appendFile(session.file, promptRecord(session.id, "Last") + "\n"); + expect(await waitFor(() => sent.length === 1)).toBe(true); + await fs.rm(session.file); + expect(await waitFor(() => sent.length === 2)).toBe(true); + expect(sent[1]!.changes.removed).toEqual([session.id]); + expect(sent[1]!.at - sent[0]!.at).toBeGreaterThanOrEqual(780); + subscriptions.dispose(); + }); + + it("hides a continued session once its successor has history", async () => { + const successorId = randomUUID(); + const predecessor = await writeTranscript({ + trailer: [{ type: "continued-in", continuedInSessionId: successorId }], + }); + const stub = await writeTranscript({ sessionId: successorId }); + const { changes, agent } = await subscribedAgent(); + // The client lists it. + await agent.listSessions({ cwd: workspace }); + await fs.appendFile( + stub.file, + JSON.stringify({ type: "user", parentUuid: null, sessionId: successorId, cwd: workspace }) + + "\n", + ); + expect(await waitFor(() => changes().some((c) => c.removed.includes(predecessor.id)))).toBe( + true, + ); + // Nothing shows it after (its creation just before subscribe may have). + const all = changes(); + const lastRemoved = all.findLastIndex((c) => c.removed.includes(predecessor.id)); + const lastShown = all.findLastIndex((c) => + c.sessions.some((row) => row.sessionId === predecessor.id), + ); + expect(lastShown).toBeLessThan(lastRemoved); + await agent.dispose(); + }); + + it("is not started by session/list", async () => { + await writeTranscript({}); + const { agent } = await indexAgent(); + await agent.listSessions({ cwd: workspace }); + expect(handlesOf(agent)).toBeUndefined(); + }); + + it("sends a session that changed right after subscribe", async () => { + const sessions: { id: string; file: string }[] = []; + for (let i = 0; i < 20; i++) sessions.push(await writeTranscript({})); + const { agent, notifications } = await indexAgent(); + await agent.subscribeSessionList({ cwd: workspace }); + // Right after subscribe returns. + await fs.appendFile(sessions[7]!.file, promptRecord(sessions[7]!.id, "Meanwhile") + "\n"); + expect( + await waitFor(() => + changesOf(notifications).some((c) => + c.sessions.some((s) => s.sessionId === sessions[7]!.id), + ), + ), + ).toBe(true); + await agent.dispose(); + }); +}); + +describe("a session that another process holds", () => { + it("is renamed, archived, unarchived and deleted, also when its query closed here", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + // The stream ended, the husk stays mapped; another process resumed it. + agent.sessions[session.id] = mockSessionState({ queryClosed: true }, agent, session.id) as any; + await registerHolder(process.pid, session.id, { entrypoint: "cli", kind: "interactive" }); + + await agent.renameSessionTitle({ sessionId: session.id, title: "Renamed" }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "Renamed")); + await agent.archiveSession({ sessionId: session.id }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "[archived] Renamed")); + await agent.unarchiveSession({ sessionId: session.id }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "Renamed")); + await agent.deleteSession({ sessionId: session.id }); + expect(fsSync.existsSync(session.file)).toBe(false); + }); + + it.skipIf(process.platform === "win32")( + "is deleted without waiting for a CLI child of this process to exit", + async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { + stdio: "ignore", + }); + try { + await registerHolder(child.pid!, session.id, { entrypoint: "sdk-ts" }); + const started = Date.now(); + await agent.deleteSession({ sessionId: session.id }); + expect(Date.now() - started).toBeLessThan(2000); + expect(fsSync.existsSync(session.file)).toBe(false); + } finally { + child.kill("SIGKILL"); + } + }, + ); +}); + +describe("session/delete of an AIR client without sessionIndex", () => { + it("fails for an unknown session as the SDK delete did", async () => { + const { agent } = createAgent(); + await initializeClient(agent, air()); + for (const sessionId of [randomUUID(), "not-a-uuid"]) { + const sdkError = await vi.mocked(deleteSession).getMockImplementation()!(sessionId).then( + () => undefined, + (error: Error) => error, + ); + expect(sdkError).toBeInstanceOf(Error); + vi.mocked(deleteSession).mockClear(); + await expect(agent.deleteSession({ sessionId })).rejects.toThrow(sdkError!.message); + expect(deleteSession).not.toHaveBeenCalled(); + } + }); +}); + +describe("session index service lifecycle", () => { + it("starts no watcher after dispose, also for a list in flight", async () => { + await writeTranscript({}); + const service = new SessionIndexService({ + logError: () => {}, + }); + const inFlight = service.list({ cwd: workspace }, () => undefined); + service.dispose(); + await inFlight; + await service.list({ cwd: workspace }, () => undefined); + expect((service as any).watcher).toBeUndefined(); + }); +}); + +describe("rename of a session with several transcripts", () => { + it("titles every copy, and the list shows the new title at once", async () => { + const other = path.join(workspace, "other"); + const small = await writeTranscript({ cwd: other }); + const large = await writeTranscript({ + sessionId: small.id, + cwd: workspace, + trailer: [{ type: "last-prompt", lastPrompt: "x".repeat(500), sessionId: small.id }], + }); + const { agent } = await indexAgent(); + // Caches the metadata of the listed (larger) copy. + expect((await agent.listSessions({})).sessions[0]!.title).not.toBe("Both copies"); + + await agent.renameSessionTitle({ sessionId: small.id, title: "Both copies" }); + for (const file of [small.file, large.file]) { + const records = (await fs.readFile(file, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records.filter((record) => record.type === "custom-title")).toEqual([ + { type: "custom-title", customTitle: "Both copies", sessionId: small.id }, + ]); + } + const page = await agent.listSessions({}); + expect(page.sessions.map((s) => [s.sessionId, s.title])).toEqual([[small.id, "Both copies"]]); + }); +}); + +describe("delete of a session with several transcripts", () => { + it("reports a copy it could not delete, and a retry deletes it", async () => { + const first = await writeTranscript({ cwd: path.join(workspace, "a") }); + const second = await writeTranscript({ + sessionId: first.id, + cwd: path.join(workspace, "b"), + }); + const { agent } = await indexAgent(); + await agent.archiveSession({ sessionId: first.id }); + for (const file of [first.file, second.file]) { + expect(await lastRecords(file)).toEqual(titleRecords(first.id, "[archived] Fix it")); + } + const actual = vi.mocked(deleteSession).getMockImplementation()!; + vi.mocked(deleteSession) + .mockImplementationOnce(actual) + .mockImplementationOnce(async () => { + throw new Error("EACCES: permission denied"); + }); + + await expect(agent.deleteSession({ sessionId: first.id })).rejects.toThrow("EACCES"); + expect([first.file, second.file].filter((file) => fsSync.existsSync(file))).toHaveLength(1); + expect( + (await agent.listSessions({ _meta: listMeta({ archived: "archived" }) })).sessions.map( + (s) => s.sessionId, + ), + ).toEqual([first.id]); + + await agent.deleteSession({ sessionId: first.id }); + expect([first.file, second.file].filter((file) => fsSync.existsSync(file))).toEqual([]); + }); +}); + +describe("cwd recovery from a sibling transcript", () => { + it("does not depend on the batch the sibling is read in", async () => { + const base = Date.parse("2026-04-01T00:00:00Z"); + const other = path.join(workspace, "other"); + // Newest, without a cwd of its own; its only sibling with a cwd is the + // oldest transcript, past the point where the page is full. + const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base }); + const fillers: string[] = []; + for (let i = 1; i <= 16; i++) { + fillers.push((await writeTranscript({ lastMessageAt: base - i * 1000 })).id); + } + await writeTranscript({ cwd: other, lastMessageAt: base - 3600_000 }); + const { agent } = await indexAgent(); + for (let attempt = 0; attempt < 2; attempt++) { + const page = await agent.listSessions({ _meta: listMeta({ limit: 2 }) }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([ + [noCwd.id, other], + [fillers[0], workspace], + ]); + } + }); +}); + +describe("a last message longer than the tail window", () => { + it("keeps the session at its last message time after a metadata record", async () => { + const lastMessageAt = Date.parse("2026-05-01T00:00:00Z"); + const id = randomUUID(); + const session = await writeTranscript({ + sessionId: id, + lastMessageAt: lastMessageAt - 60_000, + mtimeMs: lastMessageAt + 3600_000, + trailer: [ + { + type: "assistant", + sessionId: id, + cwd: workspace, + uuid: randomUUID(), + timestamp: new Date(lastMessageAt).toISOString(), + message: { + role: "assistant", + content: [{ type: "text", text: "y".repeat(300_000) }], + stop_reason: "end_turn", + }, + }, + { type: "custom-title", customTitle: "Renamed later", sessionId: id }, + ], + }); + const { agent } = await indexAgent(); + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions.map((s) => [s.sessionId, s.updatedAt])).toEqual([ + [session.id, new Date(lastMessageAt).toISOString()], + ]); + }); +}); + +describe("custom title sidecar", () => { + it("survives concurrent writes and leaves other temporary files alone", async () => { + const session = await writeTranscript({}); + const dir = path.join(path.dirname(session.file), session.id); + await fs.mkdir(dir, { recursive: true }); + const foreign = path.join(dir, "custom-title.json.tmp.foreign"); + await fs.writeFile(foreign, "{}"); + const titles = Array.from({ length: 20 }, (_, i) => `Title ${i}`); + await Promise.all(titles.map((title) => writeCustomTitleSidecar(session.file, title))); + const written = JSON.parse(await fs.readFile(path.join(dir, "custom-title.json"), "utf8")); + expect(titles).toContain(written.customTitle); + expect((await fs.readdir(dir)).sort()).toEqual([ + "custom-title.json", + "custom-title.json.tmp.foreign", + ]); + }); +}); + +describe("long project paths that share a prefix", () => { + const longBase = () => path.join(workspace, "x".repeat(210)); + + it("lists only the directories whose transcripts belong to the path", async () => { + const mine = path.join(longBase(), "mine"); + const theirs = path.join(longBase(), "theirs"); + const own = await writeTranscript({ cwd: mine }); + // The CLI hashes a long name differently from the SDK. + const prefix = encodeProjectPath(mine).slice(0, 200); + const cliCopy = await writeTranscript({ cwd: mine, dirName: `${prefix}-cli0hash` }); + await writeTranscript({ cwd: theirs }); + expect(encodeProjectPath(theirs).startsWith(prefix)).toBe(true); + const { agent } = await indexAgent(); + + const page = await agent.listSessions({ cwd: mine }); + expect(page.sessions.map((s) => s.sessionId).sort()).toEqual([own.id, cliCopy.id].sort()); + }); + + it("does not change a subscription on a live record of another long path", async () => { + const mine = path.join(longBase(), "mine"); + const theirs = path.join(longBase(), "theirs"); + const own = await writeTranscript({ cwd: mine }); + const other = await writeTranscript({ cwd: theirs }); + await fs.mkdir(path.join(configDir, "sessions"), { recursive: true }); + const changes: ListChanges[] = []; + const service = new SessionIndexService({ + notifyListChanges: async (change) => { + changes.push(change); + }, + logError: () => {}, + }); + await service.subscribeList(mine); + await settle(); + await registerHolder(process.pid, other.id, liveCli("busy")); + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(changes).toEqual([]); + await registerHolder(process.ppid, own.id, liveCli("busy")); + expect(await waitFor(() => changes.length > 0)).toBe(true); + expect(changes[0]!.sessions.map((s) => s.sessionId)).toEqual([own.id]); + service.dispose(); + }, 15_000); +}); + +describe("session title after an explicit rename", () => { + it("never publishes the title a turn end read before the rename", async () => { + const updates: any[] = []; + const agent: any = { + client: { sessionUpdate: async (update: unknown) => updates.push(update) }, + logger: { error: () => {} }, + sessions: {}, + }; + const titles = new SessionTitles(agent, "s1"); + const session: any = { queryClosed: false, cancelled: false, cwd: "/nowhere", query: {} }; + agent.sessions.s1 = session; + let resolveInfo!: (info: any) => void; + vi.mocked(getSessionInfo).mockImplementationOnce( + () => new Promise((resolve) => (resolveInfo = resolve)), + ); + const turnEnd = titles.onTurnEnd(session); + await titles.setExplicitTitle("Mine", async () => {}); + resolveInfo({ customTitle: "Old", summary: "Old", lastModified: Date.now() }); + await turnEnd; + await titles.onTurnEnd(session); + expect(updates.map((update) => update.update.title)).toEqual(["Mine"]); + }); + + it("never publishes a title read before a later client rename", async () => { + const updates: any[] = []; + const agent: any = { + client: { sessionUpdate: async (update: unknown) => updates.push(update) }, + logger: { error: () => {} }, + sessions: {}, + }; + const titles = new SessionTitles(agent, "s1"); + const session: any = { queryClosed: false, cancelled: false, cwd: "/nowhere", query: {} }; + agent.sessions.s1 = session; + await titles.setExplicitTitle("First", async () => "First"); + let resolveInfo!: (info: any) => void; + vi.mocked(getSessionInfo).mockImplementationOnce( + () => new Promise((resolve) => (resolveInfo = resolve)), + ); + const turnEnd = titles.onTurnEnd(session); + await titles.setExplicitTitle("Second", async () => "Second"); + resolveInfo({ customTitle: "First", summary: "First", lastModified: Date.now() }); + await turnEnd; + expect(updates.map((update) => update.update.title)).toEqual(["First", "Second"]); + }); + + it("adopts a title stored later by someone else, and generates none", async () => { + const updates: any[] = []; + const agent: any = { + client: { sessionUpdate: async (update: unknown) => updates.push(update) }, + logger: { error: () => {} }, + sessions: {}, + }; + const titles = new SessionTitles(agent, "s1"); + const generateSessionTitle = vi.fn(async () => "Generated"); + const session: any = { + queryClosed: false, + cancelled: false, + cwd: "/nowhere", + query: { generateSessionTitle }, + }; + agent.sessions.s1 = session; + await titles.setExplicitTitle("Mine", async () => "Mine"); + vi.mocked(getSessionInfo).mockResolvedValueOnce({ + customTitle: "Mine", + summary: "Mine", + lastModified: Date.now(), + } as any); + await titles.onTurnEnd(session); + vi.mocked(getSessionInfo).mockResolvedValueOnce({ + customTitle: "Renamed in the CLI", + summary: "Renamed in the CLI", + lastModified: Date.now(), + } as any); + await titles.onTurnEnd(session); + // Renamed back to the title the client stored. + vi.mocked(getSessionInfo).mockResolvedValueOnce({ + customTitle: "Mine", + summary: "Mine", + lastModified: Date.now(), + } as any); + await titles.onTurnEnd(session); + vi.mocked(getSessionInfo).mockResolvedValueOnce({ + summary: "First prompt", + lastModified: Date.now(), + } as any); + await titles.onTurnEnd(session); + expect(updates.map((update) => update.update.title)).toEqual([ + "Mine", + "Renamed in the CLI", + "Mine", + ]); + expect(generateSessionTitle).not.toHaveBeenCalled(); + }); +}); + +describe("worktrees after git worktree move", () => { + it("follows a moved linked worktree", async () => { + const repo = path.join(workspace, "repo"); + const before = path.join(workspace, "before"); + const after = path.join(workspace, "after"); + const meta = path.join(repo, ".git", "worktrees", "wt"); + await fs.mkdir(meta, { recursive: true }); + await fs.mkdir(before); + await fs.writeFile(path.join(meta, "gitdir"), `${path.join(before, ".git")}\n`); + // The move rewrites the gitdir file only: the directory keeps its mtime. + const worktreesDir = path.dirname(meta); + await fs.utimes(worktreesDir, 1_700_000_000, 1_700_000_000); + expect(await repositoryWorktrees(repo)).toEqual([repo, before]); + + await fs.rename(before, after); + await fs.writeFile(path.join(meta, "gitdir"), `${path.join(after, ".git")}\n`); + await fs.utimes(worktreesDir, 1_700_000_000, 1_700_000_000); + expect(await repositoryWorktrees(repo)).toEqual([repo, after]); + }); +}); + +/** The real SDK `deleteSession`, behind the spy. */ +const sdkDelete = () => vi.mocked(deleteSession).getMockImplementation()!; + +describe("empty transcripts on delete", () => { + it("deletes a session that has only an empty transcript, and its session directory", async () => { + const id = randomUUID(); + const dir = path.join(configDir, "projects", encodeProjectPath(workspace)); + await fs.mkdir(path.join(dir, id), { recursive: true }); + await fs.writeFile(path.join(dir, `${id}.jsonl`), ""); + const { agent } = await indexAgent(); + await agent.deleteSession({ sessionId: id }); + expect(await fs.readdir(dir)).toEqual([]); + }); + + it("deletes a real and an empty copy of a session", async () => { + const real = await writeTranscript({ cwd: path.join(workspace, "a") }); + const emptyDir = path.join(configDir, "projects", encodeProjectPath(path.join(workspace, "b"))); + await fs.mkdir(emptyDir, { recursive: true }); + await fs.writeFile(path.join(emptyDir, `${real.id}.jsonl`), ""); + const { agent } = await indexAgent(); + await agent.deleteSession({ sessionId: real.id }); + expect(fsSync.existsSync(real.file)).toBe(false); + expect(await fs.readdir(emptyDir)).toEqual([]); + }); + + it("archives for an AIR client without sessionIndex only what the SDK delete would find", async () => { + const id = randomUUID(); + const dir = path.join(configDir, "projects", encodeProjectPath(workspace)); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, `${id}.jsonl`), ""); + const sdkError = await sdkDelete()(id).then( + () => undefined, + (error: Error) => error, + ); + const service = new SessionIndexService({ + logError: () => {}, + }); + await expect(archiveInsteadOfDelete(id, service)).rejects.toThrow(sdkError!.message); + expect(await fs.readFile(path.join(dir, `${id}.jsonl`), "utf8")).toBe(""); + }); +}); + +describe("a failing SDK delete", () => { + it("fails the request when the session directory could not be removed, and a retry finishes", async () => { + const session = await writeTranscript({}); + const sessionDir = path.join(path.dirname(session.file), session.id); + await fs.mkdir(path.join(sessionDir, "subagents"), { recursive: true }); + await fs.writeFile(path.join(sessionDir, "subagents", "agent-1.jsonl"), "{}\n"); + const { agent } = await indexAgent(); + // The SDK removed the transcript, then failed to remove `/`. + vi.mocked(deleteSession).mockImplementationOnce(async () => { + await fs.rm(session.file); + throw Object.assign(new Error("EACCES: permission denied, rmdir"), { code: "EACCES" }); + }); + await expect(agent.deleteSession({ sessionId: session.id })).rejects.toThrow("EACCES"); + expect(fsSync.existsSync(sessionDir)).toBe(true); + + // The retry finds the session directory without a transcript. + await agent.deleteSession({ sessionId: session.id }); + expect(fsSync.existsSync(sessionDir)).toBe(false); + }); + + it("removes a session directory that the SDK left behind", async () => { + const session = await writeTranscript({}); + const sessionDir = path.join(path.dirname(session.file), session.id); + await fs.mkdir(sessionDir); + const { agent } = await indexAgent(); + vi.mocked(deleteSession).mockImplementationOnce(async () => { + await fs.rm(session.file); + }); + await agent.deleteSession({ sessionId: session.id }); + expect(fsSync.existsSync(sessionDir)).toBe(false); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "fails the request when a session directory cannot be removed", + async () => { + const session = await writeTranscript({}); + const projectDir = path.dirname(session.file); + const sessionDir = path.join(projectDir, session.id); + await fs.mkdir(sessionDir); + const { agent } = await indexAgent(); + vi.mocked(deleteSession).mockImplementationOnce(async () => { + await fs.rm(session.file); + await fs.chmod(projectDir, 0o500); + }); + try { + await expect(agent.deleteSession({ sessionId: session.id })).rejects.toThrow(); + } finally { + await fs.chmod(projectDir, 0o700); + } + expect(fsSync.existsSync(sessionDir)).toBe(true); + }, + ); +}); + +describe("a long project directory whose transcript gets its cwd later", () => { + it("is listed once a transcript in it names the path", async () => { + const mine = path.join(workspace, "x".repeat(210), "mine"); + const prefix = encodeProjectPath(mine).slice(0, 200); + const session = await writeTranscript({ + cwd: mine, + recordCwd: null, + dirName: `${prefix}-cli0hash`, + }); + const { agent } = await indexAgent(); + expect((await agent.listSessions({ cwd: mine })).sessions).toEqual([]); + // Appending does not change the directory mtime. + await fs.appendFile( + session.file, + JSON.stringify({ type: "user", cwd: mine, sessionId: session.id, message: {} }) + "\n", + ); + expect((await agent.listSessions({ cwd: mine })).sessions.map((s) => s.sessionId)).toEqual([ + session.id, + ]); + }); +}); + +describe("rename of a running session with several transcripts", () => { + it("titles the other copies here and leaves the CLI's own copy to the CLI", async () => { + const own = await writeTranscript({ cwd: workspace }); + const other = await writeTranscript({ + sessionId: own.id, + cwd: path.join(workspace, "moved"), + trailer: [{ type: "last-prompt", lastPrompt: "x".repeat(500), sessionId: own.id }], + }); + const { agent } = await indexAgent(); + await agent.listSessions({}); + const cliRecord = titleRecords(own.id, "Live title") + .map((record) => JSON.stringify(record) + "\n") + .join(""); + // The CLI titles its own transcript. + const rename = vi.fn(async () => { + await fs.appendFile(own.file, cliRecord); + }); + const ownBefore = await fs.readFile(own.file, "utf8"); + agent.sessions[own.id] = mockSessionState( + { cwd: workspace, query: { renameSession: rename } }, + agent, + own.id, + ) as any; + await agent.renameSessionTitle({ sessionId: own.id, title: "Live title" }); + + expect(rename).toHaveBeenCalledWith("Live title", own.id); + expect(await fs.readFile(own.file, "utf8")).toBe(ownBefore + cliRecord); + expect(fsSync.existsSync(path.join(path.dirname(own.file), own.id))).toBe(false); + expect(await lastRecords(other.file)).toEqual(titleRecords(own.id, "Live title")); + const sidecar = path.join(path.dirname(other.file), own.id, "custom-title.json"); + expect(JSON.parse(await fs.readFile(sidecar, "utf8"))).toEqual({ customTitle: "Live title" }); + expect((await agent.listSessions({})).sessions[0]!.title).toBe("Live title"); + }); +}); + +describe("the stored title of an archived session at turn end", () => { + it("is published without the prefix to an AIR client only", async () => { + for (const [request, expected] of [ + [air("sessionIndex"), "Done"], + [air(), "Done"], + [{}, "[archived] Done"], + ] as const) { + const { agent, updates } = createAgent(); + await initializeClient(agent, request); + const titles = new SessionTitles(agent, "s1"); + vi.mocked(getSessionInfo).mockResolvedValueOnce({ + sessionId: "s1", + summary: "[archived] Done", + customTitle: "[archived] Done", + lastModified: 0, + }); + await titles.onTurnEnd({ queryClosed: false, cwd: workspace } as any); + expect(updates.map((update: any) => update.update.title)).toEqual([expected]); + } + }); +}); + +describe("a failed rename", () => { + it("leaves the title open to generation", async () => { + const updates: any[] = []; + const agent: any = { + client: { sessionUpdate: async (update: unknown) => updates.push(update) }, + logger: { error: () => {} }, + sessions: {}, + }; + const titles = new SessionTitles(agent, "s1"); + const session: any = { + queryClosed: false, + cancelled: false, + cwd: "/nowhere", + query: { generateSessionTitle: async () => "Generated" }, + }; + agent.sessions.s1 = session; + titles.onPrompt([{ type: "text", text: "Please refactor the parser module" }]); + await expect( + titles.setExplicitTitle("Mine", async () => { + throw new Error("disk full"); + }), + ).rejects.toThrow("disk full"); + vi.mocked(getSessionInfo).mockResolvedValueOnce(undefined); + await titles.onTurnEnd(session); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(updates.map((update) => update.update.title)).toEqual(["Generated"]); + }); +}); + +describe("a title record after a torn last line", () => { + it("is not written for a running session whose CLI cannot take a title", async () => { + const session = await writeTranscript({}); + await fs.appendFile(session.file, '{"type":"assistant","mess'); + const before = await fs.readFile(session.file, "utf8"); + const { agent } = await indexAgent(); + // A running session whose SDK has no rename control request: its CLI + // holds the title and would write it back. + agent.sessions[session.id] = runningSession({ cwd: workspace }, agent, session.id); + await expect( + agent.renameSessionTitle({ sessionId: session.id, title: "Later" }), + ).rejects.toMatchObject({ code: -32600 }); + await expect(agent.archiveSession({ sessionId: session.id })).rejects.toMatchObject({ + code: -32600, + }); + expect(await fs.readFile(session.file, "utf8")).toBe(before); + }); + + it("follows a complete line when no process writes the transcript", async () => { + const session = await writeTranscript({}); + await fs.appendFile(session.file, '{"type":"assistant","mess'); + const { agent } = await indexAgent(); + await agent.renameSessionTitle({ sessionId: session.id, title: "Later" }); + const lines = (await fs.readFile(session.file, "utf8")).split("\n"); + expect(lines.slice(-4)).toEqual([ + '{"type":"assistant","mess', + ...titleRecords(session.id, "Later").map((record) => JSON.stringify(record)), + "", + ]); + }); +}); + +describe("cwd recovery in a directory without any cwd", () => { + it("reads a bounded number of transcripts, and not again until it changes", async () => { + const base = Date.parse("2026-06-01T00:00:00Z"); + const other = path.join(workspace, "other"); + await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base }); + for (let i = 1; i <= 20; i++) await writeTranscript({ lastMessageAt: base - i * 1000 }); + for (let i = 0; i < 100; i++) { + await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base - 3600_000 - i }); + } + const { agent } = await indexAgent(); + vi.mocked(getSessionInfo).mockClear(); + await agent.listSessions({ _meta: listMeta({ limit: 2 }) }); + expect(vi.mocked(getSessionInfo).mock.calls.length).toBeLessThanOrEqual(16 + 64); + vi.mocked(getSessionInfo).mockClear(); + await agent.listSessions({ _meta: listMeta({ limit: 2 }) }); + expect(getSessionInfo).not.toHaveBeenCalled(); + }); +}); + +describe("tail growth", () => { + const big = (id: string, at: number, size: number) => + JSON.stringify({ + type: "assistant", + sessionId: id, + timestamp: new Date(at).toISOString(), + message: { role: "assistant", content: [{ type: "text", text: "z".repeat(size) }] }, + }); + + it("finds a last message beyond the first grown windows", async () => { + const id = randomUUID(); + const file = path.join(workspace, `${id}.jsonl`); + const at = Date.parse("2026-07-01T00:00:00Z"); + await fs.writeFile(file, `${big(id, at - 1000, 10)}\n${big(id, at, 2_000_000)}\n`); + const { size } = await fs.stat(file); + expect((await scanTranscriptFile(file, size)).lastMessageAt).toBe(at); + }); + + it("does not search a transcript of the same size again", async () => { + const id = randomUUID(); + const file = path.join(workspace, `${id}.jsonl`); + const at = Date.parse("2026-07-01T00:00:00Z"); + const filler = JSON.stringify({ type: "progress", data: "q".repeat(5_000_000) }); + const first = `${big(id, at, 10)}\n${filler}\n`; + const size = Buffer.byteLength(first); + await fs.writeFile(file, first); + expect((await scanTranscriptFile(file, size)).lastMessageAt).toBeUndefined(); + // Same path and size, a message 100 KB before the end now: the cached + // miss stands, the tail is not searched again. + const message = big(id, at, 10); + const pad = JSON.stringify({ type: "progress", data: "p".repeat(100_000) }); + const moved = JSON.stringify({ + type: "progress", + data: "q".repeat(5_000_000 - message.length - pad.length - 2), + }); + const second = `${message}\n${moved}\n${message}\n${pad}\n`; + expect(Buffer.byteLength(second)).toBe(size); + await fs.writeFile(file, second); + expect((await scanTranscriptFile(file, size)).lastMessageAt).toBeUndefined(); + }); +}); + +describe("other copies on a running session's rename", () => { + async function runningWithCopy(otherTrailer = "") { + const own = await writeTranscript({ cwd: workspace }); + const other = await writeTranscript({ sessionId: own.id, cwd: path.join(workspace, "moved") }); + if (otherTrailer) await fs.appendFile(other.file, otherTrailer); + const { agent, updates } = await indexAgent(); + agent.sessions[own.id] = mockSessionState( + { cwd: workspace, query: { renameSession: async () => {} } }, + agent, + own.id, + ) as any; + return { agent, updates, own, other }; + } + + it("titles a copy whose last line is torn after closing that line, as no process holds it", async () => { + const { agent, other, own } = await runningWithCopy('{"type":"assistant","mess'); + const before = await fs.readFile(other.file, "utf8"); + await agent.renameSessionTitle({ sessionId: own.id, title: "Live" }); + expect(await fs.readFile(other.file, "utf8")).toBe( + before + + "\n" + + titleRecords(own.id, "Live") + .map((record) => JSON.stringify(record) + "\n") + .join(""), + ); + }); + + it("succeeds once the CLI has the title, even when another copy fails", async () => { + const { agent, updates, other, own } = await runningWithCopy(); + // A file where the sidecar directory would go. + await fs.writeFile(path.join(path.dirname(other.file), own.id), ""); + await agent.renameSessionTitle({ sessionId: own.id, title: "Live" }); + expect(updates).toContainEqual({ + sessionId: own.id, + update: { sessionUpdate: "session_info_update", title: "Live" }, + }); + }); +}); + +describe("list metadata of the listed copy", () => { + it("takes the title from the listed transcript, not another copy", async () => { + const id = randomUUID(); + // Sorted first, so the SDK finds this copy first without a dir. + await writeTranscript({ + sessionId: id, + cwd: path.join(workspace, "a"), + trailer: [{ type: "custom-title", customTitle: "Old copy", sessionId: id }], + }); + // Newer, so the list shows this copy, as the SDK list does. + await writeTranscript({ + sessionId: id, + cwd: path.join(workspace, "b"), + mtimeMs: Date.parse("2026-01-02T00:00:00Z"), + trailer: [{ type: "custom-title", customTitle: "Listed copy", sessionId: id }], + }); + const { agent } = await indexAgent(); + const page = await agent.listSessions({}); + expect(page.sessions.map((s) => s.title)).toEqual(["Listed copy"]); + }); + + it("keeps a row whose copy the SDK does not find", async () => { + const session = await writeTranscript({}); + vi.mocked(getSessionInfo).mockResolvedValueOnce(undefined); + const { agent } = await indexAgent(); + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions.map((s) => [s.sessionId, s.title])).toEqual([[session.id, "Fix it"]]); + }); +}); + +describe("a continued session", () => { + it("is hidden once its successor has history, as in the SDK list", async () => { + const successorId = randomUUID(); + const predecessor = await writeTranscript({ + lastMessageAt: Date.parse("2026-08-01T00:00:00Z"), + trailer: [{ type: "continued-in", continuedInSessionId: successorId }], + }); + const stub = await writeTranscript({ + sessionId: successorId, + lastMessageAt: Date.parse("2026-08-01T01:00:00Z"), + }); + const { agent } = await indexAgent(); + const ids = async () => + (await agent.listSessions({ cwd: workspace })).sessions.map((s) => s.sessionId); + const sdkIds = async () => (await listSessions({ dir: workspace })).map((s) => s.sessionId); + + // The successor has no history yet: both are listed. + expect((await ids()).sort()).toEqual([predecessor.id, stub.id].sort()); + expect((await sdkIds()).sort()).toEqual([predecessor.id, stub.id].sort()); + + await fs.appendFile( + stub.file, + JSON.stringify({ type: "user", parentUuid: null, sessionId: successorId, cwd: workspace }) + + "\n", + ); + expect(await ids()).toEqual([successorId]); + expect(await sdkIds()).toEqual([successorId]); + }); +}); + +describe.skipIf(process.platform !== "darwin")("a project directory renamed in case", () => { + it("is the project directory of the cwd on a case-insensitive volume", async () => { + const lower = path.join(workspace, "repo"); + const upper = path.join(workspace, "Repo"); + const session = await writeTranscript({ cwd: lower }); + if (!fsSync.existsSync(path.join(configDir, "projects", encodeProjectPath(upper)))) return; + const { agent } = await indexAgent(); + const page = await agent.listSessions({ cwd: upper }); + expect(page.sessions.map((s) => s.sessionId)).toEqual([session.id]); + // The SDK finds it too. + expect((await listSessions({ dir: upper })).map((s) => s.sessionId)).toEqual([session.id]); + }); +}); + +describe("delete of a session that runs here", () => { + it("tears it down and deletes it, also when another process holds it", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + agent.sessions[session.id] = mockSessionState( + { input: { end: () => {} }, query: { close: () => {}, interrupt: async () => {} } }, + agent, + session.id, + ) as any; + // Another CLI resumed the session meanwhile. + await registerHolder(process.pid, session.id); + await agent.deleteSession({ sessionId: session.id }); + expect(agent.sessions[session.id]).toBeUndefined(); + expect(fsSync.existsSync(session.file)).toBe(false); + }); +}); + +describe.skipIf(process.platform !== "darwin")( + "a cwd that differs in case from its directory", + () => { + it("is recovered from the transcript", async () => { + const lower = path.join(workspace, "repo"); + const upper = path.join(workspace, "Repo"); + // The repository was renamed in case; the CLI keeps writing to the old directory. + const session = await writeTranscript({ cwd: lower, recordCwd: upper }); + if (!fsSync.existsSync(path.join(configDir, "projects", encodeProjectPath(upper)))) return; + const { agent } = await indexAgent(); + for (const params of [{}, { cwd: upper }]) { + const page = await agent.listSessions(params); + expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[session.id, upper]]); + } + }); + }, +); + +describe("title of a listed copy that starts with a slash command", () => { + it("is the first real prompt, as the SDK titles it", async () => { + const id = randomUUID(); + const dir = path.join(configDir, "projects", encodeProjectPath(workspace)); + await fs.mkdir(dir, { recursive: true }); + const file = path.join(dir, `${id}.jsonl`); + const record = (content: unknown, at: string) => ({ + type: "user", + sessionId: id, + cwd: workspace, + uuid: randomUUID(), + parentUuid: null, + timestamp: at, + message: { role: "user", content }, + }); + await fs.writeFile( + file, + [ + record("/init", "2026-09-01T00:00:00Z"), + record("Add a parser", "2026-09-01T00:00:01Z"), + ] + .map((entry) => JSON.stringify(entry)) + .join("\n") + "\n", + ); + // The SDK does not find this copy: the title comes from the file. + vi.mocked(getSessionInfo).mockResolvedValueOnce(undefined); + const { agent } = await indexAgent(); + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions.map((s) => s.title)).toEqual([ + (await vi + .importActual( + "@anthropic-ai/claude-agent-sdk", + ) + .then((sdk) => sdk.getSessionInfo(id, { dir: workspace })))!.summary, + ]); + expect(page.sessions[0]!.title).toBe("Add a parser"); + }); +}); + +describe("session ids in another case", () => { + it("do not match: a session id matches exactly", async () => { + const session = await writeTranscript({}); + const upper = session.id.toUpperCase(); + const { agent } = await indexAgent(); + const rename = vi.fn(async () => {}); + agent.sessions[session.id] = mockSessionState( + { cwd: workspace, query: { renameSession: rename } }, + agent, + session.id, + ) as any; + const before = await fs.readFile(session.file, "utf8"); + for (const request of [ + () => agent.renameSessionTitle({ sessionId: upper, title: "x" }), + () => agent.archiveSession({ sessionId: upper }), + () => agent.unarchiveSession({ sessionId: upper }), + () => agent.deleteSession({ sessionId: upper }), + ]) { + await expect(request()).rejects.toMatchObject({ code: -32002 }); + } + expect(rename).not.toHaveBeenCalled(); + expect(agent.sessions[session.id]).toBeDefined(); + expect(await fs.readFile(session.file, "utf8")).toBe(before); + }); +}); + +describe("rename of a session that runs here and in another process", () => { + it("is renamed through the CLI that runs it here", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + const rename = vi.fn(async () => {}); + agent.sessions[session.id] = mockSessionState( + { cwd: workspace, query: { renameSession: rename } }, + agent, + session.id, + ) as any; + await registerHolder(process.pid, session.id); + await agent.renameSessionTitle({ sessionId: session.id, title: "Mine" }); + expect(rename).toHaveBeenCalledWith("Mine", session.id); + }); +}); + +describe("a transcript named by an upper-case id", () => { + it("is renamed, archived and deleted under the listed id and its own spelling", async () => { + const upper = randomUUID().toUpperCase(); + const session = await writeTranscript({ sessionId: upper }); + const dir = path.dirname(session.file); + const { agent } = await indexAgent(); + const [row] = (await agent.listSessions({ cwd: workspace })).sessions; + expect(row!.sessionId).toBe(upper); + + await agent.renameSessionTitle({ sessionId: row!.sessionId, title: "Upper" }); + expect(await lastRecords(session.file)).toEqual(titleRecords(upper, "Upper")); + // No transcript of another spelling appeared. + expect((await fs.readdir(dir)).filter((name) => name.endsWith(".jsonl"))).toEqual([ + `${upper}.jsonl`, + ]); + expect((await agent.listSessions({ cwd: workspace })).sessions[0]!.title).toBe("Upper"); + + await agent.archiveSession({ sessionId: row!.sessionId }); + expect( + ( + await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: "all" }) }) + ).sessions.map((s) => (s._meta as any).jetbrains.air.archived), + ).toEqual([true]); + + await agent.deleteSession({ sessionId: row!.sessionId }); + expect(vi.mocked(deleteSession).mock.calls).toEqual([[upper]]); + expect(await fs.readdir(dir)).toEqual([]); + }); +}); + +describe("rename of a running session with a copy under another long path", () => { + it("titles the copy of the other path that shares the cut prefix", async () => { + const base = path.join(workspace, "x".repeat(210)); + const mine = path.join(base, "mine"); + const theirs = path.join(base, "theirs"); + const prefix = encodeProjectPath(mine).slice(0, 200); + // The CLI's own copy, in a directory hashed the CLI's way. + const own = await writeTranscript({ cwd: mine, dirName: `${prefix}-cli0hash` }); + const other = await writeTranscript({ sessionId: own.id, cwd: theirs }); + expect(path.basename(path.dirname(other.file)).startsWith(prefix)).toBe(true); + const ownBefore = await fs.readFile(own.file, "utf8"); + const { agent } = await indexAgent(); + agent.sessions[own.id] = mockSessionState( + { cwd: mine, query: { renameSession: async () => {} } }, + agent, + own.id, + ) as any; + await agent.renameSessionTitle({ sessionId: own.id, title: "Long" }); + + expect(await fs.readFile(own.file, "utf8")).toBe(ownBefore); + expect(await lastRecords(other.file)).toEqual(titleRecords(own.id, "Long")); + expect( + fsSync.existsSync(path.join(path.dirname(other.file), own.id, "custom-title.json")), + ).toBe(true); + }); +}); + +describe("archive state (ACP RFD #2161)", () => { + it("stops a session loaded here: interrupts it, retitles it through its CLI, then closes it", async () => { + const session = await writeTranscript({}); + const { agent, updates } = await indexAgent(); + const close = vi.fn(); + const interrupt = vi.fn(async () => {}); + const rename = cliRename(session.file, session.id); + const loaded = mockSessionState( + { + cwd: workspace, + query: { close, interrupt, renameSession: rename }, + input: { end: vi.fn() }, + }, + agent, + session.id, + ) as any; + agent.sessions[session.id] = loaded; + const before = await fs.readFile(session.file, "utf8"); + + await agent.archiveSession({ sessionId: session.id }); + + const archivedMeta = (archived: boolean) => ({ + sessionId: session.id, + update: { + sessionUpdate: "session_info_update", + _meta: { jetbrains: { air: { version: 1, archived } } }, + }, + }); + expect(updates).toEqual([archivedMeta(true)]); + // Interrupted, retitled while its CLI ran, then closed as session/close + // (whose cancel interrupts again). + expect(interrupt).toHaveBeenCalled(); + expect(rename.mock.calls).toEqual([["[archived] Fix it", session.id]]); + expect(close).toHaveBeenCalledTimes(1); + expect(interrupt.mock.invocationCallOrder[0]).toBeLessThan(rename.mock.invocationCallOrder[0]!); + expect(rename.mock.invocationCallOrder[0]).toBeLessThan(close.mock.invocationCallOrder[0]!); + expect(loaded.queryClosed).toBe(true); + expect(agent.sessions[session.id]).toBeUndefined(); + // A prompt fails as for a closed session. + await expect( + agent.prompt({ sessionId: session.id, prompt: [{ type: "text", text: "go on" }] }), + ).rejects.toThrow("Session not found"); + + // Unarchive does not reopen it, and reports nothing to the closed session. + await agent.unarchiveSession({ sessionId: session.id }); + expect(agent.sessions[session.id]).toBeUndefined(); + expect(updates).toEqual([archivedMeta(true)]); + expect(rename).toHaveBeenCalledTimes(1); + // The CLI wrote the archive title; the adapter wrote the unarchive one. + expect(await fs.readFile(session.file, "utf8")).toBe( + before + + [ + JSON.stringify({ + type: "custom-title", + customTitle: "[archived] Fix it", + sessionId: session.id, + }), + ...titleRecords(session.id, "Fix it").map((record) => JSON.stringify(record)), + ].join("\n") + + "\n", + ); + }); + + it("is reported to a loaded session on unarchive, which keeps running", async () => { + const session = await writeTranscript({ + trailer: titleRecords("ignored", "[archived] Fix it").map((record) => ({ + ...record, + sessionId: "", + })), + }); + const { agent, updates } = await indexAgent(); + const close = vi.fn(); + const interrupt = vi.fn(async () => {}); + const rename = cliRename(session.file, session.id); + const loaded = mockSessionState( + { cwd: workspace, query: { close, interrupt, renameSession: rename } }, + agent, + session.id, + ) as any; + agent.sessions[session.id] = loaded; + + await agent.unarchiveSession({ sessionId: session.id }); + + expect(updates).toEqual([ + { + sessionId: session.id, + update: { + sessionUpdate: "session_info_update", + _meta: { jetbrains: { air: { version: 1, archived: false } } }, + }, + }, + ]); + expect(rename.mock.calls).toEqual([["Fix it", session.id]]); + expect(agent.sessions[session.id]).toBe(loaded); + expect(loaded.queryClosed).toBeFalsy(); + expect(close).not.toHaveBeenCalled(); + expect(interrupt).not.toHaveBeenCalled(); + }); + + it("archives and unarchives a running session whose transcript has an agent name", async () => { + const session = await writeTranscript({ + trailer: [ + ...titleRecords("ignored", "[archived] Fix it"), + ...titleRecords("ignored", "Fix it"), + ].map((record) => ({ ...record, sessionId: "" })), + }); + const { agent } = await indexAgent(); + const listed = async () => + ( + await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: "all" }) }) + ).sessions.map((row) => [row.title, (row._meta as any).jetbrains.air.archived]); + const load = () => { + agent.sessions[session.id] = runningSession( + { cwd: workspace, query: { renameSession: cliRename(session.file, session.id) } }, + agent, + session.id, + ); + }; + + // Loading an archived session does not unarchive it: unarchive it live. + await agent.archiveSession({ sessionId: session.id }); + expect(await listed()).toEqual([["Fix it", true]]); + load(); + await agent.unarchiveSession({ sessionId: session.id }); + expect(await listed()).toEqual([["Fix it", false]]); + + // Archive it live: the CLI writes only the custom title. + await agent.archiveSession({ sessionId: session.id }); + expect(agent.sessions[session.id]).toBeUndefined(); + expect(await listed()).toEqual([["Fix it", true]]); + }); + + it("is not reported for a session not loaded on this connection", async () => { + const session = await writeTranscript({}); + const { agent, updates } = await indexAgent(); + await agent.archiveSession({ sessionId: session.id }); + expect(updates).toEqual([]); + }); + + it("never brings back a deleted session", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + await agent.archiveSession({ sessionId: session.id }); + await agent.deleteSession({ sessionId: session.id }); + for (const archived of ["all", "archived"]) { + expect( + (await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived }) })).sessions, + ).toEqual([]); + } + await expect(agent.unarchiveSession({ sessionId: session.id })).rejects.toMatchObject({ + code: -32002, + }); + await expect(agent.archiveSession({ sessionId: session.id })).rejects.toMatchObject({ + code: -32002, + }); + }); + + it("is unknown for an archived session whose transcript is gone", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + await agent.archiveSession({ sessionId: session.id }); + // The CLI cleanup removed the transcript. + await fs.rm(session.file); + await expect(agent.archiveSession({ sessionId: session.id })).rejects.toMatchObject({ + code: -32002, + }); + await expect(agent.unarchiveSession({ sessionId: session.id })).rejects.toMatchObject({ + code: -32002, + }); + }); + + it("shows a session that an AIR client without sessionIndex marked done as archived", async () => { + const session = await writeTranscript({}); + const legacy = createAgent().agent; + await initializeClient(legacy, air()); + await legacy.deleteSession({ sessionId: session.id }); + + const { agent } = await indexAgent(); + expect((await agent.listSessions({ cwd: workspace })).sessions).toEqual([]); + const all = await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: "all" }) }); + expect(all.sessions.map((s) => [s.sessionId, (s._meta as any).jetbrains.air.archived])).toEqual( + [[session.id, true]], + ); + }); +}); + +describe("session list extensions RFD", () => { + it("reports forkedFrom for a session the SDK forked", async () => { + const parent = await writeTranscript({}); + const { sessionId: child } = await sdkForkSession(parent.id, { dir: workspace }); + const { agent } = await indexAgent(); + const rows = (await agent.listSessions({ cwd: workspace })).sessions; + const forked = rows.find((row) => row.sessionId === child); + expect((forked!._meta as any).jetbrains.air.forkedFrom).toBe(parent.id); + const original = rows.find((row) => row.sessionId === parent.id); + expect((original!._meta as any).jetbrains.air).not.toHaveProperty("forkedFrom"); + }); +}); + +describe("load and resume of a session that another process holds", () => { + const stubOpen = (agent: ClaudeAcpAgent) => { + const opened = vi.fn(async () => ({ sessionId: "x" }) as any); + (agent as any).getOrCreateSession = opened; + (agent as any).createSessionWhileReplaying = opened; + return opened; + }; + + it("opens it for a sessionIndex client, as the CLI does", async () => { + const session = await writeTranscript({}); + await registerHolder(process.pid, session.id); + const { agent } = await indexAgent(); + const opened = stubOpen(agent); + await agent.loadSession({ sessionId: session.id, cwd: workspace, mcpServers: [] }); + await agent.resumeSession({ sessionId: session.id, cwd: workspace, mcpServers: [] }); + expect(opened).toHaveBeenCalledTimes(2); + }); + + it("loads and prompts it for a sessionIndex client", async () => { + const session = await writeTranscript({}); + await registerHolder(process.pid, session.id); + const { agent } = await indexAgent(); + (agent as any).sendAvailableCommandsUpdate = vi.fn(async () => {}); + const input = new Pushable(); + async function* turn() { + const { value: user } = await input[Symbol.asyncIterator]().next(); + yield { ...userEcho(user), session_id: session.id }; + yield successfulResultMessage({ session_id: session.id }); + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } + (agent as any).createSessionWhileReplaying = vi.fn(async () => { + agent.sessions[session.id] = mockSessionState( + { cwd: workspace, query: wrapQuery(turn()), input }, + agent, + session.id, + ); + return { sessionId: session.id }; + }); + + await agent.loadSession({ sessionId: session.id, cwd: workspace, mcpServers: [] }); + const response = await agent.prompt({ + sessionId: session.id, + prompt: [{ type: "text", text: "go on" }], + }); + expect(response.stopReason).toBe("end_turn"); + }); + + it("opens as before for a client without sessionIndex", async () => { + const session = await writeTranscript({}); + await registerHolder(process.pid, session.id); + const { agent } = createAgent(); + await initializeClient(agent, air()); + const opened = stubOpen(agent); + await agent.resumeSession({ sessionId: session.id, cwd: workspace, mcpServers: [] }); + await agent.loadSession({ sessionId: session.id, cwd: workspace, mcpServers: [] }); + expect(opened).toHaveBeenCalledTimes(2); + }); +}); + +describe("paths that share a project directory", () => { + const changesFor = ( + notifications: { method: string; params: Record }[], + subscriptionId: string, + ) => + notifications + .map((n) => n.params as unknown as ListChanges) + .filter((change) => change.subscriptionId === subscriptionId); + + it("are listed and subscribed apart while both exist, as the SDK lists them", async () => { + const dotted = path.join(workspace, "app.v2"); + const dashed = path.join(workspace, "app-v2"); + await fs.mkdir(dotted); + await fs.mkdir(dashed); + expect(encodeProjectPath(dotted)).toBe(encodeProjectPath(dashed)); + const mine = await writeTranscript({ + cwd: dotted, + lastMessageAt: Date.parse("2026-01-02T00:00:00Z"), + }); + const theirs = await writeTranscript({ cwd: dashed }); + const { agent, notifications } = await indexAgent(); + for (const [cwd, id] of [ + [dotted, mine.id], + [dashed, theirs.id], + ] as const) { + const page = await agent.listSessions({ cwd }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[id, cwd]]); + expect((await listSessions({ dir: cwd })).map((s) => s.sessionId)).toEqual([id]); + } + + // A change of the dotted path's session is no change of the dashed list. + const { subscriptionId } = await agent.subscribeSessionList({ cwd: dashed }); + await settle(); + const named = () => + changesFor(notifications, subscriptionId).flatMap((c) => [ + ...c.sessions.map((s) => s.sessionId), + ...c.removed, + ]); + await fs.appendFile(mine.file, promptRecord(mine.id, "Mine") + "\n"); + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(named()).not.toContain(mine.id); + const promptAt = Date.now() - 1000; + await fs.appendFile(theirs.file, promptRecord(theirs.id, "Theirs", promptAt) + "\n"); + const sent = () => + changesFor(notifications, subscriptionId).flatMap((c) => + c.sessions.filter((s) => airRow(s).lastPromptAt === new Date(promptAt).toISOString()), + ); + expect(await waitFor(() => sent().length > 0)).toBe(true); + expect(sent().map((s) => s.sessionId)).toEqual([theirs.id]); + expect(named()).not.toContain(mine.id); + await agent.dispose(); + }, 10_000); + + it("are listed together once the other path is gone, as the SDK lists them", async () => { + const dotted = path.join(workspace, "app.v2"); + const dashed = path.join(workspace, "app-v2"); + await fs.mkdir(dashed); + // `app.v2` does not exist (any more). + const gone = await writeTranscript({ + cwd: dotted, + lastMessageAt: Date.parse("2026-01-02T00:00:00Z"), + }); + const here = await writeTranscript({ cwd: dashed }); + const { agent } = await indexAgent(); + const sdk = await listSessions({ dir: dashed }); + expect(sdk.map((s) => s.sessionId)).toEqual([gone.id, here.id]); + const page = await agent.listSessions({ cwd: dashed }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([ + [gone.id, dotted], + [here.id, dashed], + ]); + // Once it exists again, it is another path's session. + await fs.mkdir(dotted); + expect((await agent.listSessions({ cwd: dashed })).sessions.map((s) => s.sessionId)).toEqual([ + here.id, + ]); + expect((await listSessions({ dir: dashed })).map((s) => s.sessionId)).toEqual([here.id]); + }); + + it("move a session from one path's subscription to the other's", async () => { + const dotted = path.join(workspace, "app.v2"); + const dashed = path.join(workspace, "app-v2"); + await fs.mkdir(dotted); + await fs.mkdir(dashed); + const mine = await writeTranscript({ cwd: dotted }); + await writeTranscript({ cwd: dashed }); + const { agent, notifications } = await indexAgent(); + const fromDotted = await agent.subscribeSessionList({ cwd: dotted }); + const fromDashed = await agent.subscribeSessionList({ cwd: dashed }); + await settle(); + + await fs.appendFile( + mine.file, + JSON.stringify({ type: "relocated", sessionId: mine.id, relocatedCwd: dashed }) + "\n", + ); + const added = () => + changesFor(notifications, fromDashed.subscriptionId) + .flatMap((c) => c.sessions) + .filter((s) => s.sessionId === mine.id); + const removed = () => + changesFor(notifications, fromDotted.subscriptionId).flatMap((c) => c.removed); + expect(await waitFor(() => added().length > 0 && removed().length > 0)).toBe(true); + expect(added().map((s) => [s.sessionId, s.cwd])).toEqual([[mine.id, dashed]]); + // It may also name the other path's session, never listed here. + expect(removed()).toContain(mine.id); + await agent.dispose(); + }); +}); + +describe("a relocation out of the project directory", () => { + it("keeps the session in a subscription, as the list keeps showing it", async () => { + const session = await writeTranscript({}); + const changes: ListChanges[] = []; + const service = new SessionIndexService({ + notifyListChanges: async (change) => { + changes.push(change); + }, + logError: () => {}, + }); + await service.subscribeList(workspace); + await settle(); + await fs.appendFile( + session.file, + JSON.stringify({ type: "relocated", sessionId: session.id, relocatedCwd: "/elsewhere" }) + + "\n", + ); + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(changes.flatMap((change) => change.removed)).toEqual([]); + const rows = await service.index.list({ + cwd: workspace, + limit: 10, + archived: "unarchived", + }); + expect(rows.rows).toHaveLength(1); + service.dispose(); + }); +}); + +describe("worktrees of a subdirectory cwd", () => { + it("are the same subdirectory in each existing worktree", async () => { + const repo = path.join(workspace, "repo"); + const linked = path.join(workspace, "linked"); + const meta = path.join(repo, ".git", "worktrees", "linked"); + await fs.mkdir(meta, { recursive: true }); + await fs.mkdir(path.join(repo, "packages", "a"), { recursive: true }); + await fs.mkdir(path.join(linked, "packages", "a"), { recursive: true }); + await fs.writeFile(path.join(meta, "gitdir"), `${path.join(linked, ".git")}\n`); + await fs.writeFile(path.join(linked, ".git"), `gitdir: ${meta}\n`); + await fs.writeFile(path.join(meta, "commondir"), "../..\n"); + const sub = path.join(repo, "packages", "a"); + const linkedSub = path.join(linked, "packages", "a"); + const base = Date.parse("2026-02-01T00:00:00Z"); + const inSub = await writeTranscript({ cwd: sub, lastMessageAt: base }); + const inLinkedSub = await writeTranscript({ cwd: linkedSub, lastMessageAt: base - 1000 }); + await writeTranscript({ cwd: linked, lastMessageAt: base - 2000 }); + await writeTranscript({ cwd: repo, lastMessageAt: base - 3000 }); + const { agent } = await indexAgent(); + const page = await agent.listSessions({ + cwd: sub, + _meta: listMeta({ includeWorktrees: true }), + }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([ + [inSub.id, sub], + [inLinkedSub.id, linkedSub], + ]); + }); +}); + +describe("list cursor scope", () => { + it("does not bind the limit", async () => { + const base = Date.parse("2026-03-01T00:00:00Z"); + const ids: string[] = []; + for (let i = 0; i < 4; i++) + ids.push((await writeTranscript({ lastMessageAt: base - i * 1000 })).id); + const { agent } = await indexAgent(); + const first = await agent.listSessions({ cwd: workspace, _meta: listMeta({ limit: 1 }) }); + const rest = await agent.listSessions({ + cwd: workspace, + cursor: first.nextCursor, + _meta: listMeta({ limit: 10, archived: null, includeWorktrees: null }), + }); + expect([...first.sessions, ...rest.sessions].map((s) => s.sessionId)).toEqual(ids); + }); +}); + +describe("open a session that runs here and that another process resumed", () => { + it("opens it for a sessionIndex client", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + agent.sessions[session.id] = mockSessionState({}, agent, session.id) as any; + await registerHolder(process.pid, session.id); + (agent as any).getOrCreateSession = vi.fn(async () => ({ sessionId: session.id })); + (agent as any).replaySessionHistory = vi.fn(async () => {}); + (agent as any).sendAvailableCommandsUpdate = vi.fn(async () => {}); + await agent.resumeSession({ sessionId: session.id, cwd: workspace, mcpServers: [] }); + await agent.loadSession({ sessionId: session.id, cwd: workspace, mcpServers: [] }); + expect((agent as any).getOrCreateSession).toHaveBeenCalledTimes(2); + }); +}); + +describe("archive of a loaded session without its transcript", () => { + it("is unknown once the session had history, and allowed for a new unwritten one", async () => { + const { agent, updates } = await indexAgent(); + const query = { renameSession: async () => {} }; + const stale = randomUUID(); + agent.sessions[stale] = mockSessionState({ queryClosed: true }, agent, stale) as any; + const finished = randomUUID(); + agent.sessions[finished] = runningSession( + { lastTurnEndedAt: Date.now(), query }, + agent, + finished, + ); + const resumed = randomUUID(); + agent.sessions[resumed] = runningSession({ resumedFromHistory: true, query }, agent, resumed); + for (const sessionId of [stale, finished, resumed]) { + await expect(agent.archiveSession({ sessionId })).rejects.toMatchObject({ code: -32002 }); + await expect(agent.unarchiveSession({ sessionId })).rejects.toMatchObject({ code: -32002 }); + } + expect(updates).toEqual([]); + // A failed archive does not close the session. + expect(agent.sessions[finished]).toBeDefined(); + + // The CLI holds the title until it writes the transcript. + const fresh = randomUUID(); + const rename = vi.fn(async () => {}); + agent.sessions[fresh] = runningSession({ query: { renameSession: rename } }, agent, fresh); + await agent.archiveSession({ sessionId: fresh }); + const name = `Session ${fresh.slice(0, 8)}`; + expect(rename.mock.calls).toEqual([[`[archived] ${name}`, fresh]]); + expect(updates).toHaveLength(1); + // The archive closed it: without its CLI, it needs a transcript. + expect(agent.sessions[fresh]).toBeUndefined(); + await expect(agent.archiveSession({ sessionId: fresh })).rejects.toMatchObject({ + code: -32002, + }); + }); +}); + +describe("a session relocated to a path with the same project directory", () => { + it("is listed under the cwd it was moved to", async () => { + const from = path.join(workspace, "app.v2"); + const to = path.join(workspace, "app-v2"); + await fs.mkdir(from); + await fs.mkdir(to); + const id = randomUUID(); + const session = await writeTranscript({ + sessionId: id, + cwd: from, + trailer: [{ type: "relocated", sessionId: id, relocatedCwd: to }], + }); + const { agent } = await indexAgent(); + expect( + (await agent.listSessions({ cwd: to })).sessions.map((s) => [s.sessionId, s.cwd]), + ).toEqual([[session.id, to]]); + expect((await agent.listSessions({ cwd: from })).sessions).toEqual([]); + }); +}); + +describe("order by the last user activity", () => { + it("ranks by the last prompt, else updatedAt, and pages and merges archived on that key", async () => { + const now = Date.parse("2026-04-01T12:00:00Z"); + const minute = 60_000; + // Prompted 10 minutes ago; the agent kept working until now. + const workedOn = await writeTranscript({ + lastMessageAt: now - 10 * minute + 1000, + mtimeMs: now + 500, + }); + await fs.appendFile( + workedOn.file, + JSON.stringify({ + type: "assistant", + sessionId: workedOn.id, + cwd: workspace, + uuid: randomUUID(), + timestamp: new Date(now).toISOString(), + message: { + role: "assistant", + content: [{ type: "text", text: "more" }], + stop_reason: "end_turn", + }, + }) + "\n", + ); + await fs.utimes(workedOn.file, (now + 500) / 1000, (now + 500) / 1000); + // Prompted 5 minutes ago, done a minute later. + const recent = await writeTranscript({ lastMessageAt: now - 4 * minute }); + // No real prompt (a slash command only): ordered by updatedAt (7 minutes ago). + const noPrompt = await writeTranscript({ + prompt: "/compact", + lastMessageAt: now - 7 * minute, + }); + const { agent } = await indexAgent(); + + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions.map((s) => s.sessionId)).toEqual([recent.id, noPrompt.id, workedOn.id]); + const workedOnRow = page.sessions[2]!; + expect(workedOnRow.updatedAt).toBe(new Date(now).toISOString()); + expect((workedOnRow._meta as any).jetbrains.air.lastPromptAt).toBe( + new Date(now - 10 * minute).toISOString(), + ); + expect((page.sessions[1]!._meta as any).jetbrains.air).not.toHaveProperty("lastPromptAt"); + + // A cursor anchors on the same key. + const seen: string[] = []; + let cursor: string | undefined; + do { + const next = await agent.listSessions({ + cwd: workspace, + cursor, + _meta: listMeta({ limit: 1 }), + }); + seen.push(...next.sessions.map((s) => s.sessionId)); + cursor = next.nextCursor ?? undefined; + } while (cursor); + expect(seen).toEqual([recent.id, noPrompt.id, workedOn.id]); + + // Archived sessions merge on the same key. + await agent.archiveSession({ sessionId: noPrompt.id }); + expect((await agent.listSessions({ cwd: workspace })).sessions.map((s) => s.sessionId)).toEqual( + [recent.id, workedOn.id], + ); + const all = await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: "all" }) }); + expect(all.sessions.map((s) => s.sessionId)).toEqual([recent.id, noPrompt.id, workedOn.id]); + }); +}); + +describe("a last prompt followed by more than the tail window", () => { + it("is found by growing the tail, and kept while the transcript grows", async () => { + const now = Date.parse("2026-05-01T12:00:00Z"); + const minute = 60_000; + const working = await writeTranscript({ lastMessageAt: now - 10 * minute + 1000 }); + const output = (at: number, size: number) => + JSON.stringify({ + type: "user", + sessionId: working.id, + cwd: workspace, + uuid: randomUUID(), + timestamp: new Date(at).toISOString(), + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t", content: "o".repeat(size) }], + }, + }) + "\n"; + // 300 KB of tool output after the prompt. + await fs.appendFile(working.file, output(now - minute, 300_000)); + await fs.utimes(working.file, now / 1000, now / 1000); + const prompted = await writeTranscript({ lastMessageAt: now - 4 * minute }); + const { agent } = await indexAgent(); + + const ids = async () => + (await agent.listSessions({ cwd: workspace })).sessions.map((s) => s.sessionId); + expect(await ids()).toEqual([prompted.id, working.id]); + const row = (await agent.listSessions({ cwd: workspace })).sessions[1]!; + expect((row._meta as any).jetbrains.air.lastPromptAt).toBe( + new Date(now - 10 * minute).toISOString(), + ); + + // The agent writes on: the session stays where its last prompt puts it. + await fs.appendFile(working.file, output(now, 1000)); + await fs.utimes(working.file, (now + 1000) / 1000, (now + 1000) / 1000); + expect(await ids()).toEqual([prompted.id, working.id]); + }); +}); + +describe("a session whose last prompt is an image or a document", () => { + it("is ordered by that prompt", async () => { + const now = Date.parse("2026-06-01T12:00:00Z"); + const minute = 60_000; + const middle = await writeTranscript({ lastMessageAt: now - 5 * minute }); + const withMedia: string[] = []; + for (const [type, at] of [ + ["image", now - 2 * minute], + ["document", now - minute], + ] as const) { + // A text prompt 10 minutes ago, then a media-only prompt. + const session = await writeTranscript({ lastMessageAt: now - 10 * minute + 1000 }); + await fs.appendFile( + session.file, + JSON.stringify({ + type: "user", + sessionId: session.id, + cwd: workspace, + uuid: randomUUID(), + timestamp: new Date(at).toISOString(), + message: { role: "user", content: [{ type, source: { type: "base64", data: "x" } }] }, + }) + "\n", + ); + await fs.utimes(session.file, (at + 500) / 1000, (at + 500) / 1000); + withMedia.push(session.id); + } + const { agent } = await indexAgent(); + const page = await agent.listSessions({ cwd: workspace }); + expect(page.sessions.map((s) => s.sessionId)).toEqual([withMedia[1], withMedia[0], middle.id]); + expect((page.sessions[0]!._meta as any).jetbrains.air.lastPromptAt).toBe( + new Date(now - minute).toISOString(), + ); + }); +}); + +describe("session index cost", () => { + it("pages through a project reading each transcript once", async () => { + const base = Date.parse("2026-08-01T00:00:00Z"); + for (let i = 0; i < 120; i++) await writeTranscript({ lastMessageAt: base - i * 60_000 }); + const { agent } = await indexAgent(); + vi.mocked(getSessionInfo).mockClear(); + let cursor: string | undefined; + let rows = 0; + do { + const page = await agent.listSessions({ + cwd: workspace, + cursor, + _meta: listMeta({ limit: 10 }), + }); + rows += page.sessions.length; + cursor = page.nextCursor ?? undefined; + } while (cursor); + expect(rows).toBe(120); + // Pages after a cursor skip the transcripts cached before it. + expect(vi.mocked(getSessionInfo).mock.calls.length).toBeLessThanOrEqual(120); + }); + + it("reads only the changed transcript for a subscription", async () => { + const sessions: { id: string; file: string }[] = []; + for (let i = 0; i < 30; i++) sessions.push(await writeTranscript({})); + const changes: ListChanges[] = []; + const service = new SessionIndexService({ + notifyListChanges: async (change) => { + changes.push(change); + }, + logError: () => {}, + }); + await service.subscribeList(workspace); + await settle(); + vi.mocked(getSessionInfo).mockClear(); + await fs.appendFile(sessions[3]!.file, promptRecord(sessions[3]!.id, "Next") + "\n"); + expect(await waitFor(() => changes.length > 0)).toBe(true); + // At most the changed one (it may have been read already, for its + // creation just before subscribe). + for (const [id] of vi.mocked(getSessionInfo).mock.calls) expect(id).toBe(sessions[3]!.id); + service.dispose(); + }); +}); + +describe("a transcript without a prompt in its last 4 MB", () => { + it("keeps what an earlier wide scan found when a small append has none of it", async () => { + const id = randomUUID(); + const file = path.join(workspace, `${id}.jsonl`); + const at = Date.parse("2026-07-01T00:00:00Z"); + const result = (size: number) => + JSON.stringify({ + type: "user", + sessionId: id, + timestamp: new Date(at + 5000).toISOString(), + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t", content: "o".repeat(size) }], + }, + }) + "\n"; + const size = Buffer.byteLength(result(10)); + await fs.writeFile(file, result(10) + result(100)); + const grown = await scanTranscriptFile(file, size + Buffer.byteLength(result(100)), undefined, { + size, + promptSearched: true, + model: "claude-model-x", + lastTurnEndedAt: at, + }); + expect(grown.model).toBe("claude-model-x"); + expect(grown.lastTurnEndedAt).toBe(at); + }); + + it("keeps an inherited turn end when a small append needs a wider tail", async () => { + const id = randomUUID(); + const file = path.join(workspace, `${id}.jsonl`); + const at = Date.parse("2026-07-01T00:00:00Z"); + const line = (entry: object) => JSON.stringify({ sessionId: id, ...entry }) + "\n"; + const working = Array.from({ length: 400 }, (_, i) => + line({ + type: "assistant", + timestamp: new Date(at + 10_000 + i).toISOString(), + message: { + role: "assistant", + stop_reason: "tool_use", + content: [{ type: "text", text: "w".repeat(2_000) }], + }, + }), + ).join(""); + const meta = line({ type: "last-prompt", lastPrompt: "m".repeat(1_000) }).repeat(80); + const before = + line({ + type: "user", + timestamp: new Date(at).toISOString(), + message: { role: "user", content: "Go" }, + }) + + line({ + type: "assistant", + timestamp: new Date(at + 1000).toISOString(), + message: { + role: "assistant", + stop_reason: "end_turn", + content: [{ type: "text", text: "ok" }], + }, + }) + + working + + meta; + const appended = line({ type: "last-prompt", lastPrompt: "x" }); + await fs.writeFile(file, before + appended); + const grown = await scanTranscriptFile(file, Buffer.byteLength(before + appended), undefined, { + size: Buffer.byteLength(before), + lastPromptAt: at, + promptSearched: true, + lastTurnEndedAt: at + 1000, + }); + expect(grown.lastMessageAt).toBeDefined(); + expect(grown.lastTurnEndedAt).toBe(at + 1000); + }); + + it("is searched again when replaced by another file of the same size", async () => { + const id = randomUUID(); + const file = path.join(workspace, `${id}.jsonl`); + const at = Date.parse("2026-07-01T00:00:00Z"); + const record = (type: "user" | "assistant", time: number, text: string) => + JSON.stringify({ + type, + sessionId: id, + timestamp: new Date(time).toISOString(), + message: { role: type, content: type === "user" ? text : [{ type: "text", text }] }, + }) + "\n"; + const output = record("assistant", at, "a".repeat(1_000_000)); + const original = record("user", at, "Start") + output.repeat(5); + await fs.writeFile(file, original); + const size = Buffer.byteLength(original); + expect((await scanTranscriptFile(file, size, undefined, undefined, 1)).lastPromptAt).toBe( + undefined, + ); + const head = + record("user", at, "Start") + output.repeat(3) + record("user", at + 1000, "Hidden"); + const last = record("assistant", at + 2000, ""); + const pad = size - Buffer.byteLength(head + output + last); + const replacement = head + output + record("assistant", at + 2000, "z".repeat(pad)); + expect(Buffer.byteLength(replacement)).toBe(size); + await fs.writeFile(file, replacement); + expect((await scanTranscriptFile(file, size, undefined, undefined, 2)).lastPromptAt).toBe( + at + 1000, + ); + }); + + it("is searched again when another file replaces it", async () => { + const id = randomUUID(); + const dir = path.join(configDir, "projects", encodeProjectPath(workspace)); + await fs.mkdir(dir, { recursive: true }); + const file = path.join(dir, `${id}.jsonl`); + const at = Date.parse("2026-07-01T00:00:00Z"); + const record = (type: "user" | "assistant", time: number, text: string) => + JSON.stringify({ + type, + sessionId: id, + cwd: workspace, + uuid: randomUUID(), + timestamp: new Date(time).toISOString(), + message: { role: type, content: type === "user" ? text : [{ type: "text", text }] }, + }) + "\n"; + const output = record("assistant", at, "a".repeat(1_000_000)); + await fs.writeFile( + file, + record("user", at - 60_000, "Start") + + output.repeat(5) + + record("assistant", at, "y".repeat(40_000)), + ); + const { agent } = await indexAgent(); + const lastPromptAt = async () => + ((await agent.listSessions({ cwd: workspace })).sessions[0]!._meta as any).jetbrains.air + .lastPromptAt; + expect(await lastPromptAt()).toBeUndefined(); + + // A slightly larger file takes its place, grown by less than its tail + // covers: a prompt 1 MB before its end. + const replacement = `${file}.new`; + await fs.writeFile( + replacement, + record("user", at - 60_000, "Start") + + output.repeat(4) + + record("user", at + 1000, "Hidden") + + output + + record("assistant", at + 2000, "z".repeat(40_000)), + ); + await fs.rename(replacement, file); + expect(await lastPromptAt()).toBe(new Date(at + 1000).toISOString()); + }); + + it("is not searched again while it only grows by what its tail covers", async () => { + const id = randomUUID(); + const file = path.join(workspace, `${id}.jsonl`); + const record = (type: "user" | "assistant", at: number, text: string) => + JSON.stringify({ + type, + sessionId: id, + timestamp: new Date(at).toISOString(), + message: { role: type, content: type === "user" ? text : [{ type: "text", text }] }, + }) + "\n"; + const at = Date.parse("2026-07-01T00:00:00Z"); + const output = record("assistant", at, "a".repeat(1_000_000)); + const original = record("user", at, "Start") + output.repeat(5); + await fs.writeFile(file, original); + let size = Buffer.byteLength(original); + const first = await scanTranscriptFile(file, size); + expect(first.lastPromptAt).toBeUndefined(); + expect(first.promptSearched).toBe(true); + + // A prompt 1 MB back and a small append: the earlier full search stands, + // so the 1 MB is not read (the prompt stays unseen). + const replaced = record("user", at, "Hidden") + output + record("assistant", at, "z"); + await fs.writeFile(file, replaced); + size = Buffer.byteLength(replaced); + // The appended bytes (the last record) are inside the tail window. + const grown = await scanTranscriptFile(file, size, undefined, { + size: size - 50, + lastPromptAt: undefined, + promptSearched: true, + }); + expect(grown.lastPromptAt).toBeUndefined(); + expect(grown.promptSearched).toBe(true); + }); +}); + +describe("archive in AIR's title format, edge cases", () => { + it("renames a running archived session through its CLI with the prefix kept", async () => { + const session = await writeTranscript({ + trailer: titleRecords("", "[archived] Fix it"), + }); + const { agent, updates } = await indexAgent(); + const rename = cliRename(session.file, session.id); + agent.sessions[session.id] = runningSession( + { cwd: workspace, query: { renameSession: rename } }, + agent, + session.id, + ); + await agent.renameSessionTitle({ sessionId: session.id, title: "[archived] New" }); + expect(rename.mock.calls).toEqual([["[archived] New", session.id]]); + expect(updates).toContainEqual({ + sessionId: session.id, + update: { sessionUpdate: "session_info_update", title: "New" }, + }); + }); + it("stores a rename as the CLI keeps it, and never archives by a client title", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + await agent.renameSessionTitle({ sessionId: session.id, title: "[archived] Mine" }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "Mine")); + const long = `${"a".repeat(199)} tail`; + await agent.renameSessionTitle({ sessionId: session.id, title: long }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "a".repeat(199))); + await agent.archiveSession({ sessionId: session.id }); + await agent.renameSessionTitle({ sessionId: session.id, title: long }); + expect(await lastRecords(session.file)).toEqual( + titleRecords(session.id, `[archived] ${"a".repeat(189)}`), + ); + }); + + it("ranks an agent name in the head above a later custom title, as AIR does", async () => { + const filler = Array.from({ length: 40 }, () => ({ + type: "system", + subtype: "informational", + content: "x".repeat(4000), + })); + const session = await writeTranscript({ + trailer: [ + ...titleRecords("", "[archived] Done"), + ...filler, + // A custom title alone, as the SDK renameSession writes it. + { type: "custom-title", customTitle: "Plain", sessionId: "" }, + ], + }); + expect((await fs.stat(session.file)).size).toBeGreaterThan(128 * 1024); + const { agent } = await indexAgent(); + const [row] = ( + await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: "all" }) }) + ).sessions; + // The agent name of the head decides, not the custom title of the tail. + expect(row!.title).toBe("Done"); + expect((row!._meta as any).jetbrains.air.archived).toBe(true); + // Unarchive writes both records, so the two names agree again. + await agent.unarchiveSession({ sessionId: session.id }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "Done")); + const [after] = ( + await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: "all" }) }) + ).sessions; + expect(after!.title).toBe("Done"); + expect((after!._meta as any).jetbrains.air.archived).toBe(false); + }); + + it("brings an existing sidecar in line with a transcript already archived", async () => { + const session = await writeTranscript({ + trailer: titleRecords("", "[archived] Done"), + }); + await writeCustomTitleSidecar(session.file, "Done"); + const { agent } = await indexAgent(); + const before = await fs.readFile(session.file, "utf8"); + await agent.archiveSession({ sessionId: session.id }); + expect(await fs.readFile(session.file, "utf8")).toBe(before); + const sidecar = path.join(path.dirname(session.file), session.id, "custom-title.json"); + expect(JSON.parse(await fs.readFile(sidecar, "utf8"))).toEqual({ + customTitle: "[archived] Done", + }); + }); + + it("filters archived rows after reading them, across batches and pages", async () => { + const base = Date.parse("2026-05-01T00:00:00Z"); + const sessions = []; + for (let i = 0; i < 40; i++) { + const archived = i % 3 === 0; + sessions.push({ + archived, + ...(await writeTranscript({ + lastMessageAt: base - i * 1000, + ...(archived && { trailer: titleRecords("", "[archived] Old") }), + })), + }); + } + const { agent } = await indexAgent(); + for (const archived of ["unarchived", "archived"] as const) { + const expected = sessions + .filter((session) => session.archived === (archived === "archived")) + .map((session) => session.id); + const seen: string[] = []; + let cursor: string | undefined; + do { + const page = await agent.listSessions({ + cwd: workspace, + cursor, + _meta: listMeta({ archived, limit: 4 }), + }); + seen.push(...page.sessions.map((s) => s.sessionId)); + cursor = page.nextCursor ?? undefined; + } while (cursor); + expect(seen).toEqual(expected); + } + }); +}); + +describe("an archive that stores nothing", () => { + it("leaves the title open to generation", async () => { + const updates: any[] = []; + const agent: any = { + client: { sessionUpdate: async (update: unknown) => updates.push(update) }, + logger: { error: () => {} }, + sessions: {}, + }; + const titles = new SessionTitles(agent, "s1"); + const session: any = { + queryClosed: false, + cancelled: false, + cwd: "/nowhere", + query: { generateSessionTitle: async () => "Generated" }, + }; + agent.sessions.s1 = session; + titles.onPrompt([{ type: "text", text: "Please refactor the parser module" }]); + await titles.setExplicitTitle(undefined, async () => undefined); + vi.mocked(getSessionInfo).mockResolvedValueOnce(undefined); + await titles.onTurnEnd(session); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(updates.map((update) => update.update.title)).toEqual(["Generated"]); + }); +}); + +describe("archive in AIR's title format, round two", () => { + it("never leaves a twice-prefixed title archived by a rename or an unarchive", async () => { + const session = await writeTranscript({ + trailer: titleRecords("", "[archived] [archived] Twice"), + }); + const { agent } = await indexAgent(); + await agent.unarchiveSession({ sessionId: session.id }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "Twice")); + await agent.renameSessionTitle({ sessionId: session.id, title: "[archived] [archived] X" }); + expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "X")); + }); + + it("publishes the title an archived rename stored", async () => { + const session = await writeTranscript({ + trailer: titleRecords("", "[archived] Fix it"), + }); + const { agent, updates } = await indexAgent(); + agent.sessions[session.id] = runningSession( + { cwd: workspace, query: { renameSession: cliRename(session.file, session.id) } }, + agent, + session.id, + ); + await agent.renameSessionTitle({ sessionId: session.id, title: "x".repeat(200) }); + expect(updates.at(-1)).toEqual({ + sessionId: session.id, + update: { sessionUpdate: "session_info_update", title: "x".repeat(189) }, + }); + }); + it("reads the title of a running session from its transcript once the CLI changed it", async () => { + const session = await writeTranscript({}); + const { agent } = await indexAgent(); + const rename = vi.fn(async (title: string) => { + // The CLI writes the custom title before it answers. + await fs.appendFile( + session.file, + JSON.stringify({ type: "custom-title", customTitle: title, sessionId: session.id }) + "\n", + ); + }); + agent.sessions[session.id] = runningSession( + { cwd: workspace, query: { renameSession: rename } }, + agent, + session.id, + ); + await agent.renameSessionTitle({ sessionId: session.id, title: "First" }); + // A `/rename` in the session, which the adapter does not see. + await fs.appendFile( + session.file, + titleRecords(session.id, "Renamed in the CLI") + .map((record) => JSON.stringify(record) + "\n") + .join(""), + ); + await agent.archiveSession({ sessionId: session.id }); + expect(rename.mock.calls.map(([title]) => title)).toEqual([ + "First", + "[archived] Renamed in the CLI", + ]); + }); + it("unarchives a resumed session that AIR archived through its CLI", async () => { + const session = await writeTranscript({ trailer: titleRecords("", "[archived] Done") }); + const { agent } = await indexAgent(); + const rename = vi.fn(async () => {}); + agent.sessions[session.id] = mockSessionState( + { cwd: workspace, resumedFromHistory: true, query: { renameSession: rename } }, + agent, + session.id, + ) as any; + await agent.unarchiveSession({ sessionId: session.id }); + expect(rename.mock.calls).toEqual([["Done", session.id]]); + }); + + it("keeps the title open to generation across overlapping changes that store nothing", async () => { + const updates: any[] = []; + const agent: any = { + client: { sessionUpdate: async (update: unknown) => updates.push(update) }, + logger: { error: () => {} }, + sessions: {}, + }; + const titles = new SessionTitles(agent, "s1"); + const session: any = { + queryClosed: false, + cancelled: false, + cwd: "/nowhere", + query: { generateSessionTitle: async () => "Generated" }, + }; + agent.sessions.s1 = session; + titles.onPrompt([{ type: "text", text: "Please refactor the parser module" }]); + let release!: () => void; + const first = titles.setExplicitTitle( + undefined, + () => new Promise((resolve) => (release = () => resolve(undefined))), + ); + const second = titles.setExplicitTitle(undefined, async () => undefined); + await new Promise((resolve) => setTimeout(resolve, 5)); + release(); + await Promise.all([first, second]); + vi.mocked(getSessionInfo).mockResolvedValueOnce(undefined); + await titles.onTurnEnd(session); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(updates.map((update) => update.update.title)).toEqual(["Generated"]); + }); +}); + +describe("archive in AIR's title format, round three", () => { + it("titles the copy of the unresolved cwd of a running session here", async () => { + const real = path.join(workspace, "real"); + const link = path.join(workspace, "link"); + await fs.mkdir(real); + await fs.symlink(real, link); + const cliCopy = await writeTranscript({ cwd: real }); + const linkCopy = await writeTranscript({ sessionId: cliCopy.id, cwd: link }); + const { agent } = await indexAgent(); + const rename = cliRename(cliCopy.file, cliCopy.id); + agent.sessions[cliCopy.id] = runningSession( + { cwd: link, query: { renameSession: rename } }, + agent, + cliCopy.id, + ); + await agent.archiveSession({ sessionId: cliCopy.id }); + expect(rename.mock.calls).toEqual([["[archived] Fix it", cliCopy.id]]); + expect(await lastRecords(linkCopy.file)).toEqual(titleRecords(cliCopy.id, "[archived] Fix it")); + }); + + it("keeps updatedAt of a session whose last message outgrows the tail search", async () => { + const at = Date.parse("2026-03-01T00:00:00Z"); + const session = await writeTranscript({ lastMessageAt: at, mtimeMs: at + 60_000 }); + const huge = { + type: "assistant", + sessionId: session.id, + message: { + role: "assistant", + content: [{ type: "text", text: "y".repeat(5 * 1024 * 1024) }], + }, + timestamp: new Date(at + 1000).toISOString(), + }; + await fs.appendFile(session.file, JSON.stringify(huge) + "\n"); + await fs.utimes(session.file, (at + 60_000) / 1000, (at + 60_000) / 1000); + const { agent } = await indexAgent(); + const [before] = (await agent.listSessions({ cwd: workspace })).sessions; + expect(before!.updatedAt).toBe(new Date(at + 1000).toISOString()); + await agent.archiveSession({ sessionId: session.id }); + const [after] = ( + await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived: "archived" }) }) + ).sessions; + expect(after!.updatedAt).toBe(before!.updatedAt); + }); +}); + +describe("archive of a long session", () => { + it("keeps a last prompt that only an earlier scan could see", async () => { + const at = Date.parse("2026-03-01T00:00:00Z"); + const session = await writeTranscript({ lastMessageAt: at }); + const record = (i: number, size: number) => + JSON.stringify({ + type: "assistant", + sessionId: session.id, + message: { + role: "assistant", + content: [{ type: "text", text: "z".repeat(size) }], + stop_reason: "end_turn", + }, + timestamp: new Date(at + 1000 + i).toISOString(), + }) + "\n"; + // Grows past the 4 MB search in steps the tail window covers, listed in + // between, so the prompt is known from the earlier scans only. + await fs.appendFile(session.file, record(0, 4 * 1024 * 1024 - 100 * 1024)); + const { agent } = await indexAgent(); + const list = async (archived = "all") => + (await agent.listSessions({ cwd: workspace, _meta: listMeta({ archived }) })).sessions[0]!; + const lastPromptAt = (row: any) => row._meta.jetbrains.air.lastPromptAt; + expect(lastPromptAt(await list())).toBe(new Date(at - 1000).toISOString()); + for (let i = 1; i <= 6; i++) { + await fs.appendFile(session.file, record(i, 30 * 1024)); + expect(lastPromptAt(await list())).toBe(new Date(at - 1000).toISOString()); + } + await agent.archiveSession({ sessionId: session.id }); + expect(lastPromptAt(await list("archived"))).toBe(new Date(at - 1000).toISOString()); + }); +}); diff --git a/src/tests/session-titles.test.ts b/src/tests/session-titles.test.ts index 23b50472..ed05a478 100644 --- a/src/tests/session-titles.test.ts +++ b/src/tests/session-titles.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js"; -import { appendTitleContext } from "../session-titles.js"; +import { appendTitleContext, sanitizeTitle } from "../session-titles.js"; import { Pushable } from "../utils.js"; import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk"; import { @@ -17,6 +17,36 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => { return { ...actual, getSessionInfo: vi.fn() }; }); +describe("sanitizeTitle", () => { + /** The sanitizer before it learned to read only the start of a long text. */ + function fullScanTitle(text: string): string { + const sanitized = text + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return sanitized.length <= 256 ? sanitized : sanitized.slice(0, 255) + "…"; + } + + it("gives a long text the same title as a scan of the whole text", () => { + const texts = [ + "lorem ipsum dolor sit amet\n".repeat(400_000), + " \n\t".repeat(3000) + "word ".repeat(2000), + // The cut of the scanned prefix falls inside a run of whitespace. + "x".repeat(300) + " ".repeat(5000) + "tail", + // Mostly whitespace: the prefix collapses below a title's length. + "a ".repeat(100) + " ".repeat(10_000) + "b".repeat(300), + " ".repeat(10_000) + "short", + "\u00a0\ufeff".repeat(2100) + "😀".repeat(2000), + "y".repeat(4095) + "😀".repeat(10), + "z".repeat(257) + " ".repeat(4096), + "z".repeat(256) + " ".repeat(4096), + ]; + for (const text of texts) { + expect(sanitizeTitle(text)).toBe(fullScanTitle(text)); + } + }); +}); + describe("SDK title generation contract", () => { // Smoke test for the one undeclared SDK method this feature rests on: // `generateSessionTitle` is on the runtime `Query` class but absent from diff --git a/src/utils.ts b/src/utils.ts index 5ba08dd8..d93b4e21 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -45,12 +45,20 @@ export class Pushable implements AsyncIterable { } } -// 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 { return new WritableStream({ write(chunk) { return new Promise((resolve, reject) => { - nodeStream.write(Buffer.from(chunk), (err) => { + // A Uint8Array is written as it is: Node wraps it in a Buffer view + // without a copy. The ACP encoder hands each message a fresh array, + // so nothing changes the bytes while the write is pending. An + // object-mode stream would get the array itself, so it keeps + // getting a Buffer copy, as before. + const data = nodeStream.writableObjectMode ? Buffer.from(chunk) : chunk; + nodeStream.write(data, (err) => { if (err) { reject(err); } else { @@ -84,6 +92,58 @@ export function unreachable(value: never, logger: Logger = console) { logger.error(`Unexpected case: ${valueAsString}`); } +/** + * Yields to the event loop when the current macrotask has run for too long. + * + * An await of an already-resolved promise continues in a microtask, so a loop + * that drains a backlog of buffered messages never lets timers or I/O run: + * incoming requests, `session/cancel` among them, wait until the backlog is + * gone. A loop calls {@link maybeYield} once per item. Within the budget it + * returns undefined and the loop goes on at once. Past the budget it returns a + * promise that resolves in the check phase of the event loop, after pending + * I/O has run. + * + * The budget is measured from the first call since the event loop last + * reached its check phase, so a loop that already waits on real I/O never + * yields, and all loops that share one instance share one budget per + * event-loop iteration. + * + * A test that fakes `setImmediate` and drives such a loop for longer than the + * budget must advance the fake timers, or the loop waits for them. + */ +export class EventLoopYielder { + /** Whether a check-phase marker is pending. While it is, the event loop has + * not reached its check phase since {@link sliceStart}. */ + private markerPending = false; + private sliceStart = 0; + + constructor( + private readonly budgetMs: number, + private readonly now: () => number = () => performance.now(), + ) {} + + maybeYield(): Promise | 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((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 { return new Promise((resolve) => setTimeout(resolve, time)); }