@double_geste/agent-standup 1.22.0 → 1.23.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/server.js CHANGED
@@ -6,6 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
8
  const crypto = require('crypto');
9
+ const kokoro = require('./kokoro.js'); // natural voices, installed on demand (see kokoro.js)
9
10
  const zlib = require('zlib');
10
11
  const { execFileSync } = require('child_process');
11
12
 
@@ -47,6 +48,8 @@ const toolLabel = (b, ts) => {
47
48
  const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x.text || '').join('\n') : '');
48
49
  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
50
  const NO_RESULT = new Set(['Agent', 'Task', 'SendMessage', 'TodoWrite']); // internal bookkeeping, nothing to show
51
+ const HARNESS = /<(system-reminder|browser_instruction|ide_selection|ide_opened_file|task-notification|command-name|command-message|command-args|local-command-stdout|local-command-caveat)>[\s\S]*?<\/\1>\s*/g; // blocks the harness wraps around what the human typed
52
+
50
53
 
51
54
  let sessionsMemo = { at: 0, rows: [] };
52
55
  function listSessions() {
@@ -155,14 +158,24 @@ function boardRows0() {
155
158
  return { proj, id, title: p.title || id, mtime: st.mtimeMs, state, tool, ageMs, subActive, subTotal };
156
159
  });
157
160
  }
161
+ // Settings live in a file, not only in the page's localStorage: the port changes between VS Code windows and reloads, and localStorage is per origin (port included).
162
+ const PREFS = process.env.AGENT_STANDUP_PREFS || (process.platform === 'win32' ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'agent-standup', 'prefs.json') : path.join(os.homedir(), '.config', 'agent-standup', 'prefs.json'));
163
+ function readPrefs() { try { const v = JSON.parse(fs.readFileSync(PREFS, 'utf8')); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } }
164
+ function writePrefs(v) { fs.mkdirSync(path.dirname(PREFS), { recursive: true }); const tmp = PREFS + '.' + process.pid + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(v, null, 1)); fs.renameSync(tmp, PREFS); }
158
165
  // Most recently active session (optionally limited to one project dir) — drives the live feed.
159
- function currentSession(projFilter, idFilter) {
166
+ const STICK_MS = 90000; // a session written to in the last 90 s keeps the call, so another session writing elsewhere does not steal it mid-line
167
+ function sessionMtime(proj, id) {
168
+ try { let m = fs.statSync(path.join(ROOT, proj, id + '.jsonl')).mtimeMs; const sd = path.join(ROOT, proj, id, 'subagents'); if (fs.existsSync(sd)) for (const x of fs.readdirSync(sd)) m = Math.max(m, fs.statSync(path.join(sd, x)).mtimeMs); return m; } catch { return 0; }
169
+ }
170
+ function currentSession(projFilter, idFilter, stick) {
171
+ const safe = (x) => typeof x === 'string' && /^[\w.\-]+$/.test(x) && x !== '..';
172
+ if (stick && safe(stick.id) && safe(stick.proj) && (!projFilter || stick.proj === projFilter || stick.proj.startsWith(projFilter + '-')) && Date.now() - sessionMtime(stick.proj, stick.id) < STICK_MS) return { proj: stick.proj, id: stick.id, mtime: sessionMtime(stick.proj, stick.id) };
160
173
  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
174
  let best = null;
162
175
  if (!fs.existsSync(ROOT)) return {};
163
176
  const consider = (proj, id, m) => { if (!best || m > best.mtime) best = { proj, id, mtime: m }; };
164
177
  for (const proj of fs.readdirSync(ROOT)) {
165
- if (projFilter && proj !== projFilter) continue;
178
+ if (projFilter && proj !== projFilter && !proj.startsWith(projFilter + '-')) continue; // the folder itself, or a folder inside it
166
179
  const dir = path.join(ROOT, proj); let files;
167
180
  try { files = fs.readdirSync(dir); } catch { continue; }
168
181
  for (const f of files) {
@@ -173,7 +186,7 @@ function currentSession(projFilter, idFilter) {
173
186
  consider(proj, id, m);
174
187
  }
175
188
  }
176
- if (!best && projFilter) return currentSession('');
189
+ if (!best && projFilter) return currentSession('', '', stick);
177
190
  return best || {};
178
191
  }
179
192
 
@@ -217,7 +230,7 @@ function parseRecord(st, r) {
217
230
  }
218
231
  if (items.length) st.raw.push({ ts, agent, kind: 'results', items });
219
232
  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();
233
+ const txt = blocks(r).filter((b) => b.type === 'text').map((b) => b.text).join('\n').replace(HARNESS, '').replace(/^\s*\[Request interrupted[^\]]*\]\s*/i, '').trim(); // what the human typed, without the blocks the harness wraps around it
221
234
  if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt) && !r.isMeta) st.raw.push({ ts, agent: 'user', kind: 'user', text: txt });
