@double_geste/agent-standup 1.28.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/agent-standup.js +4 -2
- package/codex.js +2 -2
- package/kokoro.js +2 -2
- package/package.json +4 -3
- package/public/index.html +48 -30
- package/public/locales/da.json +7 -1
- package/public/locales/de.json +7 -1
- package/public/locales/en.json +7 -1
- package/public/locales/es.json +7 -1
- package/public/locales/ja.json +7 -1
- package/public/locales/ko.json +7 -1
- package/public/locales/pt-BR.json +7 -1
- package/public/locales/zh-CN.json +7 -1
- package/public/locales/zh-TW.json +7 -1
- package/server.js +63 -45
- package/vibe.js +322 -0
package/public/locales/ko.json
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
109
|
+
for (const m of SOURCES) for (const r of guard(() => m.sessions(30), [])) rows.push({ proj: m.PROJ, id: r.id, mtime: r.mtime, size: r.size, agents: guard(() => (m.subCount ? m.subCount(r.id) : m.subFiles ? m.subFiles(r.id).length : 0), 0), cx: r, src: m });
|
|
107
110
|
for (const proj of fs.existsSync(ROOT) ? fs.readdirSync(ROOT) : []) {
|
|
108
111
|
const dir = path.join(ROOT, proj);
|
|
109
112
|
let files;
|
|
@@ -115,9 +118,9 @@ function listSessions0() {
|
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
120
|
rows.sort((a, b) => b.mtime - a.mtime);
|
|
118
|
-
const top = rows.filter((r) => !r.cx).slice(0, 60).concat(rows.filter((r) => r.cx)).sort((a, b) => b.mtime - a.mtime); // the 60 most recent Claude Code sessions as always, and up to 30
|
|
121
|
+
const top = rows.filter((r) => !r.cx).slice(0, 60).concat(rows.filter((r) => r.cx)).sort((a, b) => b.mtime - a.mtime); // the 60 most recent Claude Code sessions as always, and up to 30 of each other tool beside them: none pushes another out
|
|
119
122
|
for (const r of top) {
|
|
120
|
-
if (r.cx) { const c = r.cx; delete r.cx; r.title =
|
|
123
|
+
if (r.cx) { const c = r.cx, m = r.src; delete r.cx; delete r.src; r.title = guard(() => m.title(c), '') || r.id; r.project = guard(() => m.projectLabel(c.cwd), '') || m.SOURCE; r.source = m.SOURCE; continue; }
|
|
121
124
|
// titles come from the cached tail read (custom title, then AI title, then first prompt): no whole-file reads
|
|
122
125
|
let t = null; try { const f = path.join(ROOT, r.proj, r.id + '.jsonl'); t = parseTail(f, fs.statSync(f)).title; } catch {}
|
|
123
126
|
r.title = t || r.id; r.project = r.proj.replace(/^-Users-[^-]+-/, '').replace(/-/g, '/');
|
|
@@ -128,8 +131,8 @@ function listSessions0() {
|
|
|
128
131
|
function stamp(proj, id) {
|
|
129
132
|
const base = path.join(ROOT, proj); let n = 0;
|
|
130
133
|
const add = (f) => { try { const s = fs.statSync(f); n += s.mtimeMs + s.size; } catch {} };
|
|
131
|
-
|
|
132
|
-
const subs =
|
|
134
|
+
const sx = sourceOf(proj); add(sx ? guard(() => sx.fileOf(id), '') || '' : path.join(base, id + '.jsonl')); if (sx && sx.watch) for (const f of guard(() => sx.watch(id), [])) add(f);
|
|
135
|
+
const subs = subFilesOf(proj, id); // the transcripts and the folders they are in (a folder changes when a file appears in it, e.g. an agent's meta file), not every file around them
|
|
133
136
|
for (const x of subs) add(x.file); for (const x of subs.journals) add(x.file); for (const d of subs.dirs) n += mtimeOf(d);
|
|
134
137
|
return n;
|
|
135
138
|
}
|
|
@@ -213,14 +216,14 @@ function boardRows() {
|
|
|
213
216
|
}
|
|
214
217
|
function boardRows0() {
|
|
215
218
|
const now = Date.now(), rows = [];
|
|
216
|
-
for (const r of
|
|
219
|
+
for (const m of SOURCES) for (const r of guard(() => m.sessions(), [])) if (now - r.mtime < 12 * 3600e3) rows.push({ proj: m.PROJ, id: r.id, st: { mtimeMs: r.mtime, size: r.size }, cx: r, src: m });
|
|
217
220
|
for (const proj of fs.existsSync(ROOT) ? fs.readdirSync(ROOT) : []) {
|
|
218
221
|
const dir = path.join(ROOT, proj); let files; try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
|
|
219
222
|
for (const f of files) { try { const st = fs.statSync(path.join(dir, f)); if (now - st.mtimeMs < 12 * 3600e3) rows.push({ proj, id: f.slice(0, -6), st }); } catch {} }
|
|
220
223
|
}
|
|
221
224
|
rows.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
|
|
222
|
-
return rows.filter((r) => !r.cx).slice(0, 40).concat(rows.filter((r) => r.
|
|
223
|
-
if (cx) return
|
|
225
|
+
return rows.filter((r) => !r.cx).slice(0, 40).concat(...SOURCES.map((m) => rows.filter((r) => r.src === m).slice(0, 20))).sort((a, b) => b.st.mtimeMs - a.st.mtimeMs).map(({ proj, id, st, cx, src }) => {
|
|
226
|
+
if (cx) return guard(() => src.boardRow(cx, now), null);
|
|
224
227
|
const file = path.join(ROOT, proj, id + '.jsonl'); let p; try { p = parseTail(file, st); } catch { p = { pending: [] }; }
|
|
225
228
|
const age = now - st.mtimeMs, lp = (p.pending || [])[(p.pending || []).length - 1]; let state = 'idle', tool = '', ageMs = age;
|
|
226
229
|
if (age < 30 * 60e3) {
|
|
@@ -232,7 +235,7 @@ function boardRows0() {
|
|
|
232
235
|
let subActive = 0, subTotal = 0;
|
|
233
236
|
for (const x of subFiles(proj, id)) { subTotal++; if (now - mtimeOf(x.file) < 90e3) subActive++; }
|
|
234
237
|
return { proj, id, title: p.title || id, mtime: st.mtimeMs, state, tool, ageMs, subActive, subTotal };
|
|
235
|
-
});
|
|
238
|
+
}).filter(Boolean);
|
|
236
239
|
}
|
|
237
240
|
// Settings live in a file, not only in the page's localStorage: the port changes between VS Code windows and reloads, and localStorage is per origin (port included).
|
|
238
241
|
const PREFS = process.env.AGENT_STANDUP_PREFS || (process.platform === 'win32' ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'agent-standup', 'prefs.json') : path.join(os.homedir(), '.config', 'agent-standup', 'prefs.json'));
|
|
@@ -241,17 +244,17 @@ function writePrefs(v) { fs.mkdirSync(path.dirname(PREFS), { recursive: true });
|
|
|
241
244
|
// Most recently active session (optionally limited to one project dir) — drives the live feed.
|
|
242
245
|
const STICK_MS = 90000; // a session written to in the last 90 s keeps the call, so another session writing elsewhere does not steal it mid-line
|
|
243
246
|
function sessionMtime(proj, id) {
|
|
244
|
-
|
|
245
|
-
let m = mtimeOf(path.join(ROOT, proj, id + '.jsonl')); if (!m) return 0; const subs =
|
|
247
|
+
const sx = sourceOf(proj), r = sx ? guard(() => sx.row(id), null) : null; if (sx && (!r || !sx.subFiles)) return r ? r.mtime : 0;
|
|
248
|
+
let m = sx ? r.mtime : mtimeOf(path.join(ROOT, proj, id + '.jsonl')); if (!m) return 0; const subs = subFilesOf(proj, id);
|
|
246
249
|
for (const x of subs) m = Math.max(m, mtimeOf(x.file)); for (const d of subs.dirs) m = Math.max(m, mtimeOf(d)); return m; // the folders too: a new file in them is activity, as when every file around the transcripts was looked at
|
|
247
250
|
}
|
|
248
251
|
function currentSession(projFilter, idFilter, stick) {
|
|
249
252
|
const safe = (x) => typeof x === 'string' && /^[\w.\-]+$/.test(x) && x !== '..';
|
|
250
|
-
if (stick && safe(stick.id) && safe(stick.proj) && (!projFilter || stick.proj === projFilter || stick.proj.startsWith(projFilter + '-') || (stick.proj
|
|
251
|
-
if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && (projFilter
|
|
253
|
+
if (stick && safe(stick.id) && safe(stick.proj) && (!projFilter || stick.proj === projFilter || stick.proj.startsWith(projFilter + '-') || (sourceOf(stick.proj) && guard(() => sourceOf(stick.proj).inProject(stick.id, projFilter), false))) && Date.now() - sessionMtime(stick.proj, stick.id) < STICK_MS) return { proj: stick.proj, id: stick.id, mtime: sessionMtime(stick.proj, stick.id) };
|
|
254
|
+
if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && (sourceOf(projFilter) ? !!guard(() => sourceOf(projFilter).fileOf(idFilter), null) : fs.existsSync(path.join(ROOT, projFilter, idFilter + '.jsonl')))) return { proj: projFilter, id: idFilter, mtime: Date.now() }; // pinned from the board
|
|
252
255
|
let best = null;
|
|
253
256
|
const consider = (proj, id, m) => { if (!best || m > best.mtime) best = { proj, id, mtime: m }; };
|
|
254
|
-
for (const r of
|
|
257
|
+
for (const m of SOURCES) for (const r of guard(() => m.sessions(), [])) if (!projFilter || guard(() => m.inProject(r.id, projFilter), false)) consider(m.PROJ, r.id, r.mtime); // a Codex or Vibe session counts for a project when it was started in that folder
|
|
255
258
|
for (const proj of fs.existsSync(ROOT) ? fs.readdirSync(ROOT) : []) {
|
|
256
259
|
if (projFilter && proj !== projFilter && !proj.startsWith(projFilter + '-')) continue; // the folder itself, or a folder inside it
|
|
257
260
|
const dir = path.join(ROOT, proj); let files;
|
|
@@ -276,7 +279,7 @@ function readNew(file, agent, journal, record = parseRecord) {
|
|
|
276
279
|
let size, mtime; try { const s = fs.statSync(file); size = s.size; mtime = s.mtimeMs; } catch { fileStates.delete(file); return null; }
|
|
277
280
|
let st = fileStates.get(file);
|
|
278
281
|
if (st && size >= st.offset && mtime !== st.mtime && st.fp.length && !sameBytes(file, st)) st = null; // rewritten in place and not shorter: checked only when the file was written to, with one small read
|
|
279
|
-
if (!st || size < st.offset) { st = { agent, journal: !!journal, gen: ++fileGen, offset: 0, mtime: 0, fp: Buffer.alloc(0), tail: Buffer.alloc(0), raw: [], toolName: {}, msgUsage: new Map() }; fileStates.set(file, st); }
|
|
282
|
+
if (!st || size < st.offset) { st = { agent, file, journal: !!journal, gen: ++fileGen, offset: 0, mtime: 0, fp: Buffer.alloc(0), tail: Buffer.alloc(0), raw: [], toolName: {}, msgUsage: new Map() }; fileStates.set(file, st); }
|
|
280
283
|
st.mtime = mtime;
|
|
281
284
|
while (size > st.offset) { // read in chunks of at most 16 MB so a huge transcript never needs one giant buffer
|
|
282
285
|
const fd = fs.openSync(file, 'r'); let buf;
|
|
@@ -292,6 +295,7 @@ function readNew(file, agent, journal, record = parseRecord) {
|
|
|
292
295
|
try { record(st, r); } catch {} // one odd record must not take the whole timeline down
|
|
293
296
|
}
|
|
294
297
|
}
|
|
298
|
+
if (record.after) { try { record.after(st); } catch {} } // a format without a time on each line (Mistral Vibe) dates what was just read, once it has all of it
|
|
295
299
|
return st;
|
|
296
300
|
}
|
|
297
301
|
function parseRecord(st, r) {
|
|
@@ -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
|
|
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'
|
|
359
|
-
const states = [readNew(mainFile, 'main', false,
|
|
372
|
+
const agents = { main: { id: 'main', role: 'Lead', type: 'main' } }; // the lead has the same name whatever tool runs the session: the page gives it
|
|
373
|
+
const states = [(sx && sx.state && guard(() => sx.state(id), null)) || readNew(mainFile, 'main', false, rec)], subs = subFilesOf(proj, id), runs = {}; files.push(mainFile); // sx.state: a format that is not read line by line hands over the events of the session whole // runs: workflow run → { agents (file name → agent id), by (who launched it), toolId, done }
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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)
|