@oddessentials/agent-guild 1.17.0 → 1.19.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.
@@ -0,0 +1,598 @@
1
+ // Saved conversations for the History preview, newest page first, in whole visible messages.
2
+ // A cursor checks the file's first bytes and those around its offset, so appends keep it valid.
3
+
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import zlib from 'node:zlib';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { createHash } from 'node:crypto';
10
+ import { antigravityUserConversation } from '../report/hooks.mjs';
11
+ import { historyError, transcriptFile, transcriptMessage } from './antigravity-transcript.mjs';
12
+
13
+ export const PAGE_LIMITS = {
14
+ messages: 40,
15
+ scanBytes: 8 * 1024 * 1024,
16
+ maxScanBytes: 32 * 1024 * 1024,
17
+ maxLineBytes: 16 * 1024 * 1024,
18
+ maxDecodedBytes: 64 * 1024 * 1024,
19
+ chunkBytes: 256 * 1024,
20
+ };
21
+ const ANCHOR_BYTES = 4096;
22
+ const MAX_CURSOR = 2048;
23
+ /** The wrappers Claude Code puts around text it inserted as a user record, in records from before `origin`. */
24
+ const CLAUDE_INSERTED = /^<(command-name|local-command-stdout|bash-input|bash-stdout|bash-stderr)>/;
25
+
26
+ const changed = () => historyError(409, 'history_changed', 'The conversation changed. Refresh to read it again.');
27
+ const badCursor = () => historyError(400, 'bad_history_cursor', 'Invalid conversation page.');
28
+ const unavailable = () => historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
29
+ const tooLarge = () => historyError(413, 'history_too_large', 'This compressed conversation is too large to preview.');
30
+
31
+ /** Codex compresses rollouts at level 3, whose window is 2 MiB; a frame asking for more than 8 MiB is refused. */
32
+ export function zstdDecoder() {
33
+ return zlib.createZstdDecompress({ params: { [zlib.constants.ZSTD_d_windowLogMax]: 23 } });
34
+ }
35
+
36
+ function digest(buffer) {
37
+ return createHash('sha256').update(buffer).digest('hex').slice(0, 24);
38
+ }
39
+
40
+ function safeInteger(value, min = 0) {
41
+ return Number.isSafeInteger(value) && value >= min;
42
+ }
43
+
44
+ function encodeCursor(value) {
45
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
46
+ }
47
+
48
+ function decodeCursor(cursor, source, id) {
49
+ if (typeof cursor !== 'string' || !cursor || cursor.length > MAX_CURSOR) throw badCursor();
50
+ let value;
51
+ try { value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); } catch { /* invalid below */ }
52
+ if (!value || value.v !== 1 || value.s !== source || value.i !== id || typeof value.st !== 'object' || value.st === null) throw badCursor();
53
+ return value;
54
+ }
55
+
56
+ /** Reject a file outside the tool's folder, or one reached through a link below it. */
57
+ export async function checkedFile(root, file) {
58
+ let current;
59
+ try { current = await fs.promises.realpath(root); } catch { throw unavailable(); }
60
+ const rel = path.relative(root, file);
61
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) throw unavailable();
62
+ const parts = rel.split(path.sep);
63
+ for (const [i, part] of parts.entries()) {
64
+ current = path.join(current, part);
65
+ let stat;
66
+ try { stat = await fs.promises.lstat(current); } catch { throw unavailable(); }
67
+ if (stat.isSymbolicLink() || (i === parts.length - 1 ? !stat.isFile() : !stat.isDirectory())) throw unavailable();
68
+ }
69
+ return current;
70
+ }
71
+
72
+ // ---- byte sources -------------------------------------------------------------
73
+
74
+ async function plainSource(file) {
75
+ const handle = await fs.promises.open(file, 'r');
76
+ try {
77
+ const { size } = await handle.stat();
78
+ return {
79
+ size,
80
+ async read(position, length) {
81
+ const buffer = Buffer.alloc(Math.max(0, length));
82
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
83
+ return buffer.subarray(0, bytesRead);
84
+ },
85
+ close: () => handle.close(),
86
+ };
87
+ } catch (err) {
88
+ await handle.close();
89
+ throw err;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * A compressed rollout is decoded to a private temporary file, removed when the page is read; offsets address the
95
+ * decoded JSONL. Decoding stops once it passes the limit.
96
+ */
97
+ async function openSource(file, limits) {
98
+ let target = file;
99
+ try { await fs.promises.access(target); } catch {
100
+ // Codex compresses a cold rollout, and decompresses one to resume it, under the other name.
101
+ const other = target.endsWith('.zst') ? target.slice(0, -4) : `${target}.zst`;
102
+ try { await fs.promises.access(other); target = other; } catch { throw unavailable(); }
103
+ }
104
+ if (!target.endsWith('.zst')) return plainSource(target).catch(() => { throw unavailable(); });
105
+ if (typeof zlib.createZstdDecompress !== 'function') throw unavailable();
106
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-guild-rollout-'));
107
+ const remove = () => fs.promises.rm(dir, { recursive: true, force: true });
108
+ try {
109
+ const decoded = path.join(dir, 'rollout.jsonl');
110
+ const bounded = async function* (chunks) {
111
+ let size = 0;
112
+ for await (const chunk of chunks) {
113
+ size += chunk.length;
114
+ if (size > limits.maxDecodedBytes) throw tooLarge();
115
+ yield chunk;
116
+ }
117
+ };
118
+ await pipeline(fs.createReadStream(target), zstdDecoder(), bounded, fs.createWriteStream(decoded, { flags: 'wx', mode: 0o600 }));
119
+ const source = await plainSource(decoded);
120
+ return { ...source, close: async () => { try { await source.close(); } finally { await remove(); } } };
121
+ } catch (err) {
122
+ await remove();
123
+ throw err.code === 'history_too_large' ? err : unavailable();
124
+ }
125
+ }
126
+
127
+ async function anchors(source, offset, end) {
128
+ const hl = Math.min(ANCHOR_BYTES, source.size);
129
+ const as = Math.max(0, offset - ANCHOR_BYTES);
130
+ const ae = Math.min(end, offset + ANCHOR_BYTES);
131
+ return { hl, hh: digest(await source.read(0, hl)), as, ae, ah: digest(await source.read(as, ae - as)) };
132
+ }
133
+
134
+ /** Only the spans anchors() makes: at most ANCHOR_BYTES of the head, and of each side of the offset. */
135
+ function checkAnchors(cursor) {
136
+ if (!safeInteger(cursor.o) || !safeInteger(cursor.as) || !safeInteger(cursor.ae) || !safeInteger(cursor.hl)
137
+ || typeof cursor.hh !== 'string' || typeof cursor.ah !== 'string'
138
+ || cursor.hl > ANCHOR_BYTES || cursor.as !== Math.max(0, cursor.o - ANCHOR_BYTES)
139
+ || cursor.ae < cursor.o || cursor.ae > cursor.o + ANCHOR_BYTES) throw badCursor();
140
+ }
141
+
142
+ async function verify(source, cursor) {
143
+ if (source.size < cursor.ae || source.size < cursor.hl) throw changed();
144
+ if (digest(await source.read(0, cursor.hl)) !== cursor.hh) throw changed();
145
+ if (digest(await source.read(cursor.as, cursor.ae - cursor.as)) !== cursor.ah) throw changed();
146
+ }
147
+
148
+ /** Where the last whole line ends, or null when the partial line after it is already too long to read. */
149
+ async function lastLineEnd(source, limits) {
150
+ let position = source.size;
151
+ while (position > 0) {
152
+ if (source.size - position > limits.maxLineBytes) return null;
153
+ const length = Math.min(limits.chunkBytes, position);
154
+ const chunk = await source.read(position - length, length);
155
+ const newline = chunk.lastIndexOf(10);
156
+ if (newline >= 0) {
157
+ const end = position - length + newline + 1;
158
+ return source.size - end > limits.maxLineBytes ? null : end;
159
+ }
160
+ position -= length;
161
+ }
162
+ return source.size > limits.maxLineBytes ? null : 0;
163
+ }
164
+
165
+ /**
166
+ * Lines before `end`, which follows a newline or is 0, newest first; the nothing between that newline and `end` is not a
167
+ * line. A line longer than the limit ends them, with `bytes` null, before more of it is read.
168
+ */
169
+ async function* linesBackward(source, end, limits) {
170
+ let position = end;
171
+ let tail = [];
172
+ let tailSize = 0;
173
+ let first = true;
174
+ while (position > 0) {
175
+ const length = Math.min(limits.chunkBytes, position);
176
+ position -= length;
177
+ const chunk = await source.read(position, length);
178
+ let lineEnd = chunk.length;
179
+ for (let i = chunk.lastIndexOf(10, lineEnd - 1); i >= 0; i = lineEnd > 0 ? chunk.lastIndexOf(10, lineEnd - 1) : -1) {
180
+ const size = lineEnd - i - 1 + tailSize;
181
+ if (size > limits.maxLineBytes) {
182
+ yield { offset: null, size, bytes: null };
183
+ return;
184
+ }
185
+ if (!first || size) yield { offset: position + i + 1, size, bytes: Buffer.concat([chunk.subarray(i + 1, lineEnd), ...tail]) };
186
+ first = false;
187
+ tail = [];
188
+ tailSize = 0;
189
+ lineEnd = i;
190
+ }
191
+ tailSize += lineEnd;
192
+ if (tailSize > limits.maxLineBytes) {
193
+ yield { offset: null, size: tailSize, bytes: null };
194
+ return;
195
+ }
196
+ tail.unshift(chunk.subarray(0, lineEnd));
197
+ }
198
+ if (tailSize > 0) yield { offset: 0, size: tailSize, bytes: Buffer.concat(tail) };
199
+ }
200
+
201
+ async function readFirstRecord(source, limits) {
202
+ const head = await source.read(0, Math.min(source.size, limits.maxLineBytes));
203
+ const newline = head.indexOf(10);
204
+ try { return JSON.parse(head.subarray(0, newline < 0 ? head.length : newline).toString('utf8')); } catch { return null; }
205
+ }
206
+
207
+ /**
208
+ * One page of a JSONL transcript, read backwards from the cursor. A reader turns
209
+ * records into messages, newest first:
210
+ * - `starts(state, record)`: the record begins a message, so the page may stop before it;
211
+ * - `take(state, record)`: consume it;
212
+ * - `open(state)`: a message is unfinished, so the page may not stop;
213
+ * - `flush(state, atStart)`: finish the open message;
214
+ * - `done(state)`: nothing older belongs to the conversation; `failed` says it cannot be shown truthfully;
215
+ * - `cursor(state)`: what the next page needs;
216
+ * - `skipsCorrupt`: a record that is not JSON is passed over, as the tool passes over it, instead of ending the history.
217
+ * A line too long to read, or a message that does not end within the most a page reads, ends the readable history
218
+ * there: the unfinished message is dropped and the page says it is `truncated`.
219
+ */
220
+ export async function readJsonlPage({ source: kind, id, file, cursor, reader, firstRecord = null, limits = PAGE_LIMITS, open = openSource }) {
221
+ const saved = cursor ? decodeCursor(cursor, kind, id) : null;
222
+ if (saved) checkAnchors(saved);
223
+ const source = await open(file, limits);
224
+ try {
225
+ if (firstRecord && !firstRecord(await readFirstRecord(source, limits))) throw unavailable();
226
+ let end, incomplete = false;
227
+ if (saved) {
228
+ await verify(source, saved);
229
+ end = saved.o;
230
+ } else {
231
+ end = await lastLineEnd(source, limits);
232
+ if (end === null) return { id, messages: [], nextCursor: null, incomplete: true, omitted: false, truncated: true };
233
+ incomplete = end < source.size;
234
+ }
235
+ const before = await anchors(source, end, end);
236
+ const state = { messages: [], omitted: false, failed: false, ...reader.start(saved?.st ?? null) };
237
+ let last = end, scanned = 0, more = false, truncated = false;
238
+ for await (const line of linesBackward(source, end, limits)) {
239
+ const unfinished = reader.open(state);
240
+ if (line.bytes === null || (unfinished && scanned >= limits.maxScanBytes)) {
241
+ truncated = true;
242
+ break;
243
+ }
244
+ let record = null, parsed = true;
245
+ if (line.size) {
246
+ try { record = JSON.parse(line.bytes.toString('utf8')); } catch { parsed = false; }
247
+ if (typeof record !== 'object') record = null;
248
+ }
249
+ if ((!unfinished && scanned >= limits.scanBytes)
250
+ || (record && reader.starts(state, record) && (state.messages.length >= limits.messages || scanned >= limits.scanBytes))) {
251
+ more = true;
252
+ break;
253
+ }
254
+ if (!parsed) {
255
+ state.omitted = true;
256
+ if (!reader.skipsCorrupt) {
257
+ truncated = true;
258
+ break;
259
+ }
260
+ } else if (record) reader.take(state, record);
261
+ scanned += line.size + 1;
262
+ last = line.offset;
263
+ if (reader.done(state)) break;
264
+ }
265
+ if (!truncated) reader.flush(state, !more);
266
+ if (state.failed) throw unavailable();
267
+ if (more && last >= end) throw unavailable();
268
+ const after = await anchors(source, end, end);
269
+ if (after.hh !== before.hh || after.ah !== before.ah) throw changed();
270
+ const nextCursor = more ? encodeCursor({ v: 1, s: kind, i: id, o: last, ...(await anchors(source, last, end)), st: reader.cursor(state) }) : null;
271
+ if (nextCursor && nextCursor.length > MAX_CURSOR) throw unavailable();
272
+ return { id, messages: state.messages.reverse(), nextCursor, incomplete, omitted: state.omitted, truncated };
273
+ } finally {
274
+ await source.close();
275
+ }
276
+ }
277
+
278
+ function clean(text) {
279
+ return typeof text === 'string' ? text.trim() : '';
280
+ }
281
+
282
+ // ---- Claude Code ----------------------------------------------------------------
283
+
284
+ /** The text of a prompt the user wrote, or null for any other record. */
285
+ export function claudePrompt(record) {
286
+ if (record?.type !== 'user' || record.isMeta === true || record.isCompactSummary === true) return null;
287
+ const origin = record.origin && typeof record.origin === 'object' ? record.origin.kind : undefined;
288
+ if (origin !== undefined && origin !== 'human') return null;
289
+ const content = record.message?.content;
290
+ let text;
291
+ if (typeof content === 'string') text = content;
292
+ else if (Array.isArray(content) && !content.some((part) => part?.type === 'tool_result')) {
293
+ text = content.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('\n');
294
+ } else return null;
295
+ const value = clean(text);
296
+ if (origin === undefined && CLAUDE_INSERTED.test(value)) return null;
297
+ return value && !value.startsWith('[Request interrupted by user') ? value : null;
298
+ }
299
+
300
+ function claudeReplyText(record) {
301
+ const content = record.message?.content;
302
+ if (typeof content === 'string') return clean(content);
303
+ if (!Array.isArray(content)) return '';
304
+ return content.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text.trim()).filter(Boolean).join('\n\n');
305
+ }
306
+
307
+ function claudeMessageId(record) {
308
+ return record.type === 'assistant' && typeof record.message?.id === 'string' ? record.message.id : null;
309
+ }
310
+
311
+ /**
312
+ * Claude Code keeps every branch in one file. The conversation it resumes runs
313
+ * from the newest message back through `parentUuid`, and across a compaction
314
+ * through `logicalParentUuid`; records off that chain were rewound away. One
315
+ * reply is several records sharing `message.id`.
316
+ */
317
+ export const claudeReader = {
318
+ skipsCorrupt: true,
319
+ start: (saved) => ({ leaf: saved?.f === true, expected: typeof saved?.u === 'string' ? saved.u : null, root: false, reply: null }),
320
+ onChain(s, record) {
321
+ if (record.isSidechain === true || typeof record.uuid !== 'string') return false;
322
+ return s.leaf ? record.uuid === s.expected : record.type === 'user' || record.type === 'assistant';
323
+ },
324
+ starts(s, record) {
325
+ if (!s.leaf) return record.type === 'last-prompt' || this.onChain(s, record);
326
+ if (!this.onChain(s, record)) return false;
327
+ if (claudePrompt(record) !== null) return true;
328
+ const messageId = claudeMessageId(record);
329
+ return messageId !== null && messageId !== s.reply?.id;
330
+ },
331
+ take(s, record) {
332
+ if (!s.leaf) {
333
+ if (record.type === 'last-prompt' && typeof record.leafUuid === 'string') {
334
+ s.leaf = true;
335
+ s.expected = record.leafUuid;
336
+ return;
337
+ }
338
+ if (!this.onChain(s, record)) return;
339
+ s.leaf = true;
340
+ s.expected = record.uuid;
341
+ }
342
+ if (!this.onChain(s, record)) return;
343
+ s.expected = record.parentUuid || record.logicalParentUuid || null;
344
+ s.root = s.expected === null;
345
+ const prompt = claudePrompt(record);
346
+ if (prompt !== null) {
347
+ this.flush(s);
348
+ s.messages.push({ role: 'user', text: prompt });
349
+ return;
350
+ }
351
+ const messageId = claudeMessageId(record);
352
+ if (messageId === null) return;
353
+ if (s.reply?.id !== messageId) {
354
+ this.flush(s);
355
+ s.reply = { id: messageId, parts: [] };
356
+ }
357
+ const text = claudeReplyText(record);
358
+ if (text) s.reply.parts.unshift(text);
359
+ },
360
+ open: (s) => s.reply !== null,
361
+ flush(s, atStart = false) {
362
+ // A chain that never reaches its root lost a record: the reply waiting on it may be missing a part.
363
+ if (atStart && s.leaf && !s.root && s.expected !== null) s.omitted = true;
364
+ else if (s.reply?.parts.length) s.messages.push({ role: 'assistant', text: s.reply.parts.join('\n\n') });
365
+ s.reply = null;
366
+ },
367
+ done: (s) => s.root,
368
+ cursor: (s) => ({ f: s.leaf, u: s.expected }),
369
+ };
370
+
371
+ // ---- Codex CLI --------------------------------------------------------------------
372
+
373
+ /**
374
+ * A message as Codex shows it: older rollouts carry `user_message` and `agent_message` events, newer ones
375
+ * `item_completed` items. The `response_item` copies also hold the context Codex inserts, so they are never read.
376
+ */
377
+ export function codexMessage(record) {
378
+ const payload = record?.type === 'event_msg' ? record.payload : null;
379
+ if (!payload || typeof payload !== 'object') return null;
380
+ if (payload.type === 'user_message') return { role: 'user', text: clean(payload.message) };
381
+ if (payload.type === 'agent_message') return { role: 'assistant', text: clean(payload.message) };
382
+ if (payload.type !== 'item_completed') return null;
383
+ const item = payload.item;
384
+ const text = Array.isArray(item?.content) ? item.content.filter((part) => typeof part?.text === 'string').map((part) => part.text).join('\n') : '';
385
+ if (item?.type === 'UserMessage') return { role: 'user', text: clean(text) };
386
+ if (item?.type === 'AgentMessage') return { role: 'assistant', text: clean(text) };
387
+ return null;
388
+ }
389
+
390
+ function codexRollback(record) {
391
+ return record.type === 'event_msg' && record.payload?.type === 'thread_rolled_back';
392
+ }
393
+
394
+ /** One rollout has one kind of message. A legacy `thread_rolled_back` dropped the user turns before it. */
395
+ export const codexReader = {
396
+ skipsCorrupt: true,
397
+ start: (saved) => ({ drop: safeInteger(saved?.d) ? saved.d : 0 }),
398
+ starts: (s, record) => codexRollback(record) || codexMessage(record) !== null,
399
+ take(s, record) {
400
+ if (codexRollback(record)) {
401
+ if (safeInteger(record.payload.num_turns)) s.drop += record.payload.num_turns;
402
+ return;
403
+ }
404
+ const message = codexMessage(record);
405
+ if (!message) return;
406
+ if (s.drop > 0) {
407
+ if (message.role === 'user') s.drop--;
408
+ return;
409
+ }
410
+ if (message.text) s.messages.push(message);
411
+ },
412
+ open: () => false,
413
+ flush: () => {},
414
+ done: () => false,
415
+ cursor: (s) => (s.drop ? { d: s.drop } : {}),
416
+ };
417
+
418
+ // ---- Grok Build -------------------------------------------------------------------
419
+
420
+ function grokUpdate(record) {
421
+ const update = record?.params?.update;
422
+ return update && typeof update === 'object' ? update : null;
423
+ }
424
+
425
+ /** What a turn does after its prompt; read backwards, any of it ends the turn before. */
426
+ export const GROK_TURN_ACTIVITY = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'turn_completed', 'rewind_marker']);
427
+
428
+ /**
429
+ * A chunk of a prompt: the key its chunks share, whether text or image, its index, whether the prompt is one Grok
430
+ * Build hides or ran itself, and its text if it has any. Grok Build 1.0.70 numbers chunks with promptIndex; earlier
431
+ * versions gave them a promptId.
432
+ */
433
+ export function grokPromptChunk(record) {
434
+ const update = grokUpdate(record);
435
+ if (update?.sessionUpdate !== 'user_message_chunk') return null;
436
+ return {
437
+ key: update._meta?.promptIndex ?? record.params._meta?.promptId ?? null,
438
+ index: update._meta?.promptIndex,
439
+ hidden: Boolean(update.content?._meta?.bash_command) || update._meta?.host_turn === true || update._meta?.hideFromScrollback === true,
440
+ text: update.content?.type === 'text' && typeof update.content.text === 'string' ? update.content.text : null,
441
+ };
442
+ }
443
+
444
+ /**
445
+ * A turn is the user's prompt and the replies after it; a tool call ends a reply, and only text shows. Records such
446
+ * as `image_dropped` can sit between a prompt's chunks. A rewind leaves the old turns in `updates.jsonl` and appends
447
+ * a `rewind_marker`: read backwards, every turn at or above the lowest target passed so far was discarded. A record
448
+ * that is not JSON may have been a rewind or part of a prompt, so it ends the history.
449
+ */
450
+ export const grokReader = {
451
+ skipsCorrupt: false,
452
+ start: (saved) => ({ cut: safeInteger(saved?.c) ? saved.c : null, turn: null }),
453
+ continues: (s, record, update) => {
454
+ const chunk = grokPromptChunk(record);
455
+ return chunk ? chunk.key === s.turn.key : !GROK_TURN_ACTIVITY.has(update.sessionUpdate);
456
+ },
457
+ starts(s, record) {
458
+ const update = grokUpdate(record);
459
+ if (!update) return false;
460
+ if (!s.turn) return true;
461
+ return s.turn.user && (update.sessionUpdate === 'rewind_marker' || !this.continues(s, record, update));
462
+ },
463
+ take(s, record) {
464
+ const update = grokUpdate(record);
465
+ if (!update) return;
466
+ if (s.turn?.user && !this.continues(s, record, update)) this.flush(s);
467
+ if (update.sessionUpdate === 'rewind_marker') {
468
+ if (safeInteger(update.target_prompt_index)) s.cut = s.cut === null ? update.target_prompt_index : Math.min(s.cut, update.target_prompt_index);
469
+ return;
470
+ }
471
+ s.turn ??= { user: false, key: null, index: undefined, hidden: false, prompt: [], replies: [], reply: null };
472
+ const turn = s.turn;
473
+ const chunk = grokPromptChunk(record);
474
+ if (chunk) {
475
+ turn.user = true;
476
+ turn.reply = null;
477
+ turn.key = chunk.key;
478
+ turn.index = chunk.index;
479
+ if (chunk.hidden) turn.hidden = true;
480
+ else if (chunk.text !== null) turn.prompt.unshift(chunk.text);
481
+ } else if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
482
+ turn.reply = null;
483
+ } else if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
484
+ if (!turn.reply) {
485
+ turn.reply = [];
486
+ turn.replies.unshift(turn.reply);
487
+ }
488
+ turn.reply.unshift(update.content.text);
489
+ }
490
+ },
491
+ open: (s) => s.turn !== null,
492
+ flush(s) {
493
+ const turn = s.turn;
494
+ s.turn = null;
495
+ if (!turn) return;
496
+ const replies = turn.replies.map((parts) => clean(parts.join(''))).filter(Boolean);
497
+ const prompt = turn.hidden ? '' : clean(turn.prompt.join(''));
498
+ if (!replies.length && !prompt) return;
499
+ if (s.cut !== null) {
500
+ if (!safeInteger(turn.index)) { s.failed = true; return; }
501
+ if (turn.index >= s.cut) return;
502
+ }
503
+ for (const text of replies.reverse()) s.messages.push({ role: 'assistant', text });
504
+ if (prompt) s.messages.push({ role: 'user', text: prompt });
505
+ },
506
+ done: (s) => s.failed,
507
+ cursor: (s) => (s.cut !== null ? { c: s.cut } : {}),
508
+ };
509
+
510
+ // ---- Antigravity CLI ----------------------------------------------------------------
511
+
512
+ export const antigravityReader = {
513
+ skipsCorrupt: true,
514
+ start: () => ({}),
515
+ starts: (s, record) => transcriptMessage(record) !== null,
516
+ take(s, record) {
517
+ const message = transcriptMessage(record);
518
+ if (message) s.messages.push(message);
519
+ },
520
+ open: () => false,
521
+ flush: () => {},
522
+ done: () => false,
523
+ cursor: () => ({}),
524
+ };
525
+
526
+ export const JSONL_READERS = {
527
+ claude: { reader: claudeReader },
528
+ codex: { reader: codexReader },
529
+ grok: { reader: grokReader },
530
+ antigravity: { reader: antigravityReader, firstRecord: antigravityUserConversation },
531
+ };
532
+
533
+ export async function readAntigravityTranscript(dir, id, cursor = null, limits = PAGE_LIMITS) {
534
+ return readJsonlPage({ source: 'antigravity', id, file: await transcriptFile(dir, id), cursor, ...JSONL_READERS.antigravity, limits });
535
+ }
536
+
537
+ // ---- Docker Agent -------------------------------------------------------------------
538
+
539
+ /**
540
+ * Docker Agent's own VisibleExchanges rule, worked out by SQLite: 1 for a message row that is not implicit, from the
541
+ * user or the assistant, with no tool calls and text content; 0 for any other row; null for a row that might be shown
542
+ * but is larger than `$max`. CASE stops at the first match, so no JSON function sees a row larger than `$max`.
543
+ */
544
+ export const DOCKER_VISIBLE = `case
545
+ when implicit or subsession_id is not null or message_json is null then 0
546
+ when octet_length(message_json) > $max then null
547
+ when not json_valid(message_json) then 0
548
+ when coalesce(json_extract(message_json, '$.role') not in ('user', 'assistant'), 1) then 0
549
+ when coalesce(json_array_length(message_json, '$.tool_calls'), 0) <> 0 then 0
550
+ else coalesce(json_type(message_json, '$.content') = 'text', 0) end`;
551
+ const DOCKER_PAGE_SQL = (after) => `
552
+ select id, position, octet_length(message_json) as size, visible,
553
+ case when visible then json_extract(message_json, '$.role') end as role,
554
+ case when visible then json_extract(message_json, '$.content') end as content
555
+ from (select id, position, message_json, ${DOCKER_VISIBLE} as visible from session_items
556
+ where session_id = $session and item_type = 'message'${after ? ' and position < $after' : ''})
557
+ order by position desc`;
558
+
559
+ /** A page of a Docker Agent session, newest first, within the page's message and scan limits. */
560
+ export async function readDockerConversation({ dir, id, cursor, loadSqlite, limits = PAGE_LIMITS }) {
561
+ const saved = cursor ? decodeCursor(cursor, 'docker', id) : null;
562
+ if (saved && (!safeInteger(saved.p) || !safeInteger(saved.r, 1))) throw badCursor();
563
+ const { DatabaseSync } = await loadSqlite();
564
+ let db;
565
+ try {
566
+ try { db = new DatabaseSync(path.join(dir, 'session.db'), { readOnly: true }); } catch { throw unavailable(); }
567
+ if (saved && db.prepare('select id from session_items where session_id = ? and position = ?').get(id, saved.p)?.id !== saved.r) throw changed();
568
+ const messages = [];
569
+ let last = null, scanned = 0, more = false, truncated = false;
570
+ const params = { max: limits.maxLineBytes, session: id, ...(saved ? { after: saved.p } : {}) };
571
+ for (const row of db.prepare(DOCKER_PAGE_SQL(saved)).iterate(params)) {
572
+ if (last && (scanned >= limits.scanBytes || (row.visible && messages.length >= limits.messages))) {
573
+ more = true;
574
+ break;
575
+ }
576
+ if (row.visible === null) {
577
+ truncated = true;
578
+ break;
579
+ }
580
+ last = row;
581
+ scanned += (row.size ?? 0) + 1;
582
+ if (row.visible && clean(row.content)) messages.push({ role: row.role, text: clean(row.content) });
583
+ }
584
+ return {
585
+ id,
586
+ messages: messages.reverse(),
587
+ nextCursor: more ? encodeCursor({ v: 1, s: 'docker', i: id, p: last.position, r: last.id, st: {} }) : null,
588
+ incomplete: false,
589
+ omitted: false,
590
+ truncated,
591
+ };
592
+ } catch (err) {
593
+ if (err.status) throw err;
594
+ throw unavailable();
595
+ } finally {
596
+ db?.close();
597
+ }
598
+ }
@@ -19,6 +19,7 @@ import { createManagerServer } from './server.mjs';
19
19
  import { YardArt } from './yard-art.mjs';
