@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.
@@ -58,6 +58,7 @@
58
58
  "Files touched": "건드린 파일",
59
59
  "Final report": "최종 보고",
60
60
  "first error": "첫 오류",
61
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "프랑스어는 시스템 음성으로 읽습니다. “향상된” 또는 “프리미엄” 음성을 설치하면 훨씬 좋아집니다.",
61
62
  "Hide code & file names in videos": "동영상에서 코드와 파일 이름 숨기기",
62
63
  "Highlights": "하이라이트",
63
64
  "Human": "사람",
@@ -65,6 +66,7 @@
65
66
  "input": "입력",
66
67
  "is sharing their screen": "님이 화면 공유 중",
67
68
  "Join the call": "통화 참가",
69
+ "Keep my agents’ own names": "내 에이전트 이름 그대로 사용",
68
70
  "Language": "언어",
69
71
  "Lead": "메인 에이전트",
70
72
  "Lead context": "메인 에이전트 컨텍스트",
@@ -72,6 +74,7 @@
72
74
  "lines": "줄",
73
75
  "Listing ": "목록 보는 중: ",
74
76
  "Little lines & animations": "짧은 대사와 애니메이션",
77
+ "Live": "라이브",
75
78
  "longest wait ": "최장 대기 ",
76
79
  "Looking for a tool": "도구 찾는 중",
77
80
  "looping?": "반복 중?",
@@ -106,6 +109,8 @@
106
109
  "Recent activity": "최근 활동",
107
110
  "Remove finished agents (2 min)": "끝난 에이전트 제거 (2분 후)",
108
111
  "Repeated failures": "반복된 실패",
112
+ "Replay": "다시 보기",
113
+ "REPLAY": "다시 보기",
109
114
  "report": "보고",
110
115
  "report to": "보고 대상:",
111
116
  "Role": "역할",
@@ -167,10 +172,11 @@
167
172
  "Reload": "새로고침",
168
173
  "Open folder": "폴더 열기",
169
174
  "custom character(s) loaded": "개의 커스텀 캐릭터를 불러왔습니다",
175
+ "Demo": "데모",
170
176
  "Drop your pixel art (PNG) in this folder to replace the characters:": "이 폴더에 픽셀 아트(PNG)를 넣으면 캐릭터가 바뀝니다:",
