@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.
- package/dist/acp-agent.d.ts +26 -1
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +91 -17
- package/dist/air-extension.d.ts +23 -0
- package/dist/air-extension.d.ts.map +1 -1
- package/dist/air-extension.js +23 -0
- package/dist/diff.d.ts +2 -0
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +37 -1
- package/dist/mcp-command.d.ts.map +1 -1
- package/dist/mcp-command.js +5 -0
- package/dist/session-index/activity.d.ts +56 -0
- package/dist/session-index/activity.d.ts.map +1 -0
- package/dist/session-index/activity.js +96 -0
- package/dist/session-index/archive-title.d.ts +52 -0
- package/dist/session-index/archive-title.d.ts.map +1 -0
- package/dist/session-index/archive-title.js +77 -0
- package/dist/session-index/connection.d.ts +152 -0
- package/dist/session-index/connection.d.ts.map +1 -0
- package/dist/session-index/connection.js +310 -0
- package/dist/session-index/dir-listing.d.ts +30 -0
- package/dist/session-index/dir-listing.d.ts.map +1 -0
- package/dist/session-index/dir-listing.js +110 -0
- package/dist/session-index/explicit-title.d.ts +34 -0
- package/dist/session-index/explicit-title.d.ts.map +1 -0
- package/dist/session-index/explicit-title.js +65 -0
- package/dist/session-index/first-prompt.d.ts +37 -0
- package/dist/session-index/first-prompt.d.ts.map +1 -0
- package/dist/session-index/first-prompt.js +195 -0
- package/dist/session-index/list-subscriptions.d.ts +190 -0
- package/dist/session-index/list-subscriptions.d.ts.map +1 -0
- package/dist/session-index/list-subscriptions.js +1163 -0
- package/dist/session-index/live-registry.d.ts +72 -0
- package/dist/session-index/live-registry.d.ts.map +1 -0
- package/dist/session-index/live-registry.js +240 -0
- package/dist/session-index/project-dirs.d.ts +45 -0
- package/dist/session-index/project-dirs.d.ts.map +1 -0
- package/dist/session-index/project-dirs.js +122 -0
- package/dist/session-index/service.d.ts +158 -0
- package/dist/session-index/service.d.ts.map +1 -0
- package/dist/session-index/service.js +546 -0
- package/dist/session-index/session-index.d.ts +201 -0
- package/dist/session-index/session-index.d.ts.map +1 -0
- package/dist/session-index/session-index.js +740 -0
- package/dist/session-index/session-info.d.ts +16 -0
- package/dist/session-index/session-info.d.ts.map +1 -0
- package/dist/session-index/session-info.js +51 -0
- package/dist/session-index/transcript-scan.d.ts +137 -0
- package/dist/session-index/transcript-scan.d.ts.map +1 -0
- package/dist/session-index/transcript-scan.js +545 -0
- package/dist/session-index/worktrees.d.ts +25 -0
- package/dist/session-index/worktrees.d.ts.map +1 -0
- package/dist/session-index/worktrees.js +128 -0
- package/dist/session-titles.d.ts +21 -0
- package/dist/session-titles.d.ts.map +1 -1
- package/dist/session-titles.js +101 -5
- package/dist/utils.d.ts +31 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +60 -2
- 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"}
|