@oddessentials/agent-guild 0.4.0 → 0.5.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 +12 -7
- package/bin/agent-guild-report.mjs +14 -1
- package/bin/agent-guild.mjs +85 -46
- package/config/providers.default.json +4 -0
- package/package.json +1 -1
- package/src/manager/launch.mjs +64 -0
- package/src/manager/main.mjs +24 -4
- package/src/manager/providers.mjs +11 -0
- package/src/manager/server.mjs +25 -8
- package/src/manager/session-history.mjs +496 -0
- package/src/manager/session-manager.mjs +4 -0
- package/src/manager/session.mjs +16 -0
- package/src/report/hooks.mjs +4 -0
- package/web/app.js +403 -50
- package/web/index.html +48 -4
- package/web/styles.css +46 -3
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
// Past sessions of each coding tool, read from where the tool itself keeps
|
|
2
|
+
// them. Only the head of a transcript is read, and a file is parsed again
|
|
3
|
+
// only when its size or mtime changed.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import zlib from 'node:zlib';
|
|
9
|
+
import { resolveCommand, buildSpawnSpec, runSpec } from './command-resolver.mjs';
|
|
10
|
+
import { toIso } from './usage.mjs';
|
|
11
|
+
|
|
12
|
+
export const HISTORY_TTL_MS = 5 * 1000;
|
|
13
|
+
export const DEFAULT_LIMIT = 100;
|
|
14
|
+
export const MAX_LIMIT = 500;
|
|
15
|
+
const HEAD_BYTES = 256 * 1024;
|
|
16
|
+
const MAX_TITLE = 120;
|
|
17
|
+
const MAX_ID = 200;
|
|
18
|
+
const MAX_WALK_DEPTH = 4;
|
|
19
|
+
|
|
20
|
+
export class HistoryError extends Error {}
|
|
21
|
+
|
|
22
|
+
const TAG_RE = /^<[a-z][a-z0-9_-]*[\s>]/i;
|
|
23
|
+
|
|
24
|
+
function shortPath(file) {
|
|
25
|
+
const home = os.homedir();
|
|
26
|
+
return file.startsWith(home) ? `~${file.slice(home.length)}` : file;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One line of prompt text, or null for an empty or tool-inserted (`<tag>…`) message. */
|
|
30
|
+
function promptTitle(text) {
|
|
31
|
+
if (typeof text !== 'string') return null;
|
|
32
|
+
const clean = text.replace(/\s+/g, ' ').trim();
|
|
33
|
+
if (!clean || TAG_RE.test(clean)) return null;
|
|
34
|
+
return clean.slice(0, MAX_TITLE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function contentText(content) {
|
|
38
|
+
if (typeof content === 'string') return content;
|
|
39
|
+
if (!Array.isArray(content)) return '';
|
|
40
|
+
return content.map((part) => (typeof part === 'string' ? part : part && typeof part.text === 'string' ? part.text : '')).filter(Boolean).join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseLines(text) {
|
|
44
|
+
const records = [];
|
|
45
|
+
for (const line of text.split('\n')) {
|
|
46
|
+
if (!line.trim()) continue;
|
|
47
|
+
try { records.push(JSON.parse(line)); } catch { /* a cut-off or foreign line */ }
|
|
48
|
+
}
|
|
49
|
+
return records;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readHead(file, bytes = HEAD_BYTES) {
|
|
53
|
+
if (file.endsWith('.zst')) {
|
|
54
|
+
if (typeof zlib.createZstdDecompress !== 'function') return null;
|
|
55
|
+
const source = fs.createReadStream(file);
|
|
56
|
+
const inflate = source.pipe(zlib.createZstdDecompress());
|
|
57
|
+
const chunks = [];
|
|
58
|
+
let size = 0;
|
|
59
|
+
try {
|
|
60
|
+
for await (const chunk of inflate) {
|
|
61
|
+
chunks.push(chunk);
|
|
62
|
+
size += chunk.length;
|
|
63
|
+
if (size >= bytes) break;
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
source.destroy();
|
|
67
|
+
}
|
|
68
|
+
return Buffer.concat(chunks).subarray(0, bytes).toString('utf8');
|
|
69
|
+
}
|
|
70
|
+
const handle = await fs.promises.open(file, 'r');
|
|
71
|
+
try {
|
|
72
|
+
const { bytesRead, buffer } = await handle.read(Buffer.alloc(bytes), 0, bytes, 0);
|
|
73
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
74
|
+
} finally {
|
|
75
|
+
await handle.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readDir(dir) {
|
|
80
|
+
try {
|
|
81
|
+
return await fs.promises.readdir(dir, { withFileTypes: true });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return [];
|
|
84
|
+
throw new HistoryError(`${shortPath(dir)} could not be read: ${err.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function readText(file) {
|
|
89
|
+
try { return await fs.promises.readFile(file, 'utf8'); } catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function newestFirst(a, b) {
|
|
93
|
+
return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') || (b.startedAt ?? '').localeCompare(a.startedAt ?? '') || a.id.localeCompare(b.id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function cleanEntry({ id, title, cwd, startedAt, updatedAt }) {
|
|
97
|
+
const cleanId = String(id ?? '').trim();
|
|
98
|
+
if (!cleanId || cleanId.length > MAX_ID || /\p{Cc}/u.test(cleanId)) return null;
|
|
99
|
+
const folder = typeof cwd === 'string' && cwd.trim() ? cwd.trim() : null;
|
|
100
|
+
return {
|
|
101
|
+
id: cleanId,
|
|
102
|
+
title: typeof title === 'string' && title.trim() ? title.replace(/\s+/g, ' ').trim().slice(0, MAX_TITLE) : null,
|
|
103
|
+
cwd: folder,
|
|
104
|
+
startedAt: toIso(startedAt),
|
|
105
|
+
updatedAt: toIso(updatedAt),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function dedupe(entries) {
|
|
110
|
+
const byId = new Map();
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const known = byId.get(entry.id);
|
|
113
|
+
if (!known || newestFirst(entry, known) < 0) byId.set(entry.id, entry);
|
|
114
|
+
}
|
|
115
|
+
return [...byId.values()].sort(newestFirst);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class FileMemo {
|
|
119
|
+
constructor() {
|
|
120
|
+
this.entries = new Map();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async entry(file, stat, parse) {
|
|
124
|
+
const known = this.entries.get(file);
|
|
125
|
+
if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs) return known.entry;
|
|
126
|
+
let entry = null;
|
|
127
|
+
try { entry = await parse(); } catch { /* unreadable or not a transcript */ }
|
|
128
|
+
this.entries.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, entry });
|
|
129
|
+
return entry;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
prune(root, seen) {
|
|
133
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
134
|
+
for (const file of this.entries.keys()) {
|
|
135
|
+
if (file.startsWith(prefix) && !seen.has(file)) this.entries.delete(file);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function statFile(file) {
|
|
141
|
+
try {
|
|
142
|
+
const stat = await fs.promises.stat(file);
|
|
143
|
+
return stat.isFile() ? stat : null;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- Claude Code ----------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
export function claudeConfigDir(env = process.env) {
|
|
152
|
+
return (env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')).normalize('NFC');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function claudeEntry(id, text, stat) {
|
|
156
|
+
let cwd = null;
|
|
157
|
+
let startedAt = null;
|
|
158
|
+
let title = null;
|
|
159
|
+
let seenUser = false;
|
|
160
|
+
for (const record of parseLines(text)) {
|
|
161
|
+
if (!record || typeof record !== 'object') continue;
|
|
162
|
+
if (cwd === null && typeof record.cwd === 'string') cwd = record.cwd;
|
|
163
|
+
if (startedAt === null && record.timestamp) startedAt = record.timestamp;
|
|
164
|
+
if (record.type === 'custom-title' && typeof record.customTitle === 'string' && record.customTitle.trim()) title = record.customTitle;
|
|
165
|
+
if (record.type !== 'user' || record.isSidechain === true) continue;
|
|
166
|
+
seenUser = true;
|
|
167
|
+
if (title === null) title = promptTitle(contentText(record.message?.content));
|
|
168
|
+
}
|
|
169
|
+
if (cwd === null && !seenUser) return null;
|
|
170
|
+
return cleanEntry({ id, title, cwd, startedAt, updatedAt: stat.mtime });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Sessions under `<dir>/projects/<folder>/<id>.jsonl`; sub-agent transcripts sit in sub-folders and are not listed. */
|
|
174
|
+
export async function listClaudeSessions(dir, memo = new FileMemo()) {
|
|
175
|
+
const root = path.join(dir, 'projects');
|
|
176
|
+
const entries = [];
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const project of await readDir(root)) {
|
|
179
|
+
if (!project.isDirectory()) continue;
|
|
180
|
+
const folder = path.join(root, project.name);
|
|
181
|
+
for (const item of await readDir(folder)) {
|
|
182
|
+
if (!item.isFile() || !item.name.endsWith('.jsonl')) continue;
|
|
183
|
+
const file = path.join(folder, item.name);
|
|
184
|
+
const stat = await statFile(file);
|
|
185
|
+
if (!stat) continue;
|
|
186
|
+
seen.add(file);
|
|
187
|
+
const id = item.name.slice(0, -'.jsonl'.length);
|
|
188
|
+
const entry = await memo.entry(file, stat, async () => claudeEntry(id, await readHead(file), stat));
|
|
189
|
+
if (entry) entries.push(entry);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
memo.prune(root, seen);
|
|
193
|
+
return dedupe(entries);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- Codex CLI ------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
export function codexHome(env = process.env) {
|
|
199
|
+
return env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function codexPrompt(record) {
|
|
203
|
+
const payload = record?.payload;
|
|
204
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
205
|
+
if (record.type === 'event_msg') {
|
|
206
|
+
if (payload.type === 'user_message') return promptTitle(payload.message);
|
|
207
|
+
if (payload.type === 'item_completed' && payload.item?.type === 'UserMessage') return promptTitle(contentText(payload.item.content));
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
if (record.type === 'response_item' && payload.type === 'message' && payload.role === 'user') return promptTitle(contentText(payload.content));
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function codexEntry(text, stat) {
|
|
215
|
+
const records = parseLines(text);
|
|
216
|
+
const meta = records.find((r) => r?.type === 'session_meta')?.payload;
|
|
217
|
+
if (!meta || typeof meta !== 'object') return null;
|
|
218
|
+
const id = meta.id ?? meta.session_id;
|
|
219
|
+
if (typeof meta.source === 'object' && meta.source !== null) return null;
|
|
220
|
+
if (meta.parent_thread_id || (meta.thread_source && meta.thread_source !== 'user')) return null;
|
|
221
|
+
let title = null;
|
|
222
|
+
for (const record of records) {
|
|
223
|
+
title = codexPrompt(record);
|
|
224
|
+
if (title) break;
|
|
225
|
+
}
|
|
226
|
+
return cleanEntry({ id, title, cwd: meta.cwd, startedAt: meta.timestamp ?? records[0]?.timestamp, updatedAt: stat.mtime });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function walk(dir, depth, onFile) {
|
|
230
|
+
for (const item of await readDir(dir)) {
|
|
231
|
+
const file = path.join(dir, item.name);
|
|
232
|
+
if (item.isDirectory()) {
|
|
233
|
+
if (depth > 0) await walk(file, depth - 1, onFile);
|
|
234
|
+
} else if (item.isFile()) await onFile(file, item.name);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Rollouts under `<dir>/sessions/YYYY/MM/DD/rollout-*.jsonl`, uncompressed or zstd-compressed. Sub-agent threads are skipped. */
|
|
239
|
+
export async function listCodexSessions(dir, memo = new FileMemo()) {
|
|
240
|
+
const root = path.join(dir, 'sessions');
|
|
241
|
+
const entries = [];
|
|
242
|
+
const seen = new Set();
|
|
243
|
+
await walk(root, MAX_WALK_DEPTH, async (file, name) => {
|
|
244
|
+
if (!/^rollout-.*\.jsonl(\.zst)?$/.test(name)) return;
|
|
245
|
+
const stat = await statFile(file);
|
|
246
|
+
if (!stat) return;
|
|
247
|
+
seen.add(file);
|
|
248
|
+
const entry = await memo.entry(file, stat, async () => {
|
|
249
|
+
const head = await readHead(file);
|
|
250
|
+
return head === null ? null : codexEntry(head, stat);
|
|
251
|
+
});
|
|
252
|
+
if (entry) entries.push(entry);
|
|
253
|
+
});
|
|
254
|
+
memo.prune(root, seen);
|
|
255
|
+
return dedupe(entries);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- Gemini CLI -----------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export function geminiDir(env = process.env) {
|
|
261
|
+
return path.join(env.GEMINI_CLI_HOME || os.homedir(), '.gemini');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function geminiPrompt(record) {
|
|
265
|
+
if (!record || typeof record !== 'object') return null;
|
|
266
|
+
if (record.type === 'user' && record.id !== undefined) {
|
|
267
|
+
const text = contentText(record.content);
|
|
268
|
+
return /^[/?]/.test(text.trim()) ? null : promptTitle(text);
|
|
269
|
+
}
|
|
270
|
+
const checkpoint = record.$set?.messages;
|
|
271
|
+
if (Array.isArray(checkpoint)) {
|
|
272
|
+
for (const message of checkpoint) {
|
|
273
|
+
const title = geminiPrompt(message);
|
|
274
|
+
if (title) return title;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function geminiEntry(text, stat, cwd, legacy) {
|
|
281
|
+
let meta;
|
|
282
|
+
let records;
|
|
283
|
+
if (legacy) {
|
|
284
|
+
try { meta = JSON.parse(text); } catch { return null; }
|
|
285
|
+
records = Array.isArray(meta?.messages) ? meta.messages : [];
|
|
286
|
+
} else {
|
|
287
|
+
records = parseLines(text);
|
|
288
|
+
meta = records.shift();
|
|
289
|
+
}
|
|
290
|
+
if (!meta || typeof meta !== 'object' || typeof meta.sessionId !== 'string') return null;
|
|
291
|
+
if (meta.kind === 'subagent') return null;
|
|
292
|
+
let title = typeof meta.summary === 'string' && meta.summary.trim() ? meta.summary : null;
|
|
293
|
+
for (const record of records) {
|
|
294
|
+
if (title) break;
|
|
295
|
+
title = geminiPrompt(record);
|
|
296
|
+
}
|
|
297
|
+
return cleanEntry({ id: meta.sessionId, title, cwd, startedAt: meta.startTime, updatedAt: stat.mtime });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The project folder a Gemini CLI temp folder stands for: its `.project_root` marker, else the registry in `projects.json`. */
|
|
301
|
+
async function geminiProjectRoots(root, folders) {
|
|
302
|
+
const roots = new Map();
|
|
303
|
+
let registry = null;
|
|
304
|
+
for (const folder of folders) {
|
|
305
|
+
const marker = (await readText(path.join(root, folder, '.project_root')))?.trim();
|
|
306
|
+
if (marker) {
|
|
307
|
+
roots.set(folder, marker);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (registry === null) {
|
|
311
|
+
let projects = {};
|
|
312
|
+
try { projects = JSON.parse(await readText(path.join(path.dirname(root), 'projects.json')))?.projects ?? {}; } catch { /* no registry */ }
|
|
313
|
+
registry = new Map(Object.entries(projects).map(([dir, slug]) => [slug, dir]));
|
|
314
|
+
}
|
|
315
|
+
roots.set(folder, registry.get(folder) ?? null);
|
|
316
|
+
}
|
|
317
|
+
return roots;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Chats under `<dir>/tmp/<project>/chats/session-*.jsonl` (older versions: `.json`). Sub-agent chats sit in sub-folders. */
|
|
321
|
+
export async function listGeminiSessions(dir, memo = new FileMemo()) {
|
|
322
|
+
const root = path.join(dir, 'tmp');
|
|
323
|
+
const entries = [];
|
|
324
|
+
const seen = new Set();
|
|
325
|
+
const folders = (await readDir(root)).filter((item) => item.isDirectory()).map((item) => item.name);
|
|
326
|
+
const roots = await geminiProjectRoots(root, folders);
|
|
327
|
+
for (const folder of folders) {
|
|
328
|
+
const chats = path.join(root, folder, 'chats');
|
|
329
|
+
for (const item of await readDir(chats)) {
|
|
330
|
+
const legacy = item.name.endsWith('.json');
|
|
331
|
+
if (!item.isFile() || !item.name.startsWith('session-') || !(legacy || item.name.endsWith('.jsonl'))) continue;
|
|
332
|
+
const file = path.join(chats, item.name);
|
|
333
|
+
const stat = await statFile(file);
|
|
334
|
+
if (!stat) continue;
|
|
335
|
+
seen.add(file);
|
|
336
|
+
const cwd = roots.get(folder);
|
|
337
|
+
const entry = await memo.entry(file, stat, async () => geminiEntry(legacy ? await readText(file) : await readHead(file), stat, cwd, legacy));
|
|
338
|
+
if (entry) entries.push(entry);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
memo.prune(root, seen);
|
|
342
|
+
return dedupe(entries);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- Grok Build -----------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
export function grokHome(env = process.env) {
|
|
348
|
+
return env.GROK_HOME || path.join(os.homedir(), '.grok');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The folder a Grok Build session bucket stands for: its percent-encoded name, else its `.cwd` file. */
|
|
352
|
+
async function grokBucketCwd(bucket) {
|
|
353
|
+
let decoded = null;
|
|
354
|
+
try { decoded = decodeURIComponent(path.basename(bucket)); } catch { /* not percent-encoded */ }
|
|
355
|
+
if (decoded && (decoded.startsWith('/') || /^[A-Za-z]:[\\/]/.test(decoded))) return decoded;
|
|
356
|
+
return (await readText(path.join(bucket, '.cwd')))?.trim() || null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function grokPrompt(text) {
|
|
360
|
+
const parts = [];
|
|
361
|
+
let promptId;
|
|
362
|
+
for (const record of parseLines(text)) {
|
|
363
|
+
const update = record?.params?.update;
|
|
364
|
+
if (update?.sessionUpdate !== 'user_message_chunk' || update.content?.type !== 'text') continue;
|
|
365
|
+
if (update.content._meta?.bash_command || update._meta?.host_turn === true) continue;
|
|
366
|
+
const id = record.params._meta?.promptId ?? null;
|
|
367
|
+
if (parts.length && id !== promptId) break;
|
|
368
|
+
promptId = id;
|
|
369
|
+
parts.push(update.content.text);
|
|
370
|
+
}
|
|
371
|
+
return promptTitle(parts.join(''));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function grokEntry(folder, summary, stat, bucketCwd) {
|
|
375
|
+
const info = summary?.info;
|
|
376
|
+
if (!summary || typeof summary !== 'object') return null;
|
|
377
|
+
if (summary.hidden === true || String(summary.session_kind ?? '').startsWith('subagent')) return null;
|
|
378
|
+
let title = [summary.generated_title, summary.session_summary].find((t) => typeof t === 'string' && t.trim()) ?? null;
|
|
379
|
+
if (summary.num_messages === 0 && !title) return null;
|
|
380
|
+
if (title === null) {
|
|
381
|
+
const updates = await readHead(path.join(folder, 'updates.jsonl')).catch(() => null);
|
|
382
|
+
if (updates) title = grokPrompt(updates);
|
|
383
|
+
}
|
|
384
|
+
return cleanEntry({
|
|
385
|
+
id: info?.id ?? path.basename(folder),
|
|
386
|
+
title,
|
|
387
|
+
cwd: typeof info?.cwd === 'string' && info.cwd ? info.cwd : bucketCwd,
|
|
388
|
+
startedAt: summary.created_at,
|
|
389
|
+
updatedAt: summary.last_active_at ?? summary.updated_at ?? stat.mtime,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Sessions under `<dir>/sessions/<percent-encoded folder>/<id>/summary.json`. Hidden and sub-agent sessions are skipped. */
|
|
394
|
+
export async function listGrokSessions(dir, memo = new FileMemo()) {
|
|
395
|
+
const root = path.join(dir, 'sessions');
|
|
396
|
+
const entries = [];
|
|
397
|
+
const seen = new Set();
|
|
398
|
+
for (const bucket of await readDir(root)) {
|
|
399
|
+
if (!bucket.isDirectory()) continue;
|
|
400
|
+
const bucketDir = path.join(root, bucket.name);
|
|
401
|
+
const cwd = await grokBucketCwd(bucketDir);
|
|
402
|
+
for (const item of await readDir(bucketDir)) {
|
|
403
|
+
if (!item.isDirectory()) continue;
|
|
404
|
+
const folder = path.join(bucketDir, item.name);
|
|
405
|
+
const file = path.join(folder, 'summary.json');
|
|
406
|
+
const stat = await statFile(file);
|
|
407
|
+
if (!stat) continue;
|
|
408
|
+
seen.add(file);
|
|
409
|
+
const entry = await memo.entry(file, stat, async () => grokEntry(folder, JSON.parse(await readText(file)), stat, cwd));
|
|
410
|
+
if (entry) entries.push(entry);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
memo.prune(root, seen);
|
|
414
|
+
return dedupe(entries);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---- any command that prints JSON ------------------------------------------
|
|
418
|
+
|
|
419
|
+
export async function commandHistory({ command, args = [] }, env, platform = process.platform) {
|
|
420
|
+
const resolved = resolveCommand(command, env, platform);
|
|
421
|
+
if (!resolved) throw new HistoryError(`history command "${command}" was not found on PATH`);
|
|
422
|
+
const spec = buildSpawnSpec(resolved, args, env, platform);
|
|
423
|
+
let stdout;
|
|
424
|
+
try {
|
|
425
|
+
({ stdout } = await runSpec(spec, { env }));
|
|
426
|
+
} catch (err) {
|
|
427
|
+
throw new HistoryError(`history command failed: ${String(err.stderr || err.message).trim().slice(0, 200)}`);
|
|
428
|
+
}
|
|
429
|
+
let body;
|
|
430
|
+
try { body = JSON.parse(stdout); } catch { throw new HistoryError('history command did not print JSON'); }
|
|
431
|
+
if (!Array.isArray(body?.sessions)) throw new HistoryError('history command output has no "sessions" array');
|
|
432
|
+
return dedupe(body.sessions.map((s) => (s && typeof s === 'object' ? cleanEntry(s) : null)).filter(Boolean));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---- monitor ----------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
export class SessionHistory {
|
|
438
|
+
/**
|
|
439
|
+
* @param {object} opts
|
|
440
|
+
* @param {import('./providers.mjs').ProviderRegistry} opts.registry
|
|
441
|
+
* @param {object} [opts.env]
|
|
442
|
+
* @param {number} [opts.ttlMs]
|
|
443
|
+
* @param {object} [opts.readers] session listers by source, replaceable in tests
|
|
444
|
+
*/
|
|
445
|
+
constructor({ registry, env = process.env, platform = process.platform, ttlMs = HISTORY_TTL_MS, readers = {} } = {}) {
|
|
446
|
+
this.registry = registry;
|
|
447
|
+
this.env = env;
|
|
448
|
+
this.platform = platform;
|
|
449
|
+
this.ttlMs = ttlMs;
|
|
450
|
+
this.readers = { claude: listClaudeSessions, codex: listCodexSessions, gemini: listGeminiSessions, grok: listGrokSessions, ...readers };
|
|
451
|
+
this.memo = new FileMemo();
|
|
452
|
+
this.cache = new Map();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
static sourceDir(source, env) {
|
|
456
|
+
if (source === 'claude') return claudeConfigDir(env);
|
|
457
|
+
if (source === 'codex') return codexHome(env);
|
|
458
|
+
if (source === 'gemini') return geminiDir(env);
|
|
459
|
+
if (source === 'grok') return grokHome(env);
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async list(provider, account = this.registry.account(provider), { limit } = {}) {
|
|
464
|
+
const n = Math.min(MAX_LIMIT, Math.max(1, Math.floor(Number(limit)) || DEFAULT_LIMIT));
|
|
465
|
+
const snapshot = await this.snapshot(provider, account);
|
|
466
|
+
return { ...snapshot, sessions: snapshot.sessions.slice(0, n) };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
snapshot(provider, account) {
|
|
470
|
+
const key = `${provider.id}\0${account.id}`;
|
|
471
|
+
const entry = this.cache.get(key);
|
|
472
|
+
const now = Date.now();
|
|
473
|
+
if (entry?.inflight) return entry.inflight;
|
|
474
|
+
if (entry && now - entry.at < this.ttlMs) return Promise.resolve(entry.snapshot);
|
|
475
|
+
const inflight = this._read(provider, account).then((snapshot) => {
|
|
476
|
+
this.cache.set(key, { snapshot, at: Date.now() });
|
|
477
|
+
return snapshot;
|
|
478
|
+
});
|
|
479
|
+
this.cache.set(key, { ...entry, inflight });
|
|
480
|
+
return inflight;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async _read(provider, account) {
|
|
484
|
+
const base = { providerId: provider.id, accountId: account.id, sessions: [], total: 0, fetchedAt: new Date().toISOString(), error: null };
|
|
485
|
+
const env = { ...this.env, ...provider.env, ...account.env };
|
|
486
|
+
try {
|
|
487
|
+
const sessions = typeof provider.history === 'string'
|
|
488
|
+
? await this.readers[provider.history](SessionHistory.sourceDir(provider.history, env), this.memo)
|
|
489
|
+
: await commandHistory(provider.history, env, this.platform);
|
|
490
|
+
return { ...base, sessions, total: sessions.length };
|
|
491
|
+
} catch (err) {
|
|
492
|
+
const message = err instanceof HistoryError ? err.message : `history could not be read: ${err.message}`;
|
|
493
|
+
return { ...base, error: message };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
@@ -292,6 +292,10 @@ export class SessionManager extends EventEmitter {
|
|
|
292
292
|
return this._reportingSession(id, auth).reportModel(report);
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
reportToolSession(id, report, auth) {
|
|
296
|
+
return this._reportingSession(id, auth).reportToolSession(report);
|
|
297
|
+
}
|
|
298
|
+
|
|
295
299
|
_reportingSession(id, { reportToken, trusted = false } = {}) {
|
|
296
300
|
const session = this.sessions.get(id);
|
|
297
301
|
// Without the API token, an unknown session and a wrong token look the
|
package/src/manager/session.mjs
CHANGED
|
@@ -18,6 +18,7 @@ const AGENT_STATUSES = new Set(['working', 'waiting', 'idle', 'done']);
|
|
|
18
18
|
const MODEL_SOURCE_RANK = { args: 0, screen: 1, report: 2 };
|
|
19
19
|
const SCREEN_SCAN_DELAY_MS = 400;
|
|
20
20
|
const SCREEN_SCAN_MAX_DELAY_MS = 2000;
|
|
21
|
+
const MAX_TOOL_SESSION_ID = 200;
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Colours reported to programs that query them (OSC 10/11/12), matching the
|
|
@@ -87,6 +88,7 @@ export class Session extends EventEmitter {
|
|
|
87
88
|
this.lastOutputAt = null;
|
|
88
89
|
this.agents = new Map();
|
|
89
90
|
this.model = null;
|
|
91
|
+
this.toolSessionId = null;
|
|
90
92
|
this.modelRegex = null;
|
|
91
93
|
if (opts.provider.modelPattern && this.task === null) {
|
|
92
94
|
try {
|
|
@@ -421,6 +423,7 @@ export class Session extends EventEmitter {
|
|
|
421
423
|
const isAgent = report && typeof report === 'object' &&
|
|
422
424
|
((report.agentId ?? report.agent ?? report.id) !== undefined || report.finishForeground === true);
|
|
423
425
|
if (isAgent) this.reportAgent(report, 'terminal');
|
|
426
|
+
else if (report?.toolSessionId !== undefined) this.reportToolSession(report);
|
|
424
427
|
else this.reportModel(report);
|
|
425
428
|
} catch (err) {
|
|
426
429
|
this.emit('warning', `ignored in-band report: ${err.message}`);
|
|
@@ -455,6 +458,18 @@ export class Session extends EventEmitter {
|
|
|
455
458
|
return this.model;
|
|
456
459
|
}
|
|
457
460
|
|
|
461
|
+
reportToolSession(report) {
|
|
462
|
+
if (!report || typeof report !== 'object') throw badRequest('tool session report must be an object');
|
|
463
|
+
if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
|
|
464
|
+
const id = String(report.toolSessionId ?? '').trim();
|
|
465
|
+
if (!id || id.length > MAX_TOOL_SESSION_ID || /\p{Cc}/u.test(id)) throw badRequest(`toolSessionId must be a printable id of at most ${MAX_TOOL_SESSION_ID} characters`);
|
|
466
|
+
if (this.toolSessionId !== id) {
|
|
467
|
+
this.toolSessionId = id;
|
|
468
|
+
this._changed();
|
|
469
|
+
}
|
|
470
|
+
return this.toolSessionId;
|
|
471
|
+
}
|
|
472
|
+
|
|
458
473
|
_foregroundAgentWorking() {
|
|
459
474
|
for (const agent of this.agents.values()) {
|
|
460
475
|
if (agent.foreground && agent.status === 'working') return true;
|
|
@@ -520,6 +535,7 @@ export class Session extends EventEmitter {
|
|
|
520
535
|
rows: this.rows,
|
|
521
536
|
attachedClients: this.subscribers.size,
|
|
522
537
|
model: this.model,
|
|
538
|
+
toolSessionId: this.toolSessionId,
|
|
523
539
|
agents: [...this.agents.values()],
|
|
524
540
|
};
|
|
525
541
|
}
|
package/src/report/hooks.mjs
CHANGED
|
@@ -126,6 +126,10 @@ export function hookToReports(input) {
|
|
|
126
126
|
? text(input.to_model)
|
|
127
127
|
: text(input.model, input.modelId, input.llm_request?.model);
|
|
128
128
|
if (model) reports.push({ model });
|
|
129
|
+
|
|
130
|
+
// Gemini CLI has no session-start event; its turn start stands in.
|
|
131
|
+
const toolSessionId = insideSubagent ? null : text(input.session_id, input.sessionId);
|
|
132
|
+
if (toolSessionId && (event === 'SessionStart' || event === 'BeforeAgent')) reports.push({ toolSessionId });
|
|
129
133
|
return reports;
|
|
130
134
|
}
|
|
131
135
|
|