@double_geste/agent-standup 0.0.0-stage → 1.22.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/LICENSE +9 -0
- package/README.md +17 -2
- package/bin/agent-standup.js +60 -0
- package/demo.js +95 -0
- package/media/icon.png +0 -0
- package/package.json +42 -4
- package/public/index.html +1687 -0
- package/public/locales/README.md +26 -0
- package/public/locales/de.json +169 -0
- package/public/locales/en.json +164 -0
- package/public/locales/es.json +169 -0
- package/public/locales/ja.json +169 -0
- package/public/locales/pt-BR.json +165 -0
- package/public/locales/zh-CN.json +169 -0
- package/public/locales/zh-TW.json +169 -0
- package/server.js +542 -0
package/server.js
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Agent Standup: turns Claude Code session transcripts into a replayable "team call" timeline.
|
|
3
|
+
// No dependencies. Run: node server.js → http://localhost:4747
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const zlib = require('zlib');
|
|
10
|
+
const { execFileSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
const PORT = process.env.PORT || 4747;
|
|
13
|
+
// Same place Claude Code writes to: honours CLAUDE_CONFIG_DIR (custom config dirs are common on Linux); AGENT_STANDUP_PROJECTS overrides it.
|
|
14
|
+
const ROOT = process.env.AGENT_STANDUP_PROJECTS || path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects');
|
|
15
|
+
const PUBLIC = path.join(__dirname, 'public');
|
|
16
|
+
|
|
17
|
+
const blocks = (rec) => {
|
|
18
|
+
const c = rec.message && rec.message.content;
|
|
19
|
+
if (typeof c === 'string') return [{ type: 'text', text: c }];
|
|
20
|
+
return Array.isArray(c) ? c : [];
|
|
21
|
+
};
|
|
22
|
+
const clip = (x, n = 700) => String(x == null ? '' : x).slice(0, n);
|
|
23
|
+
// What each tool call looks like on a screen ("d"), plus its id/timestamp so the result can be matched later.
|
|
24
|
+
const toolLabel = (b, ts) => {
|
|
25
|
+
const i = b.input || {};
|
|
26
|
+
const q0 = Array.isArray(i.questions) && i.questions[0] ? i.questions[0] : null, mcp = /^mcp__/.test(b.name) ? b.name.split('__') : null, firstStr = mcp ? Object.values(i).find((v) => typeof v === 'string') : '';
|
|
27
|
+
const arg = i.file_path || i.notebook_path || i.path || i.pattern || i.command || i.url || i.query || i.skill || (q0 && q0.question) || i.description || firstStr || '';
|
|
28
|
+
const out = { id: b.id, ts, name: b.name, arg: String(arg).slice(0, 90) };
|
|
29
|
+
switch (b.name) {
|
|
30
|
+
case 'Bash': out.d = { kind: 'cmd', text: clip(i.command, 1200), note: clip(i.description, 140) }; break;
|
|
31
|
+
case 'Read': out.d = { kind: 'read', file: i.file_path, offset: i.offset, limit: i.limit }; break;
|
|
32
|
+
case 'Edit': case 'MultiEdit': { const e = i.edits ? i.edits[0] || {} : i; out.d = { kind: 'edit', file: i.file_path, old: clip(e.old_string, 500), new: clip(e.new_string, 500) }; break; }
|
|
33
|
+
case 'Write': out.d = { kind: 'write', file: i.file_path, text: clip(i.content, 800) }; break;
|
|
34
|
+
case 'Grep': out.d = { kind: 'grep', pattern: clip(i.pattern, 100), path: i.path, glob: i.glob }; break;
|
|
35
|
+
case 'Glob': out.d = { kind: 'glob', pattern: clip(i.pattern, 100), path: i.path }; break;
|
|
36
|
+
case 'TodoWrite': out.d = { kind: 'todo', items: (i.todos || []).slice(0, 20).map((x) => ({ c: clip(x.content, 120), s: x.status })) }; break;
|
|
37
|
+
case 'WebFetch': out.d = { kind: 'web', url: i.url }; break;
|
|
38
|
+
case 'WebSearch': out.d = { kind: 'web', query: i.query }; break;
|
|
39
|
+
case 'NotebookEdit': out.d = { kind: 'edit', file: i.notebook_path, old: '', new: clip(i.new_source, 500) }; break;
|
|
40
|
+
case 'AskUserQuestion': out.d = { kind: 'text', what: 'question', text: clip((q0 ? q0.question + '\n' + (q0.options || []).map((o) => '• ' + (o.label || '')).join('\n') : ''), 500) }; break;
|
|
41
|
+
case 'Skill': out.d = { kind: 'text', what: 'skill', v: i.skill, text: clip(i.skill + (i.args ? ' ' + i.args : ''), 200) }; break;
|
|
42
|
+
case 'ToolSearch': out.d = { kind: 'text', what: 'toolsearch', text: clip(i.query, 200) }; break;
|
|
43
|
+
default: out.d = mcp ? { kind: 'text', what: 'mcp', v: (mcp[1] || '') + ' · ' + (mcp.slice(2).join('__') || ''), text: clip(JSON.stringify(i), 400) } : { kind: 'text', text: clip(JSON.stringify(i), 300) };
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x.text || '').join('\n') : '');
|
|
48
|
+
const RESULT_MAX = { AskUserQuestion: 900, Skill: 300, ToolSearch: 500, Read: 1900, Bash: 1300, Grep: 900, Glob: 800, WebFetch: 800, WebSearch: 800, Edit: 200, MultiEdit: 200, Write: 200 };
|
|
49
|
+
const NO_RESULT = new Set(['Agent', 'Task', 'SendMessage', 'TodoWrite']); // internal bookkeeping, nothing to show
|
|
50
|
+
|
|
51
|
+
let sessionsMemo = { at: 0, rows: [] };
|
|
52
|
+
function listSessions() {
|
|
53
|
+
if (Date.now() - sessionsMemo.at < 2000) return sessionsMemo.rows;
|
|
54
|
+
const out = listSessions0(); sessionsMemo = { at: Date.now(), rows: out }; return out;
|
|
55
|
+
}
|
|
56
|
+
function listSessions0() {
|
|
57
|
+
const rows = [];
|
|
58
|
+
if (!fs.existsSync(ROOT)) return rows;
|
|
59
|
+
for (const proj of fs.readdirSync(ROOT)) {
|
|
60
|
+
const dir = path.join(ROOT, proj);
|
|
61
|
+
let files;
|
|
62
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
|
|
63
|
+
for (const f of files) {
|
|
64
|
+
const id = f.replace(/\.jsonl$/, '');
|
|
65
|
+
const st = fs.statSync(path.join(dir, f));
|
|
66
|
+
const subDir = path.join(dir, id, 'subagents');
|
|
67
|
+
const agents = fs.existsSync(subDir) ? fs.readdirSync(subDir).filter((x) => x.endsWith('.jsonl')).length : 0;
|
|
68
|
+
rows.push({ proj, id, mtime: st.mtimeMs, size: st.size, agents });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
rows.sort((a, b) => b.mtime - a.mtime);
|
|
72
|
+
const top = rows.slice(0, 60);
|
|
73
|
+
for (const r of top) { // titles come from the cached tail read (custom title, then AI title, then first prompt): no whole-file reads
|
|
74
|
+
let t = null; try { const f = path.join(ROOT, r.proj, r.id + '.jsonl'); t = parseTail(f, fs.statSync(f)).title; } catch {}
|
|
75
|
+
r.title = t || r.id; r.project = r.proj.replace(/^-Users-[^-]+-/, '').replace(/-/g, '/');
|
|
76
|
+
}
|
|
77
|
+
return top;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stamp(proj, id) {
|
|
81
|
+
const base = path.join(ROOT, proj); let n = 0;
|
|
82
|
+
const add = (f) => { try { const s = fs.statSync(f); n += s.mtimeMs + s.size; } catch {} };
|
|
83
|
+
add(path.join(base, id + '.jsonl'));
|
|
84
|
+
const sd = path.join(base, id, 'subagents');
|
|
85
|
+
if (fs.existsSync(sd)) for (const f of fs.readdirSync(sd)) add(path.join(sd, f));
|
|
86
|
+
return n;
|
|
87
|
+
}
|
|
88
|
+
const tlCache = new Map(); // "proj/id" → { st (file stamp), tl, at, ms, files[], json?, gz? }
|
|
89
|
+
const MAX_CACHED_SESSIONS = 3;
|
|
90
|
+
function timelineEntry(proj, id) {
|
|
91
|
+
const key = proj + '/' + id, st = stamp(proj, id), hit = tlCache.get(key), now = Date.now();
|
|
92
|
+
if (hit && hit.st === st && !(hit.recentUntil && now > hit.recentUntil)) return hit; // a sub-agent's 4-minute silence rule can flip without any file change
|
|
93
|
+
if (hit && now - hit.at < Math.max(250, hit.ms * 2)) return hit; // very big sessions: don't rebuild on every poll
|
|
94
|
+
const t0 = Date.now(), tl = buildTimeline(proj, id), base = path.join(ROOT, proj), sd = path.join(base, id, 'subagents');
|
|
95
|
+
const files = [path.join(base, id + '.jsonl')]; try { for (const f of fs.readdirSync(sd)) files.push(path.join(sd, f)); } catch {}
|
|
96
|
+
const entry = { st, tl, at: Date.now(), ms: Date.now() - t0, files, recentUntil: tl.recentUntil || 0 };
|
|
97
|
+
tlCache.delete(key); tlCache.set(key, entry);
|
|
98
|
+
while (tlCache.size > MAX_CACHED_SESSIONS) { // free the memory of sessions nobody is looking at any more
|
|
99
|
+
const [oldKey, old] = tlCache.entries().next().value; tlCache.delete(oldKey);
|
|
100
|
+
for (const f of old.files) fileStates.delete(f);
|
|
101
|
+
}
|
|
102
|
+
return entry;
|
|
103
|
+
}
|
|
104
|
+
const timeline = (proj, id) => timelineEntry(proj, id).tl;
|
|
105
|
+
|
|
106
|
+
// ---- Board: one cheap row per recent session (state from the tail of its transcript), so many parallel sessions can be watched at once.
|
|
107
|
+
const readTail = (file, bytes) => { const fd = fs.openSync(file, 'r'); try { const sz = fs.fstatSync(fd).size, n = Math.min(bytes, sz), buf = Buffer.alloc(n); fs.readSync(fd, buf, 0, n, sz - n); return { text: buf.toString('utf8'), cut: sz > bytes }; } finally { fs.closeSync(fd); } };
|
|
108
|
+
const readHead = (file, bytes) => { const fd = fs.openSync(file, 'r'); try { const n = Math.min(bytes, fs.fstatSync(fd).size), buf = Buffer.alloc(n); fs.readSync(fd, buf, 0, n, 0); return buf.toString('utf8'); } finally { fs.closeSync(fd); } };
|
|
109
|
+
const APPROVAL = /^(Edit|Write|MultiEdit|NotebookEdit)/, ASKS = /^(AskUserQuestion|ExitPlanMode)/, SUBTOOL = /^(Task|Agent)$/;
|
|
110
|
+
const boardCache = new Map(); // file → { sig, parsed, title }
|
|
111
|
+
let boardMemo = { at: 0, rows: [] };
|
|
112
|
+
function parseTail(file, st) {
|
|
113
|
+
const sig = st.mtimeMs + ':' + st.size, hit = boardCache.get(file); if (hit && hit.sig === sig) return hit;
|
|
114
|
+
const { text, cut } = readTail(file, 192 * 1024), lines = text.split('\n'); if (cut) lines.shift();
|
|
115
|
+
const pend = new Map(); let lastType = null, lastTool = false, lastTs = 0;
|
|
116
|
+
for (const l of lines) {
|
|
117
|
+
if (!l) continue; let r; try { r = JSON.parse(l); } catch { continue; }
|
|
118
|
+
const t = Date.parse(r.timestamp) || 0, bl = blocks(r);
|
|
119
|
+
if (r.type === 'assistant' || r.type === 'user') { lastType = r.type; lastTool = bl.some((b) => b.type === 'tool_use'); if (t) lastTs = t; }
|
|
120
|
+
for (const b of bl) { if (b.type === 'tool_use') pend.set(b.id, { name: b.name, ts: t }); else if (b.type === 'tool_result') pend.delete(b.tool_use_id); }
|
|
121
|
+
}
|
|
122
|
+
let title = hit && hit.title, tried = hit && hit.tried;
|
|
123
|
+
if (!title && !tried) {
|
|
124
|
+
const big = readTail(file, 256 * 1024).text, mc = [...big.matchAll(/"type":"custom-title","customTitle":"((?:[^"\\]|\\.)*)"/g)].pop(), m = mc || [...big.matchAll(/"type":"ai-title","aiTitle":"((?:[^"\\]|\\.)*)"/g)].pop(); // a title the person typed wins over the generated one
|
|
125
|
+
if (m) { try { title = JSON.parse('"' + m[1] + '"'); } catch {} }
|
|
126
|
+
if (!title) { const hd = readHead(file, 64 * 1024), u2 = hd.match(/"type":"user"[\s\S]{0,3000}?"type":"text","text":"(?!<)((?:[^"\\]|\\.)*)"/); if (u2) { try { title = JSON.parse('"' + u2[1] + '"').replace(/\s+/g, ' ').slice(0, 90); } catch {} } }
|
|
127
|
+
if (!title) { const u = readHead(file, 64 * 1024).match(/"role":"user","content":"((?:[^"\\]|\\.)*)"/); if (u) { try { title = JSON.parse('"' + u[1] + '"').replace(/\s+/g, ' ').slice(0, 90); } catch {} } }
|
|
128
|
+
}
|
|
129
|
+
const out = { sig, title: title || null, tried: true, lastType, lastTool, lastTs, pending: [...pend.values()] };
|
|
130
|
+
boardCache.set(file, out); if (boardCache.size > 200) boardCache.delete(boardCache.keys().next().value); return out;
|
|
131
|
+
}
|
|
132
|
+
function boardRows() {
|
|
133
|
+
if (Date.now() - boardMemo.at < 1200) return boardMemo.rows; // several windows polling at once share one scan
|
|
134
|
+
const out = boardRows0(); boardMemo = { at: Date.now(), rows: out }; return out;
|
|
135
|
+
}
|
|
136
|
+
function boardRows0() {
|
|
137
|
+
const now = Date.now(), rows = [];
|
|
138
|
+
if (!fs.existsSync(ROOT)) return rows;
|
|
139
|
+
for (const proj of fs.readdirSync(ROOT)) {
|
|
140
|
+
const dir = path.join(ROOT, proj); let files; try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
|
|
141
|
+
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 {} }
|
|
142
|
+
}
|
|
143
|
+
rows.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
|
|
144
|
+
return rows.slice(0, 40).map(({ proj, id, st }) => {
|
|
145
|
+
const file = path.join(ROOT, proj, id + '.jsonl'); let p; try { p = parseTail(file, st); } catch { p = { pending: [] }; }
|
|
146
|
+
const age = now - st.mtimeMs, lp = (p.pending || [])[(p.pending || []).length - 1]; let state = 'idle', tool = '', ageMs = age;
|
|
147
|
+
if (age < 30 * 60e3) {
|
|
148
|
+
if (lp) { ageMs = now - (lp.ts || st.mtimeMs); tool = lp.name;
|
|
149
|
+
state = SUBTOOL.test(lp.name) ? 'waiting' : (ASKS.test(lp.name) && ageMs > 4000) || (APPROVAL.test(lp.name) && ageMs > 15000) ? 'blocked' : 'working'; }
|
|
150
|
+
else if (p.lastType === 'assistant' && !p.lastTool) state = 'turn';
|
|
151
|
+
else state = age < 10 * 60e3 ? 'working' : 'idle';
|
|
152
|
+
}
|
|
153
|
+
let subActive = 0, subTotal = 0; const sd = path.join(ROOT, proj, id, 'subagents');
|
|
154
|
+
try { for (const x of fs.readdirSync(sd)) if (x.endsWith('.jsonl')) { subTotal++; if (now - fs.statSync(path.join(sd, x)).mtimeMs < 90e3) subActive++; } } catch {}
|
|
155
|
+
return { proj, id, title: p.title || id, mtime: st.mtimeMs, state, tool, ageMs, subActive, subTotal };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Most recently active session (optionally limited to one project dir) — drives the live feed.
|
|
159
|
+
function currentSession(projFilter, idFilter) {
|
|
160
|
+
if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && fs.existsSync(path.join(ROOT, projFilter, idFilter + '.jsonl'))) return { proj: projFilter, id: idFilter, mtime: Date.now() }; // pinned from the board
|
|
161
|
+
let best = null;
|
|
162
|
+
if (!fs.existsSync(ROOT)) return {};
|
|
163
|
+
const consider = (proj, id, m) => { if (!best || m > best.mtime) best = { proj, id, mtime: m }; };
|
|
164
|
+
for (const proj of fs.readdirSync(ROOT)) {
|
|
165
|
+
if (projFilter && proj !== projFilter) continue;
|
|
166
|
+
const dir = path.join(ROOT, proj); let files;
|
|
167
|
+
try { files = fs.readdirSync(dir); } catch { continue; }
|
|
168
|
+
for (const f of files) {
|
|
169
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
170
|
+
const id = f.slice(0, -6); let m = fs.statSync(path.join(dir, f)).mtimeMs;
|
|
171
|
+
const sd = path.join(dir, id, 'subagents');
|
|
172
|
+
if (fs.existsSync(sd)) for (const x of fs.readdirSync(sd)) m = Math.max(m, fs.statSync(path.join(sd, x)).mtimeMs);
|
|
173
|
+
consider(proj, id, m);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!best && projFilter) return currentSession('');
|
|
177
|
+
return best || {};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- Incremental transcript reader: each .jsonl is parsed once, then only the bytes appended since are read.
|
|
181
|
+
// A session is merged from its main file and one file per sub-agent; the per-file results are cached and re-assembled cheaply.
|
|
182
|
+
const fileStates = new Map(); // path → { agent, offset, tail, raw[], toolName{}, msgUsage Map }
|
|
183
|
+
function readNew(file, agent) {
|
|
184
|
+
let size; try { size = fs.statSync(file).size; } catch { fileStates.delete(file); return null; }
|
|
185
|
+
let st = fileStates.get(file);
|
|
186
|
+
if (!st || size < st.offset) { st = { agent, offset: 0, tail: Buffer.alloc(0), raw: [], toolName: {}, msgUsage: new Map() }; fileStates.set(file, st); }
|
|
187
|
+
while (size > st.offset) { // read in chunks of at most 16 MB so a huge transcript never needs one giant buffer
|
|
188
|
+
const fd = fs.openSync(file, 'r'); let buf;
|
|
189
|
+
try { buf = Buffer.alloc(Math.min(16 * 1024 * 1024, size - st.offset)); fs.readSync(fd, buf, 0, buf.length, st.offset); } finally { fs.closeSync(fd); }
|
|
190
|
+
st.offset += buf.length;
|
|
191
|
+
const all = st.tail.length ? Buffer.concat([st.tail, buf]) : buf, cut = all.lastIndexOf(10); // only complete lines; a half-written last line waits for the next read
|
|
192
|
+
if (cut < 0) { st.tail = all; continue; }
|
|
193
|
+
st.tail = all.subarray(cut + 1);
|
|
194
|
+
for (const line of all.subarray(0, cut).toString('utf8').split('\n')) {
|
|
195
|
+
if (!line) continue;
|
|
196
|
+
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
197
|
+
if (!r || typeof r !== 'object') continue;
|
|
198
|
+
try { parseRecord(st, r); } catch {} // one odd record must not take the whole timeline down
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return st;
|
|
202
|
+
}
|
|
203
|
+
function parseRecord(st, r) {
|
|
204
|
+
const agent = st.agent, ts = Date.parse(r.timestamp) || 0;
|
|
205
|
+
if (r.type === 'system' && r.subtype === 'compact_boundary') { st.raw.push({ ts, agent, kind: 'compact' }); return; } // Claude Code compacted the conversation
|
|
206
|
+
if (r.type === 'queue-operation' && typeof r.content === 'string' && r.content.includes('<task-notification>')) { // how Claude Code reports that a background sub-agent has stopped
|
|
207
|
+
const id = (r.content.match(/<tool-use-id>([^<]+)<\/tool-use-id>/) || [])[1], status = (r.content.match(/<status>([^<]+)<\/status>/) || [])[1];
|
|
208
|
+
if (id && status && status !== 'running') st.raw.push({ ts, agent, kind: 'agentdone', toolId: id });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (r.type === 'user') {
|
|
212
|
+
const items = []; // tool results: the real output that appeared on the agent's screen
|
|
213
|
+
for (const b of blocks(r)) if (b.type === 'tool_result' && (st.toolName[b.tool_use_id] === 'Agent' || st.toolName[b.tool_use_id] === 'Task') && !(st.bg && st.bg[b.tool_use_id]) && !/^\s*Async agent launched/i.test(resultText(b.content))) st.raw.push({ ts, agent, kind: 'agentdone', toolId: b.tool_use_id }); // a synchronous call: the lead got the sub-agent's answer back, so it has finished (async launches answer immediately and are not a completion)
|
|
214
|
+
for (const b of blocks(r)) if (b.type === 'tool_result' && st.toolName[b.tool_use_id] && !NO_RESULT.has(st.toolName[b.tool_use_id])) {
|
|
215
|
+
const n = st.toolName[b.tool_use_id];
|
|
216
|
+
items.push({ id: b.tool_use_id, err: !!b.is_error, out: clip(resultText(b.content).replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trimEnd(), RESULT_MAX[n] || (/^mcp__/.test(n) ? 900 : 600)) });
|
|
217
|
+
}
|
|
218
|
+
if (items.length) st.raw.push({ ts, agent, kind: 'results', items });
|
|
219
|
+
if (agent !== 'main') return; // a sub-agent's first user message is the order, emitted from the parent's side
|
|
220
|
+
const txt = blocks(r).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
|
|
221
|
+
if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt) && !r.isMeta) st.raw.push({ ts, agent: 'user', kind: 'user', text: txt });
|
|
222
|
+
} else if (r.type === 'assistant') {
|
|
223
|
+
const m = r.message || {};
|
|
224
|
+
if (m.id && m.usage) { // the same message is repeated once per content block with identical numbers: keep one per message id
|
|
225
|
+
const u = m.usage, prev = st.msgUsage.get(m.id);
|
|
226
|
+
st.msgUsage.set(m.id, { ts: prev ? prev.ts : ts, u: { i: u.input_tokens || 0, o: u.output_tokens || 0, r: u.cache_read_input_tokens || 0, w: u.cache_creation_input_tokens || 0 }, model: m.model });
|
|
227
|
+
}
|
|
228
|
+
for (const b of blocks(r)) {
|
|
229
|
+
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) st.raw.push({ ts, agent, kind: 'say', text: b.text.trim() });
|
|
230
|
+
else if (b.type === 'tool_use') {
|
|
231
|
+
st.toolName[b.id] = b.name; if (b.input && b.input.run_in_background) (st.bg = st.bg || {})[b.id] = true;
|
|
232
|
+
if (b.name === 'Agent' || b.name === 'Task') st.raw.push({ ts, agent, kind: 'agentcall', toolId: b.id, text: (b.input && b.input.prompt) || '', title: (b.input && b.input.description) || '' });
|
|
233
|
+
else st.raw.push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts) });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const metaCache = new Map(); // meta file path → { mtime, value }
|
|
239
|
+
function readMeta(f) {
|
|
240
|
+
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')); metaCache.set(f, { mtime: mt, value: v }); return v; } catch { return {}; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function buildTimeline(proj, id) {
|
|
244
|
+
const base = path.join(ROOT, proj), mainFile = path.join(base, id + '.jsonl');
|
|
245
|
+
if (!fs.existsSync(mainFile)) throw new Error('session not found');
|
|
246
|
+
const agents = { main: { id: 'main', role: 'Lead', type: 'main' } };
|
|
247
|
+
const states = [readNew(mainFile, 'main')];
|
|
248
|
+
const subDir = path.join(base, id, 'subagents');
|
|
249
|
+
if (fs.existsSync(subDir)) {
|
|
250
|
+
for (const f of fs.readdirSync(subDir).filter((x) => x.endsWith('.jsonl'))) {
|
|
251
|
+
const aid = f.replace(/^agent-/, '').replace(/\.jsonl$/, ''), m = readMeta(path.join(subDir, f.replace(/\.jsonl$/, '.meta.json')));
|
|
252
|
+
agents[aid] = { id: aid, role: m.description || m.agentType || 'Agent', type: m.agentType || 'agent', toolUseId: m.toolUseId };
|
|
253
|
+
try { agents[aid].mtime = fs.statSync(path.join(subDir, f)).mtimeMs; } catch {}
|
|
254
|
+
const st = readNew(path.join(subDir, f), aid); if (st) states.push(st);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const childByTool = {}; // Agent tool_use id → the sub-agent it created, so we know who ordered whom
|
|
258
|
+
for (const a of Object.values(agents)) if (a.toolUseId) childByTool[a.toolUseId] = a.id;
|
|
259
|
+
|
|
260
|
+
const finished = new Set(), now0 = Date.now(); for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'agentdone' && childByTool[e.toolId]) finished.add(childByTool[e.toolId]);
|
|
261
|
+
let recentUntil = 0; for (const a of Object.values(agents)) if (a.id !== 'main' && a.mtime && now0 - a.mtime <= 240000) recentUntil = Math.max(recentUntil, a.mtime + 241000);
|
|
262
|
+
for (const a of Object.values(agents)) if (a.id !== 'main' && now0 - (a.mtime || 0) > 240000) finished.add(a.id); // old transcripts without a completion record: silent for 4 min counts as finished
|
|
263
|
+
const events = [], parent = {}, doneSeen = new Set();
|
|
264
|
+
for (const st of states) {
|
|
265
|
+
if (!st) continue;
|
|
266
|
+
let lastSay = -1; // the last thing a sub-agent says is its report back to the lead
|
|
267
|
+
if (st.agent !== 'main' && finished.has(st.agent)) for (let i = st.raw.length - 1; i >= 0; i--) if (st.raw[i].kind === 'say') { lastSay = i; break; }
|
|
268
|
+
st.raw.forEach((e, i) => {
|
|
269
|
+
if (e.kind === 'agentdone') { const to = childByTool[e.toolId]; if (to && !doneSeen.has(to)) { doneSeen.add(to); events.push({ ts: e.ts, agent: to, kind: 'finish', to: e.agent }); } }
|
|
270
|
+
else if (e.kind === 'agentcall') { const to = childByTool[e.toolId]; if (to) { parent[to] = e.agent; events.push({ ts: e.ts, agent: e.agent, to, kind: 'order', text: e.text, title: e.title }); } }
|
|
271
|
+
else if (i === lastSay) events.push({ ...e, kind: 'report', to: null }); // `to` filled below, once every order is known
|
|
272
|
+
else events.push(e);
|
|
273
|
+
});
|
|
274
|
+
for (const [, mu] of st.msgUsage) events.push({ ts: mu.ts, agent: st.agent, kind: 'usage', u: mu.u, model: mu.model });
|
|
275
|
+
}
|
|
276
|
+
for (const e of events) if (e.kind === 'report' && e.to === null) e.to = parent[e.agent] || 'main';
|
|
277
|
+
|
|
278
|
+
events.sort((a, b) => a.ts - b.ts);
|
|
279
|
+
const merged = []; let lastReal = -1; // consecutive tool calls of one agent become one activity beat; usage events are transparent to that rule
|
|
280
|
+
for (const e of events) {
|
|
281
|
+
const p = lastReal >= 0 ? merged[lastReal] : null;
|
|
282
|
+
if (e.kind === 'usage' || e.kind === 'finish' || e.kind === 'compact') { merged.push(e); continue; }
|
|
283
|
+
if (e.kind === 'tool' && p && p.kind === 'tools' && p.agent === e.agent) p.tools.push(e.tool);
|
|
284
|
+
else { merged.push(e.kind === 'tool' ? { ts: e.ts, agent: e.agent, kind: 'tools', tools: [e.tool] } : e); lastReal = merged.length - 1; }
|
|
285
|
+
}
|
|
286
|
+
const real = merged.find((e) => e.kind !== 'usage' && e.kind !== 'finish' && e.kind !== 'compact'), first = real ? real.ts : 0, last = merged.length ? merged[merged.length - 1].ts : 0; // time zero = the first thing said or done
|
|
287
|
+
return { id, proj, agents: Object.values(agents), events: merged.map((e) => ({ ...e, t: Math.max(0, e.ts - first) })), durationMs: last - first, recentUntil };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Ask the local `claude` CLI (uses the user's existing login) to map a JSON array of strings to another array. Cached.
|
|
291
|
+
const { spawn } = require('child_process');
|
|
292
|
+
const cache = new Map();
|
|
293
|
+
// Find a usable `claude` binary: explicit setting → PATH → usual install spots → the one bundled with the VS Code Claude Code extension.
|
|
294
|
+
let lastTrError = '', claudeCache = null;
|
|
295
|
+
const exists = (f) => { try { return fs.statSync(f).isFile(); } catch { return false; } };
|
|
296
|
+
function bundledClaude() {
|
|
297
|
+
const home = os.homedir(); let best = null;
|
|
298
|
+
for (const dir of ['.vscode', '.vscode-insiders', '.cursor', '.windsurf', '.vscode-oss']) {
|
|
299
|
+
let names; try { names = fs.readdirSync(path.join(home, dir, 'extensions')); } catch { continue; }
|
|
300
|
+
for (const d of names.filter((x) => x.startsWith('anthropic.claude-code-')))
|
|
301
|
+
for (const n of ['claude', 'claude.exe']) { const f = path.join(home, dir, 'extensions', d, 'resources', 'native-binary', n); if (exists(f) && (!best || d.localeCompare(best.d, undefined, { numeric: true }) > 0)) best = { d, f }; }
|
|
302
|
+
}
|
|
303
|
+
return best && best.f;
|
|
304
|
+
}
|
|
305
|
+
function findClaude() {
|
|
306
|
+
if (claudeCache && exists(claudeCache)) return claudeCache;
|
|
307
|
+
const home = os.homedir(), win = process.platform === 'win32', names = win ? ['claude.exe', 'claude.cmd', 'claude.bat'] : ['claude'], cands = [];
|
|
308
|
+
if (process.env.AGENT_STANDUP_CLAUDE) cands.push(process.env.AGENT_STANDUP_CLAUDE);
|
|
309
|
+
for (const d of (process.env.PATH || '').split(path.delimiter)) for (const n of names) cands.push(path.join(d, n));
|
|
310
|
+
const dirs = win ? [path.join(home, '.local', 'bin'), path.join(home, '.claude', 'local'), path.join(process.env.APPDATA || '', 'npm'), path.join(process.env.LOCALAPPDATA || '', 'Programs', 'claude')]
|
|
311
|
+
: ['/opt/homebrew/bin', '/usr/local/bin', path.join(home, '.claude/local'), path.join(home, '.local/bin'), path.join(home, '.npm-global/bin'), path.join(home, '.bun/bin'), path.join(home, '.volta/bin')];
|
|
312
|
+
try { const nvm = path.join(home, '.nvm', 'versions', 'node'); for (const v of fs.readdirSync(nvm)) dirs.push(path.join(nvm, v, 'bin')); } catch {}
|
|
313
|
+
for (const d of dirs) for (const n of names) cands.push(path.join(d, n));
|
|
314
|
+
const found = cands.find(exists) || bundledClaude() || null;
|
|
315
|
+
return (claudeCache = found);
|
|
316
|
+
}
|
|
317
|
+
function spawnClaude(args, opts) {
|
|
318
|
+
const bin = findClaude(); if (!bin) { lastTrError = 'not-found'; return null; }
|
|
319
|
+
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(bin)) return spawn('"' + bin + '"', args, { ...opts, shell: true, windowsHide: true }); // npm's Windows shims need a shell
|
|
320
|
+
return spawn(bin, args, { ...opts, windowsHide: true });
|
|
321
|
+
}
|
|
322
|
+
let running = 0; const queue = [];
|
|
323
|
+
const pump = () => { while (running < 3 && queue.length) { running++; queue.shift()(); } };
|
|
324
|
+
const schedule = (fn) => new Promise((res, rej) => { queue.push(() => fn().then(res, rej).finally(() => { running--; pump(); })); pump(); });
|
|
325
|
+
// Fast path (~1-2 s instead of ~6 s): if the user has an Anthropic API key in the environment, call the API directly.
|
|
326
|
+
async function apiMap(prompt, need) {
|
|
327
|
+
const key = process.env.ANTHROPIC_API_KEY; if (!key) throw new Error('no key');
|
|
328
|
+
const r = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' },
|
|
329
|
+
body: JSON.stringify({ model: 'claude-haiku-5-5', max_tokens: 2000, messages: [{ role: 'user', content: prompt }] }) });
|
|
330
|
+
const j = await r.json(); const out = j.content[0].text, arr = JSON.parse(out.slice(out.indexOf('['), out.lastIndexOf(']') + 1));
|
|
331
|
+
if (arr.length !== need.length) throw new Error('bad length'); return arr;
|
|
332
|
+
}
|
|
333
|
+
function claudeMap(kind, instruction, texts) {
|
|
334
|
+
const need = [...new Set(texts.filter((t) => !cache.has(kind + '|' + t)))];
|
|
335
|
+
const done = (ok) => ({ texts: texts.map((t) => cache.get(kind + '|' + t) || t), ok });
|
|
336
|
+
if (!need.length) return Promise.resolve(done(true));
|
|
337
|
+
const prompt = instruction + ' Output ONLY a JSON array of strings, same length and order, no markdown.\n' + JSON.stringify(need);
|
|
338
|
+
return apiMap(prompt, need).then((arr) => { need.forEach((t, i) => cache.set(kind + '|' + t, String(arr[i]))); return done(true); }).catch(() => schedule(() => new Promise((resolve) => {
|
|
339
|
+
const p = spawnClaude(['-p', '--model', 'haiku', '--no-session-persistence'], { cwd: os.tmpdir() }); if (!p) return resolve(done(false));
|
|
340
|
+
let out = ''; const timer = setTimeout(() => p.kill(), 60000);
|
|
341
|
+
p.stdout.on('data', (d) => (out += d));
|
|
342
|
+
p.on('error', () => resolve(done(false)));
|
|
343
|
+
p.on('close', () => {
|
|
344
|
+
clearTimeout(timer);
|
|
345
|
+
let ok = false;
|
|
346
|
+
try {
|
|
347
|
+
const arr = JSON.parse(out.slice(out.indexOf('['), out.lastIndexOf(']') + 1));
|
|
348
|
+
if (arr.length === need.length) { need.forEach((t, i) => cache.set(kind + '|' + t, String(arr[i]))); ok = true; }
|
|
349
|
+
} catch {}
|
|
350
|
+
resolve(done(ok));
|
|
351
|
+
});
|
|
352
|
+
p.stdin.end(prompt);
|
|
353
|
+
})));
|
|
354
|
+
}
|
|
355
|
+
const translate = (texts) => translateFast(texts);
|
|
356
|
+
|
|
357
|
+
// Warm `claude` workers: after a slow first call each translation takes ~2-3 s (a cold `claude -p` costs ~6 s).
|
|
358
|
+
const TR_INSTR = 'Translate each string of this JSON array into natural spoken French, as a teammate would say it in a meeting. Keep code identifiers, file paths, library names and numbers unchanged.';
|
|
359
|
+
const TR_SYS = 'You are a translation engine. Translate the user message into natural spoken French, as a colleague would say it in a meeting. Keep code identifiers, file paths, library names and numbers unchanged. Reply with ONLY the translation, nothing else.';
|
|
360
|
+
class TrWorker {
|
|
361
|
+
constructor() { this.q = []; this.busy = false; this.n = 0; this.p = null; this.cur = null; this.buf = ''; }
|
|
362
|
+
start() {
|
|
363
|
+
const p = spawnClaude(['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--model', 'haiku', '--no-session-persistence', '--system-prompt', TR_SYS], { cwd: os.tmpdir() });
|
|
364
|
+
if (!p) return;
|
|
365
|
+
this.p = p; this.buf = ''; this.n = 0;
|
|
366
|
+
p.stderr.on('data', (d) => { lastTrError = String(d).slice(-300); });
|
|
367
|
+
p.stdout.on('data', (d) => {
|
|
368
|
+
this.buf += d; let i;
|
|
369
|
+
while ((i = this.buf.indexOf('\n')) >= 0) {
|
|
370
|
+
const l = this.buf.slice(0, i); this.buf = this.buf.slice(i + 1);
|
|
371
|
+
try { const m = JSON.parse(l); if (m.type === 'result' && this.cur) { if (m.is_error) lastTrError = String(m.result || '').slice(0, 200); else lastTrError = ''; this.finish(m.is_error ? null : String(m.result || '').trim()); } } catch {}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
const dead = () => { if (this.p !== p) return; this.p = null; this.finish(null); };
|
|
375
|
+
p.on('close', dead); p.on('error', dead);
|
|
376
|
+
}
|
|
377
|
+
finish(res) { const c = this.cur; this.cur = null; this.busy = false; if (c) { clearTimeout(c.timer); c.res(res); } this.pump(); }
|
|
378
|
+
ask(text) { return new Promise((res) => { this.q.push({ text, res }); this.pump(); }); }
|
|
379
|
+
kill() { clearTimeout(this.idle); if (this.p) { const p = this.p; this.p = null; try { p.kill(); } catch {} } }
|
|
380
|
+
pump() {
|
|
381
|
+
if (this.busy || !this.q.length) return;
|
|
382
|
+
if (!this.p || this.n >= 30) { if (this.p) { const old = this.p; this.p = null; old.kill(); } this.start(); if (!this.p) { while (this.q.length) this.q.shift().res(null); return; } } // restart now and then: the conversation context grows
|
|
383
|
+
clearTimeout(this.idle); this.idle = setTimeout(() => { if (!this.busy && !this.q.length) this.kill(); }, 10 * 60 * 1000); // free the process when nothing was translated for 10 min
|
|
384
|
+
const job = this.q.shift(); this.busy = true; this.n++; this.cur = job;
|
|
385
|
+
job.timer = setTimeout(() => { if (this.cur === job && this.p) this.p.kill(); }, 45000);
|
|
386
|
+
this.p.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: job.text } }) + '\n');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const trWorkers = [new TrWorker(), new TrWorker()];
|
|
390
|
+
const shutdown = () => trWorkers.forEach((w) => w.kill());
|
|
391
|
+
process.on('exit', shutdown);
|
|
392
|
+
const pickWorker = () => trWorkers.reduce((a, b) => (a.q.length + a.busy <= b.q.length + b.busy ? a : b));
|
|
393
|
+
async function translateFast(texts) {
|
|
394
|
+
const need = [...new Set(texts.filter((t) => !cache.has('fr|' + t)))];
|
|
395
|
+
if (need.length && need.length <= 3 && !process.env.ANTHROPIC_API_KEY) { // a few lines: warm workers in parallel; many lines: one batched call
|
|
396
|
+
await Promise.all(need.map(async (t) => { const r = await pickWorker().ask(t); if (r) cache.set('fr|' + t, r); }));
|
|
397
|
+
return { texts: texts.map((t) => cache.get('fr|' + t) || t), ok: texts.every((t) => cache.has('fr|' + t)) };
|
|
398
|
+
}
|
|
399
|
+
return claudeMap('fr', TR_INSTR, texts);
|
|
400
|
+
}
|
|
401
|
+
const quips = (texts, lang) => claudeMap('q' + lang, `Each string is a work report from a software agent. For each, write ONE short, deadpan, funny reaction (max 12 words) from a grumpy teammate in a South Park-style cartoon meeting, in ${lang === 'fr' ? 'French' : 'English'}. No emojis.`, texts);
|
|
402
|
+
|
|
403
|
+
// ---- text-to-speech, generated on the machine and cached as wav:
|
|
404
|
+
// macOS → `say`, Windows → System.Speech (SAPI voices) through PowerShell, Linux → espeak-ng.
|
|
405
|
+
let sayVoices = null;
|
|
406
|
+
const WIN = process.platform === 'win32', MAC = process.platform === 'darwin';
|
|
407
|
+
const PS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command'];
|
|
408
|
+
function listVoices() {
|
|
409
|
+
if (sayVoices) return sayVoices;
|
|
410
|
+
const voices = [];
|
|
411
|
+
try {
|
|
412
|
+
if (MAC) {
|
|
413
|
+
const out = execFileSync('say', ['-v', '?'], { encoding: 'utf8', timeout: 5000 });
|
|
414
|
+
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] }); }
|
|
415
|
+
} else if (WIN) {
|
|
416
|
+
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 });
|
|
417
|
+
for (const line of out.split(/\r?\n/)) { const [name, loc] = line.trim().split('|'); if (name && loc) voices.push({ name, locale: loc.replace('-', '_') }); }
|
|
418
|
+
} else {
|
|
419
|
+
const out = execFileSync('espeak-ng', ['--voices'], { encoding: 'utf8', timeout: 5000 });
|
|
420
|
+
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()) }); }
|
|
421
|
+
}
|
|
422
|
+
} catch {}
|
|
423
|
+
return (sayVoices = { available: voices.length > 0, voices, platform: process.platform });
|
|
424
|
+
}
|
|
425
|
+
const ttsDir = path.join(os.tmpdir(), 'agent-standup-tts');
|
|
426
|
+
const ttsJobs = new Map();
|
|
427
|
+
try { for (const f of fs.readdirSync(ttsDir)) { const p = path.join(ttsDir, f); if (Date.now() - fs.statSync(p).mtimeMs > 864e5) fs.unlinkSync(p); } } catch {} // drop cached audio older than a day
|
|
428
|
+
function synthesize(voice, wpm, text, out) {
|
|
429
|
+
return new Promise((resolve, reject) => {
|
|
430
|
+
let p;
|
|
431
|
+
if (MAC) p = spawn('say', ['-v', voice, '-r', String(wpm), '--data-format=LEI16@22050', '-o', out, '--', text]);
|
|
432
|
+
else if (WIN) {
|
|
433
|
+
const rate = Math.max(-6, Math.min(8, Math.round((wpm - 175) / 12))); // SAPI rate: -10..10
|
|
434
|
+
p = spawn('powershell.exe', [...PS, '$ErrorActionPreference = "Stop"; Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.SelectVoice($env:AS_VOICE); $s.Rate = [int]$env:AS_RATE; $s.SetOutputToWaveFile($env:AS_OUT); $s.Speak($env:AS_TEXT); $s.Dispose()'],
|
|
435
|
+
{ env: { ...process.env, AS_VOICE: voice, AS_RATE: String(rate), AS_OUT: out, AS_TEXT: text }, windowsHide: true });
|
|
436
|
+
} else p = spawn('espeak-ng', ['-v', voice, '-s', String(wpm), '-w', out, '--', text]);
|
|
437
|
+
p.on('error', reject); p.on('close', (code) => (code === 0 && fs.existsSync(out) ? resolve() : reject(new Error('tts failed'))));
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function tts(text, voice, rate) {
|
|
441
|
+
const vs = listVoices();
|
|
442
|
+
if (!vs.available || !vs.voices.some((v) => v.name === voice)) return Promise.reject(new Error('unknown voice'));
|
|
443
|
+
text = String(text).slice(0, 700); rate = Math.max(90, Math.min(360, Math.round(+rate || 180)));
|
|
444
|
+
const key = crypto.createHash('sha1').update([voice, rate, text].join('|')).digest('hex'), file = path.join(ttsDir, key + '.wav');
|
|
445
|
+
if (fs.existsSync(file)) return Promise.resolve(file);
|
|
446
|
+
if (ttsJobs.has(key)) return ttsJobs.get(key);
|
|
447
|
+
fs.mkdirSync(ttsDir, { recursive: true });
|
|
448
|
+
const tmp = file + '.part.wav';
|
|
449
|
+
const job = synthesize(voice, rate, text, tmp).then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
|
|
450
|
+
ttsJobs.set(key, job); return job;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json; charset=utf-8' };
|
|
454
|
+
|
|
455
|
+
// ---- Custom characters: the person's own pixel art replaces the drawn characters. Plain image files in one folder, nothing else is ever served from it.
|
|
456
|
+
// lead.png / claude.png → the lead · you.png → the human · any other image → a pool for the sub-agents · name-talk.png → optional open-mouth frame of name.png
|
|
457
|
+
const CHAR_NAME = /^[\w][\w .()\-]{0,78}\.(png|gif|webp|jpe?g)$/i, CHAR_MAX = 300 * 1024, CHAR_TYPES = { png: 'image/png', gif: 'image/gif', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg' };
|
|
458
|
+
const charactersDir = () => process.env.AGENT_STANDUP_CHARACTERS || path.join(os.homedir(), '.agent-standup', 'characters');
|
|
459
|
+
function listCharacters() {
|
|
460
|
+
const dir = charactersDir(); let names = []; try { names = fs.readdirSync(dir); } catch {}
|
|
461
|
+
const ok = names.filter((f) => CHAR_NAME.test(f)).filter((f) => { try { const st = fs.statSync(path.join(dir, f)); return st.isFile() && st.size <= CHAR_MAX; } catch { return false; } }).slice(0, 80);
|
|
462
|
+
const stem = (f) => f.replace(/\.[^.]+$/, ''), by = new Map(ok.map((f) => [stem(f).toLowerCase(), f]));
|
|
463
|
+
const chars = ok.filter((f) => !/-talk$/i.test(stem(f))).map((f) => { const st = stem(f), l = st.toLowerCase();
|
|
464
|
+
return { stem: st, file: f, talk: by.get(l + '-talk') || null, role: /^(lead|claude)$/.test(l) ? 'lead' : /^(you|human|user)$/.test(l) ? 'you' : 'agent' }; }).sort((a, b) => a.stem.localeCompare(b.stem));
|
|
465
|
+
return { dir, max: CHAR_MAX, chars };
|
|
466
|
+
}
|
|
467
|
+
let audioOwner = { id: null, seen: 0 };
|
|
468
|
+
function start(port = PORT) {
|
|
469
|
+
return http.createServer((req, res) => {
|
|
470
|
+
const url = new URL(req.url, 'http://x');
|
|
471
|
+
// Only the local machine may talk to this server: blocks other websites (cross-site requests) and DNS-rebinding attempts.
|
|
472
|
+
const local = (h) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(h || '');
|
|
473
|
+
let originOk = true; try { originOk = !req.headers.origin || local(new URL(req.headers.origin).host); } catch { originOk = false; }
|
|
474
|
+
if (!local(req.headers.host) || !originOk) { res.writeHead(403); return res.end('forbidden'); }
|
|
475
|
+
const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
|
476
|
+
try {
|
|
477
|
+
if (url.pathname === '/api/translate' && req.method === 'POST') {
|
|
478
|
+
let body = ''; req.on('data', (d) => (body += d));
|
|
479
|
+
return req.on('end', async () => { try { json(await translate(JSON.parse(body).texts || [])); } catch (e) { json({ error: String(e) }, 400); } });
|
|
480
|
+
}
|
|
481
|
+
if (url.pathname === '/api/quips' && req.method === 'POST') {
|
|
482
|
+
let body = ''; req.on('data', (d) => (body += d));
|
|
483
|
+
return req.on('end', async () => { try { const b = JSON.parse(body); json(await quips(b.texts || [], b.lang)); } catch (e) { json({ error: String(e) }, 400); } });
|
|
484
|
+
}
|
|
485
|
+
if (url.pathname === '/api/sessions') return json(listSessions());
|
|
486
|
+
if (url.pathname === '/api/projects') { // projects (working folders) ordered by latest activity — lets the live view follow one of them
|
|
487
|
+
const m = new Map(); for (const r of listSessions()) { const p = m.get(r.proj) || { proj: r.proj, mtime: 0, sessions: 0 }; p.mtime = Math.max(p.mtime, r.mtime); p.sessions++; m.set(r.proj, p); }
|
|
488
|
+
return json([...m.values()].sort((a, b) => b.mtime - a.mtime));
|
|
489
|
+
}
|
|
490
|
+
if (url.pathname === '/api/audio') { // only one window at a time plays sound: the last one that claimed it (expires if that window stops answering)
|
|
491
|
+
const cid = url.searchParams.get('cid') || '', ok = /^[\w-]{4,40}$/.test(cid);
|
|
492
|
+
if (ok && url.searchParams.get('claim')) audioOwner = { id: cid, seen: Date.now() };
|
|
493
|
+
else if (ok && audioOwner.id === cid) audioOwner.seen = Date.now();
|
|
494
|
+
if (audioOwner.id && Date.now() - audioOwner.seen > 6000) audioOwner = { id: null, seen: 0 };
|
|
495
|
+
return json({ owner: audioOwner.id });
|
|
496
|
+
}
|
|
497
|
+
if (url.pathname === '/api/health') return json({ claude: findClaude(), apiKey: !!process.env.ANTHROPIC_API_KEY, trError: lastTrError, platform: process.platform });
|
|
498
|
+
if (url.pathname === '/api/warm') { trWorkers.forEach((w) => { if (!w.p) w.start(); }); return json({ ok: true }); }
|
|
499
|
+
if (url.pathname === '/api/voices') return json(listVoices());
|
|
500
|
+
if (url.pathname === '/api/tts') {
|
|
501
|
+
return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'))
|
|
502
|
+
.then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); fs.createReadStream(f).pipe(res); })
|
|
503
|
+
.catch((e) => json({ error: String(e.message) }, 400));
|
|
504
|
+
}
|
|
505
|
+
if (url.pathname === '/api/current') return json(currentSession(url.searchParams.get('proj') || '', url.searchParams.get('id') || ''));
|
|
506
|
+
if (url.pathname === '/api/board') return json(boardRows());
|
|
507
|
+
if (url.pathname === '/api/characters') return json(listCharacters());
|
|
508
|
+
if (url.pathname === '/api/session') {
|
|
509
|
+
const proj = url.searchParams.get('proj') || '', sid = url.searchParams.get('id') || '', SAFE = /^[\w.\-]+$/;
|
|
510
|
+
if (!SAFE.test(proj) || !SAFE.test(sid) || proj === '..' || sid === '..' || proj.length > 200 || sid.length > 200) { res.writeHead(400); return res.end('bad request'); }
|
|
511
|
+
if (proj !== '_demo' && !fs.existsSync(path.join(ROOT, proj, sid + '.jsonl'))) return json({ error: 'session not found' }, 404);
|
|
512
|
+
const entry = proj === '_demo' ? { tl: require('./demo.js')() } : timelineEntry(proj, sid), tl = entry.tl; // built-in fictional session
|
|
513
|
+
const after = url.searchParams.get('after');
|
|
514
|
+
if (after == null) { // the whole timeline: serialize once per version, send gzip-compressed when the browser allows it (12 MB → ~1.5 MB)
|
|
515
|
+
const gz = /\bgzip\b/.test(req.headers['accept-encoding'] || '');
|
|
516
|
+
if (!entry.json) entry.json = JSON.stringify(tl);
|
|
517
|
+
if (gz && !entry.gz) entry.gz = zlib.gzipSync(entry.json, { level: 5 });
|
|
518
|
+
res.writeHead(200, { 'content-type': 'application/json', ...(gz ? { 'content-encoding': 'gzip' } : {}), vary: 'accept-encoding' });
|
|
519
|
+
return res.end(gz ? entry.gz : entry.json);
|
|
520
|
+
}
|
|
521
|
+
return json({ id: tl.id, agents: tl.agents, total: tl.events.length, events: tl.events.slice(+after) });
|
|
522
|
+
}
|
|
523
|
+
if (url.pathname.startsWith('/characters/')) { // only plain image files of the characters folder, by exact name
|
|
524
|
+
let f = ''; try { f = decodeURIComponent(url.pathname.slice('/characters/'.length)); } catch {}
|
|
525
|
+
const dir = charactersDir(), fp = path.join(dir, f);
|
|
526
|
+
try { if (!CHAR_NAME.test(f) || !fp.startsWith(dir + path.sep) || fs.statSync(fp).size > CHAR_MAX) throw 0; } catch { res.writeHead(404); return res.end('not found'); }
|
|
527
|
+
res.writeHead(200, { 'content-type': CHAR_TYPES[f.split('.').pop().toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-cache' }); return fs.createReadStream(fp).pipe(res);
|
|
528
|
+
}
|
|
529
|
+
const file = path.join(PUBLIC, url.pathname === '/' ? 'index.html' : url.pathname);
|
|
530
|
+
if (!file.startsWith(PUBLIC) || !fs.existsSync(file)) { res.writeHead(404); return res.end('not found'); }
|
|
531
|
+
res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' });
|
|
532
|
+
res.end(fs.readFileSync(file));
|
|
533
|
+
} catch (e) { json({ error: String(e.message || e) }, 500); }
|
|
534
|
+
}).listen(port, '127.0.0.1', () => console.log(`Agent Standup → http://localhost:${port}`));
|
|
535
|
+
}
|
|
536
|
+
module.exports = { start, currentSession, shutdown, findClaude, bundledClaude, boardRows, charactersDir, listCharacters };
|
|
537
|
+
if (require.main === module) {
|
|
538
|
+
start().on('error', (e) => {
|
|
539
|
+
console.error(e.code === 'EADDRINUSE' ? `Port ${PORT} is already in use (maybe Agent Standup is already running, e.g. inside VS Code). Try: PORT=4790 node server.js` : String(e));
|
|
540
|
+
process.exit(1);
|
|
541
|
+
});
|
|
542
|
+
}
|