@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.
@@ -10,18 +10,26 @@ import { fileURLToPath } from 'node:url';
10
10
  import { resolveCommand, buildSpawnSpec, runSpec } from './command-resolver.mjs';
11
11
  import { toIso } from './usage.mjs';
12
12
  import { antigravityUserConversation } from '../report/hooks.mjs';
13
- import { ANTIGRAVITY_ID, transcriptFile, readAntigravityTranscript, historyError } from './antigravity-transcript.mjs';
13
+ import { ANTIGRAVITY_ID, transcriptFile, transcriptMessage, historyError } from './antigravity-transcript.mjs';
14
+ import {
15
+ JSONL_READERS, DOCKER_VISIBLE, GROK_TURN_ACTIVITY, readJsonlPage, readAntigravityTranscript, readDockerConversation, checkedFile,
16
+ claudePrompt, codexMessage, grokPromptChunk, zstdDecoder,
17
+ } from './history-transcripts.mjs';
14
18
 
15
19
  export const HISTORY_TTL_MS = 5 * 1000;
16
20
  export const DEFAULT_LIMIT = 100;
17
21
  export const MAX_LIMIT = 500;
18
22
  const HEAD_BYTES = 256 * 1024;
23
+ const TAIL_BYTES = 256 * 1024;
19
24
  const MAX_TITLE = 120;
20
25
  const MAX_ID = 200;
21
26
  const MAX_WALK_DEPTH = 4;
22
27
 
23
28
  export class HistoryError extends Error {}
24
29
 
30
+ export const DETAIL_SOURCES = new Set(['claude', 'codex', 'antigravity', 'grok', 'docker']);
31
+ const TRANSCRIPT_ROOTS = { claude: 'projects', codex: 'sessions', grok: 'sessions' };
32
+
25
33
  const TAG_RE = /^<[a-z][a-z0-9_-]*[\s>]/i;
26
34
 