171
177
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 메인 에이전트 · you.png = 나 · 나머지 = 서브에이전트 · name-talk.png = 입을 벌린 모습",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "다운로드 중입니다. 계속 사용하셔도 됩니다. 그동안은 시스템 음성을 사용합니다.",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "품질 좋은 로컬 음성으로, 대부분 영어입니다 (프랑스어 음성은 하나). 무료이며 아무것도 컴퓨터 밖으로 전송되지 않습니다.",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "품질 좋은 로컬 음성으로, 영어입니다 (프랑스어는 시스템 음성을 사용). 무료이며 아무것도 컴퓨터 밖으로 전송되지 않습니다.",
174
180
  "Installing…": "설치 중…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "모델 불러오는 중 (처음 사용할 때 다운로드, 약 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "npm을 찾을 수 없습니다. 이 옵션을 사용하려면 Node.js를 설치하세요.",
@@ -8,6 +8,7 @@
8
8
  "Reload": "Recarregar",
9
9
  "Open folder": "Abrir pasta",
10
10
  "custom character(s) loaded": "personagem(ns) personalizado(s) carregado(s)",
11
+ "Demo": "Demo",
11
12
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Solte sua pixel art (PNG) nesta pasta para substituir os personagens:",
12
13
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = o agente principal · you.png = você · outros = os subagentes · name-talk.png = boca aberta",
13
14
  " full — compaction soon": " cheio — compactação em breve",
@@ -64,6 +65,7 @@
64
65
  "Files touched": "Arquivos modificados",
65
66
  "Final report": "Relatório final",
66
67
  "first error": "primeiro erro",
68
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "O francês é lido pelas vozes do seu sistema: instalar uma “aprimorada” ou “premium” faz muita diferença.",
67
69
  "Hide code & file names in videos": "Ocultar código e arquivos nos vídeos",
68
70
  "Highlights": "Destaques",
69
71
  "Human": "Humano",
@@ -71,6 +73,7 @@
71
73
  "input": "entrada",
72
74
  "is sharing their screen": "está compartilhando a tela",
73
75
  "Join the call": "Entrar na chamada",
76
+ "Keep my agents’ own names": "Manter os nomes dos meus agentes",
74
77
  "Language": "Idioma",
75
78
  "Lead": "Agente principal",
76
79
  "Lead context": "Contexto do agente principal",
@@ -78,6 +81,7 @@
78
81
  "lines": "linhas",
79
82
  "Listing ": "Listando ",
80
83
  "Little lines & animations": "Falas e animações",
84
+ "Live": "Ao vivo",
81
85
  "longest wait ": "maior espera ",
82
86
  "Looking for a tool": "Procurando uma ferramenta",
83
87
  "looping?": "em loop?",
@@ -112,6 +116,8 @@
112
116
  "Recent activity": "Atividade recente",
113
117
  "Remove finished agents (2 min)": "Remover agentes concluídos (2 min)",
114
118
  "Repeated failures": "Falhas repetidas",
119
+ "Replay": "Replay",
120
+ "REPLAY": "REPLAY",
115
121
  "report": "relatório",
116
122
  "report to": "relatório para",
117
123
  "Role": "Função",
@@ -170,7 +176,7 @@
170
176
  "Your Claude Code agents as a live team call": "Seus agentes do Claude Code em uma chamada de equipe ao vivo",
171
177
  "your turn": "sua vez",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "Baixando, pode continuar: enquanto isso, as vozes do sistema são usadas.",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Vozes locais de boa qualidade, principalmente em inglês (só uma voz em francês). Grátis, nada sai do seu computador.",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "Vozes locais de boa qualidade, em inglês (o francês usa as vozes do seu sistema). Grátis, nada sai do seu computador.",
174
180
  "Installing…": "Instalando…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "Carregando o modelo (baixado no primeiro uso, cerca de 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "npm não encontrado: instale o Node.js para ativar esta opção.",
@@ -58,6 +58,7 @@
58
58
  "Files touched": "涉及的文件",
59
59
  "Final report": "最终报告",
60
60
  "first error": "首个错误",
61
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "法语由系统语音朗读:安装“增强”或“优质”语音会好很多。",
61
62
  "Hide code & file names in videos": "在视频中隐藏代码和文件名",
62
63
  "Highlights": "精彩时刻",
63
64
  "Human": "真人",
@@ -65,6 +66,7 @@
65
66
  "input": "输入",
66
67
  "is sharing their screen": "正在共享屏幕",
67
68
  "Join the call": "加入视频通话",
69
+ "Keep my agents’ own names": "保留我的智能体自己的名字",
68
70
  "Language": "语言",
69
71
  "Lead": "主代理",
70
72
  "Lead context": "主代理上下文",
@@ -72,6 +74,7 @@
72
74
  "lines": "行",
73
75
  "Listing ": "正在列出 ",
74
76
  "Little lines & animations": "小台词和动画",
77
+ "Live": "实时",
75
78
  "longest wait ": "最长等待 ",
76
79
  "Looking for a tool": "正在找工具",
77
80
  "looping?": "在循环?",
@@ -106,6 +109,8 @@
106
109
  "Recent activity": "最近动态",
107
110
  "Remove finished agents (2 min)": "移除已完成的代理(2 分钟后)",
108
111
  "Repeated failures": "反复失败",
112
+ "Replay": "回放",
113
+ "REPLAY": "回放",
109
114
  "report": "报告",
110
115
  "report to": "汇报给",
111
116
  "Role": "角色",
@@ -167,10 +172,11 @@
167
172
  "Reload": "重新加载",
168
173
  "Open folder": "打开文件夹",
169
174
  "custom character(s) loaded": "个自定义角色已加载",
175
+ "Demo": "演示",
170
176
  "Drop your pixel art (PNG) in this folder to replace the characters:": "把你的像素画(PNG)放进此文件夹即可替换角色:",
171
177
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 主代理 · you.png = 你 · 其他 = 子代理 · name-talk.png = 张嘴",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "正在下载,你可以继续使用:期间会使用系统语音。",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "高质量的本地语音,以英语为主(法语只有一种声音)。免费,数据不会离开你的电脑。",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "高质量的本地语音,仅英语(法语使用系统语音)。免费,数据不会离开你的电脑。",
174
180
  "Installing…": "正在安装…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "正在加载模型(首次使用时下载,约 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "未找到 npm:请安装 Node.js 以启用此选项。",
@@ -54,6 +54,7 @@
54
54
  "Reload": "重新載入",
55
55
  "Open folder": "開啟資料夾",
56
56
  "custom character(s) loaded": "個自訂角色已載入",
57
+ "Demo": "示範",
57
58
  "Drop your pixel art (PNG) in this folder to replace the characters:": "把你的像素畫(PNG)放進此資料夾即可替換角色:",
58
59
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 主代理 · you.png = 你 · 其他 = 子代理 · name-talk.png = 張嘴畫面",
59
60
  "failing in a loop?": "卡在失敗迴圈?",
@@ -64,6 +65,7 @@
64
65
  "Files touched": "異動的檔案",
65
66
  "Final report": "最終報告",
66
67
  "first error": "首次錯誤",
68
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "法文由系統語音朗讀:安裝“增強”或“高品質”語音會好很多。",
67
69
  "Hide code & file names in videos": "在影片中隱藏程式碼與檔名",
68
70
  "Highlights": "精華",
69
71
  "Human": "真人",
@@ -71,6 +73,7 @@
71
73
  "input": "輸入",
72
74
  "is sharing their screen": "正在分享畫面",
73
75
  "Join the call": "加入視訊通話",
76
+ "Keep my agents’ own names": "保留我的代理自己的名字",
74
77
  "Language": "語言",
75
78
  "Lead": "主代理",
76
79
  "Lead context": "主代理上下文",
@@ -78,6 +81,7 @@
78
81
  "lines": "行",
79
82
  "Listing ": "列出 ",
80
83
  "Little lines & animations": "小對白與動畫",
84
+ "Live": "即時",
81
85
  "longest wait ": "最長等待 ",
82
86
  "Looking for a tool": "正在找工具",
83
87
  "looping?": "鬼打牆?",
@@ -112,6 +116,8 @@
112
116
  "Recent activity": "最近活動",
113
117
  "Remove finished agents (2 min)": "移除已完成的代理(2 分鐘)",
114
118
  "Repeated failures": "重複失敗",
119
+ "Replay": "重播",
120
+ "REPLAY": "重播",
115
121
  "report": "報告",
116
122
  "report to": "回報給",
117
123
  "Role": "角色",
@@ -170,7 +176,7 @@
170
176
  "Your Claude Code agents as a live team call": "把你的 Claude Code 代理變成即時團隊通話",
171
177
  "your turn": "換你了",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "下載中,你可以繼續使用:這段時間會先用系統語音。",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質不錯的本機語音,以英文為主(法文只有一種聲音)。免費,資料不會離開你的電腦。",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "品質不錯的本機語音,僅英文(法文使用系統語音)。免費,資料不會離開你的電腦。",
174
180
  "Installing…": "安裝中…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "正在載入模型(第一次使用時下載,約 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "找不到 npm:請安裝 Node.js 才能啟用此選項。",
package/server.js CHANGED
@@ -7,9 +7,11 @@ 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
- const { execFileSync } = require('child_process');
14
+ const { execFileSync, execFile } = require('child_process');
13
15
 
14
16
  const PORT = process.env.PORT || 4747;
15
17
  // Same place Claude Code writes to: honours CLAUDE_CONFIG_DIR (custom config dirs are common on Linux); AGENT_STANDUP_PROJECTS overrides it.
@@ -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) {
@@ -336,7 +340,7 @@ function parseRecord(st, r) {
336
340
  else if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { said = push({ ts, agent, kind: 'say', text: b.text.trim(), ...th() }); st.act = st.raw.length - 1; st.sayOf = m.id; st.sayAt = st.act; }
337
341
  else if (b.type === 'tool_use') {
338
342
  st.toolName[b.id] = b.name; if (b.input && b.input.run_in_background) (st.bg = st.bg || {})[b.id] = true;
339
- if (b.name === 'Agent' || b.name === 'Task') push({ ts, agent, kind: 'agentcall', toolId: b.id, bg: !!(b.input && b.input.run_in_background), text: (b.input && b.input.prompt) || '', title: (b.input && b.input.description) || '' });
343
+ if (b.name === 'Agent' || b.name === 'Task') push({ ts, agent, kind: 'agentcall', toolId: b.id, bg: !!(b.input && b.input.run_in_background), text: (b.input && b.input.prompt) || '', title: (b.input && b.input.description) || '', ...(b.input && typeof b.input.name === 'string' && b.input.name.trim() ? { nm: b.input.name.trim() } : {}) });
340
344
  else { push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts), ...th() }); st.act = st.raw.length - 1; }
341
345
  }
342
346
  }
@@ -346,27 +350,41 @@ 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);
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").
356
+ // The types Claude Code ships are not names: those agents get a first name from the page, as before.
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;
358
+ function ownName(s, type) {
359
+ const raw = String(s || '').split(':').pop().trim(); if (!raw || (type && STOCK_AGENT.test(raw))) return ''; // a plugin's agent is written plugin:agent
360
+ let n = raw.replace(/[_\-]+/g, ' ').replace(/[^\p{L}\p{N} .'’&+]/gu, ' ').replace(/\s+/g, ' ').trim(); if (!/[\p{L}\p{N}]/u.test(n)) return '';
361
+ if (n === n.toLowerCase()) n = n.replace(/(^|\s)\p{Ll}/gu, (m) => m.toUpperCase());
362
+ return n.length > 26 ? n.slice(0, 25).trimEnd() + '…' : n;
363
+ }
350
364
  const metaCache = new Map(); // meta file path → { mtime, value }
351
365
  function readMeta(f) {
352
366
  try { const mt = fs.statSync(f).mtimeMs, c = metaCache.get(f); if (c && c.mtime === mt) return c.value; const v = JSON.parse(fs.readFileSync(f, 'utf8')); if (metaCache.size > 3000) metaCache.clear(); metaCache.set(f, { mtime: mt, value: v }); return v; } catch { return {}; }
353
367
  }
354
368
 
355
369
  function buildTimeline(proj, id, files = []) {
356
- 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)
357
371
  if (!fs.existsSync(mainFile)) throw new Error('session not found');
358
- const agents = { main: { id: 'main', role: 'Lead', type: 'main', ...(cx ? { name: 'Codex' } : {}) } };
359
- 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 }
360
374
  for (const x of subs) {
361
- 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'));
362
376
  agents[aid] = { id: aid, role: m.description || m.workflowPhase || m.agentType || 'Agent', type: m.agentType || 'agent', toolUseId: m.toolUseId };
363
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
364
- agents[aid].mtime = mtimeOf(x.file) || undefined;
365
- 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);
366
380
  }
