@double_geste/agent-standup 1.25.6 → 1.25.8

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
@@ -50,30 +50,44 @@ const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(
50
50
  const IMG_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']), mediaMemo = new Map();
51
51
  const isImg = (x) => !!x && x.type === 'image' && !!x.source && x.source.type === 'base64' && typeof x.source.data === 'string' && IMG_TYPES.has(x.source.media_type);
52
52
  const resultImages = (c) => (Array.isArray(c) ? c.filter(isImg) : []);
53
+ const mtimeOf = (f) => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } }; // 0 for a file that is gone, or a symlink that points nowhere: never an exception
54
+ // Every sub-agent transcript of a session: <id>/subagents/*.jsonl, plus the ones a workflow launched, one folder deeper in <id>/subagents/workflows/<run>/agent-*.jsonl (each run also keeps a journal.jsonl, which is not an agent).
55
+ function subFiles(proj, id) {
56
+ const sd = path.join(ROOT, proj, id, 'subagents'), out = [], seen = new Set(['main', 'user']); out.dirs = [sd]; out.journals = [];
57
+ const add = (dir, f, run) => { let aid = f.replace(/^agent-/, '').replace(/\.jsonl$/, '').replace(/[^\w.-]/g, '_'); const id0 = aid; if (seen.has(aid)) aid = (run || 'agent').replace(/[^\w.-]/g, '_') + '.' + aid; seen.add(aid); out.push({ file: path.join(dir, f), aid, id0, run }); }; // an id is the file name; the same name in two folders gets its run in front
58
+ let names; try { names = fs.readdirSync(sd); } catch { return out; }
59
+ for (const f of names) if (f.endsWith('.jsonl')) add(sd, f, '');
60
+ if (!names.includes('workflows')) return out;
61
+ const wd = path.join(sd, 'workflows'); let runs = []; try { runs = fs.readdirSync(wd).sort(); } catch {} out.dirs.push(wd);
62
+ for (const run of runs) {
63
+ const dir = path.join(wd, run); let fl; try { fl = fs.readdirSync(dir).sort(); } catch { continue; } out.dirs.push(dir);
64
+ for (const f of fl) { if (/^agent-.*\.jsonl$/.test(f)) add(dir, f, run); else if (f === 'journal.jsonl') out.journals.push({ file: path.join(dir, f), run }); }
65
+ }
66
+ return out;
67
+ }
53
68
  function mediaFile(proj, sid, agent) {
54
69
  if (agent === 'main') return path.join(ROOT, proj, sid + '.jsonl');
55
- const dir = path.join(ROOT, proj, sid, 'subagents'); let hit = null;
56
- try { hit = fs.readdirSync(dir).find((x) => x.endsWith('.jsonl') && x.replace(/^agent-/, '').replace(/\.jsonl$/, '').replace(/[^\w.-]/g, '_') === agent); } catch {}
57
- return hit ? path.join(dir, hit) : null;
70
+ const hit = subFiles(proj, sid).find((x) => x.aid === agent);
71
+ return hit ? hit.file : null;
58
72
  }
