@double_geste/agent-standup 1.22.0 → 1.22.1
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/bin/agent-standup.js +6 -3
- package/package.json +1 -1
- package/public/index.html +8 -7
- package/server.js +25 -19
package/bin/agent-standup.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// agent-standup [serve] [--port 4747] [--open] start the local server and (optionally) open the page
|
|
4
4
|
// agent-standup status [--here] [--json] counts of working / needs you / your turn
|
|
5
5
|
// agent-standup statusline same, one short line; reads Claude Code's statusLine JSON on stdin
|
|
6
|
-
// Everything reads the local session files Claude Code already writes. Nothing is sent anywhere.
|
|
6
|
+
// Everything reads the local session files Claude Code already writes. Nothing is sent anywhere unless you turn on the optional French translation or jokes (they use your own Claude login or ANTHROPIC_API_KEY).
|
|
7
7
|
const path = require('path'), { spawn } = require('child_process');
|
|
8
8
|
const pkg = require('../package.json');
|
|
9
9
|
const argv = process.argv.slice(2), flags = {}, rest = [];
|
|
@@ -26,7 +26,7 @@ Options
|
|
|
26
26
|
--open open the page in your browser
|
|
27
27
|
--projects DIR where the session files are (default ~/.claude/projects, or $CLAUDE_CONFIG_DIR/projects)
|
|
28
28
|
--characters DIR folder with your own pixel-art characters (default ~/.agent-standup/characters)
|
|
29
|
-
--here status: only sessions of the current folder
|
|
29
|
+
--here status: only sessions of the current folder (matched on its real path, as Claude Code names it)
|
|
30
30
|
--json status: machine-readable output (add --titles to include session titles)
|
|
31
31
|
`;
|
|
32
32
|
if (flags.help || cmd === 'help') { process.stdout.write(HELP); process.exit(0); }
|
|
@@ -34,7 +34,10 @@ if (flags.version || cmd === 'version') { console.log(pkg.version); process.exit
|
|
|
34
34
|
if (flags.projects) process.env.AGENT_STANDUP_PROJECTS = path.resolve(flags.projects);
|
|
35
35
|
if (flags.config) process.env.CLAUDE_CONFIG_DIR = path.resolve(flags.config);
|
|
36
36
|
if (flags.characters) process.env.AGENT_STANDUP_CHARACTERS = path.resolve(flags.characters);
|
|
37
|
-
if (flags.port)
|
|
37
|
+
if (flags.port !== undefined) { // a real port number or nothing: no silent fallback to another port
|
|
38
|
+
if (!/^\d+$/.test(String(flags.port)) || +flags.port < 1 || +flags.port > 65535) { console.error(`Invalid --port "${flags.port}": use a number between 1 and 65535.`); process.exit(2); }
|
|
39
|
+
process.env.PORT = String(+flags.port);
|
|
40
|
+
}
|
|
38
41
|
const server = require('../server.js');
|
|
39
42
|
const projKey = (dir) => String(dir || '').replace(/[^a-zA-Z0-9]/g, '-'); // Claude Code's project folder naming
|
|
40
43
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@double_geste/agent-standup",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.1",
|
|
4
4
|
"description": "Your Claude Code agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-standup": "bin/agent-standup.js"
|
package/public/index.html
CHANGED
|
@@ -339,7 +339,7 @@ async function loadChars() {
|
|
|
339
339
|
} catch {}
|
|
340
340
|
CHARS = next;
|
|
341
341
|
}
|
|
342
|
-
const customFor = (id) => (id === 'main' ? CHARS.lead : id === 'user' ? CHARS.you : CHARS.pool.length ? CHARS.pool
|
|
342
|
+
const customFor = (id) => (id === 'main' ? CHARS.lead : id === 'user' ? CHARS.you : CHARS.pool.length ? CHARS.pool.reduce((b, c) => (hash(id + '|' + (c.idle || '')) > hash(id + '|' + (b.idle || '')) ? c : b)) : null);
|
|
343
343
|
function renderCharInfo() {
|
|
344
344
|
const el = $('#charInfo'); if (!el) return;
|
|
345
345
|
el.innerHTML = CHARS.n ? `✓ ${CHARS.n} ${esc(L('personnage(s) personnalisé(s) chargé(s)', 'custom character(s) loaded'))}` : `${esc(L('Déposez vos images (PNG) dans ce dossier pour remplacer les personnages :', 'Drop your pixel art (PNG) in this folder to replace the characters:'))}<br><code style="word-break:break-all">${esc(CHARS.dir)}</code><br>${esc(L('lead.png = le chef · you.png = vous · les autres = les sous-agents · nom-talk.png = bouche ouverte', 'lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth'))}`;
|
|
@@ -439,7 +439,7 @@ function avatarSVG(p, o, uid = '') { // uid keeps SVG ids unique per instance:
|
|
|
439
439
|
const sparks = star(22, 28, 3) + star(78, 22, 2.6, '#ff9bb8') + star(70, 40, 1.9, '#9be7ff') + star(30, 46, 1.6, '#ffe58a');
|
|
440
440
|
const custFig = (c, talking) => { const base = 'x="20" y="6" width="60" height="58" preserveAspectRatio="xMidYMax meet"', px = 'image-rendering:pixelated;';
|
|
441
441
|
return `<image class="cimg" href="${c.idle}" ${base} style="${px}${talking && c.talk ? 'display:none' : ''}"/>` + (c.talk ? `<image class="cimg cimg-talk" href="${c.talk}" ${base} style="${px}${talking ? '' : 'display:none'}"/>` : ''); };
|
|
442
|
-
const fig = p.custom ? custFig(p.custom, st && o.m
|
|
442
|
+
const fig = p.custom ? custFig(p.custom, st && o.m === 2) : `${arm('l')}${bodyShape(p.shape, `url(#${gid})`, c2)}${arm('r')}${face}${acc}`;
|
|
443
443
|
return `<svg viewBox="0 0 100 75" preserveAspectRatio="xMidYMid slice"${st ? ' xmlns="http://www.w3.org/2000/svg" width="400" height="300"' : ''}>
|
|
444
444
|
<defs><linearGradient id="${gid}" gradientUnits="userSpaceOnUse" x1="0" y1="12" x2="0" y2="72"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c}"/></linearGradient>
|
|
445
445
|
<linearGradient id="${uid}dk${p.scene}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${sc.desk[0]}"/><stop offset="1" stop-color="${sc.desk[1]}"/></linearGradient>
|
|
@@ -834,7 +834,7 @@ function paintStatus() {
|
|
|
834
834
|
$('#seek').value = S.idx; $('#pos').textContent = `${Math.min(S.idx + 1, S.events.length)} / ${S.events.length}`;
|
|
835
835
|
}
|
|
836
836
|
const applyStatus = (ev) => {
|
|
837
|
-
if (ev.kind === 'order') { (S.waitOn[ev.agent] = S.waitOn[ev.agent] || new Set()).add(ev.to); S.orderTs[ev.to] = ev.ts || 0; S.status[ev.to] = 'working'; delete S.doneAt[ev.to]; S.gone.delete(ev.to); S.leaving.delete(ev.to); const t = $('#t-' + ev.to); if (t) t.classList.remove('vanishing'); }
|
|
837
|
+
if (ev.kind === 'order') { (S.waitOn[ev.agent] = S.waitOn[ev.agent] || new Set()).add(ev.to); S.orderTs[ev.to] = ev.ts || 0; S.status[ev.to] = 'working'; delete S.doneAt[ev.to]; S.gone.delete(ev.to); S.leaving.delete(ev.to); if (!ev.bg && ev.toolId) S.run[ev.agent] = { id: ev.toolId, ts: ev.ts, start: performance.now(), name: 'Agent' }; const t = $('#t-' + ev.to); if (t) t.classList.remove('vanishing'); }
|
|
838
838
|
if (ev.kind === 'finish') { if (S.waitOn[ev.to]) S.waitOn[ev.to].delete(ev.agent); if (S.status[ev.agent] !== 'done') { S.status[ev.agent] = 'done'; S.doneAt[ev.agent] = S.clock; } delete S.run[ev.agent]; const lr = S.run[ev.to]; if (lr && /^(Task|Agent)$/.test(lr.name) && !(S.waitOn[ev.to] && S.waitOn[ev.to].size)) delete S.run[ev.to]; }
|
|
839
839
|
if (ev.kind === 'report') { if (S.waitOn[ev.to]) S.waitOn[ev.to].delete(ev.agent); S.status[ev.agent] = 'done'; S.doneAt[ev.agent] = S.clock; delete S.run[ev.agent]; }
|
|
840
840
|
};
|
|
@@ -877,7 +877,7 @@ const WIN1M = /(opus-4-[78]|opus-5|sonnet-5|fable-5)/;
|
|
|
877
877
|
function ctxWin(model, ctx) { const base = WIN1M.test(model || '') ? 1e6 : 2e5; return ctx > base ? 1e6 : base; } // the transcript does not say which window is in use: infer it, a bigger observed context proves the 1M window
|
|
878
878
|
function tally(ev) { // numbers that pile up over a session: token usage, errors, last sign of life
|
|
879
879
|
if (ev.kind === 'compact') { S.compacts[ev.agent] = (S.compacts[ev.agent] || 0) + 1; delete S.ctxWarned[ev.agent]; }
|
|
880
|
-
const det = loopStep(S.rep, ev); if (det) { S.loops[ev.agent] = det; later('badges', renderBadges, 300); } else if (S.loops[ev.agent] && (ev.kind === 'results' ? ev.items.every((it) => !it.err) : (ev.kind === 'tools' && S.rep[ev.agent].n < 2))) { delete S.loops[ev.agent]; later('badges', renderBadges, 300); }
|
|
880
|
+
const det = loopStep(S.rep, ev); if (det) { S.loops[ev.agent] = det; later('badges', renderBadges, 300); } else if (S.loops[ev.agent] && (ev.kind === 'results' ? (S.loops[ev.agent].kind === 'errors' && ev.items.every((it) => !it.err)) : (ev.kind === 'tools' && S.rep[ev.agent].n < 2))) { delete S.loops[ev.agent]; later('badges', renderBadges, 300); }
|
|
881
881
|
if (ev.kind === 'usage') { const c = ev.u.i + ev.u.r + ev.u.w; if (c > 0 && ev.model !== '<synthetic>') { S.ctx[ev.agent] = c; S.model[ev.agent] = ev.model; later('tok', renderCtx, 300); } const u = (S.usage[ev.agent] = S.usage[ev.agent] || { i: 0, o: 0, r: 0, w: 0 }); u.i += ev.u.i; u.o += ev.u.o; u.r += ev.u.r; u.w += ev.u.w; later('tok', renderTokens, 300); }
|
|
882
882
|
else if (ev.kind === 'results') ev.items.forEach((it) => { if (it.err) { S.errs[ev.agent] = (S.errs[ev.agent] || 0) + 1; later('badges', renderBadges, 300); oops(ev.agent); } });
|
|
883
883
|
else if (ev.kind === 'tools' || ev.kind === 'say') S.lastAct[ev.agent] = ev.ts;
|
|
@@ -1147,7 +1147,7 @@ function startTalk(id, text, label) {
|
|
|
1147
1147
|
if (S.present === id) { $('#pcap').textContent = text; $('#pcam').classList.add('speaking'); }
|
|
1148
1148
|
const m = t.querySelector('.mouthE'); let open = false; clearInterval(mouths.get(id));
|
|
1149
1149
|
mouths.set(id, setInterval(() => {
|
|
1150
|
-
if (!m) { const ct = t.querySelector('.cimg-talk'); if (ct) { const lv0 = S.level[id], op = lv0 ? lv0() > 0.05 : (open = !open); ct.style.display = op ? '' : 'none'; t.querySelector('.cimg').style.display = op ? 'none' : ''; } return; } // custom character: swap between its two frames
|
|
1150
|
+
if (!m) { const ct = t.querySelector('.cimg-talk'); if (ct) { const lv0 = S.level[id], op = lv0 ? lv0() > 0.05 : (open = !open); ct.style.display = op ? '' : 'none'; t.querySelector('.cimg').style.display = op ? 'none' : ''; if (S.present === id) { const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi) { pc.style.display = op ? '' : 'none'; pi.style.display = op ? 'none' : ''; } } } return; } // custom character: swap between its two frames
|
|
1151
1151
|
const pm = S.present === id ? $('#pcam .mouthE') : null, lv = S.level[id]; if (lv) { const v = lv(); const ry = (.9 + Math.min(4.2, v * 28)).toFixed(1), rx = (4.8 - Math.min(1.1, v * 6)).toFixed(1); m.setAttribute('ry', ry); m.setAttribute('rx', rx); if (pm) { pm.setAttribute('ry', ry); pm.setAttribute('rx', rx); } }
|
|
1152
1152
|
else { open = !open; m.setAttribute('ry', open ? 4.2 : 1.1); m.setAttribute('rx', open ? 4 : 4.7); if (pm) { pm.setAttribute('ry', open ? 4.2 : 1.1); pm.setAttribute('rx', open ? 4 : 4.7); } }
|
|
1153
1153
|
}, 70));
|
|
@@ -1170,6 +1170,7 @@ function stopTalk(id) {
|
|
|
1170
1170
|
const t = $('#t-' + id); if (!t) return;
|
|
1171
1171
|
t.classList.remove('speaking'); const m = t.querySelector('.mouthE'); if (m) { m.setAttribute('ry', .9); m.setAttribute('rx', 4.6); }
|
|
1172
1172
|
const ct = t.querySelector('.cimg-talk'); if (ct) { ct.style.display = 'none'; t.querySelector('.cimg').style.display = ''; }
|
|
1173
|
+
const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi && S.present === id) { pc.style.display = 'none'; pi.style.display = ''; }
|
|
1173
1174
|
}
|
|
1174
1175
|
const clearSpeaking = () => [...S.speakers].forEach(stopTalk);
|
|
1175
1176
|
|
|
@@ -1447,7 +1448,7 @@ async function fillProjects() { // live view: follow the latest activity anywh
|
|
|
1447
1448
|
sel.innerHTML = `<option value="">${L('Tous les projets (le plus récent)', 'All projects (most recent)')}</option>` + rows.map((r) => `<option value="${esc(r.proj)}">${esc(niceProj(r.proj))}</option>`).join('');
|
|
1448
1449
|
sel.value = prefs.proj && rows.some((r) => r.proj === prefs.proj) ? prefs.proj : ''; sel.style.display = rows.length > 1 ? '' : 'none';
|
|
1449
1450
|
}
|
|
1450
|
-
$('#liveProj').onchange = () => { prefs.proj = $('#liveProj').value; S.pin = ''; savePrefs(); S.cur = null; followCurrent(); };
|
|
1451
|
+
$('#liveProj').onchange = () => { Q.delete('proj'); prefs.proj = $('#liveProj').value; S.pin = ''; savePrefs(); S.cur = null; followCurrent(); };
|
|
1451
1452
|
async function followCurrent() {
|
|
1452
1453
|
const cur = await (await fetch('/api/current?proj=' + encodeURIComponent(Q.get('proj') || prefs.proj || '') + (S.pin ? '&id=' + encodeURIComponent(S.pin) : ''))).json();
|
|
1453
1454
|
if (!cur.id) { $('#empty').classList.add('on'); return false; } $('#empty').classList.remove('on');
|
|
@@ -1629,7 +1630,7 @@ function setView(v) { prefs.view = v; savePrefs(); document.body.classList.toggl
|
|
|
1629
1630
|
|
|
1630
1631
|
$('#viewSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setView(b.dataset.v); };
|
|
1631
1632
|
$('#langSel').onchange = () => setLang($('#langSel').value, true);
|
|
1632
|
-
$('#charReload').onclick = async () => { await loadChars(); for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
|
|
1633
|
+
$('#charReload').onclick = async () => { await loadChars(); for (const k in imgCache) delete imgCache[k]; for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
|
|
1633
1634
|
$('#charOpen').onclick = () => window.parent.postMessage({ agentStandup: 'openCharacters' }, '*');
|
|
1634
1635
|
$('#modeSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setMode(b.dataset.m); };
|
|
1635
1636
|
$('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
|
package/server.js
CHANGED
|
@@ -229,7 +229,7 @@ function parseRecord(st, r) {
|
|
|
229
229
|
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) st.raw.push({ ts, agent, kind: 'say', text: b.text.trim() });
|
|
230
230
|
else if (b.type === 'tool_use') {
|
|
231
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) || '' });
|
|
232
|
+
if (b.name === 'Agent' || b.name === 'Task') st.raw.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) || '' });
|
|
233
233
|
else st.raw.push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts) });
|
|
234
234
|
}
|
|
235
235
|
}
|
|
@@ -248,7 +248,7 @@ function buildTimeline(proj, id) {
|
|
|
248
248
|
const subDir = path.join(base, id, 'subagents');
|
|
249
249
|
if (fs.existsSync(subDir)) {
|
|
250
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')));
|
|
251
|
+
const aid = f.replace(/^agent-/, '').replace(/\.jsonl$/, '').replace(/[^\w.-]/g, '_'), m = readMeta(path.join(subDir, f.replace(/\.jsonl$/, '.meta.json')));
|
|
252
252
|
agents[aid] = { id: aid, role: m.description || m.agentType || 'Agent', type: m.agentType || 'agent', toolUseId: m.toolUseId };
|
|
253
253
|
try { agents[aid].mtime = fs.statSync(path.join(subDir, f)).mtimeMs; } catch {}
|
|
254
254
|
const st = readNew(path.join(subDir, f), aid); if (st) states.push(st);
|
|
@@ -267,7 +267,7 @@ function buildTimeline(proj, id) {
|
|
|
267
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
268
|
st.raw.forEach((e, i) => {
|
|
269
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 }); } }
|
|
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', toolId: e.toolId, bg: e.bg, text: e.text, title: e.title }); } }
|
|
271
271
|
else if (i === lastSay) events.push({ ...e, kind: 'report', to: null }); // `to` filled below, once every order is known
|
|
272
272
|
else events.push(e);
|
|
273
273
|
});
|
|
@@ -336,7 +336,7 @@ function claudeMap(kind, instruction, texts) {
|
|
|
336
336
|
if (!need.length) return Promise.resolve(done(true));
|
|
337
337
|
const prompt = instruction + ' Output ONLY a JSON array of strings, same length and order, no markdown.\n' + JSON.stringify(need);
|
|
338
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));
|
|
339
|
+
const p = spawnClaude(['-p', '--model', 'haiku', '--no-session-persistence', '--tools', ''], { cwd: os.tmpdir() }); if (!p) return resolve(done(false));
|
|
340
340
|
let out = ''; const timer = setTimeout(() => p.kill(), 60000);
|
|
341
341
|
p.stdout.on('data', (d) => (out += d));
|
|
342
342
|
p.on('error', () => resolve(done(false)));
|
|
@@ -360,7 +360,7 @@ const TR_SYS = 'You are a translation engine. Translate the user message into na
|
|
|
360
360
|
class TrWorker {
|
|
361
361
|
constructor() { this.q = []; this.busy = false; this.n = 0; this.p = null; this.cur = null; this.buf = ''; }
|
|
362
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() });
|
|
363
|
+
const p = spawnClaude(['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--model', 'haiku', '--no-session-persistence', '--tools', '', '--system-prompt', TR_SYS], { cwd: os.tmpdir() });
|
|
364
364
|
if (!p) return;
|
|
365
365
|
this.p = p; this.buf = ''; this.n = 0;
|
|
366
366
|
p.stderr.on('data', (d) => { lastTrError = String(d).slice(-300); });
|
|
@@ -422,7 +422,8 @@ function listVoices() {
|
|
|
422
422
|
} catch {}
|
|
423
423
|
return (sayVoices = { available: voices.length > 0, voices, platform: process.platform });
|
|
424
424
|
}
|
|
425
|
-
const ttsDir = path.join(os.tmpdir(), 'agent-standup-tts');
|
|
425
|
+
const ttsDir = path.join(os.tmpdir(), 'agent-standup-tts-' + (process.getuid ? process.getuid() : 'user'));
|
|
426
|
+
try { fs.mkdirSync(ttsDir, { recursive: true, mode: 0o700 }); } catch {}
|
|
426
427
|
const ttsJobs = new Map();
|
|
427
428
|
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
429
|
function synthesize(voice, wpm, text, out) {
|
|
@@ -456,9 +457,10 @@ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css
|
|
|
456
457
|
// 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
458
|
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
459
|
const charactersDir = () => process.env.AGENT_STANDUP_CHARACTERS || path.join(os.homedir(), '.agent-standup', 'characters');
|
|
460
|
+
// lstat, not stat: a symlink is not a regular file, so it is skipped and never served
|
|
459
461
|
function listCharacters() {
|
|
460
462
|
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.
|
|
463
|
+
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);
|
|
462
464
|
const stem = (f) => f.replace(/\.[^.]+$/, ''), by = new Map(ok.map((f) => [stem(f).toLowerCase(), f]));
|
|
463
465
|
const chars = ok.filter((f) => !/-talk$/i.test(stem(f))).map((f) => { const st = stem(f), l = st.toLowerCase();
|
|
464
466
|
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));
|
|
@@ -473,15 +475,18 @@ function start(port = PORT) {
|
|
|
473
475
|
let originOk = true; try { originOk = !req.headers.origin || local(new URL(req.headers.origin).host); } catch { originOk = false; }
|
|
474
476
|
if (!local(req.headers.host) || !originOk) { res.writeHead(403); return res.end('forbidden'); }
|
|
475
477
|
const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
|
478
|
+
// Endpoints with side effects (they spawn programs or claim the sound) refuse requests that a browser marks as coming from another site, e.g. an <img> on a web page.
|
|
479
|
+
const sfs = req.headers['sec-fetch-site'];
|
|
480
|
+
if (sfs && sfs !== 'same-origin' && sfs !== 'none' && ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts'].includes(url.pathname)) { res.writeHead(403); return res.end('forbidden'); }
|
|
481
|
+
const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
|
|
482
|
+
let body = '', over = false; req.setTimeout(10000, () => req.destroy());
|
|
483
|
+
req.on('data', (d) => { if (over) return; body += d; if (body.length > 262144) { over = true; res.writeHead(413); res.end('too large'); req.destroy(); } });
|
|
484
|
+
req.on('end', () => { if (!over) cb(body); });
|
|
485
|
+
};
|
|
486
|
+
const textList = (x) => (Array.isArray(x) ? x : []).slice(0, 200).map((t) => String(t).slice(0, 2000));
|
|
476
487
|
try {
|
|
477
|
-
if (url.pathname === '/api/translate' && req.method === 'POST') {
|
|
478
|
-
|
|
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
|
-
}
|
|
488
|
+
if (url.pathname === '/api/translate' && req.method === 'POST') return readBody(async (body) => { try { json(await translate(textList(JSON.parse(body).texts))); } catch (e) { json({ error: String(e) }, 400); } });
|
|
489
|
+
if (url.pathname === '/api/quips' && req.method === 'POST') return readBody(async (body) => { try { const b = JSON.parse(body); json(await quips(textList(b.texts), b.lang === 'fr' ? 'fr' : 'en')); } catch (e) { json({ error: String(e) }, 400); } });
|
|
485
490
|
if (url.pathname === '/api/sessions') return json(listSessions());
|
|
486
491
|
if (url.pathname === '/api/projects') { // projects (working folders) ordered by latest activity — lets the live view follow one of them
|
|
487
492
|
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); }
|
|
@@ -523,15 +528,16 @@ function start(port = PORT) {
|
|
|
523
528
|
if (url.pathname.startsWith('/characters/')) { // only plain image files of the characters folder, by exact name
|
|
524
529
|
let f = ''; try { f = decodeURIComponent(url.pathname.slice('/characters/'.length)); } catch {}
|
|
525
530
|
const dir = charactersDir(), fp = path.join(dir, f);
|
|
526
|
-
try { if (!CHAR_NAME.test(f) || !fp.startsWith(dir + path.sep) ||
|
|
531
|
+
try { const ls = fs.lstatSync(fp); if (!CHAR_NAME.test(f) || !fp.startsWith(dir + path.sep) || !ls.isFile() || ls.size > CHAR_MAX) throw 0; } catch { res.writeHead(404); return res.end('not found'); }
|
|
527
532
|
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
533
|
}
|
|
529
534
|
const file = path.join(PUBLIC, url.pathname === '/' ? 'index.html' : url.pathname);
|
|
530
|
-
|
|
535
|
+
let fst = null; try { fst = fs.statSync(file); } catch {}
|
|
536
|
+
if (!file.startsWith(PUBLIC + path.sep) || !fst || !fst.isFile()) { res.writeHead(404); return res.end('not found'); }
|
|
531
537
|
res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' });
|
|
532
538
|
res.end(fs.readFileSync(file));
|
|
533
|
-
} catch (e) { json({ error: String(e.message || e) }, 500); }
|
|
534
|
-
}).listen(port, '127.0.0.1', ()
|
|
539
|
+
} catch (e) { if (res.headersSent) return res.destroy(); json({ error: String(e.message || e) }, 500); }
|
|
540
|
+
}).listen(port, '127.0.0.1', function () { console.log(`Agent Standup → http://localhost:${this.address().port}`); });
|
|
535
541
|
}
|
|
536
542
|
module.exports = { start, currentSession, shutdown, findClaude, bundledClaude, boardRows, charactersDir, listCharacters };
|
|
537
543
|
if (require.main === module) {
|