@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,545 @@
1
+ /**
2
+ * What the session list needs from a transcript beyond the SDK metadata.
3
+ *
4
+ * One open reads the first and the last {@link CHUNK_SIZE} bytes, the same
5
+ * window that the SDK reads. The tail gives the time of the last message, the
6
+ * end of the last turn. The head and the tail give the `cwd` candidates.
7
+ *
8
+ * A last message longer than the tail window leaves no message in it: then
9
+ * the tail window grows, up to {@link MAX_TAIL_SIZE}, until it holds one.
10
+ */
11
+ import * as fs from "node:fs/promises";
12
+ import { firstPrompt, isUserPrompt, mediaPrompt, promptOf } from "./first-prompt.js";
13
+ const CHUNK_SIZE = 64 * 1024;
14
+ /** The largest tail window read to find the last message. */
15
+ const MAX_TAIL_SIZE = 4 * 1024 * 1024;
16
+ const TAIL_GROWTH = 4;
17
+ const FULL_SEARCH_CACHE_SIZE = 512;
18
+ /** By `path\0size`, the facts of the transcripts whose last
19
+ * {@link MAX_TAIL_SIZE} bytes lack the last message or prompt: a file of
20
+ * the same size is not searched again. */
21
+ const fullySearched = new Map();
22
+ /** Facts that an earlier scan may have found before the tail window. */
23
+ const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt"];
24
+ /** The last `window` bytes of a file of `size` bytes, without the first,
25
+ * cut line. The whole file when it fits. */
26
+ async function readTail(handle, size, window) {
27
+ const length = Math.min(window, size);
28
+ const buffer = Buffer.allocUnsafe(length);
29
+ const { bytesRead } = await handle.read(buffer, 0, length, size - length);
30
+ const raw = buffer.toString("utf8", 0, bytesRead);
31
+ if (length === size)
32
+ return raw;
33
+ const newline = raw.indexOf("\n");
34
+ return newline >= 0 ? raw.slice(newline + 1) : "";
35
+ }
36
+ /** The first and the last 64 KB of the first `size` bytes of `filePath`:
37
+ * what was appended after the file was stat'ed is not read, so the result
38
+ * matches the size it is cached by. The tail is the head for a file that
39
+ * fits in one chunk. */
40
+ export async function readHeadTail(filePath, size) {
41
+ const handle = await fs.open(filePath, "r");
42
+ try {
43
+ return await readHeadTailOf(handle, size);
44
+ }
45
+ finally {
46
+ await handle.close();
47
+ }
48
+ }
49
+ /** {@link readHeadTail} of a file that is open already. */
50
+ export async function readHeadTailOf(handle, size) {
51
+ const buffer = Buffer.allocUnsafe(CHUNK_SIZE);
52
+ const first = await handle.read(buffer, 0, Math.min(CHUNK_SIZE, size), 0);
53
+ const head = buffer.toString("utf8", 0, first.bytesRead);
54
+ if (size <= CHUNK_SIZE)
55
+ return { head, tail: head };
56
+ return { head, tail: await readTail(handle, size, CHUNK_SIZE) };
57
+ }
58
+ /**
59
+ * The facts of `filePath`, from its head and tail. When the transcript has
60
+ * messages but the tail window lacks the last message or the last user
61
+ * prompt (a long answer or tool output follows it), the window grows,
62
+ * reading only the new range each step, until it holds both or reaches
63
+ * {@link MAX_TAIL_SIZE}. The result of a full search is kept per
64
+ * `(path, size)`.
65
+ *
66
+ * `previous` is what an earlier scan of the same file found: when the file
67
+ * only grew by less than the tail window, and the tail holds no prompt, the
68
+ * appended bytes hold none either, so the earlier `lastPromptAt` stands.
69
+ */
70
+ export async function scanTranscriptFile(filePath, size, headTail, previous,
71
+ /** Tells a file replaced by another of the same size apart (its inode). */
72
+ identity) {
73
+ const read = headTail ?? (await readHeadTail(filePath, size));
74
+ const facts = scanTranscript(read);
75
+ // The file only grew by bytes that the tail covers, and the tail holds no
76
+ // prompt: the appended bytes hold none either, so the earlier search
77
+ // stands (its prompt, or that the last 4 MB held none).
78
+ const grownWithinTail = previous !== undefined &&
79
+ size >= previous.size &&
80
+ size - Buffer.byteLength(read.tail) <= previous.size;
81
+ if (grownWithinTail) {
82
+ // What the earlier, possibly wider, scan found before the tail stands
83
+ // when the tail has nothing newer.
84
+ for (const key of INHERITED_FACTS) {
85
+ if (facts[key] === undefined && previous[key] !== undefined) {
86
+ facts[key] = previous[key];
87
+ }
88
+ }
89
+ }
90
+ if (facts.lastPromptAt !== undefined || size <= CHUNK_SIZE) {
91
+ facts.promptSearched = true;
92
+ }
93
+ else if (grownWithinTail && (previous.lastPromptAt !== undefined || previous.promptSearched)) {
94
+ facts.lastPromptAt = previous.lastPromptAt;
95
+ facts.promptSearched = true;
96
+ }
97
+ const complete = (found) => found.lastMessageAt !== undefined && found.promptSearched === true;
98
+ if (!facts.hasMessages || complete(facts) || size <= CHUNK_SIZE)
99
+ return facts;
100
+ const key = `${filePath}\0${size}\0${identity ?? ""}`;
101
+ const searched = fullySearched.get(key);
102
+ if (searched)
103
+ return searched;
104
+ let result = facts;
105
+ const handle = await fs.open(filePath, "r");
106
+ try {
107
+ // The bytes from `start` to the end, grown by reading the new range only.
108
+ let start = Math.max(0, size - CHUNK_SIZE);
109
+ const first = Buffer.allocUnsafe(size - start);
110
+ const { bytesRead: firstRead } = await handle.read(first, 0, first.length, start);
111
+ let bytes = first.subarray(0, firstRead);
112
+ for (let window = CHUNK_SIZE * TAIL_GROWTH; start > 0; window *= TAIL_GROWTH) {
113
+ const nextStart = Math.max(0, size - Math.min(window, MAX_TAIL_SIZE));
114
+ if (nextStart >= start)
115
+ break;
116
+ const range = Buffer.allocUnsafe(start - nextStart);
117
+ const { bytesRead } = await handle.read(range, 0, range.length, nextStart);
118
+ bytes = Buffer.concat([range.subarray(0, bytesRead), bytes]);
119
+ start = nextStart;
120
+ let tail = bytes.toString("utf8");
121
+ if (start > 0) {
122
+ // The first line of the window is cut; drop it.
123
+ const newline = tail.indexOf("\n");
124
+ tail = newline >= 0 ? tail.slice(newline + 1) : "";
125
+ }
126
+ // A wider tail ends with the same records: what the narrow one found
127
+ // stays, and the wider one adds what lay before it.
128
+ const wider = scanTranscript({ head: read.head, tail }, {
129
+ wide: true,
130
+ known: { model: facts.model, tailCwd: facts.tailCwd },
131
+ });
132
+ const lastPromptAt = wider.lastPromptAt ?? facts.lastPromptAt;
133
+ result = {
134
+ ...wider,
135
+ ...(facts.model !== undefined && { model: facts.model }),
136
+ ...(facts.tailCwd !== undefined && { tailCwd: facts.tailCwd }),
137
+ // A turn end the wider tail lacks may lie before it: the
138
+ // narrow scan inherited it from an earlier scan of this file.
139
+ lastTurnEndedAt: wider.lastTurnEndedAt ?? facts.lastTurnEndedAt,
140
+ hasMessages: facts.hasMessages,
141
+ lastPromptAt,
142
+ // Searched to the start of the file, or found.
143
+ promptSearched: facts.promptSearched || lastPromptAt !== undefined || start === 0,
144
+ };
145
+ if (complete(result))
146
+ return result;
147
+ }
148
+ }
149
+ finally {
150
+ await handle.close();
151
+ }
152
+ // The whole window is searched: no prompt in it either.
153
+ result = { ...result, promptSearched: true };
154
+ fullySearched.set(key, result);
155
+ if (fullySearched.size > FULL_SEARCH_CACHE_SIZE) {
156
+ fullySearched.delete(fullySearched.keys().next().value);
157
+ }
158
+ return result;
159
+ }
160
+ const RELOCATED_MARKER = '"relocated"';
161
+ /** The cwd of the last `relocated` record of the tail: where the session
162
+ * was moved, whatever cwd its earlier messages carry. */
163
+ export function relocatedCwd(tail) {
164
+ const lines = tail.split("\n");
165
+ for (let i = lines.length - 1; i >= 0; i--) {
166
+ const line = lines[i];
167
+ if (!line.includes(RELOCATED_MARKER))
168
+ continue;
169
+ const entry = parseLine(line);
170
+ if (entry?.type === "relocated" && typeof entry.relocatedCwd === "string") {
171
+ return entry.relocatedCwd;
172
+ }
173
+ }
174
+ return undefined;
175
+ }
176
+ /** The cwd a transcript belongs to, as the SDK reads it: the last
177
+ * `relocated` record of the tail, else the first cwd of the head. */
178
+ export function transcriptProjectCwd({ head, tail }) {
179
+ const relocated = relocatedCwd(tail);
180
+ if (relocated !== undefined)
181
+ return relocated;
182
+ const headCwd = CWD_PATTERN.exec(head)?.[1];
183
+ return headCwd === undefined ? undefined : decodeJsonString(headCwd);
184
+ }
185
+ const MESSAGE_MARKERS = [
186
+ '"type":"user"',
187
+ '"type":"assistant"',
188
+ '"type": "user"',
189
+ '"type": "assistant"',
190
+ ];
191
+ /** System records that the CLI writes when a turn ends. */
192
+ const TURN_END_SYSTEM_SUBTYPES = new Set(["stop_hook_summary", "turn_duration"]);
193
+ const INTERRUPT_PREFIX = "[Request interrupted by user";
194
+ function parseLine(line) {
195
+ try {
196
+ const value = JSON.parse(line);
197
+ return value && typeof value === "object" && !Array.isArray(value)
198
+ ? value
199
+ : undefined;
200
+ }
201
+ catch {
202
+ return undefined;
203
+ }
204
+ }
205
+ function timestampOf(entry) {
206
+ if (typeof entry.timestamp !== "string")
207
+ return undefined;
208
+ const value = Date.parse(entry.timestamp);
209
+ return Number.isNaN(value) ? undefined : value;
210
+ }
211
+ function firstText(entry) {
212
+ const message = entry.message;
213
+ const content = message?.content;
214
+ if (typeof content === "string")
215
+ return content;
216
+ if (Array.isArray(content)) {
217
+ for (const block of content) {
218
+ if (block && typeof block === "object" && block.type === "text") {
219
+ const text = block.text;
220
+ if (typeof text === "string")
221
+ return text;
222
+ }
223
+ }
224
+ }
225
+ return undefined;
226
+ }
227
+ /**
228
+ * How a main-chain record bears on the end of the turn: `finished` for a
229
+ * record that ends a turn (an assistant `end_turn`, an API error, a user
230
+ * interrupt, the CLI's turn-end system records), `unfinished` for a record of
231
+ * a turn in progress, and undefined for a record that says nothing.
232
+ */
233
+ export function turnEffect(entry) {
234
+ if (entry.isSidechain === true)
235
+ return undefined;
236
+ switch (entry.type) {
237
+ case "assistant": {
238
+ if (entry.isApiErrorMessage === true)
239
+ return "finished";
240
+ const stopReason = entry.message?.stop_reason;
241
+ return stopReason === "end_turn" ? "finished" : "unfinished";
242
+ }
243
+ case "user": {
244
+ if (entry.isMeta === true)
245
+ return undefined;
246
+ return firstText(entry)?.startsWith(INTERRUPT_PREFIX) ? "finished" : "unfinished";
247
+ }
248
+ case "system":
249
+ return TURN_END_SYSTEM_SUBTYPES.has(entry.subtype) ? "finished" : undefined;
250
+ default:
251
+ return undefined;
252
+ }
253
+ }
254
+ const FORKED_FROM_PATTERN = /"forkedFrom":\s?\{"sessionId":\s?"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"/;
255
+ const CWD_PATTERN = /"cwd":\s?"((?:[^"\\]|\\.)*)"/;
256
+ function decodeJsonString(raw) {
257
+ try {
258
+ const value = JSON.parse(`"${raw}"`);
259
+ return typeof value === "string" ? value : undefined;
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ }
265
+ /** The facts of a transcript, from its head and its tail. */
266
+ export function scanTranscript({ head, tail }, options = {}) {
267
+ const facts = {
268
+ hasMessages: MESSAGE_MARKERS.some((marker) => head.includes(marker) || tail.includes(marker)),
269
+ };
270
+ const headCwd = CWD_PATTERN.exec(head)?.[1];
271
+ if (headCwd !== undefined)
272
+ facts.headCwd = decodeJsonString(headCwd);
273
+ const forkedFrom = FORKED_FROM_PATTERN.exec(head)?.[1];
274
+ if (forkedFrom !== undefined)
275
+ facts.forkedFrom = forkedFrom;
276
+ const lines = tail.split("\n");
277
+ let turnEndFound = false;
278
+ let messageFound = false;
279
+ let promptFound = false;
280
+ /** The last turn ended: is its end an API error? */
281
+ let checkingTurnError = false;
282
+ // A wide tail (see scanTranscriptFile) is searched for what the narrow
283
+ // one lacked: the last message, prompt and turn end, and the model and
284
+ // the cwd only when the narrow tail had none (its records can all be cut,
285
+ // e.g. one large tool result), so a long answer is not parsed line by line.
286
+ const wide = options.wide === true;
287
+ let modelFound = wide && options.known?.model !== undefined;
288
+ const searchCwd = !wide || options.known?.tailCwd === undefined;
289
+ for (let i = lines.length - 1; i >= 0; i--) {
290
+ const line = lines[i];
291
+ if (!line)
292
+ continue;
293
+ const isTurnRecord = line.includes('"user"') || line.includes('"assistant"') || line.includes('"system"');
294
+ const hasCwd = searchCwd && facts.tailCwd === undefined && line.includes('"cwd"');
295
+ if (!isTurnRecord && !hasCwd)
296
+ continue;
297
+ // Everything has been found: stop parsing.
298
+ if (turnEndFound &&
299
+ messageFound &&
300
+ promptFound &&
301
+ modelFound &&
302
+ !checkingTurnError &&
303
+ !hasCwd) {
304
+ break;
305
+ }
306
+ // Once the last message and the turn state are known, only a line that
307
+ // can still tell something is parsed: lines are often large (tool
308
+ // results, long answers), and a long tail has thousands of them.
309
+ if (messageFound &&
310
+ facts.turnState !== undefined &&
311
+ !hasCwd &&
312
+ !(!promptFound && mayBePrompt(line)) &&
313
+ !(!modelFound && hasType(line, "assistant")) &&
314
+ !(!turnEndFound && mayEndTurn(line)) &&
315
+ !(checkingTurnError && isTurnRecord)) {
316
+ continue;
317
+ }
318
+ const entry = parseLine(line);
319
+ if (!entry)
320
+ continue;
321
+ if (hasCwd && typeof entry.cwd === "string" && entry.cwd)
322
+ facts.tailCwd = entry.cwd;
323
+ if (entry.isSidechain === true)
324
+ continue;
325
+ if (!messageFound && (entry.type === "user" || entry.type === "assistant")) {
326
+ messageFound = true;
327
+ facts.lastMessageAt = timestampOf(entry);
328
+ }
329
+ if (!promptFound && entry.type === "user" && isUserPrompt(entry)) {
330
+ promptFound = true;
331
+ facts.lastPromptAt = timestampOf(entry);
332
+ }
333
+ if (!modelFound && entry.type === "assistant") {
334
+ const model = entry.message?.model;
335
+ // The CLI marks the messages it makes up (API errors) `<synthetic>`.
336
+ if (typeof model === "string" && model && model !== "<synthetic>") {
337
+ modelFound = true;
338
+ facts.model = model;
339
+ }
340
+ }
341
+ const effect = turnEffect(entry);
342
+ if (effect === undefined)
343
+ continue;
344
+ if (facts.turnState === undefined) {
345
+ facts.turnState = effect;
346
+ checkingTurnError = effect === "finished";
347
+ }
348
+ if (checkingTurnError) {
349
+ // The record that ended the last turn, behind the CLI's turn-end
350
+ // system records: an API error is an error.
351
+ if (entry.type === "assistant" && entry.isApiErrorMessage === true) {
352
+ facts.lastTurnError = true;
353
+ checkingTurnError = false;
354
+ }
355
+ else if (entry.type !== "system") {
356
+ checkingTurnError = false;
357
+ }
358
+ }
359
+ if (effect === "finished" && !turnEndFound) {
360
+ turnEndFound = true;
361
+ facts.lastTurnEndedAt = timestampOf(entry);
362
+ }
363
+ }
364
+ return facts;
365
+ }
366
+ function hasType(line, type) {
367
+ return line.includes(`"type":"${type}"`) || line.includes(`"type": "${type}"`);
368
+ }
369
+ /** Whether a line may be a user prompt: a user record that is no tool
370
+ * result, or one that carries an image or a document. */
371
+ function mayBePrompt(line) {
372
+ if (!hasType(line, "user"))
373
+ return false;
374
+ return !hasType(line, "tool_result") || hasType(line, "image") || hasType(line, "document");
375
+ }
376
+ /** Whether a line may end a turn (see {@link turnEffect}). */
377
+ function mayEndTurn(line) {
378
+ return (line.includes('"end_turn"') ||
379
+ line.includes('"isApiErrorMessage"') ||
380
+ line.includes(INTERRUPT_PREFIX) ||
381
+ [...TURN_END_SYSTEM_SUBTYPES].some((subtype) => line.includes(`"${subtype}"`)));
382
+ }
383
+ /** Every `"key":"value"` string of `text`, in order, decoded. */
384
+ function stringFields(text, key) {
385
+ const pattern = new RegExp(`"${key}":\\s?"((?:[^"\\\\]|\\\\.)*)"`, "g");
386
+ const values = [];
387
+ for (const match of text.matchAll(pattern)) {
388
+ const value = decodeJsonString(match[1]);
389
+ if (value !== undefined)
390
+ values.push(value);
391
+ }
392
+ return values;
393
+ }
394
+ const lastField = (text, key) => stringFields(text, key).at(-1) || undefined;
395
+ /** Whether the first record of the transcript is a sidechain one, which the
396
+ * SDK never lists. */
397
+ export function isSidechainTranscript(head) {
398
+ const newline = head.indexOf("\n");
399
+ const first = newline >= 0 ? head.slice(0, newline) : head;
400
+ return first.includes('"isSidechain":true') || first.includes('"isSidechain": true');
401
+ }
402
+ /** The last `customTitle` of the tail, as the SDK reads it. */
403
+ export function tailCustomTitle(tail) {
404
+ return lastField(tail, "customTitle");
405
+ }
406
+ /** The last non-blank top-level `agentName` of the records of `text`, the
407
+ * name `/rename`, a `rename_session` and AIR write with the custom title. */
408
+ function lastAgentName(text) {
409
+ if (!text.includes('"agentName"'))
410
+ return undefined;
411
+ const lines = text.split("\n");
412
+ for (let i = lines.length - 1; i >= 0; i--) {
413
+ const line = lines[i];
414
+ if (!line.includes('"agentName"'))
415
+ continue;
416
+ const value = parseLine(line)?.agentName;
417
+ if (typeof value === "string" && value.trim())
418
+ return value;
419
+ }
420
+ return undefined;
421
+ }
422
+ /** The last agent name of a transcript, as AIR's native Claude provider
423
+ * reads it: the top-level `agentName` of any record, from the tail, else
424
+ * from the head. */
425
+ export function transcriptAgentName({ head, tail }) {
426
+ return lastAgentName(tail) ?? lastAgentName(head);
427
+ }
428
+ /**
429
+ * The titles the SDK gives a transcript, from its own head and tail, in the
430
+ * SDK's order: custom title (tail, the sidecar, head), AI title, then
431
+ * {@link generatedTitle}. For a transcript that the SDK `getSessionInfo`
432
+ * does not read (another copy of the session comes first in its search).
433
+ * The sidecar title counts only when the tail has no custom title.
434
+ */
435
+ export function sdkTitles({ head, tail }, sidecarTitle) {
436
+ const customTitle = lastField(tail, "customTitle") ??
437
+ sidecarTitle ??
438
+ lastField(head, "customTitle") ??
439
+ lastField(tail, "aiTitle") ??
440
+ lastField(head, "aiTitle");
441
+ return { customTitle, summary: customTitle ?? generatedTitle({ head, tail }) };
442
+ }
443
+ /** The title of a transcript without a custom title, in the SDK's order: AI
444
+ * title, last prompt, summary, first prompt. */
445
+ export function generatedTitle({ head, tail }) {
446
+ return (lastField(tail, "aiTitle") ??
447
+ lastField(head, "aiTitle") ??
448
+ lastField(tail, "lastPrompt") ??
449
+ lastField(tail, "summary") ??
450
+ (firstPrompt(head) || mediaPrompt(head) || undefined));
451
+ }
452
+ /** The time of the last `"timestamp"` in the last 64 KB of a file, cut
453
+ * line included, epoch ms: a record longer than that still ends with it. */
454
+ export async function lastTimestamp(filePath, size) {
455
+ const handle = await fs.open(filePath, "r");
456
+ let text;
457
+ try {
458
+ const length = Math.min(size, CHUNK_SIZE);
459
+ const buffer = Buffer.allocUnsafe(length);
460
+ const { bytesRead } = await handle.read(buffer, 0, length, size - length);
461
+ text = buffer.toString("utf8", 0, bytesRead);
462
+ }
463
+ finally {
464
+ await handle.close();
465
+ }
466
+ for (const raw of stringFields(text, "timestamp").reverse()) {
467
+ const value = Date.parse(raw);
468
+ if (!Number.isNaN(value))
469
+ return value;
470
+ }
471
+ return undefined;
472
+ }
473
+ const CONTINUED_IN_MARKER = '"type":"continued-in"';
474
+ /**
475
+ * The session this transcript was continued in, as the SDK list reads it: the
476
+ * last `continued-in` record of the tail, unless a completed turn follows it
477
+ * (the session was resumed here again).
478
+ */
479
+ export function continuedInSessionId(tail) {
480
+ if (!tail.includes(CONTINUED_IN_MARKER))
481
+ return undefined;
482
+ const lines = tail.split("\n");
483
+ for (let i = lines.length - 1; i >= 0; i--) {
484
+ const line = lines[i];
485
+ const isContinuation = line.includes(CONTINUED_IN_MARKER);
486
+ const isMessage = line.includes('"type":"user"') || line.includes('"type":"assistant"');
487
+ if (!isContinuation && !isMessage)
488
+ continue;
489
+ const entry = parseLine(line);
490
+ if (!entry)
491
+ continue;
492
+ if (isContinuation && entry.type === "continued-in") {
493
+ const id = entry.continuedInSessionId;
494
+ return typeof id === "string" && SESSION_ID.test(id) ? id : undefined;
495
+ }
496
+ if (entry.type === "assistant") {
497
+ const stopReason = entry.message?.stop_reason;
498
+ if (entry.isApiErrorMessage !== true && typeof stopReason === "string")
499
+ return undefined;
500
+ }
501
+ else if (promptOf(entry, { commandFallback: "" }) !== undefined) {
502
+ return undefined;
503
+ }
504
+ }
505
+ return undefined;
506
+ }
507
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
508
+ const HISTORY_MARKER = '"parentUuid":';
509
+ const HISTORY_SCAN_LIMIT = 16 * 1024 * 1024;
510
+ const HISTORY_SCAN_CHUNK = 1024 * 1024;
511
+ /** Whether the transcript at `filePath` holds conversation history (a record
512
+ * with a `parentUuid`), as the SDK checks a continuation's successor. */
513
+ export async function hasHistory(filePath) {
514
+ let handle;
515
+ try {
516
+ handle = await fs.open(filePath, "r");
517
+ }
518
+ catch {
519
+ return false;
520
+ }
521
+ try {
522
+ const { size } = await handle.stat();
523
+ const buffer = Buffer.allocUnsafe(HISTORY_SCAN_CHUNK + HISTORY_MARKER.length);
524
+ let carry = 0;
525
+ for (let offset = 0; offset < Math.min(size, HISTORY_SCAN_LIMIT);) {
526
+ const { bytesRead } = await handle.read(buffer, carry, HISTORY_SCAN_CHUNK, offset);
527
+ if (bytesRead === 0)
528
+ return false;
529
+ const end = carry + bytesRead;
530
+ if (buffer.subarray(0, end).includes(HISTORY_MARKER))
531
+ return true;
532
+ carry = Math.min(HISTORY_MARKER.length, end);
533
+ buffer.copyWithin(0, end - carry, end);
534
+ offset += bytesRead;
535
+ }
536
+ // Past the scan limit the SDK assumes history.
537
+ return size > HISTORY_SCAN_LIMIT;
538
+ }
539
+ catch {
540
+ return false;
541
+ }
542
+ finally {
543
+ await handle.close();
544
+ }
545
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The git worktrees of a working directory, read from the repository files.
3
+ *
4
+ * The SDK `listSessions({ dir })` includes the sessions of every worktree of
5
+ * the repository and runs `git worktree list` for that on each call. This
6
+ * module reads the same list without spawning git, the way the Codex TUI does:
7
+ * the main worktree is the parent of the common `.git` directory, and each
8
+ * linked worktree is named by `<common-dir>/worktrees/<name>/gitdir`.
9
+ * A worktree whose directory no longer exists is dropped. Nothing is cached:
10
+ * `git worktree move` rewrites a `gitdir` file without touching the
11
+ * `worktrees` directory, so a cache keyed on it would miss the move.
12
+ */
13
+ /**
14
+ * The same subdirectory as `cwd` in every existing worktree of its
15
+ * repository (the main one and the linked ones), as the Codex TUI expands a
16
+ * cwd: for `/repo/packages/a`, `/wt1/packages/a` if it exists. For a cwd at a
17
+ * worktree root, the worktree roots. Empty outside a repository.
18
+ */
19
+ export declare function worktreeCounterparts(cwd: string): Promise<string[]>;
20
+ /**
21
+ * Every existing worktree of the repository that contains `cwd`: the main
22
+ * worktree and the linked ones. Empty outside a repository.
23
+ */
24
+ export declare function repositoryWorktrees(cwd: string): Promise<string[]>;
25
+ //# sourceMappingURL=worktrees.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktrees.d.ts","sourceRoot":"","sources":["../../src/session-index/worktrees.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAqFH;;;;;GAKG;AACH,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAazE;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAaxE"}