@double_geste/agent-standup 1.25.7 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.25.7",
3
+ "version": "1.25.8",
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
@@ -160,6 +160,8 @@
160
160
  .tile.speaking, .tile.bye { overflow:visible; z-index:4 } .tile > svg:first-child { border-radius:13px } .tile.speaking .prop { left:auto; right:8px; top:52px }
161
161
  .tile.focus.speaking:has(.status:not(:empty)) .bubble { top:32px } /* the spotlight has room: there the badge stays visible above the bubble */
162
162
  body:not(.tiny) #center:has(#pcap:not(:empty)) .tile.presenter:not(.bye) .bubble { display:none } /* the presenter's line is the caption of the shared screen: not said twice */
163
+ .tile.musing:not(.speaking):not(.bye) .bubble { display:block; top:auto; bottom:36px; max-width:min(80%, 320px); font-size:12px; font-style:italic; color:#40455c; background:rgba(255,255,255,.88); border-radius:14px; box-shadow:0 2px 8px rgba(0,0,0,.28); animation:fadein .5s } /* an inner thought: not said, so lower, lighter, and never over the status at the top */
164
+ .tile.focus.musing:not(.speaking):not(.bye) .bubble { bottom:46px; font-size:13px } .tile.musing:not(.speaking):not(.bye) .bubble small { display:inline; margin-right:5px; font-style:normal }
163
165
  .tile.bye .bubble { display:block; font-size:13.5px; animation:fadein .25s } .tile.speaking .bubble { display:block; font-size:13px } .tile.focus.speaking .bubble { font-size:14.5px } .bubble small { display:block; color:#6a6f85; font-size:10.5px }
164
166
  .ticker { display:none; position:absolute; left:6px; right:6px; bottom:30px; font:10.5px/1.3 ui-monospace,Menlo,monospace; color:#fff; background:rgba(15,20,35,.8); border-radius:5px; padding:2px 7px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
165
167
  .ticker.on { display:block } .ticker i { font-style:normal; color:#ffd24a } .ticker.flash { animation:flash .25s } @keyframes flash { from { background:#3b5bdb } }
@@ -634,10 +636,10 @@ const AUD = {
634
636
  },
635
637
  join() { this.tone(523, .14, 'triangle', .1); this.tone(784, .2, 'triangle', .1, .12); },
636
638
  ding() { this.tone(988, .5, 'triangle', .14); this.tone(1318, .5, 'sine', .06, .02); },
637
- murmur(pitch, dur) { // cartoon mumble, used when there is no real voice
639
+ murmur(pitch, dur, vol = 1) { // cartoon mumble, used when there is no real voice (vol: lower for a thought that escapes)
638
640
  if (!this.ctx || !prefs.voice) return; const c = this.ctx, t = c.currentTime, o = c.createOscillator(); o.type = 'sawtooth';
639
641
  const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3; const g = c.createGain();
640
- g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.08, t + 0.05); g.gain.setValueAtTime(0.08, t + dur - 0.1); g.gain.linearRampToValueAtTime(0, t + dur);
642
+ g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.08 * vol, t + 0.05); g.gain.setValueAtTime(0.08 * vol, t + dur - 0.1); g.gain.linearRampToValueAtTime(0, t + dur);
641
643
  const base = 85 + pitch * 70; for (let x = 0; x < dur; x += 0.11) { o.frequency.setValueAtTime(base * (0.8 + Math.random() * 0.55), t + x); bp.frequency.setValueAtTime(500 + Math.random() * 900, t + x); }
642
644
  o.connect(bp).connect(g).connect(this.voice); o.start(t); o.stop(t + dur + 0.05); this.live.add(o); o.onended = () => this.live.delete(o); return o;
643
645
  },
@@ -781,7 +783,7 @@ function warmTTS(i) { // called by run() before each line (i is that line) and
781
783
  else if (isSub(e.agent)) { while (j < Math.min(len, k + 28) && group.length < 3) { const e2 = S.events[j]; if (quiet(e2)) j++; else if (e2.kind !== 'order' && isSub(e2.agent) && !group.some((g) => g.agent === e2.agent)) { group.push(e2); j++; } else break; } }
782
784
  const pl = paceAt(k), many = e.kind === 'order' && group.length > 1, rate = TTS.rateAt(many || !S.live ? k : j - 1, pl.boost);
783
785
  for (const g of many ? [e] : group) { // orders in a row: only the first is read out
784
- n++; const p = S.agents[g.agent], bt = baseText(g); if (pl.hurry || !p || (FR() && !trMem.has(bt) && langOf(bt) !== 'fr')) continue; // not translated yet: what will be said is not known
786
+ n++; const p = S.agents[g.agent], bt = baseText(g); if ((pl.hurry && g.kind !== 'user') || !p || (FR() && !trMem.has(bt) && langOf(bt) !== 'fr')) continue; // not translated yet: what will be said is not known
785
787
  const said = clipSpeech(talkLine(g), pl.pace), text = speakable(said), voice = TTS.voiceFor(p, sessFr(said, g)); if (text && voice) jobs.push({ k, text, voice, rate });
786
788
  }
787
789
  k = j;
@@ -930,10 +932,10 @@ function sessFr(text, ev) { // for the lines that come from the session (ev: t
930
932
  }
931
933
  return (lo ?? sessLang() ?? (FR() ? 'fr' : 'en')) === 'fr';
932
934
  }
933
- async function speakLine(text, p, primary, token, fr = frVoice(text)) {
935
+ async function speakLine(text, p, primary, token, fr = frVoice(text), always = false) { // always: your own lines are voiced even when the call is behind
934
936
  captionRun(p.id, text, estMs(text));
935
937
  if (AUD.off() || !prefs.voice) { if (!AUD.off() && prefs.office) AUD.murmur(0.5 + (p.vi % 5) / 10, estMs(text) / 1000); return sleep(estMs(text)); }
936
- if (S.hurry) return sleep(estMs(text));
938
+ if (S.hurry && !always) return sleep(estMs(text));
937
939
  if ((prefs.vstyle === 'animal' || prefs.vstyle === 'sims') && AUD.ctx) { try { await speakBabble(text, p, primary ? 1 : 0.5, token); return; } catch {} }
938
940
  if (TTS.server && AUD.ctx) { try { await speakServer(text, p, primary ? 1 : 0.5, token, fr); return; } catch {} }
939
941
  if (primary && synth && !EMBED && (await speakBrowser(text, p, token, fr))) return;
@@ -1009,7 +1011,7 @@ const applyStatus = (ev) => {
1009
1011
  if (ev.kind === 'user' || (ev.agent === 'main' && (ev.kind === 'say' || ev.kind === 'tools' || (ev.kind === 'order' && !ev.wf)))) S.turnOver = !!ev.end; // Claude Code marks the message that closes the lead's turn; anything after it opens the next one (not the agents a workflow launches by itself)
1010
1012
  };
1011
1013
  function recomputeStatus(upTo) {
1012
- S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.lastTool = {}; S.mainBusyAt = 0; S.turnOver = false; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.recomputing = true; document.querySelectorAll('.tile').forEach((t) => { clearTimeout(t._byeT); t.classList.remove('bye'); }); hidePresent();
1014
+ S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.lastTool = {}; S.mainBusyAt = 0; S.turnOver = false; S.th = {}; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.recomputing = true; document.querySelectorAll('.tile').forEach((t) => { clearTimeout(t._byeT); t.classList.remove('bye'); }); hidePresent();
1013
1015
  S.status = {}; S.tc = {}; S.doneAt = {}; S.gone.clear(); S.leaving.clear(); document.querySelectorAll('.tile.vanishing').forEach((t) => t.classList.remove('vanishing')); const doneIdx = {}; document.querySelectorAll('.ticker').forEach((x) => { x.classList.remove('on'); x.innerHTML = ''; });
1014
1016
  for (let i = 0; i < upTo; i++) {
1015
1017
  const e = S.events[i]; applyStatus(e); tally(e); if (e.kind === 'tools') S.tc[e.agent] = (S.tc[e.agent] || 0) + e.tools.length;
@@ -1049,6 +1051,7 @@ function tally(ev) { // numbers that pile up over a session: token usage, erro
1049
1051
  if (ev.kind === 'usage') { const c = ev.u.i + ev.u.r + ev.u.w; if (c > 0 && ev.model !== '<synthetic>') { S.ctx[ev.agent] = c; S.model[ev.agent] = ev.model; later('tok', renderCtx, 300); } const u = (S.usage[ev.agent] = S.usage[ev.agent] || { i: 0, o: 0, r: 0, w: 0 }); u.i += ev.u.i; u.o += ev.u.o; u.r += ev.u.r; u.w += ev.u.w; later('tok', renderTokens, 300); }
1050
1052
  else if (ev.kind === 'results') ev.items.forEach((it) => { if (it.err) { S.errs[ev.agent] = (S.errs[ev.agent] || 0) + 1; later('badges', renderBadges, 300); oops(ev.agent); noteTrouble(ev.agent, false); } else noteTrouble(ev.agent, true); });
1051
1053
  else if (ev.kind === 'tools' || ev.kind === 'say') S.lastAct[ev.agent] = ev.ts;
1054
+ if (ev.th) S.th[ev.agent] = { text: ev.th, ts: ev.ts }; else if (ev.kind === 'user') delete S.th.main; // your order starts a new chain of thought: what the lead thought before is no longer what it is on
1052
1055
  if (ev.kind === 'results') S.lastAct[ev.agent] = ev.ts; else if (ev.kind === 'user') S.lastAct.main = ev.ts; // a result coming back and your own order are signs of life too: the lead is thinking about them
1053
1056
  }
1054
1057
  const tokTip = (u) => `${L('Jetons enregistrés par Claude Code (comptés une fois par message, ce n’est pas une facture)', 'Tokens recorded by Claude Code (counted once per message, not a bill)')}\n${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)}`;
@@ -1322,7 +1325,7 @@ setInterval(() => {
1322
1325
  // ---------- speaking tiles ----------
1323
1326
  const mouths = new Map();
1324
1327
  function startTalk(id, text, label) {
1325
- const t = $('#t-' + id); if (!t) return;
1328
+ const t = $('#t-' + id); if (!t) return; endThought(id); stopMumble(); TH.next[id] = Date.now() + 8000; // a spoken line takes the bubble and the floor
1326
1329
  if (S.present && S.present !== id) hidePresent();
1327
1330
  S.spokeAt = performance.now(); S.fill = 0; S.mused = false;
1328
1331
  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);
@@ -1358,6 +1361,73 @@ function stopTalk(id) {
1358
1361
  const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi && S.present === id) { pc.style.display = 'none'; pi.style.display = ''; }
1359
1362
  }
1360
1363
  const clearSpeaking = () => [...S.speakers].forEach(stopTalk);
1364
+ // =====================================================================
1365
+ // inner thoughts: an agent that has been at work for a while thinks to itself, in a bubble it does not say. What it thinks is real: its own reasoning when the session kept it
1366
+ // (rarely: Claude Code mostly stores a signature), otherwise what it is on right now (the command it waits for, the files it goes between, its to-do item, a failure, its task).
1367
+ // One time in five the thought escapes as a mumble: its own voice, low and muffled. Live only, never while it speaks, off with the little lines or in Quiet.
1368
+ // =====================================================================
1369
+ const TH = { since: {}, next: {}, said: {}, last: 0, voice: null };
1370
+ const THINK = {
1371
+ en: { run: ['Still running… {x}.', 'Come on… {x}.', '{x}. Any second now.'], err: ['Why did that fail?', 'That error again… what am I missing?', 'Not what I expected from “{x}”.'], two: ['{a}, then {b}… what links them?', '{a} and {b}. It has to be in one of them.'], one: ['Something in {a} does not add up.', '{a}… where was that part again?'], again: ['Back to {a}. Again.', '{a}, once more. I am missing something.'],
1372
+ todo: ['One thing at a time: {x}.', '{x}… then the next one.'], next: ['After this: {x}.'], grep: ['Where is “{x}” hiding?', '“{x}”… it has to be somewhere.'], web: ['“{x}”… let’s see what comes up.'], task: ['{x}. Stay on it.', '{x}… getting there, I think.'], many: ['{n} calls in. What do I know so far?'] },
1373
+ fr: { run: ['Toujours en cours… {x}.', 'Allez… {x}.', '{x}. Ça arrive.'], err: ['Pourquoi ça a planté ?', 'Encore cette erreur… qu’est-ce que je rate ?', 'Pas ce que j’attendais de « {x} ».'], two: ['{a}, puis {b}… quel est le lien ?', '{a} et {b}. C’est forcément dans l’un des deux.'], one: ['Il y a un truc dans {a} qui ne colle pas.', '{a}… c’était où, déjà ?'], again: ['Retour à {a}. Encore.', '{a}, une fois de plus. Je rate quelque chose.'],
1374
+ todo: ['Une chose à la fois : {x}.', '{x}… puis la suite.'], next: ['Après ça : {x}.'], grep: ['Où se cache « {x} » ?', '« {x} »… c’est forcément quelque part.'], web: ['« {x} »… voyons ce que ça donne.'], task: ['{x}. On reste dessus.', '{x}… j’y arrive, je crois.'], many: ['{n} appels. Qu’est-ce que je sais, à ce stade ?'] },
1375
+ };
1376
+ function thoughtFor(id) {
1377
+ const now = Date.now(), W = THINK[FR() ? 'fr' : 'en'], seen = (TH.said[id] = TH.said[id] || []), cut = (x, n) => { x = String(x || '').replace(/\s+/g, ' ').trim(); return (x.length > n ? x.slice(0, n).replace(/\s+\S*$/, '') + '…' : x).replace(/[.\s]+$/, ''); };
1378
+ const real = realThought(id); if (real) return real; // its own words first, always: the rest is only for when there are none (left)
1379
+ const buf = (S.term[id] || []).slice(-8), last = buf[buf.length - 1], r = S.run[id], hot = [], out = [], add = (to, k, v) => { for (const tpl of W[k]) to.push(tpl.replace(/\{(\w+)\}/g, (m, key) => v[key])); };
1380
+ const what = (e) => { const d = e.tool.d || {}; return cut(d.note || (d.kind === 'cmd' ? String(d.text || '').trim().split(/\s+/).slice(0, 3).join(' ') : base(d.file) || e.tool.name), 60); };
1381
+ if (last && last.k === 'cmd' && r && r.id === last.id && now - (r.ts || now) > 20000) add(hot, 'run', { x: what(last) }); // a command that takes its time
1382
+ if (last && last.err) add(hot, 'err', { x: what(last) });
1383
+ const files = buf.filter((e) => /^(read|edit|write)$/.test(e.k)).map((e) => base((e.tool.d || {}).file)).filter(Boolean), uniq = [...new Set(files)], f = uniq[uniq.length - 1];
1384
+ if (f && files.filter((x) => x === f).length >= 3) add(out, 'again', { a: f }); else if (uniq.length > 1) add(out, 'two', { a: uniq[uniq.length - 2], b: f }); else if (f) add(out, 'one', { a: f });
1385
+ const td = S.todos[id] || [], cur = td.find((x) => x.s === 'in_progress'), nx = td.find((x) => x.s === 'pending'); if (cur) add(out, 'todo', { x: cut(cur.c, 70) }); if (nx) add(out, 'next', { x: cut(nx.c, 70) });
1386
+ const g = buf.slice(-3).reverse().find((e) => e.k === 'grep' && (e.tool.d || {}).pattern); if (g) add(out, 'grep', { x: cut(g.tool.d.pattern, 30) });
1387
+ const w = buf.slice(-3).reverse().find((e) => e.k === 'web' && (e.tool.d || {}).query); if (w) add(out, 'web', { x: cut(w.tool.d.query, 50) });
1388
+ const p = S.agents[id]; if (isSub(id) && p && p.role && !/^agent$/i.test(p.role)) add(out, 'task', { x: cut(p.role, 60) });
1389
+ if ((S.tc[id] || 0) >= 15) add(out, 'many', { n: S.tc[id] });
1390
+ const fresh = (a) => a.filter((x) => !seen.includes(x)), h = fresh(hot), o = fresh(out), pool = h.length && (!o.length || Math.random() < 0.6) ? h : o; if (!pool.length) return null;
1391
+ const line = pool[Math.floor(Math.random() * pool.length)]; seen.push(line); if (seen.length > 6) seen.shift(); return line;
1392
+ }
1393
+ function realThought(id, peek) { // the next sentence of the agent's own reasoning for the work it is on (kept by the session for some of its steps); null when there is none, or when all of it has been shown
1394
+ const th = S.th[id]; if (!th || Date.now() - th.ts > 600000) return null;
1395
+ if (!th.ss) { th.ss = (th.text.match(/[^.!?]+(?:[.!?]+|$)/g) || []).map((x) => x.trim()).filter((x) => x.length >= 12).map((x) => (x.length > 150 ? x.slice(0, 150).replace(/\s+\S*$/, '') + '…' : x)); th.i = 0; }
1396
+ return th.i < th.ss.length ? (peek ? th.ss[th.i] : th.ss[th.i++]) : null;
1397
+ }
1398
+ function showThought(id, text) {
1399
+ const t = $('#t-' + id); if (!t || t.style.display === 'none' || S.speakers.has(id) || t.classList.contains('bye')) return false;
1400
+ t.querySelector('.bubble').innerHTML = `<small>💭</small>${esc(text)}`; t.classList.add('musing'); clearTimeout(t._thT); t._thT = setTimeout(() => endThought(id), Math.min(9000, 3500 + text.length * 55)); return true;
1401
+ }
1402
+ function endThought(id) {
1403
+ const t = $('#t-' + id); if (!t || !t.classList.contains('musing')) return; clearTimeout(t._thT); t.classList.remove('musing'); if (TH.voice && TH.voice.id === id) stopMumble();
1404
+ if (!S.speakers.has(id) && !t.classList.contains('bye')) t.querySelector('.bubble').innerHTML = '';
1405
+ }
1406
+ const clearThoughts = () => { document.querySelectorAll('.tile.musing').forEach((t) => endThought(t.id.slice(2))); stopMumble(); };
1407
+ function stopMumble() { const v = TH.voice; if (v) { TH.voice = null; try { v.src.stop(); } catch {} } }
1408
+ async function mumble(id, text) { // lips closed: the mouth does not move, the music does not make room, and anyone who starts to speak cuts it
1409
+ const p = S.agents[id], t = $('#t-' + id); if (!p || !t || !prefs.voice || AUD.off() || !AUD.ctx || S.speakers.size || TH.voice) return;
1410
+ if (!TTS.server || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') { AUD.murmur(0.5 + (p.vi % 5) / 10, Math.min(2.2, estMs(text) / 1000), 0.4); return; }
1411
+ const voice = TTS.voiceFor(p, frVoice(text)), token = S.token; if (!voice) return; let buf; try { buf = await TTS.buf(speakable(text), voice, TTS.rate(), true); } catch { return; } // made like a line prepared ahead: it never delays a real one
1412
+ if (token !== S.token || S.speakers.size || TH.voice || !t.classList.contains('musing')) return; // someone spoke meanwhile, or the thought is gone
1413
+ const c = AUD.ctx, src = c.createBufferSource(), lp = c.createBiquadFilter(), g = c.createGain(); src.buffer = buf; lp.type = 'lowpass'; lp.frequency.value = 1100; g.gain.value = 0.2; let stops = [];
1414
+ if (prefs.vstyle === 'natural' || prefs.vstyle === 'kokoro') src.connect(lp); else { const ch = fxChain(c, src, fxFor(p)); stops = ch.stops; ch.node.connect(lp); }
1415
+ lp.connect(g); g.connect(AUD.voice); AUD.live.add(src); TH.voice = { id, src };
1416
+ src.onended = () => { AUD.live.delete(src); stops.forEach((o) => { try { o.stop(); } catch {} }); if (TH.voice && TH.voice.src === src) TH.voice = null; }; src.start();
1417
+ }
1418
+ setInterval(() => { // who gets a thought: someone at work for a while and silent; two at most on screen, a few seconds apart, each agent every half minute or so
1419
+ const now = Date.now(); if (!S.live || !S.playing || S.locked || S.recomputing || !prefs.banter || prefs.chat === 'quiet' || S.hurry || S.boost > 1 || (document.hidden && !S.exporting) || !atTail()) return;
1420
+ const ready = [];
1421
+ for (const p of Object.values(S.agents)) {
1422
+ const id = p.id, t = $('#t-' + id); if (id === 'user' || !t) continue;
1423
+ if (t.style.display === 'none' || S.gone.has(id) || agentState(id, now).k !== 'working') { delete TH.since[id]; continue; }
1424
+ TH.since[id] = TH.since[id] || now; if (!S.speakers.has(id) && !t.classList.contains('musing') && now - TH.since[id] > 15000 && now > (TH.next[id] || 0)) ready.push(id);
1425
+ }
1426
+ if (!ready.length || now - TH.last < 5000 || document.querySelectorAll('.tile.musing').length >= 2) return;
1427
+ const own = ready.filter((id) => realThought(id, true)), from = own.length ? own : ready; // whoever has reasoning of its own to show goes first
1428
+ const id = from[Math.floor(Math.random() * from.length)], text = thoughtFor(id); TH.next[id] = now + (text ? 28000 + Math.random() * 24000 : 8000) / chatMul(); if (!text || !showThought(id, text)) return;
1429
+ TH.last = now; if (Math.random() < 0.2) mumble(id, text);
1430
+ }, 1500);
1361
1431
 
1362
1432
  // ---------- hierarchy tree ----------
1363
1433
  function renderTree() {
@@ -1712,7 +1782,8 @@ async function run(i) {
1712
1782
  const rem = S.events.length - i;
1713
1783
  if (rem > 300 || aheadSpeakable(i, 16) > 16 || lagMs(i) > 60000) { // only a really big backlog is skipped; before that, lines are shortened instead
1714
1784
  let k = S.events.length, back = 0; while (k > i && back < 8) { k--; if (SPK.has(S.events[k].kind)) back++; }
1715
- const to = rem > 300 ? Math.max(k, S.events.length - 14) : k; if (to > i) { i = to; recomputeStatus(i); paintStatus(); }
1785
+ let to = rem > 300 ? Math.max(k, S.events.length - 14) : k; for (let u = i; u < to; u++) if (S.events[u].kind === 'user') { to = u; break; } // never over a line of yours: what you asked is always said, shortened if need be
1786
+ if (to > i) { i = to; recomputeStatus(i); paintStatus(); }
1716
1787
  }
1717
1788
  }
1718
1789
  const q = S.live ? aheadSpeakable(i, 14) : 0; // lines waiting to be said: the more of them, the shorter each one is read, but it is always read
@@ -1749,7 +1820,7 @@ async function run(i) {
1749
1820
  if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1750
1821
  if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1751
1822
  const said = clipSpeech(line, S.pace); if (g.agent === 'main' && g.kind === 'say') { const full = plain(g.text), key = said.replace(/…$/, '').trim().slice(-30), pos = key ? full.indexOf(key) : -1, n = pos >= 0 ? pos + key.length : said.length; S.mainSaid = { ev: g, said: n, full: n >= full.length - 5 }; }
1752
- return speakLine(said, S.agents[g.agent], true, token, sessFr(said, g)).then(() => { if (token === S.token) stopTalk(g.agent); });
1823
+ return speakLine(said, S.agents[g.agent], true, token, sessFr(said, g), g.kind === 'user').then(() => { if (token === S.token) stopTalk(g.agent); });
1753
1824
  };
1754
1825
  let plays;
1755
1826
  if (ev.kind === 'order' && group.length > 1) { // the first order in full, then "Also Remy on X, Iris on Y"
@@ -1772,7 +1843,7 @@ function clipSpeech(t, cap) { // the bubble keeps the full text; the voice rea
1772
1843
  const m = t.slice(0, cap + 40).match(/^.{20,}?[.!?](?=\s|$)/); if (m && m[0].length <= cap + 30) return m[0];
1773
1844
  return t.slice(0, cap).replace(/\s+\S*$/, '') + '…';
1774
1845
  }
1775
- function pause() { S.playing = false; S.hurry = false; S.boost = 1; S.token++; synth && synth.cancel(); AUD.stopAll(); clearSpeaking(); $('#card').classList.remove('on'); syncUI(); }
1846
+ function pause() { S.playing = false; S.hurry = false; S.boost = 1; S.token++; synth && synth.cancel(); AUD.stopAll(); clearSpeaking(); clearThoughts(); $('#card').classList.remove('on'); syncUI(); }
1776
1847
  function togglePlay() {
1777
1848
  if (S.locked) return;
1778
1849
  if (S.playing) { S.userPaused = true; pause(); } else { claimAudio(); S.userPaused = false; run(S.idx >= S.events.length - 1 && !S.live ? (recomputeStatus(0), 0) : S.live ? S.pos : S.idx); } // live: from the first line not said yet (the one that was cut, if any)
@@ -1799,7 +1870,7 @@ async function loadSession(proj, id) {
1799
1870
  const data = await (await fetch(`/api/session?proj=${encodeURIComponent(proj)}&id=${id}`)).json();
1800
1871
  if (mine !== S.loadN || data.error) return false;
1801
1872
  S.term = {}; S.camFor = null; S.focus = null; S.agents = {}; for (const k in nameBook) delete nameBook[k]; [{ id: 'user' }, ...data.agents].forEach((a) => (S.agents[a.id] = persona(a)));
1802
- S.events = data.events; stampT(0); S.byK = new Map(data.events.map((e) => [e.k, e])); S.seq = data.seq || 0; S.ep = data.ep || ''; S.sawEnd = data.events.some((e) => e.end); S.late = []; S.pos = 0; S.joinAt = Date.now(); S.idx = 0; S.status = {}; S.tc = {}; S.cur = { proj, id }; S.logTo = 0; S.detail = null; $('#detail').classList.remove('on');
1873
+ clearThoughts(); Object.assign(TH, { since: {}, next: {}, said: {}, last: 0 }); S.events = data.events; stampT(0); S.byK = new Map(data.events.map((e) => [e.k, e])); S.seq = data.seq || 0; S.ep = data.ep || ''; S.sawEnd = data.events.some((e) => e.end); S.late = []; S.pos = 0; S.joinAt = Date.now(); S.idx = 0; S.status = {}; S.tc = {}; S.cur = { proj, id }; S.logTo = 0; S.detail = null; $('#detail').classList.remove('on');
1803
1874
  const opt = $('#sessions').selectedOptions[0]; S.title = (S.live ? niceProj(proj) : opt && opt.dataset.title) || '';
1804
1875
  if (proj === '_demo') S.events.forEach((e) => { if (e.fr) trMem.set(baseText(e), e.fr); }); // the demo ships its own French
1805
1876
  $('#subtitle').textContent = S.title; recomputeStatus(0); buildStage(); renderHighlights(); setFocus('main'); renderLog(true); computeGantt(); layout(); paintStatus(); prefetchTr(0); return true;
@@ -2197,7 +2268,7 @@ addEventListener('keydown', (e) => { // on the way down: Escape also works fro
2197
2268
  // ---------- a bubble that is not whole on screen (last row of tiles, a row of the compact list below the fold) brings the stage to it ----------
2198
2269
  new MutationObserver((ms) => {
2199
2270
  for (const m of ms) {
2200
- const b = m.target; if (b.className !== 'bubble' || !b.offsetHeight) continue; const st = $('#stage'), r = b.getBoundingClientRect(), s = st.getBoundingClientRect();
2271
+ const b = m.target; if (b.className !== 'bubble' || !b.offsetHeight || b.parentNode.classList.contains('musing')) continue; const st = $('#stage'), r = b.getBoundingClientRect(), s = st.getBoundingClientRect(); // (a thought does not move the stage)
2201
2272
  let room = r.top - s.top - 10; for (const o of st.querySelectorAll('.bubble')) { const q = o.getBoundingClientRect(); if (o !== b && q.height && q.top >= s.top && q.bottom <= s.bottom) room = Math.min(room, q.top - s.top - 10); } // never at the cost of another line that is on screen
2202
2273
  if (r.bottom > s.bottom - 6) { if (room > 0) st.scrollBy({ top: Math.min(r.bottom - s.bottom + 10, room), behavior: 'smooth' }); } else if (r.top < s.top + 4) st.scrollBy({ top: r.top - s.top - 10, behavior: 'smooth' });
2203
2274
  }
package/server.js CHANGED
@@ -302,7 +302,7 @@ function parseRecord(st, r) {
302
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
303
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
304
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();
305
- if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt)) 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
306
306
  return;
307
307
  }
308
308
  if (r.type === 'user') {
@@ -325,12 +325,14 @@ function parseRecord(st, r) {
325
325
  const u = m.usage, prev = st.msgUsage.get(m.id);
326
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 });
327
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
328
329
  for (const b of blocks(r)) {
329
- if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { said = push({ ts, agent, kind: 'say', text: b.text.trim() }); st.act = st.raw.length - 1; st.sayOf = m.id; st.sayAt = st.act; }
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; }
330
332
  else if (b.type === 'tool_use') {
331
333
  st.toolName[b.id] = b.name; if (b.input && b.input.run_in_background) (st.bg = st.bg || {})[b.id] = true;
332
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) || '' });
333
- else { push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts) }); st.act = st.raw.length - 1; }
335
+ else { push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts), ...th() }); st.act = st.raw.length - 1; }
334
336
  }
335
337
  }
336
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)
@@ -391,7 +393,7 @@ function buildTimeline(proj, id, files = []) {
391
393
  const p = lastReal >= 0 ? merged[lastReal] : null;
392
394
  if (e.kind === 'usage' || e.kind === 'finish' || e.kind === 'compact') { merged.push(e); continue; }
393
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
394
- else { merged.push(e.kind === 'tool' ? { ts: e.ts, agent: e.agent, kind: 'tools', tools: [e.tool], ...(e.end ? { end: 1 } : {}), k: e.k } : e); lastReal = merged.length - 1; }
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; }
395
397
  }
396
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
397
399
  return { id, proj, agents: Object.values(agents), events: merged.map((e) => ({ ...e, t: Math.max(0, e.ts - first) })), durationMs: last - first, recentUntil };