diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 9574dc09..d32bb45c 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -141,8 +141,8 @@ 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, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, 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`, `sessionListSubscribe` and `sessionWorktrees`. +The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`. +An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`. The response to a client that is not AIR has no `_meta.jetbrains` key. ### Capabilities @@ -158,6 +158,7 @@ The response to a client that is not AIR has no `_meta.jetbrains` key. | `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) | | `backgroundSubagents` | yes | Ends a prompt turn at its result while background subagents still run. | [Background subagents](#background-subagents) | | `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) | +| `sessionBatchGet` | with `sessionIndex` | Supports `_session/batch_get` for metadata of known session IDs. | [Batch get](#batch-get) | | `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) | @@ -1282,8 +1283,8 @@ The session index makes `session/list` a bounded, ordered page with row metadata 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`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)). -The client does not declare these four; declaring them without `sessionIndex` enables nothing. +Exactly when it advertises `sessionIndex`, the agent also advertises `sessionBatchGet` (it supports `_session/batch_get`), `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)). +The client does not declare these five; 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. @@ -1442,6 +1443,71 @@ In all of the session index methods and in `session/delete`, a session id matche Loading an archived session does not unarchive it. The CLI cleanup still deletes old transcripts (`cleanupPeriodDays`), archived or not. +### Batch get + +`_session/batch_get` refreshes metadata for retained session IDs, such as pinned +sessions and folder members outside the recent list page. Check for +`sessionBatchGet` in `initialize.result._meta.jetbrains.air.capabilities` before +calling it. + +The params object must contain only `sessionIds`: an array of distinct, +non-blank strings. IDs match exactly, without trimming or case normalization. +Malformed params, duplicates or extra fields return `Invalid params` (`-32602`). +The agent sets no limit on the number of IDs; the client chooses the batch size +for the latency it accepts. Archived sessions and sessions in other projects +or worktrees are eligible. + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "method": "_session/batch_get", + "params": { "sessionIds": ["oldA", "oldB", "oldC"] } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "result": { + "sessions": [ + { + "sessionId": "oldA", + "cwd": "/project", + "title": "Fix the build", + "updatedAt": "2026-01-01T00:00:00.000Z", + "_meta": { "jetbrains": { "air": { "version": 1, "archived": false } } } + } + ] + } +} +``` + +The example IDs are placeholders for stored Claude session UUIDs. `sessions` +contains the same full rows as `session/list` without a project filter, including +archived sessions and `_meta.jetbrains.air`. Copies use the same +newest-readable-copy selection as the list. Returned sessions preserve their +relative request order. Non-UUID IDs and IDs without a listable transcript are +omitted: absent, empty, unreadable or malformed transcripts, sidechains, sessions +whose continuation has history, and transcripts whose cwd cannot be recovered. +A sidecar alone does not supply a row. + +`{ "sessionIds": [] }` returns `{ "sessions": [] }` without reading the store. +A store-wide failure, such as an unreadable projects root, rejects the request. +Individual results reflect the store as each ID is read. + +Lookup normally addresses only the requested transcript filenames in each project +and reads their metadata. A transcript without a cwd can use a sibling's cwd, +as the list does. This fallback enumerates and stats sibling transcripts only +in the directories of rows still missing a cwd, then reads at most 64 siblings +per directory, newest mtime first. A failed recovery is cached for 60 seconds +unless the directory mtime changes. Other project histories are not enumerated. +The agent looks up 100 IDs at a time; the client may request any number of IDs. +Merge returned rows with the recent page by `sessionId`. A batch read does not +expand an existing list subscription's scope; refresh retained IDs outside that +scope explicitly. + ### List subscription `_session/list/subscribe { "cwd": "…" }` returns `{ "subscriptionId": "…" }`. @@ -1465,6 +1531,7 @@ From then on the agent sends `_session/list/changes` notifications for the sessi - `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. +- There is no page or recent-session limit: changes to older pinned or folder sessions within that scope are reported even outside the page AIR loaded. Pinned IDs outside the subscribed project and worktrees are not tracked; `_session/batch_get` does not add them to the subscription. AIR must subscribe to their project scope or refresh them explicitly. - `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom`, `agentWorktree` 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. @@ -1517,6 +1584,7 @@ Names and semantics follow them; the transport differs, and so do the `archived` | capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` | | capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) | | capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) | +| capability `sessionBatchGet`, `_session/batch_get` | none | | `_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 | diff --git a/src/acp-agent.ts b/src/acp-agent.ts index d02b477b..5856fd1c 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -147,7 +147,11 @@ import { withAirMeta, } from "./air-extension.js"; import * as sessionIndex from "./session-index/connection.js"; -import type { RenameSessionRequest, SessionIdRequest } from "./session-index/service.js"; +import type { + BatchGetSessionsRequest, + RenameSessionRequest, + SessionIdRequest, +} from "./session-index/service.js"; import type { ListSubscribeRequest, ListUnsubscribeRequest, @@ -3249,7 +3253,7 @@ export class ClaudeAcpAgent { return this.sessionIndex.clientTitle(title); } - /** `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`, + /** `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`, * `_session/archive`, `_session/unarchive`. */ async subscribeSessionList(params: ListSubscribeRequest) { return this.sessionIndex.subscribeList(params); @@ -3257,6 +3261,9 @@ export class ClaudeAcpAgent { async unsubscribeSessionList(params: ListUnsubscribeRequest) { return this.sessionIndex.unsubscribeList(params); } + async batchGetSessions(params: BatchGetSessionsRequest) { + return this.sessionIndex.batchGet(params); + } renameSessionTitle(params: RenameSessionRequest) { return this.sessionIndex.rename(params); } diff --git a/src/air-extension.ts b/src/air-extension.ts index 553ddd55..c4425ac2 100644 --- a/src/air-extension.ts +++ b/src/air-extension.ts @@ -22,6 +22,9 @@ export const AIR_PLAN_FILE_CAPABILITY = "planFile"; * subscription. Advertised only to a client that declares it. */ export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex"; +/** Metadata lookup of known ids with `_session/batch_get`. Advertised + * exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is negotiated. */ +export const AIR_SESSION_BATCH_GET_CAPABILITY = "sessionBatchGet"; /** * The agent supports `_session/archive` and `_session/unarchive`. Advertised * exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not diff --git a/src/session-index/connection.ts b/src/session-index/connection.ts index 61d96719..69b762aa 100644 --- a/src/session-index/connection.ts +++ b/src/session-index/connection.ts @@ -20,6 +20,7 @@ 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_BATCH_GET_CAPABILITY, AIR_SESSION_INDEX_CAPABILITY, AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY, AIR_SESSION_RENAME_CAPABILITY, @@ -46,6 +47,8 @@ import { renameTo, parseRenameSessionRequest, parseSessionIdRequest, + parseBatchGetSessionsRequest, + SESSION_BATCH_GET_METHOD, SESSION_ARCHIVE_METHOD, SESSION_RENAME_METHOD, SESSION_UNARCHIVE_METHOD, @@ -54,6 +57,8 @@ import { type RetitleOptions, type SessionIdRequest, type TitleChange, + type BatchGetSessionsRequest, + type BatchGetSessionsResponse, } from "./service.js"; /** What the session index keeps on each `Session`. */ @@ -144,12 +149,13 @@ export class SessionIndexConnection { } /** The AIR capabilities the agent advertises for the index: the index - * itself, archive, rename, the list subscription and the `agentWorktree` + * itself, batch get, archive, rename, the list subscription and the `agentWorktree` * row field, all only to a client that declared `sessionIndex`. */ capabilities(): string[] { return this.service ? [ AIR_SESSION_INDEX_CAPABILITY, + AIR_SESSION_BATCH_GET_CAPABILITY, AIR_SESSION_ARCHIVE_CAPABILITY, AIR_SESSION_RENAME_CAPABILITY, AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY, @@ -230,6 +236,12 @@ export class SessionIndexConnection { return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId)); } + batchGet(params: BatchGetSessionsRequest): Promise { + return this.requireService(SESSION_BATCH_GET_METHOD).batchGet(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. */ @@ -448,6 +460,7 @@ export class SessionIndexConnection { /** The agent methods behind the session index extension methods. */ type SessionIndexMethods = { + batchGetSessions(params: BatchGetSessionsRequest): Promise; subscribeSessionList(params: ListSubscribeRequest): Promise; unsubscribeSessionList(params: ListUnsubscribeRequest): Promise; renameSessionTitle(params: RenameSessionRequest): Promise; @@ -455,10 +468,15 @@ type SessionIndexMethods = { unarchiveSession(params: SessionIdRequest): Promise; }; -/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`, +/** Routes `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`, * `_session/rename`, `_session/archive` and `_session/unarchive`. */ export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp { return app + .onRequest( + SESSION_BATCH_GET_METHOD, + { parse: parseBatchGetSessionsRequest }, + (ctx) => agent().batchGetSessions(ctx.params), + ) .onRequest( LIST_SUBSCRIBE_METHOD, { parse: parseListSubscribeRequest }, diff --git a/src/session-index/project-dirs.ts b/src/session-index/project-dirs.ts index 14ade477..37f19e9c 100644 --- a/src/session-index/project-dirs.ts +++ b/src/session-index/project-dirs.ts @@ -104,6 +104,17 @@ export async function canonicalPath(cwd: string): Promise { } } +/** Whether the path exists, including a symlink whose target is missing. */ +export 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 names of the project directories of `cwd` among `rootEntries`. */ export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] { const exact = encodeProjectPath(cwd); diff --git a/src/session-index/service.ts b/src/session-index/service.ts index ffa090c1..75b4bd77 100644 --- a/src/session-index/service.ts +++ b/src/session-index/service.ts @@ -15,6 +15,7 @@ import { RequestError, type ListSessionsRequest, type ListSessionsResponse, + type SessionInfo, } from "@agentclientprotocol/sdk/unstable"; import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk"; import { airExtensionMeta } from "../air-extension.js"; @@ -37,7 +38,7 @@ import { import { LiveSessionRegistry } from "./live-registry.js"; import { canonicalPath, - errorCode, + exists, isExactProjectDir, isSessionId, projectDirMatches, @@ -65,6 +66,7 @@ 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"; +export const SESSION_BATCH_GET_METHOD = "_session/batch_get"; /** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */ const RESOURCE_NOT_FOUND = -32002; @@ -73,6 +75,27 @@ const CURSOR_VERSION = 5; export type SessionIdRequest = { sessionId: string }; export type RenameSessionRequest = { sessionId: string; title: string }; +export type BatchGetSessionsRequest = { sessionIds: string[] }; +export type BatchGetSessionsResponse = { + sessions: SessionInfo[]; +}; + +export function parseBatchGetSessionsRequest(value: unknown): BatchGetSessionsRequest { + const record = asRecord(value); + const ids = record.sessionIds; + if ( + Object.keys(record).length !== 1 || + !Array.isArray(ids) || + ids.some((id) => typeof id !== "string" || !id.trim()) || + new Set(ids).size !== ids.length + ) { + throw RequestError.invalidParams( + undefined, + "params require only sessionIds: distinct, non-blank strings", + ); + } + return { sessionIds: ids }; +} export function sessionNotFound(sessionId: string): RequestError { return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId }); @@ -364,16 +387,6 @@ 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 { @@ -520,6 +533,22 @@ export class SessionIndexService { return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions }; } + /** Metadata for known ids, independent of list pages and project filters. */ + async batchGet( + { sessionIds }: BatchGetSessionsRequest, + own: (sessionId: string) => OwnSessionState | undefined, + ): Promise { + if (sessionIds.length === 0) return { sessions: [] }; + const live = await this.registry.snapshot(); + const now = this.now(); + const rows = await this.index.getSessions(sessionIds); + return { + sessions: rows.map((row) => + sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now), + ), + }; + } + /** 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(); diff --git a/src/session-index/session-index.ts b/src/session-index/session-index.ts index 0f0a2bd5..a8c2b17a 100644 --- a/src/session-index/session-index.ts +++ b/src/session-index/session-index.ts @@ -26,6 +26,8 @@ import { effectiveTitle, visibleTitle } from "./archive-title.js"; import { canonicalPath, encodeProjectPath, + errorCode, + exists, isExactProjectDir, isSessionId, normalizePath, @@ -58,11 +60,15 @@ import { isInLinkedWorktree, worktreeCounterparts } from "./worktrees.js"; export const DEFAULT_LIST_LIMIT = 50; export const MAX_LIST_LIMIT = 200; +/** Discovery could not establish which sessions the store contains. */ +export class SessionStoreUnavailableError extends Error {} /** 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; +/** Session ids looked up together by {@link SessionIndex.getSessions}. */ +const LOOKUP_CHUNK_SIZE = 100; /** 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. */ @@ -97,6 +103,24 @@ export type TranscriptCandidate = { ino: number; }; +type TranscriptFile = Pick< + TranscriptCandidate, + "sessionId" | "filePath" | "dirName" | "projectPath" +>; + +/** Non-empty transcript files, with bounded stats shared by lists and id lookups. */ +async function transcriptCandidates( + files: readonly TranscriptFile[], +): Promise { + const stats = await statFiles(files.map(({ filePath }) => filePath)); + return files.flatMap((file, i) => { + const current = stats[i]; + return current?.isFile() && current.size > 0 + ? [{ ...file, mtimeMs: current.mtimeMs, size: current.size, ino: current.ino }] + : []; + }); +} + /** The cached metadata of one transcript. */ type TranscriptMetadata = { /** The visible title: without the archive prefix. */ @@ -526,25 +550,16 @@ export class SessionIndex { const listed = await this.listings.namesOfAll(dirPaths); 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 }), + (name) => ({ + sessionId: name.slice(0, -6), + 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 { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates: all }; + const candidates = await transcriptCandidates(files); + return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates }; } /** @@ -580,6 +595,90 @@ export class SessionIndex { }; } + /** Known ids in request order, using the same rows as the list. + * Addresses exact filenames; only rows without a cwd need sibling discovery. */ + async getSessions(sessionIds: readonly string[]): Promise { + if (sessionIds.length === 0) return []; + const root = projectsRoot(); + let projectEntries: string[]; + try { + projectEntries = await fs.readdir(root); + } catch (cause) { + if (errorCode(cause) === "ENOENT" && !(await exists(root))) { + return []; + } + throw new SessionStoreUnavailableError("Session store could not be read", { cause }); + } + const ids = sessionIds.filter(isSessionId); + const byId = new Map(); + // Paths are project directories × ids: bound them per chunk, not per request. + for (let start = 0; start < ids.length; start += LOOKUP_CHUNK_SIZE) { + const chunk = ids.slice(start, start + LOOKUP_CHUNK_SIZE); + for (const row of await this.lookupRows(root, projectEntries, chunk)) { + byId.set(row.sessionId, row); + } + } + return sessionIds.flatMap((id) => { + const row = byId.get(id); + return row ? [row] : []; + }); + } + + /** The rows of `ids` found under `projectEntries`. All copies of an id are + * in the same call, so copy selection is unchanged by chunking. */ + private async lookupRows( + root: string, + projectEntries: readonly string[], + ids: readonly string[], + ): Promise { + let candidates = await transcriptCandidates( + projectEntries.flatMap((dirName) => + ids.map((sessionId) => ({ + sessionId, + filePath: path.join(root, dirName, `${sessionId}.jsonl`), + dirName, + })), + ), + ); + if (CASE_INSENSITIVE_PATHS) { + // On a case-insensitive file system, stat of `ABC.jsonl` also finds + // `abc.jsonl`. Keep a file only when its real name is the requested one, + // or differs by more than case (a symlink, as the list would read it). + // A failed realpath means the file is unreadable, so the id is omitted. + const exact = await Promise.all( + candidates.map(async ({ filePath }) => { + try { + const actual = path.basename(await fs.realpath(filePath)); + const expected = path.basename(filePath); + return actual === expected || actual.toLowerCase() !== expected.toLowerCase(); + } catch { + return false; + } + }), + ); + candidates = candidates.filter((_, i) => exact[i]); + } + return this.collectRows(candidates.sort(byMtimeDescending), { + inScope: scopeOf(undefined), + archived: "all", + wanted: Infinity, + unread: async (read, dirNames) => { + const dirs = [...dirNames]; + const listed = await this.listings.namesOfAll(dirs.map((dir) => path.join(root, dir))); + const siblings = dirs.flatMap((dirName, i) => + listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))) + .map((name) => ({ + sessionId: name.slice(0, -6), + filePath: path.join(root, dirName, name), + dirName, + })) + .filter(({ filePath }) => !read.has(filePath)), + ); + return (await transcriptCandidates(siblings)).sort(byMtimeDescending); + }, + }); + } + /** Every transcript file of `sessionId` (see {@link scanSession}). Empty * files only with `includeEmpty`. */ async findTranscripts( @@ -778,9 +877,12 @@ export class SessionIndex { archived: ArchivedFilter; after?: ListCursor; wanted: number; - /** The transcripts not read (by path), which may supply a sibling's - * cwd. */ - unread: (read: ReadonlySet) => readonly TranscriptCandidate[]; + /** The unread transcripts of the directories still eligible for cwd + * recovery. Called only after checking the negative cache. */ + unread?: ( + read: ReadonlySet, + dirNames: ReadonlySet, + ) => readonly TranscriptCandidate[] | Promise; restat?: boolean; }, ): Promise { @@ -852,14 +954,15 @@ export class SessionIndex { } await settlePending(); } - if (pending.length > 0) { + const unread = options.unread; + if (pending.length > 0 && unread) { // 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), + (dirNames) => unread(read, dirNames), options.restat ?? false, ); await settlePending(); @@ -893,7 +996,9 @@ export class SessionIndex { * changes. `restat` as in {@link collectRows}. */ private async learnDirCwds( dirNames: ReadonlySet, - unread: readonly TranscriptCandidate[], + unread: ( + dirNames: ReadonlySet, + ) => readonly TranscriptCandidate[] | Promise, restatFirst: boolean, ): Promise { const root = projectsRoot(); @@ -909,8 +1014,9 @@ export class SessionIndex { if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS) continue; mtimes.set(dirName, mtimeMs); } + if (mtimes.size === 0) return; const probes = new Map(); - let remaining = unread.filter((candidate) => { + let remaining = (await unread(new Set(mtimes.keys()))).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; diff --git a/src/tests/session-index.test.ts b/src/tests/session-index.test.ts index c754eef7..beea1a80 100644 --- a/src/tests/session-index.test.ts +++ b/src/tests/session-index.test.ts @@ -16,13 +16,18 @@ import { 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/unstable"; +import { ClaudeAcpAgent, v1AgentApp, type AcpClient } from "../acp-agent.js"; +import { + client as acpClient, + type InitializeResponse, + type SessionInfo, +} from "@agentclientprotocol/sdk/unstable"; import { encodeProjectPath } from "../session-index/project-dirs.js"; import { archiveInsteadOfDelete, archiveTo, renameTo, + parseBatchGetSessionsRequest, SessionIndexService, writeCustomTitleSidecar, } from "../session-index/service.js"; @@ -45,6 +50,11 @@ import { } from "./session-doubles.js"; import { Pushable } from "../utils.js"; +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readdir: vi.fn(actual.readdir) }; +}); + vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => { const actual = await importOriginal(); return { @@ -262,6 +272,7 @@ const airCapabilities = (response: { _meta?: Record | null }) = describe("sessionIndex negotiation", () => { const indexCapabilities = [ "sessionIndex", + "sessionBatchGet", "sessionArchive", "sessionRename", "sessionListSubscribe", @@ -295,7 +306,7 @@ describe("sessionIndex negotiation", () => { // Declaring archive or rename alone enables nothing. const withoutIndex = await createAgent().agent.initialize({ protocolVersion: 1, - clientCapabilities: air("sessionArchive", "sessionRename"), + clientCapabilities: air("sessionArchive", "sessionRename", "sessionBatchGet"), }); expect(airCapabilities(withoutIndex)).toEqual(baseline); @@ -319,7 +330,7 @@ describe("sessionIndex negotiation", () => { 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"]]) { + for (const capabilities of [[], ["sessionArchive", "sessionRename", "sessionBatchGet"]]) { const { agent } = createAgent(); await initializeClient(agent, air(...capabilities)); const sessionId = randomUUID(); @@ -328,10 +339,423 @@ describe("sessionIndex negotiation", () => { }); await expect(agent.archiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 }); await expect(agent.unarchiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 }); + await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toMatchObject({ + code: -32601, + }); } }); }); +describe("_session/batch_get", () => { + it("routes and validates the extension through the ACP connection", async () => { + let agent!: ClaudeAcpAgent; + const connection = acpClient({ name: "batch-get-test" }).connect( + v1AgentApp({ log: () => {}, error: () => {} }, (created) => { + agent = created; + }), + ); + try { + const response = await connection.agent.request("initialize", { + protocolVersion: 1, + clientCapabilities: air("sessionIndex"), + }); + expect(airCapabilities(response)).toContain("sessionBatchGet"); + expect(await connection.agent.request("_session/batch_get", { sessionIds: [] })).toEqual({ + sessions: [], + }); + await expect( + connection.agent.request("_session/batch_get", { sessionIds: [], cwd: "/repo" }), + ).rejects.toMatchObject({ code: -32602 }); + } finally { + connection.close(); + await agent.dispose(); + } + }); + + it("accepts only distinct, non-blank string ids and no other fields", () => { + for (const params of [ + null, + [], + {}, + { sessionIds: null }, + { sessionIds: "id" }, + { sessionIds: [""] }, + { sessionIds: [" "] }, + { sessionIds: [1] }, + { sessionIds: ["id", "id"] }, + { sessionIds: [], cwd: null }, + ]) { + expect(() => parseBatchGetSessionsRequest(params)).toThrow( + expect.objectContaining({ code: -32602 }), + ); + } + const sessionIds = Array.from({ length: 1000 }, (_, i) => `id-${i}`); + expect(parseBatchGetSessionsRequest({ sessionIds })).toEqual({ sessionIds }); + // Ids are exact, not trimmed or otherwise normalized. + expect(parseBatchGetSessionsRequest({ sessionIds: [" id ", "id"] })).toEqual({ + sessionIds: [" id ", "id"], + }); + }); + + it("returns empty results without touching the store", async () => { + const { agent } = await indexAgent(); + const index = (agent as any).sessionIndex.service.index; + const lookup = vi.spyOn(index, "getSessions").mockRejectedValue(new Error("store down")); + expect(await agent.batchGetSessions({ sessionIds: [] })).toEqual({ + sessions: [], + }); + expect(lookup).not.toHaveBeenCalled(); + await agent.dispose(); + }); + + it("returns no sessions when the store is absent", async () => { + const { agent } = await indexAgent(); + const sessionIds = [randomUUID(), "retained-id"]; + expect(await agent.batchGetSessions({ sessionIds })).toEqual({ + sessions: [], + }); + await agent.dispose(); + }); + + it("finds ids older than the recent 50 without listing or reading unrelated history", async () => { + const base = Date.parse("2026-03-01T00:00:00Z"); + const ids: string[] = []; + for (let i = 0; i < 55; i++) { + ids.push( + ( + await writeTranscript({ + cwd: path.join(workspace, `project-${i % 2}`), + lastMessageAt: base - i * 60_000, + }) + ).id, + ); + } + const recent = await indexAgent(); + const page = await recent.agent.listSessions({}); + expect(page.sessions).toHaveLength(50); + expect(page.sessions.map((s) => s.sessionId)).not.toContain(ids[54]); + await recent.agent.dispose(); + const { agent } = await indexAgent(); + const index = (agent as any).sessionIndex.service.index; + const list = vi.spyOn(index, "list").mockRejectedValue(new Error("must not list")); + const names = vi.spyOn(index.listings, "namesOfAll").mockRejectedValue(new Error("history")); + const wanted = [ids[54]!, ids[53]!, ids[0]!]; + const readdir = vi.mocked(fs.readdir); + readdir.mockClear(); + try { + const result = await agent.batchGetSessions({ sessionIds: wanted }); + expect(result.sessions.map((s) => s.sessionId)).toEqual(wanted); + expect(transcriptsRead(agent)).toBe(wanted.length); + const root = path.join(configDir, "projects"); + const storeReads = readdir.mock.calls + .map(([dir]) => String(dir)) + .filter((dir) => dir === root || dir.startsWith(root + path.sep)); + expect(storeReads).toEqual([root]); + expect(list).not.toHaveBeenCalled(); + expect(names).not.toHaveBeenCalled(); + expect(listSessions).not.toHaveBeenCalled(); + expect(getSessionInfo).not.toHaveBeenCalled(); + } finally { + readdir.mockClear(); + await agent.dispose(); + } + }); + + it("keeps request order and copy selection across lookup chunks", async () => { + const base = Date.parse("2026-03-01T00:00:00Z"); + const first = await writeTranscript({ lastMessageAt: base }); + const last = await writeTranscript({ lastMessageAt: base - 60_000 }); + const copyId = randomUUID(); + await writeTranscript({ sessionId: copyId, prompt: "Old copy", lastMessageAt: base - 120_000 }); + await writeTranscript({ + sessionId: copyId, + cwd: path.join(workspace, "other"), + prompt: "New copy", + lastMessageAt: base - 30_000, + }); + // 250 ids: three lookup chunks of 100, with found ids in the first, second and third. + const sessionIds: string[] = Array.from({ length: 250 }, () => randomUUID()); + sessionIds[0] = last.id; + sessionIds[120] = copyId; + sessionIds[249] = first.id; + const { agent } = await indexAgent(); + try { + const result = await agent.batchGetSessions({ sessionIds }); + expect(result.sessions.map((s) => s.sessionId)).toEqual([last.id, copyId, first.id]); + expect(result.sessions[1]!.title).toBe("New copy"); + } finally { + await agent.dispose(); + } + }); + + it("recovers a cold batch row from a sibling and keeps it in the next list", async () => { + const base = Date.parse("2026-04-01T00:00:00Z"); + const other = path.join(workspace, "other"); + 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); + } + // This sibling is older than the transcripts needed to fill the page. + await writeTranscript({ cwd: other, lastMessageAt: base - 3600_000 }); + const { agent } = await indexAgent(); + const index = (agent as any).sessionIndex.service.index; + const names = vi.spyOn(index.listings, "namesOfAll"); + const now = vi.spyOn(Date, "now").mockReturnValue(Date.now()); + try { + const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] }); + expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]); + expect(transcriptsRead(agent)).toBe(2); + expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]); + names.mockRestore(); + + // Use the same index without changing files or letting the negative cache expire. + 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], + ]); + expect(page.sessions[0]).toEqual(result.sessions[0]); + } finally { + names.mockRestore(); + now.mockRestore(); + await agent.dispose(); + } + }); + + it("bounds sibling cwd probes and retries a negative result after its cache expires", async () => { + const base = Date.parse("2026-04-01T00:00:00Z"); + const other = path.join(workspace, "other"); + const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base }); + const siblings: { id: string; file: string }[] = []; + for (let i = 1; i <= 70; i++) { + siblings.push( + await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base - i * 1000 }), + ); + } + await writeTranscript({ lastMessageAt: base }); + const { agent } = await indexAgent(); + const index = (agent as any).sessionIndex.service.index; + const names = vi.spyOn(index.listings, "namesOfAll"); + const at = Date.now(); + const now = vi.spyOn(Date, "now").mockReturnValue(at); + try { + expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] }); + expect(transcriptsRead(agent)).toBe(1 + 64); + expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]); + + // Rewriting an existing sibling does not change the directory mtime. + await writeTranscript({ sessionId: siblings[0]!.id, cwd: other, lastMessageAt: base }); + expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] }); + expect(transcriptsRead(agent)).toBe(1 + 64); + expect(names).toHaveBeenCalledTimes(1); + + now.mockReturnValue(at + 60_001); + const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] }); + expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]); + expect(transcriptsRead(agent)).toBe(1 + 64 + 1); + expect(names.mock.calls).toEqual([ + [[path.dirname(noCwd.file)]], + [[path.dirname(noCwd.file)]], + ]); + } finally { + names.mockRestore(); + now.mockRestore(); + await agent.dispose(); + } + }); + + it("returns the same unscoped list rows for archived, worktree and duplicate copies in request order", async () => { + const repo = path.join(workspace, "repo"); + const worktree = path.join(workspace, "worktree"); + await linkWorktrees(repo, worktree); + const archivedId = randomUUID(); + const archived = await writeTranscript({ + sessionId: archivedId, + cwd: repo, + trailer: titleRecords(archivedId, "[archived] Done"), + }); + const worktreeId = randomUUID(); + await writeTranscript({ + sessionId: worktreeId, + cwd: worktree, + trailer: [worktreeState(worktreeId, { path: worktree })], + }); + const copyId = randomUUID(); + await writeTranscript({ sessionId: copyId, cwd: repo, prompt: "Old copy" }); + await writeTranscript({ + sessionId: copyId, + cwd: worktree, + prompt: "New copy", + mtimeMs: Date.parse("2026-02-01"), + }); + const { agent } = await indexAgent(); + const requested = [worktreeId, archived.id, copyId]; + const result = await agent.batchGetSessions({ sessionIds: requested }); + const read = transcriptsRead(agent); + const listed = await agent.listSessions({ _meta: listMeta({ archived: "all" }) }); + expect(transcriptsRead(agent)).toBe(read); + expect(result.sessions).toEqual( + requested.map((id) => listed.sessions.find((s) => s.sessionId === id)), + ); + expect(airRow(result.sessions[1]!).archived).toBe(true); + expect(agentWorktreeOfRow(result.sessions[0])).toBe(true); + expect(result.sessions[2]).toMatchObject({ title: "New copy", cwd: worktree }); + await agent.dispose(); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "returns readable sessions in request order and omits unavailable ids", + async () => { + const first = await writeTranscript({}); + const found = await writeTranscript({}); + const unreadable = await writeTranscript({}); + await fs.chmod(unreadable.file, 0); + const absent = randomUUID(); + const { agent } = await indexAgent(); + try { + const result = await agent.batchGetSessions({ + sessionIds: [absent, first.id, unreadable.id, found.id], + }); + expect(result.sessions.map((s) => s.sessionId)).toEqual([first.id, found.id]); + expect(Object.keys(result)).toEqual(["sessions"]); + } finally { + await fs.chmod(unreadable.file, 0o600); + await agent.dispose(); + } + }, + ); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "uses a readable copy when another copy cannot be read or stat'ed, as the unscoped list does", + async () => { + const sessionId = randomUUID(); + await writeTranscript({ sessionId, prompt: "Readable copy" }); + const hiddenProject = path.join(workspace, "inaccessible"); + const unreadable = await writeTranscript({ + sessionId, + cwd: hiddenProject, + prompt: "Unreadable newer copy", + mtimeMs: Date.parse("2026-02-01"), + }); + const { agent } = await indexAgent(); + try { + // A failed metadata read, then a failed stat of the same copy. + for (const target of [unreadable.file, path.dirname(unreadable.file)]) { + await fs.chmod(target, 0); + const result = await agent.batchGetSessions({ sessionIds: [sessionId] }); + const listed = await agent.listSessions({}); + expect(result).toEqual({ sessions: listed.sessions }); + expect(result.sessions[0]?.title).toBe("Readable copy"); + await fs.chmod(target, 0o700); + } + } finally { + await fs.chmod(path.dirname(unreadable.file), 0o700); + await fs.chmod(unreadable.file, 0o600); + await agent.dispose(); + } + }, + ); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "omits failed stats, dangling links and non-file transcripts", + async () => { + const unreadable = await writeTranscript({ cwd: path.join(workspace, "inaccessible") }); + const dangling = await writeTranscript({}); + await fs.rm(dangling.file); + await fs.symlink(path.join(workspace, "not-there"), dangling.file); + const nonFile = await writeTranscript({}); + await fs.rm(nonFile.file); + await fs.mkdir(nonFile.file); + const { agent } = await indexAgent(); + const projectDir = path.dirname(unreadable.file); + await fs.chmod(projectDir, 0); + try { + const result = await agent.batchGetSessions({ + sessionIds: [unreadable.id, dangling.id, nonFile.id], + }); + expect(result).toEqual({ sessions: [] }); + } finally { + await fs.chmod(projectDir, 0o700); + await agent.dispose(); + } + }, + ); + + it("matches UUIDs exactly without case or whitespace normalization", async () => { + const sessionId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + await writeTranscript({ sessionId }); + const { agent } = await indexAgent(); + const result = await agent.batchGetSessions({ + sessionIds: [sessionId.toUpperCase(), ` ${sessionId} `, sessionId], + }); + expect(result.sessions.map((row) => row.sessionId)).toEqual([sessionId]); + await agent.dispose(); + }); + + it("omits empty, malformed, sidecar-only and unresolved-cwd records", async () => { + const empty = await writeTranscript({}); + await fs.writeFile(empty.file, ""); + const malformed = await writeTranscript({}); + await fs.writeFile(malformed.file, "not json\n"); + const sidecar = randomUUID(); + const sidecarDir = path.join(configDir, "projects", encodeProjectPath(workspace), sidecar); + await fs.mkdir(sidecarDir, { recursive: true }); + await fs.writeFile(path.join(sidecarDir, "custom-title.json"), '{"customTitle":"Saved"}'); + const unresolved = await writeTranscript({ + cwd: path.join(workspace, "unknown"), + recordCwd: null, + }); + const { agent } = await indexAgent(); + const ids = [empty.id, malformed.id, sidecar, unresolved.id]; + const result = await agent.batchGetSessions({ sessionIds: ids }); + expect(result).toEqual({ sessions: [] }); + await agent.dispose(); + }); + + it + .skipIf(process.platform === "win32" || process.getuid?.() === 0) + .each(["unreadable", "unreadable after a cached list", "a file", "a dangling symlink"])( + "rejects a projects root that is %s", + async (kind) => { + const found = await writeTranscript({}); + const root = path.join(configDir, "projects"); + const { agent } = await indexAgent(); + try { + if (kind === "unreadable after a cached list") { + const old = new Date(Date.now() - 60_000); + await fs.utimes(root, old, old); + await agent.listSessions({}); + } + if (kind.startsWith("unreadable")) { + await fs.chmod(root, 0); + } else { + await fs.rm(root, { recursive: true }); + if (kind === "a file") await fs.writeFile(root, "not a store"); + else await fs.symlink(path.join(workspace, "missing-store"), root); + } + for (const sessionId of [found.id, "retained-id"]) { + await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toThrow( + "Session store could not be read", + ); + } + } finally { + if (kind.startsWith("unreadable")) await fs.chmod(root, 0o700); + await agent.dispose(); + } + }, + ); + + it("rejects under ACP v2 even when sessionIndex was declared", async () => { + const { agent } = createAgent({ v2: true }); + await initializeClient(agent, air("sessionIndex")); + await expect(agent.batchGetSessions({ sessionIds: [] })).rejects.toMatchObject({ + code: -32601, + }); + await agent.dispose(); + }); +}); + describe("session/list of a sessionIndex client", () => { const base = Date.parse("2026-03-01T00:00:00Z");