@double_geste/agent-standup 1.27.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.27.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;
@@ -354,7 +355,7 @@ let prefsTimer = null;
354
355
  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); };
355
356
  // 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.
356
357
  // A missing string falls back to English, so a half-translated language still works. See public/locales/README.md to add one.
357
- const LANGS = [['en', 'English', 'en'], ['fr', 'Français', 'fr'], ['zh-TW', '繁體中文', 'zh-Hant-TW'], ['zh-CN', '简体中文', 'zh-Hans-CN'], ['es', 'Español', 'es'], ['de', 'Deutsch', 'de'], ['da', 'Dansk', 'da'], ['pt-BR', 'Português (Brasil)', 'pt-BR'], ['ja', '日本語', 'ja']];
358
+ const LANGS = [['en', 'English', 'en'], ['fr', 'Français', 'fr'], ['zh-TW', '繁體中文', 'zh-Hant-TW'], ['zh-CN', '简体中文', 'zh-Hans-CN'], ['es', 'Español', 'es'], ['de', 'Deutsch', 'de'], ['da', 'Dansk', 'da'], ['pt-BR', 'Português (Brasil)', 'pt-BR'], ['ja', '日本語', 'ja'], ['ko', '한국어', 'ko']];
358
359
  let LOC = {};
359
360
  const FR = () => prefs.lang === 'fr';
360
361
  const FR_W = /\b(le|la|les|des|une|un|et|est|pour|dans|que|qui|pas|sur|avec|je|tu|nous|vous|ce|cette|ça|du|au|aux|mais|sont|été|fait|plus|ne|ont|il|elle|ils|elles|très|aussi|donc|bien|tout|voici|voilà|merci|oui|non|comme|où|déjà|encore)\b/gi, EN_W = /\b(the|and|is|are|to|of|in|that|with|for|this|it|on|you|we|not|have|has|was|be|will|can|from|your|at|as|but|all|now|also|done|here|there|which|what|then|just|so)\b/gi;