59
73
  function toolImage(file, toolId, n) { // looks for the tool's result line in the transcript, 8 MB at a time; the few pictures asked last are kept in memory
60
- const key = file + '|' + toolId + '|' + n; if (mediaMemo.has(key)) return mediaMemo.get(key);
61
- const needle = Buffer.from(toolId), mark = Buffer.from('"image"'); let fd, found = null, carry = Buffer.alloc(0), pos = 0;
74
+ const key = file + '|' + toolId + '|' + n, memo = mediaMemo.get(key); if (memo && memo.buf) return memo;
75
+ const needle = Buffer.from('"' + toolId + '"'), mark = Buffer.from('"image"'), until = Date.now() + 2000; let fd, found = null, carry = Buffer.alloc(0), pos = 0, size = 0; // the id between its quotes, so a made-up one-letter id does not match on every line; and never more than 2 s of searching
62
76
  try {
63
- fd = fs.openSync(file, 'r'); const size = fs.fstatSync(fd).size;
64
- while (pos < size && !found) {
77
+ fd = fs.openSync(file, 'r'); size = fs.fstatSync(fd).size; if (memo && memo.miss === size) return null; // already searched in vain, and nothing was written since
78
+ while (pos < size && !found && Date.now() < until) {
65
79
  const buf = Buffer.alloc(Math.min(8 * 1024 * 1024, size - pos)); fs.readSync(fd, buf, 0, buf.length, pos); pos += buf.length;
66
80
  const all = carry.length ? Buffer.concat([carry, buf]) : buf; let from = 0;
67
- for (let hit; !found && (hit = all.indexOf(needle, from)) >= 0;) {
81
+ for (let hit; !found && (hit = all.indexOf(needle, from)) >= 0 && Date.now() < until;) {
68
82
  const a = all.lastIndexOf(10, hit) + 1, b = all.indexOf(10, hit); if (b < 0) break; // the line goes on in the next chunk
69
- from = b + 1; const m = all.indexOf(mark, a); if (m < 0 || m > b) continue;
83
+ from = b + 1; if (all.subarray(a, b).indexOf(mark) < 0) continue;
70
84
  let r; try { r = JSON.parse(all.subarray(a, b).toString('utf8')); } catch { continue; }
71
85
  for (const blk of blocks(r)) if (blk.type === 'tool_result' && blk.tool_use_id === toolId) { const im = resultImages(blk.content)[n]; if (im) found = { type: im.source.media_type, buf: Buffer.from(im.source.data, 'base64') }; }
72
86
  }
73
87
  carry = all.subarray(all.lastIndexOf(10) + 1);
74
88
  }
75
89
  } catch {} finally { if (fd != null) fs.closeSync(fd); }
76
- if (found) { mediaMemo.set(key, found); if (mediaMemo.size > 12) mediaMemo.delete(mediaMemo.keys().next().value); }
90
+ mediaMemo.delete(key); mediaMemo.set(key, found || { miss: size }); if (mediaMemo.size > 12) mediaMemo.delete(mediaMemo.keys().next().value);
77
91
  return found;
78
92
  }
79
93
  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 };
@@ -95,10 +109,8 @@ function listSessions0() {
95
109
  try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
96
110
  for (const f of files) {
97
111
  const id = f.replace(/\.jsonl$/, '');
98
- const st = fs.statSync(path.join(dir, f));
99
- const subDir = path.join(dir, id, 'subagents');
100
- const agents = fs.existsSync(subDir) ? fs.readdirSync(subDir).filter((x) => x.endsWith('.jsonl')).length : 0;
101
- rows.push({ proj, id, mtime: st.mtimeMs, size: st.size, agents });
112
+ let st; try { st = fs.statSync(path.join(dir, f)); } catch { continue; } // a link to a transcript that is gone
113
+ rows.push({ proj, id, mtime: st.mtimeMs, size: st.size, agents: subFiles(proj, id).length });
102
114
  }
103
115
  }
104
116
  rows.sort((a, b) => b.mtime - a.mtime);
@@ -114,27 +126,48 @@ function stamp(proj, id) {
114
126
  const base = path.join(ROOT, proj); let n = 0;
115
127
  const add = (f) => { try { const s = fs.statSync(f); n += s.mtimeMs + s.size; } catch {} };
116
128
  add(path.join(base, id + '.jsonl'));
117
- const sd = path.join(base, id, 'subagents');
118
- if (fs.existsSync(sd)) for (const f of fs.readdirSync(sd)) add(path.join(sd, f));
129
+ const subs = subFiles(proj, id); // the transcripts and the folders they are in (a folder changes when a file appears in it, e.g. an agent's meta file), not every file around them
130
+ for (const x of subs) add(x.file); for (const x of subs.journals) add(x.file); for (const d of subs.dirs) n += mtimeOf(d);
119
131
  return n;
120
132
  }
121
- const tlCache = new Map(); // "proj/id" → { st (file stamp), tl, at, ms, files[], json?, gz? }
133
+ const tlCache = new Map(); // "proj/id" → { st (file stamp), tl, at, ms, files[], feed, sq, json?, gz?, idle? }
122
134
  const MAX_CACHED_SESSIONS = 3;
123
135
  function timelineEntry(proj, id) {
124
136
  const key = proj + '/' + id, st = stamp(proj, id), hit = tlCache.get(key), now = Date.now();
125
- if (hit && hit.st === st && !(hit.recentUntil && now > hit.recentUntil)) return hit; // a sub-agent's 4-minute silence rule can flip without any file change
126
- if (hit && now - hit.at < Math.max(250, hit.ms * 2)) return hit; // very big sessions: don't rebuild on every poll
127
- const t0 = Date.now(), tl = buildTimeline(proj, id), base = path.join(ROOT, proj), sd = path.join(base, id, 'subagents');
128
- const files = [path.join(base, id + '.jsonl')]; try { for (const f of fs.readdirSync(sd)) files.push(path.join(sd, f)); } catch {}
129
- const entry = { st, tl, at: Date.now(), ms: Date.now() - t0, files, recentUntil: tl.recentUntil || 0 };
137
+ if (hit && ((hit.st === st && !(hit.recentUntil && now > hit.recentUntil)) || now - hit.at < Math.max(250, hit.ms * 2))) { // nothing was written (a sub-agent's 4-minute silence rule can flip without any file change); or a very big session, which is not rebuilt on every poll
138
+ tlCache.delete(key); tlCache.set(key, hit); return hit; // asked for again: the last one to be dropped
139
+ }
140
+ const t0 = Date.now(), files = [], tl = buildTimeline(proj, id, files), feed = feedStep(hit && hit.feed, tl, files);
141
+ const entry = { st, tl, at: Date.now(), ms: Date.now() - t0, files, feed, sq: feed.sq, recentUntil: tl.recentUntil || 0 };
130
142
  tlCache.delete(key); tlCache.set(key, entry);
131
- while (tlCache.size > MAX_CACHED_SESSIONS) { // free the memory of sessions nobody is looking at any more
143
+ while (tlCache.size > MAX_CACHED_SESSIONS) { // free the memory of sessions nobody is looking at any more (their live feed goes with them: a page still on one of them is told to load it again)
132
144
  const [oldKey, old] = tlCache.entries().next().value; tlCache.delete(oldKey);
133
145
  for (const f of old.files) fileStates.delete(f);
134
146
  }
135
147
  return entry;
136
148
  }
149
+ // ---- Live feed. The timeline is sorted again at each build and events already sent can change (an order typed mid-turn is stamped at typing time and lands before what the page has; a sub-agent's last line becomes its report; a tool call joins an earlier beat),
150
+ // so a page cannot ask for "what comes after index n". Every event has a key `k` that never changes, each build that changes something gets the next sequence number, and a page asks for the events that are new or different since the number it holds.
151
+ // `ep` names this memory: it is a new one after a restart, after the session left the cache, or when a transcript was rewritten, and the page then loads the whole session again.
152
+ const sigOf = (e) => e.kind + '|' + e.agent + '|' + (e.to || '') + '|' + (e.end || '') + '|' + (e.kind === 'tools' ? e.tools.length + ':' + e.tools[e.tools.length - 1].id : e.kind === 'usage' ? e.u.i + ',' + e.u.o + ',' + e.u.r + ',' + e.u.w + ',' + e.model : e.kind === 'order' ? e.toolId + ',' + e.bg + ',' + e.title : e.kind === 'finish' ? e.ts : ''); // everything about an event that can differ from one build to the next. Its text, its tool calls, its results and its time never do: they come from one transcript record (same key = same record); and `t` moves only when time zero does, which feedStep looks at once for all
153
+ function feedStep(prev, tl, files) {
154
+ let f = prev && files.every((x) => { const was = prev.gens.get(x); return was === undefined || was === (fileStates.get(x) || {}).gen; }) ? prev : null; // a transcript read again from its start numbers its events again: the keys no longer mean the same thing
155
+ for (let pass = 0; ; pass++) {
156
+ if (!f) f = { ep: crypto.randomBytes(6).toString('hex'), seq: 0, known: new Map(), gens: new Map() };
157
+ const n = tl.events.length, sq = new Uint32Array(n), seq = f.seq + 1, before = f.known.size, z = tl.events.find((e) => e.kind !== 'usage' && e.kind !== 'finish' && e.kind !== 'compact'), t0 = z ? z.ts : 0, moved = f.t0 !== t0; let hits = 0, changed = false; f.t0 = t0; // time zero moved (something now comes before the first line): every `t` is different
158
+ for (let i = 0; i < n; i++) {
159
+ const e = tl.events[i], s = sigOf(e), was = f.known.get(e.k);
160
+ if (was) { hits++; if (was.s !== s || moved) { was.s = s; was.q = seq; changed = true; } sq[i] = was.q; } else { f.known.set(e.k, { s, q: seq }); sq[i] = seq; changed = true; }
161
+ }
162
+ if (hits < before && !pass) { f = null; continue; } // an event the page may hold no longer exists: nothing can tell it to drop it, so everything starts over
163
+ if (changed) f.seq = seq;
164
+ for (const x of files) f.gens.set(x, (fileStates.get(x) || {}).gen);
165
+ f.sq = sq; tl.ep = f.ep; tl.seq = f.seq; return f;
166
+ }
167
+ }
137
168
  const timeline = (proj, id) => timelineEntry(proj, id).tl;
169
+ const demoEntry = () => { const tl = require('./demo.js')(); tl.events.forEach((e, i) => { e.k = 'd:' + i; }); tl.ep = 'demo'; tl.seq = 1; return { tl }; }; // the built-in session never changes: one sequence number for all of it
170
+ const feedSince = (entry, after) => { const ev = entry.tl.events, sq = entry.sq, out = []; if (after < entry.tl.seq) for (let i = 0; i < ev.length; i++) if ((sq ? sq[i] : 1) > after) out.push(ev[i]); return out; }; // in timeline order; nothing to scan when the page is up to date
138
171
 
139
172
  // ---- Board: one cheap row per recent session (state from the tail of its transcript), so many parallel sessions can be watched at once.
140
173
  const readTail = (file, bytes) => { const fd = fs.openSync(file, 'r'); try { const sz = fs.fstatSync(fd).size, n = Math.min(bytes, sz), buf = Buffer.alloc(n); fs.readSync(fd, buf, 0, n, sz - n); return { text: buf.toString('utf8'), cut: sz > bytes }; } finally { fs.closeSync(fd); } };
@@ -145,23 +178,32 @@ let boardMemo = { at: 0, rows: [] };
145
178
  function parseTail(file, st) {
146
179
  const sig = st.mtimeMs + ':' + st.size, hit = boardCache.get(file); if (hit && hit.sig === sig) return hit;
147
180
  const { text, cut } = readTail(file, 192 * 1024), lines = text.split('\n'); if (cut) lines.shift();
148
- const pend = new Map(); let lastType = null, lastTool = false, lastTs = 0;
181
+ const pend = new Map(); let lastType = null, lastTool = false, lastTs = 0, custom = (hit && hit.custom) || null, ai = (hit && hit.ai) || null, first = hit ? hit.first : undefined;
149
182
  for (const l of lines) {
150
183
  if (!l) continue; let r; try { r = JSON.parse(l); } catch { continue; }
184
+ if (r.type === 'custom-title' && typeof r.customTitle === 'string') custom = r.customTitle; else if (r.type === 'ai-title' && typeof r.aiTitle === 'string') ai = r.aiTitle; // a session is renamed, or named, long after its row was first shown: the latest one read wins
151
185
  const t = Date.parse(r.timestamp) || 0, bl = blocks(r);
152
186
  if (r.type === 'assistant' || r.type === 'user') { lastType = r.type; lastTool = bl.some((b) => b.type === 'tool_use'); if (t) lastTs = t; }
153
187
  for (const b of bl) { if (b.type === 'tool_use') pend.set(b.id, { name: b.name, ts: t }); else if (b.type === 'tool_result') pend.delete(b.tool_use_id); }
154
188
  }
155
- let title = hit && hit.title, tried = hit && hit.tried;
156
- if (!title && !tried) {
157
- const big = readTail(file, 256 * 1024).text, mc = [...big.matchAll(/"type":"custom-title","customTitle":"((?:[^"\\]|\\.)*)"/g)].pop(), m = mc || [...big.matchAll(/"type":"ai-title","aiTitle":"((?:[^"\\]|\\.)*)"/g)].pop(); // a title the person typed wins over the generated one
158
- if (m) { try { title = JSON.parse('"' + m[1] + '"'); } catch {} }
159
- if (!title) { const hd = readHead(file, 64 * 1024), u2 = hd.match(/"type":"user"[\s\S]{0,3000}?"type":"text","text":"(?!<)((?:[^"\\]|\\.)*)"/); if (u2) { try { title = JSON.parse('"' + u2[1] + '"').replace(/\s+/g, ' ').slice(0, 90); } catch {} } }
160
- if (!title) { const u = readHead(file, 64 * 1024).match(/"role":"user","content":"((?:[^"\\]|\\.)*)"/); if (u) { try { title = JSON.parse('"' + u[1] + '"').replace(/\s+/g, ' ').slice(0, 90); } catch {} } }
189
+ if (!hit && !custom) { // first look at this file: a little further back than the lines read above, and without needing whole lines
190
+ const big = readTail(file, 256 * 1024).text, un = (m) => { try { return m ? JSON.parse('"' + m[1] + '"') : null; } catch { return null; } };
191
+ custom = un([...big.matchAll(/"type":"custom-title","customTitle":"((?:[^"\\]|\\.)*)"/g)].pop()); if (!ai) ai = un([...big.matchAll(/"type":"ai-title","aiTitle":"((?:[^"\\]|\\.)*)"/g)].pop());
161
192
  }
162
- const out = { sig, title: title || null, tried: true, lastType, lastTool, lastTs, pending: [...pend.values()] };
193
+ if (!custom && !ai && first == null) first = firstPrompt(file) || (st.size > 64 * 1024 ? '' : null); // '' = looked for and not there; a file that has just been created is looked at again
194
+ const out = { sig, title: custom || ai || first || null, custom, ai, first, lastType, lastTool, lastTs, pending: [...pend.values()] }; // a title the person typed wins over the generated one, and both over the first prompt
163
195
  boardCache.set(file, out); if (boardCache.size > 200) boardCache.delete(boardCache.keys().next().value); return out;
164
196
  }
197
+ function firstPrompt(file) { // the first thing the person typed, read record by record: a pattern over the raw text ran from a user record into the assistant's first answer and took that as the title
198
+ const ONE = (t) => t.replace(/\s+/g, ' ').trim().slice(0, 90);
199
+ for (const l of readHead(file, 64 * 1024).split('\n')) {
200
+ if (!l) continue; let r; try { r = JSON.parse(l); } catch { r = null; }
201
+ if (r) { if (r.type !== 'user' || r.isMeta || r.isCompactSummary) continue; const t = ONE(blocks(r).filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n').replace(HARNESS, '')); if (t && t[0] !== '<' && !/^\[Request interrupted/i.test(t)) return t; continue; }
202
+ const u = /"type":"user"/.test(l) && l.match(/"role":"user","content":(?:\[\{"type":"text","text":)?"(?!<)((?:[^"\\]|\\.){1,200})/); // a first prompt too long to be whole in what was read: its start, from that same line only
203
+ if (u) { try { const t = ONE(JSON.parse('"' + u[1].replace(/\\u[0-9a-fA-F]{0,3}$/, '') + '"')); if (t) return t; } catch {} }
204
+ }
205
+ return null;
206
+ }
165
207
  function boardRows() {
166
208
  if (Date.now() - boardMemo.at < 1200) return boardMemo.rows; // several windows polling at once share one scan
167
209
  const out = boardRows0(); boardMemo = { at: Date.now(), rows: out }; return out;
@@ -183,8 +225,8 @@ function boardRows0() {
183
225
  else if (p.lastType === 'assistant' && !p.lastTool) state = 'turn';
184
226
  else state = age < 10 * 60e3 ? 'working' : 'idle';
185
227
  }
186
- let subActive = 0, subTotal = 0; const sd = path.join(ROOT, proj, id, 'subagents');
187
- try { for (const x of fs.readdirSync(sd)) if (x.endsWith('.jsonl')) { subTotal++; if (now - fs.statSync(path.join(sd, x)).mtimeMs < 90e3) subActive++; } } catch {}
228
+ let subActive = 0, subTotal = 0;
229
+ for (const x of subFiles(proj, id)) { subTotal++; if (now - mtimeOf(x.file) < 90e3) subActive++; }
188
230
  return { proj, id, title: p.title || id, mtime: st.mtimeMs, state, tool, ageMs, subActive, subTotal };
189
231
  });
190
232
  }