222
235
  } else if (r.type === 'assistant') {
223
236
  const m = r.message || {};
@@ -229,7 +242,7 @@ function parseRecord(st, r) {
229
242
  if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) st.raw.push({ ts, agent, kind: 'say', text: b.text.trim() });
230
243
  else if (b.type === 'tool_use') {
231
244
  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) || '' });
245
+ 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
246
  else st.raw.push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts) });
234
247
  }
235
248
  }
@@ -248,7 +261,7 @@ function buildTimeline(proj, id) {
248
261
  const subDir = path.join(base, id, 'subagents');
249
262
  if (fs.existsSync(subDir)) {
250
263
  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')));
264
+ const aid = f.replace(/^agent-/, '').replace(/\.jsonl$/, '').replace(/[^\w.-]/g, '_'), m = readMeta(path.join(subDir, f.replace(/\.jsonl$/, '.meta.json')));
252
265
  agents[aid] = { id: aid, role: m.description || m.agentType || 'Agent', type: m.agentType || 'agent', toolUseId: m.toolUseId };
253
266
  try { agents[aid].mtime = fs.statSync(path.join(subDir, f)).mtimeMs; } catch {}
254
267
  const st = readNew(path.join(subDir, f), aid); if (st) states.push(st);
@@ -267,7 +280,7 @@ function buildTimeline(proj, id) {
267
280
  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
281
  st.raw.forEach((e, i) => {
269
282
  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 }); } }
283
+ 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
284
  else if (i === lastSay) events.push({ ...e, kind: 'report', to: null }); // `to` filled below, once every order is known
272
285
  else events.push(e);
273
286
  });
@@ -336,7 +349,7 @@ function claudeMap(kind, instruction, texts) {
336
349
  if (!need.length) return Promise.resolve(done(true));
337
350
  const prompt = instruction + ' Output ONLY a JSON array of strings, same length and order, no markdown.\n' + JSON.stringify(need);
338
351
  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));
352
+ const p = spawnClaude(['-p', '--model', 'haiku', '--no-session-persistence', '--tools', ''], { cwd: os.tmpdir() }); if (!p) return resolve(done(false));
340
353
  let out = ''; const timer = setTimeout(() => p.kill(), 60000);
341
354
  p.stdout.on('data', (d) => (out += d));
342
355
  p.on('error', () => resolve(done(false)));
@@ -360,7 +373,7 @@ const TR_SYS = 'You are a translation engine. Translate the user message into na
360
373
  class TrWorker {
361
374
  constructor() { this.q = []; this.busy = false; this.n = 0; this.p = null; this.cur = null; this.buf = ''; }
362
375
  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() });
376
+ 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
377
  if (!p) return;
365
378
  this.p = p; this.buf = ''; this.n = 0;
366
379
  p.stderr.on('data', (d) => { lastTrError = String(d).slice(-300); });
@@ -387,7 +400,7 @@ class TrWorker {
387
400
  }
388
401
  }
389
402
  const trWorkers = [new TrWorker(), new TrWorker()];
390
- const shutdown = () => trWorkers.forEach((w) => w.kill());
403
+ const shutdown = () => { trWorkers.forEach((w) => w.kill()); kokoro.shutdown(); };
391
404
  process.on('exit', shutdown);
