@double_geste/agent-standup 1.29.0 → 1.30.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 +2 -2
- package/bin/agent-standup.js +4 -2
- package/codex.js +2 -2
- package/package.json +3 -2
- package/public/index.html +3 -3
- package/server.js +29 -24
- package/vibe.js +322 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Agent Standup (command line)
|
|
2
2
|
|
|
3
|
-
Your Claude Code and
|
|
3
|
+
Your Claude Code, Codex and Mistral Vibe sessions as a live team call, without any editor. Free, MIT, and it never calls Claude: it only reads the session files Claude Code, Codex and Mistral Vibe already write.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @double_geste/agent-standup # then open http://localhost:4747 (add --open to open it for you)
|
|
@@ -15,4 +15,4 @@ Install once for status bars (faster than npx): `npm i -g @double_geste/agent-st
|
|
|
15
15
|
|
|
16
16
|
Full documentation, the VS Code extension, the desktop app and the demo: https://agent-standup.com · https://github.com/Atoll92/agent-standup.git
|
|
17
17
|
|
|
18
|
-
Unofficial: not affiliated with Anthropic or
|
|
18
|
+
Unofficial: not affiliated with Anthropic, OpenAI or Mistral AI.
|
package/bin/agent-standup.js
CHANGED
|
@@ -9,12 +9,12 @@ const pkg = require('../package.json');
|
|
|
9
9
|
const argv = process.argv.slice(2), flags = {}, rest = [];
|
|
10
10
|
for (let i = 0; i < argv.length; i++) {
|
|
11
11
|
const a = argv[i], val = () => argv[++i];
|
|
12
|
-
if (a === '--port') flags.port = val(); else if (a === '--projects') flags.projects = val(); else if (a === '--codex') flags.codex = val(); else if (a === '--characters') flags.characters = val(); else if (a === '--config-dir') flags.config = val();
|
|
12
|
+
if (a === '--port') flags.port = val(); else if (a === '--projects') flags.projects = val(); else if (a === '--codex') flags.codex = val(); else if (a === '--vibe') flags.vibe = val(); else if (a === '--characters') flags.characters = val(); else if (a === '--config-dir') flags.config = val();
|
|
13
13
|
else if (a === '--open') flags.open = true; else if (a === '--here') flags.here = true; else if (a === '--json') flags.json = true; else if (a === '--titles') flags.titles = true;
|
|
14
14
|
else if (a === '-h' || a === '--help') flags.help = true; else if (a === '-v' || a === '--version') flags.version = true; else rest.push(a);
|
|
15
15
|
}
|
|
16
16
|
const cmd = rest[0] || 'serve';
|
|
17
|
-
const HELP = `Agent Standup ${pkg.version} — your Claude Code and
|
|
17
|
+
const HELP = `Agent Standup ${pkg.version} — your Claude Code, Codex and Mistral Vibe agents as a live video call
|
|
18
18
|
|
|
19
19
|
Usage
|
|
20
20
|
agent-standup [serve] [--port 4747] [--open] start the local server (http://localhost:4747)
|
|
@@ -26,6 +26,7 @@ Options
|
|
|
26
26
|
--open open the page in your browser
|
|
27
27
|
--projects DIR where the session files are (default ~/.claude/projects, or $CLAUDE_CONFIG_DIR/projects)
|
|
28
28
|
--codex DIR where Codex keeps its files (default ~/.codex, or $CODEX_HOME); "off" to leave Codex sessions out
|
|
29
|
+
--vibe DIR where Mistral Vibe keeps its files (default ~/.vibe, or $VIBE_HOME); "off" to leave Vibe sessions out
|
|
29
30
|
--characters DIR folder with your own pixel-art characters (default ~/.agent-standup/characters)
|
|
30
31
|
--here status: only sessions of the current folder (matched on its real path, as Claude Code names it)
|
|
31
32
|
--json status: machine-readable output (add --titles to include session titles)
|
|
@@ -34,6 +35,7 @@ if (flags.help || cmd === 'help') { process.stdout.write(HELP); process.exit(0);
|
|
|
34
35
|
if (flags.version || cmd === 'version') { console.log(pkg.version); process.exit(0); }
|
|
35
36
|
if (flags.projects) process.env.AGENT_STANDUP_PROJECTS = path.resolve(flags.projects);
|
|
36
37
|
if (flags.codex !== undefined) process.env.AGENT_STANDUP_CODEX = /^(0|off|no|false)?$/i.test(String(flags.codex)) ? 'off' : path.resolve(String(flags.codex));
|
|
38
|
+
if (flags.vibe !== undefined) process.env.AGENT_STANDUP_VIBE = /^(0|off|no|false)?$/i.test(String(flags.vibe)) ? 'off' : path.resolve(String(flags.vibe));
|
|
37
39
|
if (flags.config) process.env.CLAUDE_CONFIG_DIR = path.resolve(flags.config);
|
|
38
40
|
if (flags.characters) process.env.AGENT_STANDUP_CHARACTERS = path.resolve(flags.characters);
|
|
39
41
|
if (flags.port !== undefined) { // a real port number or nothing: no silent fallback to another port
|
package/codex.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// events a Claude Code record gives (say, user, tool, results, usage, compact), so everything after that is shared: the timeline, the live feed, the page.
|
|
5
5
|
// All Codex sessions are listed under one made-up project, PROJ. No Claude Code project folder can have that name: theirs are paths with every other character turned into "-".
|
|
6
6
|
const fs = require('fs'), os = require('os'), path = require('path');
|
|
7
|
-
const PROJ = '_codex';
|
|
7
|
+
const PROJ = '_codex', SOURCE = 'codex';
|
|
8
8
|
// Where Codex keeps its files: AGENT_STANDUP_CODEX (a folder; "0" or "off" turns Codex sessions off), else $CODEX_HOME, else ~/.codex.
|
|
9
9
|
// When AGENT_STANDUP_PROJECTS points the app at one folder of Claude Code sessions (--projects, the tests), Codex is only read if it is asked for too.
|
|
10
10
|
function home() {
|
|
@@ -206,4 +206,4 @@ function parser({ toolLabel, clip, resultMax = {} }) {
|
|
|
206
206
|
if (p.type === 'web_search_call' && !cx.items && id) call(id, 'WebSearch', { query: (p.action && p.action.query) || '' });
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
|
-
module.exports = { PROJ, home, dirKey, sessions, row, fileOf, inProject, projectLabel, title, boardRow, parser };
|
|
209
|
+
module.exports = { PROJ, SOURCE, home, dirKey, sessions, row, fileOf, inProject, projectLabel, title, boardRow, parser };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@double_geste/agent-standup",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Your Claude Code and
|
|
3
|
+
"version": "1.30.0",
|
|
4
|
+
"description": "Your Claude Code, Codex and Mistral Vibe agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-standup": "bin/agent-standup.js"
|
|
7
7
|
},
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"bin",
|
|
11
11
|
"server.js",
|
|
12
12
|
"codex.js",
|
|
13
|
+
"vibe.js",
|
|
13
14
|
"kokoro.js",
|
|
14
15
|
"kokoro-worker.js",
|
|
15
16
|
"demo.js",
|
package/public/index.html
CHANGED
|
@@ -419,7 +419,7 @@ function renameAll() { // ⚙ → own names on or off: every sub-agent is name
|
|
|
419
419
|
}
|
|
420
420
|
function persona0(a) {
|
|
421
421
|
const h = hash(a.id), base = { ...a, given: 'given' in a ? a.given : a.name };
|
|
422
|
-
if (a.id === 'main') return { ...base, name: a.name || '
|
|
422
|
+
if (a.id === 'main') return { ...base, name: a.name || 'Margot', role: L('Chef', 'Lead'), shirt: '#e8845f', shape: 0, acc: 1, eye: 0, glasses: false, scene: 6, prop: 1, acc2: '#f5c04a', lead: true, vi: 0, gid: 'main' };
|
|
423
423
|
if (a.id === 'user') return { ...base, name: L('Vous', 'You'), role: L('Humain', 'Human'), shirt: '#8a94c9', shape: 1, acc: 2, eye: 1, glasses: false, scene: 7, prop: 1, acc2: '#ff7a6b', vi: 3, gid: 'user' };
|
|
424
424
|
return { ...base, name: subName(base, h), roleEn: a.roleEn || a.role, role: (FR() && a.roleFr) || a.roleEn || a.role, shirt: PAL[(h >>> 6) % 8], shape: (h >>> 3) % 5, acc: (h >>> 9) % 6, eye: (h >>> 12) % 3, glasses: ((h >>> 15) % 4) === 0,
|
|
425
425
|
scene: (h >>> 18) % 6, prop: (h >>> 21) % 3, acc2: PAL[(h >>> 24) % 8], vi: h >>> 4, gid: h.toString(36) };
|
|
@@ -1930,7 +1930,7 @@ async function loadSession(proj, id) {
|
|
|
1930
1930
|
if (proj === '_demo') S.events.forEach((e) => { if (e.fr) trMem.set(baseText(e), e.fr); }); // the demo ships its own French
|
|
1931
1931
|
$('#subtitle').textContent = S.title; recomputeStatus(0); buildStage(); renderHighlights(); setFocus('main'); renderLog(true); computeGantt(); layout(); paintStatus(); prefetchTr(0); return true;
|
|
1932
1932
|
}
|
|
1933
|
-
const niceProj = (p) => (p === '_demo' ? '' : p === '_codex' ? 'Codex' : String(p).replace(/^-+/, '').replace(/-/g, '/').split('/').slice(-2).join('/'));
|
|
1933
|
+
const niceProj = (p) => (p === '_demo' ? '' : p === '_codex' ? 'Codex' : p === '_vibe' ? 'Mistral Vibe' : String(p).replace(/^-+/, '').replace(/-/g, '/').split('/').slice(-2).join('/'));
|
|
1934
1934
|
async function fillProjects() { // live view: follow the latest activity anywhere, or pin one project
|
|
1935
1935
|
const sel = $('#liveProj'); let rows = []; try { rows = await (await fetch('/api/projects')).json(); } catch {}
|
|
1936
1936
|
sel.innerHTML = `<option value="">${L('Tous les projets (le plus récent)', 'All projects (most recent)')}</option>` + rows.map((r) => `<option value="${esc(r.proj)}">${esc(niceProj(r.proj))}</option>`).join('');
|
|
@@ -2283,7 +2283,7 @@ function paintPill() {
|
|
|
2283
2283
|
const what = (id) => `${nameOf(id)} — ${st[id].txt.replace(/^\S+\s/, '')}`, more = (a) => (a.length > 1 ? ` +${a.length - 1}` : ''), lead = st.main || { k: 'idle' };
|
|
2284
2284
|
if (by.blocked.length) { cls = 'blocked'; to = by.blocked[0]; txt = '✋ ' + what(to) + more(by.blocked); }
|
|
2285
2285
|
else if (by.stalled.length) { cls = 'stalled'; to = by.stalled[0]; txt = '⚠ ' + what(to) + more(by.stalled); }
|
|
2286
|
-
else if (by.working.length || by.waiting.length) { // "2 working ·
|
|
2286
|
+
else if (by.working.length || by.waiting.length) { // "2 working · Margot — waiting on Ken, Mira", or what the only one at work is doing
|
|
2287
2287
|
const w = by.working, wt = lead.k === 'waiting' ? 'main' : w.length ? '' : by.waiting[0]; cls = 'working'; to = wt || (w.length === 1 ? w[0] : 'main');
|
|
2288
2288
|
txt = (w.length === 1 && !wt ? '⚙ ' + what(w[0]) : w.length ? `⚙ ${w.length} ${L('au travail', 'working')}` : '') + (wt ? (w.length ? ' · ' : '🔗 ') + what(wt) : '');
|
|
2289
2289
|
}
|
package/server.js
CHANGED
|
@@ -7,6 +7,8 @@ const path = require('path');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const crypto = require('crypto');
|
|
9
9
|
const codex = require('./codex.js'); // Codex sessions, read as the same call (see codex.js)
|
|
10
|
+
const vibe = require('./vibe.js'); // Mistral Vibe sessions, the same way (see vibe.js)
|
|
11
|
+
const SOURCES = [codex, vibe], sourceOf = (proj) => SOURCES.find((m) => m.PROJ === proj) || null, guard = (fn, or) => { try { return fn(); } catch { return or; } }; // guard: a session of another tool that cannot be read costs that session, never the list // the tools other than Claude Code: each lists its sessions under one made-up project
|
|
10
12
|
const kokoro = require('./kokoro.js'); // natural voices, installed on demand (see kokoro.js)
|
|
11
13
|
const zlib = require('zlib');
|
|
12
14
|
const { execFileSync, execFile } = require('child_process');
|
|
@@ -66,6 +68,7 @@ function subFiles(proj, id) {
|
|
|
66
68
|
}
|
|
67
69
|
return out;
|
|
68
70
|
}
|
|
71
|
+
const subFilesOf = (proj, id) => { const sx = sourceOf(proj); return sx && sx.subFiles ? guard(() => sx.subFiles(id), Object.assign([], { dirs: [], journals: [] })) : subFiles(proj, id); }; // Mistral Vibe keeps its sub-agents its own way
|
|
69
72
|
function mediaFile(proj, sid, agent) {
|
|
70
73
|
if (agent === 'main') return path.join(ROOT, proj, sid + '.jsonl');
|
|
71
74
|
const hit = subFiles(proj, sid).find((x) => x.aid === agent);
|
|
@@ -103,7 +106,7 @@ function listSessions() {
|
|
|
103
106
|
}
|
|
104
107
|
function listSessions0() {
|
|
105
108
|
const rows = [];
|
|
106
|
-
for (const r of
|
|
109
|
+
for (const m of SOURCES) for (const r of guard(() => m.sessions(30), [])) rows.push({ proj: m.PROJ, id: r.id, mtime: r.mtime, size: r.size, agents: guard(() => (m.subCount ? m.subCount(r.id) : m.subFiles ? m.subFiles(r.id).length : 0), 0), cx: r, src: m });
|
|
107
110
|
for (const proj of fs.existsSync(ROOT) ? fs.readdirSync(ROOT) : []) {
|
|
108
111
|
const dir = path.join(ROOT, proj);
|
|
109
112
|
let files;
|
|
@@ -115,9 +118,9 @@ function listSessions0() {
|
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
120
|
rows.sort((a, b) => b.mtime - a.mtime);
|
|
118
|
-
const top = rows.filter((r) => !r.cx).slice(0, 60).concat(rows.filter((r) => r.cx)).sort((a, b) => b.mtime - a.mtime); // the 60 most recent Claude Code sessions as always, and up to 30
|
|
121
|
+
const top = rows.filter((r) => !r.cx).slice(0, 60).concat(rows.filter((r) => r.cx)).sort((a, b) => b.mtime - a.mtime); // the 60 most recent Claude Code sessions as always, and up to 30 of each other tool beside them: none pushes another out
|
|
119
122
|
for (const r of top) {
|
|
120
|
-
if (r.cx) { const c = r.cx; delete r.cx; r.title =
|
|
123
|
+
if (r.cx) { const c = r.cx, m = r.src; delete r.cx; delete r.src; r.title = guard(() => m.title(c), '') || r.id; r.project = guard(() => m.projectLabel(c.cwd), '') || m.SOURCE; r.source = m.SOURCE; continue; }
|
|
121
124
|
// titles come from the cached tail read (custom title, then AI title, then first prompt): no whole-file reads
|
|
122
125
|
let t = null; try { const f = path.join(ROOT, r.proj, r.id + '.jsonl'); t = parseTail(f, fs.statSync(f)).title; } catch {}
|
|
123
126
|
r.title = t || r.id; r.project = r.proj.replace(/^-Users-[^-]+-/, '').replace(/-/g, '/');
|
|
@@ -128,8 +131,8 @@ function listSessions0() {
|
|
|
128
131
|
function stamp(proj, id) {
|
|
129
132
|
const base = path.join(ROOT, proj); let n = 0;
|
|
130
133
|
const add = (f) => { try { const s = fs.statSync(f); n += s.mtimeMs + s.size; } catch {} };
|
|
131
|
-
|
|
132
|
-
const subs =
|
|
134
|
+
const sx = sourceOf(proj); add(sx ? guard(() => sx.fileOf(id), '') || '' : path.join(base, id + '.jsonl')); if (sx && sx.watch) for (const f of guard(() => sx.watch(id), [])) add(f);
|
|
135
|
+
const subs = subFilesOf(proj, id); // the transcripts and the folders they are in (a folder changes when a file appears in it, e.g. an agent's meta file), not every file around them
|
|
133
136
|
for (const x of subs) add(x.file); for (const x of subs.journals) add(x.file); for (const d of subs.dirs) n += mtimeOf(d);
|
|
134
137
|
return n;
|
|
135
138
|
}
|
|
@@ -213,14 +216,14 @@ function boardRows() {
|
|
|
213
216
|
}
|
|
214
217
|
function boardRows0() {
|
|
215
218
|
const now = Date.now(), rows = [];
|
|
216
|
-
for (const r of
|
|
219
|
+
for (const m of SOURCES) for (const r of guard(() => m.sessions(), [])) if (now - r.mtime < 12 * 3600e3) rows.push({ proj: m.PROJ, id: r.id, st: { mtimeMs: r.mtime, size: r.size }, cx: r, src: m });
|
|
217
220
|
for (const proj of fs.existsSync(ROOT) ? fs.readdirSync(ROOT) : []) {
|
|
218
221
|
const dir = path.join(ROOT, proj); let files; try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
|
|
219
222
|
for (const f of files) { try { const st = fs.statSync(path.join(dir, f)); if (now - st.mtimeMs < 12 * 3600e3) rows.push({ proj, id: f.slice(0, -6), st }); } catch {} }
|
|
220
223
|
}
|
|
221
224
|
rows.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
|
|
222
|
-
return rows.filter((r) => !r.cx).slice(0, 40).concat(rows.filter((r) => r.
|
|
223
|
-
if (cx) return
|
|
225
|
+
return rows.filter((r) => !r.cx).slice(0, 40).concat(...SOURCES.map((m) => rows.filter((r) => r.src === m).slice(0, 20))).sort((a, b) => b.st.mtimeMs - a.st.mtimeMs).map(({ proj, id, st, cx, src }) => {
|
|
226
|
+
if (cx) return guard(() => src.boardRow(cx, now), null);
|
|
224
227
|
const file = path.join(ROOT, proj, id + '.jsonl'); let p; try { p = parseTail(file, st); } catch { p = { pending: [] }; }
|
|
225
228
|
const age = now - st.mtimeMs, lp = (p.pending || [])[(p.pending || []).length - 1]; let state = 'idle', tool = '', ageMs = age;
|
|
226
229
|
if (age < 30 * 60e3) {
|
|
@@ -232,7 +235,7 @@ function boardRows0() {
|
|
|
232
235
|
let subActive = 0, subTotal = 0;
|
|
233
236
|
for (const x of subFiles(proj, id)) { subTotal++; if (now - mtimeOf(x.file) < 90e3) subActive++; }
|
|
234
237
|
return { proj, id, title: p.title || id, mtime: st.mtimeMs, state, tool, ageMs, subActive, subTotal };
|
|
235
|
-
});
|
|
238
|
+
}).filter(Boolean);
|
|
236
239
|
}
|
|
237
240
|
// Settings live in a file, not only in the page's localStorage: the port changes between VS Code windows and reloads, and localStorage is per origin (port included).
|
|
238
241
|
const PREFS = process.env.AGENT_STANDUP_PREFS || (process.platform === 'win32' ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'agent-standup', 'prefs.json') : path.join(os.homedir(), '.config', 'agent-standup', 'prefs.json'));
|
|
@@ -241,17 +244,17 @@ function writePrefs(v) { fs.mkdirSync(path.dirname(PREFS), { recursive: true });
|
|
|
241
244
|
// Most recently active session (optionally limited to one project dir) — drives the live feed.
|
|
242
245
|
const STICK_MS = 90000; // a session written to in the last 90 s keeps the call, so another session writing elsewhere does not steal it mid-line
|
|
243
246
|
function sessionMtime(proj, id) {
|
|
244
|
-
|
|
245
|
-
let m = mtimeOf(path.join(ROOT, proj, id + '.jsonl')); if (!m) return 0; const subs =
|
|
247
|
+
const sx = sourceOf(proj), r = sx ? guard(() => sx.row(id), null) : null; if (sx && (!r || !sx.subFiles)) return r ? r.mtime : 0;
|
|
248
|
+
let m = sx ? r.mtime : mtimeOf(path.join(ROOT, proj, id + '.jsonl')); if (!m) return 0; const subs = subFilesOf(proj, id);
|
|
246
249
|
for (const x of subs) m = Math.max(m, mtimeOf(x.file)); for (const d of subs.dirs) m = Math.max(m, mtimeOf(d)); return m; // the folders too: a new file in them is activity, as when every file around the transcripts was looked at
|
|
247
250
|
}
|
|
248
251
|
function currentSession(projFilter, idFilter, stick) {
|
|
249
252
|
const safe = (x) => typeof x === 'string' && /^[\w.\-]+$/.test(x) && x !== '..';
|
|
250
|
-
if (stick && safe(stick.id) && safe(stick.proj) && (!projFilter || stick.proj === projFilter || stick.proj.startsWith(projFilter + '-') || (stick.proj
|
|
251
|
-
if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && (projFilter
|
|
253
|
+
if (stick && safe(stick.id) && safe(stick.proj) && (!projFilter || stick.proj === projFilter || stick.proj.startsWith(projFilter + '-') || (sourceOf(stick.proj) && guard(() => sourceOf(stick.proj).inProject(stick.id, projFilter), false))) && Date.now() - sessionMtime(stick.proj, stick.id) < STICK_MS) return { proj: stick.proj, id: stick.id, mtime: sessionMtime(stick.proj, stick.id) };
|
|
254
|
+
if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && (sourceOf(projFilter) ? !!guard(() => sourceOf(projFilter).fileOf(idFilter), null) : fs.existsSync(path.join(ROOT, projFilter, idFilter + '.jsonl')))) return { proj: projFilter, id: idFilter, mtime: Date.now() }; // pinned from the board
|
|
252
255
|
let best = null;
|
|
253
256
|
const consider = (proj, id, m) => { if (!best || m > best.mtime) best = { proj, id, mtime: m }; };
|
|
254
|
-
for (const r of
|
|
257
|
+
for (const m of SOURCES) for (const r of guard(() => m.sessions(), [])) if (!projFilter || guard(() => m.inProject(r.id, projFilter), false)) consider(m.PROJ, r.id, r.mtime); // a Codex or Vibe session counts for a project when it was started in that folder
|
|
255
258
|
for (const proj of fs.existsSync(ROOT) ? fs.readdirSync(ROOT) : []) {
|
|
256
259
|
if (projFilter && proj !== projFilter && !proj.startsWith(projFilter + '-')) continue; // the folder itself, or a folder inside it
|
|
257
260
|
const dir = path.join(ROOT, proj); let files;
|
|
@@ -276,7 +279,7 @@ function readNew(file, agent, journal, record = parseRecord) {
|
|
|
276
279
|
let size, mtime; try { const s = fs.statSync(file); size = s.size; mtime = s.mtimeMs; } catch { fileStates.delete(file); return null; }
|
|
277
280
|
let st = fileStates.get(file);
|
|
278
281
|
if (st && size >= st.offset && mtime !== st.mtime && st.fp.length && !sameBytes(file, st)) st = null; // rewritten in place and not shorter: checked only when the file was written to, with one small read
|
|
279
|
-
if (!st || size < st.offset) { st = { agent, journal: !!journal, gen: ++fileGen, offset: 0, mtime: 0, fp: Buffer.alloc(0), tail: Buffer.alloc(0), raw: [], toolName: {}, msgUsage: new Map() }; fileStates.set(file, st); }
|
|
282
|
+
if (!st || size < st.offset) { st = { agent, file, journal: !!journal, gen: ++fileGen, offset: 0, mtime: 0, fp: Buffer.alloc(0), tail: Buffer.alloc(0), raw: [], toolName: {}, msgUsage: new Map() }; fileStates.set(file, st); }
|
|
280
283
|
st.mtime = mtime;
|
|
281
284
|
while (size > st.offset) { // read in chunks of at most 16 MB so a huge transcript never needs one giant buffer
|
|
282
285
|
const fd = fs.openSync(file, 'r'); let buf;
|
|
@@ -292,6 +295,7 @@ function readNew(file, agent, journal, record = parseRecord) {
|
|
|
292
295
|
try { record(st, r); } catch {} // one odd record must not take the whole timeline down
|
|
293
296
|
}
|
|
294
297
|
}
|
|
298
|
+
if (record.after) { try { record.after(st); } catch {} } // a format without a time on each line (Mistral Vibe) dates what was just read, once it has all of it
|
|
295
299
|
return st;
|
|
296
300
|
}
|
|
297
301
|
function parseRecord(st, r) {
|
|
@@ -346,7 +350,8 @@ function parseRecord(st, r) {
|
|
|
346
350
|
}
|
|
347
351
|
}
|
|
348
352
|
}
|
|
349
|
-
const codexRecord = codex.parser({ toolLabel, clip, resultMax: RESULT_MAX });
|
|
353
|
+
const codexRecord = codex.parser({ toolLabel, clip, resultMax: RESULT_MAX }), vibeRecord = vibe.parser({ toolLabel, clip, resultMax: RESULT_MAX });
|
|
354
|
+
const recordOf = (sx) => (sx === codex ? codexRecord : sx === vibe ? vibeRecord : parseRecord);
|
|
350
355
|
// A sub-agent the person named keeps that name on screen: the name given when it was launched, or else its type when that type is the person's own (.claude/agents/code-reviewer.md → "Code Reviewer").
|
|
351
356
|
// The types Claude Code ships are not names: those agents get a first name from the page, as before.
|
|
352
357
|
const STOCK_AGENT = /^(general-purpose|explore|plan|claude|agent|task|fork|main|default|worker|subagent|workflow-subagent|statusline-setup|output-style-setup|claude-code-guide|verification|magic-docs)$/i;
|
|
@@ -362,16 +367,16 @@ function readMeta(f) {
|
|
|
362
367
|
}
|
|
363
368
|
|
|
364
369
|
function buildTimeline(proj, id, files = []) {
|
|
365
|
-
const
|
|
370
|
+
const sx = sourceOf(proj), rec = recordOf(sx), base = path.join(ROOT, proj), mainFile = sx ? sx.fileOf(id) || '' : path.join(base, id + '.jsonl'); // a Codex or Vibe session is kept somewhere else, in another format (rec); from here on it is a session like any other (Codex without sub-agents)
|
|
366
371
|
if (!fs.existsSync(mainFile)) throw new Error('session not found');
|
|
367
|
-
const agents = { main: { id: 'main', role: 'Lead', type: 'main'
|
|
368
|
-
const states = [readNew(mainFile, 'main', false,
|
|
372
|
+
const agents = { main: { id: 'main', role: 'Lead', type: 'main' } }; // the lead has the same name whatever tool runs the session: the page gives it
|
|
373
|
+
const states = [(sx && sx.state && guard(() => sx.state(id), null)) || readNew(mainFile, 'main', false, rec)], subs = subFilesOf(proj, id), runs = {}; files.push(mainFile); // sx.state: a format that is not read line by line hands over the events of the session whole // runs: workflow run → { agents (file name → agent id), by (who launched it), toolId, done }
|
|
369
374
|
for (const x of subs) {
|
|
370
|
-
const aid = x.aid, m = readMeta(x.file.replace(/\.jsonl$/, '.meta.json'));
|
|
375
|
+
const aid = x.aid, m = x.meta || readMeta(x.file.replace(/\.jsonl$/, '.meta.json'));
|
|
371
376
|
agents[aid] = { id: aid, role: m.description || m.workflowPhase || m.agentType || 'Agent', type: m.agentType || 'agent', toolUseId: m.toolUseId };
|
|
372
377
|
if (x.run) { agents[aid].wf = x.run; if (m.workflowPhase) agents[aid].phase = m.workflowPhase; (runs[x.run] = runs[x.run] || { agents: {}, done: new Set() }).agents[x.id0] = aid; } // launched by a workflow: no Agent call of the lead points at it
|
|
373
|
-
agents[aid].mtime = mtimeOf(x.file) || undefined;
|
|
374
|
-
const st = readNew(x.file, aid); if (st) states.push(st); files.push(x.file);
|
|
378
|
+
agents[aid].mtime = x.mtime || mtimeOf(x.file) || undefined;
|
|
379
|
+
const st = x.st || readNew(x.file, aid, false, rec); if (st) states.push(st); files.push(x.file);
|
|
375
380
|
}
|
|
376
381
|
for (const j of subs.journals) { const st = readNew(j.file, 'wf:' + j.run, true), run = runs[j.run]; files.push(j.file); if (st && run) for (const e of st.raw) if (run.agents[e.child]) run.done.add(run.agents[e.child]); }
|
|
377
382
|
const childByTool = {}; // Agent tool_use id → the sub-agent it created, so we know who ordered whom
|
|
@@ -592,7 +597,7 @@ const CHAR_NAME = /^[\w][\w .()\-]{0,78}\.(png|gif|webp|jpe?g)$/i, CHAR_MAX = 30
|
|
|
592
597
|
const charactersDir = () => process.env.AGENT_STANDUP_CHARACTERS || path.join(os.homedir(), '.agent-standup', 'characters');
|
|
593
598
|
// lstat, not stat: a symlink is not a regular file, so it is skipped and never served
|
|
594
599
|
// Built-in character packs, shipped with the app (examples/<folder>): picked in ⚙ → Characters. examples/characters stays in the repository as a model for one's own folder; it is not shipped
|
|
595
|
-
const PACKS = { cats: 'cats' }, packDir = (name) => (PACKS
|
|
600
|
+
const PACKS = { cats: 'cats' }, packDir = (name) => (Object.prototype.hasOwnProperty.call(PACKS, name) ? path.join(__dirname, 'examples', PACKS[name]) : null); // its own names only: 'constructor' or 'toString' is not a pack (they answered 500)
|
|
596
601
|
function listCharacters(pack) {
|
|
597
602
|
const dir = pack ? packDir(pack) : charactersDir(); let names = []; if (!dir) return { dir: '', max: CHAR_MAX, chars: [] }; try { names = fs.readdirSync(dir); } catch {}
|
|
598
603
|
const ok = names.filter((f) => CHAR_NAME.test(f)).filter((f) => { try { const st = fs.lstatSync(path.join(dir, f)); return st.isFile() && st.size <= CHAR_MAX; } catch { return false; } }).slice(0, 80);
|
|
@@ -666,7 +671,7 @@ function start(port = PORT) {
|
|
|
666
671
|
if (url.pathname === '/api/session') {
|
|
667
672
|
const proj = url.searchParams.get('proj') || '', sid = url.searchParams.get('id') || '', SAFE = /^[\w.\-]+$/;
|
|
668
673
|
if (!SAFE.test(proj) || !SAFE.test(sid) || proj === '..' || sid === '..' || proj.length > 200 || sid.length > 200) { res.writeHead(400); return res.end('bad request'); }
|
|
669
|
-
if (proj !== '_demo' && !(proj
|
|
674
|
+
if (proj !== '_demo' && !(sourceOf(proj) ? sourceOf(proj).fileOf(sid) : fs.existsSync(path.join(ROOT, proj, sid + '.jsonl')))) return json({ error: 'session not found' }, 404);
|
|
670
675
|
const entry = proj === '_demo' ? demoEntry() : timelineEntry(proj, sid), tl = entry.tl; // built-in fictional session
|
|
671
676
|
const after = url.searchParams.get('after'), ep = url.searchParams.get('ep');
|
|
672
677
|
if (after == null) { // the whole timeline: serialize once per version, send gzip-compressed when the browser allows it (12 MB → ~1.5 MB)
|
package/vibe.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Mistral Vibe sessions (Mistral's coding agent, the `vibe` command) shown as the same call as Claude Code sessions. Nothing is ever written in Vibe's folder.
|
|
2
|
+
// Vibe has two ways of keeping a session, and both are read:
|
|
3
|
+
// - The current one (the engine Vibe 2.26 starts by default; checked against real sessions of 2.26.1): <home>/logs/session/unified/<id>/. CURRENT names the latest snapshot
|
|
4
|
+
// ("generation"); its manifest lists the pages of the history (chunks/<sha>.json, arrays of entries) and the journal that follows it (journal/<n>.jsonl), whose
|
|
5
|
+
// projection_delta records append, replace or remove entries until the next snapshot. An entry is a message (user or assistant), a reasoning, or an "effect": a tool
|
|
6
|
+
// call with its input, its state and its result, and the times it started and ended. A sub-agent has no history of its own on disk: what is known of it is what the
|
|
7
|
+
// lead did with it (subagent.spawn, .send_message, .wait with the answer it got, .stop).
|
|
8
|
+
// - The earlier one (still written by `vibe --legacy-harness`; read from Vibe's source, vibe/core/session/session_logger.py): <home>/logs/session/<prefix>_<date>_<time>_<id>/
|
|
9
|
+
// with messages.jsonl (one message per line, no time on any of them) and meta.json; a sub-agent is a session of its own in <session>/agents/.
|
|
10
|
+
// server.js reads the earlier format with its own incremental reader and parser() below; the current one is turned into the same events here (state()).
|
|
11
|
+
// All Vibe sessions are listed under one made-up project, PROJ, like Codex ones.
|
|
12
|
+
const fs = require('fs'), os = require('os'), path = require('path');
|
|
13
|
+
const PROJ = '_vibe', SOURCE = 'vibe', MESSAGES = 'messages.jsonl', META = 'meta.json';
|
|
14
|
+
// Where Vibe keeps its files: AGENT_STANDUP_VIBE (a folder; "0" or "off" turns Vibe sessions off), else $VIBE_HOME, else ~/.vibe.
|
|
15
|
+
// When AGENT_STANDUP_PROJECTS points the app at one folder of Claude Code sessions (--projects, the tests), Vibe is only read if it is asked for too.
|
|
16
|
+
function home() {
|
|
17
|
+
const v = process.env.AGENT_STANDUP_VIBE;
|
|
18
|
+
if (v != null) return /^(0|off|no|false)?$/i.test(v.trim()) ? null : v;
|
|
19
|
+
if (process.env.AGENT_STANDUP_PROJECTS) return null;
|
|
20
|
+
return process.env.VIBE_HOME || path.join(os.homedir(), '.vibe');
|
|
21
|
+
}
|
|
22
|
+
const dirKey = (cwd) => String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-'); // the name Claude Code gives the same folder: how "this project" is asked for everywhere else
|
|
23
|
+
const SAFE = /^[\w][\w.\-]*$/; // a session is known by the name of its folder; one that could not be an id elsewhere in the app is left out
|
|
24
|
+
const stat = (f) => { try { return fs.statSync(f); } catch { return null; } };
|
|
25
|
+
|
|
26
|
+
// ---- finding the sessions
|
|
27
|
+
let memo = { at: 0, home: undefined, rows: [], byId: new Map() };
|
|
28
|
+
function scan() { // every session folder with the date and size of its messages, newest first; shared by all the polls of two seconds
|
|
29
|
+
const h = home(), now = Date.now(); if (memo.home === h && now - memo.at < 2000) return memo;
|
|
30
|
+
const rows = [], byId = new Map(), root = h ? path.join(h, 'logs', 'session') : '';
|
|
31
|
+
let names = []; if (root) { try { names = fs.readdirSync(root, { withFileTypes: true }); } catch {} }
|
|
32
|
+
for (const e of names) {
|
|
33
|
+
if (!e.isDirectory() || !SAFE.test(e.name) || e.name === 'unified') continue;
|
|
34
|
+
const dir = path.join(root, e.name), file = path.join(dir, MESSAGES), st = stat(file); if (!st || !st.isFile()) continue;
|
|
35
|
+
const row = { id: e.name, dir, file, mtime: st.mtimeMs, size: st.size }; rows.push(row); byId.set(row.id, row);
|
|
36
|
+
}
|
|
37
|
+
let uni = []; if (root) { try { uni = fs.readdirSync(path.join(root, 'unified'), { withFileTypes: true }); } catch {} }
|
|
38
|
+
for (const e of uni) { // the current format: one folder per session, CURRENT is rewritten at each snapshot
|
|
39
|
+
if (!e.isDirectory() || !SAFE.test(e.name) || byId.has(e.name)) continue;
|
|
40
|
+
const dir = path.join(root, 'unified', e.name), file = path.join(dir, 'CURRENT'), st = stat(file); if (!st || !st.isFile()) continue;
|
|
41
|
+
const row = { id: e.name, dir, file, mtime: st.mtimeMs, size: st.size, uni: true }; rows.push(row); byId.set(row.id, row);
|
|
42
|
+
}
|
|
43
|
+
rows.sort((a, b) => b.mtime - a.mtime);
|
|
44
|
+
for (const r of rows.filter((x) => x.uni).slice(0, 40)) { const j = journalOf(r.dir); if (j) { r.journal = j.file; r.mtime = Math.max(r.mtime, j.mtime); r.size += j.size; } } // between two snapshots only the journal is written: it is what says a session is moving
|
|
45
|
+
rows.sort((a, b) => b.mtime - a.mtime);
|
|
46
|
+
memo = { at: now, home: h, rows, byId }; return memo;
|
|
47
|
+
}
|
|
48
|
+
function journalOf(dir) { // the newest journal segment of a session
|
|
49
|
+
let names; try { names = fs.readdirSync(path.join(dir, 'journal')).filter((f) => f.endsWith('.jsonl')).sort(); } catch { return null; }
|
|
50
|
+
for (let i = names.length - 1; i >= 0 && i >= names.length - 2; i--) { const file = path.join(dir, 'journal', names[i]), st = stat(file); if (st) return i === names.length - 1 || st.size ? { file, mtime: st.mtimeMs, size: st.size } : null; }
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const row = (id) => scan().byId.get(String(id)) || null;
|
|
54
|
+
const fileOf = (id) => { const r = row(id); return r ? r.file : null; };
|
|
55
|
+
|
|
56
|
+
// ---- meta.json: read again only when it was written
|
|
57
|
+
const metaCache = new Map(); // session folder → { sig, v }
|
|
58
|
+
const NO_META = { title: '', start: 0, cwd: '', children: [], stats: null, model: '', cw: 0 };
|
|
59
|
+
function meta(dir) {
|
|
60
|
+
const f = path.join(dir, META), st = stat(f); if (!st) return NO_META;
|
|
61
|
+
const sig = st.mtimeMs + ':' + st.size, hit = metaCache.get(dir); if (hit && hit.sig === sig) return hit.v;
|
|
62
|
+
let j = null; try { j = JSON.parse(fs.readFileSync(f, 'utf8')); } catch {} // being written: asked again next time, nothing is kept
|
|
63
|
+
if (!j || typeof j !== 'object') return hit ? hit.v : NO_META;
|
|
64
|
+
const env = j.environment && typeof j.environment === 'object' ? j.environment : {}, cfg = j.config && typeof j.config === 'object' ? j.config : {}, model = typeof cfg.active_model === 'string' ? cfg.active_model : '';
|
|
65
|
+
const mc = (Array.isArray(cfg.models) ? cfg.models : []).find((m) => m && (m.alias === model || m.name === model)), s = j.stats && typeof j.stats === 'object' ? j.stats : null;
|
|
66
|
+
const v = {
|
|
67
|
+
title: typeof j.title === 'string' ? j.title.trim() : '', start: Date.parse(j.start_time) || 0, cwd: typeof env.working_directory === 'string' ? env.working_directory : typeof j.origin_directory === 'string' ? j.origin_directory : '',
|
|
68
|
+
children: (Array.isArray(j.child_sessions) ? j.child_sessions : []).filter((c) => c && typeof c === 'object').map((c) => ({ tool: String(c.tool_call_id || ''), agent: String(c.agent || ''), rel: ((x) => (/^agents\/[\w][\w.\-]*$/.test(x) ? x : ''))(String(c.relative_path || '').replace(/\\/g, '/')), // inside the session's own agents folder, or not followed
|
|
69
|
+
sid: String(c.session_id || '') })),
|
|
70
|
+
stats: s ? { p: +s.session_prompt_tokens || 0, c: +s.session_completion_tokens || 0, r: +s.session_cached_tokens || 0, x: +s.context_tokens || 0 } : null, model, cw: mc && mc.max_context_length > 0 ? +mc.max_context_length : 0,
|
|
71
|
+
};
|
|
72
|
+
if (metaCache.size > 3000) metaCache.clear(); metaCache.set(dir, { sig, v }); return v;
|
|
73
|
+
}
|
|
74
|
+
// The sessions a person started, newest first, each with the folder it was started in.
|
|
75
|
+
function sessions(limit = 80) { return scan().rows.slice(0, limit).map((r) => { const m = meta(r.dir); return { ...r, cwd: m.cwd, key: dirKey(m.cwd) }; }); } // meta.json is written in both formats
|
|
76
|
+
const inProject = (id, key) => { const r = row(id); if (!r || !key) return false; if (key === PROJ) return true; const d = dirKey(meta(r.dir).cwd); return d === key || d.startsWith(key + '-'); };
|
|
77
|
+
const projectLabel = (cwd) => { const h = os.homedir(), rel = cwd && cwd.startsWith(h + path.sep) ? cwd.slice(h.length + 1) : cwd; return 'Mistral Vibe' + (rel ? ' · ' + rel.split(path.sep).join('/') : ''); };
|
|
78
|
+
|
|
79
|
+
// ---- names: the title Vibe gave the session, else the first thing the person typed
|
|
80
|
+
function readPart(file, from, len) { let fd; try { fd = fs.openSync(file, 'r'); const buf = Buffer.alloc(len), n = fs.readSync(fd, buf, 0, len, from); return buf.subarray(0, n).toString('utf8'); } catch { return ''; } finally { if (fd != null) fs.closeSync(fd); } }
|
|
81
|
+
const typed = (m) => String((typeof m.input_text === 'string' && m.input_text) || (typeof m.content === 'string' ? m.content : '') || '').replace(/^Scratchpad directory:[^\n]*\n(?:You can read and write files here[^\n]*\n)?\s*/, '').trim(); // without the note Vibe puts in front of a sub-agent's task
|
|
82
|
+
const promptCache = new Map(); // messages file → the first prompt ('' = looked for in the first megabyte and not there)
|
|
83
|
+
function firstPrompt(file, size) {
|
|
84
|
+
const hit = promptCache.get(file); if (hit != null) return hit;
|
|
85
|
+
let out = '';
|
|
86
|
+
for (const l of readPart(file, 0, Math.min(1024 * 1024, size)).split('\n')) { if (!l.includes('"user"')) continue; let j; try { j = JSON.parse(l); } catch { continue; } if (j && j.role === 'user' && !j.injected) { const t = typed(j); if (t) { out = t.replace(/\s+/g, ' ').slice(0, 90); break; } } }
|
|
87
|
+
if (out || size > 64 * 1024) { if (promptCache.size > 2000) promptCache.clear(); promptCache.set(file, out); } // a file that has just been created is looked at again
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
const brief = (s) => { const m = /^(.{8,70}?)[.!?](\s|$)/.exec(s); if (m) return m[1]; if (s.length <= 56) return s; const cut = s.slice(0, 56), sp = cut.lastIndexOf(' '); return (sp > 30 ? cut.slice(0, sp) : cut) + '…'; }; // what a sub-agent was asked, in a few words: Vibe gives a task no title
|
|
91
|
+
const title = (r) => (r.uni ? meta(r.dir).title || uniSummary(r).title || r.id : meta(r.dir).title || firstPrompt(r.file, r.size) || r.id);
|
|
92
|
+
|
|
93
|
+
// ---- sub-agents: the sessions in <session>/agents/, each tied to the task call that started it
|
|
94
|
+
function subFiles(id) {
|
|
95
|
+
const out = []; out.dirs = []; out.journals = []; const r = row(id); if (!r) return out;
|
|
96
|
+
if (r.uni) { for (const k of uniState(r).kids) out.push({ file: r.file, aid: k.aid, id0: k.aid, run: '', st: k.st, mtime: k.last, meta: { agentType: k.type || 'agent', description: brief(str(k.task).replace(/\s+/g, ' ').trim().slice(0, 90)) || k.name || 'agent', toolUseId: k.spawn } }); return out; }
|
|
97
|
+
const ad = path.join(r.dir, 'agents'); out.dirs.push(ad);
|
|
98
|
+
let names; try { names = fs.readdirSync(ad, { withFileTypes: true }); } catch { return out; }
|
|
99
|
+
const links = meta(r.dir).children;
|
|
100
|
+
for (const e of names.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
101
|
+
if (!e.isDirectory() || !SAFE.test(e.name)) continue;
|
|
102
|
+
const dir = path.join(ad, e.name), file = path.join(dir, MESSAGES), st = stat(file); if (!st) continue;
|
|
103
|
+
const link = links.find((c) => c.rel === 'agents/' + e.name) || null, agent = (link && link.agent) || e.name.replace(/_\d{8}_\d{6}_[\w-]+$/, '') || 'agent';
|
|
104
|
+
out.push({ file, aid: e.name, id0: e.name, run: '', meta: { agentType: agent, description: brief(firstPrompt(file, st.size)) || agent, ...(link && link.tool ? { toolUseId: link.tool } : {}) } });
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
const watch = (id) => { const r = row(id); return !r ? [] : r.uni ? [path.join(r.dir, META), ...(r.journal ? [r.journal] : [])] : [path.join(r.dir, META)]; }; // written after the messages: the title, the totals and the links to the sub-agents come from it
|
|
109
|
+
|
|
110
|
+
// ---- the board: is the agent in the middle of a turn, or is it the person's turn? The last message says.
|
|
111
|
+
const tailCache = new Map();
|
|
112
|
+
function boardRow(r, now) {
|
|
113
|
+
if (r.uni) { // the current format says it outright: idle, running, or blocked on an approval or a question
|
|
114
|
+
const u = uniSummary(r), kind = u.status, age = now - r.mtime, live = u.live;
|
|
115
|
+
const state = age >= 30 * 60e3 ? 'idle' : kind === 'blocked' ? 'blocked' : kind === 'running' ? (u.waiting ? 'waiting' : 'working') : kind === 'idle' || kind === 'failed' ? 'turn' : age < 10 * 60e3 ? 'working' : 'idle';
|
|
116
|
+
return { proj: PROJ, id: r.id, title: title(r), mtime: r.mtime, state, tool: '', ageMs: age, subActive: state === 'idle' || state === 'turn' ? 0 : live, subTotal: u.kids, dir: dirKey(meta(r.dir).cwd), source: SOURCE };
|
|
117
|
+
}
|
|
118
|
+
const sig = r.mtime + ':' + r.size; let t = tailCache.get(r.file);
|
|
119
|
+
if (!t || t.sig !== sig) {
|
|
120
|
+
t = { sig, turn: '' };
|
|
121
|
+
const n = Math.min(256 * 1024, r.size), lines = readPart(r.file, r.size - n, n).split('\n').filter(Boolean); let last = null; try { last = JSON.parse(lines[lines.length - 1] || ''); } catch {}
|
|
122
|
+
if (last && typeof last === 'object') t.turn = last.role === 'assistant' && !(Array.isArray(last.tool_calls) && last.tool_calls.length) ? 'closed' : 'open'; // a plain answer closes the turn; a tool call or a tool result means the agent goes on
|
|
123
|
+
if (tailCache.size > 200) tailCache.delete(tailCache.keys().next().value); tailCache.set(r.file, t);
|
|
124
|
+
}
|
|
125
|
+
const m = meta(r.dir), subs = m.children.map((c) => stat(path.join(r.dir, c.rel, MESSAGES))).filter(Boolean), newest = Math.max(r.mtime, ...subs.map((s) => s.mtimeMs));
|
|
126
|
+
const age = now - newest, state = age >= 30 * 60e3 ? 'idle' : t.turn === 'closed' ? 'turn' : age < 10 * 60e3 ? 'working' : 'idle';
|
|
127
|
+
return { proj: PROJ, id: r.id, title: title(r), mtime: newest, state, tool: '', ageMs: age, subActive: state === 'working' ? subs.filter((s) => now - s.mtimeMs < 240000).length : 0, subTotal: m.children.length, dir: dirKey(m.cwd), source: SOURCE };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- the current format: the history of a session, as Vibe would load it
|
|
131
|
+
const str = (v) => (v == null ? '' : typeof v === 'string' ? v : JSON.stringify(v));
|
|
132
|
+
const MAX_FILE = 64 * 1024 * 1024, readJson = (f) => { const st = fs.statSync(f); if (st.size > MAX_FILE) throw new Error('too large'); return JSON.parse(fs.readFileSync(f, 'utf8')); }; // a page of history is a few megabytes at most; anything far beyond is not read
|
|
133
|
+
const uniSig = (r) => { const j = journalOf(r.dir), cs = stat(r.file); return (cs ? cs.mtimeMs + ':' + cs.size : '') + '|' + (j ? j.file + ':' + j.mtime + ':' + j.size : ''); };
|
|
134
|
+
const chunkCache = new Map(), uniCache = new Map(); // a page of history never changes (it is named after its content); session folder → { sig, entries, env, model }
|
|
135
|
+
function uniLoad(r) {
|
|
136
|
+
const sig = uniSig(r), hit = uniCache.get(r.dir); if (hit && hit.sig === sig) return hit;
|
|
137
|
+
try {
|
|
138
|
+
const cur = readJson(r.file); if (!SAFE.test(String(cur.generation))) throw new Error('generation'); // a name, never a path
|
|
139
|
+
const gdir = path.join(r.dir, 'generations', String(cur.generation)), man = readJson(path.join(gdir, 'manifest.json')), snap = readJson(path.join(gdir, 'projection-state.json')).snapshot || {};
|
|
140
|
+
let entries = [];
|
|
141
|
+
for (const sha of (man.projection_state && man.projection_state.chunks) || []) { if (!/^[0-9a-f]{16,}$/.test(sha)) continue; const key = r.dir + '/' + sha; let c = chunkCache.get(key); if (!c) { c = readJson(path.join(r.dir, 'chunks', sha + '.json')); if (chunkCache.size > 8) chunkCache.delete(chunkCache.keys().next().value); chunkCache.set(key, c); } /* kept per session: a page is named after its content, but that is Vibe's promise, not something to rely on between two folders */ if (Array.isArray(c)) entries = entries.concat(c); }
|
|
142
|
+
if (!entries.length && snap.history && Array.isArray(snap.history.entries)) entries = snap.history.entries.slice();
|
|
143
|
+
let env = snap; const seg = man.recovery_journal_segment && typeof man.recovery_journal_segment.path === 'string' && SAFE_SEG.test(man.recovery_journal_segment.path) ? path.join(r.dir, man.recovery_journal_segment.path) : '';
|
|
144
|
+
let text = ''; if (seg) { try { const st = fs.statSync(seg); if (st.size <= MAX_FILE) text = fs.readFileSync(seg, 'utf8'); } catch {} }
|
|
145
|
+
for (const line of text.split('\n')) { // what happened since the snapshot. A last line still being written does not parse and is read next time
|
|
146
|
+
if (!line) continue; let rec; try { rec = JSON.parse(line); } catch { continue; }
|
|
147
|
+
if (!rec || rec.type !== 'projection_delta' || !(rec.sequence > cur.snapshot_sequence) || !rec.payload || !Array.isArray(rec.payload.delta)) continue;
|
|
148
|
+
for (const op of rec.payload.delta) {
|
|
149
|
+
if (!op || typeof op !== 'object') continue;
|
|
150
|
+
if (op.op === 'append_entry' && op.entry) entries.push(op.entry);
|
|
151
|
+
else if (op.op === 'replace_entry' && op.entry) { let i = entries.length - 1; while (i >= 0 && entries[i].id !== op.id) i--; if (i >= 0) entries[i] = op.entry; else entries.push(op.entry); }
|
|
152
|
+
else if (op.op === 'remove_entry') entries = entries.filter((e) => e.id !== op.id);
|
|
153
|
+
else if (op.op === 'set_envelope' && op.state) env = op.state;
|
|
154
|
+
else if (op.op === 'set_history_entries' && Array.isArray(op.entries)) entries = op.entries.slice();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
let model = hit && hit.gen === cur.generation ? hit.model : ''; if (!model) { try { model = str((readJson(path.join(gdir, 'runtime-state.json')).session_metadata || {}).active_model); } catch {} }
|
|
158
|
+
const v = { sig, gen: cur.generation, entries: entries.filter((e) => e && typeof e === 'object' && typeof e.id === 'string'), env, model, state: null };
|
|
159
|
+
uniCache.delete(r.dir); if (uniCache.size >= 4) uniCache.delete(uniCache.keys().next().value); uniCache.set(r.dir, v); return v; // the histories of the last few sessions looked at: a history can be megabytes
|
|
160
|
+
} catch { return hit || { sig: '', entries: [], env: {}, model: '', state: null }; } // caught in the middle of a snapshot: what was read last time stands until the next look
|
|
161
|
+
}
|
|
162
|
+
const SAFE_SEG = /^journal\/[\w.\-]+\.jsonl$/;
|
|
163
|
+
const UNI_TOOLS = { 'file_system.bash': 'Bash', 'file_system.read_file': 'Read', 'file_system.write_file': 'Write', 'file_system.search_replace': 'Edit', 'file_system.grep': 'Grep', 'skill.read': 'Skill', 'process.start': 'Bash' };
|
|
164
|
+
const OPEN = /^(pending|queued|running|in_progress|awaiting|waiting|blocked|started)/;
|
|
165
|
+
const usageMemo = new Map(); // session folder → { p, c, r, list }: the lines of usage already given out. A line never changes once the page has it, so the totals Vibe keeps are handed out as what was added since the last look
|
|
166
|
+
let helpers = { toolLabel: (b, ts) => ({ id: b.id, ts, name: b.name, arg: '' }), clip: (t, n) => String(t).slice(0, n), resultMax: {} }; // set by parser(): the server's own way of describing a tool call
|
|
167
|
+
function uniState(r) {
|
|
168
|
+
const u = uniLoad(r); if (u.state) return u.state;
|
|
169
|
+
const { toolLabel, clip, resultMax } = helpers, mk = (agent) => ({ agent, raw: [], toolName: {}, msgUsage: new Map(), lastTs: 0 }), st = mk('main'), kids = new Map(), lastOf = new Map(); let th = '', waiting = false;
|
|
170
|
+
const push = (to, e, key) => { e.k = to.agent + ':' + key; to.raw.push(e); if (e.ts > to.lastTs) to.lastTs = e.ts; return e; }, take = () => { const t = th; th = ''; return t ? { th: t } : {}; };
|
|
171
|
+
const text = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => (x && typeof x.text === 'string' ? x.text : '')).filter(Boolean).join('\n') : '').trim();
|
|
172
|
+
const one = (e) => {
|
|
173
|
+
const ts = +e.createdAt || 0, end = Math.max(ts, +e.updatedAt || 0);
|
|
174
|
+
if (e.type === 'reasoning') { const t = text(e.text) || text(e.summary); if (t) th = clip(t.replace(/[^\S\n]+/g, ' ').replace(/ ?\n[\n ]*/g, '\n').trim(), 900); return; }
|
|
175
|
+
if (e.type === 'message') {
|
|
176
|
+
const t = text(e.content); if (!t) return;
|
|
177
|
+
if (e.role === 'user') push(st, { ts, agent: 'user', kind: 'user', text: t }, e.id);
|
|
178
|
+
else if (e.role === 'assistant') lastOf.set(e.turnId, push(st, { ts, agent: 'main', kind: 'say', text: t, ...take() }, e.id));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (e.type !== 'effect' || !e.detail || typeof e.detail !== 'object') return;
|
|
182
|
+
const raw = str(e.detail.toolName), input = e.detail.input && typeof e.detail.input === 'object' ? e.detail.input : {}, state = e.state && typeof e.state === 'object' ? e.state : {}, out = state.output && typeof state.output === 'object' ? state.output : {}, sc = out.structured_content && typeof out.structured_content === 'object' ? out.structured_content : {};
|
|
183
|
+
const open = !state.status || OPEN.test(str(state.status)), failed = state.status === 'failed' || out.type === 'failure' || sc.type === 'error' || (typeof sc.returncode === 'number' && sc.returncode !== 0), errText = str((state.error && state.error.message) || (out.error && out.error.message) || (sc.type === 'error' ? sc.error : ''));
|
|
184
|
+
if (raw === 'search_tool_functions' || raw === 'subagent.list') return; // the agent looking up its own tools: not part of the call
|
|
185
|
+
if (/^subagent\./.test(raw)) {
|
|
186
|
+
const name = str(e.detail.agentName || input.agentName), sub = (out._meta && out._meta['mistral.vibe.subagent']) || {}, cid = str(e.detail.childSessionId || sub.childSessionId);
|
|
187
|
+
let kid = kids.get(name);
|
|
188
|
+
if (raw === 'subagent.spawn' && cid) { // a sub-agent joins: the task is its order
|
|
189
|
+
kid = { aid: cid.replace(/[^\w.-]/g, '_'), name, type: str(input.agent), task: str(input.task), spawn: e.id, st: null, last: end, done: false, said: '' }; kid.st = mk(kid.aid); kids.set(name, kid);
|
|
190
|
+
st.toolName[e.id] = 'Task'; lastOf.set(e.turnId, push(st, { ts, agent: 'main', kind: 'agentcall', toolId: e.id, bg: true, text: kid.task, title: name, ...(name ? { nm: name } : {}), ...take() }, e.id)); return;
|
|
191
|
+
}
|
|
192
|
+
if (kid && raw === 'subagent.send_message') { kid.last = end; if (str(input.message).trim()) push(st, { ts, agent: 'main', to: kid.aid, kind: 'order', toolId: e.id, bg: true, text: str(input.message), title: name }, e.id); return; } // one more order to the same sub-agent
|
|
193
|
+
if (kid && raw === 'subagent.wait') { if (open) { waiting = true; return; } waiting = false; kid.last = end; const said = str(sc.value != null ? sc.value : text(out.content)).trim(); if (said && said !== kid.said) { kid.said = said; push(kid.st, { ts: end, agent: kid.aid, kind: 'say', text: said }, e.id); } return; } // what the lead got back when it waited: the sub-agent's words
|
|
194
|
+
if (kid && (raw === 'subagent.stop' || raw === 'subagent.interrupt')) { if (!open && raw === 'subagent.stop' && !kid.done) { kid.done = true; kid.last = end; push(st, { ts: Math.max(end, kid.st.lastTs + 1), agent: 'main', kind: 'agentdone', toolId: kid.spawn }, e.id); } return; }
|
|
195
|
+
}
|
|
196
|
+
if (raw === 'vibe.todo') { if (input.action === 'write') { st.toolName[e.id] = 'TodoWrite'; lastOf.set(e.turnId, push(st, { ts, agent: 'main', kind: 'tool', tool: toolLabel({ id: e.id, name: 'TodoWrite', input: { todos: (Array.isArray(input.todos) ? input.todos : []).map((x) => ({ content: (x && x.content) || '', status: x && x.status === 'cancelled' ? 'completed' : (x && x.status) || 'pending' })) } }, ts), ...take() }, e.id)); } return; }
|
|
197
|
+
const name = UNI_TOOLS[raw] || raw || 'tool', rep0 = Array.isArray(input.content) ? input.content.filter((x) => x && typeof x === 'object') : [];
|
|
198
|
+
const args = name === 'Read' ? { file_path: input.path, offset: input.offset, limit: input.limit } : name === 'Write' ? { file_path: input.path || input.file_path, content: str(input.content) } : name === 'Edit' ? { file_path: input.file_path || input.path, old_string: rep0.map((x) => str(x.old_str)).join('\n…\n'), new_string: rep0.map((x) => str(x.new_str)).join('\n…\n') } : name === 'Skill' ? { skill: input.name } : raw === 'process.start' ? { command: str(input.command), run_in_background: true } : input;
|
|
199
|
+
st.toolName[e.id] = name; lastOf.set(e.turnId, push(st, { ts, agent: 'main', kind: 'tool', tool: toolLabel({ id: e.id, name, input: args }, ts), ...take() }, e.id));
|
|
200
|
+
if (open) return;
|
|
201
|
+
const o = errText ? errText : name === 'Bash' ? [sc.stdout, sc.stderr].filter(Boolean).map(str).join('\n') : name === 'Read' ? str(sc.content) : name === 'Write' ? (sc.bytes_written != null ? sc.bytes_written + ' bytes' : '') : name === 'Edit' ? (sc.lines_changed != null ? sc.lines_changed + ' lines changed' : '') : str(state.outputText) || text(out.content) || (sc.value != null ? str(sc.value) : '');
|
|
202
|
+
push(st, { ts: end, agent: 'main', kind: 'results', items: [{ id: e.id, err: !!failed, out: clip(o.trimEnd(), resultMax[name] || 600) }] }, e.id + '/r');
|
|
203
|
+
};
|
|
204
|
+
for (const e of u.entries) { try { one(e); } catch {} } // an entry in a shape this reader does not expect is left out: it must never stop the rest, nor the sessions of the other tools
|
|
205
|
+
const env = u.env || {}, se = env.session || {}, turn = env.latestTurn || {}, idle = se.status && (se.status.type === 'idle' || se.status.type === 'failed');
|
|
206
|
+
for (const [tid, ev] of lastOf) if (ev.kind !== 'agentcall' && (tid !== turn.id || (turn.status && turn.status !== 'in_progress') || idle)) ev.end = 1; // a turn that is over ends on the last thing the lead said or did in it
|
|
207
|
+
if (idle) for (const k of kids.values()) if (!k.done) { k.done = true; push(st, { ts: Math.max(+turn.completedAt || 0, st.lastTs, k.st.lastTs + 1), agent: 'main', kind: 'agentdone', toolId: k.spawn }, k.spawn + '/done'); } // the lead's turn is over: so are the sub-agents it had not stopped itself
|
|
208
|
+
const tu = se.tokenUsage && typeof se.tokenUsage === 'object' ? se.tokenUsage : null, cu = se.contextUsage && typeof se.contextUsage === 'object' ? se.contextUsage : {};
|
|
209
|
+
if (tu) {
|
|
210
|
+
let m = usageMemo.get(r.dir); const p = +tu.inputTokens || 0, c = +tu.outputTokens || 0, cr = +tu.cachedInputTokens || 0;
|
|
211
|
+
if (!m || p < m.p || c < m.c) { m = { p: 0, c: 0, r: 0, list: [] }; if (usageMemo.size > 300) usageMemo.delete(usageMemo.keys().next().value); usageMemo.set(r.dir, m); }
|
|
212
|
+
const dp = p - m.p, dc = c - m.c, dr = Math.max(0, cr - m.r);
|
|
213
|
+
if (dp > 0 || dc > 0) {
|
|
214
|
+
const ts = +se.updatedAt || st.lastTs || Date.now(), model = u.model || 'mistral', cached = Math.min(dr, dp), fresh = dp - cached, x = +cu.inputTokens || 0, id = 'v' + (m.list.length + 1);
|
|
215
|
+
if (x > 0 && dp > x) { const xr = Math.min(cached, +cu.cachedInputTokens || 0, x), xi = x - xr; m.list.push(['' + id + 'a', { ts: ts - 1, u: { i: Math.max(0, fresh - xi), o: dc, r: cached - xr, w: 0 }, model }], [id, { ts, u: { i: xi, o: 0, r: xr, w: 0 }, model }]); } // several requests since the last look: the last line is the size of the context now, which is what the gauge shows
|
|
216
|
+
else m.list.push([id, { ts, u: { i: fresh, o: Math.max(0, dc), r: cached, w: 0 }, model }]);
|
|
217
|
+
m.p = p; m.c = c; m.r = cr;
|
|
218
|
+
}
|
|
219
|
+
for (const [k, v] of m.list) st.msgUsage.set(k, v);
|
|
220
|
+
}
|
|
221
|
+
u.state = { main: st, kids: [...kids.values()], env, waiting }; return u.state;
|
|
222
|
+
}
|
|
223
|
+
const uniLight = new Map(); // session folder → what the lists need of a session (title, state, how many sub-agents), kept after its history has been let go
|
|
224
|
+
function uniSummary(r) {
|
|
225
|
+
const sig = uniSig(r), hit = uniLight.get(r.dir); if (hit && hit.sig === sig) return hit;
|
|
226
|
+
let v = { sig, title: '', status: '', kids: 0, live: 0, waiting: false }, read = false;
|
|
227
|
+
try { const u = uniLoad(r), st = uniState(r), se = (st.env && st.env.session) || {}; read = u.sig === sig; v = { sig, title: str(se.title).trim() || str(se.preview).replace(/\s+/g, ' ').trim().slice(0, 90), status: str(se.status && se.status.type), kids: st.kids.length, live: st.kids.filter((k) => !k.done).length, waiting: !!st.waiting }; } catch {}
|
|
228
|
+
if (read) { if (uniLight.size > 500) uniLight.delete(uniLight.keys().next().value); uniLight.set(r.dir, v); } // not kept when the session could not be read (caught in the middle of a write): asked again next time
|
|
229
|
+
return v;
|
|
230
|
+
}
|
|
231
|
+
const subCount = (id) => { const r = row(id); return !r ? 0 : r.uni ? uniSummary(r).kids : subFiles(id).length; };
|
|
232
|
+
const state = (id) => { const r = row(id); return r && r.uni ? uniState(r).main : null; }; // null: a session in the earlier format, read line by line with parser()
|
|
233
|
+
|
|
234
|
+
// ---- the earlier format: one line of messages.jsonl → the events of the call
|
|
235
|
+
const TOOLS = { bash: 'Bash', read_file: 'Read', write_file: 'Write', edit: 'Edit', search_replace: 'Edit', grep: 'Grep', web_fetch: 'WebFetch', web_search: 'WebSearch', ask_user_question: 'AskUserQuestion', skill: 'Skill', exit_plan_mode: 'ExitPlanMode' };
|
|
236
|
+
const TAGS = /<\/?(tool_error|user_cancellation|vibe_stop_event|vibe_warning)>/g;
|
|
237
|
+
function parser({ toolLabel, clip, resultMax = {} }) {
|
|
238
|
+
helpers = { toolLabel, clip, resultMax };
|
|
239
|
+
const args = (tc) => { const a = tc && tc.function && tc.function.arguments; if (a && typeof a === 'object') return a; try { const v = JSON.parse(a || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } };
|
|
240
|
+
function output(name, m) { // what the tool gave back, as it would have appeared on the agent's screen
|
|
241
|
+
const o = m.tool_result && typeof m.tool_result === 'object' && m.tool_result.output && typeof m.tool_result.output === 'object' ? m.tool_result.output : null, text = str(m.content);
|
|
242
|
+
const err = /<tool_error>|<user_cancellation>/.test(text) || !!(m.tool_result && m.tool_result.cancelled) || (o && typeof o.exit_code === 'number' && o.exit_code !== 0);
|
|
243
|
+
let out = text.replace(TAGS, '');
|
|
244
|
+
if (o && !/<tool_error>/.test(text)) {
|
|
245
|
+
if (name === 'Bash') out = [o.stdout, o.stderr].filter(Boolean).map(str).join('\n');
|
|
246
|
+
else if (name === 'Read' || name === 'WebFetch') out = str(o.content != null ? o.content : out);
|
|
247
|
+
else if (name === 'Write') out = o.bytes_written != null ? o.bytes_written + ' bytes' : '';
|
|
248
|
+
else if (name === 'Edit') out = str(o.message || '');
|
|
249
|
+
else if (name === 'Grep') out = str(o.matches != null ? o.matches : out);
|
|
250
|
+
else if (name === 'WebSearch') out = str(o.answer != null ? o.answer : out);
|
|
251
|
+
}
|
|
252
|
+
return { out: out.trimEnd(), err: !!err };
|
|
253
|
+
}
|
|
254
|
+
function record(st, m) {
|
|
255
|
+
if (typeof m.role !== 'string') return;
|
|
256
|
+
const agent = st.agent, vb = st.vb || (st.vb = { batch: [], lines: 0, last: 0, n: 0, stats: null, calls: {} }), line = vb.lines++;
|
|
257
|
+
const push = (e) => { e.ts = 0; e.k = agent + ':' + st.raw.length; st.raw.push(e); vb.batch.push({ e, line }); return e; }; // the time is given in after(), once the whole batch is known
|
|
258
|
+
const th = () => { const t = st.th; st.th = ''; return t ? { th: t } : {}; };
|
|
259
|
+
if (m.context_boundary === 'compaction') { push({ agent, kind: 'compact' }); return; } // Vibe compacted the conversation: the message is its summary, not something said
|
|
260
|
+
if (m.role === 'user') {
|
|
261
|
+
if (m.injected) return; // put there by Vibe (a hook, a reminder), not typed
|
|
262
|
+
const sh = m.manual_shell && typeof m.manual_shell === 'object' && typeof m.manual_shell.command === 'string' ? '! ' + m.manual_shell.command : '', text = sh || typed(m);
|
|
263
|
+
if (agent !== 'main') { if (!st.prompt && text && !st.raw.length) { st.prompt = { ts: 0, text }; vb.batch.push({ e: st.prompt, line }); } return; } // a sub-agent's first message is its order, shown from the side of whoever gave it
|
|
264
|
+
if (text) push({ agent: 'user', kind: 'user', text });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (m.role === 'assistant') {
|
|
268
|
+
const think = str(m.reasoning_content).trim(); if (think) st.th = clip(think.replace(/[^\S\n]+/g, ' ').replace(/ ?\n[\n ]*/g, '\n').trim(), 900);
|
|
269
|
+
const calls = (Array.isArray(m.tool_calls) ? m.tool_calls : []).filter((tc) => tc && typeof tc === 'object' && tc.function && tc.function.name), text = str(m.content).replace(TAGS, '').trim();
|
|
270
|
+
if (text) { const e = push({ agent, kind: 'say', text, ...th() }); if (!calls.length && agent === 'main') e.end = 1; } // a plain answer, without a tool call, closes the lead's turn
|
|
271
|
+
calls.forEach((tc, i) => {
|
|
272
|
+
const id = String(tc.id || 'vb' + line + '_' + i), raw = String(tc.function.name), a = args(tc);
|
|
273
|
+
if (raw === 'task') { st.toolName[id] = 'Task'; vb.calls[id] = push({ agent, kind: 'agentcall', toolId: id, bg: false, text: str(a.task), title: str(a.agent || 'explore') }); return; }
|
|
274
|
+
if (raw === 'todo') { if (a.action !== 'write') return; st.toolName[id] = 'TodoWrite'; push({ agent, kind: 'tool', tool: toolLabel({ id, name: 'TodoWrite', input: { todos: (Array.isArray(a.todos) ? a.todos : []).map((x) => ({ content: (x && x.content) || '', status: x && x.status === 'cancelled' ? 'completed' : (x && x.status) || 'pending' })) } }, 0), ...th() }); return; }
|
|
275
|
+
const name = TOOLS[raw] || raw, input = name === 'Skill' ? { skill: a.name } : name === 'AskUserQuestion' ? { questions: (Array.isArray(a.questions) ? a.questions : []).map((q) => ({ question: (q && (q.question || q.header)) || '', options: (q && Array.isArray(q.options) && q.options) || [] })) } : a;
|
|
276
|
+
st.toolName[id] = name; vb.calls[id] = push({ agent, kind: 'tool', tool: toolLabel({ id, name, input }, 0), ...th() });
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (m.role === 'tool') {
|
|
281
|
+
const id = String(m.tool_call_id || ''), name = st.toolName[id]; if (!id || !name) return;
|
|
282
|
+
const dur = m.tool_result && typeof m.tool_result.duration === 'number' && m.tool_result.duration >= 0 ? m.tool_result.duration * 1000 : -1;
|
|
283
|
+
if (name === 'Task') { push({ agent, kind: 'agentdone', toolId: id }); return; } // the lead got the sub-agent's answer back: it has finished
|
|
284
|
+
if (name === 'TodoWrite') return;
|
|
285
|
+
const r = output(name, m), e = push({ agent, kind: 'results', items: [{ id, err: r.err, out: clip(r.out, resultMax[name] || 600) }] });
|
|
286
|
+
if (dur >= 0 && vb.calls[id]) e._call = { c: vb.calls[id], dur };
|
|
287
|
+
delete vb.calls[id];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// The times. A message carries none, so each batch of lines read at once is spread between two known moments: when the previous batch was written (for the first one,
|
|
291
|
+
// when the session started) and when the file was last written. Followed live, a batch is one step of the agent and the times are right to within that step; a session
|
|
292
|
+
// opened afterwards is spread evenly from its start to its end. Two things are known better and used: a sub-agent starts when its own session starts and hands back
|
|
293
|
+
// when its file was last written, and a tool that says how long it took is placed that long before its result.
|
|
294
|
+
record.after = function after(st) {
|
|
295
|
+
const vb = st.vb; if (!vb || !st.file) return; const dir = path.dirname(st.file), m = meta(dir);
|
|
296
|
+
if (vb.batch.length) {
|
|
297
|
+
const end = st.mtime || Date.now(), n = Math.max(1, vb.lines - vb.n); let start = vb.last || m.start || 0; if (!(start > 0) || start >= end) start = end - Math.min(60000, n * 1500);
|
|
298
|
+
const marks = [[-1, start]]; // [line in the batch, time]: the known moments, in order
|
|
299
|
+
for (const { e, line } of vb.batch) {
|
|
300
|
+
if (e.kind !== 'agentcall' && e.kind !== 'agentdone') continue;
|
|
301
|
+
const link = m.children.find((c) => c.tool === e.toolId), cd = link && link.rel ? path.join(dir, link.rel) : '', t = !cd ? 0 : e.kind === 'agentcall' ? meta(cd).start : ((stat(path.join(cd, MESSAGES)) || {}).mtimeMs || -50) + 50; // handed back just after the last thing the sub-agent wrote, which is its report
|
|
302
|
+
const prev = marks[marks.length - 1]; if (t > prev[1] && t < end && line - vb.n > prev[0]) marks.push([line - vb.n, t]);
|
|
303
|
+
}
|
|
304
|
+
marks.push([n - 1, end]);
|
|
305
|
+
const at = (j) => { let k = 1; while (k < marks.length - 1 && marks[k][0] < j) k++; const [j0, t0] = marks[k - 1], [j1, t1] = marks[k]; return j1 === j0 ? t1 : t0 + ((t1 - t0) * (j - j0)) / (j1 - j0); };
|
|
306
|
+
let seen = -1, off = 0;
|
|
307
|
+
for (const { e, line } of vb.batch) { const j = line - vb.n; off = j === seen ? off + 1 : 0; seen = j; e.ts = Math.round(at(j)) + off; if (e.tool) e.tool.ts = e.ts; } // events of one line keep their order, a millisecond apart
|
|
308
|
+
for (const { e } of vb.batch) if (e._call) { const { c, dur } = e._call; delete e._call; const t = Math.max(c.ts, Math.round(e.ts - dur)); if (t < e.ts) { c.ts = t; if (c.tool) c.tool.ts = t; } }
|
|
309
|
+
vb.batch = []; vb.n = vb.lines; vb.last = end; if (end > (st.lastTs || 0)) st.lastTs = end;
|
|
310
|
+
}
|
|
311
|
+
const s = m.stats; if (!s) return; // the totals of the session, which Vibe keeps in meta.json: what was used since the last look is one more line of usage
|
|
312
|
+
const was = vb.stats || { p: 0, c: 0, r: 0 }, dp = s.p - was.p, dc = s.c - was.c, dr = Math.max(0, s.r - was.r); if (dp < 0 || dc < 0) { vb.stats = { p: s.p, c: s.c, r: s.r }; return; } // the totals went down (a new count after a rewind): start again from here
|
|
313
|
+
if (dp <= 0 && dc <= 0) return; vb.stats = { p: s.p, c: s.c, r: s.r };
|
|
314
|
+
const ts = vb.last || st.mtime || Date.now(), model = m.model || 'mistral', cw = m.cw ? { cw: m.cw } : {}, id = 'v' + (vb.u = (vb.u || 0) + 1), cached = Math.min(dr, dp), fresh = dp - cached;
|
|
315
|
+
if (s.x > 0 && dp > s.x) { // several requests since the last look: the last line of usage must be the size of the context now, which is what the gauge shows
|
|
316
|
+
const xr = Math.min(cached, s.x), xi = s.x - xr;
|
|
317
|
+
st.msgUsage.set(id + 'a', { ts: ts - 1, u: { i: Math.max(0, fresh - xi), o: dc, r: cached - xr, w: 0 }, model, ...cw }); st.msgUsage.set(id, { ts, u: { i: xi, o: 0, r: xr, w: 0 }, model, ...cw });
|
|
318
|
+
} else st.msgUsage.set(id, { ts, u: { i: fresh, o: Math.max(0, dc), r: cached, w: 0 }, model, ...cw });
|
|
319
|
+
};
|
|
320
|
+
return record;
|
|
321
|
+
}
|
|
322
|
+
module.exports = { PROJ, SOURCE, home, dirKey, sessions, row, fileOf, inProject, projectLabel, title, boardRow, subFiles, subCount, watch, state, parser };
|