@oddessentials/agent-guild 1.18.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.
- package/README.md +1 -0
- package/bin/agent-guild.mjs +16 -0
- package/docs/api.md +33 -13
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/package.json +1 -1
- package/src/manager/antigravity-transcript.mjs +4 -70
- package/src/manager/card-names.mjs +110 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/history-transcripts.mjs +598 -0
- package/src/manager/main.mjs +4 -2
- package/src/manager/providers.mjs +1 -1
- package/src/manager/pty.mjs +26 -0
- package/src/manager/server.mjs +4 -0
- package/src/manager/session-history.mjs +135 -66
- package/src/manager/session-manager.mjs +53 -9
- package/src/manager/session.mjs +21 -18
- package/src/mcp/server.mjs +11 -0
- package/web/app.js +38 -45
- package/web/history-preview.js +7 -7
- package/web/index.html +1 -1
- package/web/yard/art.json +1 -1
- package/web/yard/model.mjs +1 -1
- package/web/yard/renderer.js +1 -1
|
@@ -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,
|
|
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
|
|
33
|
-
function
|
|
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
|
-
|
|
37
|
-
return clean.slice(0, MAX_TITLE);
|
|
44
|
+
return clean ? clean.slice(0, MAX_TITLE) : null;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
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(
|
|
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
|
-
|
|
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
|
|
209
|
+
let prompt = null;
|
|
162
210
|
let seenUser = false;
|
|
163
|
-
|
|
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
|
-
|
|
218
|
+
prompt ??= lineTitle(claudePrompt(record));
|
|
171
219
|
}
|
|
172
220
|
if (cwd === null && !seenUser) return null;
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
parts
|
|
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
|
|
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.
|
|
554
|
-
(
|
|
555
|
-
|
|
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
|
-
|
|
561
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
148
|
-
|| (
|
|
149
|
-
||
|
|
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:
|
|
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}`));
|
package/src/manager/session.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
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:
|
|
416
|
-
* multiplexer's
|
|
417
|
-
* it.
|
|
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
|
-
|
|
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
|
|
package/src/mcp/server.mjs
CHANGED
|
@@ -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 }) => {
|