392
405
  const pickWorker = () => trWorkers.reduce((a, b) => (a.q.length + a.busy <= b.q.length + b.busy ? a : b));
393
406
  async function translateFast(texts) {
@@ -422,7 +435,8 @@ function listVoices() {
422
435
  } catch {}
423
436
  return (sayVoices = { available: voices.length > 0, voices, platform: process.platform });
424
437
  }
425
- const ttsDir = path.join(os.tmpdir(), 'agent-standup-tts');
438
+ const ttsDir = path.join(os.tmpdir(), 'agent-standup-tts-' + (process.getuid ? process.getuid() : 'user'));
439
+ try { fs.mkdirSync(ttsDir, { recursive: true, mode: 0o700 }); } catch {}
426
440
  const ttsJobs = new Map();
427
441
  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
442
  function synthesize(voice, wpm, text, out) {
@@ -437,16 +451,17 @@ function synthesize(voice, wpm, text, out) {
437
451
  p.on('error', reject); p.on('close', (code) => (code === 0 && fs.existsSync(out) ? resolve() : reject(new Error('tts failed'))));
438
452
  });
439
453
  }
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');
454
+ function tts(text, voice, rate, engine) {
455
+ const kk = engine === 'kokoro';
456
+ if (kk) { if (!kokoro.hasVoice(voice)) return Promise.reject(new Error('unknown voice')); }
457
+ else { const vs = listVoices(); if (!vs.available || !vs.voices.some((v) => v.name === voice)) return Promise.reject(new Error('unknown voice')); }
458
+ text = String(text).slice(0, kk ? 1500 : 700); rate = Math.max(90, Math.min(360, Math.round(+rate || 180)));
459
+ const key = crypto.createHash('sha1').update([kk ? 'kokoro' : 'system', voice, rate, text].join('|')).digest('hex'), file = path.join(ttsDir, key + '.wav');
445
460
  if (fs.existsSync(file)) return Promise.resolve(file);
446
461
  if (ttsJobs.has(key)) return ttsJobs.get(key);
447
462
  fs.mkdirSync(ttsDir, { recursive: true });
448
463
  const tmp = file + '.part.wav';
449
- const job = synthesize(voice, rate, text, tmp).then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
464
+ const job = (kk ? kokoro.synth(text, voice, kokoro.speedFor(rate), tmp) : synthesize(voice, rate, text, tmp)).then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
450
465
  ttsJobs.set(key, job); return job;
451
466
  }
452
467
 
@@ -456,9 +471,10 @@ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css
456
471
  // 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
472
  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
473
  const charactersDir = () => process.env.AGENT_STANDUP_CHARACTERS || path.join(os.homedir(), '.agent-standup', 'characters');
474
+ // lstat, not stat: a symlink is not a regular file, so it is skipped and never served
459
475
  function listCharacters() {
460
476
  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);
477
+ 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
478
  const stem = (f) => f.replace(/\.[^.]+$/, ''), by = new Map(ok.map((f) => [stem(f).toLowerCase(), f]));
463
479
  const chars = ok.filter((f) => !/-talk$/i.test(stem(f))).map((f) => { const st = stem(f), l = st.toLowerCase();
464
480
  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 +489,18 @@ function start(port = PORT) {
473
489
  let originOk = true; try { originOk = !req.headers.origin || local(new URL(req.headers.origin).host); } catch { originOk = false; }
474
490
  if (!local(req.headers.host) || !originOk) { res.writeHead(403); return res.end('forbidden'); }
475
491
  const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
492
+ // 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.
493
+ const sfs = req.headers['sec-fetch-site'];
494
+ if (sfs && sfs !== 'same-origin' && sfs !== 'none' && ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/voices', '/api/kokoro', '/api/kokoro/install'].includes(url.pathname)) { res.writeHead(403); return res.end('forbidden'); }
495
+ const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
496
+ let body = '', over = false; req.setTimeout(10000, () => req.destroy());
497
+ req.on('data', (d) => { if (over) return; body += d; if (body.length > 262144) { over = true; res.writeHead(413); res.end('too large'); req.destroy(); } });
498
+ req.on('end', () => { if (!over) cb(body); });
499
+ };
500
+ const textList = (x) => (Array.isArray(x) ? x : []).slice(0, 200).map((t) => String(t).slice(0, 2000));
476
501
  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
- }
502
+ 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); } });
503
+ 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
504
  if (url.pathname === '/api/sessions') return json(listSessions());
