@agentclientprotocol/claude-agent-acp 0.88.1-preview.6 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/acp-agent.d.ts +26 -1
  2. package/dist/acp-agent.d.ts.map +1 -1
  3. package/dist/acp-agent.js +91 -17
  4. package/dist/air-extension.d.ts +23 -0
  5. package/dist/air-extension.d.ts.map +1 -1
  6. package/dist/air-extension.js +23 -0
  7. package/dist/diff.d.ts +2 -0
  8. package/dist/diff.d.ts.map +1 -1
  9. package/dist/diff.js +37 -1
  10. package/dist/mcp-command.d.ts.map +1 -1
  11. package/dist/mcp-command.js +5 -0
  12. package/dist/session-index/activity.d.ts +56 -0
  13. package/dist/session-index/activity.d.ts.map +1 -0
  14. package/dist/session-index/activity.js +96 -0
  15. package/dist/session-index/archive-title.d.ts +52 -0
  16. package/dist/session-index/archive-title.d.ts.map +1 -0
  17. package/dist/session-index/archive-title.js +77 -0
  18. package/dist/session-index/connection.d.ts +152 -0
  19. package/dist/session-index/connection.d.ts.map +1 -0
  20. package/dist/session-index/connection.js +310 -0
  21. package/dist/session-index/dir-listing.d.ts +30 -0
  22. package/dist/session-index/dir-listing.d.ts.map +1 -0
  23. package/dist/session-index/dir-listing.js +110 -0
  24. package/dist/session-index/explicit-title.d.ts +34 -0
  25. package/dist/session-index/explicit-title.d.ts.map +1 -0
  26. package/dist/session-index/explicit-title.js +65 -0
  27. package/dist/session-index/first-prompt.d.ts +37 -0
  28. package/dist/session-index/first-prompt.d.ts.map +1 -0
  29. package/dist/session-index/first-prompt.js +195 -0
  30. package/dist/session-index/list-subscriptions.d.ts +190 -0
  31. package/dist/session-index/list-subscriptions.d.ts.map +1 -0
  32. package/dist/session-index/list-subscriptions.js +1163 -0
  33. package/dist/session-index/live-registry.d.ts +72 -0
  34. package/dist/session-index/live-registry.d.ts.map +1 -0
  35. package/dist/session-index/live-registry.js +240 -0
  36. package/dist/session-index/project-dirs.d.ts +45 -0
  37. package/dist/session-index/project-dirs.d.ts.map +1 -0
  38. package/dist/session-index/project-dirs.js +122 -0
  39. package/dist/session-index/service.d.ts +158 -0
  40. package/dist/session-index/service.d.ts.map +1 -0
  41. package/dist/session-index/service.js +546 -0
  42. package/dist/session-index/session-index.d.ts +201 -0
  43. package/dist/session-index/session-index.d.ts.map +1 -0
  44. package/dist/session-index/session-index.js +740 -0
  45. package/dist/session-index/session-info.d.ts +16 -0
  46. package/dist/session-index/session-info.d.ts.map +1 -0
  47. package/dist/session-index/session-info.js +51 -0
  48. package/dist/session-index/transcript-scan.d.ts +137 -0
  49. package/dist/session-index/transcript-scan.d.ts.map +1 -0
  50. package/dist/session-index/transcript-scan.js +545 -0
  51. package/dist/session-index/worktrees.d.ts +25 -0
  52. package/dist/session-index/worktrees.d.ts.map +1 -0
  53. package/dist/session-index/worktrees.js +128 -0
  54. package/dist/session-titles.d.ts +21 -0
  55. package/dist/session-titles.d.ts.map +1 -1
  56. package/dist/session-titles.js +101 -5
  57. package/dist/utils.d.ts +31 -0
  58. package/dist/utils.d.ts.map +1 -1
  59. package/dist/utils.js +60 -2
  60. package/package.json +1 -1
