@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/bin/agent-standup.js +6 -3
- package/kokoro-worker.js +25 -0
- package/kokoro.js +82 -0
- package/package.json +3 -1
- package/public/index.html +94 -30
- package/public/locales/de.json +12 -1
- package/public/locales/en.json +11 -0
- package/public/locales/es.json +12 -1
- package/public/locales/ja.json +12 -1
- package/public/locales/pt-BR.json +17 -2
- package/public/locales/zh-CN.json +12 -1
- package/public/locales/zh-TW.json +17 -6
- package/public/meme.css +60 -0
- package/public/meme.js +235 -0
- package/public/sfx/oh-my-god.mp3 +0 -0
- package/public/sfx/scratch-1.mp3 +0 -0
- package/public/sfx/scratch-2.mp3 +0 -0
- package/public/sfx/scratch-3.mp3 +0 -0
- package/server.js +61 -33
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
|
-
|
|
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
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
|
|
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.
|
|
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
|
-
|
|
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')
|
|
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/
|
|
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) ||
|
|
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
|
-
|
|
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', ()
|
|
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) {
|