20
20
  import { loadPty } from './pty.mjs';
21
21
  import { createNotesStore } from './notes.mjs';
22
+ import { createCardNames } from './card-names.mjs';
22
23
  import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
23
24
  import { SelfUpdate } from './self-update.mjs';
24
25
  import { addRuntimePaths, resolveBaseEnv, pathReader } from './shell-env.mjs';
@@ -87,8 +88,9 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
87
88
  registry.reportingEnabled = (provider) => sessionHooks.enabled(provider);
88
89
  registry.reportingNote = (provider) => sessionHooks.note(provider);
89
90
  sessionHooks.warm();
91
+ const cardNames = createCardNames(paths.cardNames);
90
92
  const manager = new SessionManager({
91
- registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks, store: multiplexerStore,
93
+ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks, store: multiplexerStore, cardNames,
92
94
  });
93
95
  environment.setSessionLookup((id) => {
94
96
  let session;
@@ -99,7 +101,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
99
101
  if (event.type === 'session.removed') environment.forgetSession(event.sessionId);
100
102
  });
101
103
  const usage = new UsageMonitor({ registry, env: baseEnv });
102
- const history = new SessionHistory({ registry, env: baseEnv });
104
+ const history = new SessionHistory({ registry, env: baseEnv, names: cardNames });
103
105
  const memory = new AgentMemory({ env: baseEnv, resolveCwd: (cwd) => manager.resolveCwd(cwd) });