@@ -0,0 +1,740 @@
1
+ /**
2
+ * The session list of a `sessionIndex` client.
3
+ *
4
+ * Each list enumerates the transcripts of the requested cwd and of every
5
+ * worktree of its repository: one `readdir` per project directory and one
6
+ * `stat` per transcript, no long-lived cache. The metadata of a transcript is
7
+ * cached by `(path, mtime, size)` in an LRU and read on a miss with the SDK
8
+ * `getSessionInfo` (titles, sidecar) plus one head and tail read for
9
+ * what the SDK does not report (see {@link scanTranscript}). Misses are read in
10
+ * parallel batches.
11
+ *
12
+ * Order is the last user activity descending, then session id: the time of
13
+ * the last real user prompt (`lastPromptAt`), else `updatedAt`. `updatedAt`
14
+ * is the time of the last message, capped at the transcript mtime, so a
15
+ * rename or another metadata record does not move a session up, and the
16
+ * order key is never later than it. Candidates are read in mtime order: a
17
+ * candidate's order key is at most its mtime, so the scan stops as soon as
18
+ * the page is full and the next mtime is older than the key of the last row.
19
+ */
20
+ import * as fs from "node:fs/promises";
21
+ import * as path from "node:path";
22
+ import { sanitizeTitle } from "../session-titles.js";
23
+ import { effectiveTitle, visibleTitle } from "./archive-title.js";
24
+ import { canonicalPath, encodeProjectPath, isExactProjectDir, isSessionId, normalizePath, pathAndAncestors, projectDirMatches, projectDirsOf, projectsRoot, sameProjectPath, } from "./project-dirs.js";
25
+ import { continuedInSessionId, hasHistory, isSidechainTranscript, lastTimestamp, readHeadTail, relocatedCwd, scanTranscriptFile, sdkTitles, tailCustomTitle, transcriptAgentName, transcriptProjectCwd, } from "./transcript-scan.js";
26
+ import { DirListings, statFiles } from "./dir-listing.js";
27
+ import { worktreeCounterparts } from "./worktrees.js";
28
+ export const DEFAULT_LIST_LIMIT = 50;
29
+ export const MAX_LIST_LIMIT = 200;
30
+ /** Entries of the metadata cache: a few hundred bytes each, so the
31
+ * transcripts of a large history (tens of thousands) stay cached and deep
32
+ * pages do not read them again. */
33
+ const METADATA_CACHE_SIZE = 50_000;
34
+ const READ_BATCH_SIZE = 16;
35
+ /** Transcripts read at most per directory to recover a sibling's cwd. */
36
+ const MAX_CWD_PROBES_PER_DIR = 64;
37
+ /** How long a directory that gave no cwd is not probed again. */
38
+ const NO_CWD_RETRY_MS = 60_000;
39
+ export const ARCHIVED_FILTERS = ["unarchived", "archived", "all"];
40
+ /** Whether `filter` keeps a session of that archive state. */
41
+ export function archivedFilterKeeps(filter, archived) {
42
+ return filter === "all" || (filter === "archived") === archived;
43
+ }
44
+ /** A small LRU: a `Map` keeps insertion order, and a hit is re-inserted. */
45
+ class Lru {
46
+ capacity;
47
+ entries = new Map();
48
+ constructor(capacity) {
49
+ this.capacity = capacity;
50
+ }
51
+ get(key) {
52
+ const value = this.entries.get(key);
53
+ if (value !== undefined) {
54
+ this.entries.delete(key);
55
+ this.entries.set(key, value);
56
+ }
57
+ return value;
58
+ }
59
+ set(key, value) {
60
+ this.entries.delete(key);
61
+ this.entries.set(key, value);
62
+ if (this.entries.size > this.capacity) {
63
+ this.entries.delete(this.entries.keys().next().value);
64
+ }
65
+ }
66
+ delete(key) {
67
+ this.entries.delete(key);
68
+ }
69
+ /** The value of `key`, without making it recent. */
70
+ peek(key) {
71
+ return this.entries.get(key);
72
+ }
73
+ }
74
+ function compareRows(a, b) {
75
+ if (a.orderAtMs !== b.orderAtMs)
76
+ return b.orderAtMs - a.orderAtMs;
77
+ return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0;
78
+ }
79
+ function toRow({ candidate, metadata }, cwd) {
80
+ return {
81
+ archived: metadata.archived,
82
+ sessionId: candidate.sessionId,
83
+ cwd,
84
+ title: metadata.title,
85
+ updatedAtMs: metadata.updatedAtMs,
86
+ orderAtMs: Math.min(metadata.facts.lastPromptAt ?? metadata.updatedAtMs, metadata.updatedAtMs),
87
+ facts: metadata.facts,
88
+ mtimeMs: candidate.mtimeMs,
89
+ };
90
+ }
91
+ function isAfter(row, cursor) {
92
+ return compareRows(row, cursor) > 0;
93
+ }
94
+ function byMtimeDescending(a, b) {
95
+ if (a.mtimeMs !== b.mtimeMs)
96
+ return b.mtimeMs - a.mtimeMs;
97
+ return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0;
98
+ }
99
+ /** The title of the CLI's `custom-title.json` sidecar of a transcript. */
100
+ export async function readSidecarTitle(filePath, sessionId) {
101
+ try {
102
+ const text = await fs.readFile(path.join(path.dirname(filePath), sessionId, "custom-title.json"), "utf8");
103
+ const title = JSON.parse(text).customTitle;
104
+ return typeof title === "string" && title.trim() ? title : undefined;
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ /**
111
+ * On macOS the SDK opens `<projects>/<encoded cwd>` through the file system,
112
+ * which ignores case on the usual volumes: a directory whose name differs in
113
+ * case only (a repository renamed in case) is the project directory then.
114
+ * Returns that entry of `rootEntries` when the file system resolves the
115
+ * exact name to it.
116
+ */
117
+ async function caseInsensitiveProjectDir(projectPath, rootEntries) {
118
+ if (process.platform !== "darwin")
119
+ return undefined;
120
+ const exact = encodeProjectPath(projectPath);
121
+ if (rootEntries.includes(exact))
122
+ return undefined;
123
+ const lower = exact.toLowerCase();
124
+ const variant = rootEntries.find((name) => name.toLowerCase() === lower);
125
+ if (!variant)
126
+ return undefined;
127
+ try {
128
+ return (await fs.stat(path.join(projectsRoot(), exact))).isDirectory() ? variant : undefined;
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ /** Whether the encoding of `cwd` names the directory `dirName` on disk. */
135
+ async function sameDirOnDisk(dirName, cwd) {
136
+ const root = projectsRoot();
137
+ try {
138
+ const [listed, resolved] = await Promise.all([
139
+ fs.stat(path.join(root, dirName)),
140
+ fs.stat(path.join(root, encodeProjectPath(cwd))),
141
+ ]);
142
+ return listed.isDirectory() && listed.ino === resolved.ino && listed.dev === resolved.dev;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
148
+ /** Whether paths are compared ignoring case, as the SDK does on macOS and
149
+ * Windows. */
150
+ const CASE_INSENSITIVE_PATHS = process.platform === "darwin" || process.platform === "win32";
151
+ /** A path as the SDK compares it: forward slashes, in any case where the
152
+ * file system ignores it. */
153
+ function comparable(value) {
154
+ const slashed = normalizePath(value).replaceAll("\\", "/");
155
+ return CASE_INSENSITIVE_PATHS ? slashed.toLowerCase() : slashed;
156
+ }
157
+ /** Whether `a` and `b` are different paths that encode to the same project
158
+ * directory name (`/ws/app.v2` and `/ws/app-v2`). */
159
+ function collides(a, b) {
160
+ return sameProjectPath(a, b) && comparable(a) !== comparable(b);
161
+ }
162
+ /** A path the SDK does not resolve: a `..` segment, or a UNC or device path. */
163
+ function unresolvable(value) {
164
+ return /(^|[\\/])\.\.([\\/]|$)/.test(value) || /^[\\/]{2}/.test(value);
165
+ }
166
+ /**
167
+ * Which transcripts of the project directories of `paths` (every one
168
+ * without `paths`) the SDK `listSessions` of those paths lists. It leaves out
169
+ * only a transcript whose cwd (see {@link TranscriptMetadata.projectCwd}) is
170
+ * another path that encodes to the same directory and still resolves on disk
171
+ * to such another path; one whose cwd is gone, or lies in one of `paths`, is
172
+ * listed.
173
+ */
174
+ function scopeOf(paths) {
175
+ if (!paths)
176
+ return async () => true;
177
+ const own = paths.map(comparable);
178
+ const resolved = new Map();
179
+ const hidden = async (cwd, projectPath) => {
180
+ if (!collides(cwd, projectPath) || unresolvable(cwd) || unresolvable(projectPath))
181
+ return false;
182
+ let real;
183
+ try {
184
+ real = normalizePath(await fs.realpath(cwd));
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ return collides(real, await canonicalPath(projectPath));
190
+ };
191
+ return async ({ projectPath }, { projectCwd }) => {
192
+ if (projectPath === undefined || projectCwd === undefined)
193
+ return true;
194
+ const cwd = comparable(projectCwd);
195
+ if (own.some((ownPath) => cwd === ownPath || cwd.startsWith(ownPath.endsWith("/") ? ownPath : `${ownPath}/`))) {
196
+ return true;
197
+ }
198
+ const key = `${projectCwd}\0${projectPath}`;
199
+ let result = resolved.get(key);
200
+ if (!result) {
201
+ result = hidden(projectCwd, projectPath).then((isHidden) => !isHidden);
202
+ resolved.set(key, result);
203
+ }
204
+ return result;
205
+ };
206
+ }
207
+ export class SessionIndex {
208
+ getSessionInfo;
209
+ listings = new DirListings();
210
+ metadata = new Lru(METADATA_CACHE_SIZE);
211
+ /** The metadata reads in flight, by path: a list and a subscription that
212
+ * want the same transcript at the same stat read it once. */
213
+ reading = new Map();
214
+ /** A cwd that encodes to each project directory name, learned from its
215
+ * transcripts; it recovers the cwd of a sibling that has none. */
216
+ dirCwds = new Map();
217
+ /** The project cwd of the transcripts of prefix-matched long directories. */
218
+ transcriptCwds = new Lru(METADATA_CACHE_SIZE);
219
+ /** Directories whose transcripts gave no cwd, by the directory mtime and
220
+ * when that was learned. */
221
+ dirsWithoutCwd = new Map();
222
+ constructor(getSessionInfo) {
223
+ this.getSessionInfo = getSessionInfo;
224
+ }
225
+ /** The paths whose sessions a list of `cwd` shows: `cwd`, and with
226
+ * `includeWorktrees` the same subdirectory of every other existing
227
+ * worktree of its repository (the worktree roots for a cwd at the root). */
228
+ async listedPaths(cwd, includeWorktrees) {
229
+ const canonical = await canonicalPath(cwd);
230
+ if (!includeWorktrees)
231
+ return [canonical];
232
+ return [...new Set([canonical, ...(await worktreeCounterparts(canonical))])];
233
+ }
234
+ /** The project directories (names under the projects root) of `paths`.
235
+ * A long path is matched by its cut prefix, which other long paths may
236
+ * share: such a directory counts only when one of its transcripts belongs
237
+ * to the path, as the SDK checks. */
238
+ async projectDirs(paths) {
239
+ const rootEntries = await this.listings.names(projectsRoot());
240
+ const seen = new Set();
241
+ const result = [];
242
+ for (const projectPath of paths) {
243
+ const names = projectDirsOf(projectPath, rootEntries);
244
+ const caseVariant = await caseInsensitiveProjectDir(projectPath, rootEntries);
245
+ if (caseVariant && !names.includes(caseVariant))
246
+ names.unshift(caseVariant);
247
+ for (const dirName of names) {
248
+ if (seen.has(dirName))
249
+ continue;
250
+ if (dirName !== caseVariant &&
251
+ dirName !== encodeProjectPath(projectPath) &&
252
+ !(await this.longDirBelongsTo(dirName, projectPath))) {
253
+ continue;
254
+ }
255
+ seen.add(dirName);
256
+ result.push({ dirName, projectPath });
257
+ }
258
+ }
259
+ return result;
260
+ }
261
+ /** Whether a transcript of the prefix-matched `dirName` belongs to
262
+ * `projectPath`. The cwd of each transcript is cached by its
263
+ * `(path, mtime, size)`, so a transcript that changes is read again. */
264
+ async longDirBelongsTo(dirName, projectPath) {
265
+ const dir = path.join(projectsRoot(), dirName);
266
+ for (const name of await this.listings.names(dir)) {
267
+ if (!name.endsWith(".jsonl"))
268
+ continue;
269
+ const filePath = path.join(dir, name);
270
+ try {
271
+ const stats = await fs.stat(filePath);
272
+ if (!stats.isFile())
273
+ continue;
274
+ let cached = this.transcriptCwds.get(filePath);
275
+ if (!cached || cached.mtimeMs !== stats.mtimeMs || cached.size !== stats.size) {
276
+ cached = {
277
+ mtimeMs: stats.mtimeMs,
278
+ size: stats.size,
279
+ cwd: transcriptProjectCwd(await readHeadTail(filePath, stats.size)),
280
+ };
281
+ this.transcriptCwds.set(filePath, cached);
282
+ }
283
+ if (cached.cwd && sameProjectPath(cached.cwd, projectPath))
284
+ return true;
285
+ }
286
+ catch {
287
+ // Gone or unreadable: look at the next one.
288
+ }
289
+ }
290
+ return false;
291
+ }
292
+ /** Every non-empty transcript file of `paths` (without, of all projects),
293
+ * every copy of a session: a list shows the newest one that has a row
294
+ * (see {@link collectRows}), as the SDK does. */
295
+ async enumerateFiles(paths) {
296
+ const root = projectsRoot();
297
+ const dirs = paths
298
+ ? await this.projectDirs(paths)
299
+ : (await this.listings.names(root)).map((dirName) => ({ dirName }));
300
+ // All directories' names first, then one bounded run of stats.
301
+ const listed = await this.listings.namesOfAll(dirs.map(({ dirName }) => path.join(root, dirName)));
302
+ 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 })));
303
+ const stats = await statFiles(files.map(({ filePath }) => filePath));
304
+ const all = [];
305
+ files.forEach(({ name, filePath, dirName, projectPath }, i) => {
306
+ const fileStats = stats[i];
307
+ if (!fileStats?.isFile() || fileStats.size === 0)
308
+ return;
309
+ all.push({
310
+ sessionId: name.slice(0, -6),
311
+ filePath,
312
+ dirName,
313
+ projectPath,
314
+ mtimeMs: fileStats.mtimeMs,
315
+ size: fileStats.size,
316
+ ino: fileStats.ino,
317
+ });
318
+ });
319
+ return all;
320
+ }
321
+ /**
322
+ * Every transcript (with its size) and every `<sessionId>/` directory
323
+ * (sidecar, subagent transcripts) of the session, in any project
324
+ * directory, by the exact id. One pass over the (cached) project directory
325
+ * listings.
326
+ */
327
+ async scanSession(sessionId) {
328
+ if (!isSessionId(sessionId))
329
+ return { transcripts: [], sessionDirs: [] };
330
+ const root = projectsRoot();
331
+ const dirs = (await this.listings.names(root)).map((dirName) => path.join(root, dirName));
332
+ const listed = await this.listings.namesOfAll(dirs);
333
+ const transcriptName = `${sessionId}.jsonl`;
334
+ const transcriptPaths = dirs.flatMap((dir, i) => listed[i].includes(transcriptName) ? [path.join(dir, transcriptName)] : []);
335
+ const dirPaths = dirs.flatMap((dir, i) => listed[i].includes(sessionId) ? [path.join(dir, sessionId)] : []);
336
+ const [transcriptStats, dirStats] = await Promise.all([
337
+ statFiles(transcriptPaths),
338
+ Promise.all(dirPaths.map((dir) => fs.lstat(dir).catch(() => undefined))),
339
+ ]);
340
+ return {
341
+ transcripts: transcriptPaths.flatMap((filePath, i) => {
342
+ const stats = transcriptStats[i];
343
+ return stats?.isFile() ? [{ filePath, size: stats.size }] : [];
344
+ }),
345
+ sessionDirs: dirPaths.filter((_, i) => dirStats[i]?.isDirectory()),
346
+ };
347
+ }
348
+ /** Every transcript file of `sessionId` (see {@link scanSession}). Empty
349
+ * files only with `includeEmpty`. */
350
+ async findTranscripts(sessionId, options = {}) {
351
+ const { transcripts } = await this.scanSession(sessionId);
352
+ return transcripts
353
+ .filter(({ size }) => size > 0 || options.includeEmpty)
354
+ .map(({ filePath }) => filePath);
355
+ }
356
+ /** Every `<sessionId>/` directory of the session (see {@link scanSession}). */
357
+ async findSessionDirs(sessionId) {
358
+ return (await this.scanSession(sessionId)).sessionDirs;
359
+ }
360
+ /** Whether `candidate` was read at its stat: its metadata is cached, also
361
+ * when it holds no row. False after a failed read. */
362
+ isRead(candidate) {
363
+ const cached = this.metadata.peek(candidate.filePath);
364
+ return (cached !== undefined &&
365
+ cached.mtimeMs === candidate.mtimeMs &&
366
+ cached.size === candidate.size &&
367
+ cached.ino === candidate.ino);
368
+ }
369
+ /** The session that the cached metadata of `filePath` says it was
370
+ * continued in. */
371
+ continuedIn(filePath) {
372
+ return this.metadata.peek(filePath)?.metadata?.continuedIn;
373
+ }
374
+ /** Drops the cached metadata of `filePaths`. */
375
+ invalidate(filePaths) {
376
+ for (const filePath of filePaths)
377
+ this.metadata.delete(filePath);
378
+ }
379
+ /** One page of rows, plus whether more rows follow the page. */
380
+ async list(query) {
381
+ const paths = query.cwd
382
+ ? await this.listedPaths(query.cwd, query.includeWorktrees ?? false)
383
+ : undefined;
384
+ const after = query.after;
385
+ const enumerated = (await this.enumerateFiles(paths)).sort(byMtimeDescending);
386
+ // A page after a cursor skips, without reading them, the sessions whose
387
+ // newest transcript has a cached order key before the cursor. The
388
+ // archive state comes from the title, so the archive filter applies once
389
+ // a transcript is read.
390
+ const skipped = new Set();
391
+ const seen = new Set();
392
+ for (const candidate of enumerated) {
393
+ if (seen.has(candidate.sessionId))
394
+ continue;
395
+ seen.add(candidate.sessionId);
396
+ if (after && this.cachedBefore(candidate, after))
397
+ skipped.add(candidate.sessionId);
398
+ }
399
+ const candidates = enumerated.filter((candidate) => !skipped.has(candidate.sessionId));
400
+ // One row more than the page tells whether a next page exists, so a
401
+ // cursor never leads to an empty page.
402
+ const rows = await this.collectRows(candidates, {
403
+ inScope: scopeOf(paths),
404
+ archived: query.archived,
405
+ after,
406
+ wanted: query.limit + 1,
407
+ unread: (read) => enumerated.filter((candidate) => !read.has(candidate)),
408
+ });
409
+ return { rows: rows.slice(0, query.limit), hasMore: rows.length > query.limit };
410
+ }
411
+ /**
412
+ * The rows of `candidates`, one per session (its newest transcript that has
413
+ * a row), any archive state, as a list of `paths` shows them: a candidate
414
+ * without a row (no title, a sidechain, continued elsewhere, another path
415
+ * that shares the project directory) is left out. `siblings` are the other
416
+ * transcripts of the candidates' directories, read only when a candidate
417
+ * has no cwd of its own. The metadata cache makes an unchanged transcript
418
+ * free.
419
+ */
420
+ async rowsOf(paths, candidates, siblings) {
421
+ return this.collectRows(candidates, {
422
+ inScope: scopeOf(paths),
423
+ archived: "all",
424
+ wanted: Infinity,
425
+ unread: (read) => siblings().filter((candidate) => !read.has(candidate)),
426
+ });
427
+ }
428
+ /**
429
+ * The rows of `candidates` (as {@link rowsOf}) that the metadata cache
430
+ * alone gives, read from it at once, and the ids of the sessions it gives
431
+ * no answer for: a transcript of it has nothing cached at its stat, no cwd
432
+ * known, or is continued in another session (whether the successor hides
433
+ * it depends on the successor now). Reads no transcript.
434
+ */
435
+ async cachedRowsOf(paths, candidates) {
436
+ const unknown = new Set();
437
+ const resolved = [];
438
+ for (const candidate of candidates) {
439
+ const id = candidate.sessionId;
440
+ const cached = this.metadata.peek(candidate.filePath);
441
+ if (!cached ||
442
+ cached.mtimeMs !== candidate.mtimeMs ||
443
+ cached.size !== candidate.size ||
444
+ cached.ino !== candidate.ino ||
445
+ cached.metadata?.continuedIn) {
446
+ unknown.add(id);
447
+ continue;
448
+ }
449
+ if (!cached.metadata)
450
+ continue;
451
+ const cwd = cached.metadata.fileCwd ?? this.fallbackCwd(candidate);
452
+ if (cwd)
453
+ resolved.push({ item: { candidate, metadata: cached.metadata }, cwd });
454
+ else
455
+ unknown.add(id);
456
+ }
457
+ const inScope = scopeOf(paths);
458
+ const newest = new Map();
459
+ for (const { item, cwd } of resolved) {
460
+ const id = item.candidate.sessionId;
461
+ if (unknown.has(id) || !(await inScope(item.candidate, item.metadata)))
462
+ continue;
463
+ const previous = newest.get(id);
464
+ if (previous && previous.mtimeMs >= item.candidate.mtimeMs)
465
+ continue;
466
+ newest.set(id, { row: toRow(item, cwd), mtimeMs: item.candidate.mtimeMs });
467
+ }
468
+ return { rows: [...newest.values()].map(({ row }) => row), unknown };
469
+ }
470
+ /**
471
+ * Reads `candidates` in order into rows, one per session, sorted. A
472
+ * session copied to several transcripts shows its newest one that has a
473
+ * row, as the SDK list does. With a finite `wanted`, stops once `wanted`
474
+ * rows are certain to come first (a candidate's order key is at most its
475
+ * mtime, so `candidates` must be in mtime order then).
476
+ */
477
+ async collectRows(candidates, options) {
478
+ const { inScope, archived, after, wanted } = options;
479
+ /** The row of each session (null: left out by the archive filter or
480
+ * the cursor), and the mtime of the transcript it is from. */
481
+ const bySession = new Map();
482
+ const rows = () => [...bySession.values()].flatMap(({ row }) => (row ? [row] : [])).sort(compareRows);
483
+ // Read transcripts without a cwd of their own: a sibling of the same
484
+ // directory may supply it, whichever batch it is read in.
485
+ let pending = [];
486
+ const accept = async (resolved, cwd) => {
487
+ // The SDK leaves out another path that encodes to the same project
488
+ // directory (see scopeOf).
489
+ if (!(await inScope(resolved.candidate, resolved.metadata)))
490
+ return;
491
+ const { sessionId, mtimeMs } = resolved.candidate;
492
+ const previous = bySession.get(sessionId);
493
+ if (previous && previous.mtimeMs >= mtimeMs)
494
+ return;
495
+ const row = toRow(resolved, cwd);
496
+ // A newer copy that the filter or the cursor leaves out still hides
497
+ // an older one.
498
+ const kept = archivedFilterKeeps(archived, row.archived) && (!after || isAfter(row, after));
499
+ bySession.set(sessionId, { row: kept ? row : null, mtimeMs });
500
+ };
501
+ const settlePending = async () => {
502
+ const left = [];
503
+ for (const resolved of pending) {
504
+ const cwd = this.fallbackCwd(resolved.candidate);
505
+ if (cwd)
506
+ await accept(resolved, cwd);
507
+ else
508
+ left.push(resolved);
509
+ }
510
+ pending = left;
511
+ };
512
+ const read = new Set();
513
+ let index = 0;
514
+ while (index < candidates.length) {
515
+ if (bySession.size >= wanted) {
516
+ const sorted = rows();
517
+ // A candidate's order key is at most its mtime.
518
+ if (sorted.length >= wanted && candidates[index].mtimeMs < sorted[wanted - 1].orderAtMs) {
519
+ break;
520
+ }
521
+ }
522
+ const batch = candidates.slice(index, index + READ_BATCH_SIZE);
523
+ index += batch.length;
524
+ const resolved = await Promise.all(batch.map(async (candidate) => {
525
+ read.add(candidate);
526
+ const metadata = await this.metadataOf(candidate);
527
+ if (!metadata || (await this.continuedElsewhere(candidate, metadata)))
528
+ return undefined;
529
+ return { candidate, metadata };
530
+ }));
531
+ for (const item of resolved) {
532
+ if (!item)
533
+ continue;
534
+ const cwd = item.metadata.fileCwd ?? this.fallbackCwd(item.candidate);
535
+ if (cwd)
536
+ await accept(item, cwd);
537
+ else
538
+ pending.push(item);
539
+ }
540
+ await settlePending();
541
+ }
542
+ if (pending.length > 0) {
543
+ // A row recovered after the scan stopped still sorts into the page: it
544
+ // was read before the stop, and more rows only raise the bound. A
545
+ // sibling that the archive filter or the cursor leaves out may still
546
+ // supply the cwd.
547
+ await this.learnDirCwds(new Set(pending.map(({ candidate }) => candidate.dirName)), options.unread(read));
548
+ await settlePending();
549
+ }
550
+ return rows();
551
+ }
552
+ /** Whether the cached metadata of `candidate`, still current, places it
553
+ * at or before `cursor`. */
554
+ cachedBefore(candidate, cursor) {
555
+ const cached = this.metadata.peek(candidate.filePath);
556
+ if (!cached?.metadata ||
557
+ cached.mtimeMs !== candidate.mtimeMs ||
558
+ cached.size !== candidate.size ||
559
+ cached.ino !== candidate.ino) {
560
+ return false;
561
+ }
562
+ const { metadata } = cached;
563
+ const orderAtMs = Math.min(metadata.facts.lastPromptAt ?? metadata.updatedAtMs, metadata.updatedAtMs);
564
+ return !isAfter({ orderAtMs, sessionId: candidate.sessionId }, cursor);
565
+ }
566
+ /** Reads the unread transcripts of `dirNames` until each directory has a
567
+ * known cwd, at most {@link MAX_CWD_PROBES_PER_DIR} per directory. A
568
+ * directory that gives none is not read again for a while, unless it
569
+ * changes. */
570
+ async learnDirCwds(dirNames, unread) {
571
+ const root = projectsRoot();
572
+ const now = Date.now();
573
+ const mtimes = new Map();
574
+ for (const dirName of dirNames) {
575
+ const mtimeMs = await fs.stat(path.join(root, dirName)).then((stats) => stats.mtimeMs, () => undefined);
576
+ if (mtimeMs === undefined)
577
+ continue;
578
+ const known = this.dirsWithoutCwd.get(dirName);
579
+ if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS)
580
+ continue;
581
+ mtimes.set(dirName, mtimeMs);
582
+ }
583
+ const probes = new Map();
584
+ let remaining = unread.filter((candidate) => {
585
+ if (!mtimes.has(candidate.dirName))
586
+ return false;
587
+ const count = probes.get(candidate.dirName) ?? 0;
588
+ if (count >= MAX_CWD_PROBES_PER_DIR)
589
+ return false;
590
+ probes.set(candidate.dirName, count + 1);
591
+ return true;
592
+ });
593
+ while (remaining.length > 0) {
594
+ remaining = remaining.filter((candidate) => !this.dirCwds.has(candidate.dirName));
595
+ const batch = remaining.slice(0, READ_BATCH_SIZE);
596
+ remaining = remaining.slice(batch.length);
597
+ await Promise.all(batch.map((candidate) => this.metadataOf(candidate)));
598
+ }
599
+ for (const [dirName, mtimeMs] of mtimes) {
600
+ if (!this.dirCwds.has(dirName))
601
+ this.dirsWithoutCwd.set(dirName, { mtimeMs, at: now });
602
+ }
603
+ }
604
+ /** The requested path of the directory, else a sibling's cwd. */
605
+ fallbackCwd(candidate) {
606
+ // A directory found for the requested path belongs to it, also one that
607
+ // differs in case only (see caseInsensitiveProjectDir).
608
+ if (candidate.projectPath &&
609
+ (projectDirMatches(candidate.dirName, candidate.projectPath) ||
610
+ isExactProjectDir(candidate.dirName, candidate.projectPath))) {
611
+ return candidate.projectPath;
612
+ }
613
+ return this.dirCwds.get(candidate.dirName);
614
+ }
615
+ async metadataOf(candidate) {
616
+ const cached = this.metadata.get(candidate.filePath);
617
+ if (cached &&
618
+ cached.mtimeMs === candidate.mtimeMs &&
619
+ cached.size === candidate.size &&
620
+ cached.ino === candidate.ino) {
621
+ if (cached.metadata?.fileCwd)
622
+ this.dirCwds.set(candidate.dirName, cached.metadata.fileCwd);
623
+ return cached.metadata;
624
+ }
625
+ const key = `${candidate.mtimeMs}:${candidate.size}:${candidate.ino}`;
626
+ const inFlight = this.reading.get(candidate.filePath);
627
+ if (inFlight?.key === key)
628
+ return inFlight.promise;
629
+ const promise = this.readAndCache(candidate, cached);
630
+ this.reading.set(candidate.filePath, { key, promise });
631
+ void promise.finally(() => {
632
+ if (this.reading.get(candidate.filePath)?.promise === promise) {
633
+ this.reading.delete(candidate.filePath);
634
+ }
635
+ });
636
+ return promise;
637
+ }
638
+ async readAndCache(candidate, cached) {
639
+ let metadata;
640
+ try {
641
+ // The transcript grew since: an earlier scan may still know its last
642
+ // prompt.
643
+ metadata = await this.readMetadata(candidate, cached && cached.ino === candidate.ino
644
+ ? { ...cached.metadata?.facts, size: cached.size }
645
+ : undefined);
646
+ }
647
+ catch {
648
+ // Unreadable now (deleted, permissions): skip it and retry next time.
649
+ return null;
650
+ }
651
+ this.metadata.set(candidate.filePath, {
652
+ mtimeMs: candidate.mtimeMs,
653
+ size: candidate.size,
654
+ ino: candidate.ino,
655
+ metadata,
656
+ });
657
+ return metadata;
658
+ }
659
+ async readMetadata(candidate, previous) {
660
+ const headTail = await readHeadTail(candidate.filePath, candidate.size);
661
+ if (isSidechainTranscript(headTail.head))
662
+ return null;
663
+ const facts = await scanTranscriptFile(candidate.filePath, candidate.size, headTail, previous, candidate.ino);
664
+ if (!facts.hasMessages)
665
+ return null;
666
+ // The last relocation names the session's cwd, as the SDK reads it; the
667
+ // messages before it keep the old one.
668
+ const fileCwd = await this.recoverCwd(candidate.dirName, [
669
+ relocatedCwd(headTail.tail),
670
+ facts.headCwd,
671
+ ...(facts.tailCwd ? pathAndAncestors(facts.tailCwd) : []),
672
+ ]);
673
+ if (fileCwd)
674
+ this.dirCwds.set(candidate.dirName, fileCwd);
675
+ const sdk = await this.titlesOf(candidate, headTail, fileCwd);
676
+ // No title at all: the SDK does not list it either.
677
+ if (!sdk.summary)
678
+ return null;
679
+ // Archived by its name alone: the agent name, else the custom title.
680
+ const { title = sdk.summary, archived } = effectiveTitle(transcriptAgentName(headTail), sdk);
681
+ // A last message longer than the tail search still ends with its
682
+ // timestamp; the mtime moves with every metadata record.
683
+ const lastMessageAt = facts.lastMessageAt ??
684
+ (await lastTimestamp(candidate.filePath, candidate.size)) ??
685
+ candidate.mtimeMs;
686
+ const continuedIn = continuedInSessionId(headTail.tail);
687
+ const projectCwd = transcriptProjectCwd(headTail);
688
+ return {
689
+ title: sanitizeTitle(archived ? visibleTitle(title) : title),
690
+ archived,
691
+ ...(fileCwd && { fileCwd }),
692
+ ...(projectCwd && { projectCwd }),
693
+ ...(continuedIn && { continuedIn }),
694
+ updatedAtMs: Math.min(lastMessageAt, candidate.mtimeMs),
695
+ facts,
696
+ };
697
+ }
698
+ /**
699
+ * The titles the SDK reports for the listed transcript (`customTitle` and
700
+ * `summary` of `getSessionInfo`). The SDK reads the first copy of the
701
+ * session that its search finds; its answer is used only when that copy is
702
+ * the listed file (same size and mtime), else the same titles are taken
703
+ * from the listed file itself (see {@link sdkTitles}).
704
+ */
705
+ async titlesOf(candidate, headTail, fileCwd) {
706
+ const dir = [fileCwd, candidate.projectPath].find((cwd) => cwd !== undefined && isExactProjectDir(candidate.dirName, cwd));
707
+ const info = await this.getSessionInfo(candidate.sessionId, dir ? { dir } : {}).catch(() => undefined);
708
+ if (info &&
709
+ info.fileSize === candidate.size &&
710
+ info.lastModified === Math.trunc(candidate.mtimeMs)) {
711
+ return { customTitle: info.customTitle, summary: info.summary };
712
+ }
713
+ const sidecar = tailCustomTitle(headTail.tail) === undefined
714
+ ? await readSidecarTitle(candidate.filePath, candidate.sessionId)
715
+ : undefined;
716
+ return sdkTitles(headTail, sidecar);
717
+ }
718
+ /** Whether a successor of a continued transcript holds history: the SDK
719
+ * list then hides the predecessor. */
720
+ async continuedElsewhere(candidate, metadata) {
721
+ if (!metadata.continuedIn)
722
+ return false;
723
+ return hasHistory(path.join(path.dirname(candidate.filePath), `${metadata.continuedIn}.jsonl`));
724
+ }
725
+ /** The first candidate that encodes to `dirName`, or, on macOS, that
726
+ * encodes to a name the file system resolves to `dirName` (it differs in
727
+ * case only). A directory name is never decoded. */
728
+ async recoverCwd(dirName, candidates) {
729
+ for (const candidate of candidates) {
730
+ if (!candidate || !path.isAbsolute(candidate))
731
+ continue;
732
+ if (projectDirMatches(dirName, candidate))
733
+ return candidate;
734
+ if (isExactProjectDir(dirName, candidate) && (await sameDirOnDisk(dirName, candidate))) {
735
+ return candidate;
736
+ }
737
+ }
738
+ return undefined;
739
+ }
740
+ }