@@ -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 をインストールしてください。",
@@ -0,0 +1,227 @@
1
+ {
2
+ "_meta": {
3
+ "language": "ko",
4
+ "reviewed": false,
5
+ "note": "AI draft, native review welcome"
6
+ },
7
+ " full — compaction soon": " 사용 — 곧 압축됩니다",
8
+ "”": "”",
9
+ "↗ Open in browser": "↗ 브라우저에서 열기",
10
+ "▶ Join": "▶ 참가",
11
+ "▶ Watch the demo": "▶ 데모 보기",
12
+ "☕ Buy the (replaced) dev a coffee": "☕ (일자리를 잃은) 개발자에게 커피 사 주기",
13
+ "🎞 Export video": "🎞 동영상 내보내기",
14
+ "🎬 Clip to share (25 s)": "🎬 공유용 클립 (25초)",
15
+ "📋 Recap": "📋 요약",
16
+ "Agent voices": "에이전트 목소리",
17
+ "Agents": "에이전트",
18
+ "Agents list (left)": "에이전트 목록 (왼쪽)",
19
+ "All projects (most recent)": "모든 프로젝트 (최신순)",
20
+ "Ambience volume": "환경음 볼륨",
21
+ "An agent may be waiting for you ✋": "에이전트가 기다리고 있을 수 있어요 ✋",
22
+ "Animal Crossing (babble)": "Animal Crossing (옹알이)",
23
+ "Asking a question": "질문하는 중",
24
+ "brief": "지시",
25
+ "Busiest agents": "가장 바쁜 에이전트",
26
+ "Buy a coffee": "커피 사 주기",
27
+ "cache read": "캐시 읽기",
28
+ "cache write": "캐시 쓰기",
29
+ "Call": "통화",
30
+ "Cartoon voices (real voice)": "만화 목소리 (실제 음성)",
31
+ "Claude Code is not logged in: run “claude” once in a terminal to sign in.": "Claude Code에 로그인되어 있지 않습니다. 터미널에서 “claude”를 한 번 실행해 로그인하세요.",
32
+ "Claude Code not found: install it (npm i -g @anthropic-ai/claude-code) or set its path in the “agentStandup.claudePath” setting.": "Claude Code를 찾을 수 없습니다. 설치하거나(npm i -g @anthropic-ai/claude-code) “agentStandup.claudePath” 설정에 경로를 지정하세요.",
33
+ "Click to enable sound and follow your Claude Code session live.": "클릭하면 소리가 켜지고 Claude Code 세션을 실시간으로 따라갑니다.",
34
+ "Close": "닫기",
35
+ "commands": "명령",
36
+ "compaction": "압축",
37
+ "Context": "컨텍스트",
38
+ "context ": "컨텍스트 ",
39
+ "context compacted 🗜": "컨텍스트 압축됨 🗜",
40
+ "Copied": "복사했습니다",
41
+ "Copy as Markdown": "Markdown으로 복사",
42
+ "Copy failed": "복사 실패",
43
+ "Demo — a team fixes a slow login": "데모 — 팀이 느린 로그인을 고칩니다",
44
+ "done": "완료",
45
+ "Done": "완료",
46
+ "Done. ": "완료. ",
47
+ "duration": "소요 시간",
48
+ "Editing ": "편집 중: ",
49
+ "Editor": "편집기",
50
+ "EPISODE 1": "제1화",
51
+ "Episode recap": "에피소드 요약",
52
+ "errors": "오류",
53
+ "failing in a loop?": "실패를 반복하는 중?",
54
+ "Fetching ": "가져오는 중: ",
55
+ "files": "파일",
56
+ "Files": "파일",
57
+ "files changed": "변경된 파일",
58
+ "Files touched": "건드린 파일",
59
+ "Final report": "최종 보고",
60
+ "first error": "첫 오류",
61
+ "French is read by your system voices: installing an “enhanced” or “premium” one makes a big difference.": "프랑스어는 시스템 음성으로 읽습니다. “향상된” 또는 “프리미엄” 음성을 설치하면 훨씬 좋아집니다.",
62
+ "Hide code & file names in videos": "동영상에서 코드와 파일 이름 숨기기",
63
+ "Highlights": "하이라이트",
64
+ "Human": "사람",
65
+ "idle": "유휴",
66
+ "input": "입력",
67
+ "is sharing their screen": "님이 화면 공유 중",
68
+ "Join the call": "통화 참가",
69
+ "Keep my agents’ own names": "내 에이전트 이름 그대로 사용",
70
+ "Language": "언어",
71
+ "Lead": "메인 에이전트",
72
+ "Lead context": "메인 에이전트 컨텍스트",
73
+ "left the call": "님이 통화에서 나갔습니다",
74
+ "lines": "줄",
75
+ "Listing ": "목록 보는 중: ",
76
+ "Little lines & animations": "짧은 대사와 애니메이션",
77
+ "Live": "라이브",
78
+ "longest wait ": "최장 대기 ",
79
+ "Looking for a tool": "도구 찾는 중",
80
+ "looping?": "반복 중?",
81
+ "Music volume": "음악 볼륨",
82
+ "Natural voices": "자연스러운 목소리",
83
+ "need you": "응답 필요",
84
+ "needs you?": "호출 중?",
85
+ "needs you? ": "호출 중? ",
86
+ "No agents were harmed.": "이 과정에서 다친 에이전트는 없습니다.",
87
+ "No edits.": "편집 없음.",
88
+ "No French voice installed (Windows: Settings › Time & language › Speech).": "프랑스어 음성이 설치되어 있지 않습니다 (Windows: 설정 › 시간 및 언어 › 음성).",
89
+ "No recent session": "최근 세션 없음",
90
+ "No setting found": "일치하는 설정 없음",
91
+ "No system voice found: agents will mumble.": "시스템 음성을 찾을 수 없습니다. 에이전트가 웅얼거립니다.",
92
+ "Notify me when agents finish": "에이전트가 끝나면 알림",
93
+ "Office ambience (keys, coffee…)": "사무실 환경음 (키보드, 커피…)",
94
+ "Office music (FIP radio)": "사무실 음악 (FIP 라디오)",
95
+ "OFFLINE": "오프라인",
96
+ "Opening file…": "파일 여는 중…",
97
+ "output": "출력",
98
+ "Pause / Play (space)": "일시정지 / 재생 (스페이스)",
99
+ "PAUSED": "일시정지됨",
100
+ "Project to follow": "따라갈 프로젝트",
101
+ "Prompt from": "프롬프트 보낸 이:",
102
+ "Radio unreachable: check your connection or pick another station.": "라디오에 연결할 수 없습니다. 연결을 확인하거나 다른 방송국을 선택하세요.",
103
+ "Reactions": "리액션",
104
+ "Reactions & jokes": "리액션과 농담",
105
+ "Reactions bar (👍 👏 ☕)": "리액션 바 (👍 👏 ☕)",
106
+ "read-only": "읽기 전용",
107
+ "Reading ": "읽는 중: ",
108
+ "READY": "준비 완료",
109
+ "Recent activity": "최근 활동",
110
+ "Remove finished agents (2 min)": "끝난 에이전트 제거 (2분 후)",
111
+ "Repeated failures": "반복된 실패",
112
+ "Replay": "다시 보기",
113
+ "REPLAY": "다시 보기",
114
+ "report": "보고",
115
+ "report to": "보고 대상:",
116
+ "Role": "역할",
117
+ "running": "실행 중",
118
+ "Running ": "실행 중: ",
119
+ "running…": "실행 중…",
120
+ "Said": "발언",
121
+ "Same call repeated": "같은 호출 반복",
122
+ "Search the transcript": "대화 기록 검색",
123
+ "Search…": "검색…",
124
+ "Searching “": "검색 중: “",
125
+ "session": "세션",
126
+ "Sessions": "세션",
127
+ "Settings": "설정",
128
+ "Simlish (babble)": "Simlish (옹알이)",
129
+ "Sound": "소리",
130
+ "Sound (M key)": "소리 (M 키)",
131
+ "Sound is playing in another Agent Standup window. Click 🔈 to take it over here.": "다른 Agent Standup 창에서 소리가 재생 중입니다. 🔈 를 클릭하면 이 창으로 가져옵니다.",
132
+ "Sound is playing in another window — click to take it over here": "다른 창에서 소리가 재생 중입니다 — 클릭하면 이 창으로 가져옵니다",
133
+ "Source": "소스",
134
+ "Speed": "속도",
135
+ "stalled": "멈춤",
136
+ "stalled · ": "멈춤 · ",
137
+ "standing by": "대기 중",
138
+ "Start Claude Code in this folder — the call will start by itself.": "이 폴더에서 Claude Code를 시작하세요 — 통화가 자동으로 시작됩니다.",
139
+ "Start Claude Code in {0} — the call will start by itself.": "{0}에서 Claude Code를 시작하세요 — 통화가 자동으로 시작됩니다.",
140
+ "sub-agents active": "서브에이전트 활동 중",
141
+ "task": "작업",
142
+ "Tasks": "작업",
143
+ "THE END": "끝",
144
+ "This dev was replaced by his own agents. Spare a coffee for his career change? ☕": "이 개발자는 자기 에이전트들에게 일자리를 빼앗겼습니다. 새 출발을 응원하는 커피 한 잔 어떠세요? ☕",
145
+ "Time": "시간",
146
+ "to": "대상:",
147
+ "tokens": "토큰",
148
+ "Tokens": "토큰",
149
+ "Tokens recorded by Claude Code (counted once per message, not a bill)": "Claude Code가 기록한 토큰 (메시지당 한 번 집계, 청구 금액 아님)",
150
+ "tool calls": "도구 호출",
151
+ "tool errors": "도구 오류",
152
+ "tools": "도구",
153
+ "Tools": "도구",
154
+ "Translation unavailable right now: check that “claude” works in a terminal.": "지금은 번역을 사용할 수 없습니다. 터미널에서 “claude”가 동작하는지 확인하세요.",
155
+ "Tree": "트리",
156
+ "Untitled session": "제목 없는 세션",
157
+ "Updating todo list": "할 일 목록 업데이트 중",
158
+ "Using skill ": "스킬 사용 중: ",
159
+ "VS Code was started from inside a Claude Code session: restart VS Code normally (Dock, Spotlight) to enable translation.": "VS Code가 Claude Code 세션 안에서 실행되었습니다. 번역을 사용하려면 VS Code를 일반적인 방법(Dock, Spotlight)으로 다시 시작하세요.",
160
+ "waiting": "대기 중",
161
+ "Waiting for a session": "세션을 기다리는 중",
162
+ "waiting on ": "기다리는 중: ",
163
+ "waiting on sub-agents": "서브에이전트를 기다리는 중",
164
+ "Web search": "웹 검색",
165
+ "working": "작업 중",
166
+ "Writing ": "작성 중: ",
167
+ "You": "나",
168
+ "Your agents are done 🎉": "에이전트 작업이 끝났습니다 🎉",
169
+ "Your Claude Code agents as a live team call": "Claude Code 에이전트를 실시간 팀 통화로",
170
+ "your turn": "내 차례",
171
+ "Characters": "캐릭터",
172
+ "Reload": "새로고침",
173
+ "Open folder": "폴더 열기",
174
+ "custom character(s) loaded": "개의 커스텀 캐릭터를 불러왔습니다",
175
+ "Demo": "데모",
176
+ "Drop your pixel art (PNG) in this folder to replace the characters:": "이 폴더에 픽셀 아트(PNG)를 넣으면 캐릭터가 바뀝니다:",
177
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 메인 에이전트 · you.png = 나 · 나머지 = 서브에이전트 · name-talk.png = 입을 벌린 모습",
178
+ "Downloading, you can carry on: system voices are used meanwhile.": "다운로드 중입니다. 계속 사용하셔도 됩니다. 그동안은 시스템 음성을 사용합니다.",
179
+ "Good-quality local voices, in English (French lines use your system voices). Free, nothing leaves your machine.": "품질 좋은 로컬 음성으로, 영어입니다 (프랑스어는 시스템 음성을 사용). 무료이며 아무것도 컴퓨터 밖으로 전송되지 않습니다.",
180
+ "Installing…": "설치 중…",
181
+ "Loading the model (downloaded on first use, about 305 MB)…": "모델 불러오는 중 (처음 사용할 때 다운로드, 약 305 MB)…",
182
+ "npm not found: install Node.js to enable this option.": "npm을 찾을 수 없습니다. 이 옵션을 사용하려면 Node.js를 설치하세요.",
183
+ "The engine stopped: system voices are used instead.": "엔진이 멈췄습니다. 대신 시스템 음성을 사용합니다.",
184
+ "Very natural voices (installs on demand)": "매우 자연스러운 목소리 (필요할 때 설치)",
185
+ "Voices ready.": "음성이 준비되었습니다.",
186
+ "Voice style": "목소리 스타일",
187
+ "⌨ Keyboard shortcuts (?)": "⌨ 키보드 단축키 (?)",
188
+ "Keyboard shortcuts": "키보드 단축키",
189
+ "Mute / unmute": "음소거 / 해제",
190
+ "Pause / play": "일시정지 / 재생",
191
+ "Recap": "요약",
192
+ "This help": "이 도움말",
193
+ "Transcript": "대화 기록",
194
+ "Tree view ↔ call": "트리 보기 ↔ 통화",
195
+ "Tree view: T to go back, ? for help": "트리 보기: T로 돌아가기, ?로 도움말",
196
+ "You can also switch between Call, Tree and Sessions at the top of the window.": "창 위쪽에서 통화, 트리, 세션을 전환할 수도 있습니다.",
197
+ "Display": "표시",
198
+ "Search settings…": "설정 검색…",
199
+ "may be waiting for your approval": "승인을 기다리고 있을 수 있습니다",
200
+ "Next / previous agent that needs you": "응답이 필요한 다음 / 이전 에이전트",
201
+ "Nobody needs you right now": "지금 응답을 기다리는 에이전트가 없습니다",
202
+ "stalled: a sub-agent has been silent for a long time": "멈춤: 서브에이전트가 오랫동안 조용합니다",
203
+ "waiting on another agent": "다른 에이전트를 기다리는 중",
204
+ "What the states mean": "상태의 의미",
205
+ "Find a session": "세션 찾기",
206
+ "Find a session…": "세션 찾기…",
207
+ "Go to a session": "세션으로 이동",
208
+ "No highlight in this session": "이 세션에는 하이라이트가 없습니다",
209
+ "Replay: next / previous highlight": "다시 보기: 다음 / 이전 하이라이트",
210
+ "Replay: previous / next event": "다시 보기: 이전 / 다음 이벤트",
211
+ "Resumed where you left off": "마지막으로 본 곳부터 이어서 재생합니다",
212
+ "Chatter between agents": "에이전트 간 잡담",
213
+ "Lively": "활발하게",
214
+ "Normal": "보통",
215
+ "nothing to report": "보고할 것 없음",
216
+ "Quiet": "조용하게",
217
+ "Laptop in front of your own characters": "내 캐릭터 앞에 노트북 놓기",
218
+ "Also ": "또한 ",
219
+ " on ": " → ",
220
+ "To sum up: ": "정리하면: ",
221
+ "Cat pack": "고양이 팩",
222
+ "characters from the pack": "개의 팩 캐릭터",
223
+ "Drawn characters": "기본 캐릭터",
224
+ "To tweak them, copy them into your folder and pick “My folder”.": "수정하려면 내 폴더에 복사한 뒤 “내 폴더”를 선택하세요.",
225
+ "My folder": "내 폴더",
226
+ "The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "앱이 그리는 캐릭터입니다. 직접 만든 이미지를 쓰려면 “내 폴더”를, 아니면 팩을 선택하세요."
227
+ }
@@ -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
  }