486
505
  if (url.pathname === '/api/projects') { // projects (working folders) ordered by latest activity — lets the live view follow one of them
487
506
  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); }
@@ -496,13 +515,21 @@ function start(port = PORT) {
496
515
  }
497
516
  if (url.pathname === '/api/health') return json({ claude: findClaude(), apiKey: !!process.env.ANTHROPIC_API_KEY, trError: lastTrError, platform: process.platform });
498
517
  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());
518
+ if (url.pathname === '/api/voices') {
519
+ if (url.searchParams.get('engine') === 'kokoro') { const st = kokoro.status(); if (st.installed && !st.gaveUp) kokoro.warm(); return json({ available: st.installed && !st.gaveUp, ready: st.ready, voices: kokoro.voices(), platform: process.platform, engine: 'kokoro' }); }
520
+ return json(listVoices());
521
+ }
522
+ if (url.pathname === '/api/kokoro/install' && req.method === 'POST') return json(kokoro.install());
523
+ if (url.pathname === '/api/kokoro') return json(kokoro.status());
500
524
  if (url.pathname === '/api/tts') {
501
- return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'))
525
+ return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'))
502
526
  .then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); fs.createReadStream(f).pipe(res); })
503
527
  .catch((e) => json({ error: String(e.message) }, 400));
504
528
  }
505
- if (url.pathname === '/api/current') return json(currentSession(url.searchParams.get('proj') || '', url.searchParams.get('id') || ''));
529
+ if (url.pathname === '/api/prefs.js') { res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'no-store' }); return res.end('window.SAVED_PREFS = ' + JSON.stringify(readPrefs()).replace(/</g, '\\u003c') + ';'); }
530
+ if (url.pathname === '/api/prefs' && req.method === 'PUT') return readBody((body) => { try { const v = JSON.parse(body); if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('prefs must be an object'); writePrefs(v); json({ ok: true }); } catch (e) { json({ error: String(e.message || e) }, 400); } });
531
+ if (url.pathname === '/api/prefs') return json(readPrefs() || {});
532
+ if (url.pathname === '/api/current') return json(currentSession(url.searchParams.get('proj') || '', url.searchParams.get('id') || '', { proj: url.searchParams.get('sproj') || '', id: url.searchParams.get('sid') || '' }));
506
533
  if (url.pathname === '/api/board') return json(boardRows());
507
534
  if (url.pathname === '/api/characters') return json(listCharacters());
508
535
  if (url.pathname === '/api/session') {
@@ -523,15 +550,16 @@ function start(port = PORT) {
523
550
  if (url.pathname.startsWith('/characters/')) { // only plain image files of the characters folder, by exact name
524
551
  let f = ''; try { f = decodeURIComponent(url.pathname.slice('/characters/'.length)); } catch {}
525
552
  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'); }
553
+ 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
554
  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
555
  }
529
556
  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'); }
557
+ let fst = null; try { fst = fs.statSync(file); } catch {}
558
+ if (!file.startsWith(PUBLIC + path.sep) || !fst || !fst.isFile()) { res.writeHead(404); return res.end('not found'); }
531
559
  res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' });
532
560
  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}`));
561
+ } catch (e) { if (res.headersSent) return res.destroy(); json({ error: String(e.message || e) }, 500); }
562
+ }).listen(port, '127.0.0.1', function () { console.log(`Agent Standup → http://localhost:${this.address().port}`); });
535
563
  }
536
564
  module.exports = { start, currentSession, shutdown, findClaude, bundledClaude, boardRows, charactersDir, listCharacters };
537
565
  if (require.main === module) {