@double_geste/agent-standup 1.28.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/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 };