104
106
  const instructions = new InstructionFiles({ env: baseEnv, resolveCwd: (cwd) => manager.resolveCwd(cwd) });
105
107
  const modelStats = new ModelStats({ registry });
@@ -851,7 +851,7 @@ export class ProviderRegistry extends EventEmitter {
851
851
  historySource: provider.history === null ? null : typeof provider.history === 'string' ? provider.history : 'command',
852
852
  memorySource: provider.memory,
853
853
  instructionsSource: provider.instructions,
854
- historyDetails: provider.history === 'antigravity',
854
+ historyDetails: typeof provider.history === 'string',
855
855
  reporting: provider.reporting,
856
856
  reportingEnabled: this.reportingEnabled?.(provider) ?? null,
857
857
  reportingNote: this.reportingNote?.(provider) ?? null,
@@ -110,3 +110,29 @@ export function loadPty() {
110
110
  pty = require('node-pty');
111
111
  return pty;
112
112
  }
113
+
114
+ /**
115
+ * End a Windows session's pty as node-pty 1.2.0-beta.15's kill() does on the inbox ConPTY, without kill()'s
116
+ * console-list helper: that helper attaches to the console by the shell's pid after kill() has ended the shell, when
117
+ * Windows may have given the pid to another process (microsoft/node-pty#967). node-pty closes the pseudoconsole through
118
+ * its own handle, which ends every process attached to it and does nothing once the process has exited. Like kill(),
119
+ * it waits until the terminal is ready.
120
+ */
121
+ export function closeConsole(term) {
122
+ term._deferNoArgs(() => {
123
+ const agent = term._agent;
124
+ term._close();
125
+ agent._clearConnectionTimeout();
126
+ agent._pendingPtyInfo = undefined;
127
+ agent._inSocket.readable = false;
128
+ agent._outSocket.readable = false;
129
+ agent._ptyNative.kill(agent._pty, false);
130
+ releasePty(term);
131
+ });
132
+ }
133
+
134
+ /** What node-pty leaves open on Windows once a process has exited, by itself or by kill(): the output worker and the input pipe. */
135
+ export function releasePty(term) {
136
+ term._agent._conoutSocketWorker.dispose();
137
+ term._agent._inSocket.destroy();
138
+ }
@@ -384,6 +384,10 @@ export function createManagerServer({
384
384
  if (route === '/yard-art' && method === 'GET') return sendJson(res, 200, { yardArt: yardArt.snapshot() });
385
385
  if (route === '/yard-art/download' && method === 'POST') return sendJson(res, 202, { yardArt: yardArt.start() });
386
386
  if (route === '/yard-art/cancel' && method === 'POST') return sendJson(res, 200, { yardArt: yardArt.cancel() });
387
+ if (route === '/yard/fireworks' && method === 'POST') {
388
+ broadcast({ type: 'yard.fireworks' });
389
+ return sendJson(res, 200, { ok: true, pages: [...eventClients].filter((ws) => !ws.revoked && ws.readyState === ws.OPEN).length });
390
+ }
387
391
 
388
392
  if (route === '/info' && method === 'GET') {
389
393
  selfUpdate?.refresh().catch(() => {});