@@ -195,7 +237,8 @@ function writePrefs(v) { fs.mkdirSync(path.dirname(PREFS), { recursive: true });
195
237
  // Most recently active session (optionally limited to one project dir) — drives the live feed.
196
238
  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
197
239
  function sessionMtime(proj, id) {
198
- 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; }
240
+ let m = mtimeOf(path.join(ROOT, proj, id + '.jsonl')); if (!m) return 0; const subs = subFiles(proj, id);
241
+ for (const x of subs) m = Math.max(m, mtimeOf(x.file)); for (const d of subs.dirs) m = Math.max(m, mtimeOf(d)); return m; // the folders too: a new file in them is activity, as when every file around the transcripts was looked at
199
242
  }
200
243
  function currentSession(projFilter, idFilter, stick) {
201
244
  const safe = (x) => typeof x === 'string' && /^[\w.\-]+$/.test(x) && x !== '..';
@@ -210,10 +253,7 @@ function currentSession(projFilter, idFilter, stick) {
210
253
  try { files = fs.readdirSync(dir); } catch { continue; }
211
254
  for (const f of files) {
212
255
  if (!f.endsWith('.jsonl')) continue;
213
- const id = f.slice(0, -6); let m = fs.statSync(path.join(dir, f)).mtimeMs;
214
- const sd = path.join(dir, id, 'subagents');
215
- if (fs.existsSync(sd)) for (const x of fs.readdirSync(sd)) m = Math.max(m, fs.statSync(path.join(sd, x)).mtimeMs);
216
- consider(proj, id, m);
256
+ const m = sessionMtime(proj, f.slice(0, -6)); if (m) consider(proj, f.slice(0, -6), m); // 0: a link to a transcript that is gone
217
257
  }
218
258
  }
219
259
  if (!best && projFilter) return currentSession('', '', stick);
@@ -222,18 +262,24 @@ function currentSession(projFilter, idFilter, stick) {
222
262
 
223
263
  // ---- Incremental transcript reader: each .jsonl is parsed once, then only the bytes appended since are read.
224
264
  // A session is merged from its main file and one file per sub-agent; the per-file results are cached and re-assembled cheaply.
225
- const fileStates = new Map(); // path → { agent, offset, tail, raw[], toolName{}, msgUsage Map }
226
- function readNew(file, agent) {
227
- let size; try { size = fs.statSync(file).size; } catch { fileStates.delete(file); return null; }
265
+ const fileStates = new Map(); // path → { agent, gen, offset, mtime, fp, tail, raw[], toolName{}, msgUsage Map }
266
+ let fileGen = 0; // each time a transcript is read from its start it gets a new number, which is how the live feed knows its events were numbered again
267
+ const sameBytes = (file, st) => { // are the last bytes we read still where we read them? If not, the file was rewritten and not appended to
268
+ let fd; try { fd = fs.openSync(file, 'r'); const b = Buffer.alloc(st.fp.length); fs.readSync(fd, b, 0, b.length, st.offset - b.length); return b.equals(st.fp); } catch { return false; } finally { if (fd != null) fs.closeSync(fd); }
269
+ };
270
+ function readNew(file, agent, journal) {
271
+ let size, mtime; try { const s = fs.statSync(file); size = s.size; mtime = s.mtimeMs; } catch { fileStates.delete(file); return null; }
228
272
  let st = fileStates.get(file);
229
- if (!st || size < st.offset) { st = { agent, offset: 0, tail: Buffer.alloc(0), raw: [], toolName: {}, msgUsage: new Map() }; fileStates.set(file, st); }
273
+ if (st && size >= st.offset && mtime !== st.mtime && st.fp.length && !sameBytes(file, st)) st = null; // rewritten in place and not shorter: checked only when the file was written to, with one small read
274
+ if (!st || size < st.offset) { st = { agent, journal: !!journal, gen: ++fileGen, offset: 0, mtime: 0, fp: Buffer.alloc(0), tail: Buffer.alloc(0), raw: [], toolName: {}, msgUsage: new Map() }; fileStates.set(file, st); }
275
+ st.mtime = mtime;
230
276
  while (size > st.offset) { // read in chunks of at most 16 MB so a huge transcript never needs one giant buffer
231
277
  const fd = fs.openSync(file, 'r'); let buf;
232
278
  try { buf = Buffer.alloc(Math.min(16 * 1024 * 1024, size - st.offset)); fs.readSync(fd, buf, 0, buf.length, st.offset); } finally { fs.closeSync(fd); }
233
- st.offset += buf.length;
279
+ st.offset += buf.length; st.fp = Buffer.from(buf.length >= 64 ? buf.subarray(buf.length - 64) : Buffer.concat([st.fp, buf]).subarray(-64)); // a copy: a view would keep the whole chunk in memory
234
280
  const all = st.tail.length ? Buffer.concat([st.tail, buf]) : buf, cut = all.lastIndexOf(10); // only complete lines; a half-written last line waits for the next read
235
281
  if (cut < 0) { st.tail = all; continue; }
236
- st.tail = all.subarray(cut + 1);
282
+ st.tail = Buffer.from(all.subarray(cut + 1)); // a copy, for the same reason
237
283
  for (const line of all.subarray(0, cut).toString('utf8').split('\n')) {
238
284
  if (!line) continue;
239
285
  let r; try { r = JSON.parse(line); } catch { continue; }
@@ -244,83 +290,100 @@ function readNew(file, agent) {
244
290
  return st;
245
291
  }
246
292
  function parseRecord(st, r) {
247
- const agent = st.agent, ts = Date.parse(r.timestamp) || 0;
248
- if (r.type === 'system' && r.subtype === 'compact_boundary') { st.raw.push({ ts, agent, kind: 'compact' }); return; } // Claude Code compacted the conversation
293
+ const agent = st.agent, ts = Date.parse(r.timestamp) || 0, push = (e) => { e.k = agent + ':' + st.raw.length; st.raw.push(e); return e; }; // k: the event's key in the live feed = whose file + its rank in it. A file is only ever appended to, so the rank never changes (and when it is not, the whole file is read again under a new number, see readNew)
294
+ if (ts > (st.lastTs || 0)) st.lastTs = ts;
295
+ if (st.journal) { if (r.type === 'result' && r.agentId) push({ ts: 0, agent, kind: 'wfdone', child: String(r.agentId).replace(/[^\w.-]/g, '_') }); return; } // a workflow's journal: one line when an agent starts, one when it hands its result back (no time on them)
296
+ if (r.type === 'system' && r.subtype === 'compact_boundary') { push({ ts, agent, kind: 'compact' }); return; } // Claude Code compacted the conversation
249
297
  if (r.type === 'queue-operation' && typeof r.content === 'string' && r.content.includes('<task-notification>')) { // how Claude Code reports that a background sub-agent has stopped
250
298
  const id = (r.content.match(/<tool-use-id>([^<]+)<\/tool-use-id>/) || [])[1], status = (r.content.match(/<status>([^<]+)<\/status>/) || [])[1];
251
- if (id && status && status !== 'running') st.raw.push({ ts, agent, kind: 'agentdone', toolId: id });
299
+ if (id && status && status !== 'running') push({ ts, agent, kind: 'agentdone', toolId: id });
252
300
  return;
253
301
  }
254
302
  if (r.type === 'attachment' && r.attachment && r.attachment.type === 'queued_command') { // an order typed while the lead was working: Claude Code hands it over in the middle of the turn, as an attachment and not as a user message
255
303
  const q = r.attachment, human = q.origin ? q.origin.kind === 'human' : q.commandMode === 'prompt'; if (agent !== 'main' || !human) return; // the same record also carries task notifications
256
304
  const txt = (Array.isArray(q.prompt) ? q.prompt.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n') : typeof q.prompt === 'string' ? q.prompt : '').replace(HARNESS, '').trim();
257
- if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt)) st.raw.push({ ts, agent: 'user', kind: 'user', text: txt });
305
+ if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt)) push({ ts: Math.max(ts, st.lastTs || 0), agent: 'user', kind: 'user', text: txt }); // its place in the call is when the lead got it, not when it was typed (minutes earlier, during a long command): the call hears of it only now, and a line dated in the past looks "far behind" and was shown without a voice
258
306
  return;
259
307
  }
260
308
  if (r.type === 'user') {
261
- const items = []; // tool results: the real output that appeared on the agent's screen
262
- for (const b of blocks(r)) if (b.type === 'tool_result' && (st.toolName[b.tool_use_id] === 'Agent' || st.toolName[b.tool_use_id] === 'Task') && !(st.bg && st.bg[b.tool_use_id]) && !/^\s*Async agent launched/i.test(resultText(b.content))) st.raw.push({ ts, agent, kind: 'agentdone', toolId: b.tool_use_id }); // a synchronous call: the lead got the sub-agent's answer back, so it has finished (async launches answer immediately and are not a completion)
309
+ const items = [], wf = r.toolUseResult && typeof r.toolUseResult === 'object' && typeof r.toolUseResult.runId === 'string' ? r.toolUseResult.runId : ''; // tool results: the real output that appeared on the agent's screen
310
+ for (const b of blocks(r)) if (b.type === 'tool_result' && wf) push({ ts, agent, kind: 'wfrun', toolId: b.tool_use_id, run: wf }); // the answer to a Workflow call names the run it started: its agents are in subagents/workflows/<run>/
311
+ for (const b of blocks(r)) if (b.type === 'tool_result' && (st.toolName[b.tool_use_id] === 'Agent' || st.toolName[b.tool_use_id] === 'Task') && !(st.bg && st.bg[b.tool_use_id]) && !/^\s*Async agent launched/i.test(resultText(b.content))) push({ ts, agent, kind: 'agentdone', toolId: b.tool_use_id }); // a synchronous call: the lead got the sub-agent's answer back, so it has finished (async launches answer immediately and are not a completion)
263
312
  for (const b of blocks(r)) if (b.type === 'tool_result' && st.toolName[b.tool_use_id] && !NO_RESULT.has(st.toolName[b.tool_use_id])) {
264
313
  const n = st.toolName[b.tool_use_id], img = resultImages(b.content).length;
265
314
  items.push({ id: b.tool_use_id, err: !!b.is_error, out: clip(resultText(b.content).replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trimEnd(), RESULT_MAX[n] || (/^mcp__/.test(n) ? 900 : 600)), ...(img ? { img } : {}) }); // img: how many pictures came back; the page fetches them from /api/media
266
315
  }
267
- if (items.length) st.raw.push({ ts, agent, kind: 'results', items });
268
- if (agent !== 'main') return; // a sub-agent's first user message is the order, emitted from the parent's side
269
- 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
270
- if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt) && !r.isMeta) st.raw.push({ ts, agent: 'user', kind: 'user', text: txt });
316
+ if (items.length) push({ ts, agent, kind: 'results', items });
317
+ const typed = blocks(r).filter((b) => b.type === 'text').map((b) => b.text).join('\n').replace(HARNESS, ''), cut = /^\s*\[Request interrupted[^\]]*\]/i.test(typed); // what the human typed, without the blocks the harness wraps around it
318
+ if (agent !== 'main') { if (!st.prompt && typed.trim() && !st.raw.length) st.prompt = { ts, text: typed.trim() }; return; } // a sub-agent's first user message is the order, emitted from the parent's side (kept here for the agents of a workflow, whose order is in no other file)
319
+ if (cut && st.act >= 0) st.raw[st.act].end = 1; // Esc: the turn stops there, without the end_turn mark. The last thing the lead said or did closes it
320
+ const txt = typed.replace(/^\s*\[Request interrupted[^\]]*\]\s*/i, '').trim();
321
+ if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt) && !r.isMeta && !r.isCompactSummary) push({ ts, agent: 'user', kind: 'user', text: txt }); // not the summary Claude Code writes when it compacts: thousands of characters the human never typed
271
322
  } else if (r.type === 'assistant') {
272
- const m = r.message || {};
323
+ const m = r.message || {}; let said = null;
273
324
  if (m.id && m.usage) { // the same message is repeated once per content block with identical numbers: keep one per message id
274
325
  const u = m.usage, prev = st.msgUsage.get(m.id);
275
326
  st.msgUsage.set(m.id, { ts: prev ? prev.ts : ts, u: { i: u.input_tokens || 0, o: u.output_tokens || 0, r: u.cache_read_input_tokens || 0, w: u.cache_creation_input_tokens || 0 }, model: m.model });
276
327
  }
328
+ const th = () => { const t = st.th; st.th = ''; return t ? { th: t } : {}; }; // what the agent was thinking before this line or call, when Claude Code kept it (most of the time it only keeps a signature): the call shows it as an inner thought
277
329
  for (const b of blocks(r)) {
278
- if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) st.raw.push({ ts, agent, kind: 'say', text: b.text.trim() });
330
+ if (b.type === 'thinking' && typeof b.thinking === 'string' && b.thinking.trim()) st.th = clip(b.thinking.replace(/\s+/g, ' ').trim(), 900);
331
+ else if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { said = push({ ts, agent, kind: 'say', text: b.text.trim(), ...th() }); st.act = st.raw.length - 1; st.sayOf = m.id; st.sayAt = st.act; }
279
332
  else if (b.type === 'tool_use') {
280
333
  st.toolName[b.id] = b.name; if (b.input && b.input.run_in_background) (st.bg = st.bg || {})[b.id] = true;
281
- 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) || '' });
282
- else st.raw.push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts) });
334
+ if (b.name === 'Agent' || b.name === 'Task') 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) || '' });
335
+ else { push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts), ...th() }); st.act = st.raw.length - 1; }
283
336
  }
