@oddessentials/agent-guild 1.18.0 → 1.20.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,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const NODE_COMMANDS = new Set(['node', 'npm', 'npx', 'corepack']);
6
+ const quote = (text) => `'${text.replace(/'/g, "'\\''")}'`;
7
+ const cmdLauncher = (entry) => `@"%~dp0..\\${entry}" %*\r\n`;
8
+ const shLauncher = (target, node = null) => `#!/bin/sh\nexec ${node ? `${quote(node)} ` : ''}${quote(target)} "$@"\n`;
9
+ const gitBashLauncher = (name) => `#!/bin/sh\nexec "$(dirname "$0")/../${name}" "$@"\n`;
10
+
11
+ /**
12
+ * The installers put only <prefix>/launcher on PATH, never the Node.js they
13
+ * bring. This writes a command there for each one npm installed in that
14
+ * prefix, run on that Node.js, and removes those npm no longer has. Without
15
+ * that folder the running Node.js is not an installer's, and nothing changes.
16
+ */
17
+ export function refreshLaunchers({ execPath = process.execPath, platform = process.platform } = {}) {
18
+ const win = platform === 'win32';
19
+ const prefix = fs.realpathSync(win ? path.dirname(execPath) : path.dirname(path.dirname(execPath)));
20
+ const bin = win ? prefix : path.join(prefix, 'bin');
21
+ const dir = path.join(prefix, 'launcher');
22
+ if (!fs.existsSync(dir)) return;
23
+ const wanted = new Map();
24
+ for (const entry of fs.readdirSync(bin)) {
25
+ const name = win ? entry.match(/^(.+)\.cmd$/i)?.[1] : entry;
26
+ if (!name || NODE_COMMANDS.has(name.toLowerCase())) continue;
27
+ if (win) {
28
+ wanted.set(entry, cmdLauncher(entry));
29
+ if (fs.existsSync(path.join(bin, name))) wanted.set(name, gitBashLauncher(name));
30
+ continue;
31
+ }
32
+ const target = path.join(bin, entry);
33
+ const start = Buffer.alloc(200);
34
+ let head = '';
35
+ try {
36
+ const fd = fs.openSync(target, 'r');
37
+ try { head = start.toString('utf8', 0, fs.readSync(fd, start, 0, start.length, 0)).split('\n')[0]; } finally { fs.closeSync(fd); }
38
+ } catch { continue; }
39
+ wanted.set(entry, shLauncher(target, /^#!.*\bnode\b/.test(head) ? path.join(bin, 'node') : null));
40
+ }
41
+ for (const entry of fs.readdirSync(dir)) if (!wanted.has(entry)) fs.rmSync(path.join(dir, entry), { force: true });
42
+ for (const [entry, text] of wanted) {
43
+ const file = path.join(dir, entry);
44
+ let current = null;
45
+ try { current = fs.readFileSync(file, 'utf8'); } catch {}
46
+ if (current !== text) fs.writeFileSync(file, text, { mode: 0o755 });
47
+ }
48
+ }
49
+
50
+ /** The command one of those launchers runs, or `file` itself when it is not exactly one of them. */
51
+ export function launcherTarget(file, platform, fsx) {
52
+ const m = platform === 'win32' ? path.win32 : path.posix;
53
+ const dir = m.dirname(file);
54
+ if (m.basename(dir) !== 'launcher') return file;
55
+ const entry = m.basename(file);
56
+ const text = fsx.readText(file);
57
+ if (platform === 'win32') {
58
+ const target = m.join(m.dirname(dir), entry);
59
+ return text === cmdLauncher(entry) && fsx.isFile(target) ? target : file;
60
+ }
61
+ const bin = m.join(fsx.realpath(m.dirname(dir)), 'bin');
62
+ const target = m.join(bin, entry);
63
+ return (text === shLauncher(target) || text === shLauncher(target, m.join(bin, 'node'))) && fsx.isFile(target) ? target : file;
64
+ }
65
+
66
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) refreshLaunchers();
@@ -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 });
@@ -12,7 +12,7 @@ import { createHash } from 'node:crypto';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { resolveCommand, resolveAllCommands, pathKey, buildSpawnSpec, npmSpawnSpec, runSpec } from './command-resolver.mjs';
14
14
  import { compareVersions, probeVersion, fetchManifest, latestVersion, latestReleaseTag, brewVersion, DEFAULT_NPM_REGISTRY } from './versions.mjs';
15
- import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, homeRelative, knownLaunchers, listInstallations, platformDependency, updateHelpAccepted } from './install-channels.mjs';
15
+ import { CHANNEL_LABELS, classifyInstall, commandMtime, expandHome, formatCommand, homeRelative, knownLaunchers, listInstallations, platformDependency, updateHelpAccepted } from './install-channels.mjs';
16
16
  import { copyChannel, linkOnPath, pluginVersion, readPlugin } from './docker-plugin.mjs';
17
17
  import { weavePaths } from './shell-env.mjs';
18
18
  import { detectShells, fallbackShell } from './shells.mjs';
@@ -20,6 +20,7 @@ import { RUNNER, encodePlan } from './uninstall.mjs';
20
20
  import { paths } from './config.mjs';
21
21
  import { MultiplexerRegistry } from './multiplexers.mjs';
22
22
  import { reconcileHerdrPath } from './multiplexer-paths.mjs';
23
+ import { refreshLaunchers } from './launchers.mjs';
23
24
  import { LaunchFlags, normalizeLaunchFlags } from './launch-flags.mjs';
24
25
 