27
35
  function shortPath(file) {
@@ -29,18 +37,17 @@ function shortPath(file) {
29
37
  return file.startsWith(home) ? `~${file.slice(home.length)}` : file;
30
38
  }
31
39
 
32
- /** One line of prompt text, or null for an empty or tool-inserted (`<tag>…`) message. */
33
- function promptTitle(text) {
40
+ /** One line of prompt text, or null for an empty one. */
41
+ function lineTitle(text) {
34
42
  if (typeof text !== 'string') return null;
35
43
  const clean = text.replace(/\s+/g, ' ').trim();
36
- if (!clean || TAG_RE.test(clean)) return null;
37
- return clean.slice(0, MAX_TITLE);
44
+ return clean ? clean.slice(0, MAX_TITLE) : null;
38
45
  }
39
46
 
40
- function contentText(content) {
41
- if (typeof content === 'string') return content;
42
- if (!Array.isArray(content)) return '';
43
- return content.map((part) => (typeof part === 'string' ? part : part && typeof part.text === 'string' ? part.text : '')).filter(Boolean).join('\n');
47
+ /** For text with no marker saying who wrote it: null also for a tool-inserted (`<tag>…`) message. */
48
+ function promptTitle(text) {
49
+ const title = lineTitle(text);
50
+ return title && !TAG_RE.test(title) ? title : null;
44
51
  }
45
52
 
46
53
  function parseLines(text) {
@@ -56,7 +63,7 @@ async function readHead(file, bytes = HEAD_BYTES) {
56
63
  if (file.endsWith('.zst')) {
57
64
  if (typeof zlib.createZstdDecompress !== 'function') return null;
58
65
  const source = fs.createReadStream(file);
59
- const inflate = source.pipe(zlib.createZstdDecompress());
66
+ const inflate = source.pipe(zstdDecoder());
60
67
  const chunks = [];
61
68
  let size = 0;
62
69
  try {
@@ -79,6 +86,21 @@ async function readHead(file, bytes = HEAD_BYTES) {
79
86
  }
80
87
  }
81
88
 
89
+ /** The whole records in the last bytes of a file, and the offset the first of them starts at. */
90
+ async function readTail(file, size, bytes = TAIL_BYTES) {
91
+ if (size <= HEAD_BYTES) return { text: '', start: size };
92
+ const length = Math.min(bytes, size - HEAD_BYTES);
93
+ const handle = await fs.promises.open(file, 'r');
94
+ try {
95
+ const { bytesRead, buffer } = await handle.read(Buffer.alloc(length), 0, length, size - length);
96
+ const data = buffer.subarray(0, bytesRead);
97
+ const first = data.indexOf(10) + 1;
98
+ return { text: data.subarray(first).toString('utf8'), start: size - length + first };
99
+ } finally {
100
+ await handle.close();
101
+ }
102
+ }
103
+
82
104
  async function readDir(dir) {
83
105
  try {
84
106
  return await fs.promises.readdir(dir, { withFileTypes: true });
@@ -127,7 +149,7 @@ export class FileMemo {
127
149
  const known = this.entries.get(file);
128
150
  if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs) return known.entry;
129
151
  let entry = null;
130
- try { entry = await parse(); } catch { /* unreadable or not a transcript */ }
152
+ try { entry = await parse(known && known.size <= stat.size ? known.entry : null); } catch { /* unreadable or not a transcript */ }
131
153
  this.entries.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, entry });
132
154
  return entry;
133
155
  }
@@ -155,22 +177,51 @@ export function claudeConfigDir(env = process.env) {
155
177
  return (env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')).normalize('NFC');
156
178
  }
157
179
 
158
- function claudeEntry(id, text, stat) {
180
+ /** Claude Code's title records in whole lines of `text`, which starts at offset `base`, and where those lines end. */
181
+ function claudeTitles(text, base) {
182
+ const titles = { custom: null, ai: null, end: base };
183
+ let at = base;
184
+ for (const line of text.split('\n').slice(0, -1)) {
185
+ let record = null;
186
+ try { record = JSON.parse(line); } catch { /* a foreign line */ }
187
+ if (record?.type === 'custom-title' && typeof record.customTitle === 'string' && record.customTitle.trim()) titles.custom = { text: record.customTitle, at };
188
+ if (record?.type === 'ai-title' && typeof record.aiTitle === 'string' && record.aiTitle.trim()) titles.ai = { text: record.aiTitle, at };
189
+ at += Buffer.byteLength(line) + 1;
190
+ }
191
+ titles.end = at;
192
+ return titles;
193
+ }
194
+
195
+ /**
196
+ * The newest title record by position in the file. Only the start and end are
197
+ * read; a title found earlier still counts while it lies in the unread middle
198
+ * of a file that has only grown.
199
+ */
200
+ function newestTitle(head, tail, before, key) {
201
+ const kept = before?.titles?.[key];
202
+ const unread = kept && kept.at >= head.end && kept.at < tail.start ? kept : null;
203
+ return [head[key], unread, tail[key]].filter(Boolean).reduce((newest, title) => (title.at > newest.at ? title : newest), { at: -1, text: null });
204
+ }
205
+
206
+ function claudeEntry(id, text, tail, stat, before) {
159
207
  let cwd = null;
160
208
  let startedAt = null;
161
- let title = null;
209
+ let prompt = null;
162
210
  let seenUser = false;
163
- for (const record of parseLines(text)) {
211
+ const records = parseLines(text);
212
+ for (const record of records) {
164
213
  if (!record || typeof record !== 'object') continue;
165
214
  if (cwd === null && typeof record.cwd === 'string') cwd = record.cwd;
166
215
  if (startedAt === null && record.timestamp) startedAt = record.timestamp;
167
- if (record.type === 'custom-title' && typeof record.customTitle === 'string' && record.customTitle.trim()) title = record.customTitle;
168
216
  if (record.type !== 'user' || record.isSidechain === true) continue;
169
217
  seenUser = true;
170
- if (title === null) title = promptTitle(contentText(record.message?.content));
218
+ prompt ??= lineTitle(claudePrompt(record));
171
219
  }
172
220
  if (cwd === null && !seenUser) return null;
173
- return cleanEntry({ id, title, cwd, startedAt, updatedAt: stat.mtime });
221
+ const head = claudeTitles(text, 0), end = { ...claudeTitles(tail.text, tail.start), start: tail.start };
222
+ const custom = newestTitle(head, end, before, 'custom'), ai = newestTitle(head, end, before, 'ai');
223
+ const titles = { custom: custom.text === null ? null : custom, ai: ai.text === null ? null : ai };
224
+ return { ...cleanEntry({ id, title: titles.custom?.text ?? titles.ai?.text ?? prompt, cwd, startedAt, updatedAt: stat.mtime }), titles };
174
225
  }
175
226
 
176
227
  /** Sessions under `<dir>/projects/<folder>/<id>.jsonl`; sub-agent transcripts sit in sub-folders and are not listed. */
@@ -188,8 +239,8 @@ export async function listClaudeSessions(dir, memo = new FileMemo()) {
188
239
  if (!stat) continue;
189
240
  seen.add(file);
190
241
  const id = item.name.slice(0, -'.jsonl'.length);
191
- const entry = await memo.entry(file, stat, async () => claudeEntry(id, await readHead(file), stat));
192
- if (entry) entries.push(entry);
242
+ const entry = await memo.entry(file, stat, async (before) => claudeEntry(id, await readHead(file), await readTail(file, stat.size), stat, before));
243
+ if (entry) entries.push({ ...entry, file });
193
244
  }
194
245
  }
195
246
  memo.prune(root, seen);
@@ -202,18 +253,6 @@ export function codexHome(env = process.env) {
202
253
  return env.CODEX_HOME || path.join(os.homedir(), '.codex');
203
254
  }
204
255
 
205
- function codexPrompt(record) {
206
- const payload = record?.payload;
207
- if (!payload || typeof payload !== 'object') return null;
208
- if (record.type === 'event_msg') {
209
- if (payload.type === 'user_message') return promptTitle(payload.message);
210
- if (payload.type === 'item_completed' && payload.item?.type === 'UserMessage') return promptTitle(contentText(payload.item.content));
211
- return null;
212
- }
213
- if (record.type === 'response_item' && payload.type === 'message' && payload.role === 'user') return promptTitle(contentText(payload.content));
214
- return null;
215
- }
216
-
217
256
  function codexEntry(text, stat) {
218
257
  const records = parseLines(text);
219
258
  const meta = records.find((r) => r?.type === 'session_meta')?.payload;
@@ -223,7 +262,8 @@ function codexEntry(text, stat) {
223
262
  if (meta.parent_thread_id || (meta.thread_source && meta.thread_source !== 'user')) return null;
224
263
  let title = null;
225
264
  for (const record of records) {
226
- title = codexPrompt(record);
265
+ const message = codexMessage(record);
266
+ title = message?.role === 'user' ? lineTitle(message.text) : null;
227
267
  if (title) break;
228
268
  }
229
269
  return cleanEntry({ id, title, cwd: meta.cwd, startedAt: meta.timestamp ?? records[0]?.timestamp, updatedAt: stat.mtime });
@@ -238,11 +278,25 @@ async function walk(dir, depth, onFile) {
238
278
  }
239
279
  }
240
280
 
281
+ async function codexThreadNames(dir, memo) {
282
+ const file = path.join(dir, 'session_index.jsonl');
283
+ const stat = await statFile(file);
284
+ if (!stat) return new Map();
285
+ return (await memo.entry(file, stat, async () => {
286
+ const names = new Map();
287
+ for (const record of parseLines(await fs.promises.readFile(file, 'utf8'))) {
288
+ if (typeof record?.id === 'string' && typeof record.thread_name === 'string' && record.thread_name.trim()) names.set(record.id, record.thread_name);
289
+ }
290
+ return names;
291
+ })) ?? new Map();
292
+ }
293
+
241
294
  /** Rollouts under `<dir>/sessions/YYYY/MM/DD/rollout-*.jsonl`, uncompressed or zstd-compressed. Sub-agent threads are skipped. */
242
295
  export async function listCodexSessions(dir, memo = new FileMemo()) {
243
296
  const root = path.join(dir, 'sessions');
244
297
  const entries = [];
245
298
  const seen = new Set();
299
+ const names = await codexThreadNames(dir, memo);
246
300
  await walk(root, MAX_WALK_DEPTH, async (file, name) => {
247
301
  if (!/^rollout-.*\.jsonl(\.zst)?$/.test(name)) return;
248
302
  const stat = await statFile(file);
@@ -252,7 +306,7 @@ export async function listCodexSessions(dir, memo = new FileMemo()) {
252
306
  const head = await readHead(file);
253
307
  return head === null ? null : codexEntry(head, stat);
254
308
  });
255
- if (entry) entries.push(entry);
309
+ if (entry) entries.push({ ...entry, title: cleanEntry({ id: entry.id, title: names.get(entry.id) }).title ?? entry.title, file });
256
310
  });
257
311
  memo.prune(root, seen);
258
312
  return dedupe(entries);
@@ -299,8 +353,7 @@ function antigravityEntry(id, text, stat) {
299
353
  let first;
300
354
  try { first = JSON.parse((text ?? '').split('\n', 1)[0]); } catch { return null; }
301
355
  if (!antigravityUserConversation(first) || typeof first.content !== 'string') return null;
302
- const request = first.content.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/)?.[1];
303
- return cleanEntry({ id, title: promptTitle(request), startedAt: first.created_at, updatedAt: stat.mtime });
356
+ return cleanEntry({ id, title: lineTitle(transcriptMessage(first)?.text), startedAt: first.created_at, updatedAt: stat.mtime });
304
357
  }
305
358
 
306
359
  /** Legacy fallback. Multiple cache paths for one id cannot identify a launch folder. */
@@ -358,7 +411,7 @@ export async function listAntigravitySessions(dir, memo = new FileMemo(), { plat
358
411
  const workspaces = summary ? antigravityWorkspaces(summary.workspace_uris, platform) : [];
359
412
  const cwd = workspaces.length === 1 ? workspaces[0] : workspaces.length ? null : folders.get(entry.id) ?? null;
360
413
  const title = promptTitle(summary?.title) || promptTitle(summary?.preview) || entry.title;
361
- entries.push({ ...entry, title, cwd, ...(withStatus ? { workspaces: workspaces.length ? workspaces : cwd ? [cwd] : [] } : {}) });
414
+ entries.push({ ...entry, title, cwd, file, ...(withStatus ? { workspaces: workspaces.length ? workspaces : cwd ? [cwd] : [] } : {}) });
362
415
  }
363
416
  }
364
417
  memo.prune(root, seen);
@@ -393,21 +446,19 @@ async function loadSqlite() {
393
446
  return sqlite;
394
447
  }
395
448
 
396
- // A session's title, else its first user message; the newest message dates it. A sub-agent's session has a parent.
449
+ // A session's title, else the first message its preview shows if that is the user's; the newest message dates it.
450
+ // A sub-agent's session has a parent. No JSON function reads a row larger than $max.
397
451
  const DOCKER_SESSIONS_SQL = `
398
452
  select s.id, s.title, s.created_at, s.working_dir,
399
- (select i.message_json from session_items i where i.session_id = s.id and i.item_type = 'message' order by i.position limit 1) as first_message,
400
- (select max(json_extract(i.message_json, '$.created_at')) from session_items i where i.session_id = s.id) as updated_at
453
+ (select case when visible and json_extract(message_json, '$.role') = 'user' then json_extract(message_json, '$.content') end
454
+ from (select position, message_json, ${DOCKER_VISIBLE} as visible from session_items i where i.session_id = s.id and i.item_type = 'message')
455
+ where visible is not 0 order by position limit 1) as first_prompt,
456
+ (select max(case when octet_length(i.message_json) <= $max then json_extract(i.message_json, '$.created_at') end)
457
+ from session_items i where i.session_id = s.id) as updated_at
401
458
  from sessions s where s.parent_id is null order by s.created_at desc limit ${MAX_LIMIT}`;
402
459
 
403
460
  function dockerEntry(row) {
404
- let title = typeof row.title === 'string' && row.title.trim() ? row.title : null;
405
- if (!title && typeof row.first_message === 'string') {
406
- try {
407
- const message = JSON.parse(row.first_message);
408
- if (message?.role === 'user') title = promptTitle(contentText(message.content));
409
- } catch { /* not a message */ }
410
- }
461
+ const title = typeof row.title === 'string' && row.title.trim() ? row.title : lineTitle(row.first_prompt);
411
462
  return cleanEntry({ id: row.id, title, cwd: row.working_dir, startedAt: row.created_at, updatedAt: row.updated_at ?? row.created_at });
412
463
  }
413
464
 
@@ -419,7 +470,7 @@ export async function listDockerSessions(dir) {
419
470
  let db = null;
420
471
  try {
421
472
  db = new DatabaseSync(file, { readOnly: true });
422
- return dedupe(db.prepare(DOCKER_SESSIONS_SQL).all().map(dockerEntry).filter(Boolean));
473
+ return dedupe(db.prepare(DOCKER_SESSIONS_SQL).all({ max: HEAD_BYTES }).map(dockerEntry).filter(Boolean));
423
474
  } catch (err) {
424
475
  throw new HistoryError(`${shortPath(file)} could not be read: ${err.message}`);
425
476
  } finally {
@@ -441,19 +492,22 @@ async function grokBucketCwd(bucket) {
441
492
  return (await readText(path.join(bucket, '.cwd')))?.trim() || null;
442
493
  }
443
494
 
495
+ /** The first prompt Grok Build shows, its chunks joined as the preview joins them. */
444
496
  function grokPrompt(text) {
445
- const parts = [];
446
- let promptId;
497
+ let turn = null;
498
+ const title = () => (turn && !turn.hidden ? lineTitle(turn.parts.join('')) : null);
447
499
  for (const record of parseLines(text)) {
448
- const update = record?.params?.update;
449
- if (update?.sessionUpdate !== 'user_message_chunk' || update.content?.type !== 'text') continue;
450
- if (update.content._meta?.bash_command || update._meta?.host_turn === true) continue;
451
- const id = record.params._meta?.promptId ?? null;
452
- if (parts.length && id !== promptId) break;
453
- promptId = id;
454
- parts.push(update.content.text);
500
+ const chunk = grokPromptChunk(record);
501
+ if (turn && (chunk ? chunk.key !== turn.key : GROK_TURN_ACTIVITY.has(record?.params?.update?.sessionUpdate))) {
502
+ if (title()) return title();
503
+ turn = null;
504
+ }
505
+ if (!chunk) continue;
506
+ turn ??= { key: chunk.key, hidden: false, parts: [] };
507
+ if (chunk.hidden) turn.hidden = true;
508
+ else if (chunk.text !== null) turn.parts.push(chunk.text);
455
509
  }
456
- return promptTitle(parts.join(''));
510
+ return title();
457
511
  }
458
512
 
459
513
  async function grokEntry(folder, summary, stat, bucketCwd) {
@@ -492,7 +546,7 @@ export async function listGrokSessions(dir, memo = new FileMemo()) {
492
546
  if (!stat) continue;
493
547
  seen.add(file);
494
548
  const entry = await memo.entry(file, stat, async () => grokEntry(folder, JSON.parse(await readText(file)), stat, cwd));
495
- if (entry) entries.push(entry);
549
+ if (entry) entries.push({ ...entry, file: path.join(folder, 'updates.jsonl') });
496
550
  }
497
551
  }
498
552
  memo.prune(root, seen);
@@ -526,9 +580,11 @@ export class SessionHistory {
526
580
  * @param {object} [opts.env]
527
581
  * @param {number} [opts.ttlMs]
528
582
  * @param {object} [opts.readers] session listers by source, replaceable in tests
583
+ * @param {ReturnType<import('./card-names.mjs').createCardNames>|null} [opts.names] the names given to cards, by tool session id
529
584
  */
530
- constructor({ registry, env = process.env, platform = process.platform, ttlMs = HISTORY_TTL_MS, readers = {} } = {}) {
585
+ constructor({ registry, env = process.env, platform = process.platform, ttlMs = HISTORY_TTL_MS, readers = {}, names = null } = {}) {
531
586
  this.registry = registry;
587
+ this.names = names;
532
588
  this.env = env;
533
589
  this.platform = platform;
534
590
  this.ttlMs = ttlMs;
@@ -550,20 +606,33 @@ export class SessionHistory {
550
606
  const n = Math.min(MAX_LIMIT, Math.max(1, Math.floor(Number(limit)) || DEFAULT_LIMIT));
551
607
  const snapshot = await this.snapshot(provider, account);
552
608
  const search = typeof query === 'string' ? query.trim().toLowerCase().slice(0, 500) : '';
553
- const sessions = snapshot.sessions.filter((entry) =>
554
- (!cwd || (entry.workspaces ?? [entry.cwd]).some((folder) => sameHistoryFolder(folder, cwd, this.platform)))
555
- && (!search || [entry.id, entry.title, ...(entry.workspaces ?? [entry.cwd])].join('\n').toLowerCase().includes(search)));
609
+ const sessions = snapshot.sessions.map(({ file, titles, ...entry }) => ({ ...entry, name: this.names?.get(provider.id, account.id, entry.id) ?? null }))
610
+ .filter((entry) =>
611
+ (!cwd || (entry.workspaces ?? [entry.cwd]).some((folder) => sameHistoryFolder(folder, cwd, this.platform)))
612
+ && (!search || [entry.id, entry.name, entry.title, ...(entry.workspaces ?? [entry.cwd])].join('\n').toLowerCase().includes(search)));
556
613
  return { ...snapshot, total: sessions.length, sessions: sessions.slice(0, n) };
557
614
  }
558
615
 
559
616
  async detail(provider, account, id, cursor) {
560
- if (provider.history !== 'antigravity') throw historyError(400, 'history_detail_unsupported', 'This tool has no conversation preview.');
561
- if (!ANTIGRAVITY_ID.test(id)) throw historyError(400, 'bad_history_id', 'Invalid conversation id.');
617
+ const source = provider.history;
618
+ if (!DETAIL_SOURCES.has(source)) throw historyError(400, 'history_detail_unsupported', 'This tool has no conversation preview.');
619
+ if (typeof id !== 'string' || cleanEntry({ id })?.id !== id || (source === 'antigravity' && !ANTIGRAVITY_ID.test(id))) {
620
+ throw historyError(400, 'bad_history_id', 'Invalid conversation id.');
621
+ }
562
622
  // Revalidate membership, even if the client still has an older list open.
563
623
  const snapshot = await this._read(provider, account);
564
- if (!snapshot.sessions.some((s) => s.id === id)) throw historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
624
+ const entry = snapshot.sessions.find((s) => s.id === id);
625
+ if (!entry) throw historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
565
626
  const env = { ...this.env, ...provider.env, ...account.env };
566
- return readAntigravityTranscript(antigravityDir(env, this.platform), id, cursor);
627
+ const dir = SessionHistory.sourceDir(source, env, this.platform);
628
+ if (source === 'docker') return readDockerConversation({ dir, id, cursor, loadSqlite });
629
+ if (source === 'antigravity') return readAntigravityTranscript(dir, id, cursor);
630
+ if (typeof entry.file !== 'string') throw historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
631
+ const root = path.join(dir, TRANSCRIPT_ROOTS[source]);
632
+ // Codex may have compressed or decompressed the rollout since it was listed.
633
+ const other = entry.file.endsWith('.zst') ? entry.file.slice(0, -4) : `${entry.file}.zst`;
634
+ const file = await checkedFile(root, entry.file).catch((err) => (source === 'codex' ? checkedFile(root, other) : Promise.reject(err)));
635
+ return readJsonlPage({ source, id, file, cursor, ...JSONL_READERS[source] });
567
636
  }
568
637
 
569
638
  snapshot(provider, account) {
@@ -64,9 +64,16 @@ export class SessionManager extends EventEmitter {
64
64
  * @param {string|null} [opts.shimDir] folder with the agent-guild-report launchers, put first on PATH
65
65
  * @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
66
66
  * @param {import('./github.mjs').GitHub|null} [opts.github]
67
+ * @param {ReturnType<import('./card-names.mjs').createCardNames>|null} [opts.cardNames] names given to cards, by tool session id
67
68
  */
68
- constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null, store = null }) {
69
+ constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null, store = null, cardNames = null }) {
69
70
  super();
71
+ this.cardNames = cardNames;
72
+ /** Each card's latest choice of name for the tool session it holds, in the order the choices were made. */
73
+ this.nameChoices = new WeakMap();
74
+ this.nameOrder = 0;
75
+ /** Tool session → the order of the newest choice written to the store for it. */
76
+ this.namesWritten = new Map();
70
77
  this.registry = registry;
71
78
  this.baseEnv = baseEnv;
72
79
  this.getApiUrl = getApiUrl;
@@ -144,11 +151,12 @@ export class SessionManager extends EventEmitter {
144
151
  if (!current) throw httpError(404, `unknown provider "${provider.id}"`, 'unknown_provider');
145
152
  const signIn = this.registry.account(current, account);
146
153
  this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
147
- const sessionName = cleanName(name)
148
- || (current.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null)
149
- || (runShell && this.registry.shellsFor(provider).shells.length > 1 ? `${provider.tool} · ${runShell.label}` : null);
154
+ const defaultName = (current.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null)
155
+ || (runShell && this.registry.shellsFor(provider).shells.length > 1 ? `${provider.tool} · ${runShell.label}` : null)
156
+ || provider.tool;
157
+ const given = cleanName(name) || (resumeId ? this.cardNames?.get(provider.id, signIn.id, resumeId) : null) || null;
150
158
  const extraEnv = hooks.env ? { ...runShell?.env, ...hooks.env } : runShell?.env;
151
- const options = { provider, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn, reporting: hooks.reporting, extraEnv };
159
+ const options = { provider, cwd: workDir, cols, rows, name: given ?? defaultName, defaultName, named: given !== null, resume: resumeId, account: signIn, reporting: hooks.reporting, extraEnv };
152
160
  if (runShell?.multiplexer) return this._withMultiplexerStart(provider.id, runShell.id, () => this._startMultiplexer(options, runShell, args || []));
153
161
  const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args, runShell);
154
162
  const session = this._spawn({ ...options, spawnSpec });
@@ -209,7 +217,7 @@ export class SessionManager extends EventEmitter {
209
217
  }
210
218
  this._track(session, { spawnSpec, mux, shell, card: {
211
219
  id, reportToken, provider: provider.id, account: account.id, shell: shell.id,
212
- muxName, name: session.name, cwd: session.cwd, createdAt: session.createdAt,
220
+ muxName, name: session.name, defaultName: session.defaultName, named: session.named, cwd: session.cwd, createdAt: session.createdAt,
213
221
  // Herdr's request arguments belong to its client and must survive rebinding.
214
222
  // Tmux's request arguments belong to the already-running inner session.
215
223
  ...(shell.id === 'herdr' ? { args: [...args] } : {}),
@@ -247,13 +255,44 @@ export class SessionManager extends EventEmitter {
247
255
  this._saveCards();
248
256
  session.on('exit', () => this._checkMultiplexer(session, tracked));
249
257
  session.on('changed', () => {
250
- if (card.name === session.name && (card.context?.compactions ?? 0) === session.context.compactions && (card.context?.resets ?? 0) === session.context.resets) return;
258
+ if (card.name === session.name && card.named === session.named && (card.context?.compactions ?? 0) === session.context.compactions && (card.context?.resets ?? 0) === session.context.resets) return;
251
259
  card.name = session.name;
260
+ card.named = session.named;
252
261
  card.context = { ...session.context };
253
262
  this._saveCards();
254
263
  });
255
264
  }
256
265
 
266
+ /**
267
+ * The store holds what a card last chose for the tool session it holds: its name, or none for its default. A
268
+ * card's choice is written once, when it is made: a rename, a new tool session, or the card's start. One whose
269
+ * write failed is written again on a later change, unless a newer choice for that tool session was written since,
270
+ * so a card shown again never puts back a name another card replaced. A card nobody named writes nothing.
271
+ */
272
+ _keepCardName(session) {
273
+ // The page's identity for the tool session a card holds; a resumed card knows it before the tool reports.
274
+ const toolSessionId = session.toolSessionId ?? session.resume;
275
+ if (!this.cardNames || session.task !== null || !toolSessionId || !session.nameChosen) return;
276
+ const account = session.account?.id ?? 'default';
277
+ const key = `${session.provider.id}\0${account}\0${toolSessionId}`;
278
+ const name = session.named ? session.name : null;
279
+ let choice = this.nameChoices.get(session);
280
+ if (choice?.key !== key || choice.name !== name) {
281
+ choice = { key, name, order: ++this.nameOrder, written: false };
282
+ this.nameChoices.set(session, choice);
283
+ }
284
+ if (choice.written) return;
285
+ if ((this.namesWritten.get(key) ?? 0) > choice.order) {
286
+ choice.written = true;
287
+ return;
288
+ }
289
+ // set() and delete() say whether the store now holds the choice; get() cannot tell an unreadable store from one without it.
290
+ const saved = name ? this.cardNames.set(session.provider.id, account, toolSessionId, name) : this.cardNames.delete(session.provider.id, account, toolSessionId);
291
+ if (!saved) return;
292
+ choice.written = true;
293
+ this.namesWritten.set(key, choice.order);
294
+ }
295
+
257
296
  _saveCards() {
258
297
  // A stopping manager leaves the file as it is, for the next one to bring the cards back.
259
298
  if (!this.store || this.restoring || this.closing) return;
@@ -309,12 +348,13 @@ export class SessionManager extends EventEmitter {
309
348
  const spawnSpec = this.registry.spawnSpec(provider, shell.id === 'herdr' ? card.args ?? [] : [], null, [], mux.named);
310
349
  const session = this._spawn({
311
350
  provider, spawnSpec: null, cwd: typeof card.cwd === 'string' ? card.cwd : os.homedir(), name: card.name, account,
351
+ defaultName: typeof card.defaultName === 'string' ? card.defaultName : undefined, named: card.named === true,
312
352
  id: card.id, reportToken: card.reportToken, dropEnv: mux.dropEnv, extraEnv: shell.env,
313
353
  createdAt: typeof card.createdAt === 'string' && !Number.isNaN(Date.parse(card.createdAt)) ? card.createdAt : undefined,
314
354
  context: card.context,
315
355
  multiplexer: { label: shell.label, attach: shell.multiplexer.attach.replaceAll('{name}', card.muxName), reattachable: true },
316
356
  });
317
- this._track(session, { spawnSpec, mux, shell, card: { ...card, name: session.name, cwd: session.cwd, createdAt: session.createdAt } });
357
+ this._track(session, { spawnSpec, mux, shell, card: { ...card, name: session.name, defaultName: session.defaultName, named: session.named, cwd: session.cwd, createdAt: session.createdAt } });
318
358
  }
319
359
 
320
360
  /** Attach a stopped tmux or herdr session's card to its multiplexer session again, keeping its id and report token. */
@@ -711,7 +751,7 @@ export class SessionManager extends EventEmitter {
711
751
  }
712
752
 
713
753
  _spawn({
714
- provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
754
+ provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, defaultName, named = false, resume = null, task = null, installKind = null, installPath = null, account = null,
715
755
  extraEnv = null, dropEnv = null, clone = null, reporting = null, multiplexer = null,
716
756
  id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt, context = null, multiplexerInstall = null,
717
757
  }) {
@@ -731,6 +771,8 @@ export class SessionManager extends EventEmitter {
731
771
  cols: clampDimension(cols, 120, 2, 1000),
732
772
  rows: clampDimension(rows, 32, 1, 500),
733
773
  name,
774
+ defaultName,
775
+ named,
734
776
  reportToken,
735
777
  resume,
736
778
  task,
@@ -745,7 +787,9 @@ export class SessionManager extends EventEmitter {
745
787
  throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
746
788
  }
747
789
 
790
+ this._keepCardName(session);
748
791
  session.on('changed', () => {
792
+ this._keepCardName(session);
749
793
  if (this.sessions.has(id)) this.emit('event', { type: 'session.updated', session: session.toJSON() });
750
794
  });
751
795
  session.on('warning', (msg) => console.warn(`[session ${id}] ${msg}`));
@@ -6,8 +6,7 @@ import { EventEmitter } from 'node:events';
6
6
  import crypto from 'node:crypto';
7
7
  import headless from '@xterm/headless';
8
8
  import serializeAddon from '@xterm/addon-serialize';
9
- import { killWindowsTree } from './command-resolver.mjs';
10
- import { loadPty } from './pty.mjs';
9
+ import { loadPty, closeConsole, releasePty } from './pty.mjs';
11
10
  import { sessionLabel } from '../../web/activity-favicon.js';
12
11
 
13
12
  const { Terminal } = headless;
@@ -68,6 +67,8 @@ export class Session extends EventEmitter {
68
67
  * @param {number} opts.cols
69
68
  * @param {number} opts.rows
70
69
  * @param {string} [opts.name]
70
+ * @param {string} [opts.defaultName] the name a card has when nobody named it
71
+ * @param {boolean} [opts.named] the name was given, so it is kept for the tool session
71
72
  * @param {string|null} [opts.resume] id of the tool's own session being resumed
72
73
  * @param {string|null} [opts.task] "install" for a package install, "upgrade" for the manager's own, "clone" for a GitHub clone, else null
73
74
  * @param {{id: string, label: string}|null} [opts.account] the tool sign-in the session runs under
@@ -84,7 +85,11 @@ export class Session extends EventEmitter {
84
85
  super();
85
86
  this.id = opts.id;
86
87
  this.provider = opts.provider;
87
- this.name = cleanName(opts.name) || opts.provider.tool;
88
+ this.defaultName = cleanName(opts.defaultName) || opts.provider.tool;
89
+ this.name = cleanName(opts.name) || this.defaultName;
90
+ this.named = opts.named === true && this.name !== this.defaultName;
91
+ /** Someone gave or changed this card's name, its default included. */
92
+ this.nameChosen = opts.named === true;
88
93
  this.resume = opts.resume ?? null;
89
94
  this.task = opts.task ?? null;
90
95
  this.account = opts.account ?? null;
@@ -98,6 +103,7 @@ export class Session extends EventEmitter {
98
103
  this.activityIdleMs = opts.activityIdleMs ?? 2500;
99
104
  this.doneAgentLingerMs = opts.doneAgentLingerMs ?? 15000;
100
105
  this.killGraceMs = opts.killGraceMs ?? 4000;
106
+ this._killed = false;
101
107
  this.reportingTimeoutMs = opts.reportingTimeoutMs ?? 30000;
102
108
  this.reporting = REPORTING_STATES.has(opts.reporting?.state) ? { state: opts.reporting.state, reason: opts.reporting.reason ?? null } : null;
103
109
  this._reportingTimer = null;
@@ -198,6 +204,7 @@ export class Session extends EventEmitter {
198
204
  useConpty: true,
199
205
  });
200
206
  this.pty = proc;
207
+ this._killed = false;
201
208
  proc.onData((data) => { if (this.pty === proc) this._onData(data); });
202
209
  proc.onExit(({ exitCode, signal }) => { if (this.pty === proc) this._onExit(exitCode, signal); });
203
210
  }
@@ -244,6 +251,7 @@ export class Session extends EventEmitter {
244
251
 
245
252
  _onExit(exitCode, signal) {
246
253
  clearTimeout(this._killTimer);
254
+ if (process.platform === 'win32') releasePty(this.pty);
247
255
  this._resolveExited();
248
256
  if (this.disposed) return;
249
257
  this.exitedAt ??= new Date().toISOString();
@@ -412,16 +420,18 @@ export class Session extends EventEmitter {
412
420
 
413
421
  /**
414
422
  * End the process. On macOS and Linux: hang-up first, force after a grace
415
- * period. On Windows: end the whole process tree at once, except for a
416
- * multiplexer's client, whose server is its child there and must outlive
417
- * it. node-pty's own Windows kill first asks a helper process for the
418
- * console's process list, and when that helper fails it waits a fixed
419
- * five seconds.
423
+ * period. On Windows: close the session's console, which ends every process
424
+ * attached to it; a multiplexer's server and anything started detached
425
+ * outlive it.
420
426
  */
421
427
  kill({ graceMs = this.killGraceMs } = {}) {
422
428
  if (this.status !== 'running') return;
423
429
  if (process.platform === 'win32') {
424
- this._killWindowsTree();
430
+ // At most once per pty: node-pty 1.2.0-beta.15 closes the pseudoconsole again on a second close before the
431
+ // exit watcher runs, which corrupts this process's heap (microsoft/node-pty#983).
432
+ if (this._killed) return;
433
+ this._killed = true;
434
+ closeConsole(this.pty);
425
435
  return;
426
436
  }
427
437
  const force = () => { try { this.pty.kill('SIGKILL'); } catch { /* gone */ } };
@@ -431,15 +441,6 @@ export class Session extends EventEmitter {
431
441
  this._killTimer.unref?.();
432
442
  }
433
443
 
434
- _killWindowsTree() {
435
- const fallback = () => {
436
- if (this.status === 'running') { try { this.pty.kill(); } catch { /* gone */ } }
437
- };
438
- const pid = this.pty.pid;
439
- if (!pid) return fallback();
440
- killWindowsTree(pid, (err) => { if (err) fallback(); }, { tree: !this.multiplexer });
441
- }
442
-
443
444
  /**
444
445
  * Detach everything. A still-running process gets the normal hang-up and
445
446
  * is force-killed after the grace period if it ignores it.
@@ -461,6 +462,8 @@ export class Session extends EventEmitter {
461
462
  const clean = cleanName(name);
462
463
  if (!clean) return;
463
464
  this.name = clean;
465
+ this.named = clean !== this.defaultName;
466
+ this.nameChosen = true;
464
467
  this._changed();
465
468
  }
466
469
 
@@ -84,6 +84,13 @@ const TOOLS = [
84
84
  inputSchema: { type: 'object', properties: { session_id: sessionId }, required: ['session_id'], additionalProperties: false },
85
85
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
86
86
  },
87
+ {
88
+ name: 'fireworks',
89
+ title: 'Fireworks over the Yard',
90
+ description: 'Launch fireworks over the Yard on every screen connected to Agent Guild. Use it to celebrate something the person cares about, such as tests, a build or a release that passed. Says how many pages saw them.',
91
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
92
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
93
+ },
87
94
  ];
88
95
 
89
96
  const INPUT_TOOL = {
@@ -309,6 +316,10 @@ export function createToolHandlers(client, { allowInput = false } = {}) {
309
316
  const { session } = await client.api(`/sessions/${checkId(id)}/stop`, { method: 'POST', session: id });
310
317
  return text({ stopped: true, session: summarize(session) });
311
318
  },
319
+ async fireworks() {
320
+ const { pages } = await client.api('/yard/fireworks', { method: 'POST' });
321
+ return text({ launched: true, pages, ...(pages ? {} : { message: 'No page is open to see them. The person can open Agent Guild and choose the Yard.' }) });
322
+ },
312
323
  };
313
324
  if (allowInput) {
314
325
  handlers.send_input = async ({ session_id: id, text: typed, submit = true }) => {