284
337
  }
338
+ if (agent === 'main' && m.stop_reason === 'end_turn') { // the lead's turn is over: Claude Code writes one record per content block, each with the message's stop reason, so the mark goes on the line this record carries (a thinking block carries none)
339
+ if (!said && m.id && st.sayOf === m.id) said = st.raw[st.sayAt]; // ...or on the line the same message brought in an earlier record
340
+ if (said) said.end = 1;
341
+ }
285
342
  }
286
343
  }
287
344
  const metaCache = new Map(); // meta file path → { mtime, value }
288
345
  function readMeta(f) {
289
- try { const mt = fs.statSync(f).mtimeMs, c = metaCache.get(f); if (c && c.mtime === mt) return c.value; const v = JSON.parse(fs.readFileSync(f, 'utf8')); metaCache.set(f, { mtime: mt, value: v }); return v; } catch { return {}; }
346
+ try { const mt = fs.statSync(f).mtimeMs, c = metaCache.get(f); if (c && c.mtime === mt) return c.value; const v = JSON.parse(fs.readFileSync(f, 'utf8')); if (metaCache.size > 3000) metaCache.clear(); metaCache.set(f, { mtime: mt, value: v }); return v; } catch { return {}; }
290
347
  }
291
348
 
292
- function buildTimeline(proj, id) {
349
+ function buildTimeline(proj, id, files = []) {
293
350
  const base = path.join(ROOT, proj), mainFile = path.join(base, id + '.jsonl');
294
351
  if (!fs.existsSync(mainFile)) throw new Error('session not found');
295
352
  const agents = { main: { id: 'main', role: 'Lead', type: 'main' } };
296
- const states = [readNew(mainFile, 'main')];
297
- const subDir = path.join(base, id, 'subagents');
298
- if (fs.existsSync(subDir)) {
299
- for (const f of fs.readdirSync(subDir).filter((x) => x.endsWith('.jsonl'))) {
300
- const aid = f.replace(/^agent-/, '').replace(/\.jsonl$/, '').replace(/[^\w.-]/g, '_'), m = readMeta(path.join(subDir, f.replace(/\.jsonl$/, '.meta.json')));
301
- agents[aid] = { id: aid, role: m.description || m.agentType || 'Agent', type: m.agentType || 'agent', toolUseId: m.toolUseId };
302
- try { agents[aid].mtime = fs.statSync(path.join(subDir, f)).mtimeMs; } catch {}
303
- const st = readNew(path.join(subDir, f), aid); if (st) states.push(st);
304
- }
353
+ const states = [readNew(mainFile, 'main')], subs = subFiles(proj, id), runs = {}; files.push(mainFile); // runs: workflow run → { agents (file name → agent id), by (who launched it), toolId, done }
354
+ for (const x of subs) {
355
+ const aid = x.aid, m = readMeta(x.file.replace(/\.jsonl$/, '.meta.json'));
356
+ agents[aid] = { id: aid, role: m.description || m.workflowPhase || m.agentType || 'Agent', type: m.agentType || 'agent', toolUseId: m.toolUseId };
357
+ if (x.run) { agents[aid].wf = x.run; if (m.workflowPhase) agents[aid].phase = m.workflowPhase; (runs[x.run] = runs[x.run] || { agents: {}, done: new Set() }).agents[x.id0] = aid; } // launched by a workflow: no Agent call of the lead points at it
358
+ agents[aid].mtime = mtimeOf(x.file) || undefined;
359
+ const st = readNew(x.file, aid); if (st) states.push(st); files.push(x.file);
305
360
  }
361
+ for (const j of subs.journals) { const st = readNew(j.file, 'wf:' + j.run, true), run = runs[j.run]; files.push(j.file); if (st && run) for (const e of st.raw) if (run.agents[e.child]) run.done.add(run.agents[e.child]); }
306
362
  const childByTool = {}; // Agent tool_use id → the sub-agent it created, so we know who ordered whom
307
363
  for (const a of Object.values(agents)) if (a.toolUseId) childByTool[a.toolUseId] = a.id;
364
+ const runByTool = {}; for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'wfrun' && runs[e.run] && !runs[e.run].by) { runs[e.run].by = e.agent; runs[e.run].toolId = e.toolId; runByTool[e.toolId] = runs[e.run]; } // Workflow tool_use id → the run it started
308
365
 
309
- const finished = new Set(), now0 = Date.now(); for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'agentdone' && childByTool[e.toolId]) finished.add(childByTool[e.toolId]);
366
+ const finished = new Set(), now0 = Date.now(); for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'agentdone') { if (childByTool[e.toolId]) finished.add(childByTool[e.toolId]); else if (runByTool[e.toolId]) for (const a of Object.values(runByTool[e.toolId].agents)) finished.add(a); } // a run that stopped: so did every agent in it
367
+ for (const run of Object.values(runs)) for (const a of run.done) finished.add(a);
310
368
  let recentUntil = 0; for (const a of Object.values(agents)) if (a.id !== 'main' && a.mtime && now0 - a.mtime <= 240000) recentUntil = Math.max(recentUntil, a.mtime + 241000);