25
26
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -32,10 +33,6 @@ const FAILED_PROBE_TTL_MS = 5 * 60 * 1000;
32
33
  const PATH_REFRESH_MS = 60 * 1000;
33
34
  const INSTALLS_TTL_MS = 30 * 1000;
34
35
 
35
- function fileMtime(file) {
36
- try { return fs.statSync(file).mtimeMs; } catch { return null; }
37
- }
38
-
39
36
  function realPathOf(file) {
40
37
  try { return fs.realpathSync.native(file); } catch { return file; }
41
38
  }
@@ -418,7 +415,7 @@ export class ProviderRegistry extends EventEmitter {
418
415
  // A listed copy whose file has gone since docker listed it was removed by hand: ask docker again, once. A copy
419
416
  // docker lists that was already missing when it answered is not asked about again until the next interval,
420
417
  // or every refresh would run docker info (the providers request refreshes on every call).
421
- const missing = (copies) => copies.filter((c) => fileMtime(c.path) === null).map((c) => c.path);
418
+ const missing = (copies) => copies.filter((c) => commandMtime(c.path, this.platform) === null).map((c) => c.path);
422
419
  const gone = Boolean(read && missing(read.copies).some((file) => !read.missing.includes(file)));
423
420
  if (!found) {
424
421
  if (read) { entry.plugin = null; changed = true; }
@@ -431,7 +428,7 @@ export class ProviderRegistry extends EventEmitter {
431
428
  }
432
429
  const channel = found ? this.channelFor(provider, found) : null;
433
430
  if (channel?.probe) {
434
- const mtime = fileMtime(found);
431
+ const mtime = commandMtime(found, this.platform);
435
432
  const ttl = entry.probeOk === true ? VERSION_TTL_MS : FAILED_PROBE_TTL_MS;
436
433
  if (force || entry.probePath !== found || entry.probeMtime !== mtime || now - entry.probeAt > ttl) {
437
434
  const spec = buildSpawnSpec(channel.update.file, [...channel.update.args, '--help'], this.env, this.platform);
@@ -448,7 +445,7 @@ export class ProviderRegistry extends EventEmitter {
448
445
  const pluginCopies = entry.plugin?.copies ?? [];
449
446
  const resolved = provider.versionArgs ? (provider.plugin ? pluginCopies.find((c) => c.active)?.path ?? null : found) : null;
450
447
  if (resolved) {
451
- const mtime = fileMtime(resolved);
448
+ const mtime = commandMtime(resolved, this.platform);
452
449
  const ttl = entry.installed === null ? FAILED_PROBE_TTL_MS : VERSION_TTL_MS;
453
450
  const stale = entry.installedPath !== resolved || entry.installedMtime !== mtime || now - entry.installedAt > ttl;
454
451
  if (force || stale) {
@@ -473,7 +470,7 @@ export class ProviderRegistry extends EventEmitter {
473
470
  const installs = provider.plugin ? this._pluginInstalls(provider, pluginCopies) : this.installsFor(provider, found);
474
471
  for (const other of provider.versionArgs ? installs.filter((i) => i.resolvedPath !== resolved) : []) {
475
472
  const file = other.resolvedPath;
476
- const mtime = fileMtime(file);
473
+ const mtime = commandMtime(file, this.platform);
477
474
  const cached = entry.copies[file];
478
475
  const ttl = cached?.version ? VERSION_TTL_MS : FAILED_PROBE_TTL_MS;
479
476
  if (!force && cached && cached.mtime === mtime && now - cached.at <= ttl) {
@@ -531,6 +528,7 @@ export class ProviderRegistry extends EventEmitter {
531
528
  const provider = this.get(id);
532
529
  if (!provider) return;
533
530
  const before = this.versions.get(id)?.installed ?? null;
531
+ try { refreshLaunchers(); } catch {}
534
532
  this.emit('updated');
535
533
  await this.refreshVersions({ force: true, ids: [id] });
536
534
  const entry = this.versions.get(id);
@@ -851,7 +849,7 @@ export class ProviderRegistry extends EventEmitter {
851
849
  historySource: provider.history === null ? null : typeof provider.history === 'string' ? provider.history : 'command',
852
850
  memorySource: provider.memory,
853
851
  instructionsSource: provider.instructions,
854
- historyDetails: provider.history === 'antigravity',
852
+ historyDetails: typeof provider.history === 'string',
855
853
  reporting: provider.reporting,
856
854
  reportingEnabled: this.reportingEnabled?.(provider) ?? null,
857
855
  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(() => {});
@@ -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) {
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { buildSpawnSpec, runSpec, killWindowsTree } from './command-resolver.mjs';
7
7
  import { compareVersions, probeVersion } from './versions.mjs';
8
+ import { commandMtime } from './install-channels.mjs';
8
9
 
9
10
  export const REPORT_COMMAND = 'agent-guild-report --hook';
10
11
  export const PLUGIN_NAME = 'agent-guild';
@@ -336,8 +337,7 @@ export class SessionHooks {
336
337
  _probe(provider) {
337
338
  const resolved = this.registry.resolve(provider);
338
339
  if (!resolved) return Promise.resolve(null);
339
- let mtime = null;
340
- try { mtime = fs.statSync(resolved).mtimeMs; } catch { /* probe anyway */ }
340
+ const mtime = commandMtime(resolved, this.registry.platform);
341
341
  const cached = this.probes.get(provider.id);
342
342
  // A probe still running is shared. A finished one is reused while the binary is the same, for good when it found the
343
343
  // hooks and for the retry interval when it did not, unless it asked to be rechecked by the next session (Docker Agent).