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

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,546 @@
1
+ /**
2
+ * The `sessionIndex` AIR extension: the session list, rename, archive and
3
+ * delete of a client that declared the capability.
4
+ *
5
+ * Wire contract: docs/air-extensions.md, "Session index". Everything here is
6
+ * reached only for a `sessionIndex` client, except the archive that an AIR
7
+ * client without the capability gets in place of a delete (see
8
+ * `ClaudeAcpAgent.deleteSession`).
9
+ */
10
+ import { randomBytes } from "node:crypto";
11
+ import * as fs from "node:fs/promises";
12
+ import * as path from "node:path";
13
+ import { RequestError, } from "@agentclientprotocol/sdk";
14
+ import { deleteSession as sdkDeleteSession, getSessionInfo as sdkGetSessionInfo, } from "@anthropic-ai/claude-agent-sdk";
15
+ import { airExtensionMeta } from "../air-extension.js";
16
+ import { sanitizeTitle } from "../session-titles.js";
17
+ import { effectiveTitle, isArchivedTitle, storedTitle, titleRecords, visibleTitle, } from "./archive-title.js";
18
+ import { ListSubscriptions, } from "./list-subscriptions.js";
19
+ import { LiveSessionRegistry } from "./live-registry.js";
20
+ import { canonicalPath, errorCode, isExactProjectDir, isSessionId, projectDirMatches, sameProjectPath, } from "./project-dirs.js";
21
+ import { readHeadTail, readHeadTailOf, sdkTitles, tailCustomTitle, transcriptAgentName, transcriptProjectCwd, } from "./transcript-scan.js";
22
+ import { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, ARCHIVED_FILTERS, readSidecarTitle, SessionIndex, } from "./session-index.js";
23
+ import { sessionInfoOf } from "./session-info.js";
24
+ export const SESSION_RENAME_METHOD = "_session/rename";
25
+ export const SESSION_ARCHIVE_METHOD = "_session/archive";
26
+ export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
27
+ /** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
28
+ const RESOURCE_NOT_FOUND = -32002;
29
+ /** The cursor format. A cursor of another version is rejected. */
30
+ const CURSOR_VERSION = 5;
31
+ export function sessionNotFound(sessionId) {
32
+ return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
33
+ }
34
+ function asRecord(value) {
35
+ return value && typeof value === "object" && !Array.isArray(value)
36
+ ? value
37
+ : {};
38
+ }
39
+ export function parseSessionIdRequest(value) {
40
+ const sessionId = asRecord(value).sessionId;
41
+ if (typeof sessionId !== "string" || !sessionId.trim()) {
42
+ throw RequestError.invalidParams(undefined, "params require a non-empty sessionId");
43
+ }
44
+ return { sessionId: sessionId.trim() };
45
+ }
46
+ export function parseRenameSessionRequest(value) {
47
+ const { sessionId } = parseSessionIdRequest(value);
48
+ const raw = asRecord(value).title;
49
+ const title = typeof raw === "string" ? sanitizeTitle(raw) : "";
50
+ if (!title)
51
+ throw RequestError.invalidParams(undefined, "title must be a non-empty string");
52
+ return { sessionId, title };
53
+ }
54
+ function archivedFilter(list) {
55
+ // Omitted or null is `unarchived`.
56
+ const value = list.archived ?? "unarchived";
57
+ if (!ARCHIVED_FILTERS.includes(value)) {
58
+ throw RequestError.invalidParams({ archived: value }, '`_meta.jetbrains.air.list.archived` must be "unarchived", "archived" or "all"');
59
+ }
60
+ return value;
61
+ }
62
+ function optionalBoolean(list, key) {
63
+ // Omitted or null is false.
64
+ const value = list[key] ?? false;
65
+ if (typeof value !== "boolean") {
66
+ throw RequestError.invalidParams({ [key]: value }, `\`_meta.jetbrains.air.list.${key}\` must be a boolean`);
67
+ }
68
+ return value;
69
+ }
70
+ /** `_meta.jetbrains.air.list` of a list request. */
71
+ export function parseListOptions(meta) {
72
+ const list = asRecord(airExtensionMeta(meta)?.list);
73
+ // Omitted or null is the default; an integer of at least 1 is clamped;
74
+ // anything else is invalid.
75
+ const raw = list.limit ?? DEFAULT_LIST_LIMIT;
76
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
77
+ throw RequestError.invalidParams({ limit: raw }, "`_meta.jetbrains.air.list.limit` must be an integer of at least 1");
78
+ }
79
+ return {
80
+ limit: Math.min(MAX_LIST_LIMIT, raw),
81
+ archived: archivedFilter(list),
82
+ includeWorktrees: optionalBoolean(list, "includeWorktrees"),
83
+ };
84
+ }
85
+ export function encodeListCursor(cursor, scope) {
86
+ const payload = {
87
+ v: CURSOR_VERSION,
88
+ u: cursor.orderAtMs,
89
+ id: cursor.sessionId,
90
+ cwd: scope.cwd,
91
+ archived: scope.archived,
92
+ worktrees: scope.includeWorktrees,
93
+ };
94
+ return Buffer.from(JSON.stringify(payload)).toString("base64url");
95
+ }
96
+ /** The position a cursor names. A cursor of another cwd or filter, or one
97
+ * this adapter did not issue, is rejected. */
98
+ export function decodeListCursor(cursor, scope) {
99
+ let payload;
100
+ try {
101
+ payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
102
+ }
103
+ catch {
104
+ payload = undefined;
105
+ }
106
+ if (!payload ||
107
+ payload.v !== CURSOR_VERSION ||
108
+ typeof payload.u !== "number" ||
109
+ typeof payload.id !== "string") {
110
+ throw RequestError.invalidParams(undefined, `Unknown session/list cursor: ${cursor}`);
111
+ }
112
+ if (payload.cwd !== scope.cwd ||
113
+ payload.archived !== scope.archived ||
114
+ payload.worktrees !== scope.includeWorktrees) {
115
+ throw RequestError.invalidParams(undefined, "The session/list cursor belongs to another cwd or filter");
116
+ }
117
+ return { orderAtMs: payload.u, sessionId: payload.id };
118
+ }
119
+ /** `<projectDir>/<sessionId>/custom-title.json` of a transcript. */
120
+ function sidecarPath(transcriptPath) {
121
+ const sessionId = path.basename(transcriptPath, ".jsonl");
122
+ return path.join(path.dirname(transcriptPath), sessionId, "custom-title.json");
123
+ }
124
+ /** Writes `<projectDir>/<sessionId>/custom-title.json` the way the CLI's
125
+ * `/rename` does: file 0600 in a 0700 directory, replaced atomically. The
126
+ * temporary file has a random name of the CLI's `custom-title.json.tmp.*`
127
+ * pattern, and only a temporary file this call created is removed. */
128
+ export async function writeCustomTitleSidecar(transcriptPath, title) {
129
+ const target = sidecarPath(transcriptPath);
130
+ await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
131
+ const temporary = `${target}.tmp.${randomBytes(8).toString("hex")}`;
132
+ const handle = await fs.open(temporary, "wx", 0o600);
133
+ try {
134
+ try {
135
+ await handle.writeFile(JSON.stringify({ customTitle: title }));
136
+ }
137
+ finally {
138
+ await handle.close();
139
+ }
140
+ await fs.rename(temporary, target);
141
+ }
142
+ catch (error) {
143
+ await fs.rm(temporary, { force: true });
144
+ throw error;
145
+ }
146
+ }
147
+ /** The last two non-empty lines of a file, from its last 64 KB, and
148
+ * whether the file ends with a newline. */
149
+ async function lastLines(filePath) {
150
+ const handle = await fs.open(filePath, "r");
151
+ try {
152
+ const { size } = await handle.stat();
153
+ const length = Math.min(size, 64 * 1024);
154
+ const buffer = Buffer.alloc(length);
155
+ await handle.read(buffer, 0, length, size - length);
156
+ const text = buffer.toString("utf8");
157
+ const lines = text.split("\n").filter((line) => line.trim());
158
+ return {
159
+ lines: lines.slice(-2),
160
+ last: lines.at(-1) ?? "",
161
+ endsWithNewline: text.endsWith("\n"),
162
+ };
163
+ }
164
+ finally {
165
+ await handle.close();
166
+ }
167
+ }
168
+ /** Appends the title records of `title` (see archive-title.ts) to a
169
+ * transcript whose last two records are not those already, whoever else
170
+ * has the session open, as the CLI's `/rename` does. A torn last line is
171
+ * closed first. */
172
+ async function ensureTitleRecords(filePath, sessionId, title) {
173
+ const records = titleRecords(sessionId, title);
174
+ const { lines, last, endsWithNewline } = await lastLines(filePath);
175
+ if (lines.join("\n") + "\n" === records)
176
+ return;
177
+ const complete = endsWithNewline || last === "";
178
+ await fs.appendFile(filePath, `${complete ? "" : "\n"}${records}`);
179
+ }
180
+ /** Appends the `agent-name` record of `title` when the copy's agent name
181
+ * is another one, so its archive state follows `title`. Reads and appends
182
+ * through one handle. */
183
+ async function ensureAgentName(filePath, sessionId, title) {
184
+ const handle = await fs.open(filePath, "a+");
185
+ try {
186
+ const { size } = await handle.stat();
187
+ const headTail = await readHeadTailOf(handle, size);
188
+ const agentName = transcriptAgentName(headTail);
189
+ if (agentName === undefined || agentName === title)
190
+ return;
191
+ const complete = size === 0 || headTail.tail.endsWith("\n");
192
+ const record = JSON.stringify({ type: "agent-name", agentName: title, sessionId });
193
+ await handle.appendFile(`${complete ? "" : "\n"}${record}\n`);
194
+ }
195
+ finally {
196
+ await handle.close();
197
+ }
198
+ }
199
+ /** The title of one transcript copy as the list reads it (see
200
+ * {@link effectiveTitle}): its agent name, else the title the SDK reports.
201
+ * It carries the archive prefix only when the copy is archived: a generated
202
+ * title never archives a session. */
203
+ async function copyTitle(filePath, sessionId) {
204
+ const { size } = await fs.stat(filePath);
205
+ const headTail = await readHeadTail(filePath, size);
206
+ const sidecar = tailCustomTitle(headTail.tail) === undefined
207
+ ? await readSidecarTitle(filePath, sessionId)
208
+ : undefined;
209
+ const { title, archived } = effectiveTitle(transcriptAgentName(headTail), sdkTitles(headTail, sidecar));
210
+ return title !== undefined && !archived && isArchivedTitle(title) ? visibleTitle(title) : title;
211
+ }
212
+ /** Rewrites the sidecar of a copy whose title is `current` when the
213
+ * sidecar says the other archive state (a sidecar write that failed after
214
+ * the records were appended). */
215
+ async function alignSidecar(transcript, current, sessionId) {
216
+ if (current === undefined)
217
+ return;
218
+ const sidecar = await readSidecarTitle(transcript, sessionId);
219
+ if (sidecar === undefined || isArchivedTitle(sidecar) === isArchivedTitle(current))
220
+ return;
221
+ await writeCustomTitleSidecar(transcript, storedTitle(current, isArchivedTitle(current), sessionId));
222
+ }
223
+ /** `_session/rename`: the new title, with the archive prefix on a copy
224
+ * that is archived, so a rename keeps the archive state. */
225
+ export function renameTo(title) {
226
+ return (current, sessionId) => isArchivedTitle(current) ? storedTitle(title, true, sessionId) : title;
227
+ }
228
+ /** `_session/archive` and `_session/unarchive`: the current title with or
229
+ * without the archive prefix; a copy already in that state is left alone. */
230
+ export function archiveTo(archived) {
231
+ return (current, sessionId) => isArchivedTitle(current) === archived
232
+ ? undefined
233
+ : storedTitle(current ?? "", archived, sessionId);
234
+ }
235
+ /**
236
+ * Whether `transcript` lies in the project directory of one of `paths`, the
237
+ * one the CLI of that cwd writes: the exact encoding, or, for a long path
238
+ * whose name the CLI hashes differently, a directory with the cut prefix
239
+ * whose transcript belongs to the path. Another long path that shares the
240
+ * prefix does not count.
241
+ */
242
+ async function isTranscriptOf(transcript, paths) {
243
+ const dirName = path.basename(path.dirname(transcript));
244
+ if (paths.some((cwd) => isExactProjectDir(dirName, cwd)))
245
+ return true;
246
+ if (!paths.some((cwd) => projectDirMatches(dirName, cwd)))
247
+ return false;
248
+ try {
249
+ const { size } = await fs.stat(transcript);
250
+ const cwd = transcriptProjectCwd(await readHeadTail(transcript, size));
251
+ return cwd !== undefined && paths.some((projectPath) => sameProjectPath(cwd, projectPath));
252
+ }
253
+ catch {
254
+ // Unreadable: not known to be the CLI's own copy.
255
+ return false;
256
+ }
257
+ }
258
+ function isSdkNotFound(error) {
259
+ return error instanceof Error && /^Session \S+ not found in /.test(error.message);
260
+ }
261
+ async function exists(target) {
262
+ try {
263
+ await fs.lstat(target);
264
+ return true;
265
+ }
266
+ catch (error) {
267
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR")
268
+ return false;
269
+ throw error;
270
+ }
271
+ }
272
+ /** The error the SDK `deleteSession` gives for a session id it rejects or
273
+ * does not find, which a client without `sessionIndex` got before. */
274
+ function sdkDeleteError(sessionId) {
275
+ return new Error(isSessionId(sessionId)
276
+ ? `Session ${sessionId} not found in any project directory`
277
+ : `Invalid sessionId: ${sessionId}`);
278
+ }
279
+ /**
280
+ * `session/delete` of an AIR client without `sessionIndex`, which uses delete
281
+ * to mark a session done: archives it instead, so the transcript survives. A
282
+ * session without a transcript fails as the SDK delete did.
283
+ */
284
+ export async function archiveInsteadOfDelete(sessionId, service) {
285
+ if (!isSessionId(sessionId))
286
+ throw sdkDeleteError(sessionId);
287
+ // The SDK delete skips empty transcripts, so they do not count.
288
+ const found = await service.index.findTranscripts(sessionId);
289
+ if (found.length === 0)
290
+ throw sdkDeleteError(sessionId);
291
+ await service.retitle(sessionId, archiveTo(true), { sidecar: "existing" });
292
+ }
293
+ export class SessionIndexService {
294
+ deps;
295
+ index;
296
+ registry;
297
+ subscriptions;
298
+ /** Set for good by {@link dispose}: no subscription is made after it. */
299
+ disposed = false;
300
+ /** The mutation in flight per session, which the next one waits for. */
301
+ mutations = new Map();
302
+ now;
303
+ deleteSession;
304
+ constructor(deps) {
305
+ this.deps = deps;
306
+ this.index = new SessionIndex(deps.getSessionInfo ?? sdkGetSessionInfo);
307
+ this.registry = deps.registry ?? new LiveSessionRegistry();
308
+ this.now = deps.now ?? Date.now;
309
+ this.deleteSession = deps.deleteSession ?? ((id) => sdkDeleteSession(id));
310
+ }
311
+ /** `session/list` of a `sessionIndex` client. `own` reports the sessions
312
+ * that this connection runs. */
313
+ async list(params, own) {
314
+ const { limit, archived, includeWorktrees } = parseListOptions(params._meta);
315
+ const cwd = params.cwd ?? null;
316
+ const scope = { cwd, archived, includeWorktrees };
317
+ const after = params.cursor === null || params.cursor === undefined
318
+ ? undefined
319
+ : decodeListCursor(params.cursor, scope);
320
+ // The live registry is read while the transcripts are.
321
+ const livePromise = this.registry.snapshot();
322
+ const { rows, hasMore } = await this.index.list({
323
+ cwd,
324
+ includeWorktrees,
325
+ limit,
326
+ archived,
327
+ after,
328
+ });
329
+ const live = await livePromise;
330
+ const now = this.now();
331
+ const sessions = rows.map((row) => sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now));
332
+ const last = rows[rows.length - 1];
333
+ return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
334
+ }
335
+ /** Runs `mutation` after the previous mutation of the session ended. */
336
+ exclusive(sessionId, mutation) {
337
+ const previous = this.mutations.get(sessionId) ?? Promise.resolve();
338
+ const next = previous.catch(() => undefined).then(mutation);
339
+ const settled = next.catch(() => undefined);
340
+ this.mutations.set(sessionId, settled);
341
+ void settled.then(() => {
342
+ if (this.mutations.get(sessionId) === settled)
343
+ this.mutations.delete(sessionId);
344
+ });
345
+ return next;
346
+ }
347
+ /**
348
+ * Retitles every transcript of a session with `change`: rename, archive
349
+ * and unarchive. Returns the title stored through the CLI that runs here,
350
+ * else the title of the first copy that got one; undefined when nothing
351
+ * was stored.
352
+ *
353
+ * - The CLI runs here (`live`): it titles its own transcript and sidecar
354
+ * (`rename_session`), the only writer that cannot be overtaken by the
355
+ * title the CLI holds. The other copies get the records and the sidecar
356
+ * here, as a best effort once the CLI has the title: a failure is
357
+ * logged, not returned.
358
+ * - Otherwise every transcript gets the records (and the sidecar, see
359
+ * {@link RetitleOptions.sidecar}).
360
+ *
361
+ * As the CLI's `/rename`, the records are written whether or not another
362
+ * process has the session open.
363
+ *
364
+ * A session without a transcript is unknown (`-32002`), unless
365
+ * {@link RetitleOptions.mayBeUnwritten}.
366
+ */
367
+ async retitle(sessionId, change, options) {
368
+ const { live } = options;
369
+ if (!isSessionId(sessionId)) {
370
+ if (!live || !options.mayBeUnwritten)
371
+ throw sessionNotFound(sessionId);
372
+ return this.exclusive(sessionId, async () => {
373
+ const title = change(live.stored() ?? live.shown, sessionId);
374
+ if (title !== undefined) {
375
+ await live.rename(title);
376
+ live.remember(title);
377
+ }
378
+ return title;
379
+ });
380
+ }
381
+ return this.exclusive(sessionId, async () => {
382
+ const transcripts = await this.index.findTranscripts(sessionId);
383
+ if (transcripts.length === 0 && !options.mayBeUnwritten)
384
+ throw sessionNotFound(sessionId);
385
+ let stored;
386
+ // The cache is left as it is: the appended records change the size,
387
+ // and the next read keeps what the earlier scans found (a last prompt
388
+ // beyond the tail search).
389
+ if (live) {
390
+ const canonical = await canonicalPath(live.cwd);
391
+ const paths = [...new Set([live.cwd, canonical])];
392
+ const own = [];
393
+ for (const transcript of transcripts) {
394
+ if (await isTranscriptOf(transcript, paths))
395
+ own.push(transcript);
396
+ }
397
+ // The CLI writes the transcript of the resolved cwd.
398
+ const cli = own.find((transcript) => isExactProjectDir(path.basename(path.dirname(transcript)), canonical)) ?? own[0];
399
+ const others = transcripts.filter((transcript) => transcript !== cli);
400
+ // The transcript holds the current title, also one changed otherwise
401
+ // (a `/rename`); without one, the title last given to the CLI.
402
+ const primary = cli ?? others[0];
403
+ const read = primary ? await copyTitle(primary, sessionId) : undefined;
404
+ const current = read ?? live.stored() ?? live.shown;
405
+ stored = change(current, sessionId);
406
+ if (stored !== undefined) {
407
+ await live.rename(stored);
408
+ live.remember(stored);
409
+ // The CLI writes the custom title before it answers, the agent name
410
+ // only later. An older agent name decides the archive state first,
411
+ // so the copy gets the new one now.
412
+ // A best effort: the CLI took the title, and writes the agent name
413
+ // itself too.
414
+ if (cli) {
415
+ try {
416
+ await ensureAgentName(cli, sessionId, stored);
417
+ }
418
+ catch (error) {
419
+ this.deps.logError(`writing the agent name of ${sessionId} failed`, error);
420
+ }
421
+ }
422
+ }
423
+ else if (cli) {
424
+ await alignSidecar(cli, current, sessionId);
425
+ }
426
+ try {
427
+ await this.titleCopies(sessionId, others, change, options.sidecar);
428
+ }
429
+ catch (error) {
430
+ this.deps.logError(`titling the other transcripts of ${sessionId} failed`, error);
431
+ }
432
+ }
433
+ else {
434
+ stored = await this.titleCopies(sessionId, transcripts, change, options.sidecar);
435
+ }
436
+ return stored;
437
+ });
438
+ }
439
+ /** Appends the title records `change` gives each copy, then its sidecar.
440
+ * A copy already in the requested state gets nothing, but its sidecar is
441
+ * brought in line with it. Returns the title the first titled copy got. */
442
+ async titleCopies(sessionId, transcripts, change, sidecar) {
443
+ let first;
444
+ for (const transcript of transcripts) {
445
+ const current = await copyTitle(transcript, sessionId);
446
+ const title = change(current, sessionId);
447
+ if (title === undefined) {
448
+ await alignSidecar(transcript, current, sessionId);
449
+ continue;
450
+ }
451
+ await ensureTitleRecords(transcript, sessionId, title);
452
+ first ??= title;
453
+ if (sidecar === "always" || (await exists(sidecarPath(transcript)))) {
454
+ await writeCustomTitleSidecar(transcript, title);
455
+ }
456
+ }
457
+ return first;
458
+ }
459
+ /** Deletes every transcript of the session, whoever else has it open, as
460
+ * the CLI does. `known`: the session was loaded here (and is torn down
461
+ * already), so a missing transcript is no error.
462
+ *
463
+ * The SDK deletes the first non-empty transcript it finds and its
464
+ * `<sessionId>/` directory, one copy per call; empty transcripts, which it
465
+ * skips, are removed here. */
466
+ async delete(sessionId, known) {
467
+ if (!isSessionId(sessionId))
468
+ throw sessionNotFound(sessionId);
469
+ await this.exclusive(sessionId, async () => {
470
+ // A session directory can outlive its transcript: a delete that removed
471
+ // the transcript and then failed on the directory.
472
+ const found = await this.index.scanSession(sessionId);
473
+ const all = found.transcripts.map(({ filePath }) => filePath);
474
+ if (all.length === 0 && found.sessionDirs.length === 0) {
475
+ // Unknown, unless it was loaded here.
476
+ if (known)
477
+ return;
478
+ throw sessionNotFound(sessionId);
479
+ }
480
+ try {
481
+ // The SDK deletes one non-empty copy per call.
482
+ for (const { filePath } of found.transcripts.filter(({ size }) => size > 0)) {
483
+ try {
484
+ await this.deleteSession(sessionId);
485
+ }
486
+ catch (error) {
487
+ // Only "not found" with that copy gone is a copy removed meanwhile.
488
+ if (isSdkNotFound(error) && !(await exists(filePath)))
489
+ continue;
490
+ throw error;
491
+ }
492
+ }
493
+ // Empty transcripts, which the SDK skips, and session directories
494
+ // without a transcript.
495
+ for (const { filePath, size } of found.transcripts) {
496
+ if (size === 0)
497
+ await fs.rm(filePath, { force: true });
498
+ }
499
+ for (const sessionDir of found.sessionDirs) {
500
+ if (await exists(`${sessionDir}.jsonl`))
501
+ continue;
502
+ await fs.rm(sessionDir, { recursive: true, force: true });
503
+ }
504
+ const left = await this.index.scanSession(sessionId);
505
+ const remaining = [
506
+ ...left.transcripts.map(({ filePath }) => filePath),
507
+ ...left.sessionDirs,
508
+ ];
509
+ if (remaining.length > 0) {
510
+ throw new Error(`Session ${sessionId} was not deleted: ${remaining.join(", ")} remain`);
511
+ }
512
+ }
513
+ finally {
514
+ this.index.invalidate(all);
515
+ }
516
+ });
517
+ }
518
+ /** `_session/list/subscribe`: pushes the changed rows of `cwd` (an
519
+ * absolute path) until {@link unsubscribeList} or {@link dispose}. */
520
+ subscribeList(cwd) {
521
+ if (this.disposed) {
522
+ return Promise.reject(RequestError.internalError(undefined, "The connection is closed"));
523
+ }
524
+ this.subscriptions ??= new ListSubscriptions({
525
+ index: this.index,
526
+ registry: this.registry,
527
+ own: (sessionId) => this.deps.ownSessionState?.(sessionId),
528
+ notify: (changes) => this.deps.notifyListChanges?.(changes) ?? Promise.resolve(),
529
+ logError: this.deps.logError,
530
+ });
531
+ return this.subscriptions.subscribe(cwd);
532
+ }
533
+ /** `_session/list/unsubscribe`. Idempotent. */
534
+ unsubscribeList(subscriptionId) {
535
+ this.subscriptions?.unsubscribe(subscriptionId);
536
+ }
537
+ /** A session this connection runs changed what its row shows. */
538
+ ownSessionChanged(sessionId) {
539
+ this.subscriptions?.ownSessionChanged(sessionId);
540
+ }
541
+ dispose() {
542
+ this.disposed = true;
543
+ this.subscriptions?.dispose();
544
+ this.subscriptions = undefined;
545
+ }
546
+ }