311
369
  for (const a of Object.values(agents)) if (a.id !== 'main' && now0 - (a.mtime || 0) > 240000) finished.add(a.id); // old transcripts without a completion record: silent for 4 min counts as finished
312
- const events = [], parent = {}, doneSeen = new Set();
370
+ const events = [], parent = {}, doneSeen = new Set(), lastTs = {}; for (const st of states) if (st) lastTs[st.agent] = st.lastTs || 0;
371
+ const finish = (to, ts, by) => { if (!doneSeen.has(to)) { doneSeen.add(to); events.push({ ts, agent: to, kind: 'finish', to: by, k: 'f/' + to }); } }; // one per agent, whichever record says it first: the key is the agent, not the record
313
372
  for (const st of states) {
314
373
  if (!st) continue;
315
374
  let lastSay = -1; // the last thing a sub-agent says is its report back to the lead
316
375
  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; }
376
+ const run = agents[st.agent] && agents[st.agent].wf ? runs[agents[st.agent].wf] : null;
377
+ if (run && st.prompt) { parent[st.agent] = run.by || 'main'; events.push({ ts: st.prompt.ts, agent: parent[st.agent], to: st.agent, kind: 'order', ...(run.toolId ? { toolId: run.toolId } : {}), bg: true, wf: agents[st.agent].wf, text: st.prompt.text, title: agents[st.agent].role, k: 'o/' + st.agent }); } // the order a workflow gave is the first message of the agent's own file: shown as an order from whoever launched the workflow (the lead when nothing says who), given in the background, when the agent started
317
378
  st.raw.forEach((e, i) => {
318
- 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 }); } }
319
- 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 }); } }
379
+ if (e.kind === 'agentdone') { const to = childByTool[e.toolId], wr = runByTool[e.toolId]; if (to) finish(to, e.ts, e.agent); else if (wr) for (const a of Object.values(wr.agents)) if (!wr.done.has(a)) finish(a, Math.max(e.ts, lastTs[a] || 0), e.agent); } // the agents the journal knows about are finished below, at their own time
380
+ 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, k: e.k }); } }
381
+ else if (e.kind === 'wfrun' || e.kind === 'wfdone') return;
320
382
  else if (i === lastSay) events.push({ ...e, kind: 'report', to: null }); // `to` filled below, once every order is known
