@double_geste/agent-standup 1.25.1 → 1.25.3

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.
Files changed (38) hide show
  1. package/examples/cats/README.txt +7 -0
  2. package/examples/cats/biscuit-talk.png +0 -0
  3. package/examples/cats/biscuit.png +0 -0
  4. package/examples/cats/cinnamon-talk.png +0 -0
  5. package/examples/cats/cinnamon.png +0 -0
  6. package/examples/cats/earl-grey-talk.png +0 -0
  7. package/examples/cats/earl-grey.png +0 -0
  8. package/examples/cats/lead-talk.png +0 -0
  9. package/examples/cats/lead.png +0 -0
  10. package/examples/cats/miso-talk.png +0 -0
  11. package/examples/cats/miso.png +0 -0
  12. package/examples/cats/mochi-talk.png +0 -0
  13. package/examples/cats/mochi.png +0 -0
  14. package/examples/cats/oreo-talk.png +0 -0
  15. package/examples/cats/oreo.png +0 -0
  16. package/examples/cats/patches-talk.png +0 -0
  17. package/examples/cats/patches.png +0 -0
  18. package/examples/cats/salem-talk.png +0 -0
  19. package/examples/cats/salem.png +0 -0
  20. package/examples/cats/smokey-talk.png +0 -0
  21. package/examples/cats/smokey.png +0 -0
  22. package/examples/cats/you-talk.png +0 -0
  23. package/examples/cats/you.png +0 -0
  24. package/examples/characters/README.txt +5 -0
  25. package/examples/characters/buddy.png +0 -0
  26. package/examples/characters/lead-talk.png +0 -0
  27. package/examples/characters/lead.png +0 -0
  28. package/package.json +2 -1
  29. package/public/index.html +54 -19
  30. package/public/locales/da.json +11 -1
  31. package/public/locales/de.json +11 -1
  32. package/public/locales/en.json +10 -0
  33. package/public/locales/es.json +11 -1
  34. package/public/locales/ja.json +11 -1
  35. package/public/locales/pt-BR.json +11 -1
  36. package/public/locales/zh-CN.json +11 -1
  37. package/public/locales/zh-TW.json +11 -1
  38. package/server.js +10 -6
@@ -0,0 +1,7 @@
1
+ Cat pack for Agent Standup (public domain / CC0, like the starter pack).
2
+ Copy the PNG files into your characters folder, then press Reload:
3
+ - lead.png = Boss, a grumpy cream cat (the lead)
4
+ - you.png = Kibbles, a bag of cat food (you)
5
+ - salem, smokey, mochi, patches, miso, biscuit, cinnamon, earl-grey, oreo = the sub-agents
6
+ - name-talk.png = open-mouth frame for each of them
7
+ 64x64 px, transparent background. Drawn procedurally in Python with the help of an AI assistant.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,5 @@
1
+ Example pixel characters (public domain, made for Agent Standup).
2
+ Copy them into your characters folder to try the feature, then draw your own:
3
+ - 32x32 or 64x64 px PNG with a transparent background, facing front, feet at the bottom
4
+ - lead.png = the lead (Claude), you.png = you, any other name = a sub-agent
5
+ - name-talk.png = optional open-mouth frame
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.25.1",
3
+ "version": "1.25.3",
4
4
  "description": "Your Claude Code agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
