@double_geste/agent-standup 1.28.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,6 @@ Install once for status bars (faster than npx): `npm i -g @double_geste/agent-st
13
13
  - **tmux**: `set -g status-right '#(agent-standup status --here)'`
14
14
  - **Anything else**: `agent-standup status --json`
15
15
 
16
- Full documentation, the VS Code extension, the desktop app and the demo: https://atoll92.github.io/agent-standup/ · https://github.com/Atoll92/agent-standup.git
16
+ Full documentation, the VS Code extension, the desktop app and the demo: https://agent-standup.com · https://github.com/Atoll92/agent-standup.git
17
17
 
18
18
  Unofficial: not affiliated with Anthropic or OpenAI.
package/kokoro.js CHANGED
@@ -7,8 +7,8 @@ const DIR = process.env.AGENT_STANDUP_KOKORO_DIR || (WIN ? path.join(process.env
7
7
  const PKG = 'kokoro-js@1.2.1', DTYPE = 'q4', SIZE_MB = 730, MAX_RESTARTS = 3;
8
8
  const WORKER = process.env.AGENT_STANDUP_KOKORO_WORKER || path.join(__dirname, 'kokoro-worker.js'), LOG = path.join(DIR, 'install.log');
9
9
 
10
- // In the order agents get them (the lead takes the first), so successive agents sound distinct. French has a single voice in Kokoro.
11
- const VOICES = [['af_heart', 'Heart', 'en_US'], ['am_fenrir', 'Fenrir', 'en_US'], ['bf_emma', 'Emma', 'en_GB'], ['am_michael', 'Michael', 'en_US'], ['af_bella', 'Bella', 'en_US'], ['bm_george', 'George', 'en_GB'], ['af_sarah', 'Sarah', 'en_US'], ['am_puck', 'Puck', 'en_US'], ['ff_siwis', 'Siwis', 'fr_FR']].map(([name, label, locale]) => ({ name, label, locale }));
10
+ // In the order agents get them (the lead takes the first), so successive agents sound distinct. English only: kokoro-js has no French voice (it refuses ff_siwis), so French lines are read by the system voices.
11
+ const VOICES = [['af_heart', 'Heart', 'en_US'], ['am_fenrir', 'Fenrir', 'en_US'], ['bf_emma', 'Emma', 'en_GB'], ['am_michael', 'Michael', 'en_US'], ['af_bella', 'Bella', 'en_US'], ['bm_george', 'George', 'en_GB'], ['af_sarah', 'Sarah', 'en_US'], ['am_puck', 'Puck', 'en_US']].map(([name, label, locale]) => ({ name, label, locale }));
12
12
  const voices = () => VOICES, hasVoice = (n) => VOICES.some((v) => v.name === n);
13
13
  const speedFor = (wpm) => Math.max(0.5, Math.min(2, Math.round((wpm / 178) * 20) / 20)); // 178 words a minute is about Kokoro's natural pace
14
14
  const exists = (f) => { try { fs.statSync(f); return true; } catch { return false; } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "Your Claude Code and Codex 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"
@@ -27,7 +27,7 @@
27
27
  "type": "git",
28
28
  "url": "git+https://github.com/Atoll92/agent-standup.git"
29
29
  },
30
- "homepage": "https://atoll92.github.io/agent-standup/",
30
+ "homepage": "https://agent-standup.com",
31
31
  "bugs": {
32
32
  "url": "https://github.com/Atoll92/agent-standup/issues"
33
33
  },
package/public/index.html CHANGED
@@ -18,7 +18,7 @@
18
18
  /* ---------- header ---------- */
19
19
  header { height:48px; flex:none; display:flex; align-items:center; gap:10px; padding:0 12px; border-bottom:1px solid var(--line); background:var(--bg) }
20
20
  .brand { display:flex; align-items:center; gap:9px; min-width:0; overflow:hidden } .brand .logo { flex:none } .brand > div { min-width:0 } .brand b { overflow:hidden; text-overflow:ellipsis }
21
- header > * { flex-shrink:0 } header .brand { flex-shrink:100 } header #board { flex-shrink:50 } .tok, .ctxp { flex-shrink:2; min-width:0; overflow:hidden; text-overflow:ellipsis } #pill { flex-shrink:.05; min-width:0; overflow:hidden; text-overflow:ellipsis } /* in a narrow window the buttons (the gear above all) stay whole: the name gives way first, then the counters */
21
+ header > * { flex-shrink:0 } header .brand { flex-shrink:100; min-width:26px } header #board { flex-shrink:.01 } .tok, .ctxp { flex-shrink:2; min-width:0; overflow:hidden; text-overflow:ellipsis } #pill { flex-shrink:.05; min-width:min-content; overflow:hidden; text-overflow:ellipsis } /* in a narrow window the buttons (the gear above all) stay whole: the name gives way first (down to the logo), then the counters; the chips only when nothing else can (.01, not 0: the header must never overflow) */
22
22
  @media (max-width:720px) { .ctxp { display:none !important } } @media (max-width:620px) { .tok { display:none !important } } @media (max-width:560px) { #bCoffee { display:none !important } } @media (max-width:480px) { header { gap:6px; padding-left:8px; padding-right:8px } } /* narrow: the coffee link stays in ⚙ › Tools */
23
23
  .logo { width:26px; height:26px; display:block; flex:none }
24
24
  .brand b { font:700 14px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif; white-space:nowrap }
@@ -153,7 +153,7 @@
153
153
  /* the header's status chips are styled by id: "board" is also the class the Sessions view puts on <body>, and a rule on .board used to hide the whole page in a narrow window */
154
154
  #board { display:flex; gap:6px; font-size:11.5px; flex-wrap:nowrap; min-width:0; overflow:hidden } #board em { font-style:normal } #board span { flex:none; padding:2px 8px; border-radius:99px; background:var(--panel2); border:1px solid var(--line); white-space:nowrap }
155
155
  #board .working { color:var(--gold) } #board .waiting { color:#8fd3ff } #board .blocked { color:#fff; background:#c0392b; border-color:#c0392b } #board .stalled { color:#1b1d27; background:#ffb347; border-color:#ffb347 } #board .done { color:var(--ok) }
156
- @media (max-width:1180px) { #board em { display:none } .meet { display:none } }
156
+ @media (max-width:1180px) { #board em { display:none } .meet { display:none } } #board.tight em { display:none } /* tight: set by fitBoard when the chips do not all fit with their words */
157
157
  @media (max-width:640px) { #board { display:none } }
158
158
  /* what is said sits above everything else on the tile (badge, prop, ticker) and is never cut: a line longer than its tile grows over the tiles below */
159
159
  .bubble { display:none; position:absolute; z-index:6; top:6px; left:6px; width:max-content; max-width:calc(100% - 12px); background:#fff; color:#1b1d27; border-radius:11px; padding:6px 10px; font-size:12.5px; line-height:1.35; box-shadow:0 4px 14px rgba(0,0,0,.35); overflow-wrap:anywhere }
@@ -290,13 +290,14 @@
290
290
  <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>
291
291
  <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>
292
292
  <h4 data-fr="Source" data-en="Source">Source</h4>
293
- <div class="box"><div class="seg" style="width:100%" id="modeSeg"><button data-m="live" style="flex:1">Live</button><button data-m="replay" style="flex:1">Replay</button><button data-m="demo" style="flex:1">Demo</button></div>
293
+ <div class="box"><div class="seg" style="width:100%" id="modeSeg"><button data-m="live" style="flex:1" data-fr="Live" data-en="Live">Live</button><button data-m="replay" style="flex:1" data-fr="Replay" data-en="Replay">Replay</button><button data-m="demo" style="flex:1" data-fr="Démo" data-en="Demo">Demo</button></div>
294
294
  <select id="liveProj" style="display:none;margin-top:6px" title="Project to follow"></select>
295
295
  <select id="sessions" style="display:none"></select></div>
296
296
  <h4 data-fr="Personnages" data-en="Characters">Personnages</h4>
297
297
  <div class="box"><select id="charPack" aria-label="Characters"><option value="none" data-fr="Personnages dessinés" data-en="Drawn characters">Personnages dessinés</option><option value="folder" data-fr="Mon dossier" data-en="My folder">Mon dossier</option><option value="cats" data-fr="Pack de chats" data-en="Cat pack">Pack de chats</option></select></div>
298
298
  <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>
299
299
  <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>
300
+ <button class="mi" data-t="ownNames"><span data-fr="Garder les noms de mes agents" data-en="Keep my agents’ own names">Garder les noms de mes agents</span><i class="sw"></i></button>
300
301
  <h4 data-fr="Outils" data-en="Tools">Outils</h4>
301
302
  <button class="mi" id="mCoffee" style="display:none"><span id="mCoffeeT"></span></button>
302
303
  <button class="mi" id="mKeys"><span data-fr="⌨ Raccourcis clavier (?)" data-en="⌨ Keyboard shortcuts (?)">⌨ Raccourcis clavier (?)</span></button>
@@ -344,7 +345,7 @@ const CID = Math.random().toString(36).slice(2, 12); // identifies this window
344
345
  const Q = new URLSearchParams(location.search);
345
346
  const EMBED = Q.get('embed') === '1' || window.parent !== window;
346
347
 
347
- let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: false, office: false, officeVol: 0.8, vstyle: 'cartoon', notify: false, epi: false, vanish: true, music: false, station: 'fip', musicVol: 0.35, view: 'call', hideCode: false };
348
+ let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: false, office: false, officeVol: 0.8, vstyle: 'cartoon', notify: false, epi: false, vanish: true, music: false, station: 'fip', musicVol: 0.35, view: 'call', hideCode: false, ownNames: true };
348
349
  { let loc = {}; try { loc = JSON.parse(localStorage.getItem('cc.prefs') || '{}') || {}; } catch {} // the settings file (shared by every window) and this origin's copy: the more recent one wins
349
350
  const sv = window.SAVED_PREFS && typeof window.SAVED_PREFS === 'object' ? window.SAVED_PREFS : {}; Object.assign(prefs, (sv._t || 0) > (loc._t || 0) ? sv : loc); }
350
351
  if (prefs.vstyle === 'babble') prefs.vstyle = 'animal', prefs.vstyleChosen = true;
@@ -411,11 +412,16 @@ function renderCharInfo() {
411
412
  $('#charOpen').style.display = EMBED ? '' : 'none';
412
413
  }
413
414
  function persona(a) { const p = persona0(a); p.custom = customFor(a.id); return p; }
415
+ const subName = (a, h) => (a.own && prefs.ownNames ? (nameBook[a.id] = a.own) : a.given || uniqueName(a.id, h)); // own: the name the person gave this agent (see ownName in server.js); otherwise a first name from the list. a.own is tested first: the landing page reuses this section and has no settings
416
+ function renameAll() { // ⚙ → own names on or off: every sub-agent is named again, in the order it arrived
417
+ for (const k in nameBook) delete nameBook[k]; for (const p of Object.values(S.agents)) if (p.id !== 'main' && p.id !== 'user') p.name = subName(p, hash(p.id));
418
+ buildStage(); paintStatus(); renderLog(true); syncUI();
419
+ }
414
420
  function persona0(a) {
415
- const h = hash(a.id), base = { ...a };
421
+ const h = hash(a.id), base = { ...a, given: 'given' in a ? a.given : a.name };
416
422
  if (a.id === 'main') return { ...base, name: a.name || 'Claude', role: L('Chef', 'Lead'), shirt: '#e8845f', shape: 0, acc: 1, eye: 0, glasses: false, scene: 6, prop: 1, acc2: '#f5c04a', lead: true, vi: 0, gid: 'main' };
417
423
  if (a.id === 'user') return { ...base, name: L('Vous', 'You'), role: L('Humain', 'Human'), shirt: '#8a94c9', shape: 1, acc: 2, eye: 1, glasses: false, scene: 7, prop: 1, acc2: '#ff7a6b', vi: 3, gid: 'user' };
418
- return { ...base, name: a.name || uniqueName(a.id, h), roleEn: a.roleEn || a.role, role: (FR() && a.roleFr) || a.roleEn || a.role, shirt: PAL[(h >>> 6) % 8], shape: (h >>> 3) % 5, acc: (h >>> 9) % 6, eye: (h >>> 12) % 3, glasses: ((h >>> 15) % 4) === 0,
424
+ return { ...base, name: subName(base, h), roleEn: a.roleEn || a.role, role: (FR() && a.roleFr) || a.roleEn || a.role, shirt: PAL[(h >>> 6) % 8], shape: (h >>> 3) % 5, acc: (h >>> 9) % 6, eye: (h >>> 12) % 3, glasses: ((h >>> 15) % 4) === 0,
419
425
  scene: (h >>> 18) % 6, prop: (h >>> 21) % 3, acc2: PAL[(h >>> 24) % 8], vi: h >>> 4, gid: h.toString(36) };
420
426
  }
421
427
 
@@ -756,33 +762,39 @@ const synth = window.speechSynthesis;
756
762
  const estMs = (text) => Math.min(9000, 900 + text.length * 42) / spd();
757
763
 
758
764
  const NOVELTY = /grandma|grandpa|rocko|bad news|bahh|bells|boing|bubbles|cellos|good news|jester|organ|superstar|trinoids|whisper|wobble|zarvox|albert|fred|junior|kathy|ralph|hysterical|deranged|pipe/i;
759
- const GOOD = /premium|enhanced|natural|siri|neural|améli|amelie|thomas|jacques|audrey|marie|eddy|flo\b|reed|sandy|shelley|samantha|daniel|karen|moira|serena|oliver|google|hortense|julie|paul|denise|henri|eloise|zira|david|hazel|mark|aria|guy/i;
760
- const voiceScore = (n) => (/premium|enhanced|natural|neural|siri/i.test(n) ? 100 : 0) + (GOOD.test(n) ? 30 : 0) - (NOVELTY.test(n) ? 200 : 0);
761
- function rankVoices(list) { // list of names → best distinct voices first
762
- const seen = new Set(), out = [];
763
- [...list].sort((a, b) => voiceScore(b) - voiceScore(a)).forEach((n) => { const base = n.replace(/\s*\(.*$/, ''); if (voiceScore(n) > -100 && !seen.has(base)) { seen.add(base); out.push(n); } });
765
+ const ROBOT = /^(eddy|flo|reed|sandy|shelley)\b/i; /* Apple's synthetic voices: understandable, and plainly a machine. Kept for when nothing else is installed */
766
+ const GOOD = /premium|enhanced|natural|siri|neural|améli|amelie|thomas|jacques|audrey|aurélie|aurelie|marie|samantha|daniel|karen|moira|serena|oliver|google|hortense|julie|paul|denise|henri|eloise|zira|david|hazel|mark|aria|guy/i;
767
+ const voiceScore = (n) => (/premium|enhanced|natural|neural|siri/i.test(n) ? 100 : 0) + (GOOD.test(n) ? 30 : 0) - (ROBOT.test(n) ? 50 : 0) - (NOVELTY.test(n) ? 200 : 0);
768
+ const frHome = () => [...(navigator.languages || []), navigator.language || ''].map((l) => String(l).replace('-', '_')).find((l) => /^fr_[A-Z]{2}$/.test(l)) || 'fr_FR'; /* French from France unless the computer itself is set to another French */
769
+ function rankVoices(list, lang) { // [{ name, locale }] → names, best distinct voices first. The synthetic ones only when fewer than two others exist
770
+ const home = lang === 'fr' ? frHome() : '', sc = (v) => voiceScore(v.name) + (home && v.locale === home ? 10 : 0), seen = new Set(), out = [];
771
+ [...list].sort((a, b) => sc(b) - sc(a)).forEach((v) => { const base = v.name.replace(/\s*\(.*$/, ''), x = sc(v); if (x > -100 && (x >= 0 || out.length < 2) && !seen.has(base)) { seen.add(base); out.push(v.name); } });
764
772
  return out.slice(0, 5);
765
773
  }
766
774
  const TTS = {
767
- server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, cache: new Map(), made: new Map(), q: [], busy: false, now: 0,
775
+ server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, sys: { fr: [], en: [] }, kv: new Set(), cache: new Map(), made: new Map(), q: [], busy: false, now: 0,
768
776
  async init() {
769
- this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.cache.clear(); this.made.clear(); this.q = [];
777
+ this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.sys = { fr: [], en: [] }; this.kv = new Set(); this.cache.clear(); this.made.clear(); this.q = [];
770
778
  try {
771
- if (prefs.vstyle === 'kokoro') { // natural voices installed on demand; until they are, the system voices stay in use
772
- const k = await (await fetch('/api/voices?engine=kokoro')).json();
773
- if (k.available && k.ready) { this.server = true; this.engine = 'kokoro'; this.platform = k.platform || ''; for (const l of ['fr', 'en']) this.pool[l] = k.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name); return; }
779
+ let k = null; if (prefs.vstyle === 'kokoro') { try { k = await (await fetch('/api/voices?engine=kokoro')).json(); } catch {} if (!k || !k.available || !k.ready) k = null; } // natural voices installed on demand; until they are, the system voices stay in use
780
+ let d = { available: false, voices: [] }; try { d = await (await fetch('/api/voices')).json(); } catch {}
781
+ this.platform = d.platform || (k && k.platform) || ''; const sys = {}; for (const l of ['fr', 'en']) sys[l] = d.available ? rankVoices(d.voices.filter((v) => v.locale.startsWith(l + '_')), l) : [];
782
+ if (k) { // Kokoro speaks the languages it has voices for (English); the others are read by the system voices, each character keeping one of its own
783
+ this.server = true; this.engine = 'kokoro'; this.sys = sys;
784
+ for (const l of ['fr', 'en']) { const kv = k.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name); kv.forEach((v) => this.kv.add(v)); this.pool[l] = kv.length ? kv : sys[l]; }
785
+ return;
774
786
  }
775
- const d = await (await fetch('/api/voices')).json(); this.platform = d.platform || ''; if (!d.available) return; this.server = true;
776
- for (const l of ['fr', 'en']) this.pool[l] = rankVoices(d.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name)); } catch {}
787
+ if (!d.available) return; this.server = true; this.pool = sys; } catch {}
777
788
  },
778
789
  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; },
779
790
  rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 * lagFactor() * (S.boost > 1 ? 1.25 : 1) : +$('#speed').value))) / 10) * 10; },
780
791
  rateAt(k, boost) { const i = S.idx, b = S.boost; S.idx = k; S.boost = boost; const r = this.rate(); S.idx = i; S.boost = b; return r; }, // the rate rate() will give once playback is at event k
781
792
  buf(text, voice, rate, ahead) { // ahead: made in advance (see warmTTS); otherwise the line is wanted now
782
- const vt = this.engine + '|' + voice + '|' + text, K = (r) => this.engine + '|' + voice + '|' + r + '|' + text, key = K(rate), got = this.made.get(vt) || []; // got: the rates at which this text is ready in this voice
793
+ const eng = this.kv.has(voice) ? 'kokoro' : 'system', fb = eng === 'kokoro' && this.sys.en.length ? this.sys.en[Math.max(0, this.pool.en.indexOf(voice)) % this.sys.en.length] : ''; /* fb: the system voice that reads the line if Kokoro cannot */
794
+ const vt = eng + '|' + voice + '|' + text, K = (r) => eng + '|' + voice + '|' + r + '|' + text, key = K(rate), got = this.made.get(vt) || []; // got: the rates at which this text is ready in this voice
783
795
  if (!ahead && S.live && !got.includes(rate)) { const r = got.find((x) => Math.abs(x - rate) <= rate * 0.2 && this.cache.has(K(x))); if (r) return this.cache.get(K(r)); } // made ahead at the rate of a moment ago (the call fell behind or caught up since): played as it is, a second of silence is worse than a slightly different pace
784
796
  if (!this.cache.has(key)) {
785
- this.cache.set(key, fetch(`/api/tts?engine=${this.engine}&voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
797
+ this.cache.set(key, fetch(`/api/tts?engine=${eng}&voice=${encodeURIComponent(voice)}&rate=${rate}${fb ? '&fb=' + encodeURIComponent(fb) : ''}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
786
798
  if (this.cache.size > 60) this.cache.delete(this.cache.keys().next().value);
787
799
  this.cache.get(key).then(() => { this.made.set(vt, [...(this.made.get(vt) || []), rate]); if (this.made.size > 120) this.made.delete(this.made.keys().next().value); }, () => this.cache.delete(key));
788
800
  if (!ahead) { this.now++; const end = () => { this.now--; this.pump(); }; this.cache.get(key).then(end, end); } // a line wanted now never waits behind the ones made ahead: they stop being started until it is ready
@@ -902,7 +914,7 @@ async function speakServer(text, p, gain, token, fr = FR()) {
902
914
  function speakBrowser(text, p, token, fr = FR()) { // fallback where no server voice exists (Windows/Linux)
903
915
  return new Promise((resolve) => {
904
916
  const vs = synth.getVoices().filter((v) => v.lang.toLowerCase().startsWith(fr ? 'fr' : 'en')); if (!vs.length) return resolve(false);
905
- const ranked = rankVoices(vs.map((v) => v.name)), voice = vs.find((v) => v.name === (p.id === 'main' ? ranked[0] : ranked[(p.vi >>> 2) % ranked.length])) || vs[0];
917
+ const ranked = rankVoices(vs.map((v) => ({ name: v.name, locale: v.lang.replace('-', '_') })), fr ? 'fr' : 'en'), voice = vs.find((v) => v.name === (p.id === 'main' ? ranked[0] : ranked[(p.vi >>> 2) % ranked.length])) || vs[0];
906
918
  const parts = speakable(text).match(/[^.!?;:]+[.!?;:]*/g) || [text]; synth.cancel(); let i = 0, done = false, started = false, t0 = 0, said = 0; const all = parts.reduce((n, x) => n + x.length, 0);
907
919
  const fin = (ok) => { if (!done) { done = true; resolve(ok); } };
908
920
  setTimeout(() => { if (!started && !done) { synth.cancel(); fin(false); } }, 2500); // engine never started (e.g. inside a webview)
@@ -1032,7 +1044,7 @@ function paintStatus() {
1032
1044
  const prev = $('.row.now'); if (prev) prev.classList.remove('now');
1033
1045
  const now = $(`.row[data-i="${S.idx}"]`); if (now) { now.classList.add('now'); if (document.body.classList.contains('log')) now.scrollIntoView({ block: 'nearest' }); }
1034
1046
  later('detail', renderDetail); later('tree', renderTree); later('meet', renderMeet, 400); drawGantt();
1035
- $('#seek').value = S.idx; $('#pos').textContent = `${Math.min(S.idx + 1, S.events.length)} / ${S.events.length}`;
1047
+ const sk = $('#seek'); sk.max = Math.max(0, S.events.length - 1); sk.value = S.idx; $('#pos').textContent = `${Math.min(S.idx + 1, S.events.length)} / ${S.events.length}`;
1036
1048
  }
1037
1049
  const applyStatus = (ev) => {
1038
1050
  if (ev.kind === 'order') { (S.waitOn[ev.agent] = S.waitOn[ev.agent] || new Set()).add(ev.to); S.orderTs[ev.to] = ev.ts || 0; S.status[ev.to] = 'working'; delete S.doneAt[ev.to]; S.gone.delete(ev.to); S.leaving.delete(ev.to); if (!ev.bg && ev.toolId) S.run[ev.agent] = { id: ev.toolId, ts: ev.ts, start: performance.now(), name: 'Agent' }; const t = $('#t-' + ev.to); if (t) t.classList.remove('vanishing'); }
@@ -1141,8 +1153,14 @@ function paintStates() {
1141
1153
  paintPill();
1142
1154
  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')]]
1143
1155
  .filter(([k]) => n[k] && !S.pillSess).map(([k, i, l]) => `<span class="${k}" title="${n[k]} ${l}">${i} ${n[k]}<em> ${l}</em></span>`).join('');
1144
- if (b.innerHTML !== parts) b.innerHTML = parts; S.nBlocked = n.blocked + n.stalled; S.attn = attn; paintRoster(ros); return n;
1156
+ if (b.innerHTML !== parts) b.innerHTML = parts; fitBoard(); S.nBlocked = n.blocked + n.stalled; S.attn = attn; paintRoster(ros); return n;
1157
+ }
1158
+ function fitBoard(force) { // the chips lose their words when they do not all fit (a long language, a narrower window): a chip is never cut mid-word. Tried again with the words only when the window or the texts around have changed
1159
+ const b = $('#board'), key = innerWidth + '|' + b.textContent.length + '|' + $('#pill').textContent.length + '|' + $('#subtitle').textContent.length;
1160
+ if (b.classList.contains('tight')) { if (!force && key === fitBoard.key) return; b.classList.remove('tight'); }
1161
+ fitBoard.key = key; if (b.scrollWidth > b.clientWidth + 1) b.classList.add('tight');
1145
1162
  }
1163
+ addEventListener('resize', () => fitBoard(true)); new ResizeObserver(() => requestAnimationFrame(() => fitBoard(true))).observe($('#board')); /* the room the chips have also changes when the texts beside them do (a title, a longer status) */
1146
1164
  const RORDER = { blocked: 0, stalled: 1, working: 2, waiting: 3, idle: 4, done: 5 };
1147
1165
  function paintRoster(ros) { // a fixed list beside the call: who is doing what, urgent first, readable even with dozens of agents
1148
1166
  const box = $('#roster'); if (!box || !document.body.classList.contains('rost')) return;
@@ -2189,7 +2207,7 @@ function menuInit() {
2189
2207
  $('#menuNone').style.display = q && !secs.some((sc) => sc.items.some((e) => !e.classList.contains('mhide'))) ? '' : 'none';
2190
2208
  };
2191
2209
  }
2192
- $('#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(); }
2210
+ $('#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 === 'ownNames') renameAll(); 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(); }
2193
2211
  if (b.dataset.t === 'music') { $('#musicBox').style.display = prefs.music ? '' : 'none'; if (prefs.music) { AUD.init(); AUD.apply(); } } };
2194
2212
  $('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
2195
2213
  $('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
@@ -2205,8 +2223,8 @@ async function kokoroUI() {
2205
2223
  b.disabled = st.installing || !st.npm; b.textContent = st.installing ? L('Installation…', 'Installing…') : L(`Installer (${st.sizeMb} Mo)`, `Install (${st.sizeMb} MB)`);
2206
2224
  h.textContent = st.installing ? L('Téléchargement en cours, vous pouvez continuer : les voix du système servent en attendant.', 'Downloading, you can carry on: system voices are used meanwhile.')
2207
2225
  : st.error ? st.error : !st.npm && !st.installed ? L('npm est introuvable : installez Node.js pour activer cette option.', 'npm not found: install Node.js to enable this option.')
2208
- : st.installed ? (st.gaveUp ? L('Le moteur s’est arrêté : les voix du système servent à la place.', 'The engine stopped: system voices are used instead.') : st.ready ? L('Voix prêtes.', 'Voices ready.') : L('Chargement du modèle (téléchargé au premier usage, environ 305 Mo)…', 'Loading the model (downloaded on first use, about 305 MB)…'))
2209
- : L('Voix locales de bonne qualité, en anglais surtout (une seule voix française). Gratuit, rien n’est envoyé nulle part.', 'Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.');
2226
+ : st.installed ? (st.gaveUp ? L('Le moteur s’est arrêté : les voix du système servent à la place.', 'The engine stopped: system voices are used instead.') : st.ready ? L('Voix prêtes.', 'Voices ready.') + ' ' + L('Le français est lu par les voix de votre système : en installer une « améliorée » ou « premium » change tout.', 'French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.') : L('Chargement du modèle (téléchargé au premier usage, environ 305 Mo)…', 'Loading the model (downloaded on first use, about 305 MB)…'))
2227
+ : L('Voix locales de bonne qualité, en anglais (le français passe par les voix de votre système). Gratuit, rien n’est envoyé nulle part.', 'Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.');
2210
2228
  if ((st.installing || (st.installed && !st.ready && !st.gaveUp)) && !kokoroTimer) kokoroTimer = setInterval(async () => { await kokoroUI(); }, 2500);
2211
2229
  if (!st.installing && (st.ready || st.gaveUp || !st.installed) && kokoroTimer) { clearInterval(kokoroTimer); kokoroTimer = null; if (st.ready && TTS.engine !== 'kokoro') TTS.init(); }
2212
2230
  }
@@ -2255,7 +2273,7 @@ function lastWord() { // the last sentence the lead said: a closing question (
2255
2273
  function paintPill() {
2256
2274
  const pill = $('#pill'), now = Date.now(); let cls = '', txt = '', to = '', tip = '', sess = false;
2257
2275
  if (S.offline) { cls = 'off'; txt = '⚠ ' + L('HORS LIGNE', 'OFFLINE'); }
2258
- else if (!S.live) txt = 'REPLAY';
2276
+ else if (!S.live) txt = L('REPLAY', 'REPLAY');
2259
2277
  else if (!S.cur) txt = L('En attente d’une session', 'Waiting for a session');
2260
2278
  else if (S.locked) txt = L('PRÊT', 'READY');
2261
2279
  else if (!S.playing) txt = L('PAUSE', 'PAUSED');
@@ -42,6 +42,7 @@
42
42
  "Copy as Markdown": "Kopiér som Markdown",
43
43
  "Copy failed": "Kopiering mislykkedes",
44
44
  "custom character(s) loaded": "egne figurer indlæst",
45
+ "Demo": "Demo",
45
46
  "Demo — a team fixes a slow login": "Demo — et team retter et langsomt login",
46
47
  "done": "færdig",
47
48
  "Done": "Færdig",
@@ -62,7 +63,8 @@
62
63
  "Files touched": "Berørte filer",
63
64
  "Final report": "Slutrapport",
64
65
  "first error": "første fejl",
65
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Lokale stemmer af god kvalitet, mest engelske (kun én fransk stemme). Gratis, intet forlader din computer.",
66
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "Fransk læses af dine systemstemmer: at installere en “forbedret” eller “premium” stemme gør en stor forskel.",
67
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "Lokale stemmer af god kvalitet, på engelsk (fransk bruger dine systemstemmer). Gratis, intet forlader din computer.",
66
68
  "Hide code & file names in videos": "Skjul kode og filnavne i videoer",
67
69
  "Highlights": "Højdepunkter",
68
70
  "Human": "Menneske",
@@ -71,6 +73,7 @@
71
73
  "Installing…": "Installerer…",
72
74
  "is sharing their screen": "deler sin skærm",
73
75
  "Join the call": "Deltag i opkaldet",
76
+ "Keep my agents’ own names": "Behold mine agenters egne navne",
74
77
  "Keyboard shortcuts": "Tastaturgenveje",
75
78
  "Language": "Sprog",
76
79
  "Lead": "Leder",
@@ -80,6 +83,7 @@
80
83
  "lines": "linjer",
81
84
  "Listing ": "Viser ",
82
85
  "Little lines & animations": "Små replikker og animationer",
86
+ "Live": "Live",
83
87
  "Loading the model (downloaded on first use, about 305 MB)…": "Indlæser modellen (hentes ved første brug, ca. 305 MB)…",
84
88
  "longest wait ": "længste ventetid ",
85
89
  "Looking for a tool": "Leder efter et værktøj",
@@ -121,6 +125,8 @@
121
125
  "Reload": "Genindlæs",
122
126
  "Remove finished agents (2 min)": "Fjern færdige agenter (2 min)",
123
127
  "Repeated failures": "Gentagne fejl",
128
+ "Replay": "Afspilning",
129
+ "REPLAY": "AFSPILNING",
124
130
  "report": "rapport",
125
131
  "report to": "rapport til",
126
132
  "Role": "Rolle",
@@ -58,6 +58,7 @@
58
58
  "Files touched": "Bearbeitete Dateien",
59
59
  "Final report": "Abschlussbericht",
60
60
  "first error": "erster Fehler",
61
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "Französisch wird von deinen Systemstimmen gelesen: Eine „erweiterte“ oder „Premium“-Stimme zu installieren macht viel aus.",
61
62
  "Hide code & file names in videos": "Code & Dateinamen in Videos ausblenden",
62
63
  "Highlights": "Highlights",
63
64
  "Human": "Mensch",
@@ -65,6 +66,7 @@
65
66
  "input": "Eingabe",
66
67
  "is sharing their screen": "teilt den Bildschirm",
67
68
  "Join the call": "Dem Videoanruf beitreten",
69
+ "Keep my agents’ own names": "Eigene Namen meiner Agenten behalten",
68
70
  "Language": "Sprache",
69
71
  "Lead": "Hauptagent",
70
72
  "Lead context": "Kontext Hauptagent",
@@ -72,6 +74,7 @@
72
74
  "lines": "Zeilen",
73
75
  "Listing ": "Liste auf ",
74
76
  "Little lines & animations": "Kleine Sprüche & Animationen",
77
+ "Live": "Live",
75
78
  "longest wait ": "längstes Warten ",
76
79
  "Looking for a tool": "Sucht ein Werkzeug",
77
80
  "looping?": "Schleife?",
@@ -106,6 +109,8 @@
106
109
  "Recent activity": "Letzte Aktivität",
107
110
  "Remove finished agents (2 min)": "Fertige Agenten entfernen (2 Min.)",
108
111
  "Repeated failures": "Wiederholte Fehler",
112
+ "Replay": "Wiedergabe",
113
+ "REPLAY": "WIEDERGABE",
109
114
  "report": "Bericht",
110
115
  "report to": "Bericht an",
111
116
  "Role": "Rolle",
@@ -167,10 +172,11 @@
167
172
  "Reload": "Neu laden",
168
173
  "Open folder": "Ordner öffnen",
169
174
  "custom character(s) loaded": "eigene Figur(en) geladen",
175
+ "Demo": "Demo",
170
176
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Leg deine Pixel-Art (PNG) in diesen Ordner, um die Figuren zu ersetzen:",
171
177
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = Hauptagent · you.png = du · andere = Subagenten · name-talk.png = offener Mund",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "Wird heruntergeladen, du kannst weitermachen: bis dahin kommen die Systemstimmen zum Einsatz.",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Lokale Stimmen in guter Qualität, vor allem Englisch (nur eine französische Stimme). Kostenlos, nichts verlässt deinen Rechner.",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "Lokale Stimmen in guter Qualität, auf Englisch (Französisch nutzt deine Systemstimmen). Kostenlos, nichts verlässt deinen Rechner.",
174
180
  "Installing…": "Installation läuft…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "Modell wird geladen (beim ersten Mal heruntergeladen, etwa 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "npm nicht gefunden: Installiere Node.js, um diese Option zu nutzen.",
@@ -43,6 +43,7 @@
43
43
  "Copy as Markdown": "Copy as Markdown",
44
44
  "Copy failed": "Copy failed",
45
45
  "custom character(s) loaded": "custom character(s) loaded",
46
+ "Demo": "Demo",
46
47
  "Demo — a team fixes a slow login": "Demo — a team fixes a slow login",
47
48
  "Display": "Display",
48
49
  "done": "done",
@@ -67,8 +68,9 @@
67
68
  "Find a session": "Find a session",
68
69
  "Find a session…": "Find a session…",
69
70
  "first error": "first error",
71
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.",
70
72
  "Go to a session": "Go to a session",
71
- "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.",
73
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.",
72
74
  "Hide code & file names in videos": "Hide code & file names in videos",
73
75
  "Highlights": "Highlights",
74
76
  "Human": "Human",
@@ -77,6 +79,7 @@
77
79
  "Installing…": "Installing…",
78
80
  "is sharing their screen": "is sharing their screen",
79
81
  "Join the call": "Join the call",
82
+ "Keep my agents’ own names": "Keep my agents’ own names",
80
83
  "Keyboard shortcuts": "Keyboard shortcuts",
81
84
  "Language": "Language",
82
85
  "Laptop in front of your own characters": "Laptop in front of your own characters",
@@ -87,6 +90,7 @@
87
90
  "lines": "lines",
88
91
  "Listing ": "Listing ",
89
92
  "Little lines & animations": "Little lines & animations",
93
+ "Live": "Live",
90
94
  "Lively": "Lively",
91
95
  "Loading the model (downloaded on first use, about 305 MB)…": "Loading the model (downloaded on first use, about 305 MB)…",
92
96
  "longest wait ": "longest wait ",
@@ -137,6 +141,8 @@
137
141
  "Reload": "Reload",
138
142
  "Remove finished agents (2 min)": "Remove finished agents (2 min)",
139
143
  "Repeated failures": "Repeated failures",
144
+ "Replay": "Replay",
145
+ "REPLAY": "REPLAY",
140
146
  "Replay: next / previous highlight": "Replay: next / previous highlight",
141
147
  "Replay: previous / next event": "Replay: previous / next event",
142
148
  "report": "report",
@@ -58,6 +58,7 @@
58
58
  "Files touched": "Archivos tocados",
59
59
  "Final report": "Informe final",
60
60
  "first error": "primer error",
61
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "El francés lo leen las voces de tu sistema: instalar una «mejorada» o «premium» cambia mucho.",
61
62
  "Hide code & file names in videos": "Ocultar código y archivos en los vídeos",
62
63
  "Highlights": "Destacados",
63
64
  "Human": "Humano",
@@ -65,6 +66,7 @@
65
66
  "input": "entrada",
66
67
  "is sharing their screen": "está compartiendo pantalla",
67
68
  "Join the call": "Unirse a la videollamada",
69
+ "Keep my agents’ own names": "Mantener los nombres de mis agentes",
68
70
  "Language": "Idioma",
69
71
  "Lead": "Principal",
70
72
  "Lead context": "Contexto del agente principal",
@@ -72,6 +74,7 @@
72
74
  "lines": "líneas",
73
75
  "Listing ": "Listando ",
74
76
  "Little lines & animations": "Frases y animaciones",
77
+ "Live": "En vivo",
75
78
  "longest wait ": "espera máxima ",
76
79
  "Looking for a tool": "Buscando una herramienta",
77
80
  "looping?": "¿en bucle?",
@@ -106,6 +109,8 @@
106
109
  "Recent activity": "Actividad reciente",
107
110
  "Remove finished agents (2 min)": "Quitar agentes terminados (2 min)",
108
111
  "Repeated failures": "Fallos repetidos",
112
+ "Replay": "Repetición",
113
+ "REPLAY": "REPETICIÓN",
109
114
  "report": "informe",
110
115
  "report to": "informe a",
111
116
  "Role": "Rol",
@@ -167,10 +172,11 @@
167
172
  "Reload": "Recargar",
168
173
  "Open folder": "Abrir carpeta",
169
174
  "custom character(s) loaded": "personaje(s) propio(s) cargado(s)",
175
+ "Demo": "Demo",
170
176
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Suelta tu pixel art (PNG) en esta carpeta para reemplazar los personajes:",
171
177
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = el agente principal · you.png = tú · el resto = los subagentes · name-talk.png = boca abierta",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "Descargando, puedes seguir: mientras tanto se usan las voces del sistema.",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Voces locales de buena calidad, sobre todo en inglés (una sola voz en francés). Gratis, nada sale de tu equipo.",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "Voces locales de buena calidad, en inglés (el francés usa las voces de tu sistema). Gratis, nada sale de tu equipo.",
174
180
  "Installing…": "Instalando…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "Cargando el modelo (se descarga la primera vez, unos 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "No se encontró npm: instala Node.js para activar esta opción.",
@@ -58,6 +58,7 @@
58
58
  "Files touched": "触れたファイル",
59
59
  "Final report": "最終レポート",
60
60
  "first error": "最初のエラー",
61
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "フランス語はシステム音声で読み上げます。“拡張”または“プレミアム”の音声を入れると大きく変わります。",
61
62
  "Hide code & file names in videos": "動画でコードとファイル名を隠す",
62
63
  "Highlights": "ハイライト",
63
64
  "Human": "人間",
@@ -65,6 +66,7 @@
65
66
  "input": "入力",
66
67
  "is sharing their screen": "が画面を共有中",
67
68
  "Join the call": "ビデオ通話に参加",
69
+ "Keep my agents’ own names": "自分のエージェント名をそのまま使う",
68
70
  "Language": "言語",
69
71
  "Lead": "メインエージェント",
70
72
  "Lead context": "メインエージェントのコンテキスト",
@@ -72,6 +74,7 @@
72
74
  "lines": "行",
73
75
  "Listing ": "一覧表示中: ",
74
76
  "Little lines & animations": "ひとことセリフとアニメーション",
77
+ "Live": "ライブ",
75
78
  "longest wait ": "最長待ち時間 ",
76
79
  "Looking for a tool": "ツールを探し中",
77
80
  "looping?": "ループ中?",
@@ -106,6 +109,8 @@
106
109
  "Recent activity": "最近のアクティビティ",
107
110
  "Remove finished agents (2 min)": "完了したエージェントを削除(2分後)",
108
111
  "Repeated failures": "くり返しの失敗",
112
+ "Replay": "リプレイ",
113
+ "REPLAY": "リプレイ",
109
114
  "report": "報告",
110
115
  "report to": "報告先",
111
116
  "Role": "役割",
@@ -167,10 +172,11 @@
167
172
  "Reload": "再読み込み",
168
173
  "Open folder": "フォルダを開く",
169
174
  "custom character(s) loaded": "件のカスタムキャラクターを読み込みました",
175
+ "Demo": "デモ",
170
176
  "Drop your pixel art (PNG) in this folder to replace the characters:": "このフォルダにドット絵(PNG)を入れると、キャラクターを差し替えられます:",
171
177
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = メインエージェント · you.png = あなた · それ以外 = サブエージェント · name-talk.png = 口を開けた状態",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "ダウンロード中です。そのまま作業を続けられます。それまではシステム音声を使います。",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質の良いローカル音声(主に英語、フランス語は1種類のみ)。無料で、データがPCの外に出ることはありません。",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "品質の良いローカル音声(英語。フランス語はシステム音声を使います)。無料で、データがPCの外に出ることはありません。",
174
180
  "Installing…": "インストール中…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "モデルを読み込み中(初回使用時にダウンロード、約305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "npm が見つかりません。このオプションを使うには Node.js をインストールしてください。",
@@ -58,6 +58,7 @@
58
58
  "Files touched": "건드린 파일",
59
59
  "Final report": "최종 보고",
60
60
  "first error": "첫 오류",
61
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "프랑스어는 시스템 음성으로 읽습니다. “향상된” 또는 “프리미엄” 음성을 설치하면 훨씬 좋아집니다.",
61
62
  "Hide code & file names in videos": "동영상에서 코드와 파일 이름 숨기기",
62
63
  "Highlights": "하이라이트",
63
64
  "Human": "사람",
@@ -65,6 +66,7 @@
65
66
  "input": "입력",
66
67
  "is sharing their screen": "님이 화면 공유 중",
67
68
  "Join the call": "통화 참가",
69
+ "Keep my agents’ own names": "내 에이전트 이름 그대로 사용",
68
70
  "Language": "언어",
69
71
  "Lead": "메인 에이전트",
70
72
  "Lead context": "메인 에이전트 컨텍스트",
@@ -72,6 +74,7 @@
72
74
  "lines": "줄",
73
75
  "Listing ": "목록 보는 중: ",
74
76
  "Little lines & animations": "짧은 대사와 애니메이션",
77
+ "Live": "라이브",
75
78
  "longest wait ": "최장 대기 ",
76
79
  "Looking for a tool": "도구 찾는 중",
77
80
  "looping?": "반복 중?",
@@ -106,6 +109,8 @@
106
109
  "Recent activity": "최근 활동",
107
110
  "Remove finished agents (2 min)": "끝난 에이전트 제거 (2분 후)",
108
111
  "Repeated failures": "반복된 실패",
112
+ "Replay": "다시 보기",
113
+ "REPLAY": "다시 보기",
109
114
  "report": "보고",
110
115
  "report to": "보고 대상:",
111
116
  "Role": "역할",
@@ -167,10 +172,11 @@
167
172
  "Reload": "새로고침",
168
173
  "Open folder": "폴더 열기",
169
174
  "custom character(s) loaded": "개의 커스텀 캐릭터를 불러왔습니다",
175
+ "Demo": "데모",
170
176
  "Drop your pixel art (PNG) in this folder to replace the characters:": "이 폴더에 픽셀 아트(PNG)를 넣으면 캐릭터가 바뀝니다:",
171
177
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 메인 에이전트 · you.png = 나 · 나머지 = 서브에이전트 · name-talk.png = 입을 벌린 모습",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "다운로드 중입니다. 계속 사용하셔도 됩니다. 그동안은 시스템 음성을 사용합니다.",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "품질 좋은 로컬 음성으로, 대부분 영어입니다 (프랑스어 음성은 하나). 무료이며 아무것도 컴퓨터 밖으로 전송되지 않습니다.",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "품질 좋은 로컬 음성으로, 영어입니다 (프랑스어는 시스템 음성을 사용). 무료이며 아무것도 컴퓨터 밖으로 전송되지 않습니다.",
174
180
  "Installing…": "설치 중…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "모델 불러오는 중 (처음 사용할 때 다운로드, 약 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "npm을 찾을 수 없습니다. 이 옵션을 사용하려면 Node.js를 설치하세요.",
@@ -8,6 +8,7 @@
8
8
  "Reload": "Recarregar",
9
9
  "Open folder": "Abrir pasta",
10
10
  "custom character(s) loaded": "personagem(ns) personalizado(s) carregado(s)",
11
+ "Demo": "Demo",
11
12
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Solte sua pixel art (PNG) nesta pasta para substituir os personagens:",
12
13
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = o agente principal · you.png = você · outros = os subagentes · name-talk.png = boca aberta",
13
14
  " full — compaction soon": " cheio — compactação em breve",
@@ -64,6 +65,7 @@
64
65
  "Files touched": "Arquivos modificados",
65
66
  "Final report": "Relatório final",
66
67
  "first error": "primeiro erro",
68
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "O francês é lido pelas vozes do seu sistema: instalar uma “aprimorada” ou “premium” faz muita diferença.",
67
69
  "Hide code & file names in videos": "Ocultar código e arquivos nos vídeos",
68
70
  "Highlights": "Destaques",
69
71
  "Human": "Humano",
@@ -71,6 +73,7 @@
71
73
  "input": "entrada",
72
74
  "is sharing their screen": "está compartilhando a tela",
73
75
  "Join the call": "Entrar na chamada",
76
+ "Keep my agents’ own names": "Manter os nomes dos meus agentes",
74
77
  "Language": "Idioma",
75
78
  "Lead": "Agente principal",
76
79
  "Lead context": "Contexto do agente principal",
@@ -78,6 +81,7 @@
78
81
  "lines": "linhas",
79
82
  "Listing ": "Listando ",
80
83
  "Little lines & animations": "Falas e animações",
84
+ "Live": "Ao vivo",
81
85
  "longest wait ": "maior espera ",
82
86
  "Looking for a tool": "Procurando uma ferramenta",
83
87
  "looping?": "em loop?",
@@ -112,6 +116,8 @@
112
116
  "Recent activity": "Atividade recente",
113
117
  "Remove finished agents (2 min)": "Remover agentes concluídos (2 min)",
114
118
  "Repeated failures": "Falhas repetidas",
119
+ "Replay": "Replay",
120
+ "REPLAY": "REPLAY",
115
121
  "report": "relatório",
116
122
  "report to": "relatório para",
117
123
  "Role": "Função",
@@ -170,7 +176,7 @@
170
176
  "Your Claude Code agents as a live team call": "Seus agentes do Claude Code em uma chamada de equipe ao vivo",
171
177
  "your turn": "sua vez",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "Baixando, pode continuar: enquanto isso, as vozes do sistema são usadas.",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Vozes locais de boa qualidade, principalmente em inglês (só uma voz em francês). Grátis, nada sai do seu computador.",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "Vozes locais de boa qualidade, em inglês (o francês usa as vozes do seu sistema). Grátis, nada sai do seu computador.",
174
180
  "Installing…": "Instalando…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "Carregando o modelo (baixado no primeiro uso, cerca de 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "npm não encontrado: instale o Node.js para ativar esta opção.",
@@ -58,6 +58,7 @@
58
58
  "Files touched": "涉及的文件",
59
59
  "Final report": "最终报告",
60
60
  "first error": "首个错误",
61
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "法语由系统语音朗读:安装“增强”或“优质”语音会好很多。",
61
62
  "Hide code & file names in videos": "在视频中隐藏代码和文件名",
62
63
  "Highlights": "精彩时刻",
63
64
  "Human": "真人",
@@ -65,6 +66,7 @@
65
66
  "input": "输入",
66
67
  "is sharing their screen": "正在共享屏幕",
67
68
  "Join the call": "加入视频通话",
69
+ "Keep my agents’ own names": "保留我的智能体自己的名字",
68
70
  "Language": "语言",
69
71
  "Lead": "主代理",
70
72
  "Lead context": "主代理上下文",
@@ -72,6 +74,7 @@
72
74
  "lines": "行",
73
75
  "Listing ": "正在列出 ",
74
76
  "Little lines & animations": "小台词和动画",
77
+ "Live": "实时",
75
78
  "longest wait ": "最长等待 ",
76
79
  "Looking for a tool": "正在找工具",
77
80
  "looping?": "在循环?",
@@ -106,6 +109,8 @@
106
109
  "Recent activity": "最近动态",
107
110
  "Remove finished agents (2 min)": "移除已完成的代理(2 分钟后)",
108
111
  "Repeated failures": "反复失败",
112
+ "Replay": "回放",
113
+ "REPLAY": "回放",
109
114
  "report": "报告",
110
115
  "report to": "汇报给",
111
116
  "Role": "角色",
@@ -167,10 +172,11 @@
167
172
  "Reload": "重新加载",
168
173
  "Open folder": "打开文件夹",
169
174
  "custom character(s) loaded": "个自定义角色已加载",
175
+ "Demo": "演示",
170
176
  "Drop your pixel art (PNG) in this folder to replace the characters:": "把你的像素画(PNG)放进此文件夹即可替换角色:",
171
177
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 主代理 · you.png = 你 · 其他 = 子代理 · name-talk.png = 张嘴",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "正在下载,你可以继续使用:期间会使用系统语音。",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "高质量的本地语音,以英语为主(法语只有一种声音)。免费,数据不会离开你的电脑。",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "高质量的本地语音,仅英语(法语使用系统语音)。免费,数据不会离开你的电脑。",
174
180
  "Installing…": "正在安装…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "正在加载模型(首次使用时下载,约 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "未找到 npm:请安装 Node.js 以启用此选项。",
@@ -54,6 +54,7 @@
54
54
  "Reload": "重新載入",
55
55
  "Open folder": "開啟資料夾",
56
56
  "custom character(s) loaded": "個自訂角色已載入",
57
+ "Demo": "示範",
57
58
  "Drop your pixel art (PNG) in this folder to replace the characters:": "把你的像素畫(PNG)放進此資料夾即可替換角色:",
58
59
  "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 主代理 · you.png = 你 · 其他 = 子代理 · name-talk.png = 張嘴畫面",
59
60
  "failing in a loop?": "卡在失敗迴圈?",
@@ -64,6 +65,7 @@
64
65
  "Files touched": "異動的檔案",
65
66
  "Final report": "最終報告",
66
67
  "first error": "首次錯誤",
68
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "法文由系統語音朗讀:安裝“增強”或“高品質”語音會好很多。",
67
69
  "Hide code & file names in videos": "在影片中隱藏程式碼與檔名",
68
70
  "Highlights": "精華",
69
71
  "Human": "真人",
@@ -71,6 +73,7 @@
71
73
  "input": "輸入",
72
74
  "is sharing their screen": "正在分享畫面",
73
75
  "Join the call": "加入視訊通話",
76
+ "Keep my agents’ own names": "保留我的代理自己的名字",
74
77
  "Language": "語言",
75
78
  "Lead": "主代理",
76
79
  "Lead context": "主代理上下文",
@@ -78,6 +81,7 @@
78
81
  "lines": "行",
79
82
  "Listing ": "列出 ",
80
83
  "Little lines & animations": "小對白與動畫",
84
+ "Live": "即時",
81
85
  "longest wait ": "最長等待 ",
82
86
  "Looking for a tool": "正在找工具",
83
87
  "looping?": "鬼打牆?",
@@ -112,6 +116,8 @@
112
116
  "Recent activity": "最近活動",
113
117
  "Remove finished agents (2 min)": "移除已完成的代理(2 分鐘)",
114
118
  "Repeated failures": "重複失敗",
119
+ "Replay": "重播",
120
+ "REPLAY": "重播",
115
121
  "report": "報告",
116
122
  "report to": "回報給",
117
123
  "Role": "角色",
@@ -170,7 +176,7 @@
170
176
  "Your Claude Code agents as a live team call": "把你的 Claude Code 代理變成即時團隊通話",
171
177
  "your turn": "換你了",
172
178
  "Downloading, you can carry on: system voices are used meanwhile.": "下載中,你可以繼續使用:這段時間會先用系統語音。",
173
- "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質不錯的本機語音,以英文為主(法文只有一種聲音)。免費,資料不會離開你的電腦。",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "品質不錯的本機語音,僅英文(法文使用系統語音)。免費,資料不會離開你的電腦。",
174
180
  "Installing…": "安裝中…",
175
181
  "Loading the model (downloaded on first use, about 305 MB)…": "正在載入模型(第一次使用時下載,約 305 MB)…",
176
182
  "npm not found: install Node.js to enable this option.": "找不到 npm:請安裝 Node.js 才能啟用此選項。",
package/server.js CHANGED
@@ -9,7 +9,7 @@ const crypto = require('crypto');
9
9
  const codex = require('./codex.js'); // Codex sessions, read as the same call (see codex.js)
10
10
  const kokoro = require('./kokoro.js'); // natural voices, installed on demand (see kokoro.js)
11
11
  const zlib = require('zlib');
12
- const { execFileSync } = require('child_process');
12
+ const { execFileSync, execFile } = require('child_process');
13
13
 
14
14
  const PORT = process.env.PORT || 4747;
15
15
  // Same place Claude Code writes to: honours CLAUDE_CONFIG_DIR (custom config dirs are common on Linux); AGENT_STANDUP_PROJECTS overrides it.
@@ -336,7 +336,7 @@ function parseRecord(st, r) {
336
336
  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; }
337
337
  else if (b.type === 'tool_use') {
338
338
  st.toolName[b.id] = b.name; if (b.input && b.input.run_in_background) (st.bg = st.bg || {})[b.id] = true;
339
- 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) || '' });
339
+ 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) || '', ...(b.input && typeof b.input.name === 'string' && b.input.name.trim() ? { nm: b.input.name.trim() } : {}) });
340
340
  else { push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts), ...th() }); st.act = st.raw.length - 1; }
341
341
  }
342
342
  }
@@ -347,6 +347,15 @@ function parseRecord(st, r) {
347
347
  }
348
348
  }
349
349
  const codexRecord = codex.parser({ toolLabel, clip, resultMax: RESULT_MAX });
350
+ // A sub-agent the person named keeps that name on screen: the name given when it was launched, or else its type when that type is the person's own (.claude/agents/code-reviewer.md → "Code Reviewer").
351
+ // The types Claude Code ships are not names: those agents get a first name from the page, as before.
352
+ const STOCK_AGENT = /^(general-purpose|explore|plan|claude|agent|task|fork|main|default|worker|subagent|workflow-subagent|statusline-setup|output-style-setup|claude-code-guide|verification|magic-docs)$/i;
353
+ function ownName(s, type) {
354
+ const raw = String(s || '').split(':').pop().trim(); if (!raw || (type && STOCK_AGENT.test(raw))) return ''; // a plugin's agent is written plugin:agent
355
+ let n = raw.replace(/[_\-]+/g, ' ').replace(/[^\p{L}\p{N} .'’&+]/gu, ' ').replace(/\s+/g, ' ').trim(); if (!/[\p{L}\p{N}]/u.test(n)) return '';
356
+ if (n === n.toLowerCase()) n = n.replace(/(^|\s)\p{Ll}/gu, (m) => m.toUpperCase());
357
+ return n.length > 26 ? n.slice(0, 25).trimEnd() + '…' : n;
358
+ }
350
359
  const metaCache = new Map(); // meta file path → { mtime, value }
351
360
  function readMeta(f) {
352
361
  try { const mt = fs.statSync(f).mtimeMs, c = metaCache.get(f); if (c && c.mtime === mt) return c.value; const v = JSON.parse(fs.readFileSync(f, 'utf8')); if (metaCache.size > 3000) metaCache.clear(); metaCache.set(f, { mtime: mt, value: v }); return v; } catch { return {}; }
@@ -367,6 +376,10 @@ function buildTimeline(proj, id, files = []) {
367
376
  for (const j of subs.journals) { const st = readNew(j.file, 'wf:' + j.run, true), run = runs[j.run]; files.push(j.file); if (st && run) for (const e of st.raw) if (run.agents[e.child]) run.done.add(run.agents[e.child]); }
368
377
  const childByTool = {}; // Agent tool_use id → the sub-agent it created, so we know who ordered whom
369
378
  for (const a of Object.values(agents)) if (a.toolUseId) childByTool[a.toolUseId] = a.id;
379
+ const called = {}, born = {}; for (const st of states) if (st) { if (st.raw.length) born[st.agent] = st.raw[0].ts; for (const e of st.raw) if (e.kind === 'agentcall' && e.nm && childByTool[e.toolId]) called[childByTool[e.toolId]] = e.nm; }
380
+ const sameName = {}; for (const a of Object.values(agents).filter((x) => x.id !== 'main').sort((x, y) => (born[x.id] || Infinity) - (born[y.id] || Infinity) || (x.id < y.id ? -1 : 1))) { // in the order they started, so that a second agent of the same name is the one numbered 2 and stays so
381
+ const n = ownName(called[a.id]) || ownName(a.type, true); if (n) { const k = n.toLowerCase(); sameName[k] = (sameName[k] || 0) + 1; a.own = sameName[k] > 1 ? n + ' ' + sameName[k] : n; }
382
+ }
370
383
  const runByTool = {}; for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'wfrun' && runs[e.run] && !runs[e.run].by) { runs[e.run].by = e.agent; runs[e.run].toolId = e.toolId; runByTool[e.toolId] = runs[e.run]; } // Workflow tool_use id → the run it started
371
384
 
372
385
  const finished = new Set(), now0 = Date.now(); for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'agentdone') { if (childByTool[e.toolId]) finished.add(childByTool[e.toolId]); else if (runByTool[e.toolId]) for (const a of Object.values(runByTool[e.toolId].agents)) finished.add(a); } // a run that stopped: so did every agent in it
@@ -520,25 +533,25 @@ const quips = (texts, lang) => claudeMap('q' + lang, `Each string is a work repo
520
533
 
521
534
  // ---- text-to-speech, generated on the machine and cached as wav:
522
535
  // macOS → `say`, Windows → System.Speech (SAPI voices) through PowerShell, Linux → espeak-ng.
523
- let sayVoices = null;
536
+ let sayVoices = null, sayVoicesJob = null;
524
537
  const WIN = process.platform === 'win32', MAC = process.platform === 'darwin';
525
538
  const PS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command'];
539
+ const VOICES_CMD = MAC ? ['say', ['-v', '?'], 5000] : WIN ? ['powershell.exe', [...PS, "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name + '|' + $_.VoiceInfo.Culture.Name }"], 20000] : ['espeak-ng', ['--voices'], 5000];
540
+ function parseVoices(out) {
541
+ const voices = [];
542
+ if (MAC) { for (const line of out.split('\n')) { const m = line.match(/^(.+?)\s{2,}([a-z]{2}_[A-Z]{2})\s+#/); if (m) voices.push({ name: m[1].trim(), locale: m[2] }); } }
543
+ else if (WIN) { for (const line of out.split(/\r?\n/)) { const [name, loc] = line.trim().split('|'); if (name && loc) voices.push({ name, locale: loc.replace('-', '_') }); } }
544
+ else for (const line of out.split('\n').slice(1)) { const c = line.trim().split(/\s+/); if (c[1] && /^(fr|en)/.test(c[1])) voices.push({ name: c[1], locale: c[1].replace('-', '_').replace(/^(\w{2})$/, (m) => m + '_' + m.toUpperCase()) }); }
545
+ return { available: voices.length > 0, voices, platform: process.platform };
546
+ }
526
547
  function listVoices() {
527
548
  if (sayVoices) return sayVoices;
528
- const voices = [];
529
- try {
530
- if (MAC) {
531
- const out = execFileSync('say', ['-v', '?'], { encoding: 'utf8', timeout: 5000 });
532
- for (const line of out.split('\n')) { const m = line.match(/^(.+?)\s{2,}([a-z]{2}_[A-Z]{2})\s+#/); if (m) voices.push({ name: m[1].trim(), locale: m[2] }); }
533
- } else if (WIN) {
534
- const out = execFileSync('powershell.exe', [...PS, "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name + '|' + $_.VoiceInfo.Culture.Name }"], { encoding: 'utf8', timeout: 20000, windowsHide: true });
535
- for (const line of out.split(/\r?\n/)) { const [name, loc] = line.trim().split('|'); if (name && loc) voices.push({ name, locale: loc.replace('-', '_') }); }
536
- } else {
537
- const out = execFileSync('espeak-ng', ['--voices'], { encoding: 'utf8', timeout: 5000 });
538
- for (const line of out.split('\n').slice(1)) { const c = line.trim().split(/\s+/); if (c[1] && /^(fr|en)/.test(c[1])) voices.push({ name: c[1], locale: c[1].replace('-', '_').replace(/^(\w{2})$/, (m) => m + '_' + m.toUpperCase()) }); }
539
- }
540
- } catch {}
541
- return (sayVoices = { available: voices.length > 0, voices, platform: process.platform });
549
+ let out = ''; try { out = execFileSync(VOICES_CMD[0], VOICES_CMD[1], { encoding: 'utf8', timeout: VOICES_CMD[2], windowsHide: true }); } catch {}
550
+ return (sayVoices = parseVoices(out));
551
+ }
552
+ function loadVoices() { // the same list for the page, asked without waiting: the first answer of the system takes a second or more, and the session the page asks for at the same moment must not wait behind it
553
+ if (sayVoices) return Promise.resolve(sayVoices);
554
+ return sayVoicesJob || (sayVoicesJob = new Promise((resolve) => { try { execFile(VOICES_CMD[0], VOICES_CMD[1], { encoding: 'utf8', timeout: VOICES_CMD[2], windowsHide: true }, (err, out) => resolve(sayVoices || (sayVoices = parseVoices(err ? '' : String(out))))); } catch { resolve(sayVoices || (sayVoices = parseVoices(''))); } }));
542
555
  }
543
556
  const ttsDir = path.join(os.tmpdir(), 'agent-standup-tts-' + (process.getuid ? process.getuid() : 'user'));
544
557
  try { fs.mkdirSync(ttsDir, { recursive: true, mode: 0o700 }); } catch {}
@@ -556,7 +569,7 @@ function synthesize(voice, wpm, text, out) {
556
569
  p.on('error', reject); p.on('close', (code) => (code === 0 && fs.existsSync(out) ? resolve() : reject(new Error('tts failed'))));
557
570
  });
558
571
  }
559
- function tts(text, voice, rate, engine) {
572
+ function tts(text, voice, rate, engine, fb) {
560
573
  const kk = engine === 'kokoro';
561
574
  if (kk) { if (!kokoro.hasVoice(voice)) return Promise.reject(new Error('unknown voice')); }
562
575
  else { const vs = listVoices(); if (!vs.available || !vs.voices.some((v) => v.name === voice)) return Promise.reject(new Error('unknown voice')); }
@@ -566,7 +579,7 @@ function tts(text, voice, rate, engine) {
566
579
  if (ttsJobs.has(key)) return ttsJobs.get(key);
567
580
  fs.mkdirSync(ttsDir, { recursive: true });
568
581
  const tmp = file + '.part.wav';
569
- const job = (kk ? kokoro.synth(text, voice, kokoro.speedFor(rate), tmp).catch((e) => { const vs = listVoices(), v = vs.voices.find((x) => x.locale.startsWith((voice.startsWith('ff_') ? 'fr' : 'en') + '_')); if (!v) throw e; return synthesize(v.name, rate, text, tmp); }) : synthesize(voice, rate, text, tmp)) // a line Kokoro cannot make goes to a system voice instead of being dropped
582
+ const job = (kk ? kokoro.synth(text, voice, kokoro.speedFor(rate), tmp).catch((e) => { const vs = listVoices(), v = vs.voices.find((x) => x.name === fb) || vs.voices.find((x) => x.locale.startsWith('en_')); if (!v) throw e; return synthesize(v.name, rate, text, tmp); }) : synthesize(voice, rate, text, tmp)) // a line Kokoro cannot make goes to a system voice instead of being dropped: the one the page names (fb, its best system voice for this character), or else the first English one
570
583
  .then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
571
584
  ttsJobs.set(key, job); return job;
572
585
  }
@@ -628,12 +641,12 @@ function start(port = PORT) {
628
641
  if (url.pathname === '/api/warm') { trWorkers.forEach((w) => { if (!w.p) w.start(); }); return json({ ok: true }); }
629
642
  if (url.pathname === '/api/voices') {
630
643
  if (url.searchParams.get('engine') === 'kokoro') { const st = kokoro.status(); if (st.installed && !st.gaveUp) kokoro.warm(); return json({ available: st.installed && !st.gaveUp, ready: st.ready, voices: kokoro.voices(), platform: process.platform, engine: 'kokoro' }); }
631
- return json(listVoices());
644
+ return loadVoices().then((v) => json(v));
632
645
  }
633
646
  if (url.pathname === '/api/kokoro/install' && req.method === 'POST') return json(kokoro.install());
634
647
  if (url.pathname === '/api/kokoro') return json(kokoro.status());
635
648
  if (url.pathname === '/api/tts') {
636
- return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'))
649
+ return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'), url.searchParams.get('fb'))
637
650
  .then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); sendFile(f); })
638
651
  .catch((e) => json({ error: String(e.message) }, 400));
639
652
  }