321
383
  else events.push(e);
322
384
  });
323
- for (const [, mu] of st.msgUsage) events.push({ ts: mu.ts, agent: st.agent, kind: 'usage', u: mu.u, model: mu.model });
385
+ if (run && run.done.has(st.agent) && st.lastTs) finish(st.agent, st.lastTs, parent[st.agent] || 'main'); // the journal has no times: the agent finished when it last wrote
386
+ for (const [mid, mu] of st.msgUsage) events.push({ ts: mu.ts, agent: st.agent, kind: 'usage', u: mu.u, model: mu.model, k: 'u/' + st.agent + '/' + mid });
324
387
  }
325
388
  for (const e of events) if (e.kind === 'report' && e.to === null) e.to = parent[e.agent] || 'main';
326
389
 
@@ -329,8 +392,8 @@ function buildTimeline(proj, id) {
329
392
  for (const e of events) {
330
393
  const p = lastReal >= 0 ? merged[lastReal] : null;
331
394
  if (e.kind === 'usage' || e.kind === 'finish' || e.kind === 'compact') { merged.push(e); continue; }
332
- if (e.kind === 'tool' && p && p.kind === 'tools' && p.agent === e.agent) p.tools.push(e.tool);
333
- else { merged.push(e.kind === 'tool' ? { ts: e.ts, agent: e.agent, kind: 'tools', tools: [e.tool] } : e); lastReal = merged.length - 1; }
395
+ if (e.kind === 'tool' && p && p.kind === 'tools' && p.agent === e.agent && !p.end) { p.tools.push(e.tool); if (e.end) p.end = 1; } // a beat keeps the key of its first call; one that closed a turn takes no more calls
396
+ else { merged.push(e.kind === 'tool' ? { ts: e.ts, agent: e.agent, kind: 'tools', tools: [e.tool], ...(e.end ? { end: 1 } : {}), ...(e.th ? { th: e.th } : {}), k: e.k } : e); lastReal = merged.length - 1; }
334
397
  }
335
398
  const real = merged.find((e) => e.kind !== 'usage' && e.kind !== 'finish' && e.kind !== 'compact'), first = real ? real.ts : 0, last = merged.length ? merged[merged.length - 1].ts : 0; // time zero = the first thing said or done
336
399
  return { id, proj, agents: Object.values(agents), events: merged.map((e) => ({ ...e, t: Math.max(0, e.ts - first) })), durationMs: last - first, recentUntil };
@@ -477,7 +540,7 @@ const ttsJobs = new Map();
477
540
  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
478
541
  function synthesize(voice, wpm, text, out) {
479
542
  return new Promise((resolve, reject) => {
480
- let p;
543
+ let p; text = text.replace(/\[\[[^\]]*\]\]/g, ' ').replace(/\[(?=\[)/g, '[ ').replace(/\](?=\])/g, '] '); // macOS `say` obeys commands written between [[ and ]] (a 30-second silence, another voice, another speed) and this text comes from a transcript: they are taken out, and no two brackets are left side by side
481
544
  if (MAC) p = spawn('say', ['-v', voice, '-r', String(wpm), '--data-format=LEI16@22050', '-o', out, '--', text]);
482
545
  else if (WIN) {
483
546
  const rate = Math.max(-6, Math.min(8, Math.round((wpm - 175) / 12))); // SAPI rate: -10..10
@@ -522,15 +585,17 @@ function listCharacters(pack) {
522
585
  let audioOwner = { id: null, seen: 0 };
523
586
  function start(port = PORT) {
524
587
  return http.createServer((req, res) => {
525
- const url = new URL(req.url, 'http://x');
588
+ let url; try { url = new URL(req.url, 'http://x'); } catch { res.writeHead(400); return res.end('bad request'); } // "GET //" is not an address: answered, where it used to stop the whole server
589
+ req.on('error', () => {}); res.on('error', () => {}); // a browser that goes away mid-request is not our problem
526
590
  // Only the local machine may talk to this server: blocks other websites (cross-site requests) and DNS-rebinding attempts.
527
591
  const local = (h) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(h || '');
528
592
  let originOk = true; try { originOk = !req.headers.origin || local(new URL(req.headers.origin).host); } catch { originOk = false; }
529
593
  if (!local(req.headers.host) || !originOk) { res.writeHead(403); return res.end('forbidden'); }
530
- const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
594
+ const json = (o, code = 200) => { try { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); } catch { res.destroy(); } }; // also called from callbacks that run later, where nothing would catch an exception
595
+ const sendFile = (fp) => { const s = fs.createReadStream(fp); s.on('error', () => res.destroy()); s.pipe(res); }; // a file that vanished between the check and the read ends this answer, not the server
531
596
  // Endpoints with side effects (they spawn programs or claim the sound), and the one that serves pictures from a session, refuse requests that a browser marks as coming from another site, e.g. an <img> on a web page.
532
597
  const sfs = req.headers['sec-fetch-site'];
533
- if (sfs && sfs !== 'same-origin' && sfs !== 'none' && (req.method !== 'GET' || ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/voices', '/api/kokoro', '/api/kokoro/install', '/api/media'].includes(url.pathname))) { res.writeHead(403); return res.end('forbidden'); }
598
+ if (sfs && sfs !== 'same-origin' && sfs !== 'none' && (req.method !== 'GET' || ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/prefs.js', '/api/voices', '/api/kokoro', '/api/kokoro/install', '/api/media'].includes(url.pathname))) { res.writeHead(403); return res.end('forbidden'); }
534
599
  const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
535
600
  const chunks = []; let size = 0, over = false; req.setTimeout(10000, () => req.destroy());
536
601
  if (+req.headers['content-length'] > 262144) { res.writeHead(413); res.end('too large'); return req.destroy(); }
@@ -563,7 +628,7 @@ function start(port = PORT) {
563
628
  if (url.pathname === '/api/kokoro') return json(kokoro.status());
564
629
  if (url.pathname === '/api/tts') {
565
630
  return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'))
566
- .then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); fs.createReadStream(f).pipe(res); })
631
+ .then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); sendFile(f); })
567
632
  .catch((e) => json({ error: String(e.message) }, 400));
568
633
  }
569
634
  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') + ';'); }
@@ -574,7 +639,7 @@ function start(port = PORT) {
574
639
  if (url.pathname === '/api/characters') return json(listCharacters(url.searchParams.get('pack') || ''));
575
640
  if (url.pathname === '/api/packs') return json(Object.keys(PACKS).map((name) => ({ name, n: listCharacters(name).chars.length })));
576
641
  if (url.pathname === '/api/media') {
577
- const q = (k) => url.searchParams.get(k) || '', SAFE = /^[\w.\-]+$/, n = +q('n') || 0, bad = [q('proj'), q('id'), q('agent'), q('tool')].some((v) => !SAFE.test(v) || v === '..' || v.length > 200);
642
+ const q = (k) => url.searchParams.get(k) || '', SAFE = /^[\w.\-]+$/, n = +q('n') || 0, bad = [q('proj'), q('id'), q('agent'), q('tool')].some((v) => !SAFE.test(v) || v === '..' || v.length > 200) || q('tool').length < 6; // no tool call has an id that short, and looking for one meant reading the whole transcript
578
643
  const file = bad || !Number.isInteger(n) || n < 0 || n > 9 ? null : mediaFile(q('proj'), q('id'), q('agent')), im = file && toolImage(file, q('tool'), n);
579
644
  if (!im) { res.writeHead(bad ? 400 : 404); return res.end(bad ? 'bad request' : 'not found'); }
580
645
  res.writeHead(200, { 'content-type': im.type, 'content-length': im.buf.length, 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff' }); return res.end(im.buf);
@@ -583,8 +648,8 @@ function start(port = PORT) {
583
648
  const proj = url.searchParams.get('proj') || '', sid = url.searchParams.get('id') || '', SAFE = /^[\w.\-]+$/;
584
649
  if (!SAFE.test(proj) || !SAFE.test(sid) || proj === '..' || sid === '..' || proj.length > 200 || sid.length > 200) { res.writeHead(400); return res.end('bad request'); }
585
650
  if (proj !== '_demo' && !fs.existsSync(path.join(ROOT, proj, sid + '.jsonl'))) return json({ error: 'session not found' }, 404);
586
- const entry = proj === '_demo' ? { tl: require('./demo.js')() } : timelineEntry(proj, sid), tl = entry.tl; // built-in fictional session
587
- const after = url.searchParams.get('after');
651
+ const entry = proj === '_demo' ? demoEntry() : timelineEntry(proj, sid), tl = entry.tl; // built-in fictional session
652
+ const after = url.searchParams.get('after'), ep = url.searchParams.get('ep');
588
653
  if (after == null) { // the whole timeline: serialize once per version, send gzip-compressed when the browser allows it (12 MB → ~1.5 MB)
589
654
  const gz = /\bgzip\b/.test(req.headers['accept-encoding'] || '');
590
655
  if (!entry.json) entry.json = JSON.stringify(tl);
@@ -592,14 +657,17 @@ function start(port = PORT) {
592
657
  res.writeHead(200, { 'content-type': 'application/json', ...(gz ? { 'content-encoding': 'gzip' } : {}), vary: 'accept-encoding' });
593
658
  return res.end(gz ? entry.gz : entry.json);
594
659
  }
595
- return json({ id: tl.id, agents: tl.agents, total: tl.events.length, events: tl.events.slice(+after) });
660
+ if (ep == null) return json({ id: tl.id, agents: tl.agents, total: tl.events.length, events: tl.events.slice(+after) }); // a page loaded before 1.25.7 still asks by position
661
+ const a = +after; if (ep !== tl.ep || !(a >= 0 && a <= tl.seq)) return json({ resync: true }); // not the feed this page was following (restart, session dropped from memory, transcript rewritten): it loads the session again
662
+ if (a === tl.seq) { if (!entry.idle) entry.idle = JSON.stringify({ id: tl.id, agents: tl.agents, ep: tl.ep, seq: tl.seq, events: [] }); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(entry.idle); } // nothing new, which is the answer to nearly every poll: written once
663
+ return json({ id: tl.id, agents: tl.agents, ep: tl.ep, seq: tl.seq, events: feedSince(entry, a) }); // new events and changed ones alike, whole, under the key they always had
596
664
  }
597
665
  const pk = url.pathname.match(/^\/packs\/([a-z]+)\/(.*)$/);
598
666
  if (url.pathname.startsWith('/characters/') || (pk && packDir(pk[1]))) { // only plain image files of the characters folder (or of a built-in pack), by exact name
599
667
  let f = ''; try { f = decodeURIComponent(pk ? pk[2] : url.pathname.slice('/characters/'.length)); } catch {}
600
668
  const dir = pk ? packDir(pk[1]) : charactersDir(), fp = path.join(dir, f);
601
669
  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'); }
602
- res.writeHead(200, { 'content-type': CHAR_TYPES[f.split('.').pop().toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-cache' }); return fs.createReadStream(fp).pipe(res);
670
+ res.writeHead(200, { 'content-type': CHAR_TYPES[f.split('.').pop().toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-cache' }); return sendFile(fp);
603
671
  }
604
672
  const file = path.join(PUBLIC, url.pathname === '/' ? 'index.html' : url.pathname);
605
673
  let fst = null; try { fst = fs.statSync(file); } catch {}