367
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]); }
368
382
  const childByTool = {}; // Agent tool_use id → the sub-agent it created, so we know who ordered whom
369
383
  for (const a of Object.values(agents)) if (a.toolUseId) childByTool[a.toolUseId] = a.id;
384
+ const called = {}, born = {}; for (const st of states) if (st) { if (st.raw.length) born[st.agent] = st.raw[0].ts; for (const e of st.raw) if (e.kind === 'agentcall' && e.nm && childByTool[e.toolId]) called[childByTool[e.toolId]] = e.nm; }
385
+ const sameName = {}; for (const a of Object.values(agents).filter((x) => x.id !== 'main').sort((x, y) => (born[x.id] || Infinity) - (born[y.id] || Infinity) || (x.id < y.id ? -1 : 1))) { // in the order they started, so that a second agent of the same name is the one numbered 2 and stays so
386
+ const n = ownName(called[a.id]) || ownName(a.type, true); if (n) { const k = n.toLowerCase(); sameName[k] = (sameName[k] || 0) + 1; a.own = sameName[k] > 1 ? n + ' ' + sameName[k] : n; }
387
+ }
370
388
  const runByTool = {}; for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'wfrun' && runs[e.run] && !runs[e.run].by) { runs[e.run].by = e.agent; runs[e.run].toolId = e.toolId; runByTool[e.toolId] = runs[e.run]; } // Workflow tool_use id → the run it started
