@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.
@@ -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) process.env.PORT = String(parseInt(flags.port, 10) || 4747);
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.0",
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[hash(id) % CHARS.pool.length] : null);
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 > 0) : `${arm('l')}${bodyShape(p.shape, `url(#${gid})`, c2)}${arm('r')}${face}${acc}`;
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.statSync(path.join(dir, f)); return st.isFile() && st.size <= CHAR_MAX; } catch { return false; } }).slice(0, 80);
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
- 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
- }
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) || fs.statSync(fp).size > CHAR_MAX) throw 0; } catch { res.writeHead(404); return res.end('not found'); }
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
- if (!file.startsWith(PUBLIC) || !fs.existsSync(file)) { res.writeHead(404); return res.end('not found'); }
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', () => console.log(`Agent Standup → http://localhost:${port}`));
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) {