5
5
  "bin": {
6
6
  "agent-standup": "bin/agent-standup.js"
@@ -13,6 +13,7 @@
13
13
  "kokoro-worker.js",
14
14
  "demo.js",
15
15
  "public",
16
+ "examples",
16
17
  "media",
17
18
  "LICENSE",
18
19
  "README.md"
package/public/index.html CHANGED
@@ -279,6 +279,7 @@
279
279
  <select id="liveProj" style="display:none;margin-top:6px" title="Project to follow"></select>
280
280
  <select id="sessions" style="display:none"></select></div>
281
281
  <h4 data-fr="Personnages" data-en="Characters">Personnages</h4>
282
+ <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="starter" data-fr="Pack de départ" data-en="Starter pack">Pack de départ</option><option value="cats" data-fr="Pack de chats" data-en="Cat pack">Pack de chats</option></select></div>
282
283
  <div class="box"><div id="charInfo" class="dim" style="font-size:11.5px;line-height:1.5"></div><div style="display:flex;gap:6px;margin-top:6px"><button class="btn" id="charReload" data-fr="Recharger" data-en="Reload">Recharger</button><button class="btn" id="charOpen" style="display:none" data-fr="Ouvrir le dossier" data-en="Open folder">Ouvrir le dossier</button></div></div>
283
284
  <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>
284
285
  <h4 data-fr="Outils" data-en="Tools">Outils</h4>
@@ -342,6 +343,11 @@ const savePrefs = () => { prefs._t = Date.now(); try { localStorage.setItem('cc.
342
343
  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']];
343
344
  let LOC = {};
344
345
  const FR = () => prefs.lang === 'fr';
346
+ 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;
347
+ const langOf = (t) => { // 'fr' or 'en' when a line clearly reads as one of them, else null: the voice then follows the interface language
348
+ t = String(t || ''); const fr = (t.match(FR_W) || []).length + (t.match(/[éèêàçùâîôûœ]/g) || []).length, en = (t.match(EN_W) || []).length;
349
+ return fr >= 2 && fr > en * 1.5 ? 'fr' : en >= 2 && en > fr * 1.5 ? 'en' : null;
350
+ };
345
351
  const tr = (en) => (prefs.lang === 'en' || prefs.lang === 'fr' ? en : LOC[en] || en);
346
352
  const L = (fr, en) => (FR() ? fr : tr(en));
347
353
  async function loadLocale(code) { LOC = {}; if (code === 'en' || code === 'fr') return; try { const r = await fetch('/locales/' + encodeURIComponent(code) + '.json'); if (r.ok) LOC = await r.json(); } catch {} }
@@ -367,10 +373,14 @@ const mix = (a, b, t) => { const A = hexRGB(a), B = hexRGB(b); return '#' + A.ma
367
373
  let CHARS = { lead: null, you: null, pool: [], dir: '', n: 0 };
368
374
  const toData = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { const f = new FileReader(); f.onload = () => res(f.result); f.readAsDataURL(b); }));
369
375
  async function loadChars() {
370
- const next = { lead: null, you: null, pool: [], dir: '', n: 0 };
376
+ if (!['none', 'folder', 'starter', 'cats'].includes(prefs.charPack)) { // drawn characters by default; someone who already had images in their folder keeps them
377
+ let n = 0; try { n = ((await (await fetch('/api/characters')).json()).chars || []).length; } catch {} prefs.charPack = n ? 'folder' : 'none'; savePrefs(); const sel = $('#charPack'); if (sel) sel.value = prefs.charPack;
378
+ }
379
+ const pack = prefs.charPack, next = { lead: null, you: null, pool: [], dir: '', n: 0, pack };
380
+ if (pack === 'none') { try { next.dir = (await (await fetch('/api/characters')).json()).dir || ''; } catch {} CHARS = next; return; } // the drawn characters, whatever is in the folder
371
381
  try {
372
- const d = await (await fetch('/api/characters')).json(); next.dir = d.dir || '';
373
- for (const c of d.chars || []) { try { const e = { name: c.stem, idle: await toData('/characters/' + encodeURIComponent(c.file)), talk: c.talk ? await toData('/characters/' + encodeURIComponent(c.talk)) : null };
382
+ const base = pack === 'folder' ? '/characters/' : '/packs/' + pack + '/', d = await (await fetch('/api/characters?pack=' + (pack === 'folder' ? '' : pack))).json(); next.dir = d.dir || '';
383
+ for (const c of d.chars || []) { try { const e = { name: c.stem, idle: await toData(base + encodeURIComponent(c.file)), talk: c.talk ? await toData(base + encodeURIComponent(c.talk)) : null };
374
384
  if (c.role === 'lead') next.lead = e; else if (c.role === 'you') next.you = e; else next.pool.push(e); next.n++; } catch {} }
375
385
  } catch {}
376
386
  CHARS = next;
@@ -383,7 +393,7 @@ const customFor = (id) => { // sub-agents: the best-hashed image no other agen
383
393
  };
384
394
  function renderCharInfo() {
385
395
  const el = $('#charInfo'); if (!el) return;
386
- el.innerHTML = CHARS.n ? `✓ ${CHARS.n} ${esc(L('personnage(s) personnalisé(s) chargé(s)', 'custom character(s) loaded'))}` : `${esc(L('Déposez vos images (PNG) dans ce dossier pour remplacer les personnages :', 'Drop your pixel art (PNG) in this folder to replace the characters:'))}<br><code style="word-break:break-all">${esc(CHARS.dir)}</code><br>${esc(L('lead.png = le chef · you.png = vous · les autres = les sous-agents · nom-talk.png = bouche ouverte', 'lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth'))}`;
396
+ el.innerHTML = CHARS.pack === 'none' ? esc(L('Les personnages dessinés par l’application. Choisissez « Mon dossier » pour vos propres images, ou un pack.', 'The characters drawn by the app. Pick “My folder” for your own images, or a pack.')) : CHARS.pack !== 'folder' ? `✓ ${CHARS.n} ${esc(L('personnages du pack', 'characters from the pack'))} · ${esc(L('Pour les modifier, copiez-les dans votre dossier et choisissez « Mon dossier ».', 'To tweak them, copy them into your folder and pick “My folder”.'))}` : CHARS.n ? `✓ ${CHARS.n} ${esc(L('personnage(s) personnalisé(s) chargé(s)', 'custom character(s) loaded'))}` : `${esc(L('Déposez vos images (PNG) dans ce dossier pour remplacer les personnages :', 'Drop your pixel art (PNG) in this folder to replace the characters:'))}<br><code style="word-break:break-all">${esc(CHARS.dir)}</code><br>${esc(L('lead.png = le chef · you.png = vous · les autres = les sous-agents · nom-talk.png = bouche ouverte', 'lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth'))}`;
387
397
  $('#charOpen').style.display = EMBED ? '' : 'none';
388
398
  }
389
399
  function persona(a) { const p = persona0(a); p.custom = customFor(a.id); return p; }
@@ -527,7 +537,7 @@ const speakable = (t) => t.replace(/(?:[\w.-]+\/)+([\w.-]+)/g, (m, f) => f.repla
527
537
  const trMem = new Map(), trPending = new Map();
528
538
  const baseText = (ev, max) => short(ev.text, max || (S.live ? (ev.kind === 'order' ? 220 : 180) : (ev.kind === 'order' ? 200 : 230)));
529
539
  function ensureTr(texts) {
530
- texts = [...new Set(texts)].filter((t) => t && !trMem.has(t));
540
+ texts = [...new Set(texts)].filter((t) => t && !trMem.has(t)).filter((t) => langOf(t) !== 'fr'); // already French: nothing to translate
531
541
  const waits = texts.filter((t) => trPending.has(t)).map((t) => trPending.get(t)), fresh = texts.filter((t) => !trPending.has(t));
532
542
  if (fresh.length) {
533
543
  const pr = fetch('/api/translate', { method: 'POST', body: JSON.stringify({ texts: fresh }) }).then((r) => r.json()).then((d) => {
@@ -713,7 +723,7 @@ const TTS = {
713
723
  for (const l of ['fr', 'en']) this.pool[l] = rankVoices(d.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name)); } catch {}
714
724
  },
715
725
  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; },
716
- rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 * lagFactor() : +$('#speed').value))) / 10) * 10; },
726
+ 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; },
717
727
  buf(text, voice, rate) {
718
728
  const key = this.engine + '|' + voice + '|' + rate + '|' + text;
719
729
  if (!this.cache.has(key)) {
@@ -728,7 +738,7 @@ function warmTTS(i) { // synthesize the next few lines ahead of time
728
738
  if (!TTS.server || !AUD.ctx || !prefs.voice || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') return;
729
739
  let n = 0; for (let k = i; k < S.events.length && n < 4; k++) {
730
740
  const e = S.events[k]; if (quiet(e)) continue; n++;
731
- const p = S.agents[e.agent], v = p && TTS.voiceFor(p, FR() && trMem.has(baseText(e))); if (v) TTS.buf(speakable(spoken(e)), v, TTS.rate()).catch(() => {});
741
+ const p = S.agents[e.agent], txt = speakable(spoken(e)), v = p && TTS.voiceFor(p, frVoice(txt)); if (v) TTS.buf(txt, v, TTS.rate()).catch(() => {});
732
742
  }
733
743
  }
734
744
 
@@ -849,7 +859,8 @@ function captionRun(id, text, totalMs) {
849
859
  for (let i = 1; i < ps.length; i++) { const x = ps[i], when = at; timers.push(setTimeout(() => setCaption(id, x), when)); at += totalMs * x.length / n; }
850
860
  capTimers.set(id, timers);
851
861
  }
852
- async function speakLine(text, p, primary, token, fr = FR()) {
862
+ const frVoice = (t) => (langOf(t) ?? (FR() ? 'fr' : 'en')) === 'fr'; // a French line gets a French voice whatever the interface language, and the other way round
863
+ async function speakLine(text, p, primary, token, fr = frVoice(text)) {
853
864
  captionRun(p.id, text, estMs(text));
854
865
  if (AUD.off() || !prefs.voice) { if (!AUD.off() && prefs.office) AUD.murmur(0.5 + (p.vi % 5) / 10, estMs(text) / 1000); return sleep(estMs(text)); }
855
866
  if (S.hurry) return sleep(estMs(text));
@@ -1250,6 +1261,7 @@ setInterval(() => { // now and then a busy agent has a little thought; never w
1250
1261
  }, 7000);
1251
1262
  function oops(id) { if (!prefs.banter || !S.playing || S.recomputing) return; const t = $('#t-' + id); if (!t || t.style.display === 'none' || (t._oops && Date.now() - t._oops < 4000)) return; t._oops = Date.now(); t.classList.remove('oops'); void t.offsetWidth; t.classList.add('oops'); thought(id, '😬'); }
1252
1263
  function confetti() {
1264
+ S.wrap = true; // the lead is done: if the end of its last message was never read, it is read now (see wrapUp)
1253
1265
  if (!prefs.banter || document.hidden) return; const box = $('#center'), cols = ['#f5c04a', '#7c9cff', '#ff7a6b', '#5ad19a', '#c58bff'];
1254
1266
  for (let i = 0; i < 26; i++) { const c = document.createElement('i'); c.className = 'confetti'; c.style.left = Math.random() * 100 + '%'; c.style.background = cols[i % cols.length]; c.style.animationDelay = Math.random() * 0.6 + 's'; c.style.animationDuration = 1.8 + Math.random() * 1.4 + 's'; box.appendChild(c); setTimeout(() => c.remove(), 4200); }
1255
1267
  }
@@ -1531,7 +1543,7 @@ async function react(ev, token) {
1531
1543
  if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry || !caughtUp()) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line, thanks = false;
1532
1544
  if (ev.kind === 'order') { if (!prefs.banter && seed % 100 > 55) return; who = ev.to; line = prefs.banter ? ackLine(ev) : R.ack[seed % R.ack.length]; }
1533
1545
  else if (ev.kind === 'report') {
1534
- const r = seed % 100, joke = prefs.epi ? 60 : 35; // a remark from someone else, or (a little less often) a word of thanks from whoever receives the report
1546
+ const r = seed % 100, joke = prefs.epi ? 60 : prefs.chat === 'quiet' ? -1 : 35; // a remark from someone else, or (a little less often) a word of thanks from whoever receives the report
1535
1547
  if (r <= joke) { const others = Object.keys(S.status).concat(['main']).filter((x) => x !== ev.agent && S.agents[x] && !S.gone.has(x)); who = others[(seed >>> 3) % others.length]; line = quipMem.get(baseText(ev, 160)) || R.done[seed % R.done.length]; }
1536
1548
  else if (prefs.banter && r <= joke + 25 && ev.to !== 'user' && S.agents[ev.to] && !S.gone.has(ev.to)) { who = ev.to; line = varied('thanks', seed >>> 3); thanks = true; }
1537
1549
  else return;
@@ -1560,10 +1572,18 @@ function varied(kind, seed) { const P = TALK[FR() ? 'fr' : 'en'][kind]; let line
1560
1572
  const thinkerOf = (e) => (e.kind === 'results' ? e.agent : e.kind === 'user' ? 'main' : e.kind === 'report' || e.kind === 'finish' || e.kind === 'order' ? e.to : null);
1561
1573
  const before = (i) => { let k = i - 1; while (k >= 0 && (S.events[k].kind === 'usage' || S.events[k].kind === 'compact')) k--; return S.events[k]; };
1562
1574
  async function ponder(i, token) { // replay: an agent that really took a long time (40 s or more) before this line or call says so first, about every other time
1563
- const ev = S.events[i], p = before(i); if (!prefs.banter || S.boost > 1 || S.hurry || !p || !S.agents[ev.agent] || thinkerOf(p) !== ev.agent || ev.ts - p.ts < 40000 || hash('t' + ev.ts) % 100 > 55) return;
1575
+ const ev = S.events[i], p = before(i); if (!prefs.banter || prefs.chat === 'quiet' || S.boost > 1 || S.hurry || !p || !S.agents[ev.agent] || thinkerOf(p) !== ev.agent || ev.ts - p.ts < 40000 || hash('t' + ev.ts) % 100 > 55) return;
1564
1576
  const line = varied('think', hash(String(ev.ts))); startTalk(ev.agent, line, '💭'); await speakLine(line, S.agents[ev.agent], true, token); stopTalk(ev.agent);
1565
1577
  }
1578
+ async function wrapUp(token) { // live, once the lead is done: the part of its last message that the clipped reading left out, so you know what was done
1579
+ if (!S.wrap || S.speakers.size || !prefs.voice && !prefs.banter) return false; S.wrap = false;
1580
+ let k = S.events.length - 1; while (k >= 0 && !(S.events[k].agent === 'main' && S.events[k].kind === 'say')) k--; if (k < 0) return false; const e = S.events[k], m = S.mainSaid;
1581
+ const full = plain(e.text); let rest = m && m.ev === e ? full.slice(m.said).trim() : full; if (m && m.ev === e && m.full) return false; if (!m || m.ev !== e) rest = full;
1582
+ rest = rest.replace(/^[^\w\p{L}]+/u, ''); if (rest.length < 25) return false; const line = (m && m.ev === e ? L('Pour résumer : ', 'To sum up: ') : '') + short(rest, 520);
1583
+ startTalk('main', line, '🎉'); await speakLine(line, S.agents.main, true, token); stopTalk('main'); return true;
1584
+ }
1566
1585
  async function smallTalk(token) {
1586
+ if (prefs.chat === 'quiet') return; // quiet: nothing to fill the silences, only the real exchanges
1567
1587
  const n = S.fill || 0, last = S.events[S.events.length - 1]; if (!prefs.banter || n > 2 || !last || S.speakers.size || S.hurry || S.boost > 1 || (document.hidden && !S.exporting)) return;
1568
1588
  const quiet = performance.now() - S.spokeAt, here = Object.keys(S.agents).filter((id) => id !== 'user' && $('#t-' + id) && $('#t-' + id).style.display !== 'none'), pick = (a) => a[Math.floor(Math.random() * a.length)];
1569
1589
  const p = before(S.events.length), muser = S.live && !n && !S.mused && p ? thinkerOf(p) : null, think = !!muser && here.includes(muser) && quiet > 6000 && Date.now() - p.ts > 20000 && Date.now() - p.ts < 180000; // live: 20 s without a word from whoever is thinking, once per silence
@@ -1585,11 +1605,11 @@ const SPK = new Set(['say', 'report', 'order', 'user']);
1585
1605
  function aheadSpeakable(i, limit) { let n = 0; for (let k = i, m = Math.min(S.events.length, i + 800); k < m; k++) if (SPK.has(S.events[k].kind) && ++n > limit) break; return n; }
1586
1606
  const isSub = (id) => id !== 'main' && id !== 'user';
1587
1607
  async function run(i) {
1588
- const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; S.spokeAt = performance.now(); S.fill = 0; S.exQ = []; S.errRun = {}; S.syncTs = (S.events[i] && S.events[i].ts) || Date.now(); syncUI(); AUD.init();
1608
+ const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; S.spokeAt = performance.now(); S.fill = 0; S.exQ = []; S.errRun = {}; S.wrap = false; S.mainSaid = null; S.syncTs = (S.events[i] && S.events[i].ts) || Date.now(); syncUI(); AUD.init();
1589
1609
  if (i === 0 && prefs.epi && !S.live) { await card(L('ÉPISODE 1', 'EPISODE 1'), S.title); if (token !== S.token) return; }
1590
1610
  while (token === S.token) {
1591
1611
  const lim = S.stopAt || S.events.length;
1592
- if (i >= lim) { if (S.live && !S.stopAt) { if (!S.idle) { S.idle = true; syncUI(); } await smallTalk(token); await sleep(350); continue; } break; }
1612
+ if (i >= lim) { if (S.live && !S.stopAt) { if (!S.idle) { S.idle = true; syncUI(); } if (await wrapUp(token)) continue; await smallTalk(token); await sleep(350); continue; } break; }
1593
1613
  if (S.idle) { S.idle = false; syncUI(); }
1594
1614
  if (S.live) { // way behind: skip ahead to stay current, but only count what is actually said: sub-agents produce thousands of silent tool events
1595
1615
  const rem = S.events.length - i;
@@ -1605,10 +1625,15 @@ async function run(i) {
1605
1625
  if (!S.live && (ev.kind === 'say' || ev.kind === 'report' || ev.kind === 'order' || ev.kind === 'tools')) { await ponder(i, token); if (token !== S.token) return; }
1606
1626
  if (ev.kind === 'usage' || ev.kind === 'compact') { if (ev.kind === 'compact' && S.live && prefs.banter) toast(`${nameOf(ev.agent)}: ${L('contexte compacté 🗜', 'context compacted 🗜')}`); i++; continue; }
1607
1627
  if (ev.kind === 'finish') { applyStatus(ev); paintStatus(); i++; continue; }
1608
- if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.boost === 1 && S.present === ev.agent && ev.items.some((it) => it.img) ? (S.live ? 900 : 1800) : S.demo ? 900 : S.live ? 30 : 80) / spd()); i++; continue; } // a picture gets a moment on screen
1609
- if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); await smallTalk(token); await sleep((S.demo ? 1100 : S.live ? 110 : 250) / spd()); i++; continue; }
1628
+ const backlog = S.live ? S.events.length - i : 0, quick = S.live ? Math.min(1, 40 / Math.max(1, backlog)) : 1; // live: hundreds of tool events from parallel sub-agents must not take minutes to scroll past
1629
+ if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.boost === 1 && S.present === ev.agent && ev.items.some((it) => it.img) ? (S.live ? 900 : 1800) : S.demo ? 900 : S.live ? 30 * quick : 80) / spd()); i++; continue; } // a picture gets a moment on screen
1630
+ if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); if (quick === 1) await smallTalk(token); await sleep((S.demo ? 1100 : S.live ? 110 * quick : 250) / spd()); i++; continue; }
1610
1631
  const group = [ev]; let j = i + 1; // sub-agents talking back-to-back were running in parallel: they talk over each other
1611
- if (isSub(ev.agent) && ev.kind !== 'order') {
1632
+ if (ev.kind === 'order') { // several orders in a row (a parallel launch): the new agents all appear now, the first order is read, the others are named in one line
1633
+ while (j < Math.min(S.events.length, i + 40) && group.length < 8) { const e2 = S.events[j]; if (e2.kind === 'usage' || e2.kind === 'compact') { tally(e2); j++; continue; } if (e2.kind === 'order' && e2.agent === ev.agent) { group.push(e2); j++; } else break; }
1634
+ if (group.length > 1) { for (const g of group) applyStatus(g); paintStatus(); }
1635
+ }
1636
+ else if (isSub(ev.agent) && ev.kind !== 'order') {
1612
1637
  while (j < Math.min(S.events.length, i + 28) && group.length < 3) {
1613
1638
  const e2 = S.events[j];
1614
1639
  if (e2.kind === 'usage' || e2.kind === 'compact') { tally(e2); j++; continue; }
@@ -1626,11 +1651,18 @@ async function run(i) {
1626
1651
  startTalk(g.agent, line, label);
1627
1652
  if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1628
1653
  if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1629
- return speakLine(clipSpeech(line, S.pace), S.agents[g.agent], true, token, FR() && trMem.has(baseText(g))).then(() => stopTalk(g.agent));
1654
+ const said = clipSpeech(line, S.pace); if (g.agent === 'main' && g.kind === 'say') { const full = plain(g.text), key = said.replace(/…$/, '').trim().slice(-30), pos = key ? full.indexOf(key) : -1, n = pos >= 0 ? pos + key.length : said.length; S.mainSaid = { ev: g, said: n, full: n >= full.length - 5 }; }
1655
+ return speakLine(said, S.agents[g.agent], true, token, frVoice(said)).then(() => stopTalk(g.agent));
1630
1656
  };
1631
- let plays; if (S.live) { plays = []; let chain = Promise.resolve(); for (const g of group) { chain = chain.then(() => (token === S.token ? playOne(g) : null)); plays.push(chain); } } else plays = group.map(playOne); // live: one voice at a time so each can be heard; replay keeps the overlapping chatter
1657
+ let plays;
1658
+ if (ev.kind === 'order' && group.length > 1) { // the first order in full, then "Also Remy on X, Iris on Y"
1659
+ const rest = group.slice(1), line = `${L('Et aussi ', 'Also ')}${rest.map((o) => `${nameOf(o.to)}${L(' sur ', ' on ')}${(o.title || short(o.text, 60)).replace(/[.。]\s*$/, '')}`).join(', ')}.`;
1660
+ setTimeout(() => rest.forEach((o, k) => setTimeout(() => { fly(o.agent, o.to, '📨'); popTile(o.to); }, k * 120)), 30);
1661
+ plays = [playOne(ev).then(() => { if (token !== S.token) return; startTalk(ev.agent, line, ''); return speakLine(clipSpeech(line, S.pace), S.agents[ev.agent], true, token).then(() => stopTalk(ev.agent)); })];
1662
+ }
1663
+ else if (S.live) { plays = []; let chain = Promise.resolve(); for (const g of group) { chain = chain.then(() => (token === S.token ? playOne(g) : null)); plays.push(chain); } } else plays = group.map(playOne); // live: one voice at a time so each can be heard; replay keeps the overlapping chatter
1632
1664
  S.idx = j - 1; paintStatus(); await Promise.all(plays); if (token !== S.token) return;
1633
- for (const g of group) { await converse(g, token); if (token !== S.token) return; }
1665
+ for (const g of (ev.kind === 'order' && group.length > 1 ? [ev] : group)) { await converse(g, token); if (token !== S.token) return; }
1634
1666
  i = j;
1635
1667
  }
1636
1668
  if (token === S.token) {
@@ -1701,7 +1733,9 @@ setInterval(async () => {
1701
1733
  if (d.error) return; if (d.total < S.events.length) { S.cur = null; return; } // transcript was rewritten: reload next tick
1702
1734
  if (!d.events || !d.events.length) return;
1703
1735
  for (const a of d.agents) if (!S.agents[a.id]) { S.agents[a.id] = persona(a); addTile(S.agents[a.id]); }
1704
- const from = S.events.length; S.events = S.events.concat(d.events); $('#seek').max = S.events.length - 1; trNew(from); warmTTS(from); renderLog(false); paintStatus();
1736
+ const from = S.events.length; S.events = S.events.concat(d.events); $('#seek').max = S.events.length - 1; trNew(from); warmTTS(from); renderLog(false);
1737
+ if (S.playing && !S.recomputing) for (const e of d.events) if (e.kind === 'order' && !S.status[e.to]) { S.status[e.to] = 'working'; S.orderTs[e.to] = e.ts || 0; S.gone.delete(e.to); } // a new sub-agent shows up now, while the previous line is still being read
1738
+ paintStatus();
1705
1739
  } catch { if (!S.offline) { S.offline = true; syncUI(); } } finally { polling = false; }
1706
1740
  }, 400);
1707
1741
 
@@ -1907,6 +1941,7 @@ function setView(v) { prefs.view = v; savePrefs(); document.body.classList.toggl
1907
1941
 
1908
1942
  $('#viewSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setView(b.dataset.v); };
1909
1943
  $('#langSel').onchange = () => setLang($('#langSel').value, true);
1944
+ $('#charPack').value = ['none', 'folder', 'starter', 'cats'].includes(prefs.charPack) ? prefs.charPack : 'none'; $('#charPack').onchange = () => { prefs.charPack = $('#charPack').value; savePrefs(); $('#charReload').onclick(); };
1910
1945
  $('#charReload').onclick = async () => { await loadChars(); for (const k in imgCache) delete imgCache[k]; for (const id in S.agents) S.agents[id].custom = null; for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
1911
1946
  $('#charOpen').onclick = () => window.parent.postMessage({ agentStandup: 'openCharacters' }, '*');
1912
1947
  $('#modeSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setMode(b.dataset.m); };
@@ -206,5 +206,15 @@
206
206
  "Normal": "Normal",
207
207
  "nothing to report": "intet at rapportere",
208
208
  "Quiet": "Stille",
209
- "Laptop in front of your own characters": "Bærbar foran dine egne figurer"
209
+ "Laptop in front of your own characters": "Bærbar foran dine egne figurer",
210
+ "Also ": "Også ",
211
+ " on ": " på ",
212
+ "To sum up: ": "Kort sagt: ",
213
+ "Cat pack": "Kattepakke",
214
+ "characters from the pack": "figurer fra pakken",
215
+ "Drawn characters": "Tegnede figurer",
216
+ "Starter pack": "Startpakke",
217
+ "To tweak them, copy them into your folder and pick “My folder”.": "For at tilpasse dem: kopiér dem til din mappe og vælg “Min mappe”.",
218
+ "My folder": "Min mappe",
219
+ "The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "Figurerne tegnet af appen. Vælg “Min mappe” for dine egne billeder, eller en pakke."
210
220
  }
@@ -207,5 +207,15 @@
207
207
  "Normal": "Normal",
208
208
  "nothing to report": "nichts zu melden",
209
209
  "Quiet": "Ruhig",
210
- "Laptop in front of your own characters": "Laptop vor deinen eigenen Figuren"
210
+ "Laptop in front of your own characters": "Laptop vor deinen eigenen Figuren",
211
+ "Also ": "Außerdem ",
212
+ " on ": " an ",
213
+ "To sum up: ": "Zusammengefasst: ",
214
+ "Cat pack": "Katzen-Pack",
215
+ "characters from the pack": "Figuren aus dem Pack",
216
+ "Drawn characters": "Gezeichnete Figuren",
217
+ "Starter pack": "Starter-Pack",
218
+ "To tweak them, copy them into your folder and pick “My folder”.": "Zum Anpassen in deinen Ordner kopieren und „Mein Ordner“ wählen.",
219
+ "My folder": "Mein Ordner",
220
+ "The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "Die von der App gezeichneten Figuren. Wähle „Mein Ordner“ für eigene Bilder oder ein Pack."
211
221
  }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  " full — compaction soon": " full — compaction soon",
3
+ " on ": " on ",
3
4
  "”": "”",
4
5
  "↗ Open in browser": "↗ Open in browser",
5
6
  "⌨ Keyboard shortcuts (?)": "⌨ Keyboard shortcuts (?)",
@@ -14,6 +15,7 @@
14
15
  "Agents": "Agents",
15
16
  "Agents list (left)": "Agents list (left)",
16
17
  "All projects (most recent)": "All projects (most recent)",
18
+ "Also ": "Also ",
17
19
  "Ambience volume": "Ambience volume",
18
20
  "An agent may be waiting for you ✋": "An agent may be waiting for you ✋",
19
21
  "Animal Crossing (babble)": "Animal Crossing (babble)",
@@ -25,7 +27,9 @@
25
27
  "cache write": "cache write",
26
28
  "Call": "Call",
27
29
  "Cartoon voices (real voice)": "Cartoon voices (real voice)",
30
+ "Cat pack": "Cat pack",
28
31
  "Characters": "Characters",
32
+ "characters from the pack": "characters from the pack",
29
33
  "Chatter between agents": "Chatter between agents",
30
34
  "Claude Code is not logged in: run “claude” once in a terminal to sign in.": "Claude Code is not logged in: run “claude” once in a terminal to sign in.",
31
35
  "Claude Code not found: install it (npm i -g @anthropic-ai/claude-code) or set its path in the “agentStandup.claudePath” setting.": "Claude Code not found: install it (npm i -g @anthropic-ai/claude-code) or set its path in the “agentStandup.claudePath” setting.",
@@ -46,6 +50,7 @@
46
50
  "Done": "Done",
47
51
  "Done. ": "Done. ",
48
52
  "Downloading, you can carry on: system voices are used meanwhile.": "Downloading, you can carry on: system voices are used meanwhile.",
53
+ "Drawn characters": "Drawn characters",
49
54
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Drop your pixel art (PNG) in this folder to replace the characters:",
50
55
  "duration": "duration",
51
56
  "Editing ": "Editing ",
@@ -91,6 +96,7 @@
91
96
  "may be waiting for your approval": "may be waiting for your approval",
92
97
  "Music volume": "Music volume",
93
98
  "Mute / unmute": "Mute / unmute",
99
+ "My folder": "My folder",
94
100
  "Natural voices": "Natural voices",
95
101
  "need you": "need you",
96
102
  "needs you?": "needs you?",
@@ -161,15 +167,19 @@
161
167
  "stalled: a sub-agent has been silent for a long time": "stalled: a sub-agent has been silent for a long time",
162
168
  "standing by": "standing by",
163
169
  "Start Claude Code in this folder — the call will start by itself.": "Start Claude Code in this folder — the call will start by itself.",
170
+ "Starter pack": "Starter pack",
164
171
  "sub-agents active": "sub-agents active",
165
172
  "task": "task",
166
173
  "Tasks": "Tasks",
174
+ "The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "The characters drawn by the app. Pick “My folder” for your own images, or a pack.",
167
175
  "THE END": "THE END",
168
176
  "The engine stopped: system voices are used instead.": "The engine stopped: system voices are used instead.",
169
177
  "This dev was replaced by his own agents. Spare a coffee for his career change? ☕": "This dev was replaced by his own agents. Spare a coffee for his career change? ☕",
170
178
  "This help": "This help",
171
179
  "Time": "Time",
172
180
  "to": "to",
181
+ "To sum up: ": "To sum up: ",
182
+ "To tweak them, copy them into your folder and pick “My folder”.": "To tweak them, copy them into your folder and pick “My folder”.",
173
183
  "tokens": "tokens",
174
184
  "Tokens": "Tokens",
175
185
  "Tokens recorded by Claude Code (counted once per message, not a bill)": "Tokens recorded by Claude Code (counted once per message, not a bill)",
@@ -207,5 +207,15 @@
207
207
  "Normal": "Normal",
208
208
  "nothing to report": "nada que informar",
209
209
  "Quiet": "Tranquilo",
210
- "Laptop in front of your own characters": "Portátil delante de tus propios personajes"
210
+ "Laptop in front of your own characters": "Portátil delante de tus propios personajes",
211
+ "Also ": "También ",
212
+ " on ": " en ",
213
+ "To sum up: ": "En resumen: ",
214
+ "Cat pack": "Pack de gatos",
215
+ "characters from the pack": "personajes del pack",
216
+ "Drawn characters": "Personajes dibujados",
217
+ "Starter pack": "Pack inicial",
218
+ "To tweak them, copy them into your folder and pick “My folder”.": "Para retocarlos, cópialos en tu carpeta y elige «Mi carpeta».",
219
+ "My folder": "Mi carpeta",
220
+ "The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "Los personajes dibujados por la aplicación. Elige «Mi carpeta» para tus propias imágenes, o un pack."
211
221
  }
@@ -207,5 +207,15 @@
207
207
  "Normal": "ふつう",
208
208
  "nothing to report": "報告事項なし",
209
209
  "Quiet": "静か",
210
- "Laptop in front of your own characters": "自作キャラクターの前にノートパソコン"
210
+ "Laptop in front of your own characters": "自作キャラクターの前にノートパソコン",
211
+ "Also ": "それから ",
212
+ " on ": " は ",
213
+ "To sum up: ": "まとめると: ",
214
+ "Cat pack": "ネコパック",
215
+ "characters from the pack": "パックのキャラクター",
216
+ "Drawn characters": "描画キャラクター",
217
+ "Starter pack": "スターターパック",
218
+ "To tweak them, copy them into your folder and pick “My folder”.": "調整するには、自分のフォルダーにコピーして「自分のフォルダー」を選んでください。",
219
+ "My folder": "自分のフォルダー",
220
+ "The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "アプリが描画するキャラクターです。自分の画像を使うには「自分のフォルダー」、またはパックを選んでください。"
211
221
  }
@@ -207,5 +207,15 @@
207
207
  "Normal": "Normal",
208
208
  "nothing to report": "nada a reportar",
209
209
  "Quiet": "Quieto",
210
- "Laptop in front of your own characters": "Notebook na frente dos seus personagens"
210
+ "Laptop in front of your own characters": "Notebook na frente dos seus personagens",
211
+ "Also ": "Também ",
212
+ " on ": " em ",
213
+ "To sum up: ": "Resumindo: ",
214
+ "Cat pack": "Pacote de gatos",
215
+ "characters from the pack": "personagens do pacote",
216
+ "Drawn characters": "Personagens desenhados",
217
+ "Starter pack": "Pacote inicial",
218
+ "To tweak them, copy them into your folder and pick “My folder”.": "Para ajustá-los, copie-os para sua pasta e escolha “Minha pasta”.",
219
+ "My folder": "Minha pasta",
220
+ "The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "Os personagens desenhados pelo aplicativo. Escolha “Minha pasta” para suas próprias imagens, ou um pacote."
211
221
  }
@@ -207,5 +207,15 @@
207
207
  "Normal": "正常",
208
208
  "nothing to report": "没什么可报告的",
209
209
  "Quiet": "安静",
210
- "Laptop in front of your own characters": "在自定义角色前显示笔记本电脑"
210
+ "Laptop in front of your own characters": "在自定义角色前显示笔记本电脑",
211
+ "Also ": "另外 ",
212
+ " on ": " 负责 ",
213
+ "To sum up: ": "总结一下: ",
214
+ "Cat pack": "猫咪包",
215
+ "characters from the pack": "来自角色包的角色",
216
+ "Drawn characters": "绘制的角色",
217
+ "Starter pack": "入门包",
218
+ "To tweak them, copy them into your folder and pick “My folder”.": "若要修改,请将它们复制到你的文件夹并选择“我的文件夹”。",
219
+ "My folder": "我的文件夹",
220
+ "The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "由应用绘制的角色。选择“我的文件夹”使用你自己的图片,或选择一个角色包。"
211
221
  }
@@ -207,5 +207,15 @@
207
207
  "Normal": "正常",
208
208
  "nothing to report": "沒什麼好回報的",
209
209
  "Quiet": "安靜",
210
- "Laptop in front of your own characters": "在自訂角色前顯示筆記型電腦"
210
+ "Laptop in front of your own characters": "在自訂角色前顯示筆記型電腦",
211
+ "Also ": "另外 ",
212
+ " on ": " 負責 ",
213
+ "To sum up: ": "總結一下: ",
214
+ "Cat pack": "貓咪包",
215
+ "characters from the pack": "來自角色包的角色",
216
+ "Drawn characters": "繪製的角色",
217
+ "Starter pack": "入門包",
218
+ "To tweak them, copy them into your folder and pick “My folder”.": "若要修改,請將它們複製到你的資料夾並選擇「我的資料夾」。",
219
+ "My folder": "我的資料夾",
220
+ "The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "由應用程式繪製的角色。選擇「我的資料夾」使用你自己的圖片,或選擇一個角色包。"
211
221
  }
package/server.js CHANGED
@@ -503,8 +503,10 @@ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css
503
503
  const CHAR_NAME = /^[\w][\w .()\-]{0,78}\.(png|gif|webp|jpe?g)$/i, CHAR_MAX = 300 * 1024, CHAR_TYPES = { png: 'image/png', gif: 'image/gif', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg' };
504
504
  const charactersDir = () => process.env.AGENT_STANDUP_CHARACTERS || path.join(os.homedir(), '.agent-standup', 'characters');
505
505
  // lstat, not stat: a symlink is not a regular file, so it is skipped and never served
506
- function listCharacters() {
507
- const dir = charactersDir(); let names = []; try { names = fs.readdirSync(dir); } catch {}
506
+ // Built-in character packs, shipped with the app (examples/<folder>): picked in ⚙ → Characters, or copied into the person's own folder as a start
507
+ const PACKS = { starter: 'characters', cats: 'cats' }, packDir = (name) => (PACKS[name] ? path.join(__dirname, 'examples', PACKS[name]) : null);
508
+ function listCharacters(pack) {
509
+ const dir = pack ? packDir(pack) : charactersDir(); let names = []; if (!dir) return { dir: '', max: CHAR_MAX, chars: [] }; try { names = fs.readdirSync(dir); } catch {}
508
510
  const ok = names.filter((f) => CHAR_NAME.test(f)).filter((f) => { try { const st = fs.lstatSync(path.join(dir, f)); return st.isFile() && st.size <= CHAR_MAX; } catch { return false; } }).slice(0, 80);
509
511
  const stem = (f) => f.replace(/\.[^.]+$/, ''), by = new Map(ok.map((f) => [stem(f).toLowerCase(), f]));
510
512
  const chars = ok.filter((f) => !/-talk$/i.test(stem(f))).map((f) => { const st = stem(f), l = st.toLowerCase();
@@ -563,7 +565,8 @@ function start(port = PORT) {
563
565
  if (url.pathname === '/api/prefs') return json(readPrefs() || {});
564
566
  if (url.pathname === '/api/current') return json(currentSession(url.searchParams.get('proj') || '', url.searchParams.get('id') || '', { proj: url.searchParams.get('sproj') || '', id: url.searchParams.get('sid') || '' }));
565
567
  if (url.pathname === '/api/board') return json(boardRows());
566
- if (url.pathname === '/api/characters') return json(listCharacters());
568
+ if (url.pathname === '/api/characters') return json(listCharacters(url.searchParams.get('pack') || ''));
569
+ if (url.pathname === '/api/packs') return json(Object.keys(PACKS).map((name) => ({ name, n: listCharacters(name).chars.length })));
567
570
  if (url.pathname === '/api/media') {
568
571
  const q = (k) => url.searchParams.get(k) || '', SAFE = /^[\w.\-]+$/, n = +q('n') || 0, bad = [q('proj'), q('id'), q('agent'), q('tool')].some((v) => !SAFE.test(v) || v === '..' || v.length > 200);
569
572
  const file = bad || !Number.isInteger(n) || n < 0 || n > 9 ? null : mediaFile(q('proj'), q('id'), q('agent')), im = file && toolImage(file, q('tool'), n);
@@ -585,9 +588,10 @@ function start(port = PORT) {
585
588
  }
586
589
  return json({ id: tl.id, agents: tl.agents, total: tl.events.length, events: tl.events.slice(+after) });
587
590
  }
588
- if (url.pathname.startsWith('/characters/')) { // only plain image files of the characters folder, by exact name
589
- let f = ''; try { f = decodeURIComponent(url.pathname.slice('/characters/'.length)); } catch {}
590
- const dir = charactersDir(), fp = path.join(dir, f);
591
+ const pk = url.pathname.match(/^\/packs\/([a-z]+)\/(.*)$/);
592
+ if (url.pathname.startsWith('/characters/') || (pk && packDir(pk[1]))) { // only plain image files of the characters folder (or of a built-in pack), by exact name
593
+ let f = ''; try { f = decodeURIComponent(pk ? pk[2] : url.pathname.slice('/characters/'.length)); } catch {}
594
+ const dir = pk ? packDir(pk[1]) : charactersDir(), fp = path.join(dir, f);
591
595
  try { const ls = fs.lstatSync(fp); if (!CHAR_NAME.test(f) || !fp.startsWith(dir + path.sep) || !ls.isFile() || ls.size > CHAR_MAX) throw 0; } catch { res.writeHead(404); return res.end('not found'); }
592
596
  res.writeHead(200, { 'content-type': CHAR_TYPES[f.split('.').pop().toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-cache' }); return fs.createReadStream(fp).pipe(res);
593
597
  }