@double_geste/agent-standup 1.23.1 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.23.1",
3
+ "version": "1.24.1",
4
4
  "description": "Your Claude Code agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
5
5
  "bin": {
6
6
  "agent-standup": "bin/agent-standup.js"
package/public/index.html CHANGED
@@ -85,11 +85,12 @@
85
85
  .prop.cheer { animation:cheer 1.1s ease-out both } @keyframes cheer { 0% { transform:scale(.3) translateY(10px); opacity:0 } 40% { transform:scale(1.25); opacity:1 } 100% { transform:translateY(-14px); opacity:0 } }
86
86
  /* ---------- screen sharing (presentation mode) ---------- */
87
87
  #present { display:none; flex:none; position:relative; height:clamp(190px,46vh,430px); background:radial-gradient(ellipse at 50% 0,#1b2033,#0b0d14); border-bottom:1px solid var(--line); padding:12px 16px; justify-content:center }
88
- #present.on { display:flex; animation:fadein .3s }
88
+ #present.on { display:flex; animation:fadein .3s } #present.pic { height:clamp(220px,60vh,640px) }
89
89
  .pwin { flex:0 1 900px; display:flex; flex-direction:column; border-radius:10px; overflow:hidden; background:#0d1117; box-shadow:0 18px 50px rgba(0,0,0,.6), 0 0 0 2px rgba(240,160,32,.55) }
90
90
  .pbar { display:flex; align-items:center; gap:6px; padding:7px 10px; background:#1c1f2b; font-size:11.5px; color:#9aa0b4 } .pbar i { width:10px; height:10px; border-radius:50%; display:inline-block }
91
91
  .pbar .r { background:#ff5f57 } .pbar .y { background:#febc2e } .pbar .g { background:#28c840 } #ptitle { margin:0 0 0 14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0 }
92
92
  #pbody { flex:1; overflow:hidden; padding:10px 14px; font:12.5px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; color:#d6dae6 } .pwin.editor #pbody { background:#1a1b26; padding:0 }
93
+ .pwin.image #pbody { display:flex; gap:8px; align-items:center; justify-content:center; background:#0b0c11; padding:8px } .pwin.image #pbody img { flex:0 1 auto; min-width:0; max-width:100%; max-height:100%; object-fit:contain; border-radius:4px } .pmore { color:#9aa0b4; font-size:12px } .dimg { display:block; max-width:100%; border-radius:6px; margin:4px 0 }
93
94
  .ptab { display:flex; align-items:center; gap:10px; background:#16161e; padding:5px 12px; border-bottom:1px solid #2a2b3d; font:11.5px -apple-system,system-ui,sans-serif } .ptab span { background:#1a1b26; padding:3px 10px; border-radius:6px 6px 0 0; color:#e9eaf0 } .ptab em { color:#6b7089; font-style:normal }
94
95
  .ln { display:flex; gap:10px; padding:0 12px; white-space:pre-wrap; word-break:break-all } .ln b { width:12px; flex:none; color:#7a7f98; font-weight:400 } .ln.add { background:rgba(63,185,80,.16); color:#9be9a8 } .ln.del { background:rgba(248,81,73,.16); color:#ffa198 }
95
96
  .tl { white-space:pre-wrap; word-break:break-word } .tl .ps { color:#7ee787 } .tl.cmd { color:#e6edf3 } .tl.tool { color:#8b95b0 } .dotg { color:#58a6ff }
@@ -181,7 +182,7 @@
181
182
  #gate h2, #empty h2 { margin:0; font:800 22px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif } #gate p, #empty p { margin:0; color:var(--dim); max-width:380px }
182
183
  .cta { background:var(--accent); color:#0b1020; font-weight:700; padding:10px 22px; border-radius:99px; font-size:14px } .cta:hover { filter:brightness(1.1) }
183
184
  .btn { background:var(--panel2); border:1px solid var(--line); padding:6px 12px; border-radius:8px } .btn:hover { border-color:var(--accent) }
184
- #menu { position:absolute; top:52px; right:10px; z-index:12; width:280px; background:var(--panel); border:1px solid var(--line); border-radius:12px; box-shadow:0 14px 40px rgba(0,0,0,.55); padding:6px; display:none }
185
+ #menu { position:absolute; top:52px; right:10px; z-index:12; width:280px; background:var(--panel); border:1px solid var(--line); border-radius:12px; box-shadow:0 14px 40px rgba(0,0,0,.55); padding:6px; display:none; max-height:calc(100vh - 62px); max-height:calc(100dvh - 62px); overflow-y:auto; overscroll-behavior:contain; scrollbar-width:thin } /* never taller than the window: it scrolls instead of being cut off */
185
186
  .coffee-btn { color:#ffd24a !important; background:rgba(255,210,74,.12); box-shadow:inset 0 0 0 1px rgba(255,210,74,.35) } .coffee-btn:hover { background:rgba(255,210,74,.22) !important }
186
187
  .glink { color:var(--dim); font-size:12.5px; text-decoration:underline; text-underline-offset:3px } .glink:hover { color:#ffd24a }
187
188
  #mCoffee { background:rgba(255,210,74,.10); box-shadow:inset 0 0 0 1px rgba(255,210,74,.3); margin-top:4px } #mCoffee:hover { background:rgba(255,210,74,.2) }
@@ -258,6 +259,8 @@
258
259
  <button class="mi" id="mCoffee" style="display:none"><span id="mCoffeeT"></span></button>
259
260
  <button class="mi" id="mRecap"><span data-fr="📋 Récapitulatif" data-en="📋 Recap">📋 Récapitulatif</span></button>
260
261
  <button class="mi" id="mExport"><span data-fr="🎞 Exporter la vidéo" data-en="🎞 Export video">🎞 Exporter la vidéo</span></button>
262
+ <button class="mi" id="mClip"><span data-fr="🎬 Clip à partager (25 s)" data-en="🎬 Clip to share (25 s)">🎬 Clip à partager (25 s)</span></button>
263
+ <button class="mi" data-t="hideCode"><span data-fr="Masquer code et fichiers dans les vidéos" data-en="Hide code & file names in videos">Masquer code et fichiers dans les vidéos</span><i class="sw"></i></button>
261
264
  <div class="keys">Space ⏯ · M 🔇 · T tree · R recap · L log · Esc ✕</div>
262
265
  <button class="mi" id="mBrowser" style="display:none"><span data-fr="↗ Ouvrir dans le navigateur" data-en="↗ Open in browser">↗ Ouvrir dans le navigateur</span></button>
263
266
  </div>
@@ -298,7 +301,7 @@ const Q = new URLSearchParams(location.search);
298
301
  const EMBED = Q.get('embed') === '1' || window.parent !== window;
299
302
  if (Q.get('pin')) S.pin = Q.get('pin');
300
303
 
301
- let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: false, office: false, officeVol: 0.8, vstyle: 'cartoon', notify: false, epi: false, vanish: true, music: false, station: 'fip', musicVol: 0.35, view: 'call' };
304
+ let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: false, office: false, officeVol: 0.8, vstyle: 'cartoon', notify: false, epi: false, vanish: true, music: false, station: 'fip', musicVol: 0.35, view: 'call', hideCode: false };
302
305
  { let loc = {}; try { loc = JSON.parse(localStorage.getItem('cc.prefs') || '{}') || {}; } catch {} // the settings file (shared by every window) and this origin's copy: the more recent one wins
303
306
  const sv = window.SAVED_PREFS && typeof window.SAVED_PREFS === 'object' ? window.SAVED_PREFS : {}; Object.assign(prefs, (sv._t || 0) > (loc._t || 0) ? sv : loc); }
304
307
  if (prefs.vstyle === 'babble') prefs.vstyle = 'animal', prefs.vstyleChosen = true;
@@ -1048,7 +1051,7 @@ function pushTerm(id, tool) {
1048
1051
  }
1049
1052
  function applyResults(ev, silent) { // the output of a tool call arrives
1050
1053
  const buf = S.term[ev.agent] || []; let last = null;
1051
- for (const it of ev.items) for (let i = buf.length - 1; i >= 0; i--) if (buf[i].id === it.id) { Object.assign(buf[i], { out: it.out, err: it.err, end: ev.ts }); last = buf[i]; break; }
1054
+ for (const it of ev.items) for (let i = buf.length - 1; i >= 0; i--) if (buf[i].id === it.id) { Object.assign(buf[i], { out: it.out, err: it.err, end: ev.ts, img: it.img || 0, picAt: it.img && !silent ? performance.now() : 0 }); last = buf[i]; break; }
1052
1055
  if (S.run[ev.agent] && ev.items.some((it) => it.id === S.run[ev.agent].id)) delete S.run[ev.agent];
1053
1056
  if (silent || !last) return;
1054
1057
  setTicker(ev.agent, last); if (S.present === ev.agent) { renderPresent(); armPresent(); }
@@ -1077,8 +1080,11 @@ function statOf(e, id) {
1077
1080
  if (e.end == null) { const r = S.run[id], el = r ? performance.now() - r.start : 0; return `⠋ ${e.tool.name} · ${L('en cours', 'running')}${el > 1500 ? ' ' + fmtDur(el) : ''}`; }
1078
1081
  return `${e.err ? '✗' : '✓'} ${e.tool.name} · ${fmtDur(e.end - e.ts)}`;
1079
1082
  }
1083
+ const mediaUrl = (agent, tool, n) => `/api/media?proj=${encodeURIComponent(S.cur.proj)}&id=${encodeURIComponent(S.cur.id)}&agent=${encodeURIComponent(agent)}&tool=${encodeURIComponent(tool)}&n=${n}`;
1080
1084
  function termModel(id) {
1081
1085
  const buf = S.term[id] || [], last = buf[buf.length - 1], lines = [], more = (n) => ({ c: 'dim', t: `… +${n} ${L('lignes', 'lines')}` });
1086
+ const pic = S.cur && buf.slice(-12).reverse().find((e) => e.img), fresh = pic && performance.now() - pic.picAt < (S.live ? 8000 : 2500 / spd()); // a picture a tool returned (screenshot, image file) stays up for a few seconds whatever comes next, then for the next few calls: a screenshot is usually followed by a click or two
1087
+ if (pic && (fresh || (buf.length - buf.indexOf(pic) <= 4 && (pic === last || !/^(edit|write|read)$/.test(last.k))))) return { view: 'image', title: pic.k === 'read' ? base(pic.tool.d.file) : pic.tool.name.split('__').pop(), imgs: Array.from({ length: Math.min(pic.img, 3) }, (_, n) => mediaUrl(id, pic.id, n)), more: pic.img - 3, lines, stat: statOf(last, id) };
1082
1088
  if (last && (last.k === 'edit' || last.k === 'write' || last.k === 'read')) {
1083
1089
  const d = last.tool.d, rows = (txt, c) => String(txt || '').split('\n').slice(0, 14).map((t) => ({ c, t }));
1084
1090
  if (last.k === 'read') {
@@ -1104,20 +1110,21 @@ const lineHtml = (l, view) => view === 'editor'
1104
1110
  // ---------- screen sharing: a real video-call presentation (window + presenter camera) ----------
1105
1111
  function renderPresent() {
1106
1112
  const id = S.present, p = S.agents[id]; if (!p) return; const m = termModel(id);
1107
- $('#present .pwin').className = 'pwin ' + (m.view === 'editor' ? 'editor' : 'term');
1108
- $('#ptitle').textContent = m.view === 'editor' ? m.title : `${p.name} — zsh — 96×24`;
1109
- const pb = $('#pbody');
1110
- pb.innerHTML = (m.view === 'editor' ? `<div class="ptab"><span>${esc(m.tab.file)}</span><em>${esc(m.tab.path)}</em></div>` : '') + m.lines.map((l) => lineHtml(l, m.view)).join('') + (m.view === 'term' ? '<div class="tl"><span class="ps">❯</span> <span class="cursor"></span></div>' : '');
1111
- pb.scrollTop = m.view === 'term' ? pb.scrollHeight : 0; $('#pstat').textContent = m.stat;
1113
+ $('#present .pwin').className = 'pwin ' + (m.view === 'term' ? 'term' : m.view); if ($('#present').classList.toggle('pic', m.view === 'image') !== S.picUp) { S.picUp = m.view === 'image'; layout(); } // a picture gets a taller window
1114
+ $('#ptitle').textContent = m.view === 'term' ? `${p.name} — zsh — 96×24` : m.title;
1115
+ const pb = $('#pbody'), shown = m.view === 'image' ? m.imgs.join(' ') : '';
1116
+ if (shown) { if (pb.dataset.img !== shown) pb.innerHTML = m.imgs.map((u) => `<img src="${esc(u)}" alt="">`).join('') + (m.more > 0 ? `<span class="pmore">+${m.more}</span>` : ''); } // the same pictures are left in place: redrawing them would make them blink
1117
+ else pb.innerHTML = (m.view === 'editor' ? `<div class="ptab"><span>${esc(m.tab.file)}</span><em>${esc(m.tab.path)}</em></div>` : '') + m.lines.map((l) => lineHtml(l, m.view)).join('') + (m.view === 'term' ? '<div class="tl"><span class="ps">❯</span> <span class="cursor"></span></div>' : '');
1118
+ pb.dataset.img = shown; pb.scrollTop = m.view === 'term' ? pb.scrollHeight : 0; $('#pstat').textContent = m.stat;
1112
1119
  const td = S.todos[id], el = $('#ptodo'); el.style.display = td && td.length ? '' : 'none'; $('#present').classList.toggle('has-todo', !!(td && td.length));
1113
1120
  if (td) el.innerHTML = `<h5>${L('Tâches', 'Tasks')} · ${td.filter((x) => x.s === 'completed').length}/${td.length}</h5>` + td.map((x) => `<div class="td ${x.s}"><i>${x.s === 'completed' ? '☑' : x.s === 'in_progress' ? '◐' : '☐'}</i>${esc(x.c)}</div>`).join('');
1114
1121
  $('#pbadge').textContent = `🖥 ${p.name} ${L('partage son écran', 'is sharing their screen')}`;
1115
1122
  if (S.camFor !== id) { $('#pcam').innerHTML = avatarSVG(p, undefined, 'c' + p.gid + '_') + `<div class="tag"><b>${esc(p.name)}</b></div>`; S.camFor = id; }
1116
1123
  $('#pcam').classList.toggle('speaking', S.speakers.has(id));
1117
1124
  }
1118
- function armPresent() { // stays up while a command is still running, then ~4 s after the last action
1125
+ function armPresent() { // stays up while a command is still running, then ~4 s after the last action (~9 s when it shows a picture)
1119
1126
  clearTimeout(presentTimer); if (S.hold) return;
1120
- presentTimer = setTimeout(() => { if (S.present && S.run[S.present] && S.playing) armPresent(); else hidePresent(); }, 4200 / Math.max(1, spd() / 1.2));
1127
+ presentTimer = setTimeout(() => { if (S.present && S.run[S.present] && S.playing) armPresent(); else hidePresent(); }, ($('#present').classList.contains('pic') ? 9000 : 4200) / Math.max(1, spd() / 1.2));
1121
1128
  }
1122
1129
  function showPresent(id) {
1123
1130
  if (S.boost > 1 || (S.speakers.size && !S.speakers.has(id))) return; // catching up, or someone else has the floor
@@ -1177,6 +1184,7 @@ const mouths = new Map();
1177
1184
  function startTalk(id, text, label) {
1178
1185
  const t = $('#t-' + id); if (!t) return;
1179
1186
  if (S.present && S.present !== id) hidePresent();
1187
+ S.spokeAt = performance.now(); S.fill = 0; S.mused = false;
1180
1188
  if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true);
1181
1189
  const shown0 = pieces(text), first = shown0.length > 1 ? shown0[0] : text; const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(first);
1182
1190
  if (S.present === id) { $('#pcap').textContent = first; $('#pcam').classList.add('speaking'); }
@@ -1200,7 +1208,7 @@ function confetti() {
1200
1208
  for (let i = 0; i < 26; i++) { const c = document.createElement('i'); c.className = 'confetti'; c.style.left = Math.random() * 100 + '%'; c.style.background = cols[i % cols.length]; c.style.animationDelay = Math.random() * 0.6 + 's'; c.style.animationDuration = 1.8 + Math.random() * 1.4 + 's'; box.appendChild(c); setTimeout(() => c.remove(), 4200); }
1201
1209
  }
1202
1210
  function stopTalk(id) {
1203
- captionClear(id);
1211
+ captionClear(id); S.spokeAt = performance.now();
1204
1212
  clearInterval(mouths.get(id)); mouths.delete(id); S.speakers.delete(id); if (!S.speakers.size) AUD.duck(false); if (S.focus === id && S.speakers.size) setFocus([...S.speakers][0]);
1205
1213
  if (S.present === id) { $('#pcap').textContent = ''; $('#pcam').classList.remove('speaking'); const pm = $('#pcam .mouthE'); if (pm) { pm.setAttribute('ry', .9); pm.setAttribute('rx', 4.6); } }
1206
1214
  const t = $('#t-' + id); if (!t) return;
@@ -1242,7 +1250,7 @@ function renderDetail() {
1242
1250
  const u = S.usage[id], tokHtml = u && tokTotal(u) ? `<h3>${L('Jetons', 'Tokens')} · ⚡ ${fmtTok(tokTotal(u))}</h3><div class="dim">${L('entrée', 'input')} ${fmtTok(u.i)} · ${L('sortie', 'output')} ${fmtTok(u.o)} · ${L('écriture cache', 'cache write')} ${fmtTok(u.w)} · ${L('lecture cache', 'cache read')} ${fmtTok(u.r)}</div>` : '';
1243
1251
  $('#dbody').innerHTML = tokHtml + (order ? `<h3>${L('Consigne de', 'Prompt from')} ${esc(nameOf(order.agent))}</h3><pre>${esc(order.text)}</pre>` : '') +
1244
1252
  `<h3>${L('Outils', 'Tools')} (${tools.length})</h3><div>${Object.entries(counts).map(([n, c]) => `${esc(n)} ×${c}`).join(' · ') || '—'}</div>` +
1245
- `<h3>${L('Activité récente', 'Recent activity')}</h3>` + (S.term[id] || []).slice(-30).reverse().map((e) => `<details><summary>${e.end == null ? '⠋' : e.err ? '✗' : '✓'} ${esc(e.tool.name)} <span class="dim">${e.end != null ? fmtDur(e.end - e.ts) : L('en cours', 'running')}</span> — ${esc((e.tool.arg || '').slice(-60))}</summary><pre>${esc((e.tool.d && e.tool.d.kind === 'cmd' ? '$ ' + e.tool.d.text + '\n' : '') + (e.out || ''))}</pre></details>`).join('') +
1253
+ `<h3>${L('Activité récente', 'Recent activity')}</h3>` + (S.term[id] || []).slice(-30).reverse().map((e) => `<details><summary>${e.end == null ? '⠋' : e.err ? '✗' : '✓'} ${esc(e.tool.name)} <span class="dim">${e.end != null ? fmtDur(e.end - e.ts) : L('en cours', 'running')}</span> — ${esc((e.tool.arg || '').slice(-60))}</summary><pre>${esc((e.tool.d && e.tool.d.kind === 'cmd' ? '$ ' + e.tool.d.text + '\n' : '') + (e.out || ''))}</pre>${e.img && S.cur ? `<img class="dimg" src="${esc(mediaUrl(id, e.id, 0))}" alt="" loading="lazy">` : ''}</details>`).join('') +
1246
1254
  evs.filter((e) => e.agent === id && (e.kind === 'say' || e.kind === 'report')).map((e) => `<h3>${e.kind === 'report' ? L('Rapport final', 'Final report') : L('A dit', 'Said')}</h3><pre>${esc(e.text)}</pre>`).join('');
1247
1255
  }
1248
1256
  $('#dclose').onclick = () => { S.detail = null; $('#detail').classList.remove('on'); };
@@ -1302,12 +1310,18 @@ function computeHighlights() {
1302
1310
  if (e.kind !== 'compact') prevT = e.t;
1303
1311
  const d = loopStep(R, e); if (d && loops.length < 3 && !loops.some((l) => l.agent === e.agent)) loops.push({ i, agent: e.agent, d });
1304
1312
  });
1305
- if (firstErr >= 0) out.push({ i: firstErr, t: '✗ ' + L('première erreur', 'first error') });
1306
- if (gap.ms >= 30000) out.push({ i: gap.i, t: '⏱ ' + L('plus longue attente ', 'longest wait ') + fmtDur(gap.ms) });
1307
- if (comp.length) out.push({ i: comp[0], t: `🗜 ${L('compactage', 'compaction')}${comp.length > 1 ? ' ×' + comp.length : ''}` });
1308
- loops.forEach((l) => out.push({ i: l.i, t: `🔁 ${nameOf(l.agent)} ${l.d.kind === 'errors' ? L('échoue en boucle ?', 'failing in a loop?') : L('boucle ?', 'looping?')}` }));
1313
+ if (firstErr >= 0) out.push({ i: firstErr, k: 'err', t: '✗ ' + L('première erreur', 'first error') });
1314
+ if (gap.ms >= 30000) out.push({ i: gap.i, k: 'wait', t: '⏱ ' + L('plus longue attente ', 'longest wait ') + fmtDur(gap.ms) });
1315
+ if (comp.length) out.push({ i: comp[0], k: 'compact', t: `🗜 ${L('compactage', 'compaction')}${comp.length > 1 ? ' ×' + comp.length : ''}` });
1316
+ loops.forEach((l) => out.push({ i: l.i, k: 'loop', t: `🔁 ${nameOf(l.agent)} ${l.d.kind === 'errors' ? L('échoue en boucle ?', 'failing in a loop?') : L('boucle ?', 'looping?')}` }));
1309
1317
  out.sort((a, b) => a.i - b.i); return out;
1310
1318
  }
1319
+ // Where a short clip starts: one spoken line before the first error or loop when there is one (so it is seen coming), otherwise before the lead first hands out work.
1320
+ function clipStart() {
1321
+ const ev = S.events, h = computeHighlights().find((x) => x.k === 'err' || x.k === 'loop'), at = h ? h.i : ev.findIndex((e) => e.kind === 'order'); if (at < 0) return 0;
1322
+ let i = at; while (i > 0 && at - i < 12 && !SPK.has(ev[--i].kind));
1323
+ return i;
1324
+ }
1311
1325
  function renderHighlights() {
1312
1326
  const box = $('#hl'); if (!box) return; const hs = S.live ? [] : computeHighlights();
1313
1327
  box.classList.toggle('has', hs.length > 0); box.innerHTML = hs.length ? `<span class="dim" style="font-size:11.5px">${L('Moments clés', 'Highlights')}</span>` + hs.map((h) => `<button class="hlc" data-i="${h.i}">${esc(h.t)}</button>`).join('') : '';
@@ -1371,11 +1385,49 @@ function ackLine(ev) {
1371
1385
  if (line === S.lastAck) line = pool[(seed + 1) % pool.length]; S.lastAck = line; return line;
1372
1386
  }
1373
1387
  async function react(ev, token) {
1374
- if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line;
1388
+ if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line, thanks = false;
1375
1389
  if (ev.kind === 'order') { if (!prefs.banter && seed % 100 > 55) return; who = ev.to; line = prefs.banter ? ackLine(ev) : R.ack[seed % R.ack.length]; }
1376
- else if (ev.kind === 'report') { if (!prefs.epi && seed % 100 > 35) return; if (seed % 100 > 60) return; const others = Object.keys(S.status).concat(['main']).filter((x) => x !== ev.agent && S.agents[x] && !S.gone.has(x)); who = others[(seed >>> 3) % others.length]; line = quipMem.get(baseText(ev, 160)) || R.done[seed % R.done.length]; }
1390
+ else if (ev.kind === 'report') {
1391
+ const r = seed % 100, joke = prefs.epi ? 60 : 35; // a remark from someone else, or (a little less often) a word of thanks from whoever receives the report
1392
+ if (r <= joke) { const others = Object.keys(S.status).concat(['main']).filter((x) => x !== ev.agent && S.agents[x] && !S.gone.has(x)); who = others[(seed >>> 3) % others.length]; line = quipMem.get(baseText(ev, 160)) || R.done[seed % R.done.length]; }
1393
+ else if (prefs.banter && r <= joke + 25 && ev.to !== 'user' && S.agents[ev.to] && !S.gone.has(ev.to)) { who = ev.to; line = varied('thanks', seed >>> 3); thanks = true; }
1394
+ else return;
1395
+ }
1377
1396
  else return;
1378
- if (!S.agents[who]) return; startTalk(who, line, ev.kind === 'order' && prefs.banter ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
1397
+ if (!S.agents[who]) return; startTalk(who, line, thanks || (ev.kind === 'order' && prefs.banter) ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
1398
+ }
1399
+ // Small talk: when nobody has said anything for a while, someone fills the silence the way people do in video calls. At most three per silence, further and further apart; any other line starts the count again.
1400
+ const FILL = {
1401
+ en: { share: ['Can everyone see my screen?', 'Bear with me, it’s loading.', 'Sorry, my machine is slow today.', 'Is this big enough for everyone?', 'One second… almost there.', 'Tell me if I’m going too fast.'],
1402
+ busy: ['Still here, just heads-down.', 'Sorry, I was on mute.', 'Quick update: still on it.', 'No blockers on my side.', 'Can you hear me?', 'Is it just me, or is it quiet?', 'I’ll have an update in a minute.'],
1403
+ wait: ['Can you hear me?', 'You guys still here?', 'Hello? I think you’re on mute.', 'Did we lose someone?', 'Is it my connection?', 'Shall we take this offline?', 'Let’s circle back when you’re ready.', 'I’ll give everyone a minute back.', 'Any other business?', 'Sorry, go ahead. No, you go.'] },
1404
+ fr: { share: ['Vous voyez bien mon écran ?', 'Deux secondes, ça charge.', 'Désolé, ma machine rame aujourd’hui.', 'C’est assez gros pour tout le monde ?', 'Une seconde… j’y suis presque.', 'Dites-moi si je vais trop vite.'],
1405
+ busy: ['Toujours là, je suis dessus.', 'Pardon, j’étais en muet.', 'Petit point : j’avance.', 'Pas de point bloquant de mon côté.', 'Vous m’entendez ?', 'C’est moi, ou c’est calme ?', 'Je vous fais un retour dans une minute.'],
1406
+ wait: ['Vous m’entendez ?', 'Vous êtes toujours là ?', 'Allô ? Je crois que ton micro est coupé.', 'On a perdu quelqu’un ?', 'C’est ma connexion ?', 'On en reparle en off ?', 'On refait un point quand tu veux.', 'Je vous rends une minute.', 'D’autres sujets ?', 'Pardon, vas-y. Non, toi d’abord.'] },
1407
+ };
1408
+ // A little more talk between agents: a word of thanks when a report comes in, a word from whoever has been thinking for a long time.
1409
+ const TALK = {
1410
+ en: { thanks: ['Thanks, that helps.', 'Got it, thanks.', 'Noted. I’ll take it from here.', 'Perfect, that’s what I needed.', 'Thanks, good work.', 'Good to know, thanks.', 'Understood, thank you.', 'Great, one less thing to worry about.'],
1411
+ think: ['Hmm, let me think.', 'Give me a second, I’m thinking.', 'Interesting… let me think this through.', 'One moment, I’m working it out.', 'Hang on, I want to get this right.', 'Let me double-check something.', 'Still thinking… almost there.', 'That one needs a bit of thought.'] },
1412
+ fr: { thanks: ['Merci, ça m’aide.', 'Bien reçu, merci.', 'C’est noté. Je prends la suite.', 'Parfait, c’est ce qu’il me fallait.', 'Merci, bon boulot.', 'Bon à savoir, merci.', 'Compris, merci.', 'Super, un souci de moins.'],
1413
+ think: ['Hmm, laissez-moi réfléchir.', 'Une seconde, je réfléchis.', 'Intéressant… je creuse ça.', 'Un instant, je fais le point.', 'Attendez, je veux être sûr.', 'Je vérifie un truc.', 'Je réfléchis encore… j’y suis presque.', 'Ça demande un peu de réflexion.'] },
1414
+ };
1415
+ function varied(kind, seed) { const P = TALK[FR() ? 'fr' : 'en'][kind]; let line = P[seed % P.length]; if (line === S.lastTalk) line = P[(seed + 1) % P.length]; return (S.lastTalk = line); } // never the same line twice in a row
1416
+ // Who is thinking once this event has happened: the agent whose tool just answered, the lead after your message or a report, a sub-agent that just got its order. After a line or a tool call, nobody is.
1417
+ const thinkerOf = (e) => (e.kind === 'results' ? e.agent : e.kind === 'user' ? 'main' : e.kind === 'report' || e.kind === 'finish' || e.kind === 'order' ? e.to : null);
1418
+ const before = (i) => { let k = i - 1; while (k >= 0 && (S.events[k].kind === 'usage' || S.events[k].kind === 'compact')) k--; return S.events[k]; };
1419
+ async function ponder(i, token) { // replay: an agent that really took a long time (40 s or more) before this line or call says so first, about every other time
1420
+ const ev = S.events[i], p = before(i); if (!prefs.banter || S.boost > 1 || S.hurry || !p || !S.agents[ev.agent] || thinkerOf(p) !== ev.agent || ev.ts - p.ts < 40000 || hash('t' + ev.ts) % 100 > 55) return;
1421
+ const line = varied('think', hash(String(ev.ts))); startTalk(ev.agent, line, '💭'); await speakLine(line, S.agents[ev.agent], true, token); stopTalk(ev.agent);
1422
+ }
1423
+ async function smallTalk(token) {
1424
+ const n = S.fill || 0, last = S.events[S.events.length - 1]; if (!prefs.banter || n > 2 || !last || S.speakers.size || S.hurry || S.boost > 1 || (document.hidden && !S.exporting)) return;
1425
+ const quiet = performance.now() - S.spokeAt, here = Object.keys(S.agents).filter((id) => id !== 'user' && $('#t-' + id) && $('#t-' + id).style.display !== 'none'), pick = (a) => a[Math.floor(Math.random() * a.length)];
1426
+ const p = before(S.events.length), muser = S.live && !n && !S.mused && p ? thinkerOf(p) : null, think = !!muser && here.includes(muser) && quiet > 6000 && Date.now() - p.ts > 20000 && Date.now() - p.ts < 180000; // live: 20 s without a word from whoever is thinking, once per silence
1427
+ if (!think && (quiet < (S.live ? 45000 : 12000 / spd()) * [1, 2.5, 6][n] || (S.live && Date.now() - last.ts > 6e5))) return; // live: a session untouched for ten minutes is a call that is over
1428
+ const busy = here.filter((id) => agentState(id, Date.now()).k === 'working'), who = think ? muser : S.present && here.includes(S.present) ? S.present : pick(busy.length ? busy : here); if (!who) return; // whoever shares their screen speaks, so the screen stays up
1429
+ const P = FILL[FR() ? 'fr' : 'en'][who === S.present ? 'share' : busy.length ? 'busy' : 'wait']; let line = think ? varied('think', Math.floor(Math.random() * 97)) : pick(P); if (!think && line === S.lastFill) line = P[(P.indexOf(line) + 1) % P.length]; if (!think) S.lastFill = line;
1430
+ startTalk(who, line, think ? '💭' : '💬'); if (think) S.mused = true; else S.fill = n + 1; await speakLine(line, S.agents[who], true, token); stopTalk(who);
1379
1431
  }
1380
1432
  async function card(text, sub, ms = 2500) {
1381
1433
 
@@ -1390,11 +1442,11 @@ const SPK = new Set(['say', 'report', 'order', 'user']);
1390
1442
  function aheadSpeakable(i, limit) { let n = 0; for (let k = i, m = Math.min(S.events.length, i + 800); k < m; k++) if (SPK.has(S.events[k].kind) && ++n > limit) break; return n; }
1391
1443
  const isSub = (id) => id !== 'main' && id !== 'user';
1392
1444
  async function run(i) {
1393
- const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; syncUI(); AUD.init();
1445
+ const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; S.spokeAt = performance.now(); S.fill = 0; syncUI(); AUD.init();
1394
1446
  if (i === 0 && prefs.epi && !S.live) { await card(L('ÉPISODE 1', 'EPISODE 1'), S.title); if (token !== S.token) return; }
1395
1447
  while (token === S.token) {
1396
1448
  const lim = S.stopAt || S.events.length;
1397
- if (i >= lim) { if (S.live && !S.stopAt) { if (!S.idle) { S.idle = true; syncUI(); } await sleep(350); continue; } break; }
1449
+ if (i >= lim) { if (S.live && !S.stopAt) { if (!S.idle) { S.idle = true; syncUI(); } await smallTalk(token); await sleep(350); continue; } break; }
1398
1450
  if (S.idle) { S.idle = false; syncUI(); }
1399
1451
  if (S.live) { // way behind: skip ahead to stay current, but only count what is actually said: sub-agents produce thousands of silent tool events
1400
1452
  const rem = S.events.length - i;
@@ -1407,10 +1459,11 @@ async function run(i) {
1407
1459
  S.pace = !S.live ? 0 : q > 7 ? 70 : q > 3 ? 130 : 260; S.hurry = q > 12; // voices are only skipped when more than a dozen lines are queued
1408
1460
  S.boost = S.hurry || (S.live && S.events.length - i > 60) ? 3 : 1;
1409
1461
  const ev = S.events[i]; S.idx = i; tally(ev);
1462
+ if (!S.live && (ev.kind === 'say' || ev.kind === 'report' || ev.kind === 'order' || ev.kind === 'tools')) { await ponder(i, token); if (token !== S.token) return; }
1410
1463
  if (ev.kind === 'usage' || ev.kind === 'compact') { if (ev.kind === 'compact' && S.live && prefs.banter) toast(`${nameOf(ev.agent)}: ${L('contexte compacté 🗜', 'context compacted 🗜')}`); i++; continue; }
1411
1464
  if (ev.kind === 'finish') { applyStatus(ev); paintStatus(); i++; continue; }
1412
- if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.demo ? 900 : S.live ? 30 : 80) / spd()); i++; continue; }
1413
- if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); await sleep((S.demo ? 1100 : S.live ? 110 : 250) / spd()); i++; continue; }
1465
+ if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.boost === 1 && S.present === ev.agent && ev.items.some((it) => it.img) ? (S.live ? 900 : 1800) : S.demo ? 900 : S.live ? 30 : 80) / spd()); i++; continue; } // a picture gets a moment on screen
1466
+ if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); await smallTalk(token); await sleep((S.demo ? 1100 : S.live ? 110 : 250) / spd()); i++; continue; }
1414
1467
  const group = [ev]; let j = i + 1; // sub-agents talking back-to-back were running in parallel: they talk over each other
1415
1468
  if (isSub(ev.agent) && ev.kind !== 'order') {
1416
1469
  while (j < Math.min(S.events.length, i + 28) && group.length < 3) {
@@ -1423,7 +1476,6 @@ async function run(i) {
1423
1476
  }
1424
1477
  }
1425
1478
  if (FR() && !S.hurry) { prefetchTr(i); await Promise.race([ensureTr(group.map((g) => baseText(g))), sleep(S.live ? 7000 : 14000)]); if (token !== S.token) return; }
1426
- if (group.length >= 3)
1427
1479
  prefetchQuips(i); warmTTS(i);
1428
1480
  const playOne = (g) => {
1429
1481
  applyStatus(g);
@@ -1585,6 +1637,8 @@ function avatarImg(p, m, st) { // m: 0 silent, 1 speaking (closed), 2 speaking
1585
1637
  const k = p.id + m + (st || ''); if (imgCache[k]) return imgCache[k];
1586
1638
  const im = new Image(); im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(avatarSVG(p, { m, working: st === 'working', done: st === 'done' })); return (imgCache[k] = im);
1587
1639
  }
1640
+ const mediaMem = new Map();
1641
+ function mediaImg(url) { let im = mediaMem.get(url); if (!im) { im = new Image(); im.src = url; mediaMem.set(url, im); if (mediaMem.size > 24) mediaMem.delete(mediaMem.keys().next().value); } return im; }
1588
1642
  function wrap(c, text, maxW, maxLines) {
1589
1643
  const words = String(text).split(/\s+/), lines = []; let cur = '';
1590
1644
  for (const w of words) { const t = cur ? cur + ' ' + w : w; if (c.measureText(t).width > maxW && cur) { lines.push(cur); cur = w; if (lines.length === maxLines) break; } else cur = t; }
@@ -1596,21 +1650,35 @@ function tileOnCanvas(c, id, x, y, w, h, big) {
1596
1650
  if (im.complete && im.naturalWidth) c.drawImage(im, x, y, w, h);
1597
1651
  c.lineWidth = sp ? 4 : 2; c.strokeStyle = sp ? (p.lead ? '#f5c04a' : '#7c9cff') : st === 'working' ? '#f0a020' : 'rgba(255,255,255,.14)'; c.strokeRect(x, y, w, h);
1598
1652
  c.font = `bold ${big ? 16 : 12}px sans-serif`; const nm = p.name + (st === 'done' ? ' ✓' : ''); c.fillStyle = 'rgba(0,0,0,.7)'; c.fillRect(x + 6, y + h - (big ? 30 : 22), c.measureText(nm).width + 12, big ? 24 : 18); c.fillStyle = '#fff'; c.fillText(nm, x + 12, y + h - (big ? 12 : 8));
1599
- if (!big && S.lastTool[id] && st === 'working') { c.font = '10px monospace'; c.fillStyle = 'rgba(15,20,35,.85)'; c.fillRect(x, y, w, 15); c.fillStyle = '#ffd24a'; c.fillText(S.lastTool[id].slice(0, Math.floor(w / 6)), x + 4, y + 11); }
1653
+ if (!big && S.lastTool[id] && st === 'working') { c.font = '10px monospace'; c.fillStyle = 'rgba(15,20,35,.85)'; c.fillRect(x, y, w, 15); c.fillStyle = '#ffd24a'; c.fillText((prefs.hideCode ? S.lastTool[id].split(' ')[0] : S.lastTool[id]).slice(0, Math.floor(w / 6)), x + 4, y + 11); }
1600
1654
  if (big && sp && S.say[id]) { c.font = '15px sans-serif'; const Ls = wrap(c, S.say[id].text, w - 40, 4), bh = Ls.length * 19 + 14; c.fillStyle = '#fff'; c.beginPath(); c.roundRect(x + 10, y + 10, w - 20, bh, 10); c.fill(); c.fillStyle = '#1b1d27'; Ls.forEach((l, i) => c.fillText(l, x + 20, y + 30 + i * 19)); }
1601
1655
  }
1602
- function drawFrame(cv) {
1603
- const c = cv.getContext('2d'), W = cv.width, H = cv.height; c.fillStyle = '#12131a'; c.fillRect(0, 0, W, H);
1604
- c.font = 'bold 20px "Comic Sans MS",sans-serif'; c.fillStyle = '#e9eaf0'; c.fillText('🎥 ' + (S.title || 'Agent Standup'), 20, 32);
1656
+ const SITE = 'atoll92.github.io/agent-standup';
1657
+ function drawFrame(cv, mark = 0) { // mark: 1 = a clip to share (site address in the corner), 2 = its end card
1658
+ const c = cv.getContext('2d'), W = cv.width, H = cv.height, hide = prefs.hideCode; c.fillStyle = '#12131a'; c.fillRect(0, 0, W, H); // hide: code, outputs, file names and the session title are masked; what agents say is kept
1659
+ if (mark === 2) { c.textAlign = 'center'; c.font = '900 76px "Comic Sans MS",sans-serif'; c.fillStyle = '#e9eaf0'; c.fillText('Agent Standup', W / 2, H / 2 - 24); c.font = '28px sans-serif'; c.fillStyle = '#cdd6f4'; c.fillText(L('Vos agents Claude Code en appel d’équipe', 'Your Claude Code agents as a live team call'), W / 2, H / 2 + 36); c.font = 'bold 26px sans-serif'; c.fillStyle = '#f5c04a'; c.fillText(SITE, W / 2, H / 2 + 96); c.textAlign = 'start'; return; }
1660
+ c.font = 'bold 20px "Comic Sans MS",sans-serif'; c.fillStyle = '#e9eaf0'; c.fillText('🎥 ' + ((!hide && S.title) || 'Agent Standup'), 20, 32);
1661
+ if (mark) { c.font = '13px sans-serif'; c.textAlign = 'right'; const w = c.measureText(SITE).width; c.fillStyle = '#12131a'; c.fillRect(W - 32 - w, 8, w + 32, 34); c.fillStyle = '#8b90a8'; c.fillText(SITE, W - 20, 30); c.textAlign = 'start'; }
1605
1662
  const ids = Object.keys(S.agents).filter((id) => id === 'main' || id === 'user' || (S.status[id] && !S.gone.has(id))), sp = [...S.speakers].filter((id) => S.agents[id]).slice(0, 3), big = sp.length ? sp : ['main', 'user'];
1606
- const pres = S.present && S.agents[S.present], bw = Math.min(480, (W - 40) / big.length - 12), bh = pres ? 330 : bw * 0.75;
1663
+ const pres = S.present && S.agents[S.present], m = pres && termModel(S.present), bw = Math.min(480, (W - 40) / big.length - 12), bh = pres ? (m.view === 'image' ? 420 : 330) : bw * 0.75; // a picture gets a taller window
1607
1664
  if (pres) {
1608
- const m = S.present && termModel(S.present), x = 20, y = 50, w = W - 40, ed = m.view === 'editor', col = { cmd: '#e6edf3', out: '#aab2c5', err: '#ff7b72', tool: '#8b95b0', dim: '#6b7089', run: '#ffd24a', add: '#9be9a8', del: '#ffa198', ctx: '#d6dae6' };
1665
+ const x = 20, y = 50, w = W - 40, ed = m.view === 'editor', col = { cmd: '#e6edf3', out: '#aab2c5', err: '#ff7b72', tool: '#8b95b0', dim: '#6b7089', run: '#ffd24a', add: '#9be9a8', del: '#ffa198', ctx: '#d6dae6' };
1609
1666
  c.fillStyle = ed ? '#1a1b26' : '#0d1117'; c.beginPath(); c.roundRect(x, y, w, bh, 12); c.fill(); c.fillStyle = '#1c1f2b'; c.fillRect(x, y, w, 28);
1610
1667
  ['#ff5f57', '#febc2e', '#28c840'].forEach((cl, i) => { c.fillStyle = cl; c.beginPath(); c.arc(x + 16 + i * 18, y + 14, 5, 0, 7); c.fill(); });
1611
- c.font = '13px sans-serif'; c.fillStyle = '#9aa0b4'; c.fillText(ed ? m.title : S.agents[S.present].name + ' — zsh', x + 90, y + 19); c.font = '14px monospace';
1612
- const rows = ed ? m.lines.slice(0, 13) : m.lines.slice(-13);
1613
- rows.forEach((l, i) => { const yy = y + 52 + i * 21; if (l.c === 'add' || l.c === 'del') { c.fillStyle = l.c === 'add' ? 'rgba(63,185,80,.16)' : 'rgba(248,81,73,.16)'; c.fillRect(x, yy - 15, w, 21); } c.fillStyle = col[l.c] || '#d6dae6'; c.fillText(((l.c === 'cmd' ? '❯ ' : l.c === 'tool' ? '● ' : ed && l.n ? String(l.n).padStart(4) + ' ' : l.c === 'add' ? '+ ' : l.c === 'del' ? '− ' : '') + String(l.t).split('\n')[0]).slice(0, 112), x + 14, yy); });
1668
+ c.font = '13px sans-serif'; c.fillStyle = '#9aa0b4'; c.fillText(ed ? (hide ? m.title.split(' — ').pop() : m.title) : m.view === 'image' ? (hide ? '' : m.title) : S.agents[S.present].name + ' — zsh', x + 90, y + 19); c.font = '14px monospace';
1669
+ if (m.view === 'image') { // masked like the code: a picture from the session is replaced by an empty frame
1670
+ const im = hide ? null : mediaImg(m.imgs[0]), ax = x + 14, ay = y + 38, aw = w - 238, ah = bh - 82, k = im && im.complete && im.naturalWidth ? Math.min(aw / im.naturalWidth, ah / im.naturalHeight, 1.5) : 0;
1671
+ if (k) c.drawImage(im, ax + (aw - im.naturalWidth * k) / 2, ay + (ah - im.naturalHeight * k) / 2, im.naturalWidth * k, im.naturalHeight * k);
1672
+ else { c.fillStyle = 'rgba(255,255,255,.07)'; c.beginPath(); c.roundRect(ax + aw / 2 - 170, ay + 10, 340, ah - 20, 10); c.fill(); c.font = '64px sans-serif'; c.textAlign = 'center'; c.fillStyle = '#6b7089'; c.fillText('🖼', ax + aw / 2, ay + ah / 2 + 22); c.textAlign = 'start'; }
1673
+ c.font = '14px monospace';
1674
+ }
1675
+ const rows = ed ? m.lines.slice(0, 13) : m.lines.slice(-13), cw = c.measureText('M').width;
1676
+ rows.forEach((l, i) => {
1677
+ const yy = y + 52 + i * 21, pre = l.c === 'cmd' ? '❯ ' : l.c === 'tool' ? '● ' : ed && l.n ? String(l.n).padStart(4) + ' ' : l.c === 'add' ? '+ ' : l.c === 'del' ? '− ' : '', txt = String(l.t).split('\n')[0].slice(0, 112 - pre.length);
1678
+ const keep = !hide || l.c === 'run' ? txt.length : l.c === 'tool' ? txt.indexOf('(') + 1 : 0; // masked: only our own words stay ("running…", the tool's name), the rest becomes bars of the same length
1679
+ if (l.c === 'add' || l.c === 'del') { c.fillStyle = l.c === 'add' ? 'rgba(63,185,80,.16)' : 'rgba(248,81,73,.16)'; c.fillRect(x, yy - 15, w, 21); } c.fillStyle = col[l.c] || '#d6dae6'; c.fillText(pre + txt.slice(0, keep), x + 14, yy);
1680
+ if (keep < txt.length) { c.globalAlpha = 0.4; for (const b of txt.slice(keep).matchAll(/\S+/g)) c.fillRect(x + 14 + (pre.length + keep + b.index) * cw, yy - 10, b[0].length * cw - 3, 10); c.globalAlpha = 1; }
1681
+ });
1614
1682
  c.font = '12px monospace'; c.fillStyle = '#8b95b0'; c.fillText(m.stat, x + 14, y + bh - 8);
1615
1683
  c.fillStyle = 'rgba(240,160,32,.94)'; c.beginPath(); c.roundRect(x + 12, y + bh - 34, 300, 24, 12); c.fill(); c.fillStyle = '#201400'; c.font = 'bold 13px sans-serif'; c.fillText('🖥 ' + S.agents[S.present].name + ' ' + L('partage son écran', 'is sharing their screen'), x + 24, y + bh - 17);
1616
1684
  tileOnCanvas(c, S.present, x + w - 210, y + bh - 160, 196, 147, false);
@@ -1619,20 +1687,29 @@ function drawFrame(cv) {
1619
1687
  others.forEach((id, i) => tileOnCanvas(c, id, 20 + (i % cols) * (tw + 8), ty + Math.floor(i / cols) * (th + 8), tw, th, false));
1620
1688
  const lead = sp[0] && S.say[sp[0]];
1621
1689
  if (lead) { c.font = '22px sans-serif'; const Ls = wrap(c, `${nameOf(sp[0])} : ${lead.text}`, W - 80, 2); c.fillStyle = 'rgba(0,0,0,.75)'; c.fillRect(0, H - 22 - Ls.length * 28, W, Ls.length * 28 + 22); c.fillStyle = '#fff'; Ls.forEach((l, i) => c.fillText(l, 40, H - 12 - (Ls.length - 1 - i) * 28 - 6)); }
1622
- const cd = $('#card'); if (cd.classList.contains('on')) { c.fillStyle = '#05060c'; c.fillRect(0, 0, W, H); c.textAlign = 'center'; c.font = '900 90px "Comic Sans MS",sans-serif'; c.fillStyle = '#d8372b'; c.fillText(cd.querySelector('h1').textContent, W / 2 + 5, H / 2 + 5); c.fillStyle = '#fff'; c.fillText(cd.querySelector('h1').textContent, W / 2, H / 2); c.font = '26px sans-serif'; c.fillStyle = '#cdd6f4'; c.fillText(cd.querySelector('p').textContent.slice(0, 80), W / 2, H / 2 + 60); c.textAlign = 'start'; }
1690
+ const cd = $('#card'); if (cd.classList.contains('on')) { c.fillStyle = '#05060c'; c.fillRect(0, 0, W, H); c.textAlign = 'center'; c.font = '900 90px "Comic Sans MS",sans-serif'; c.fillStyle = '#d8372b'; c.fillText(cd.querySelector('h1').textContent, W / 2 + 5, H / 2 + 5); c.fillStyle = '#fff'; c.fillText(cd.querySelector('h1').textContent, W / 2, H / 2); c.font = '26px sans-serif'; c.fillStyle = '#cdd6f4'; if (!hide) c.fillText(cd.querySelector('p').textContent.slice(0, 80), W / 2, H / 2 + 60); c.textAlign = 'start'; }
1623
1691
  }
1624
1692
  let recorder = null;
1625
- async function exportVideo(from = 0, to = 0) {
1693
+ const REC_TYPES = ['video/mp4;codecs=avc1.42E01F,mp4a.40.2', 'video/webm;codecs=vp9,opus', 'video/webm']; // MP4 (H.264 + AAC) where the browser can record it: X, phones and most chat apps refuse .webm
1694
+ async function exportVideo(from = 0, to = 0, clip = 0) { // clip: length in ms of a short cut to share; it starts where the replay is, or at clipStart() when the replay is at rest or nearly over
1626
1695
  if (recorder) { recorder.stop(); return; }
1627
- const wasLive = S.live; if (wasLive) await setMode('replay'); pause(); claimAudio(); AUD.init(); await AUD.ctx.resume(); AUD.muted = false; AUD.apply();
1696
+ const wasLive = S.live, here = !wasLive && S.idx > 0 && aheadSpeakable(S.idx, 3) >= 3 ? S.idx : -1; if (wasLive) await setMode('replay'); pause(); claimAudio(); AUD.init(); await AUD.ctx.resume(); AUD.muted = false; AUD.apply();
1697
+ const back = S.idx; if (clip) from = here >= 0 ? here : clipStart();
1628
1698
  const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720, stream = new MediaStream([...cv.captureStream(30).getTracks(), ...AUD.rec.stream.getTracks()]);
1629
- const mime = ['video/webm;codecs=vp9,opus', 'video/webm'].find((m) => MediaRecorder.isTypeSupported(m)), chunks = [];
1699
+ const mime = REC_TYPES.find((m) => MediaRecorder.isTypeSupported(m)) || '', ext = mime.startsWith('video/mp4') ? 'mp4' : 'webm', chunks = [], t0 = performance.now(); let outro = false;
1630
1700
  recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 4e6 }); recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
1631
- const timer = setInterval(() => { drawFrame(cv); $('#pill').textContent = `⏹ ${Math.max(0, Math.round(((S.idx - from) / ((to || S.events.length) - from)) * 100))}%`; }, 33), done = new Promise((res) => (recorder.onstop = res));
1701
+ const timer = setInterval(() => {
1702
+ const el = performance.now() - t0; drawFrame(cv, clip ? (outro ? 2 : 1) : 0);
1703
+ if (clip && S.playing && !S.stopAt && el > clip - 4000) S.stopAt = S.idx + 1; // the line being said is finished rather than cut…
1704
+ if (clip && S.playing && el > clip + 8000) pause(); // …unless it is a very long one
1705
+ $('#pill').textContent = `⏹ ${Math.max(0, Math.min(100, Math.round((clip ? el / clip : (S.idx - from) / ((to || S.events.length) - from)) * 100)))}%`;
1706
+ }, 33), done = new Promise((res) => (recorder.onstop = res));
1632
1707
  S.exporting = true; S.stopAt = to || 0; S.idx = from; recomputeStatus(from); recorder.start(500); run(from);
1633
- const poll = setInterval(() => { if (!S.playing && recorder) { setTimeout(() => recorder && recorder.stop(), 900); clearInterval(poll); } }, 300);
1634
- await done; clearInterval(timer); clearInterval(poll); S.exporting = false; S.stopAt = 0; recorder = null; syncUI();
1635
- const blob = new Blob(chunks, { type: 'video/webm' }); window.__lastExport = blob.size; const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'agent-standup.webm'; a.click();
1708
+ const poll = setInterval(() => { if (!S.playing && recorder) { const rec = recorder; outro = !!clip; setTimeout(() => recorder === rec && rec.stop(), clip ? 2600 : 900); clearInterval(poll); } }, 300);
1709
+ await done; clearInterval(timer); clearInterval(poll); S.exporting = false; S.stopAt = 0; recorder = null;
1710
+ if (clip) { pause(); S.idx = back; recomputeStatus(back); paintStatus(); } // back where the replay was, so the same clip can be taken again
1711
+ syncUI();
1712
+ const blob = new Blob(chunks, { type: mime.split(';')[0] || 'video/webm' }); window.__lastExport = blob.size; const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `agent-standup${clip ? '-clip' : ''}.${ext}`; a.click();
1636
1713
  }
1637
1714
 
1638
1715
  // =====================================================================
@@ -1704,6 +1781,7 @@ $('#bMute').onclick = toggleMute;
1704
1781
  $('#bPlay').onclick = togglePlay;
1705
1782
  $('#mRecap').onclick = () => { $('#menu').classList.remove('on'); openRecap(); };
1706
1783
  $('#mExport').onclick = () => { $('#menu').classList.remove('on'); exportVideo(); };
1784
+ $('#mClip').onclick = () => { $('#menu').classList.remove('on'); exportVideo(0, 0, 25000); };
1707
1785
  $('#mBrowser').onclick = () => { $('#menu').classList.remove('on'); window.parent.postMessage({ agentStandup: 'openBrowser' }, '*'); };
1708
1786
  $('#seek').oninput = (e) => seekTo(+e.target.value);
1709
1787
  $('#eDemo').onclick = () => setMode('demo');
@@ -12,6 +12,7 @@
12
12
  "☕ Buy the (replaced) dev a coffee": "☕ Spendier dem (ersetzten) Dev einen Kaffee",
13
13
  "☕ This dev was replaced by his agents — buy him a coffee": "☕ Dieser Dev wurde von seinen Agenten ersetzt — spendier ihm einen Kaffee",
14
14
  "🎞 Export video": "🎞 Video exportieren",
15
+ "🎬 Clip to share (25 s)": "🎬 Clip zum Teilen (25 s)",
15
16
  "📋 Recap": "📋 Zusammenfassung",
16
17
  "Agent voices": "Agenten-Stimmen",
17
18
  "Agents": "Agenten",
@@ -58,6 +59,7 @@
58
59
  "Files touched": "Bearbeitete Dateien",
59
60
  "Final report": "Abschlussbericht",
60
61
  "first error": "erster Fehler",
62
+ "Hide code & file names in videos": "Code & Dateinamen in Videos ausblenden",
61
63
  "Highlights": "Highlights",
62
64
  "Human": "Mensch",
63
65
  "idle": "untätig",
@@ -159,6 +161,7 @@
159
161
  "Writing ": "Schreibe ",
160
162
  "You": "Du",
161
163
  "Your agents are done 🎉": "Deine Agenten sind fertig 🎉",
164
+ "Your Claude Code agents as a live team call": "Deine Claude Code-Agenten live im Team-Videoanruf",
162
165
  "your turn": "du bist dran",
163
166
  "Characters": "Figuren",
164
167
  "Reload": "Neu laden",
@@ -7,6 +7,7 @@
7
7
  "☕ Buy the (replaced) dev a coffee": "☕ Buy the (replaced) dev a coffee",
8
8
  "☕ This dev was replaced by his agents — buy him a coffee": "☕ This dev was replaced by his agents — buy him a coffee",
9
9
  "🎞 Export video": "🎞 Export video",
10
+ "🎬 Clip to share (25 s)": "🎬 Clip to share (25 s)",
10
11
  "📋 Recap": "📋 Recap",
11
12
  "Agent voices": "Agent voices",
12
13
  "Agents": "Agents",
@@ -58,6 +59,7 @@
58
59
  "Final report": "Final report",
59
60
  "first error": "first error",
60
61
  "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.",
62
+ "Hide code & file names in videos": "Hide code & file names in videos",
61
63
  "Highlights": "Highlights",
62
64
  "Human": "Human",
63
65
  "idle": "idle",
@@ -168,5 +170,6 @@
168
170
  "Writing ": "Writing ",
169
171
  "You": "You",
170
172
  "Your agents are done 🎉": "Your agents are done 🎉",
173
+ "Your Claude Code agents as a live team call": "Your Claude Code agents as a live team call",
171
174
  "your turn": "your turn"
172
175
  }
@@ -12,6 +12,7 @@
12
12
  "☕ Buy the (replaced) dev a coffee": "☕ Invita un café al dev (reemplazado)",
13
13
  "☕ This dev was replaced by his agents — buy him a coffee": "☕ Este dev fue reemplazado por sus agentes — invítale un café",
14
14
  "🎞 Export video": "🎞 Exportar vídeo",
15
+ "🎬 Clip to share (25 s)": "🎬 Clip para compartir (25 s)",
15
16
  "📋 Recap": "📋 Resumen",
16
17
  "Agent voices": "Voces de los agentes",
17
18
  "Agents": "Agentes",
@@ -58,6 +59,7 @@
58
59
  "Files touched": "Archivos tocados",
59
60
  "Final report": "Informe final",
60
61
  "first error": "primer error",
62
+ "Hide code & file names in videos": "Ocultar código y archivos en los vídeos",
61
63
  "Highlights": "Destacados",
62
64
  "Human": "Humano",
63
65
  "idle": "inactivo",
@@ -159,6 +161,7 @@
159
161
  "Writing ": "Escribiendo ",
160
162
  "You": "Tú",
161
163
  "Your agents are done 🎉": "Tus agentes han terminado 🎉",
164
+ "Your Claude Code agents as a live team call": "Tus agentes de Claude Code, en videollamada de equipo en directo",
162
165
  "your turn": "tu turno",
163
166
  "Characters": "Personajes",
164
167
  "Reload": "Recargar",
@@ -12,6 +12,7 @@
12
12
  "☕ Buy the (replaced) dev a coffee": "☕ (お役御免の)開発者にコーヒーを",
13
13
  "☕ This dev was replaced by his agents — buy him a coffee": "☕ この開発者はエージェントに仕事を奪われました — コーヒーをおごってあげて",
14
14
  "🎞 Export video": "🎞 動画を書き出す",
15
+ "🎬 Clip to share (25 s)": "🎬 シェア用クリップ(25秒)",
15
16
  "📋 Recap": "📋 まとめ",
16
17
  "Agent voices": "エージェントの声",
17
18
  "Agents": "エージェント",
@@ -58,6 +59,7 @@
58
59
  "Files touched": "触れたファイル",
59
60
  "Final report": "最終レポート",
60
61
  "first error": "最初のエラー",
62
+ "Hide code & file names in videos": "動画でコードとファイル名を隠す",
61
63
  "Highlights": "ハイライト",
62
64
  "Human": "人間",
63
65
  "idle": "待機中",
@@ -159,6 +161,7 @@
159
161
  "Writing ": "書き込み中: ",
160
162
  "You": "あなた",
161
163
  "Your agents are done 🎉": "エージェントが完了しました 🎉",
164
+ "Your Claude Code agents as a live team call": "Claude Code のエージェントを、ライブのチーム通話で",
162
165
  "your turn": "あなたの番",
163
166
  "Characters": "キャラクター",
164
167
  "Reload": "再読み込み",
@@ -18,6 +18,7 @@
18
18
  "☕ Buy the (replaced) dev a coffee": "☕ Pague um café ao dev (substituído)",
19
19
  "☕ This dev was replaced by his agents — buy him a coffee": "☕ Este dev foi substituído pelos agentes dele — pague um café a ele",
20
20
  "🎞 Export video": "🎞 Exportar vídeo",
21
+ "🎬 Clip to share (25 s)": "🎬 Clipe para compartilhar (25 s)",
21
22
  "📋 Recap": "📋 Resumo",
22
23
  "Agent voices": "Vozes dos agentes",
23
24
  "Agents": "Agentes",
@@ -64,6 +65,7 @@
64
65
  "Files touched": "Arquivos modificados",
65
66
  "Final report": "Relatório final",
66
67
  "first error": "primeiro erro",
68
+ "Hide code & file names in videos": "Ocultar código e arquivos nos vídeos",
67
69
  "Highlights": "Destaques",
68
70
  "Human": "Humano",
69
71
  "idle": "ocioso",
@@ -165,6 +167,7 @@
165
167
  "Writing ": "Escrevendo ",
166
168
  "You": "Você",
167
169
  "Your agents are done 🎉": "Seus agentes terminaram 🎉",
170
+ "Your Claude Code agents as a live team call": "Seus agentes do Claude Code em uma chamada de equipe ao vivo",
168
171
  "your turn": "sua vez",
169
172
  "Downloading, you can carry on: system voices are used meanwhile.": "Baixando, pode continuar: enquanto isso, as vozes do sistema são usadas.",
170
173
  "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Vozes locais de boa qualidade, principalmente em inglês (só uma voz em francês). Grátis, nada sai do seu computador.",
@@ -12,6 +12,7 @@
12
12
  "☕ Buy the (replaced) dev a coffee": "☕ 请(被取代的)开发者喝杯咖啡",
13
13
  "☕ This dev was replaced by his agents — buy him a coffee": "☕ 这位开发者被他的代理取代了——请他喝杯咖啡吧",
14
14
  "🎞 Export video": "🎞 导出视频",
15
+ "🎬 Clip to share (25 s)": "🎬 分享用短视频(25 秒)",
15
16
  "📋 Recap": "📋 回顾",
16
17
  "Agent voices": "代理语音",
17
18
  "Agents": "代理",
@@ -58,6 +59,7 @@
58
59
  "Files touched": "涉及的文件",
59
60
  "Final report": "最终报告",
60
61
  "first error": "首个错误",
62
+ "Hide code & file names in videos": "在视频中隐藏代码和文件名",
61
63
  "Highlights": "精彩时刻",
62
64
  "Human": "真人",
63
65
  "idle": "空闲",
@@ -159,6 +161,7 @@
159
161
  "Writing ": "正在写入 ",
160
162
  "You": "你",
161
163
  "Your agents are done 🎉": "你的代理都完成啦 🎉",
164
+ "Your Claude Code agents as a live team call": "把你的 Claude Code 代理变成实时团队通话",
162
165
  "your turn": "轮到你了",
163
166
  "Characters": "角色",
164
167
  "Reload": "重新加载",
@@ -12,6 +12,7 @@
12
12
  "☕ Buy the (replaced) dev a coffee": "☕ 請(被取代的)開發者喝杯咖啡",
13
13
  "☕ This dev was replaced by his agents — buy him a coffee": "☕ 這位開發者被他的代理取代了 — 請他喝杯咖啡吧",
14
14
  "🎞 Export video": "🎞 匯出影片",
15
+ "🎬 Clip to share (25 s)": "🎬 分享用短片(25 秒)",
15
16
  "📋 Recap": "📋 摘要",
16
17
  "Agent voices": "代理語音",
17
18
  "Agents": "代理",
@@ -64,6 +65,7 @@
64
65
  "Files touched": "異動的檔案",
65
66
  "Final report": "最終報告",
66
67
  "first error": "首次錯誤",
68
+ "Hide code & file names in videos": "在影片中隱藏程式碼與檔名",
67
69
  "Highlights": "精華",
68
70
  "Human": "真人",
69
71
  "idle": "閒置",
@@ -165,6 +167,7 @@
165
167
  "Writing ": "撰寫 ",
166
168
  "You": "你",
167
169
  "Your agents are done 🎉": "你的代理都完成了 🎉",
170
+ "Your Claude Code agents as a live team call": "把你的 Claude Code 代理變成即時團隊通話",
168
171
  "your turn": "換你了",
169
172
  "Downloading, you can carry on: system voices are used meanwhile.": "下載中,你可以繼續使用:這段時間會先用系統語音。",
170
173
  "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質不錯的本機語音,以英文為主(法文只有一種聲音)。免費,資料不會離開你的電腦。",
package/server.js CHANGED
@@ -46,6 +46,36 @@ const toolLabel = (b, ts) => {
46
46
  return out;
47
47
  };
48
48
  const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x.text || '').join('\n') : '');
49
+ // Pictures a tool returned (a screenshot, an image file that was read). They are served from the transcript itself, so nothing outside the session files is ever read, and only bitmap types are sent.
50
+ const IMG_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']), mediaMemo = new Map();
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
+ const resultImages = (c) => (Array.isArray(c) ? c.filter(isImg) : []);
53
+ function mediaFile(proj, sid, agent) {
54
+ 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;
58
+ }
59
+ 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;
62
+ try {
63
+ fd = fs.openSync(file, 'r'); const size = fs.fstatSync(fd).size;
64
+ while (pos < size && !found) {
65
+ const buf = Buffer.alloc(Math.min(8 * 1024 * 1024, size - pos)); fs.readSync(fd, buf, 0, buf.length, pos); pos += buf.length;
66
+ const all = carry.length ? Buffer.concat([carry, buf]) : buf; let from = 0;
67
+ for (let hit; !found && (hit = all.indexOf(needle, from)) >= 0;) {
68
+ 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;
70
+ let r; try { r = JSON.parse(all.subarray(a, b).toString('utf8')); } catch { continue; }
71
+ 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
+ }
73
+ carry = all.subarray(all.lastIndexOf(10) + 1);
74
+ }
75
+ } 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); }
77
+ return found;
78
+ }
49
79
  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 };
50
80
  const NO_RESULT = new Set(['Agent', 'Task', 'SendMessage', 'TodoWrite']); // internal bookkeeping, nothing to show
51
81
  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
@@ -225,8 +255,8 @@ function parseRecord(st, r) {
225
255
  const items = []; // tool results: the real output that appeared on the agent's screen
226
256
  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)
227
257
  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])) {
228
- const n = st.toolName[b.tool_use_id];
229
- 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)) });
258
+ const n = st.toolName[b.tool_use_id], img = resultImages(b.content).length;
259
+ 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
230
260
  }
231
261
  if (items.length) st.raw.push({ ts, agent, kind: 'results', items });
232
262
  if (agent !== 'main') return; // a sub-agent's first user message is the order, emitted from the parent's side
@@ -490,9 +520,9 @@ function start(port = PORT) {
490
520
  let originOk = true; try { originOk = !req.headers.origin || local(new URL(req.headers.origin).host); } catch { originOk = false; }
491
521
  if (!local(req.headers.host) || !originOk) { res.writeHead(403); return res.end('forbidden'); }
492
522
  const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
493
- // 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.
523
+ // 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.
494
524
  const sfs = req.headers['sec-fetch-site'];
495
- 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'); }
525
+ 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', '/api/media'].includes(url.pathname)) { res.writeHead(403); return res.end('forbidden'); }
496
526
  const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
497
527
  let body = '', over = false; req.setTimeout(10000, () => req.destroy());
498
528
  req.on('data', (d) => { if (over) return; body += d; if (body.length > 262144) { over = true; res.writeHead(413); res.end('too large'); req.destroy(); } });
@@ -533,6 +563,12 @@ function start(port = PORT) {
533
563
  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') || '' }));
534
564
  if (url.pathname === '/api/board') return json(boardRows());
535
565
  if (url.pathname === '/api/characters') return json(listCharacters());
566
+ if (url.pathname === '/api/media') {
567
+ 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);
568
+ 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);
569
+ if (!im) { res.writeHead(bad ? 400 : 404); return res.end(bad ? 'bad request' : 'not found'); }
570
+ 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);
571
+ }
536
572
  if (url.pathname === '/api/session') {
537
573
  const proj = url.searchParams.get('proj') || '', sid = url.searchParams.get('id') || '', SAFE = /^[\w.\-]+$/;
538
574
  if (!SAFE.test(proj) || !SAFE.test(sid) || proj === '..' || sid === '..' || proj.length > 200 || sid.length > 200) { res.writeHead(400); return res.end('bad request'); }