@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agent Standup (command line)
2
2
 
3
- Your Claude Code and Codex sessions as a live team call, without any editor. Free, MIT, and it never calls Claude: it only reads the session files Claude Code and Codex already write.
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 OpenAI.
18
+ Unofficial: not affiliated with Anthropic, OpenAI or Mistral AI.
@@ -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 Codex agents as a live video call
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.29.0",
4
- "description": "Your Claude Code and Codex 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.",
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 || 'Claude', 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' };
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 · Claude — waiting on Ken, Mira", or what the only one at work is doing
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 codex.sessions(30)) rows.push({ proj: codex.PROJ, id: r.id, mtime: r.mtime, size: r.size, agents: 0, cx: r });
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 Codex ones beside them: neither pushes the other out
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 = codex.title(c); r.project = codex.projectLabel(c.cwd); r.source = 'codex'; continue; }
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
- add(proj === codex.PROJ ? codex.fileOf(id) || '' : path.join(base, id + '.jsonl'));
132
- const subs = subFiles(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
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 codex.sessions()) if (now - r.mtime < 12 * 3600e3) rows.push({ proj: codex.PROJ, id: r.id, st: { mtimeMs: r.mtime, size: r.size }, cx: r });
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.cx).slice(0, 20)).sort((a, b) => b.st.mtimeMs - a.st.mtimeMs).map(({ proj, id, st, cx }) => {
223
- if (cx) return codex.boardRow(cx, now);
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
- if (proj === codex.PROJ) { const r = codex.row(id); return r ? r.mtime : 0; }
245
- let m = mtimeOf(path.join(ROOT, proj, id + '.jsonl')); if (!m) return 0; const subs = subFiles(proj, id);
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 === codex.PROJ && codex.inProject(stick.id, projFilter))) && Date.now() - sessionMtime(stick.proj, stick.id) < STICK_MS) return { proj: stick.proj, id: stick.id, mtime: sessionMtime(stick.proj, stick.id) };
251
- if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && (projFilter === codex.PROJ ? !!codex.fileOf(idFilter) : fs.existsSync(path.join(ROOT, projFilter, idFilter + '.jsonl')))) return { proj: projFilter, id: idFilter, mtime: Date.now() }; // pinned from the board
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 codex.sessions()) if (!projFilter || codex.inProject(r.id, projFilter)) consider(codex.PROJ, r.id, r.mtime); // a Codex session counts for a project when it was started in that folder
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 cx = proj === codex.PROJ, base = path.join(ROOT, proj), mainFile = cx ? codex.fileOf(id) || '' : path.join(base, id + '.jsonl'); // a Codex session is one file somewhere else, in another format (codexRecord); from here on it is a session like any other, without sub-agents
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', ...(cx ? { name: 'Codex' } : {}) } };
368
- const states = [readNew(mainFile, 'main', false, cx ? codexRecord : parseRecord)], subs = subFiles(proj, id), runs = {}; files.push(mainFile); // runs: workflow run → { agents (file name → agent id), by (who launched it), toolId, done }
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[name] ? path.join(__dirname, 'examples', PACKS[name]) : null);
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 === codex.PROJ ? codex.fileOf(sid) : fs.existsSync(path.join(ROOT, proj, sid + '.jsonl')))) return json({ error: 'session not found' }, 404);
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 };