371
389
 
372
390
  const finished = new Set(), now0 = Date.now(); for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'agentdone') { if (childByTool[e.toolId]) finished.add(childByTool[e.toolId]); else if (runByTool[e.toolId]) for (const a of Object.values(runByTool[e.toolId].agents)) finished.add(a); } // a run that stopped: so did every agent in it
@@ -520,25 +538,25 @@ const quips = (texts, lang) => claudeMap('q' + lang, `Each string is a work repo
520
538
 
521
539
  // ---- text-to-speech, generated on the machine and cached as wav:
522
540
  // macOS → `say`, Windows → System.Speech (SAPI voices) through PowerShell, Linux → espeak-ng.
523
- let sayVoices = null;
541
+ let sayVoices = null, sayVoicesJob = null;
524
542
  const WIN = process.platform === 'win32', MAC = process.platform === 'darwin';
525
543
  const PS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command'];
544
+ const VOICES_CMD = MAC ? ['say', ['-v', '?'], 5000] : WIN ? ['powershell.exe', [...PS, "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name + '|' + $_.VoiceInfo.Culture.Name }"], 20000] : ['espeak-ng', ['--voices'], 5000];
545
+ function parseVoices(out) {
546
+ const voices = [];
547
+ if (MAC) { for (const line of out.split('\n')) { const m = line.match(/^(.+?)\s{2,}([a-z]{2}_[A-Z]{2})\s+#/); if (m) voices.push({ name: m[1].trim(), locale: m[2] }); } }
548
+ else if (WIN) { for (const line of out.split(/\r?\n/)) { const [name, loc] = line.trim().split('|'); if (name && loc) voices.push({ name, locale: loc.replace('-', '_') }); } }
549
+ else for (const line of out.split('\n').slice(1)) { const c = line.trim().split(/\s+/); if (c[1] && /^(fr|en)/.test(c[1])) voices.push({ name: c[1], locale: c[1].replace('-', '_').replace(/^(\w{2})$/, (m) => m + '_' + m.toUpperCase()) }); }
550
+ return { available: voices.length > 0, voices, platform: process.platform };
551
+ }
526
552
  function listVoices() {
527
553
  if (sayVoices) return sayVoices;
528
- const voices = [];
529
- try {
530
- if (MAC) {
531
- const out = execFileSync('say', ['-v', '?'], { encoding: 'utf8', timeout: 5000 });
532
- for (const line of out.split('\n')) { const m = line.match(/^(.+?)\s{2,}([a-z]{2}_[A-Z]{2})\s+#/); if (m) voices.push({ name: m[1].trim(), locale: m[2] }); }
533
- } else if (WIN) {
534
- const out = execFileSync('powershell.exe', [...PS, "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name + '|' + $_.VoiceInfo.Culture.Name }"], { encoding: 'utf8', timeout: 20000, windowsHide: true });
535
- for (const line of out.split(/\r?\n/)) { const [name, loc] = line.trim().split('|'); if (name && loc) voices.push({ name, locale: loc.replace('-', '_') }); }
536
- } else {
537
- const out = execFileSync('espeak-ng', ['--voices'], { encoding: 'utf8', timeout: 5000 });
538
- for (const line of out.split('\n').slice(1)) { const c = line.trim().split(/\s+/); if (c[1] && /^(fr|en)/.test(c[1])) voices.push({ name: c[1], locale: c[1].replace('-', '_').replace(/^(\w{2})$/, (m) => m + '_' + m.toUpperCase()) }); }
539
- }
540
- } catch {}
541
- return (sayVoices = { available: voices.length > 0, voices, platform: process.platform });
554
+ let out = ''; try { out = execFileSync(VOICES_CMD[0], VOICES_CMD[1], { encoding: 'utf8', timeout: VOICES_CMD[2], windowsHide: true }); } catch {}
555
+ return (sayVoices = parseVoices(out));
556
+ }
557
+ function loadVoices() { // the same list for the page, asked without waiting: the first answer of the system takes a second or more, and the session the page asks for at the same moment must not wait behind it
558
+ if (sayVoices) return Promise.resolve(sayVoices);
559
+ return sayVoicesJob || (sayVoicesJob = new Promise((resolve) => { try { execFile(VOICES_CMD[0], VOICES_CMD[1], { encoding: 'utf8', timeout: VOICES_CMD[2], windowsHide: true }, (err, out) => resolve(sayVoices || (sayVoices = parseVoices(err ? '' : String(out))))); } catch { resolve(sayVoices || (sayVoices = parseVoices(''))); } }));
542
560
  }
543
561
  const ttsDir = path.join(os.tmpdir(), 'agent-standup-tts-' + (process.getuid ? process.getuid() : 'user'));
544
562
  try { fs.mkdirSync(ttsDir, { recursive: true, mode: 0o700 }); } catch {}
@@ -556,7 +574,7 @@ function synthesize(voice, wpm, text, out) {
556
574
  p.on('error', reject); p.on('close', (code) => (code === 0 && fs.existsSync(out) ? resolve() : reject(new Error('tts failed'))));
557
575
  });
558
576
  }
559
- function tts(text, voice, rate, engine) {
577
+ function tts(text, voice, rate, engine, fb) {
560
578
  const kk = engine === 'kokoro';
561
579
  if (kk) { if (!kokoro.hasVoice(voice)) return Promise.reject(new Error('unknown voice')); }
562
580
  else { const vs = listVoices(); if (!vs.available || !vs.voices.some((v) => v.name === voice)) return Promise.reject(new Error('unknown voice')); }
@@ -566,7 +584,7 @@ function tts(text, voice, rate, engine) {
566
584
  if (ttsJobs.has(key)) return ttsJobs.get(key);
567
585
  fs.mkdirSync(ttsDir, { recursive: true });
568
586
  const tmp = file + '.part.wav';
569
- const job = (kk ? kokoro.synth(text, voice, kokoro.speedFor(rate), tmp).catch((e) => { const vs = listVoices(), v = vs.voices.find((x) => x.locale.startsWith((voice.startsWith('ff_') ? 'fr' : 'en') + '_')); if (!v) throw e; return synthesize(v.name, rate, text, tmp); }) : synthesize(voice, rate, text, tmp)) // a line Kokoro cannot make goes to a system voice instead of being dropped
587
+ const job = (kk ? kokoro.synth(text, voice, kokoro.speedFor(rate), tmp).catch((e) => { const vs = listVoices(), v = vs.voices.find((x) => x.name === fb) || vs.voices.find((x) => x.locale.startsWith('en_')); if (!v) throw e; return synthesize(v.name, rate, text, tmp); }) : synthesize(voice, rate, text, tmp)) // a line Kokoro cannot make goes to a system voice instead of being dropped: the one the page names (fb, its best system voice for this character), or else the first English one
570
588
  .then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
571
589
  ttsJobs.set(key, job); return job;
572
590
  }
@@ -579,7 +597,7 @@ const CHAR_NAME = /^[\w][\w .()\-]{0,78}\.(png|gif|webp|jpe?g)$/i, CHAR_MAX = 30
579
597
  const charactersDir = () => process.env.AGENT_STANDUP_CHARACTERS || path.join(os.homedir(), '.agent-standup', 'characters');
580
598
  // lstat, not stat: a symlink is not a regular file, so it is skipped and never served
581
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
582
- 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)
583
601
  function listCharacters(pack) {
584
602
  const dir = pack ? packDir(pack) : charactersDir(); let names = []; if (!dir) return { dir: '', max: CHAR_MAX, chars: [] }; try { names = fs.readdirSync(dir); } catch {}
585
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);
@@ -628,12 +646,12 @@ function start(port = PORT) {
628
646
  if (url.pathname === '/api/warm') { trWorkers.forEach((w) => { if (!w.p) w.start(); }); return json({ ok: true }); }
629
647
  if (url.pathname === '/api/voices') {
630
648
  if (url.searchParams.get('engine') === 'kokoro') { const st = kokoro.status(); if (st.installed && !st.gaveUp) kokoro.warm(); return json({ available: st.installed && !st.gaveUp, ready: st.ready, voices: kokoro.voices(), platform: process.platform, engine: 'kokoro' }); }
631
- return json(listVoices());
649
+ return loadVoices().then((v) => json(v));
632
650
  }
633
651
  if (url.pathname === '/api/kokoro/install' && req.method === 'POST') return json(kokoro.install());
634
652
  if (url.pathname === '/api/kokoro') return json(kokoro.status());
635
653
  if (url.pathname === '/api/tts') {
636
- return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'))
654
+ return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'), url.searchParams.get('fb'))
637
655
  .then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); sendFile(f); })
638
656
  .catch((e) => json({ error: String(e.message) }, 400));
639
657
  }
@@ -653,7 +671,7 @@ function start(port = PORT) {
653
671
  if (url.pathname === '/api/session') {
654
672
  const proj = url.searchParams.get('proj') || '', sid = url.searchParams.get('id') || '', SAFE = /^[\w.\-]+$/;
655
673
  if (!SAFE.test(proj) || !SAFE.test(sid) || proj === '..' || sid === '..' || proj.length > 200 || sid.length > 200) { res.writeHead(400); return res.end('bad request'); }
656
- 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);
657
675
  const entry = proj === '_demo' ? demoEntry() : timelineEntry(proj, sid), tl = entry.tl; // built-in fictional session
658
676
  const after = url.searchParams.get('after'), ep = url.searchParams.get('ep');
659
677
  if (after == null) { // the whole timeline: serialize once per version, send gzip-compressed when the browser allows it (12 MB → ~1.5 MB)