@double_geste/agent-standup 1.24.3 → 1.25.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.24.3",
3
+ "version": "1.25.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
@@ -190,6 +190,24 @@
190
190
  .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 }
191
191
  .glink { color:var(--dim); font-size:12.5px; text-decoration:underline; text-underline-offset:3px } .glink:hover { color:#ffd24a }
192
192
  #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) }
193
+ #menuQ { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:8px; padding:6px 9px; font:inherit; font-size:12.5px; outline:none } #menuQ:focus { border-color:var(--accent) }
194
+ #switcher { position:fixed; inset:0; z-index:20; background:rgba(5,6,12,.7); display:flex; align-items:flex-start; justify-content:center; padding-top:12vh } #switcher .sbox { width:min(560px,92vw); background:var(--panel); border:1px solid var(--line); border-radius:14px; box-shadow:0 20px 60px rgba(0,0,0,.6); overflow:hidden }
195
+ #swq { width:100%; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--text); font:inherit; font-size:15px; padding:14px 16px; outline:none } #swl { max-height:50vh; overflow:auto } .swr { display:grid; grid-template-columns:auto 1fr; gap:2px 10px; padding:9px 14px; cursor:pointer; align-items:center } .swr.on, .swr:hover { background:var(--panel2) } .swr b { font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap } .swr i { grid-column:2; font-style:normal; font-size:11px; color:var(--dim) }
196
+ #sps { display:none; gap:2px } body.replay #sps { display:inline-flex } #sps button { background:var(--panel2); color:var(--dim); border:1px solid var(--line); border-radius:6px; padding:1px 6px; font:inherit; font-size:11px; cursor:pointer } #sps button.on { color:var(--text); border-color:var(--accent) } body.compact #sps { display:none !important }
197
+ #menu .mfold, #menu .mhide { display:none !important } #menu h4.fold { cursor:pointer; user-select:none } #menu h4.fold::before { content:'▾ ' } #menu h4.fold.closed::before { content:'▸ ' }
198
+ /* compact: a very narrow panel (the VS Code side bar) shows one row per agent instead of tiles */
199
+ body.compact #stage { display:flex; flex-direction:column; gap:6px; padding:8px; overflow:auto }
200
+ body.compact .tile, body.compact .tile.focus { height:54px !important; flex:none; border-radius:10px; display:flex; align-items:center; overflow:visible; animation:none; order:0 } body.compact .tile.lead { order:-2 }
201
+ body.compact .tile > svg:first-child { width:54px; height:54px; flex:none; border-radius:9px 0 0 9px }
202
+ body.compact .tile .bubble, body.compact .tile .thought, body.compact .tile .ticker, body.compact .tile .tbadge { display:none !important }
203
+ body.compact .tile .tag { position:static; flex:1; min-width:0; padding:0 8px; flex-direction:column; gap:2px; align-items:flex-start } body.compact .tile .tag b, body.compact .tile .tag span { background:none; backdrop-filter:none; padding:0; max-width:100%; overflow:hidden; text-overflow:ellipsis }
204
+ body.compact .tile .tag span:not(.tk):not(.cx) { display:none }
205
+ body.compact .tile .status { position:static; max-width:none; margin-right:6px; font-size:10.5px; padding:2px 7px; flex:none; max-width:46% }
206
+ body.compact .tile .prop { top:2px; left:34px; font-size:16px }
207
+ body.compact .tile .status:empty { display:none }
208
+ body.compact .tile .tag { order:1 } body.compact .tile .status { order:2 } body.compact .tile .prop { order:3 }
209
+ body.compact footer { gap:6px; padding-left:8px; padding-right:8px } body.compact footer label.dim { display:none } body.compact #speed { width:70px; min-width:0 }
210
+ body.compact #present { display:none !important } body.compact #hl { display:none } body.compact #gantt { display:none } body.compact #roster { display:none }
193
211
  #menu.on { display:block } #menu h4 { margin:8px 10px 4px; font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); font-weight:600 }
194
212
  .mi { display:flex; align-items:center; justify-content:space-between; gap:10px; width:100%; text-align:left; padding:8px 10px; border-radius:8px } .mi:hover { background:var(--panel2) }
195
213
  .sw { width:30px; height:17px; border-radius:99px; background:#3a3e50; position:relative; flex:none; transition:background .15s } .sw::after { content:''; position:absolute; top:2px; left:2px; width:13px; height:13px; border-radius:50%; background:#fff; transition:transform .15s }
@@ -236,6 +254,7 @@
236
254
  </header>
237
255
 
238
256
  <div id="menu">
257
+ <input id="menuQ" type="search" placeholder="Search settings…" aria-label="Search settings" style="margin:4px 8px 2px;width:calc(100% - 16px)">
239
258
  <h4 data-fr="Langue" data-en="Language">Langue</h4>
240
259
  <div class="box"><select id="langSel" aria-label="Language"></select></div>
241
260
  <div class="hint" id="trHint" style="display:none"></div>
@@ -248,9 +267,11 @@
248
267
  <div class="box" id="musicBox" style="display:none"><select id="station"></select><input id="musicVol" type="range" min="0" max="1" step="0.05" style="width:100%" aria-label="Music volume"></div>
249
268
  <div class="box" id="vstyleBox"><select id="vstyle"></select></div>
250
269
  <div class="box" id="kokoroBox" style="display:none"><button class="btn" id="kokoroInstall" style="width:100%"></button><div class="hint" id="kokoroHint" style="padding:2px 0 6px"></div></div>
270
+ <h4 data-fr="Affichage" data-en="Display">Affichage</h4>
251
271
  <button class="mi" data-t="roster"><span data-fr="Liste des agents (à gauche)" data-en="Agents list (left)">Liste des agents (à gauche)</span><i class="sw"></i></button>
252
272
  <button class="mi" data-t="reactions"><span data-fr="Barre de réactions (👍 👏 ☕)" data-en="Reactions bar (👍 👏 ☕)">Barre de réactions (👍 👏 ☕)</span><i class="sw"></i></button>
253
273
  <button class="mi" data-t="banter"><span data-fr="Petites répliques et animations" data-en="Little lines & animations">Petites répliques et animations</span><i class="sw"></i></button>
274
+ <div class="box"><div class="lbl"><span data-fr="Bavardage entre agents" data-en="Chatter between agents">Bavardage entre agents</span></div><select id="chatSel"><option value="quiet" data-fr="Discret" data-en="Quiet">Discret</option><option value="normal" data-fr="Normal" data-en="Normal">Normal</option><option value="lively" data-fr="Animé" data-en="Lively">Animé</option></select></div>
254
275
  <button class="mi" data-t="vanish"><span data-fr="Retirer les agents terminés (2 min)" data-en="Remove finished agents (2 min)">Retirer les agents terminés (2 min)</span><i class="sw"></i></button>
255
276
  <button class="mi" data-t="epi"><span data-fr="Réactions et blagues" data-en="Reactions & jokes">Réactions et blagues</span><i class="sw"></i></button>
256
277
  <h4 data-fr="Source" data-en="Source">Source</h4>
@@ -259,6 +280,7 @@
259
280
  <select id="sessions" style="display:none"></select></div>
260
281
  <h4 data-fr="Personnages" data-en="Characters">Personnages</h4>
261
282
  <div class="box"><div id="charInfo" class="dim" style="font-size:11.5px;line-height:1.5"></div><div style="display:flex;gap:6px;margin-top:6px"><button class="btn" id="charReload" data-fr="Recharger" data-en="Reload">Recharger</button><button class="btn" id="charOpen" style="display:none" data-fr="Ouvrir le dossier" data-en="Open folder">Ouvrir le dossier</button></div></div>
283
+ <button class="mi" data-t="customLaptop"><span data-fr="Ordinateur devant vos personnages" data-en="Laptop in front of your own characters">Ordinateur devant vos personnages</span><i class="sw"></i></button>
262
284
  <h4 data-fr="Outils" data-en="Tools">Outils</h4>
263
285
  <button class="mi" id="mCoffee" style="display:none"><span id="mCoffeeT"></span></button>
264
286
  <button class="mi" id="mKeys"><span data-fr="⌨ Raccourcis clavier (?)" data-en="⌨ Keyboard shortcuts (?)">⌨ Raccourcis clavier (?)</span></button>
@@ -287,7 +309,8 @@
287
309
  </main>
288
310
  <footer>
289
311
  <input id="seek" type="range" min="0" max="0" value="0"><span class="dim" id="pos">0 / 0</span>
290
- <label class="dim"><span data-fr="Vitesse" data-en="Speed">Vitesse</span> <input id="speed" type="range" min="0.5" max="3" step="0.25" value="1.25" style="width:80px;vertical-align:middle"></label>
312
+ <label class="dim"><span data-fr="Vitesse" data-en="Speed">Vitesse</span> <input id="speed" type="range" min="0.5" max="8" step="0.25" value="1.25" style="width:80px;vertical-align:middle"></label>
313
+ <span id="sps" class="dim" role="group" aria-label="Speed presets"><button data-s="1">1×</button><button data-s="2">2×</button><button data-s="4">4×</button><button data-s="8">8×</button></span>
291
314
  </footer>
292
315
  <div id="card"></div><div id="recap"><div class="box"></div></div>
293
316
 
@@ -311,6 +334,7 @@ let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: fa
311
334
  const sv = window.SAVED_PREFS && typeof window.SAVED_PREFS === 'object' ? window.SAVED_PREFS : {}; Object.assign(prefs, (sv._t || 0) > (loc._t || 0) ? sv : loc); }
312
335
  if (prefs.vstyle === 'babble') prefs.vstyle = 'animal', prefs.vstyleChosen = true;
313
336
  if (!prefs.vstyleChosen && (prefs.vstyle === 'animal' || prefs.vstyle === 'sims')) prefs.vstyle = 'cartoon'; // the gibberish styles are opt-in: understandable voices by default
337
+ if (!prefs.notifyChosen) prefs.notify = true; // “tell me when the agents are done” is on unless the person chose otherwise
314
338
  let prefsTimer = null;
315
339
  const savePrefs = () => { prefs._t = Date.now(); try { localStorage.setItem('cc.prefs', JSON.stringify(prefs)); } catch {} clearTimeout(prefsTimer); prefsTimer = setTimeout(() => { fetch('/api/prefs', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(prefs) }).catch(() => {}); }, 400); };
316
340
  // Languages: French and English live in the code as pairs L(french, english); every other language is a catalogue public/locales/<code>.json mapping the English text to its translation.
@@ -351,7 +375,12 @@ async function loadChars() {
351
375
  } catch {}
352
376
  CHARS = next;
353
377
  }
354
- const customFor = (id) => (id === 'main' ? CHARS.lead : id === 'user' ? CHARS.you : CHARS.pool.length ? CHARS.pool.reduce((b, c) => (hash(id + '|' + (c.idle || '')) > hash(id + '|' + (b.idle || '')) ? c : b)) : null);
378
+ const customFor = (id) => { // sub-agents: the best-hashed image no other agent holds; the whole pool again only once every image is taken
379
+ if (id === 'main') return CHARS.lead; if (id === 'user') return CHARS.you; if (!CHARS.pool.length) return null;
380
+ const used = new Set(Object.keys(S.agents).filter((k) => k !== id && S.agents[k].custom).map((k) => S.agents[k].custom.name));
381
+ const free = CHARS.pool.filter((c) => !used.has(c.name)), from = free.length ? free : CHARS.pool;
382
+ return from.reduce((b, c) => (hash(id + '|' + (c.idle || '')) > hash(id + '|' + (b.idle || '')) ? c : b));
383
+ };
355
384
  function renderCharInfo() {
356
385
  const el = $('#charInfo'); if (!el) return;
357
386
  el.innerHTML = CHARS.n ? `✓ ${CHARS.n} ${esc(L('personnage(s) personnalisé(s) chargé(s)', 'custom character(s) loaded'))}` : `${esc(L('Déposez vos images (PNG) dans ce dossier pour remplacer les personnages :', 'Drop your pixel art (PNG) in this folder to replace the characters:'))}<br><code style="word-break:break-all">${esc(CHARS.dir)}</code><br>${esc(L('lead.png = le chef · you.png = vous · les autres = les sous-agents · nom-talk.png = bouche ouverte', 'lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth'))}`;
@@ -447,6 +476,7 @@ function avatarSVG(p, o, uid = '') { // uid keeps SVG ids unique per instance:
447
476
  `<rect x="79" y="55.6" width="7.4" height="6.8" rx="1.6" fill="#f6f2ea"/><path d="M86.2 57q3.2 0 3.2 2.2t-3.2 2.2" fill="none" stroke="#f6f2ea" stroke-width="1.3"/><g class="steam" fill="none" stroke="#fff" stroke-width=".8" stroke-linecap="round" opacity=".7"><path d="M81.5 53q-1.4-1.8 0-3.4M84.3 53q-1.4-1.8 0-3.4"/></g>`,
448
477
  `<rect x="76" y="58.2" width="14" height="4.2" rx="1" fill="#ff7a6b"/><rect x="77.5" y="54.4" width="12" height="3.8" rx="1" fill="#4cc3ff"/><rect x="76.5" y="50.8" width="11" height="3.6" rx="1" fill="#ffd84a"/>`][p.prop];
449
478
  const lap = `<rect x="33" y="46" width="34" height="18" rx="2.8" fill="#d3d8e6"/><rect x="33" y="46" width="34" height="18" rx="2.8" fill="url(#${uid}lg)" opacity=".5"/><circle cx="50" cy="55" r="2.3" fill="${c1}"/><rect x="29.5" y="63.4" width="41" height="3" rx="1.5" fill="#aab1c2"/>`;
479
+ const lapDark = `<rect x="33" y="46" width="34" height="18" rx="2.8" fill="#3b4152"/><rect x="33" y="46" width="34" height="18" rx="2.8" fill="url(#${uid}lg)" opacity=".18"/><circle cx="50" cy="55" r="2.3" fill="${c1}"/><rect x="29.5" y="63.4" width="41" height="3" rx="1.5" fill="#262a36"/>`; // for custom characters: darker, so it still shows under a white or cream sprite
450
480
  const hands = `<circle class="hand l" cx="38" cy="65.6" r="3.1" fill="${c2}"/><circle class="hand r" cx="62" cy="65.6" r="3.1" fill="${c2}"/>`;
451
481
  const sparks = star(22, 28, 3) + star(78, 22, 2.6, '#ff9bb8') + star(70, 40, 1.9, '#9be7ff') + star(30, 46, 1.6, '#ffe58a');
452
482
  const custFig = (c, talking) => { const base = 'x="20" y="6" width="60" height="58" preserveAspectRatio="xMidYMax meet"', px = 'image-rendering:pixelated;';
@@ -461,18 +491,25 @@ function avatarSVG(p, o, uid = '') { // uid keeps SVG ids unique per instance:
461
491
  ${fig}
462
492
  </g>
463
493
  <rect y="62" width="100" height="13" fill="url(#${uid}dk${p.scene})"/><rect y="62" width="100" height="1.3" fill="#fff" opacity=".38"/><rect y="63.3" width="100" height="2.6" fill="#000" opacity=".1"/>
464
- ${prop}${p.custom ? '' : V('laptop', working, lap) + V('hands', working, hands)}${V('sparks', done, `<g class="sparks">${sparks}</g>`)}
494
+ ${prop}${p.custom ? (prefs.customLaptop ? V('laptop', working, lapDark) : '') : V('laptop', working, lap) + V('hands', working, hands)}${V('sparks', done, `<g class="sparks">${sparks}</g>`)}
465
495
  </svg>`;
466
496
  }
467
497
 
468
498
  // =====================================================================
469
499
  // state
470
500
  // =====================================================================
471
- const S = { owner: null, agents: {}, events: [], idx: 0, playing: false, token: 0, status: {}, tc: {}, cur: null, live: true, userPaused: false, boost: 1, stopAt: 0,
501
+ const S = { owner: null, errRun: {}, exQ: [], agents: {}, events: [], idx: 0, playing: false, token: 0, status: {}, tc: {}, cur: null, live: true, userPaused: false, boost: 1, stopAt: 0,
472
502
  speakers: new Set(), say: {}, lastTool: {}, level: {}, detail: null, collapsed: new Set(), title: '', idle: false, locked: true, logTo: 0, trOk: true,
473
503
  clock: 0, doneAt: {}, gone: new Set(), leaving: new Set(), demo: false, hold: false };
474
504
  const VANISH_MS = 120000; // a finished agent leaves the call after 2 minutes (playback time)
475
- const spd = () => (S.live ? 1.12 : +$('#speed').value) * (S.boost || 1);
505
+ // Live: how far the call is behind the real session, and what to do about it (speak faster, say less, skip decoration) so it keeps the rhythm of the real conversation
506
+ const lagMs = (i = S.idx) => { // how much newer the newest event of the session is than the one being played: 0 when the call is up to date, however long the session has been idle
507
+ if (!S.live || !S.events.length) return 0; const e = S.events[Math.max(0, Math.min(i, S.events.length - 1))]; let last = S.events.length - 1; while (last > 0 && !S.events[last].ts) last--;
508
+ return e && e.ts && S.events[last].ts ? Math.max(0, S.events[last].ts - e.ts) : 0;
509
+ };
510
+ const lagFactor = () => { const l = lagMs() / 1000; return l < 6 ? 1 : l < 15 ? 1.15 : l < 30 ? 1.3 : 1.5; }; // steps, so the cached audio of a line is reused
511
+ const caughtUp = () => !S.live || lagMs() < 6000;
512
+ const spd = () => (S.live ? 1.12 * lagFactor() : +$('#speed').value) * (S.boost || 1);
476
513
  const nameOf = (id) => (S.agents[id] || {}).name || id;
477
514
 
478
515
  // ---------- text prep ----------
@@ -488,7 +525,7 @@ const speakable = (t) => t.replace(/(?:[\w.-]+\/)+([\w.-]+)/g, (m, f) => f.repla
488
525
 
489
526
  // ---------- translation (local claude CLI) ----------
490
527
  const trMem = new Map(), trPending = new Map();
491
- const baseText = (ev, max) => short(ev.text, max || (S.live ? (ev.kind === 'order' ? 150 : 180) : (ev.kind === 'order' ? 200 : 230)));
528
+ const baseText = (ev, max) => short(ev.text, max || (S.live ? (ev.kind === 'order' ? 220 : 180) : (ev.kind === 'order' ? 200 : 230)));
492
529
  function ensureTr(texts) {
493
530
  texts = [...new Set(texts)].filter((t) => t && !trMem.has(t));
494
531
  const waits = texts.filter((t) => trPending.has(t)).map((t) => trPending.get(t)), fresh = texts.filter((t) => !trPending.has(t));
@@ -676,7 +713,7 @@ const TTS = {
676
713
  for (const l of ['fr', 'en']) this.pool[l] = rankVoices(d.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name)); } catch {}
677
714
  },
678
715
  voiceFor(p, fr = FR()) { const pool = this.pool[fr ? 'fr' : 'en']; return pool.length ? (p.id === 'main' ? pool[0] : pool[(p.vi >>> 2) % pool.length]) : null; },
679
- rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 : +$('#speed').value))) / 10) * 10; },
716
+ rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 * lagFactor() : +$('#speed').value))) / 10) * 10; },
680
717
  buf(text, voice, rate) {
681
718
  const key = this.engine + '|' + voice + '|' + rate + '|' + text;
682
719
  if (!this.cache.has(key)) {
@@ -844,7 +881,7 @@ const layout = () => {
844
881
  if (nar) { k = cols; fr = cols > 1 ? 2 : 1; } // side panel: small tiles, a banner-sized spotlight
845
882
  else if (best < 0) { k = Math.min(2, cols); fr = Math.min(2, maxRows); }
846
883
  st.style.setProperty('--fcols', k); st.style.setProperty('--fr', fr); st.classList.toggle('one', cols === 1);
847
- document.body.classList.toggle('narrow', $('#center').clientWidth < 480); drawGantt();
884
+ document.body.classList.toggle('narrow', $('#center').clientWidth < 480); document.body.classList.toggle('compact', $('#center').clientWidth < 300); drawGantt();
848
885
  };
849
886
  new ResizeObserver(layout).observe($('#center'));
850
887
 
@@ -922,7 +959,7 @@ function tally(ev) { // numbers that pile up over a session: token usage, erro
922
959
  if (ev.kind === 'compact') { S.compacts[ev.agent] = (S.compacts[ev.agent] || 0) + 1; delete S.ctxWarned[ev.agent]; }
923
960
  const det = loopStep(S.rep, ev); if (det) { S.loops[ev.agent] = det; later('badges', renderBadges, 300); } else if (S.loops[ev.agent] && (ev.kind === 'results' ? (S.loops[ev.agent].kind === 'errors' && ev.items.every((it) => !it.err)) : (ev.kind === 'tools' && S.rep[ev.agent].n < 2))) { delete S.loops[ev.agent]; later('badges', renderBadges, 300); }
924
961
  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); }
925
- 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); } });
962
+ 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); });
926
963
  else if (ev.kind === 'tools' || ev.kind === 'say') S.lastAct[ev.agent] = ev.ts;
927
964
  }
928
965
  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)}`;
@@ -962,10 +999,10 @@ function agentState(id, now) {
962
999
  }
963
1000
  const STATE_CLASSES = ['working', 'waiting', 'blocked', 'stalled', 'done'];
964
1001
  function paintStates() {
965
- const now = Date.now(), n = { working: 0, waiting: 0, blocked: 0, stalled: 0, done: 0 }, ros = [];
1002
+ const now = Date.now(), n = { working: 0, waiting: 0, blocked: 0, stalled: 0, done: 0 }, ros = [], attn = [];
966
1003
  for (const p of Object.values(S.agents)) {
967
1004
  if (p.id === 'user') continue; const t = $('#t-' + p.id); if (!t) continue;
968
- const st = agentState(p.id, now), shown = t.style.display !== 'none'; if (shown && n[st.k] !== undefined && !S.gone.has(p.id)) n[st.k]++;
1005
+ const st = agentState(p.id, now), shown = t.style.display !== 'none'; if (shown && n[st.k] !== undefined && !S.gone.has(p.id)) { n[st.k]++; if (st.k === 'blocked' || st.k === 'stalled') attn.push(p.id); }
969
1006
  STATE_CLASSES.forEach((c) => t.classList.toggle(c, st.k === c));
970
1007
  const el = t.querySelector('.status'); if (el.textContent !== st.txt) el.textContent = st.txt; el.className = 'status ' + st.k;
971
1008
  const pr = t.querySelector('.prop'), want = { blocked: '✋', stalled: '😰', waiting: '⌛' }[st.k] || '', was = S.prevState[p.id]; // a raised hand, a sweat drop, an hourglass: the call metaphor for "needs you / stuck / waiting"
@@ -978,7 +1015,7 @@ function paintStates() {
978
1015
  }
979
1016
  const b = $('#board'), parts = [['working', '⚙', L('au travail', 'working')], ['waiting', '🔗', L('en attente', 'waiting')], ['blocked', '✋', L('vous attendent', 'need you')], ['stalled', '⚠', L('bloqués', 'stalled')], ['done', '✓', L('terminés', 'done')]]
980
1017
  .filter(([k]) => n[k]).map(([k, i, l]) => `<span class="${k}" title="${n[k]} ${l}">${i} ${n[k]}<em> ${l}</em></span>`).join('');
981
- if (b.innerHTML !== parts) b.innerHTML = parts; S.nBlocked = n.blocked + n.stalled; paintRoster(ros); return n;
1018
+ if (b.innerHTML !== parts) b.innerHTML = parts; S.nBlocked = n.blocked + n.stalled; S.attn = attn; paintRoster(ros); return n;
982
1019
  }
983
1020
  const RORDER = { blocked: 0, stalled: 1, working: 2, waiting: 3, idle: 4, done: 5 };
984
1021
  function paintRoster(ros) { // a fixed list beside the call: who is doing what, urgent first, readable even with dozens of agents
@@ -1393,8 +1430,105 @@ function ackLine(ev) {
1393
1430
  const pool = cat && hash(String(ev.ts)) % 4 ? R[cat] : R.any, seed = hash(String(ev.ts) + ev.to); let line = pool[seed % pool.length];
1394
1431
  if (line === S.lastAck) line = pool[(seed + 1) % pool.length]; S.lastAck = line; return line;
1395
1432
  }
1433
+ // =====================================================================
1434
+ // conversation: short exchanges between agents at the right moments, built from what really happened in the session
1435
+ // =====================================================================
1436
+ const CHAT = { quiet: 0.45, normal: 1, lively: 1.8 }, chatMul = () => CHAT[prefs.chat] || CHAT.quiet; // quiet unless chosen otherwise, so the call does not tire anyone
1437
+ const EXW = { en: { edit: ['edit', 'edits'], call: ['tool call', 'tool calls'], error: ['error', 'errors'] }, fr: { edit: ['modification', 'modifications'], call: ['appel d’outil', 'appels d’outils'], error: ['erreur', 'erreurs'] } };
1438
+ const P = (k, n) => { const w = EXW[FR() ? 'fr' : 'en'][k]; return `${n} ${n === 1 ? w[0] : w[1]}`; };
1439
+ const EXCH = {
1440
+ en: { done: ['All done.', 'Finished.', 'Okay, that’s done.', 'Done on my side.', 'That’s wrapped up.'],
1441
+ ask: ['Quick question: is that the whole scope?', 'Anything else I should touch?', 'Any constraints I should know about?', 'Should I check with you before big changes?'],
1442
+ askBack: ['Just that, thanks.', 'No, keep it focused.', 'Use your judgment.', 'Nothing special, go ahead.', 'That’s all, thanks.'],
1443
+ ok: [(f) => `Nice, ${f.what} and no errors.`, (f) => (f.file ? `Good, ${f.file} looks fine.` : 'Good, that looks fine.'), (f, b) => `Perfect${f.dur ? ', that took ' + f.dur : ''}. Thanks ${b}.`, (f) => `Great, ${f.what}. Clean.`],
1444
+ err: [(f) => `I saw ${P('error', f.errs)} along the way. All good now?`, (f) => `${P('error', f.errs)} on the way, is it solid?`],
1445
+ errBack: ['Yes, they were just retries.', 'All fixed in the end.', 'Yes, a few hiccups, nothing serious.', 'It’s solid now.'],
1446
+ slow: [(f) => `That took ${f.dur}. Anything blocking you?`, (f) => `${f.dur}, that was long. All fine?`], slowBack: ['No, it was just a big one.', 'Nothing blocking, just a lot to read.', 'All fine, it was heavy.'],
1447
+ chime: [(b) => `Good job, ${b}.`, () => 'Nice one.', (b) => `Great, ${b}.`],
1448
+ trouble: [(b) => `${b}, you okay? That’s a lot of errors.`, (b) => `${b}, need a hand with those errors?`], troubleBack: ['Working on it.', 'Yes, almost got it.', 'Give me a minute.', 'I’m on it, thanks.'],
1449
+ sync: ['Quick sync: where are we?', 'Status check, everyone?', 'Let’s do a quick round.'], syncAns: [(a) => `Still on it: ${a}.`, (a) => `${a}, going well.`, (a) => `Currently ${a}.`] },
1450
+ fr: { done: ['Terminé.', 'C’est fini.', 'Voilà, c’est fait.', 'Fini de mon côté.', 'C’est bouclé.'],
1451
+ ask: ['Petite question : c’est tout ce qu’il y a à faire ?', 'Il y a autre chose à toucher ?', 'Des contraintes que je dois connaître ?', 'Je te préviens avant les gros changements ?'],
1452
+ askBack: ['Juste ça, merci.', 'Non, reste concentré.', 'Fais au mieux.', 'Rien de spécial, vas-y.', 'C’est tout, merci.'],
1453
+ ok: [(f) => `Bien, ${f.what} et aucune erreur.`, (f) => (f.file ? `Bien, ${f.file} a l’air bon.` : 'Bien, ça a l’air bon.'), (f, b) => `Parfait${f.dur ? ', ça a pris ' + f.dur : ''}. Merci ${b}.`, (f) => `Super, ${f.what}. Propre.`],
1454
+ err: [(f) => `J’ai vu ${P('error', f.errs)} en route. Tout est bon maintenant ?`, (f) => `${P('error', f.errs)} en chemin, c’est solide ?`],
1455
+ errBack: ['Oui, c’étaient juste des essais.', 'Tout est réglé à la fin.', 'Oui, quelques accrocs, rien de grave.', 'C’est solide maintenant.'],
1456
+ slow: [(f) => `Ça a pris ${f.dur}. Quelque chose te bloque ?`, (f) => `${f.dur}, c’était long. Tout va bien ?`], slowBack: ['Non, c’était juste un gros morceau.', 'Rien ne bloque, il y avait beaucoup à lire.', 'Tout va bien, c’était lourd.'],
1457
+ chime: [(b) => `Bon boulot, ${b}.`, () => 'Joli.', (b) => `Super, ${b}.`],
1458
+ trouble: [(b) => `${b}, ça va ? Ça fait beaucoup d’erreurs.`, (b) => `${b}, besoin d’un coup de main avec ces erreurs ?`], troubleBack: ['Je m’en occupe.', 'Oui, j’y suis presque.', 'Donne-moi une minute.', 'Je gère, merci.'],
1459
+ sync: ['Petit point rapide : où en est-on ?', 'Point d’étape, tout le monde ?', 'On fait un tour de table rapide.'], syncAns: [(a) => `Toujours dessus : ${a}.`, (a) => `${a}, ça avance bien.`, (a) => `En ce moment : ${a}.`] },
1460
+ };
1461
+ const exPick = (arr, seed, ...args) => { let k = seed % arr.length; if (arr[k] === S.lastX && arr.length > 1) k = (k + 1) % arr.length; S.lastX = arr[k]; return typeof arr[k] === 'function' ? arr[k](...args) : arr[k]; };
1462
+ function exchFacts(i, agent) { // what this agent did since it received its order: edits, tool calls, errors, the last file, how long it took
1463
+ const ev = S.events; let o = -1;
1464
+ for (let k = i; k >= Math.max(0, i - 600); k--) if (ev[k].kind === 'order' && ev[k].to === agent) { o = k; break; }
1465
+ const f = { tools: 0, edits: 0, errs: 0, file: '', dur: '', ms: 0 };
1466
+ for (let k = o >= 0 ? o : Math.max(0, i - 250); k <= i; k++) {
1467
+ const e = ev[k]; if (e.agent !== agent) continue;
1468
+ if (e.kind === 'tools') for (const t of e.tools) { f.tools++; if (/^(Edit|Write|MultiEdit|NotebookEdit)/.test(t.name)) { f.edits++; f.file = base((t.d || {}).file || t.arg) || f.file; } }
1469
+ else if (e.kind === 'results') f.errs += e.items.filter((it) => it.err).length;
1470
+ }
1471
+ if (o >= 0 && ev[i].ts && ev[o].ts) { f.ms = Math.max(0, ev[i].ts - ev[o].ts); f.dur = fmtShort(f.ms); }
1472
+ f.what = f.edits ? P('edit', f.edits) : f.tools ? P('call', f.tools) : L('rien à signaler', 'nothing to report'); return f;
1473
+ }
1474
+ const exVisible = (id) => S.agents[id] && !S.gone.has(id) && $('#t-' + id) && $('#t-' + id).style.display !== 'none';
1475
+ async function exSay(id, line, token) { // one line of an exchange, then a short human pause
1476
+ if (!exVisible(id) || token !== S.token) return false; startTalk(id, line, '💬'); await speakLine(line, S.agents[id], true, token); stopTalk(id);
1477
+ if (token !== S.token) return false; await sleep((150 + Math.random() * 250) / spd()); return token === S.token;
1478
+ }
1479
+ const exReady = () => prefs.banter && !S.hurry && S.boost <= 1 && !S.recomputing && !S.speakers.size && caughtUp() && performance.now() - (S.exAt || 0) > (S.live ? 22000 : 9000 / spd()) / chatMul();
1480
+ const exLog = (kind) => { (S.exLog = S.exLog || []).push(kind); };
1481
+ function noteTrouble(agent, ok) { // three errors in a row: someone asks if everything is alright
1482
+ if (ok) { S.errRun[agent] = 0; return; } if (!prefs.banter || S.recomputing) return;
1483
+ S.errRun[agent] = (S.errRun[agent] || 0) + 1; if (S.errRun[agent] === 3) (S.exQ = S.exQ || []).push(agent);
1484
+ }
1485
+ function pickOther(not, prefer) { const ids = Object.keys(S.agents).filter((id) => id !== 'user' && !not.includes(id) && exVisible(id)); const pr = ids.filter(prefer || (() => false)); const a = pr.length ? pr : ids; return a.length ? a[Math.floor(Math.random() * a.length)] : null; }
1486
+ async function handoffX(g, seed, token) { // an order: the new agent asks a question, the lead answers
1487
+ const E = EXCH[FR() ? 'fr' : 'en'], b = g.to, a = g.agent; if (!exVisible(a) || !exVisible(b)) return false; exLog('handoff');
1488
+ return (await exSay(b, exPick(E.ask, seed), token)) && (await exSay(a, exPick(E.askBack, seed >>> 3), token));
1489
+ }
1490
+ async function reportX(g, i, seed, token) { // a report: the receiver reacts to what really happened
1491
+ const E = EXCH[FR() ? 'fr' : 'en'], b = g.agent, a = g.to; if (a === 'user' || !exVisible(a) || !exVisible(b)) return false; const f = exchFacts(i, b);
1492
+ if (f.errs > 0) { exLog('report-errors'); return (await exSay(a, exPick(E.err, seed, f), token)) && (await exSay(b, exPick(E.errBack, seed >>> 3), token)); }
1493
+ if (f.ms >= 120000) { exLog('report-slow'); return (await exSay(a, exPick(E.slow, seed, f), token)) && (await exSay(b, exPick(E.slowBack, seed >>> 3), token)); }
1494
+ exLog('report-ok'); if (!(await exSay(a, exPick(E.ok, seed, f, nameOf(b)), token))) return false;
1495
+ const c = (seed >>> 5) % 100 < 35 ? pickOther([a, b]) : null; if (c) await exSay(c, exPick(E.chime, seed >>> 7, nameOf(b)), token); return true;
1496
+ }
1497
+ async function troubleX(token) { // someone keeps failing: a colleague checks in
1498
+ const E = EXCH[FR() ? 'fr' : 'en']; while (S.exQ && S.exQ.length) {
1499
+ const x = S.exQ.shift(); if (!exVisible(x)) continue; const h = pickOther([x], (id) => id === 'main'); if (!h) continue; const seed = hash('t' + x + Date.now());
1500
+ exLog('trouble'); return (await exSay(h, exPick(E.trouble, seed, nameOf(x)), token)) && (await exSay(x, exPick(E.troubleBack, seed >>> 3), token));
1501
+ }
1502
+ return false;
1503
+ }
1504
+ async function syncX(g, token) { // every few minutes of the session: the lead asks where everyone is, the busy agents answer
1505
+ if (chatMul() < 0.9 || !g.ts || g.ts - (S.syncTs || 0) < 180000 || !exVisible('main')) return false;
1506
+ const now = Date.now(), busy = Object.keys(S.agents).filter((id) => id !== 'user' && id !== 'main' && exVisible(id) && agentState(id, now).k === 'working').slice(0, 3); if (busy.length < 2) return false;
1507
+ S.syncTs = g.ts; const E = EXCH[FR() ? 'fr' : 'en'], seed = hash('s' + g.ts); exLog('sync');
1508
+ if (!(await exSay('main', exPick(E.sync, seed), token))) return false;
1509
+ for (const [n, id] of busy.entries()) { const act = (S.act[id] || '').replace(/^\S+\s/, '') || L('au travail', 'working'); if (!(await exSay(id, exPick(E.syncAns, seed >>> (n + 1), act.charAt(0).toLowerCase() + act.slice(1)), token))) return false; }
1510
+ return true;
1511
+ }
1512
+ async function converse(g, token) { // after a line has been said: an exchange when it suits, else the usual one-liners
1513
+ if (S.hurry || S.boost > 1) return; const i = S.events.indexOf(g), seed = hash('x' + g.ts + g.agent), roll = (seed % 1000) / 1000, m = chatMul();
1514
+ if (exReady()) {
1515
+ let ran = false;
1516
+ if (g.kind === 'order' && g.agent !== 'user' && roll < 0.25 * m) ran = await handoffX(g, seed, token);
1517
+ else if (g.kind === 'report' && roll < 0.55 * m) ran = await reportX(g, i, seed, token);
1518
+ if (!ran && token === S.token && S.exQ && S.exQ.length && exReady()) ran = await troubleX(token);
1519
+ if (!ran && token === S.token && exReady()) ran = await syncX(g, token);
1520
+ if (ran) { S.exAt = performance.now(); return; } if (token !== S.token) return;
1521
+ }
1522
+ await react(g, token);
1523
+ }
1524
+ // What is said out loud for an order or a report: a natural lead-in, then the text
1525
+ function talkLine(g) {
1526
+ const E = EXCH[FR() ? 'fr' : 'en'], seed = hash('l' + g.ts + g.agent), t = spoken(g);
1527
+ if (g.kind === 'report') return t.replace(/^(Terminé\.|Done\.)\s*/, E.done[seed % E.done.length] + ' ');
1528
+ return t;
1529
+ }
1396
1530
  async function react(ev, token) {
1397
- 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;
1531
+ if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry || !caughtUp()) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line, thanks = false;
1398
1532
  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]; }
1399
1533
  else if (ev.kind === 'report') {
1400
1534
  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
@@ -1451,7 +1585,7 @@ const SPK = new Set(['say', 'report', 'order', 'user']);
1451
1585
  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; }
1452
1586
  const isSub = (id) => id !== 'main' && id !== 'user';
1453
1587
  async function run(i) {
1454
- 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();
1588
+ const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; S.spokeAt = performance.now(); S.fill = 0; S.exQ = []; S.errRun = {}; S.syncTs = (S.events[i] && S.events[i].ts) || Date.now(); syncUI(); AUD.init();
1455
1589
  if (i === 0 && prefs.epi && !S.live) { await card(L('ÉPISODE 1', 'EPISODE 1'), S.title); if (token !== S.token) return; }
1456
1590
  while (token === S.token) {
1457
1591
  const lim = S.stopAt || S.events.length;
@@ -1459,13 +1593,13 @@ async function run(i) {
1459
1593
  if (S.idle) { S.idle = false; syncUI(); }
1460
1594
  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
1461
1595
  const rem = S.events.length - i;
1462
- if (rem > 300 || aheadSpeakable(i, 16) > 16) { // only a really big backlog is skipped; before that, lines are shortened instead
1596
+ if (rem > 300 || aheadSpeakable(i, 16) > 16 || lagMs(i) > 60000) { // only a really big backlog is skipped; before that, lines are shortened instead
1463
1597
  let k = S.events.length, back = 0; while (k > i && back < 8) { k--; if (SPK.has(S.events[k].kind)) back++; }
1464
1598
  const to = rem > 300 ? Math.max(k, S.events.length - 14) : k; if (to > i) { i = to; recomputeStatus(i); paintStatus(); }
1465
1599
  }
1466
1600
  }
1467
1601
  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
1468
- 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
1602
+ const lg = lagMs(i) / 1000; S.pace = !S.live ? 0 : Math.min(q > 7 ? 70 : q > 3 ? 130 : 260, lg > 30 ? 70 : lg > 15 ? 130 : lg > 6 ? 200 : 260); S.hurry = q > 12 || lg > 45; // voices are only skipped when more than a dozen lines are queued
1469
1603
  S.boost = S.hurry || (S.live && S.events.length - i > 60) ? 3 : 1;
1470
1604
  const ev = S.events[i]; S.idx = i; tally(ev);
1471
1605
  if (!S.live && (ev.kind === 'say' || ev.kind === 'report' || ev.kind === 'order' || ev.kind === 'tools')) { await ponder(i, token); if (token !== S.token) return; }
@@ -1488,7 +1622,7 @@ async function run(i) {
1488
1622
  prefetchQuips(i); warmTTS(i);
1489
1623
  const playOne = (g) => {
1490
1624
  applyStatus(g);
1491
- const label = g.kind === 'order' ? `${L('pour', 'to')} ${nameOf(g.to)}` : g.kind === 'report' ? `${L('rapport à', 'report to')} ${nameOf(g.to)}` : '', line = spoken(g);
1625
+ const label = g.kind === 'order' ? `${L('pour', 'to')} ${nameOf(g.to)}` : g.kind === 'report' ? `${L('rapport à', 'report to')} ${nameOf(g.to)}` : '', line = talkLine(g);
1492
1626
  startTalk(g.agent, line, label);
1493
1627
  if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1494
1628
  if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
@@ -1496,7 +1630,7 @@ async function run(i) {
1496
1630
  };
1497
1631
  let plays; if (S.live) { plays = []; let chain = Promise.resolve(); for (const g of group) { chain = chain.then(() => (token === S.token ? playOne(g) : null)); plays.push(chain); } } else plays = group.map(playOne); // live: one voice at a time so each can be heard; replay keeps the overlapping chatter
1498
1632
  S.idx = j - 1; paintStatus(); await Promise.all(plays); if (token !== S.token) return;
1499
- for (const g of group) { await react(g, token); if (token !== S.token) return; }
1633
+ for (const g of group) { await converse(g, token); if (token !== S.token) return; }
1500
1634
  i = j;
1501
1635
  }
1502
1636
  if (token === S.token) {
@@ -1567,9 +1701,9 @@ setInterval(async () => {
1567
1701
  if (d.error) return; if (d.total < S.events.length) { S.cur = null; return; } // transcript was rewritten: reload next tick
1568
1702
  if (!d.events || !d.events.length) return;
1569
1703
  for (const a of d.agents) if (!S.agents[a.id]) { S.agents[a.id] = persona(a); addTile(S.agents[a.id]); }
1570
- const from = S.events.length; S.events = S.events.concat(d.events); $('#seek').max = S.events.length - 1; trNew(from); renderLog(false); paintStatus();
1704
+ const from = S.events.length; S.events = S.events.concat(d.events); $('#seek').max = S.events.length - 1; trNew(from); warmTTS(from); renderLog(false); paintStatus();
1571
1705
  } catch { if (!S.offline) { S.offline = true; syncUI(); } } finally { polling = false; }
1572
- }, 700);
1706
+ }, 400);
1573
1707
 
1574
1708
 
1575
1709
  window.addEventListener('message', (e) => { // the VS Code status bar asks to follow a given session
@@ -1611,10 +1745,29 @@ async function renderBoardView() {
1611
1745
  } catch {} finally { boardBusy = false; }
1612
1746
  }
1613
1747
  $('#boardv').onkeydown = (e) => { if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('bcard')) { e.preventDefault(); e.target.click(); } };
1614
- $('#boardv').onclick = async (e) => {
1615
- const c = e.target.closest('.bcard'); if (!c) return; prefs.proj = c.dataset.p; S.pin = c.dataset.i; savePrefs(); setView('call');
1748
+ async function openSession(proj, id) {
1749
+ prefs.proj = proj; S.pin = id; savePrefs(); setView('call');
1616
1750
  if (S.live || S.demo) { if (S.demo) await setMode('live'); S.cur = null; fillProjects(); await followCurrent(); } else await setMode('live');
1617
- };
1751
+ }
1752
+ $('#boardv').onclick = (e) => { const c = e.target.closest('.bcard'); if (c) openSession(c.dataset.p, c.dataset.i); };
1753
+ // Cmd/Ctrl+K: jump to any session from any view
1754
+ async function openSwitcher() {
1755
+ let sw = $('#switcher'); if (sw) { closeSwitcher(); return; }
1756
+ sw = document.createElement('div'); sw.id = 'switcher'; sw.innerHTML = `<div class="sbox"><input id="swq" type="search" aria-label="${esc(L('Chercher une session', 'Find a session'))}" placeholder="${esc(L('Chercher une session…', 'Find a session…'))}"><div id="swl" role="listbox"></div></div>`; document.body.appendChild(sw);
1757
+ let rows = []; try { rows = (await (await fetch('/api/board')).json()).sort((a, b) => BORDER.indexOf(a.state) - BORDER.indexOf(b.state) || b.mtime - a.mtime); } catch {}
1758
+ let sel = 0, shown = [];
1759
+ const draw = () => {
1760
+ const q = $('#swq').value.trim().toLowerCase(), now = Date.now(); shown = rows.filter((r) => !q || (r.title + ' ' + niceProj(r.proj)).toLowerCase().includes(q)).slice(0, 12); sel = Math.min(sel, Math.max(0, shown.length - 1));
1761
+ $('#swl').innerHTML = shown.map((r, i) => { const [ic, en, fr] = BSTATE[r.state]; return `<div class="swr${i === sel ? ' on' : ''}" role="option" data-i="${i}"><span class="stc ${r.state}">${ic} ${L(fr, en)}</span><b>${esc(/^[0-9a-f]{8}-[0-9a-f-]{27}$/.test(r.title) ? L('Session sans titre', 'Untitled session') : r.title)}</b><i>${esc(niceProj(r.proj))} · ${agoStr(now - r.mtime)}</i></div>`; }).join('') || `<div class="swr dim">${L('Aucune session récente', 'No recent session')}</div>`;
1762
+ };
1763
+ const pick = (i) => { const r = shown[i]; closeSwitcher(); if (r) openSession(r.proj, r.id); };
1764
+ $('#swq').oninput = () => { sel = 0; draw(); };
1765
+ $('#swq').onkeydown = (e) => { if (e.key === 'ArrowDown') { sel = Math.min(shown.length - 1, sel + 1); draw(); e.preventDefault(); } else if (e.key === 'ArrowUp') { sel = Math.max(0, sel - 1); draw(); e.preventDefault(); } else if (e.key === 'Enter') pick(sel); else if (e.key === 'Escape') closeSwitcher(); };
1766
+ $('#swl').onclick = (e) => { const r = e.target.closest('.swr[data-i]'); if (r) pick(+r.dataset.i); };
1767
+ sw.onclick = (e) => { if (e.target === sw) closeSwitcher(); };
1768
+ draw(); $('#swq').focus();
1769
+ }
1770
+ function closeSwitcher() { const sw = $('#switcher'); if (sw) sw.remove(); }
1618
1771
  setInterval(() => { if (!document.hidden) renderBoardView(); }, 2500);
1619
1772
  async function setMode(m) {
1620
1773
  if (m === 'demo') { // a built-in fictional session: nothing to install, nothing private on screen
@@ -1632,10 +1785,10 @@ async function setMode(m) {
1632
1785
  const rows = await (await fetch('/api/sessions')).json();
1633
1786
  $('#sessions').innerHTML = rows.map((r) => { const d = new Date(r.mtime).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
1634
1787
  return `<option value="${esc(r.proj)}|${r.id}" data-title="${esc(r.title)}">${r.agents ? '👥' + r.agents + ' · ' : ''}${esc(r.title.slice(0, 40))} (${d})</option>`; }).join('');
1635
- $('#empty').classList.remove('on'); const pick = rows.find((r) => r.agents) || rows[0]; if (!pick) { $('#empty').classList.add('on'); return; }
1788
+ $('#empty').classList.remove('on'); const pick = rows.find((r) => r.id === Q.get('pin')) || rows.find((r) => r.agents) || rows[0]; if (!pick) { $('#empty').classList.add('on'); return; }
1636
1789
  $('#sessions').value = `${pick.proj}|${pick.id}`; await openSelected();
1637
1790
  }
1638
- async function openSelected() { const [p, i] = $('#sessions').value.split('|'); if (await loadSession(p, i)) { S.locked = false; $('#gate').classList.remove('on'); } }
1791
+ async function openSelected() { const [p, i] = $('#sessions').value.split('|'); if (await loadSession(p, i)) { S.locked = false; $('#gate').classList.remove('on'); const at = (prefs.pos || {})[p + '/' + i]; if (!S.live && at > 3 && at < S.events.length - 3) { seekTo(at); toast(L('Reprise là où vous vous étiez arrêté', 'Resumed where you left off')); } } }
1639
1792
  $('#sessions').onchange = openSelected;
1640
1793
 
1641
1794
  // =====================================================================
@@ -1729,7 +1882,7 @@ function applyLang() {
1729
1882
  document.documentElement.lang = (LANGS.find((l) => l[0] === prefs.lang) || LANGS[0])[2];
1730
1883
  document.querySelectorAll('[data-fr]').forEach((el) => (el.textContent = FR() ? el.dataset.fr : tr(el.dataset.en)));
1731
1884
  const sel = $('#langSel'); if (!sel.options.length) sel.innerHTML = LANGS.map(([c, n]) => `<option value="${c}">${n}</option>`).join(''); sel.value = prefs.lang;
1732
- [['#bPlay', L('Pause / Lecture (espace)', 'Pause / Play (space)')], ['#bLog', 'Transcript'], ['#bMute', L('Son (touche M)', 'Sound (M key)')], ['#bMenu', L('Réglages', 'Settings')], ['#dclose', L('Fermer', 'Close')], ['#officeVol', L('Volume de l’ambiance', 'Ambience volume')], ['#musicVol', L('Volume de la musique', 'Music volume')], ['#liveProj', L('Projet à suivre', 'Project to follow')], ['#reactBar', L('Réactions', 'Reactions')], ['#logQ', L('Rechercher dans la transcription', 'Search the transcript')], ['#roster', L('Agents', 'Agents')]].forEach(([q, t]) => { const el = $(q); if (!el) return; el.title = t; el.setAttribute('aria-label', t); }); $('#logQ').placeholder = L('Rechercher…', 'Search…'); document.querySelectorAll('#reactBar button').forEach((b) => b.setAttribute('aria-label', b.dataset.e));
1885
+ [['#bPlay', L('Pause / Lecture (espace)', 'Pause / Play (space)')], ['#bLog', 'Transcript'], ['#bMute', L('Son (touche M)', 'Sound (M key)')], ['#bMenu', L('Réglages', 'Settings')], ['#dclose', L('Fermer', 'Close')], ['#officeVol', L('Volume de l’ambiance', 'Ambience volume')], ['#musicVol', L('Volume de la musique', 'Music volume')], ['#liveProj', L('Projet à suivre', 'Project to follow')], ['#reactBar', L('Réactions', 'Reactions')], ['#logQ', L('Rechercher dans la transcription', 'Search the transcript')], ['#roster', L('Agents', 'Agents')]].forEach(([q, t]) => { const el = $(q); if (!el) return; el.title = t; el.setAttribute('aria-label', t); }); $('#logQ').placeholder = L('Rechercher…', 'Search…'); $('#menuQ').placeholder = L('Chercher un réglage…', 'Search settings…'); document.querySelectorAll('#reactBar button').forEach((b) => b.setAttribute('aria-label', b.dataset.e));
1733
1886
  $('#gT').textContent = L('Rejoindre l’appel', 'Join the call'); $('#gP').textContent = L('Cliquez pour activer le son et suivre votre session Claude Code en direct.', 'Click to enable sound and follow your Claude Code session live.'); $('#gB').textContent = L('▶ Rejoindre', '▶ Join');
1734
1887
  $('#eDemo').textContent = L('▶ Voir la démo', '▶ Watch the demo'); $('#eT').textContent = L('En attente d’une session', 'Waiting for a session'); $('#eP').textContent = L('Lancez Claude Code dans ce dossier : l’appel démarrera tout seul.', 'Start Claude Code in this folder — the call will start by itself.');
1735
1888
  for (const id in S.agents) { const p = persona(S.agents[id]); Object.assign(S.agents[id], { name: p.name, role: p.role }); const t = $('#t-' + id); if (t) { t.querySelector('.tag span').textContent = p.role; t.querySelector('.tag b').textContent = p.name; if (id === 'main' || id === 'user') t.querySelector('.tag span').textContent = p.role; } }
@@ -1754,14 +1907,29 @@ function setView(v) { prefs.view = v; savePrefs(); document.body.classList.toggl
1754
1907
 
1755
1908
  $('#viewSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setView(b.dataset.v); };
1756
1909
  $('#langSel').onchange = () => setLang($('#langSel').value, true);
1757
- $('#charReload').onclick = async () => { await loadChars(); for (const k in imgCache) delete imgCache[k]; for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
1910
+ $('#charReload').onclick = async () => { await loadChars(); for (const k in imgCache) delete imgCache[k]; for (const id in S.agents) S.agents[id].custom = null; for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
1758
1911
  $('#charOpen').onclick = () => window.parent.postMessage({ agentStandup: 'openCharacters' }, '*');
1759
1912
  $('#modeSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setMode(b.dataset.m); };
1760
- $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
1913
+ // ⚙ menu: sections that fold (remembered) and a search box
1914
+ function menuInit() {
1915
+ const m = $('#menu'), secs = []; let cur = null;
1916
+ for (const el of [...m.children]) { if (el.tagName === 'H4') { cur = { h: el, items: [] }; secs.push(cur); } else if (cur && !el.classList.contains('keys')) cur.items.push(el); }
1917
+ secs.forEach((sc, i) => { sc.h.classList.add('fold'); sc.h.dataset.i = i; sc.h.onclick = () => { const c = !sc.h.classList.contains('closed'); sc.h.classList.toggle('closed', c); sc.items.forEach((e) => e.classList.toggle('mfold', c)); const set = new Set(prefs.menuClosed || []); c ? set.add(i) : set.delete(i); prefs.menuClosed = [...set]; savePrefs(); };
1918
+ if ((prefs.menuClosed || []).includes(i)) { sc.h.classList.add('closed'); sc.items.forEach((e) => e.classList.add('mfold')); } });
1919
+ $('#menuQ').oninput = () => {
1920
+ const q = $('#menuQ').value.trim().toLowerCase();
1921
+ for (const sc of secs) { let any = false;
1922
+ for (const e of sc.items) { const hit = !q || e.textContent.toLowerCase().includes(q) || [...e.querySelectorAll('option')].some((o) => o.textContent.toLowerCase().includes(q)); e.classList.toggle('mhide', !hit); if (hit && q) any = true; if (q) e.classList.remove('mfold'); else e.classList.toggle('mfold', sc.h.classList.contains('closed')); }
1923
+ sc.h.classList.toggle('mhide', !!q && !any && !sc.h.textContent.toLowerCase().includes(q)); if (q) sc.h.classList.remove('closed'); }
1924
+ if (!q) secs.forEach((sc, i) => sc.h.classList.toggle('closed', (prefs.menuClosed || []).includes(i)));
1925
+ };
1926
+ }
1927
+ $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'customLaptop') { for (const k in imgCache) delete imgCache[k]; buildStage(); paintStatus(); } if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify') { prefs.notifyChosen = true; savePrefs(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
1761
1928
  if (b.dataset.t === 'music') { $('#musicBox').style.display = prefs.music ? '' : 'none'; if (prefs.music) { AUD.init(); AUD.apply(); } } };
1762
1929
  $('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
1763
1930
  $('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
1764
1931
  $('#station').onchange = (e) => { prefs.station = e.target.value; savePrefs(); AUD.init(); RADIO.sync(); };
1932
+ $('#chatSel').value = ['quiet', 'normal', 'lively'].includes(prefs.chat) ? prefs.chat : 'quiet'; $('#chatSel').onchange = () => { prefs.chat = $('#chatSel').value; savePrefs(); };
1765
1933
  $('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices'], ['kokoro', 'Voix très naturelles (à installer)', 'Very natural voices (installs on demand)']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); TTS.init().then(kokoroUI); kokoroUI(); };
1766
1934
  // ---- ⚙ → very natural voices (Kokoro): install box and status, polled while installing
1767
1935
  let kokoroTimer = null;
@@ -1799,22 +1967,52 @@ $('#gB').onclick = () => {
1799
1967
  if (synth && !EMBED) { try { synth.speak(new SpeechSynthesisUtterance('')); } catch {} } // unlock browser speech
1800
1968
  if (S.live && S.events.length) run(S.idx);
1801
1969
  };
1970
+ // J / K (or a click on the “need you” chip) jump to the agents that need you or are stalled, one after the other
1971
+ function jumpAttn(dir = 1) {
1972
+ const a = (S.attn || []).filter((id) => $('#t-' + id));
1973
+ if (!a.length) { toast(L('Personne n’a besoin de vous pour le moment', 'Nobody needs you right now')); return; }
1974
+ S.attnAt = ((S.attnAt == null ? (dir > 0 ? -1 : 0) : S.attnAt) + dir + a.length) % a.length; const id = a[S.attnAt];
1975
+ if (document.body.classList.contains('tree') || document.body.classList.contains('board')) setView('call');
1976
+ S.focusLock = Date.now() + 25000; setFocus(id); const t = $('#t-' + id); if (t) { t.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); popTile(id); }
1977
+ toast(`${nameOf(id)} — ${agentState(id, Date.now()).txt.replace(/^\S+\s/, '')}`);
1978
+ }
1979
+ $('#board').onclick = (e) => { if (e.target.closest('.blocked, .stalled')) jumpAttn(1); };
1980
+ // Replay: arrow keys step through the call, N jumps between highlights, speed presets, and the position of each session is remembered
1981
+ const stepKind = (e) => !['usage', 'results', 'finish', 'compact'].includes(e.kind);
1982
+ function stepEvent(dir) {
1983
+ if (S.live || !S.events.length) return; let i = S.idx + dir; while (i > 0 && i < S.events.length - 1 && !stepKind(S.events[i])) i += dir;
1984
+ seekTo(Math.max(0, Math.min(S.events.length - 1, i)));
1985
+ }
1986
+ function stepHighlight(dir) {
1987
+ if (S.live) return; const hs = computeHighlights().map((h) => h.i).sort((a, b) => a - b); if (!hs.length) { toast(L('Aucun moment clé dans cette session', 'No highlight in this session')); return; }
1988
+ const next = dir > 0 ? hs.find((i) => i > S.idx + 1) : [...hs].reverse().find((i) => i < S.idx - 1); seekTo(next != null ? next : dir > 0 ? hs[0] : hs[hs.length - 1]);
1989
+ }
1990
+ function setSpeed(v) { $('#speed').value = v; document.querySelectorAll('#sps button').forEach((b) => b.classList.toggle('on', +b.dataset.s === +v)); }
1991
+ $('#sps').onclick = (e) => { const b = e.target.closest('button'); if (b) setSpeed(+b.dataset.s); };
1992
+ $('#speed').addEventListener('input', () => document.querySelectorAll('#sps button').forEach((b) => b.classList.toggle('on', +b.dataset.s === +$('#speed').value)));
1993
+ const posKey = () => (S.cur ? S.cur.proj + '/' + S.cur.id : '');
1994
+ setInterval(() => {
1995
+ if (S.live || S.demo || !S.cur || !S.events.length || S.idx < 3) return; const k = posKey(), pos = prefs.pos || (prefs.pos = {});
1996
+ const done = S.idx >= S.events.length - 3; if (done) { if (k in pos) { delete pos[k]; savePrefs(); } return; }
1997
+ if (pos[k] !== S.idx) { delete pos[k]; pos[k] = S.idx; const ks = Object.keys(pos); while (ks.length > 40) delete pos[ks.shift()]; savePrefs(); }
1998
+ }, 3000);
1802
1999
  function openKeys() {
1803
- const rows = [['Space', L('Pause / lecture', 'Pause / play')], ['M', L('Son coupé / activé', 'Mute / unmute')], ['T', L('Vue arbre ↔ appel', 'Tree view ↔ call')], ['R', L('Récapitulatif', 'Recap')], ['L', L('Transcription', 'Transcript')], ['?', L('Cette aide', 'This help')], ['Esc', L('Fermer', 'Close')]];
1804
- $('#recap .box').innerHTML = `<h1>${L('Raccourcis clavier', 'Keyboard shortcuts')}</h1><table>${rows.map(([k, d]) => `<tr><td class="f"><b>${esc(k)}</b></td><td>${esc(d)}</td></tr>`).join('')}</table><p class="dim" style="margin:14px 0 0;font-size:12px">${L('Les vues Appel, Arbre et Sessions se changent aussi en haut de la fenêtre.', 'You can also switch between Call, Tree and Sessions at the top of the window.')}</p><div style="margin-top:14px"><button class="btn" id="recapx">${L('Fermer', 'Close')}</button></div>`;
2000
+ const rows = [['Space', L('Pause / lecture', 'Pause / play')], ['← / →', L('Replay : événement précédent / suivant', 'Replay: previous / next event')], ['N / Shift+N', L('Replay : moment clé suivant / précédent', 'Replay: next / previous highlight')], ['M', L('Son coupé / activé', 'Mute / unmute')], ['T', L('Vue arbre ↔ appel', 'Tree view ↔ call')], ['R', L('Récapitulatif', 'Recap')], ['L', L('Transcription', 'Transcript')], ['J / K', L('Agent suivant / précédent qui a besoin de vous', 'Next / previous agent that needs you')], ['Cmd / Ctrl + K', L('Aller à une session', 'Go to a session')], ['?', L('Cette aide', 'This help')], ['Esc', L('Fermer', 'Close')]];
2001
+ $('#recap .box').innerHTML = `<h1>${L('Raccourcis clavier', 'Keyboard shortcuts')}</h1><table>${rows.map(([k, d]) => `<tr><td class="f"><b>${esc(k)}</b></td><td>${esc(d)}</td></tr>`).join('')}</table><h3>${L('Les états', 'What the states mean')}</h3><table>${[['⚙', L('au travail', 'working')], ['🔗', L('attend un autre agent', 'waiting on another agent')], ['✋', L('attend peut-être votre feu vert', 'may be waiting for your approval')], ['⚠', L('bloqué : un sous-agent est silencieux depuis longtemps', 'stalled: a sub-agent has been silent for a long time')], ['✓', L('terminé', 'done')]].map(([k, d]) => `<tr><td class="f">${k}</td><td>${esc(d)}</td></tr>`).join('')}</table><p class="dim" style="margin:14px 0 0;font-size:12px">${L('Les vues Appel, Arbre et Sessions se changent aussi en haut de la fenêtre.', 'You can also switch between Call, Tree and Sessions at the top of the window.')}</p><div style="margin-top:14px"><button class="btn" id="recapx">${L('Fermer', 'Close')}</button></div>`;
1805
2002
  $('#recapx').onclick = () => $('#recap').classList.remove('on'); $('#recap').classList.add('on');
1806
2003
  }
1807
2004
  $('#mKeys').onclick = () => { $('#menu').classList.remove('on'); openKeys(); };
1808
2005
  addEventListener('keydown', (e) => {
1809
- if (/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return;
2006
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openSwitcher(); return; }
2007
+ if (/INPUT|SELECT|TEXTAREA/.test(e.target.tagName) || e.metaKey || e.ctrlKey || e.altKey) return; // browser shortcuts (Cmd+R, Ctrl+N…) stay the browser's
1810
2008
  if (e.key === '?') { openKeys(); return; }
1811
- if (e.key === ' ') { e.preventDefault(); togglePlay(); } else if (e.key.toLowerCase() === 'm') toggleMute(); else if (e.key.toLowerCase() === 't') { const toTree = !document.body.classList.contains('tree'); setView(toTree ? 'tree' : 'call'); if (toTree) toast(L('Vue arbre : T pour revenir, ? pour l’aide', 'Tree view: T to go back, ? for help')); } else if (e.key.toLowerCase() === 'r') openRecap(); else if (e.key.toLowerCase() === 'l') $('#bLog').click(); else if (e.key === 'Escape') { $('#recap').classList.remove('on'); $('#menu').classList.remove('on'); }
2009
+ if (e.key === ' ') { if (e.target.closest && e.target.closest('button, .tile, .rr, .bcard, a, [role=button]')) return; e.preventDefault(); togglePlay(); } else if (e.key.toLowerCase() === 'm') toggleMute(); else if (e.key.toLowerCase() === 't') { const toTree = !document.body.classList.contains('tree'); setView(toTree ? 'tree' : 'call'); if (toTree) toast(L('Vue arbre : T pour revenir, ? pour l’aide', 'Tree view: T to go back, ? for help')); } else if (e.key.toLowerCase() === 'r') openRecap(); else if (e.key.toLowerCase() === 'l') $('#bLog').click(); else if (e.key === 'ArrowRight' && !S.live) { e.preventDefault(); stepEvent(1); } else if (e.key === 'ArrowLeft' && !S.live) { e.preventDefault(); stepEvent(-1); } else if (e.key.toLowerCase() === 'n') stepHighlight(e.shiftKey ? -1 : 1); else if (e.key.toLowerCase() === 'j') jumpAttn(1); else if (e.key.toLowerCase() === 'k') jumpAttn(-1); else if (e.key === 'Escape') { $('#recap').classList.remove('on'); $('#menu').classList.remove('on'); }
1812
2010
  });
1813
2011
  if (EMBED) $('#mBrowser').style.display = '';
1814
2012
 
1815
2013
  (async function init() {
1816
2014
  if (!LANGS.some((l) => l[0] === prefs.lang)) prefs.lang = 'en'; await loadLocale(prefs.lang); await loadChars();
1817
- document.body.classList.toggle('rost', prefs.roster !== false); document.body.classList.toggle('react', !!prefs.reactions); setView(prefs.view); applyLang(); applyMute(); TTS.init().then(updateHint);
2015
+ document.body.classList.toggle('rost', prefs.roster !== false); document.body.classList.toggle('react', !!prefs.reactions); setView(prefs.view); menuInit(); addEventListener('click', () => { try { if (prefs.notify && !EMBED && 'Notification' in window && Notification.permission === 'default') Notification.requestPermission(); } catch {} }, { once: true }); applyLang(); applyMute(); TTS.init().then(updateHint);
1818
2016
  loadHealth().then(() => { // French by default only if translation can actually work; otherwise stay in English without a warning
1819
2017
  const h = S.health || {};
1820
2018
  if (!prefs.langChosen && FR() && h.claude === null && !h.apiKey) { prefs.lang = 'en'; applyLang(); }
@@ -1828,7 +2026,7 @@ if (EMBED) $('#mBrowser').style.display = '';
1828
2026
  return;
1829
2027
  }
1830
2028
  S.live = Q.get('live') !== '0'; document.querySelectorAll('#modeSeg button').forEach((b) => b.classList.toggle('on', b.dataset.m === (S.live ? 'live' : 'replay')));
1831
- if (S.live) { S.locked = true; $('#gate').classList.add('on'); S.cur = null; syncUI(); fillProjects(); await followCurrent(); } else { S.live = true; await setMode('replay'); }
2029
+ if (S.live) { S.locked = true; $('#gate').classList.add('on'); S.cur = null; syncUI(); fillProjects(); await followCurrent(); } else { S.live = true; await setMode('replay'); if (Q.get('at') != null && S.events.length) seekTo(Math.max(0, Math.min(S.events.length - 1, +Q.get('at') || 0))); }
1832
2030
  syncUI();
1833
2031
  })();
1834
2032
  </script>
@@ -185,5 +185,26 @@
185
185
  "You can also switch between Call, Tree and Sessions at the top of the window.": "Du kan også skifte mellem Opkald, Træ og Sessioner øverst i vinduet.",
186
186
  "Your agents are done 🎉": "Dine agenter er færdige 🎉",
187
187
  "Your Claude Code agents as a live team call": "Dine Claude Code-agenter som et levende teammøde",
188
- "your turn": "din tur"
188
+ "your turn": "din tur",
189
+ "Display": "Visning",
190
+ "Search settings…": "Søg i indstillinger…",
191
+ "may be waiting for your approval": "venter måske på din godkendelse",
192
+ "Next / previous agent that needs you": "Næste / forrige agent, der har brug for dig",
193
+ "Nobody needs you right now": "Ingen har brug for dig lige nu",
194
+ "stalled: a sub-agent has been silent for a long time": "gået i stå: en underagent har været tavs længe",
195
+ "waiting on another agent": "venter på en anden agent",
196
+ "What the states mean": "Hvad tilstandene betyder",
197
+ "Find a session": "Find en session",
198
+ "Find a session…": "Find en session…",
199
+ "Go to a session": "Gå til en session",
200
+ "No highlight in this session": "Ingen højdepunkter i denne session",
201
+ "Replay: next / previous highlight": "Afspilning: næste / forrige højdepunkt",
202
+ "Replay: previous / next event": "Afspilning: forrige / næste hændelse",
203
+ "Resumed where you left off": "Fortsat hvor du slap",
204
+ "Chatter between agents": "Snak mellem agenter",
205
+ "Lively": "Livlig",
206
+ "Normal": "Normal",
207
+ "nothing to report": "intet at rapportere",
208
+ "Quiet": "Stille",
209
+ "Laptop in front of your own characters": "Bærbar foran dine egne figurer"
189
210
  }
@@ -186,5 +186,26 @@
186
186
  "Transcript": "Transkript",
187
187
  "Tree view ↔ call": "Baumansicht ↔ Anruf",
188
188
  "Tree view: T to go back, ? for help": "Baumansicht: T zum Zurückkehren, ? für Hilfe",
189
- "You can also switch between Call, Tree and Sessions at the top of the window.": "Du kannst oben im Fenster auch zwischen Anruf, Baum und Sitzungen wechseln."
189
+ "You can also switch between Call, Tree and Sessions at the top of the window.": "Du kannst oben im Fenster auch zwischen Anruf, Baum und Sitzungen wechseln.",
190
+ "Display": "Anzeige",
191
+ "Search settings…": "Einstellung suchen…",
192
+ "may be waiting for your approval": "wartet möglicherweise auf deine Freigabe",
193
+ "Next / previous agent that needs you": "Nächster / vorheriger Agent, der dich braucht",
194
+ "Nobody needs you right now": "Im Moment braucht dich niemand",
195
+ "stalled: a sub-agent has been silent for a long time": "hängt: ein Unteragent ist seit langem still",
196
+ "waiting on another agent": "wartet auf einen anderen Agenten",
197
+ "What the states mean": "Was die Zustände bedeuten",
198
+ "Find a session": "Sitzung suchen",
199
+ "Find a session…": "Sitzung suchen…",
200
+ "Go to a session": "Zu einer Sitzung springen",
201
+ "No highlight in this session": "Keine Höhepunkte in dieser Sitzung",
202
+ "Replay: next / previous highlight": "Wiedergabe: nächster / vorheriger Höhepunkt",
203
+ "Replay: previous / next event": "Wiedergabe: vorheriges / nächstes Ereignis",
204
+ "Resumed where you left off": "Dort fortgesetzt, wo du aufgehört hast",
205
+ "Chatter between agents": "Gespräche zwischen Agenten",
206
+ "Lively": "Lebhaft",
207
+ "Normal": "Normal",
208
+ "nothing to report": "nichts zu melden",
209
+ "Quiet": "Ruhig",
210
+ "Laptop in front of your own characters": "Laptop vor deinen eigenen Figuren"
190
211
  }
@@ -26,6 +26,7 @@
26
26
  "Call": "Call",
27
27
  "Cartoon voices (real voice)": "Cartoon voices (real voice)",
28
28
  "Characters": "Characters",
29
+ "Chatter between agents": "Chatter between agents",
29
30
  "Claude Code is not logged in: run “claude” once in a terminal to sign in.": "Claude Code is not logged in: run “claude” once in a terminal to sign in.",
30
31
  "Claude Code not found: install it (npm i -g @anthropic-ai/claude-code) or set its path in the “agentStandup.claudePath” setting.": "Claude Code not found: install it (npm i -g @anthropic-ai/claude-code) or set its path in the “agentStandup.claudePath” setting.",
31
32
  "Click to enable sound and follow your Claude Code session live.": "Click to enable sound and follow your Claude Code session live.",
@@ -40,6 +41,7 @@
40
41
  "Copy failed": "Copy failed",
41
42
  "custom character(s) loaded": "custom character(s) loaded",
42
43
  "Demo — a team fixes a slow login": "Demo — a team fixes a slow login",
44
+ "Display": "Display",
43
45
  "done": "done",
44
46
  "Done": "Done",
45
47
  "Done. ": "Done. ",
@@ -58,7 +60,10 @@
58
60
  "files changed": "files changed",
59
61
  "Files touched": "Files touched",
60
62
  "Final report": "Final report",
63
+ "Find a session": "Find a session",
64
+ "Find a session…": "Find a session…",
61
65
  "first error": "first error",
66
+ "Go to a session": "Go to a session",
62
67
  "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.",
63
68
  "Hide code & file names in videos": "Hide code & file names in videos",
64
69
  "Highlights": "Highlights",
@@ -70,6 +75,7 @@
70
75
  "Join the call": "Join the call",
71
76
  "Keyboard shortcuts": "Keyboard shortcuts",
72
77
  "Language": "Language",
78
+ "Laptop in front of your own characters": "Laptop in front of your own characters",
73
79
  "Lead": "Lead",
74
80
  "Lead context": "Lead context",
75
81
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth",
@@ -77,21 +83,28 @@
77
83
  "lines": "lines",
78
84
  "Listing ": "Listing ",
79
85
  "Little lines & animations": "Little lines & animations",
86
+ "Lively": "Lively",
80
87
  "Loading the model (downloaded on first use, about 305 MB)…": "Loading the model (downloaded on first use, about 305 MB)…",
81
88
  "longest wait ": "longest wait ",
82
89
  "Looking for a tool": "Looking for a tool",
83
90
  "looping?": "looping?",
91
+ "may be waiting for your approval": "may be waiting for your approval",
84
92
  "Music volume": "Music volume",
85
93
  "Mute / unmute": "Mute / unmute",
86
94
  "Natural voices": "Natural voices",
87
95
  "need you": "need you",
88
96
  "needs you?": "needs you?",
89
97
  "needs you? ": "needs you? ",
98
+ "Next / previous agent that needs you": "Next / previous agent that needs you",
90
99
  "No agents were harmed.": "No agents were harmed.",
91
100
  "No edits.": "No edits.",
92
101
  "No French voice installed (Windows: Settings › Time & language › Speech).": "No French voice installed (Windows: Settings › Time & language › Speech).",
102
+ "No highlight in this session": "No highlight in this session",
93
103
  "No recent session": "No recent session",
94
104
  "No system voice found: agents will mumble.": "No system voice found: agents will mumble.",
105
+ "Nobody needs you right now": "Nobody needs you right now",
106
+ "Normal": "Normal",
107
+ "nothing to report": "nothing to report",
95
108
  "Notify me when agents finish": "Notify me when agents finish",
96
109
  "npm not found: install Node.js to enable this option.": "npm not found: install Node.js to enable this option.",
97
110
  "Office ambience (keys, coffee…)": "Office ambience (keys, coffee…)",
@@ -105,6 +118,7 @@
105
118
  "PAUSED": "PAUSED",
106
119
  "Project to follow": "Project to follow",
107
120
  "Prompt from": "Prompt from",
121
+ "Quiet": "Quiet",
108
122
  "Radio unreachable: check your connection or pick another station.": "Radio unreachable: check your connection or pick another station.",
109
123
  "Reactions": "Reactions",
110
124
  "Reactions & jokes": "Reactions & jokes",
@@ -117,14 +131,18 @@
117
131
  "Reload": "Reload",
118
132
  "Remove finished agents (2 min)": "Remove finished agents (2 min)",
119
133
  "Repeated failures": "Repeated failures",
134
+ "Replay: next / previous highlight": "Replay: next / previous highlight",
135
+ "Replay: previous / next event": "Replay: previous / next event",
120
136
  "report": "report",
121
137
  "report to": "report to",
138
+ "Resumed where you left off": "Resumed where you left off",
122
139
  "Role": "Role",
123
140
  "running": "running",
124
141
  "Running ": "Running ",
125
142
  "running…": "running…",
126
143
  "Said": "Said",
127
144
  "Same call repeated": "Same call repeated",
145
+ "Search settings…": "Search settings…",
128
146
  "Search the transcript": "Search the transcript",
129
147
  "Search…": "Search…",
130
148
  "Searching “": "Searching “",
@@ -140,6 +158,7 @@
140
158
  "Speed": "Speed",
141
159
  "stalled": "stalled",
142
160
  "stalled · ": "stalled · ",
161
+ "stalled: a sub-agent has been silent for a long time": "stalled: a sub-agent has been silent for a long time",
143
162
  "standing by": "standing by",
144
163
  "Start Claude Code in this folder — the call will start by itself.": "Start Claude Code in this folder — the call will start by itself.",
145
164
  "sub-agents active": "sub-agents active",
@@ -173,8 +192,10 @@
173
192
  "WAITING": "WAITING",
174
193
  "Waiting for a session": "Waiting for a session",
175
194
  "waiting on ": "waiting on ",
195
+ "waiting on another agent": "waiting on another agent",
176
196
  "waiting on sub-agents": "waiting on sub-agents",
177
197
  "Web search": "Web search",
198
+ "What the states mean": "What the states mean",
178
199
  "working": "working",
179
200
  "Writing ": "Writing ",
180
201
  "You": "You",
@@ -186,5 +186,26 @@
186
186
  "Transcript": "Transcripción",
187
187
  "Tree view ↔ call": "Vista de árbol ↔ llamada",
188
188
  "Tree view: T to go back, ? for help": "Vista de árbol: T para volver, ? para la ayuda",
189
- "You can also switch between Call, Tree and Sessions at the top of the window.": "También puedes cambiar entre Llamada, Árbol y Sesiones en la parte superior de la ventana."
189
+ "You can also switch between Call, Tree and Sessions at the top of the window.": "También puedes cambiar entre Llamada, Árbol y Sesiones en la parte superior de la ventana.",
190
+ "Display": "Pantalla",
191
+ "Search settings…": "Buscar ajuste…",
192
+ "may be waiting for your approval": "puede estar esperando tu aprobación",
193
+ "Next / previous agent that needs you": "Siguiente / anterior agente que te necesita",
194
+ "Nobody needs you right now": "Nadie te necesita ahora mismo",
195
+ "stalled: a sub-agent has been silent for a long time": "bloqueado: un subagente lleva mucho tiempo en silencio",
196
+ "waiting on another agent": "esperando a otro agente",
197
+ "What the states mean": "Qué significan los estados",
198
+ "Find a session": "Buscar una sesión",
199
+ "Find a session…": "Buscar una sesión…",
200
+ "Go to a session": "Ir a una sesión",
201
+ "No highlight in this session": "No hay momentos clave en esta sesión",
202
+ "Replay: next / previous highlight": "Repetición: siguiente / anterior momento clave",
203
+ "Replay: previous / next event": "Repetición: evento anterior / siguiente",
204
+ "Resumed where you left off": "Reanudado donde lo dejaste",
205
+ "Chatter between agents": "Charla entre agentes",
206
+ "Lively": "Animado",
207
+ "Normal": "Normal",
208
+ "nothing to report": "nada que informar",
209
+ "Quiet": "Tranquilo",
210
+ "Laptop in front of your own characters": "Portátil delante de tus propios personajes"
190
211
  }
@@ -186,5 +186,26 @@
186
186
  "Transcript": "文字起こし",
187
187
  "Tree view ↔ call": "ツリー表示 ↔ 通話",
188
188
  "Tree view: T to go back, ? for help": "ツリー表示:Tで戻る、?でヘルプ",
189
- "You can also switch between Call, Tree and Sessions at the top of the window.": "ウィンドウ上部で「通話」「ツリー」「セッション」を切り替えることもできます。"
189
+ "You can also switch between Call, Tree and Sessions at the top of the window.": "ウィンドウ上部で「通話」「ツリー」「セッション」を切り替えることもできます。",
190
+ "Display": "表示",
191
+ "Search settings…": "設定を検索…",
192
+ "may be waiting for your approval": "あなたの承認を待っている可能性があります",
193
+ "Next / previous agent that needs you": "対応が必要な次 / 前のエージェント",
194
+ "Nobody needs you right now": "今はだれも対応を求めていません",
195
+ "stalled: a sub-agent has been silent for a long time": "停滞:サブエージェントが長時間沈黙しています",
196
+ "waiting on another agent": "別のエージェントを待っています",
197
+ "What the states mean": "状態の意味",
198
+ "Find a session": "セッションを検索",
199
+ "Find a session…": "セッションを検索…",
200
+ "Go to a session": "セッションへ移動",
201
+ "No highlight in this session": "このセッションにはハイライトがありません",
202
+ "Replay: next / previous highlight": "リプレイ:次 / 前のハイライト",
203
+ "Replay: previous / next event": "リプレイ:前 / 次のイベント",
204
+ "Resumed where you left off": "前回の続きから再開しました",
205
+ "Chatter between agents": "エージェント同士の会話",
206
+ "Lively": "にぎやか",
207
+ "Normal": "ふつう",
208
+ "nothing to report": "報告事項なし",
209
+ "Quiet": "静か",
210
+ "Laptop in front of your own characters": "自作キャラクターの前にノートパソコン"
190
211
  }
@@ -186,5 +186,26 @@
186
186
  "Transcript": "Transcrição",
187
187
  "Tree view ↔ call": "Visão em árvore ↔ chamada",
188
188
  "Tree view: T to go back, ? for help": "Visão em árvore: T para voltar, ? para ajuda",
189
- "You can also switch between Call, Tree and Sessions at the top of the window.": "Você também pode alternar entre Chamada, Árvore e Sessões no topo da janela."
189
+ "You can also switch between Call, Tree and Sessions at the top of the window.": "Você também pode alternar entre Chamada, Árvore e Sessões no topo da janela.",
190
+ "Display": "Exibição",
191
+ "Search settings…": "Buscar configuração…",
192
+ "may be waiting for your approval": "pode estar esperando sua aprovação",
193
+ "Next / previous agent that needs you": "Próximo / anterior agente que precisa de você",
194
+ "Nobody needs you right now": "Ninguém precisa de você agora",
195
+ "stalled: a sub-agent has been silent for a long time": "travado: um subagente está em silêncio há muito tempo",
196
+ "waiting on another agent": "esperando outro agente",
197
+ "What the states mean": "O que significam os estados",
198
+ "Find a session": "Buscar uma sessão",
199
+ "Find a session…": "Buscar uma sessão…",
200
+ "Go to a session": "Ir para uma sessão",
201
+ "No highlight in this session": "Nenhum destaque nesta sessão",
202
+ "Replay: next / previous highlight": "Replay: próximo / anterior destaque",
203
+ "Replay: previous / next event": "Replay: evento anterior / próximo",
204
+ "Resumed where you left off": "Retomado de onde você parou",
205
+ "Chatter between agents": "Conversa entre agentes",
206
+ "Lively": "Animado",
207
+ "Normal": "Normal",
208
+ "nothing to report": "nada a reportar",
209
+ "Quiet": "Quieto",
210
+ "Laptop in front of your own characters": "Notebook na frente dos seus personagens"
190
211
  }
@@ -186,5 +186,26 @@
186
186
  "Transcript": "文字记录",
187
187
  "Tree view ↔ call": "树状视图 ↔ 通话",
188
188
  "Tree view: T to go back, ? for help": "树状视图:按 T 返回,按 ? 查看帮助",
189
- "You can also switch between Call, Tree and Sessions at the top of the window.": "你也可以在窗口顶部切换通话、树状和会话视图。"
189
+ "You can also switch between Call, Tree and Sessions at the top of the window.": "你也可以在窗口顶部切换通话、树状和会话视图。",
190
+ "Display": "显示",
191
+ "Search settings…": "搜索设置…",
192
+ "may be waiting for your approval": "可能在等你批准",
193
+ "Next / previous agent that needs you": "下一个 / 上一个需要你的代理",
194
+ "Nobody needs you right now": "目前没有人需要你",
195
+ "stalled: a sub-agent has been silent for a long time": "卡住:某个子代理已长时间没有动静",
196
+ "waiting on another agent": "正在等待另一个代理",
197
+ "What the states mean": "各状态的含义",
198
+ "Find a session": "查找会话",
199
+ "Find a session…": "查找会话…",
200
+ "Go to a session": "跳转到会话",
201
+ "No highlight in this session": "此会话没有精彩片段",
202
+ "Replay: next / previous highlight": "回放:下一个 / 上一个精彩片段",
203
+ "Replay: previous / next event": "回放:上一个 / 下一个事件",
204
+ "Resumed where you left off": "已从上次停下的位置继续",
205
+ "Chatter between agents": "代理之间的交谈",
206
+ "Lively": "热闹",
207
+ "Normal": "正常",
208
+ "nothing to report": "没什么可报告的",
209
+ "Quiet": "安静",
210
+ "Laptop in front of your own characters": "在自定义角色前显示笔记本电脑"
190
211
  }
@@ -186,5 +186,26 @@
186
186
  "Transcript": "逐字稿",
187
187
  "Tree view ↔ call": "樹狀檢視 ↔ 通話",
188
188
  "Tree view: T to go back, ? for help": "樹狀檢視:按 T 返回,按 ? 查看說明",
189
- "You can also switch between Call, Tree and Sessions at the top of the window.": "你也可以在視窗上方切換通話、樹狀和工作階段檢視。"
189
+ "You can also switch between Call, Tree and Sessions at the top of the window.": "你也可以在視窗上方切換通話、樹狀和工作階段檢視。",
190
+ "Display": "顯示",
191
+ "Search settings…": "搜尋設定…",
192
+ "may be waiting for your approval": "可能在等你核准",
193
+ "Next / previous agent that needs you": "下一個 / 上一個需要你的代理",
194
+ "Nobody needs you right now": "目前沒有人需要你",
195
+ "stalled: a sub-agent has been silent for a long time": "卡住:某個子代理已長時間沒有動靜",
196
+ "waiting on another agent": "正在等待另一個代理",
197
+ "What the states mean": "各狀態的意義",
198
+ "Find a session": "尋找工作階段",
199
+ "Find a session…": "尋找工作階段…",
200
+ "Go to a session": "跳到工作階段",
201
+ "No highlight in this session": "此工作階段沒有重點時刻",
202
+ "Replay: next / previous highlight": "重播:下一個 / 上一個重點時刻",
203
+ "Replay: previous / next event": "重播:上一個 / 下一個事件",
204
+ "Resumed where you left off": "已從上次停下的位置繼續",
205
+ "Chatter between agents": "代理之間的交談",
206
+ "Lively": "熱鬧",
207
+ "Normal": "正常",
208
+ "nothing to report": "沒什麼好回報的",
209
+ "Quiet": "安靜",
210
+ "Laptop in front of your own characters": "在自訂角色前顯示筆記型電腦"
190
211
  }
package/server.js CHANGED
@@ -522,11 +522,12 @@ function start(port = PORT) {
522
522
  const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
523
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.
524
524
  const sfs = req.headers['sec-fetch-site'];
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'); }
525
+ if (sfs && sfs !== 'same-origin' && sfs !== 'none' && (req.method !== 'GET' || ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/voices', '/api/kokoro', '/api/kokoro/install', '/api/media'].includes(url.pathname))) { res.writeHead(403); return res.end('forbidden'); }
526
526
  const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
527
- let body = '', over = false; req.setTimeout(10000, () => req.destroy());
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(); } });
529
- req.on('end', () => { if (!over) cb(body); });
527
+ const chunks = []; let size = 0, over = false; req.setTimeout(10000, () => req.destroy());
528
+ if (+req.headers['content-length'] > 262144) { res.writeHead(413); res.end('too large'); return req.destroy(); }
529
+ req.on('data', (d) => { if (over) return; size += d.length; if (size > 262144) { over = true; res.writeHead(413); res.end('too large'); req.destroy(); } else chunks.push(d); }); // bytes, not characters
530
+ req.on('end', () => { if (!over) cb(Buffer.concat(chunks).toString('utf8')); });
530
531
  };
531
532
  const textList = (x) => (Array.isArray(x) ? x : []).slice(0, 200).map((t) => String(t).slice(0, 2000));
532
533
  try {