@double_geste/agent-standup 1.23.1 → 1.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/index.html +120 -42
- package/public/locales/de.json +3 -0
- package/public/locales/en.json +3 -0
- package/public/locales/es.json +3 -0
- package/public/locales/ja.json +3 -0
- package/public/locales/pt-BR.json +3 -0
- package/public/locales/zh-CN.json +3 -0
- package/public/locales/zh-TW.json +3 -0
- package/server.js +40 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@double_geste/agent-standup",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.1",
|
|
4
4
|
"description": "Your Claude Code agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-standup": "bin/agent-standup.js"
|
package/public/index.html
CHANGED
|
@@ -85,11 +85,12 @@
|
|
|
85
85
|
.prop.cheer { animation:cheer 1.1s ease-out both } @keyframes cheer { 0% { transform:scale(.3) translateY(10px); opacity:0 } 40% { transform:scale(1.25); opacity:1 } 100% { transform:translateY(-14px); opacity:0 } }
|
|
86
86
|
/* ---------- screen sharing (presentation mode) ---------- */
|
|
87
87
|
#present { display:none; flex:none; position:relative; height:clamp(190px,46vh,430px); background:radial-gradient(ellipse at 50% 0,#1b2033,#0b0d14); border-bottom:1px solid var(--line); padding:12px 16px; justify-content:center }
|
|
88
|
-
#present.on { display:flex; animation:fadein .3s }
|
|
88
|
+
#present.on { display:flex; animation:fadein .3s } #present.pic { height:clamp(220px,60vh,640px) }
|
|
89
89
|
.pwin { flex:0 1 900px; display:flex; flex-direction:column; border-radius:10px; overflow:hidden; background:#0d1117; box-shadow:0 18px 50px rgba(0,0,0,.6), 0 0 0 2px rgba(240,160,32,.55) }
|
|
90
90
|
.pbar { display:flex; align-items:center; gap:6px; padding:7px 10px; background:#1c1f2b; font-size:11.5px; color:#9aa0b4 } .pbar i { width:10px; height:10px; border-radius:50%; display:inline-block }
|
|
91
91
|
.pbar .r { background:#ff5f57 } .pbar .y { background:#febc2e } .pbar .g { background:#28c840 } #ptitle { margin:0 0 0 14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0 }
|
|
92
92
|
#pbody { flex:1; overflow:hidden; padding:10px 14px; font:12.5px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; color:#d6dae6 } .pwin.editor #pbody { background:#1a1b26; padding:0 }
|
|
93
|
+
.pwin.image #pbody { display:flex; gap:8px; align-items:center; justify-content:center; background:#0b0c11; padding:8px } .pwin.image #pbody img { flex:0 1 auto; min-width:0; max-width:100%; max-height:100%; object-fit:contain; border-radius:4px } .pmore { color:#9aa0b4; font-size:12px } .dimg { display:block; max-width:100%; border-radius:6px; margin:4px 0 }
|
|
93
94
|
.ptab { display:flex; align-items:center; gap:10px; background:#16161e; padding:5px 12px; border-bottom:1px solid #2a2b3d; font:11.5px -apple-system,system-ui,sans-serif } .ptab span { background:#1a1b26; padding:3px 10px; border-radius:6px 6px 0 0; color:#e9eaf0 } .ptab em { color:#6b7089; font-style:normal }
|
|
94
95
|
.ln { display:flex; gap:10px; padding:0 12px; white-space:pre-wrap; word-break:break-all } .ln b { width:12px; flex:none; color:#7a7f98; font-weight:400 } .ln.add { background:rgba(63,185,80,.16); color:#9be9a8 } .ln.del { background:rgba(248,81,73,.16); color:#ffa198 }
|
|
95
96
|
.tl { white-space:pre-wrap; word-break:break-word } .tl .ps { color:#7ee787 } .tl.cmd { color:#e6edf3 } .tl.tool { color:#8b95b0 } .dotg { color:#58a6ff }
|
|
@@ -181,7 +182,7 @@
|
|
|
181
182
|
#gate h2, #empty h2 { margin:0; font:800 22px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif } #gate p, #empty p { margin:0; color:var(--dim); max-width:380px }
|
|
182
183
|
.cta { background:var(--accent); color:#0b1020; font-weight:700; padding:10px 22px; border-radius:99px; font-size:14px } .cta:hover { filter:brightness(1.1) }
|
|
183
184
|
.btn { background:var(--panel2); border:1px solid var(--line); padding:6px 12px; border-radius:8px } .btn:hover { border-color:var(--accent) }
|
|
184
|
-
#menu { position:absolute; top:52px; right:10px; z-index:12; width:280px; background:var(--panel); border:1px solid var(--line); border-radius:12px; box-shadow:0 14px 40px rgba(0,0,0,.55); padding:6px; display:none }
|
|
185
|
+
#menu { position:absolute; top:52px; right:10px; z-index:12; width:280px; background:var(--panel); border:1px solid var(--line); border-radius:12px; box-shadow:0 14px 40px rgba(0,0,0,.55); padding:6px; display:none; max-height:calc(100vh - 62px); max-height:calc(100dvh - 62px); overflow-y:auto; overscroll-behavior:contain; scrollbar-width:thin } /* never taller than the window: it scrolls instead of being cut off */
|
|
185
186
|
.coffee-btn { color:#ffd24a !important; background:rgba(255,210,74,.12); box-shadow:inset 0 0 0 1px rgba(255,210,74,.35) } .coffee-btn:hover { background:rgba(255,210,74,.22) !important }
|
|
186
187
|
.glink { color:var(--dim); font-size:12.5px; text-decoration:underline; text-underline-offset:3px } .glink:hover { color:#ffd24a }
|
|
187
188
|
#mCoffee { background:rgba(255,210,74,.10); box-shadow:inset 0 0 0 1px rgba(255,210,74,.3); margin-top:4px } #mCoffee:hover { background:rgba(255,210,74,.2) }
|
|
@@ -258,6 +259,8 @@
|
|
|
258
259
|
<button class="mi" id="mCoffee" style="display:none"><span id="mCoffeeT"></span></button>
|
|
259
260
|
<button class="mi" id="mRecap"><span data-fr="📋 Récapitulatif" data-en="📋 Recap">📋 Récapitulatif</span></button>
|
|
260
261
|
<button class="mi" id="mExport"><span data-fr="🎞 Exporter la vidéo" data-en="🎞 Export video">🎞 Exporter la vidéo</span></button>
|
|
262
|
+
<button class="mi" id="mClip"><span data-fr="🎬 Clip à partager (25 s)" data-en="🎬 Clip to share (25 s)">🎬 Clip à partager (25 s)</span></button>
|
|
263
|
+
<button class="mi" data-t="hideCode"><span data-fr="Masquer code et fichiers dans les vidéos" data-en="Hide code & file names in videos">Masquer code et fichiers dans les vidéos</span><i class="sw"></i></button>
|
|
261
264
|
<div class="keys">Space ⏯ · M 🔇 · T tree · R recap · L log · Esc ✕</div>
|
|
262
265
|
<button class="mi" id="mBrowser" style="display:none"><span data-fr="↗ Ouvrir dans le navigateur" data-en="↗ Open in browser">↗ Ouvrir dans le navigateur</span></button>
|
|
263
266
|
</div>
|
|
@@ -298,7 +301,7 @@ const Q = new URLSearchParams(location.search);
|
|
|
298
301
|
const EMBED = Q.get('embed') === '1' || window.parent !== window;
|
|
299
302
|
if (Q.get('pin')) S.pin = Q.get('pin');
|
|
300
303
|
|
|
301
|
-
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' };
|
|
304
|
+
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 };
|
|
302
305
|
{ 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
|
|
303
306
|
const sv = window.SAVED_PREFS && typeof window.SAVED_PREFS === 'object' ? window.SAVED_PREFS : {}; Object.assign(prefs, (sv._t || 0) > (loc._t || 0) ? sv : loc); }
|
|
304
307
|
if (prefs.vstyle === 'babble') prefs.vstyle = 'animal', prefs.vstyleChosen = true;
|
|
@@ -1048,7 +1051,7 @@ function pushTerm(id, tool) {
|
|
|
1048
1051
|
}
|
|
1049
1052
|
function applyResults(ev, silent) { // the output of a tool call arrives
|
|
1050
1053
|
const buf = S.term[ev.agent] || []; let last = null;
|
|
1051
|
-
for (const it of ev.items) for (let i = buf.length - 1; i >= 0; i--) if (buf[i].id === it.id) { Object.assign(buf[i], { out: it.out, err: it.err, end: ev.ts }); last = buf[i]; break; }
|
|
1054
|
+
for (const it of ev.items) for (let i = buf.length - 1; i >= 0; i--) if (buf[i].id === it.id) { Object.assign(buf[i], { out: it.out, err: it.err, end: ev.ts, img: it.img || 0, picAt: it.img && !silent ? performance.now() : 0 }); last = buf[i]; break; }
|
|
1052
1055
|
if (S.run[ev.agent] && ev.items.some((it) => it.id === S.run[ev.agent].id)) delete S.run[ev.agent];
|
|
1053
1056
|
if (silent || !last) return;
|
|
1054
1057
|
setTicker(ev.agent, last); if (S.present === ev.agent) { renderPresent(); armPresent(); }
|
|
@@ -1077,8 +1080,11 @@ function statOf(e, id) {
|
|
|
1077
1080
|
if (e.end == null) { const r = S.run[id], el = r ? performance.now() - r.start : 0; return `⠋ ${e.tool.name} · ${L('en cours', 'running')}${el > 1500 ? ' ' + fmtDur(el) : ''}`; }
|
|
1078
1081
|
return `${e.err ? '✗' : '✓'} ${e.tool.name} · ${fmtDur(e.end - e.ts)}`;
|
|
1079
1082
|
}
|
|
1083
|
+
const mediaUrl = (agent, tool, n) => `/api/media?proj=${encodeURIComponent(S.cur.proj)}&id=${encodeURIComponent(S.cur.id)}&agent=${encodeURIComponent(agent)}&tool=${encodeURIComponent(tool)}&n=${n}`;
|
|
1080
1084
|
function termModel(id) {
|
|
1081
1085
|
const buf = S.term[id] || [], last = buf[buf.length - 1], lines = [], more = (n) => ({ c: 'dim', t: `… +${n} ${L('lignes', 'lines')}` });
|
|
1086
|
+
const pic = S.cur && buf.slice(-12).reverse().find((e) => e.img), fresh = pic && performance.now() - pic.picAt < (S.live ? 8000 : 2500 / spd()); // a picture a tool returned (screenshot, image file) stays up for a few seconds whatever comes next, then for the next few calls: a screenshot is usually followed by a click or two
|
|
1087
|
+
if (pic && (fresh || (buf.length - buf.indexOf(pic) <= 4 && (pic === last || !/^(edit|write|read)$/.test(last.k))))) return { view: 'image', title: pic.k === 'read' ? base(pic.tool.d.file) : pic.tool.name.split('__').pop(), imgs: Array.from({ length: Math.min(pic.img, 3) }, (_, n) => mediaUrl(id, pic.id, n)), more: pic.img - 3, lines, stat: statOf(last, id) };
|
|
1082
1088
|
if (last && (last.k === 'edit' || last.k === 'write' || last.k === 'read')) {
|
|
1083
1089
|
const d = last.tool.d, rows = (txt, c) => String(txt || '').split('\n').slice(0, 14).map((t) => ({ c, t }));
|
|
1084
1090
|
if (last.k === 'read') {
|
|
@@ -1104,20 +1110,21 @@ const lineHtml = (l, view) => view === 'editor'
|
|
|
1104
1110
|
// ---------- screen sharing: a real video-call presentation (window + presenter camera) ----------
|
|
1105
1111
|
function renderPresent() {
|
|
1106
1112
|
const id = S.present, p = S.agents[id]; if (!p) return; const m = termModel(id);
|
|
1107
|
-
$('#present .pwin').className = 'pwin ' + (m.view === '
|
|
1108
|
-
$('#ptitle').textContent = m.view === '
|
|
1109
|
-
const pb = $('#pbody');
|
|
1110
|
-
|
|
1111
|
-
pb.
|
|
1113
|
+
$('#present .pwin').className = 'pwin ' + (m.view === 'term' ? 'term' : m.view); if ($('#present').classList.toggle('pic', m.view === 'image') !== S.picUp) { S.picUp = m.view === 'image'; layout(); } // a picture gets a taller window
|
|
1114
|
+
$('#ptitle').textContent = m.view === 'term' ? `${p.name} — zsh — 96×24` : m.title;
|
|
1115
|
+
const pb = $('#pbody'), shown = m.view === 'image' ? m.imgs.join(' ') : '';
|
|
1116
|
+
if (shown) { if (pb.dataset.img !== shown) pb.innerHTML = m.imgs.map((u) => `<img src="${esc(u)}" alt="">`).join('') + (m.more > 0 ? `<span class="pmore">+${m.more}</span>` : ''); } // the same pictures are left in place: redrawing them would make them blink
|
|
1117
|
+
else pb.innerHTML = (m.view === 'editor' ? `<div class="ptab"><span>${esc(m.tab.file)}</span><em>${esc(m.tab.path)}</em></div>` : '') + m.lines.map((l) => lineHtml(l, m.view)).join('') + (m.view === 'term' ? '<div class="tl"><span class="ps">❯</span> <span class="cursor"></span></div>' : '');
|
|
1118
|
+
pb.dataset.img = shown; pb.scrollTop = m.view === 'term' ? pb.scrollHeight : 0; $('#pstat').textContent = m.stat;
|
|
1112
1119
|
const td = S.todos[id], el = $('#ptodo'); el.style.display = td && td.length ? '' : 'none'; $('#present').classList.toggle('has-todo', !!(td && td.length));
|
|
1113
1120
|
if (td) el.innerHTML = `<h5>${L('Tâches', 'Tasks')} · ${td.filter((x) => x.s === 'completed').length}/${td.length}</h5>` + td.map((x) => `<div class="td ${x.s}"><i>${x.s === 'completed' ? '☑' : x.s === 'in_progress' ? '◐' : '☐'}</i>${esc(x.c)}</div>`).join('');
|
|
1114
1121
|
$('#pbadge').textContent = `🖥 ${p.name} ${L('partage son écran', 'is sharing their screen')}`;
|
|
1115
1122
|
if (S.camFor !== id) { $('#pcam').innerHTML = avatarSVG(p, undefined, 'c' + p.gid + '_') + `<div class="tag"><b>${esc(p.name)}</b></div>`; S.camFor = id; }
|
|
1116
1123
|
$('#pcam').classList.toggle('speaking', S.speakers.has(id));
|
|
1117
1124
|
}
|
|
1118
|
-
function armPresent() { // stays up while a command is still running, then ~4 s after the last action
|
|
1125
|
+
function armPresent() { // stays up while a command is still running, then ~4 s after the last action (~9 s when it shows a picture)
|
|
1119
1126
|
clearTimeout(presentTimer); if (S.hold) return;
|
|
1120
|
-
presentTimer = setTimeout(() => { if (S.present && S.run[S.present] && S.playing) armPresent(); else hidePresent(); }, 4200 / Math.max(1, spd() / 1.2));
|
|
1127
|
+
presentTimer = setTimeout(() => { if (S.present && S.run[S.present] && S.playing) armPresent(); else hidePresent(); }, ($('#present').classList.contains('pic') ? 9000 : 4200) / Math.max(1, spd() / 1.2));
|
|
1121
1128
|
}
|
|
1122
1129
|
function showPresent(id) {
|
|
1123
1130
|
if (S.boost > 1 || (S.speakers.size && !S.speakers.has(id))) return; // catching up, or someone else has the floor
|
|
@@ -1177,6 +1184,7 @@ const mouths = new Map();
|
|
|
1177
1184
|
function startTalk(id, text, label) {
|
|
1178
1185
|
const t = $('#t-' + id); if (!t) return;
|
|
1179
1186
|
if (S.present && S.present !== id) hidePresent();
|
|
1187
|
+
S.spokeAt = performance.now(); S.fill = 0; S.mused = false;
|
|
1180
1188
|
if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true);
|
|
1181
1189
|
const shown0 = pieces(text), first = shown0.length > 1 ? shown0[0] : text; const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(first);
|
|
1182
1190
|
if (S.present === id) { $('#pcap').textContent = first; $('#pcam').classList.add('speaking'); }
|
|
@@ -1200,7 +1208,7 @@ function confetti() {
|
|
|
1200
1208
|
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); }
|
|
1201
1209
|
}
|
|
1202
1210
|
function stopTalk(id) {
|
|
1203
|
-
captionClear(id);
|
|
1211
|
+
captionClear(id); S.spokeAt = performance.now();
|
|
1204
1212
|
clearInterval(mouths.get(id)); mouths.delete(id); S.speakers.delete(id); if (!S.speakers.size) AUD.duck(false); if (S.focus === id && S.speakers.size) setFocus([...S.speakers][0]);
|
|
1205
1213
|
if (S.present === id) { $('#pcap').textContent = ''; $('#pcam').classList.remove('speaking'); const pm = $('#pcam .mouthE'); if (pm) { pm.setAttribute('ry', .9); pm.setAttribute('rx', 4.6); } }
|
|
1206
1214
|
const t = $('#t-' + id); if (!t) return;
|
|
@@ -1242,7 +1250,7 @@ function renderDetail() {
|
|
|
1242
1250
|
const u = S.usage[id], tokHtml = u && tokTotal(u) ? `<h3>${L('Jetons', 'Tokens')} · ⚡ ${fmtTok(tokTotal(u))}</h3><div class="dim">${L('entrée', 'input')} ${fmtTok(u.i)} · ${L('sortie', 'output')} ${fmtTok(u.o)} · ${L('écriture cache', 'cache write')} ${fmtTok(u.w)} · ${L('lecture cache', 'cache read')} ${fmtTok(u.r)}</div>` : '';
|
|
1243
1251
|
$('#dbody').innerHTML = tokHtml + (order ? `<h3>${L('Consigne de', 'Prompt from')} ${esc(nameOf(order.agent))}</h3><pre>${esc(order.text)}</pre>` : '') +
|
|
1244
1252
|
`<h3>${L('Outils', 'Tools')} (${tools.length})</h3><div>${Object.entries(counts).map(([n, c]) => `${esc(n)} ×${c}`).join(' · ') || '—'}</div>` +
|
|
1245
|
-
`<h3>${L('Activité récente', 'Recent activity')}</h3>` + (S.term[id] || []).slice(-30).reverse().map((e) => `<details><summary>${e.end == null ? '⠋' : e.err ? '✗' : '✓'} ${esc(e.tool.name)} <span class="dim">${e.end != null ? fmtDur(e.end - e.ts) : L('en cours', 'running')}</span> — ${esc((e.tool.arg || '').slice(-60))}</summary><pre>${esc((e.tool.d && e.tool.d.kind === 'cmd' ? '$ ' + e.tool.d.text + '\n' : '') + (e.out || ''))}</pre
|
|
1253
|
+
`<h3>${L('Activité récente', 'Recent activity')}</h3>` + (S.term[id] || []).slice(-30).reverse().map((e) => `<details><summary>${e.end == null ? '⠋' : e.err ? '✗' : '✓'} ${esc(e.tool.name)} <span class="dim">${e.end != null ? fmtDur(e.end - e.ts) : L('en cours', 'running')}</span> — ${esc((e.tool.arg || '').slice(-60))}</summary><pre>${esc((e.tool.d && e.tool.d.kind === 'cmd' ? '$ ' + e.tool.d.text + '\n' : '') + (e.out || ''))}</pre>${e.img && S.cur ? `<img class="dimg" src="${esc(mediaUrl(id, e.id, 0))}" alt="" loading="lazy">` : ''}</details>`).join('') +
|
|
1246
1254
|
evs.filter((e) => e.agent === id && (e.kind === 'say' || e.kind === 'report')).map((e) => `<h3>${e.kind === 'report' ? L('Rapport final', 'Final report') : L('A dit', 'Said')}</h3><pre>${esc(e.text)}</pre>`).join('');
|
|
1247
1255
|
}
|
|
1248
1256
|
$('#dclose').onclick = () => { S.detail = null; $('#detail').classList.remove('on'); };
|
|
@@ -1302,12 +1310,18 @@ function computeHighlights() {
|
|
|
1302
1310
|
if (e.kind !== 'compact') prevT = e.t;
|
|
1303
1311
|
const d = loopStep(R, e); if (d && loops.length < 3 && !loops.some((l) => l.agent === e.agent)) loops.push({ i, agent: e.agent, d });
|
|
1304
1312
|
});
|
|
1305
|
-
if (firstErr >= 0) out.push({ i: firstErr, t: '✗ ' + L('première erreur', 'first error') });
|
|
1306
|
-
if (gap.ms >= 30000) out.push({ i: gap.i, t: '⏱ ' + L('plus longue attente ', 'longest wait ') + fmtDur(gap.ms) });
|
|
1307
|
-
if (comp.length) out.push({ i: comp[0], t: `🗜 ${L('compactage', 'compaction')}${comp.length > 1 ? ' ×' + comp.length : ''}` });
|
|
1308
|
-
loops.forEach((l) => out.push({ i: l.i, t: `🔁 ${nameOf(l.agent)} ${l.d.kind === 'errors' ? L('échoue en boucle ?', 'failing in a loop?') : L('boucle ?', 'looping?')}` }));
|
|
1313
|
+
if (firstErr >= 0) out.push({ i: firstErr, k: 'err', t: '✗ ' + L('première erreur', 'first error') });
|
|
1314
|
+
if (gap.ms >= 30000) out.push({ i: gap.i, k: 'wait', t: '⏱ ' + L('plus longue attente ', 'longest wait ') + fmtDur(gap.ms) });
|
|
1315
|
+
if (comp.length) out.push({ i: comp[0], k: 'compact', t: `🗜 ${L('compactage', 'compaction')}${comp.length > 1 ? ' ×' + comp.length : ''}` });
|
|
1316
|
+
loops.forEach((l) => out.push({ i: l.i, k: 'loop', t: `🔁 ${nameOf(l.agent)} ${l.d.kind === 'errors' ? L('échoue en boucle ?', 'failing in a loop?') : L('boucle ?', 'looping?')}` }));
|
|
1309
1317
|
out.sort((a, b) => a.i - b.i); return out;
|
|
1310
1318
|
}
|
|
1319
|
+
// Where a short clip starts: one spoken line before the first error or loop when there is one (so it is seen coming), otherwise before the lead first hands out work.
|
|
1320
|
+
function clipStart() {
|
|
1321
|
+
const ev = S.events, h = computeHighlights().find((x) => x.k === 'err' || x.k === 'loop'), at = h ? h.i : ev.findIndex((e) => e.kind === 'order'); if (at < 0) return 0;
|
|
1322
|
+
let i = at; while (i > 0 && at - i < 12 && !SPK.has(ev[--i].kind));
|
|
1323
|
+
return i;
|
|
1324
|
+
}
|
|
1311
1325
|
function renderHighlights() {
|
|
1312
1326
|
const box = $('#hl'); if (!box) return; const hs = S.live ? [] : computeHighlights();
|
|
1313
1327
|
box.classList.toggle('has', hs.length > 0); box.innerHTML = hs.length ? `<span class="dim" style="font-size:11.5px">${L('Moments clés', 'Highlights')}</span>` + hs.map((h) => `<button class="hlc" data-i="${h.i}">${esc(h.t)}</button>`).join('') : '';
|
|
@@ -1371,11 +1385,49 @@ function ackLine(ev) {
|
|
|
1371
1385
|
if (line === S.lastAck) line = pool[(seed + 1) % pool.length]; S.lastAck = line; return line;
|
|
1372
1386
|
}
|
|
1373
1387
|
async function react(ev, token) {
|
|
1374
|
-
if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line;
|
|
1388
|
+
if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line, thanks = false;
|
|
1375
1389
|
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]; }
|
|
1376
|
-
else if (ev.kind === 'report') {
|
|
1390
|
+
else if (ev.kind === 'report') {
|
|
1391
|
+
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
|
|
1392
|
+
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]; }
|
|
1393
|
+
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; }
|
|
1394
|
+
else return;
|
|
1395
|
+
}
|
|
1377
1396
|
else return;
|
|
1378
|
-
if (!S.agents[who]) return; startTalk(who, line, ev.kind === 'order' && prefs.banter ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
|
|
1397
|
+
if (!S.agents[who]) return; startTalk(who, line, thanks || (ev.kind === 'order' && prefs.banter) ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
|
|
1398
|
+
}
|
|
1399
|
+
// Small talk: when nobody has said anything for a while, someone fills the silence the way people do in video calls. At most three per silence, further and further apart; any other line starts the count again.
|
|
1400
|
+
const FILL = {
|
|
1401
|
+
en: { share: ['Can everyone see my screen?', 'Bear with me, it’s loading.', 'Sorry, my machine is slow today.', 'Is this big enough for everyone?', 'One second… almost there.', 'Tell me if I’m going too fast.'],
|
|
1402
|
+
busy: ['Still here, just heads-down.', 'Sorry, I was on mute.', 'Quick update: still on it.', 'No blockers on my side.', 'Can you hear me?', 'Is it just me, or is it quiet?', 'I’ll have an update in a minute.'],
|
|
1403
|
+
wait: ['Can you hear me?', 'You guys still here?', 'Hello? I think you’re on mute.', 'Did we lose someone?', 'Is it my connection?', 'Shall we take this offline?', 'Let’s circle back when you’re ready.', 'I’ll give everyone a minute back.', 'Any other business?', 'Sorry, go ahead. No, you go.'] },
|
|
1404
|
+
fr: { share: ['Vous voyez bien mon écran ?', 'Deux secondes, ça charge.', 'Désolé, ma machine rame aujourd’hui.', 'C’est assez gros pour tout le monde ?', 'Une seconde… j’y suis presque.', 'Dites-moi si je vais trop vite.'],
|
|
1405
|
+
busy: ['Toujours là, je suis dessus.', 'Pardon, j’étais en muet.', 'Petit point : j’avance.', 'Pas de point bloquant de mon côté.', 'Vous m’entendez ?', 'C’est moi, ou c’est calme ?', 'Je vous fais un retour dans une minute.'],
|
|
1406
|
+
wait: ['Vous m’entendez ?', 'Vous êtes toujours là ?', 'Allô ? Je crois que ton micro est coupé.', 'On a perdu quelqu’un ?', 'C’est ma connexion ?', 'On en reparle en off ?', 'On refait un point quand tu veux.', 'Je vous rends une minute.', 'D’autres sujets ?', 'Pardon, vas-y. Non, toi d’abord.'] },
|
|
1407
|
+
};
|
|
1408
|
+
// A little more talk between agents: a word of thanks when a report comes in, a word from whoever has been thinking for a long time.
|
|
1409
|
+
const TALK = {
|
|
1410
|
+
en: { thanks: ['Thanks, that helps.', 'Got it, thanks.', 'Noted. I’ll take it from here.', 'Perfect, that’s what I needed.', 'Thanks, good work.', 'Good to know, thanks.', 'Understood, thank you.', 'Great, one less thing to worry about.'],
|
|
1411
|
+
think: ['Hmm, let me think.', 'Give me a second, I’m thinking.', 'Interesting… let me think this through.', 'One moment, I’m working it out.', 'Hang on, I want to get this right.', 'Let me double-check something.', 'Still thinking… almost there.', 'That one needs a bit of thought.'] },
|
|
1412
|
+
fr: { thanks: ['Merci, ça m’aide.', 'Bien reçu, merci.', 'C’est noté. Je prends la suite.', 'Parfait, c’est ce qu’il me fallait.', 'Merci, bon boulot.', 'Bon à savoir, merci.', 'Compris, merci.', 'Super, un souci de moins.'],
|
|
1413
|
+
think: ['Hmm, laissez-moi réfléchir.', 'Une seconde, je réfléchis.', 'Intéressant… je creuse ça.', 'Un instant, je fais le point.', 'Attendez, je veux être sûr.', 'Je vérifie un truc.', 'Je réfléchis encore… j’y suis presque.', 'Ça demande un peu de réflexion.'] },
|
|
1414
|
+
};
|
|
1415
|
+
function varied(kind, seed) { const P = TALK[FR() ? 'fr' : 'en'][kind]; let line = P[seed % P.length]; if (line === S.lastTalk) line = P[(seed + 1) % P.length]; return (S.lastTalk = line); } // never the same line twice in a row
|
|
1416
|
+
// Who is thinking once this event has happened: the agent whose tool just answered, the lead after your message or a report, a sub-agent that just got its order. After a line or a tool call, nobody is.
|
|
1417
|
+
const thinkerOf = (e) => (e.kind === 'results' ? e.agent : e.kind === 'user' ? 'main' : e.kind === 'report' || e.kind === 'finish' || e.kind === 'order' ? e.to : null);
|
|
1418
|
+
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]; };
|
|
1419
|
+
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
|
|
1420
|
+
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;
|
|
1421
|
+
const line = varied('think', hash(String(ev.ts))); startTalk(ev.agent, line, '💭'); await speakLine(line, S.agents[ev.agent], true, token); stopTalk(ev.agent);
|
|
1422
|
+
}
|
|
1423
|
+
async function smallTalk(token) {
|
|
1424
|
+
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;
|
|
1425
|
+
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)];
|
|
1426
|
+
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
|
|
1427
|
+
if (!think && (quiet < (S.live ? 45000 : 12000 / spd()) * [1, 2.5, 6][n] || (S.live && Date.now() - last.ts > 6e5))) return; // live: a session untouched for ten minutes is a call that is over
|
|
1428
|
+
const busy = here.filter((id) => agentState(id, Date.now()).k === 'working'), who = think ? muser : S.present && here.includes(S.present) ? S.present : pick(busy.length ? busy : here); if (!who) return; // whoever shares their screen speaks, so the screen stays up
|
|
1429
|
+
const P = FILL[FR() ? 'fr' : 'en'][who === S.present ? 'share' : busy.length ? 'busy' : 'wait']; let line = think ? varied('think', Math.floor(Math.random() * 97)) : pick(P); if (!think && line === S.lastFill) line = P[(P.indexOf(line) + 1) % P.length]; if (!think) S.lastFill = line;
|
|
1430
|
+
startTalk(who, line, think ? '💭' : '💬'); if (think) S.mused = true; else S.fill = n + 1; await speakLine(line, S.agents[who], true, token); stopTalk(who);
|
|
1379
1431
|
}
|
|
1380
1432
|
async function card(text, sub, ms = 2500) {
|
|
1381
1433
|
|
|
@@ -1390,11 +1442,11 @@ const SPK = new Set(['say', 'report', 'order', 'user']);
|
|
|
1390
1442
|
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; }
|
|
1391
1443
|
const isSub = (id) => id !== 'main' && id !== 'user';
|
|
1392
1444
|
async function run(i) {
|
|
1393
|
-
const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; syncUI(); AUD.init();
|
|
1445
|
+
const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; S.spokeAt = performance.now(); S.fill = 0; syncUI(); AUD.init();
|
|
1394
1446
|
if (i === 0 && prefs.epi && !S.live) { await card(L('ÉPISODE 1', 'EPISODE 1'), S.title); if (token !== S.token) return; }
|
|
1395
1447
|
while (token === S.token) {
|
|
1396
1448
|
const lim = S.stopAt || S.events.length;
|
|
1397
|
-
if (i >= lim) { if (S.live && !S.stopAt) { if (!S.idle) { S.idle = true; syncUI(); } await sleep(350); continue; } break; }
|
|
1449
|
+
if (i >= lim) { if (S.live && !S.stopAt) { if (!S.idle) { S.idle = true; syncUI(); } await smallTalk(token); await sleep(350); continue; } break; }
|
|
1398
1450
|
if (S.idle) { S.idle = false; syncUI(); }
|
|
1399
1451
|
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
|
|
1400
1452
|
const rem = S.events.length - i;
|
|
@@ -1407,10 +1459,11 @@ async function run(i) {
|
|
|
1407
1459
|
S.pace = !S.live ? 0 : q > 7 ? 70 : q > 3 ? 130 : 260; S.hurry = q > 12; // voices are only skipped when more than a dozen lines are queued
|
|
1408
1460
|
S.boost = S.hurry || (S.live && S.events.length - i > 60) ? 3 : 1;
|
|
1409
1461
|
const ev = S.events[i]; S.idx = i; tally(ev);
|
|
1462
|
+
if (!S.live && (ev.kind === 'say' || ev.kind === 'report' || ev.kind === 'order' || ev.kind === 'tools')) { await ponder(i, token); if (token !== S.token) return; }
|
|
1410
1463
|
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; }
|
|
1411
1464
|
if (ev.kind === 'finish') { applyStatus(ev); paintStatus(); i++; continue; }
|
|
1412
|
-
if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.demo ? 900 : S.live ? 30 : 80) / spd()); i++; continue; }
|
|
1413
|
-
if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); await sleep((S.demo ? 1100 : S.live ? 110 : 250) / spd()); i++; continue; }
|
|
1465
|
+
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
|
|
1466
|
+
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; }
|
|
1414
1467
|
const group = [ev]; let j = i + 1; // sub-agents talking back-to-back were running in parallel: they talk over each other
|
|
1415
1468
|
if (isSub(ev.agent) && ev.kind !== 'order') {
|
|
1416
1469
|
while (j < Math.min(S.events.length, i + 28) && group.length < 3) {
|
|
@@ -1423,7 +1476,6 @@ async function run(i) {
|
|
|
1423
1476
|
}
|
|
1424
1477
|
}
|
|
1425
1478
|
if (FR() && !S.hurry) { prefetchTr(i); await Promise.race([ensureTr(group.map((g) => baseText(g))), sleep(S.live ? 7000 : 14000)]); if (token !== S.token) return; }
|
|
1426
|
-
if (group.length >= 3)
|
|
1427
1479
|
prefetchQuips(i); warmTTS(i);
|
|
1428
1480
|
const playOne = (g) => {
|
|
1429
1481
|
applyStatus(g);
|
|
@@ -1585,6 +1637,8 @@ function avatarImg(p, m, st) { // m: 0 silent, 1 speaking (closed), 2 speaking
|
|
|
1585
1637
|
const k = p.id + m + (st || ''); if (imgCache[k]) return imgCache[k];
|
|
1586
1638
|
const im = new Image(); im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(avatarSVG(p, { m, working: st === 'working', done: st === 'done' })); return (imgCache[k] = im);
|
|
1587
1639
|
}
|
|
1640
|
+
const mediaMem = new Map();
|
|
1641
|
+
function mediaImg(url) { let im = mediaMem.get(url); if (!im) { im = new Image(); im.src = url; mediaMem.set(url, im); if (mediaMem.size > 24) mediaMem.delete(mediaMem.keys().next().value); } return im; }
|
|
1588
1642
|
function wrap(c, text, maxW, maxLines) {
|
|
1589
1643
|
const words = String(text).split(/\s+/), lines = []; let cur = '';
|
|
1590
1644
|
for (const w of words) { const t = cur ? cur + ' ' + w : w; if (c.measureText(t).width > maxW && cur) { lines.push(cur); cur = w; if (lines.length === maxLines) break; } else cur = t; }
|
|
@@ -1596,21 +1650,35 @@ function tileOnCanvas(c, id, x, y, w, h, big) {
|
|
|
1596
1650
|
if (im.complete && im.naturalWidth) c.drawImage(im, x, y, w, h);
|
|
1597
1651
|
c.lineWidth = sp ? 4 : 2; c.strokeStyle = sp ? (p.lead ? '#f5c04a' : '#7c9cff') : st === 'working' ? '#f0a020' : 'rgba(255,255,255,.14)'; c.strokeRect(x, y, w, h);
|
|
1598
1652
|
c.font = `bold ${big ? 16 : 12}px sans-serif`; const nm = p.name + (st === 'done' ? ' ✓' : ''); c.fillStyle = 'rgba(0,0,0,.7)'; c.fillRect(x + 6, y + h - (big ? 30 : 22), c.measureText(nm).width + 12, big ? 24 : 18); c.fillStyle = '#fff'; c.fillText(nm, x + 12, y + h - (big ? 12 : 8));
|
|
1599
|
-
if (!big && S.lastTool[id] && st === 'working') { c.font = '10px monospace'; c.fillStyle = 'rgba(15,20,35,.85)'; c.fillRect(x, y, w, 15); c.fillStyle = '#ffd24a'; c.fillText(S.lastTool[id].slice(0, Math.floor(w / 6)), x + 4, y + 11); }
|
|
1653
|
+
if (!big && S.lastTool[id] && st === 'working') { c.font = '10px monospace'; c.fillStyle = 'rgba(15,20,35,.85)'; c.fillRect(x, y, w, 15); c.fillStyle = '#ffd24a'; c.fillText((prefs.hideCode ? S.lastTool[id].split(' ')[0] : S.lastTool[id]).slice(0, Math.floor(w / 6)), x + 4, y + 11); }
|
|
1600
1654
|
if (big && sp && S.say[id]) { c.font = '15px sans-serif'; const Ls = wrap(c, S.say[id].text, w - 40, 4), bh = Ls.length * 19 + 14; c.fillStyle = '#fff'; c.beginPath(); c.roundRect(x + 10, y + 10, w - 20, bh, 10); c.fill(); c.fillStyle = '#1b1d27'; Ls.forEach((l, i) => c.fillText(l, x + 20, y + 30 + i * 19)); }
|
|
1601
1655
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
c
|
|
1656
|
+
const SITE = 'atoll92.github.io/agent-standup';
|
|
1657
|
+
function drawFrame(cv, mark = 0) { // mark: 1 = a clip to share (site address in the corner), 2 = its end card
|
|
1658
|
+
const c = cv.getContext('2d'), W = cv.width, H = cv.height, hide = prefs.hideCode; c.fillStyle = '#12131a'; c.fillRect(0, 0, W, H); // hide: code, outputs, file names and the session title are masked; what agents say is kept
|
|
1659
|
+
if (mark === 2) { c.textAlign = 'center'; c.font = '900 76px "Comic Sans MS",sans-serif'; c.fillStyle = '#e9eaf0'; c.fillText('Agent Standup', W / 2, H / 2 - 24); c.font = '28px sans-serif'; c.fillStyle = '#cdd6f4'; c.fillText(L('Vos agents Claude Code en appel d’équipe', 'Your Claude Code agents as a live team call'), W / 2, H / 2 + 36); c.font = 'bold 26px sans-serif'; c.fillStyle = '#f5c04a'; c.fillText(SITE, W / 2, H / 2 + 96); c.textAlign = 'start'; return; }
|
|
1660
|
+
c.font = 'bold 20px "Comic Sans MS",sans-serif'; c.fillStyle = '#e9eaf0'; c.fillText('🎥 ' + ((!hide && S.title) || 'Agent Standup'), 20, 32);
|
|
1661
|
+
if (mark) { c.font = '13px sans-serif'; c.textAlign = 'right'; const w = c.measureText(SITE).width; c.fillStyle = '#12131a'; c.fillRect(W - 32 - w, 8, w + 32, 34); c.fillStyle = '#8b90a8'; c.fillText(SITE, W - 20, 30); c.textAlign = 'start'; }
|
|
1605
1662
|
const ids = Object.keys(S.agents).filter((id) => id === 'main' || id === 'user' || (S.status[id] && !S.gone.has(id))), sp = [...S.speakers].filter((id) => S.agents[id]).slice(0, 3), big = sp.length ? sp : ['main', 'user'];
|
|
1606
|
-
const pres = S.present && S.agents[S.present], bw = Math.min(480, (W - 40) / big.length - 12), bh = pres ? 330 : bw * 0.75;
|
|
1663
|
+
const pres = S.present && S.agents[S.present], m = pres && termModel(S.present), bw = Math.min(480, (W - 40) / big.length - 12), bh = pres ? (m.view === 'image' ? 420 : 330) : bw * 0.75; // a picture gets a taller window
|
|
1607
1664
|
if (pres) {
|
|
1608
|
-
const
|
|
1665
|
+
const x = 20, y = 50, w = W - 40, ed = m.view === 'editor', col = { cmd: '#e6edf3', out: '#aab2c5', err: '#ff7b72', tool: '#8b95b0', dim: '#6b7089', run: '#ffd24a', add: '#9be9a8', del: '#ffa198', ctx: '#d6dae6' };
|
|
1609
1666
|
c.fillStyle = ed ? '#1a1b26' : '#0d1117'; c.beginPath(); c.roundRect(x, y, w, bh, 12); c.fill(); c.fillStyle = '#1c1f2b'; c.fillRect(x, y, w, 28);
|
|
1610
1667
|
['#ff5f57', '#febc2e', '#28c840'].forEach((cl, i) => { c.fillStyle = cl; c.beginPath(); c.arc(x + 16 + i * 18, y + 14, 5, 0, 7); c.fill(); });
|
|
1611
|
-
c.font = '13px sans-serif'; c.fillStyle = '#9aa0b4'; c.fillText(ed ? m.title : S.agents[S.present].name + ' — zsh', x + 90, y + 19); c.font = '14px monospace';
|
|
1612
|
-
|
|
1613
|
-
|
|
1668
|
+
c.font = '13px sans-serif'; c.fillStyle = '#9aa0b4'; c.fillText(ed ? (hide ? m.title.split(' — ').pop() : m.title) : m.view === 'image' ? (hide ? '' : m.title) : S.agents[S.present].name + ' — zsh', x + 90, y + 19); c.font = '14px monospace';
|
|
1669
|
+
if (m.view === 'image') { // masked like the code: a picture from the session is replaced by an empty frame
|
|
1670
|
+
const im = hide ? null : mediaImg(m.imgs[0]), ax = x + 14, ay = y + 38, aw = w - 238, ah = bh - 82, k = im && im.complete && im.naturalWidth ? Math.min(aw / im.naturalWidth, ah / im.naturalHeight, 1.5) : 0;
|
|
1671
|
+
if (k) c.drawImage(im, ax + (aw - im.naturalWidth * k) / 2, ay + (ah - im.naturalHeight * k) / 2, im.naturalWidth * k, im.naturalHeight * k);
|
|
1672
|
+
else { c.fillStyle = 'rgba(255,255,255,.07)'; c.beginPath(); c.roundRect(ax + aw / 2 - 170, ay + 10, 340, ah - 20, 10); c.fill(); c.font = '64px sans-serif'; c.textAlign = 'center'; c.fillStyle = '#6b7089'; c.fillText('🖼', ax + aw / 2, ay + ah / 2 + 22); c.textAlign = 'start'; }
|
|
1673
|
+
c.font = '14px monospace';
|
|
1674
|
+
}
|
|
1675
|
+
const rows = ed ? m.lines.slice(0, 13) : m.lines.slice(-13), cw = c.measureText('M').width;
|
|
1676
|
+
rows.forEach((l, i) => {
|
|
1677
|
+
const yy = y + 52 + i * 21, pre = l.c === 'cmd' ? '❯ ' : l.c === 'tool' ? '● ' : ed && l.n ? String(l.n).padStart(4) + ' ' : l.c === 'add' ? '+ ' : l.c === 'del' ? '− ' : '', txt = String(l.t).split('\n')[0].slice(0, 112 - pre.length);
|
|
1678
|
+
const keep = !hide || l.c === 'run' ? txt.length : l.c === 'tool' ? txt.indexOf('(') + 1 : 0; // masked: only our own words stay ("running…", the tool's name), the rest becomes bars of the same length
|
|
1679
|
+
if (l.c === 'add' || l.c === 'del') { c.fillStyle = l.c === 'add' ? 'rgba(63,185,80,.16)' : 'rgba(248,81,73,.16)'; c.fillRect(x, yy - 15, w, 21); } c.fillStyle = col[l.c] || '#d6dae6'; c.fillText(pre + txt.slice(0, keep), x + 14, yy);
|
|
1680
|
+
if (keep < txt.length) { c.globalAlpha = 0.4; for (const b of txt.slice(keep).matchAll(/\S+/g)) c.fillRect(x + 14 + (pre.length + keep + b.index) * cw, yy - 10, b[0].length * cw - 3, 10); c.globalAlpha = 1; }
|
|
1681
|
+
});
|
|
1614
1682
|
c.font = '12px monospace'; c.fillStyle = '#8b95b0'; c.fillText(m.stat, x + 14, y + bh - 8);
|
|
1615
1683
|
c.fillStyle = 'rgba(240,160,32,.94)'; c.beginPath(); c.roundRect(x + 12, y + bh - 34, 300, 24, 12); c.fill(); c.fillStyle = '#201400'; c.font = 'bold 13px sans-serif'; c.fillText('🖥 ' + S.agents[S.present].name + ' ' + L('partage son écran', 'is sharing their screen'), x + 24, y + bh - 17);
|
|
1616
1684
|
tileOnCanvas(c, S.present, x + w - 210, y + bh - 160, 196, 147, false);
|
|
@@ -1619,20 +1687,29 @@ function drawFrame(cv) {
|
|
|
1619
1687
|
others.forEach((id, i) => tileOnCanvas(c, id, 20 + (i % cols) * (tw + 8), ty + Math.floor(i / cols) * (th + 8), tw, th, false));
|
|
1620
1688
|
const lead = sp[0] && S.say[sp[0]];
|
|
1621
1689
|
if (lead) { c.font = '22px sans-serif'; const Ls = wrap(c, `${nameOf(sp[0])} : ${lead.text}`, W - 80, 2); c.fillStyle = 'rgba(0,0,0,.75)'; c.fillRect(0, H - 22 - Ls.length * 28, W, Ls.length * 28 + 22); c.fillStyle = '#fff'; Ls.forEach((l, i) => c.fillText(l, 40, H - 12 - (Ls.length - 1 - i) * 28 - 6)); }
|
|
1622
|
-
const cd = $('#card'); if (cd.classList.contains('on')) { c.fillStyle = '#05060c'; c.fillRect(0, 0, W, H); c.textAlign = 'center'; c.font = '900 90px "Comic Sans MS",sans-serif'; c.fillStyle = '#d8372b'; c.fillText(cd.querySelector('h1').textContent, W / 2 + 5, H / 2 + 5); c.fillStyle = '#fff'; c.fillText(cd.querySelector('h1').textContent, W / 2, H / 2); c.font = '26px sans-serif'; c.fillStyle = '#cdd6f4'; c.fillText(cd.querySelector('p').textContent.slice(0, 80), W / 2, H / 2 + 60); c.textAlign = 'start'; }
|
|
1690
|
+
const cd = $('#card'); if (cd.classList.contains('on')) { c.fillStyle = '#05060c'; c.fillRect(0, 0, W, H); c.textAlign = 'center'; c.font = '900 90px "Comic Sans MS",sans-serif'; c.fillStyle = '#d8372b'; c.fillText(cd.querySelector('h1').textContent, W / 2 + 5, H / 2 + 5); c.fillStyle = '#fff'; c.fillText(cd.querySelector('h1').textContent, W / 2, H / 2); c.font = '26px sans-serif'; c.fillStyle = '#cdd6f4'; if (!hide) c.fillText(cd.querySelector('p').textContent.slice(0, 80), W / 2, H / 2 + 60); c.textAlign = 'start'; }
|
|
1623
1691
|
}
|
|
1624
1692
|
let recorder = null;
|
|
1625
|
-
|
|
1693
|
+
const REC_TYPES = ['video/mp4;codecs=avc1.42E01F,mp4a.40.2', 'video/webm;codecs=vp9,opus', 'video/webm']; // MP4 (H.264 + AAC) where the browser can record it: X, phones and most chat apps refuse .webm
|
|
1694
|
+
async function exportVideo(from = 0, to = 0, clip = 0) { // clip: length in ms of a short cut to share; it starts where the replay is, or at clipStart() when the replay is at rest or nearly over
|
|
1626
1695
|
if (recorder) { recorder.stop(); return; }
|
|
1627
|
-
const wasLive = S.live; if (wasLive) await setMode('replay'); pause(); claimAudio(); AUD.init(); await AUD.ctx.resume(); AUD.muted = false; AUD.apply();
|
|
1696
|
+
const wasLive = S.live, here = !wasLive && S.idx > 0 && aheadSpeakable(S.idx, 3) >= 3 ? S.idx : -1; if (wasLive) await setMode('replay'); pause(); claimAudio(); AUD.init(); await AUD.ctx.resume(); AUD.muted = false; AUD.apply();
|
|
1697
|
+
const back = S.idx; if (clip) from = here >= 0 ? here : clipStart();
|
|
1628
1698
|
const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720, stream = new MediaStream([...cv.captureStream(30).getTracks(), ...AUD.rec.stream.getTracks()]);
|
|
1629
|
-
const mime =
|
|
1699
|
+
const mime = REC_TYPES.find((m) => MediaRecorder.isTypeSupported(m)) || '', ext = mime.startsWith('video/mp4') ? 'mp4' : 'webm', chunks = [], t0 = performance.now(); let outro = false;
|
|
1630
1700
|
recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 4e6 }); recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
|
|
1631
|
-
const timer = setInterval(() => {
|
|
1701
|
+
const timer = setInterval(() => {
|
|
1702
|
+
const el = performance.now() - t0; drawFrame(cv, clip ? (outro ? 2 : 1) : 0);
|
|
1703
|
+
if (clip && S.playing && !S.stopAt && el > clip - 4000) S.stopAt = S.idx + 1; // the line being said is finished rather than cut…
|
|
1704
|
+
if (clip && S.playing && el > clip + 8000) pause(); // …unless it is a very long one
|
|
1705
|
+
$('#pill').textContent = `⏹ ${Math.max(0, Math.min(100, Math.round((clip ? el / clip : (S.idx - from) / ((to || S.events.length) - from)) * 100)))}%`;
|
|
1706
|
+
}, 33), done = new Promise((res) => (recorder.onstop = res));
|
|
1632
1707
|
S.exporting = true; S.stopAt = to || 0; S.idx = from; recomputeStatus(from); recorder.start(500); run(from);
|
|
1633
|
-
const poll = setInterval(() => { if (!S.playing && recorder) { setTimeout(() => recorder &&
|
|
1634
|
-
await done; clearInterval(timer); clearInterval(poll); S.exporting = false; S.stopAt = 0; recorder = null;
|
|
1635
|
-
|
|
1708
|
+
const poll = setInterval(() => { if (!S.playing && recorder) { const rec = recorder; outro = !!clip; setTimeout(() => recorder === rec && rec.stop(), clip ? 2600 : 900); clearInterval(poll); } }, 300);
|
|
1709
|
+
await done; clearInterval(timer); clearInterval(poll); S.exporting = false; S.stopAt = 0; recorder = null;
|
|
1710
|
+
if (clip) { pause(); S.idx = back; recomputeStatus(back); paintStatus(); } // back where the replay was, so the same clip can be taken again
|
|
1711
|
+
syncUI();
|
|
1712
|
+
const blob = new Blob(chunks, { type: mime.split(';')[0] || 'video/webm' }); window.__lastExport = blob.size; const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `agent-standup${clip ? '-clip' : ''}.${ext}`; a.click();
|
|
1636
1713
|
}
|
|
1637
1714
|
|
|
1638
1715
|
// =====================================================================
|
|
@@ -1704,6 +1781,7 @@ $('#bMute').onclick = toggleMute;
|
|
|
1704
1781
|
$('#bPlay').onclick = togglePlay;
|
|
1705
1782
|
$('#mRecap').onclick = () => { $('#menu').classList.remove('on'); openRecap(); };
|
|
1706
1783
|
$('#mExport').onclick = () => { $('#menu').classList.remove('on'); exportVideo(); };
|
|
1784
|
+
$('#mClip').onclick = () => { $('#menu').classList.remove('on'); exportVideo(0, 0, 25000); };
|
|
1707
1785
|
$('#mBrowser').onclick = () => { $('#menu').classList.remove('on'); window.parent.postMessage({ agentStandup: 'openBrowser' }, '*'); };
|
|
1708
1786
|
$('#seek').oninput = (e) => seekTo(+e.target.value);
|
|
1709
1787
|
$('#eDemo').onclick = () => setMode('demo');
|
package/public/locales/de.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"☕ Buy the (replaced) dev a coffee": "☕ Spendier dem (ersetzten) Dev einen Kaffee",
|
|
13
13
|
"☕ This dev was replaced by his agents — buy him a coffee": "☕ Dieser Dev wurde von seinen Agenten ersetzt — spendier ihm einen Kaffee",
|
|
14
14
|
"🎞 Export video": "🎞 Video exportieren",
|
|
15
|
+
"🎬 Clip to share (25 s)": "🎬 Clip zum Teilen (25 s)",
|
|
15
16
|
"📋 Recap": "📋 Zusammenfassung",
|
|
16
17
|
"Agent voices": "Agenten-Stimmen",
|
|
17
18
|
"Agents": "Agenten",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"Files touched": "Bearbeitete Dateien",
|
|
59
60
|
"Final report": "Abschlussbericht",
|
|
60
61
|
"first error": "erster Fehler",
|
|
62
|
+
"Hide code & file names in videos": "Code & Dateinamen in Videos ausblenden",
|
|
61
63
|
"Highlights": "Highlights",
|
|
62
64
|
"Human": "Mensch",
|
|
63
65
|
"idle": "untätig",
|
|
@@ -159,6 +161,7 @@
|
|
|
159
161
|
"Writing ": "Schreibe ",
|
|
160
162
|
"You": "Du",
|
|
161
163
|
"Your agents are done 🎉": "Deine Agenten sind fertig 🎉",
|
|
164
|
+
"Your Claude Code agents as a live team call": "Deine Claude Code-Agenten live im Team-Videoanruf",
|
|
162
165
|
"your turn": "du bist dran",
|
|
163
166
|
"Characters": "Figuren",
|
|
164
167
|
"Reload": "Neu laden",
|
package/public/locales/en.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"☕ Buy the (replaced) dev a coffee": "☕ Buy the (replaced) dev a coffee",
|
|
8
8
|
"☕ This dev was replaced by his agents — buy him a coffee": "☕ This dev was replaced by his agents — buy him a coffee",
|
|
9
9
|
"🎞 Export video": "🎞 Export video",
|
|
10
|
+
"🎬 Clip to share (25 s)": "🎬 Clip to share (25 s)",
|
|
10
11
|
"📋 Recap": "📋 Recap",
|
|
11
12
|
"Agent voices": "Agent voices",
|
|
12
13
|
"Agents": "Agents",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"Final report": "Final report",
|
|
59
60
|
"first error": "first error",
|
|
60
61
|
"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.",
|
|
62
|
+
"Hide code & file names in videos": "Hide code & file names in videos",
|
|
61
63
|
"Highlights": "Highlights",
|
|
62
64
|
"Human": "Human",
|
|
63
65
|
"idle": "idle",
|
|
@@ -168,5 +170,6 @@
|
|
|
168
170
|
"Writing ": "Writing ",
|
|
169
171
|
"You": "You",
|
|
170
172
|
"Your agents are done 🎉": "Your agents are done 🎉",
|
|
173
|
+
"Your Claude Code agents as a live team call": "Your Claude Code agents as a live team call",
|
|
171
174
|
"your turn": "your turn"
|
|
172
175
|
}
|
package/public/locales/es.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"☕ Buy the (replaced) dev a coffee": "☕ Invita un café al dev (reemplazado)",
|
|
13
13
|
"☕ This dev was replaced by his agents — buy him a coffee": "☕ Este dev fue reemplazado por sus agentes — invítale un café",
|
|
14
14
|
"🎞 Export video": "🎞 Exportar vídeo",
|
|
15
|
+
"🎬 Clip to share (25 s)": "🎬 Clip para compartir (25 s)",
|
|
15
16
|
"📋 Recap": "📋 Resumen",
|
|
16
17
|
"Agent voices": "Voces de los agentes",
|
|
17
18
|
"Agents": "Agentes",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"Files touched": "Archivos tocados",
|
|
59
60
|
"Final report": "Informe final",
|
|
60
61
|
"first error": "primer error",
|
|
62
|
+
"Hide code & file names in videos": "Ocultar código y archivos en los vídeos",
|
|
61
63
|
"Highlights": "Destacados",
|
|
62
64
|
"Human": "Humano",
|
|
63
65
|
"idle": "inactivo",
|
|
@@ -159,6 +161,7 @@
|
|
|
159
161
|
"Writing ": "Escribiendo ",
|
|
160
162
|
"You": "Tú",
|
|
161
163
|
"Your agents are done 🎉": "Tus agentes han terminado 🎉",
|
|
164
|
+
"Your Claude Code agents as a live team call": "Tus agentes de Claude Code, en videollamada de equipo en directo",
|
|
162
165
|
"your turn": "tu turno",
|
|
163
166
|
"Characters": "Personajes",
|
|
164
167
|
"Reload": "Recargar",
|
package/public/locales/ja.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"☕ Buy the (replaced) dev a coffee": "☕ (お役御免の)開発者にコーヒーを",
|
|
13
13
|
"☕ This dev was replaced by his agents — buy him a coffee": "☕ この開発者はエージェントに仕事を奪われました — コーヒーをおごってあげて",
|
|
14
14
|
"🎞 Export video": "🎞 動画を書き出す",
|
|
15
|
+
"🎬 Clip to share (25 s)": "🎬 シェア用クリップ(25秒)",
|
|
15
16
|
"📋 Recap": "📋 まとめ",
|
|
16
17
|
"Agent voices": "エージェントの声",
|
|
17
18
|
"Agents": "エージェント",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"Files touched": "触れたファイル",
|
|
59
60
|
"Final report": "最終レポート",
|
|
60
61
|
"first error": "最初のエラー",
|
|
62
|
+
"Hide code & file names in videos": "動画でコードとファイル名を隠す",
|
|
61
63
|
"Highlights": "ハイライト",
|
|
62
64
|
"Human": "人間",
|
|
63
65
|
"idle": "待機中",
|
|
@@ -159,6 +161,7 @@
|
|
|
159
161
|
"Writing ": "書き込み中: ",
|
|
160
162
|
"You": "あなた",
|
|
161
163
|
"Your agents are done 🎉": "エージェントが完了しました 🎉",
|
|
164
|
+
"Your Claude Code agents as a live team call": "Claude Code のエージェントを、ライブのチーム通話で",
|
|
162
165
|
"your turn": "あなたの番",
|
|
163
166
|
"Characters": "キャラクター",
|
|
164
167
|
"Reload": "再読み込み",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"☕ Buy the (replaced) dev a coffee": "☕ Pague um café ao dev (substituído)",
|
|
19
19
|
"☕ This dev was replaced by his agents — buy him a coffee": "☕ Este dev foi substituído pelos agentes dele — pague um café a ele",
|
|
20
20
|
"🎞 Export video": "🎞 Exportar vídeo",
|
|
21
|
+
"🎬 Clip to share (25 s)": "🎬 Clipe para compartilhar (25 s)",
|
|
21
22
|
"📋 Recap": "📋 Resumo",
|
|
22
23
|
"Agent voices": "Vozes dos agentes",
|
|
23
24
|
"Agents": "Agentes",
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
"Files touched": "Arquivos modificados",
|
|
65
66
|
"Final report": "Relatório final",
|
|
66
67
|
"first error": "primeiro erro",
|
|
68
|
+
"Hide code & file names in videos": "Ocultar código e arquivos nos vídeos",
|
|
67
69
|
"Highlights": "Destaques",
|
|
68
70
|
"Human": "Humano",
|
|
69
71
|
"idle": "ocioso",
|
|
@@ -165,6 +167,7 @@
|
|
|
165
167
|
"Writing ": "Escrevendo ",
|
|
166
168
|
"You": "Você",
|
|
167
169
|
"Your agents are done 🎉": "Seus agentes terminaram 🎉",
|
|
170
|
+
"Your Claude Code agents as a live team call": "Seus agentes do Claude Code em uma chamada de equipe ao vivo",
|
|
168
171
|
"your turn": "sua vez",
|
|
169
172
|
"Downloading, you can carry on: system voices are used meanwhile.": "Baixando, pode continuar: enquanto isso, as vozes do sistema são usadas.",
|
|
170
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.",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"☕ Buy the (replaced) dev a coffee": "☕ 请(被取代的)开发者喝杯咖啡",
|
|
13
13
|
"☕ This dev was replaced by his agents — buy him a coffee": "☕ 这位开发者被他的代理取代了——请他喝杯咖啡吧",
|
|
14
14
|
"🎞 Export video": "🎞 导出视频",
|
|
15
|
+
"🎬 Clip to share (25 s)": "🎬 分享用短视频(25 秒)",
|
|
15
16
|
"📋 Recap": "📋 回顾",
|
|
16
17
|
"Agent voices": "代理语音",
|
|
17
18
|
"Agents": "代理",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"Files touched": "涉及的文件",
|
|
59
60
|
"Final report": "最终报告",
|
|
60
61
|
"first error": "首个错误",
|
|
62
|
+
"Hide code & file names in videos": "在视频中隐藏代码和文件名",
|
|
61
63
|
"Highlights": "精彩时刻",
|
|
62
64
|
"Human": "真人",
|
|
63
65
|
"idle": "空闲",
|
|
@@ -159,6 +161,7 @@
|
|
|
159
161
|
"Writing ": "正在写入 ",
|
|
160
162
|
"You": "你",
|
|
161
163
|
"Your agents are done 🎉": "你的代理都完成啦 🎉",
|
|
164
|
+
"Your Claude Code agents as a live team call": "把你的 Claude Code 代理变成实时团队通话",
|
|
162
165
|
"your turn": "轮到你了",
|
|
163
166
|
"Characters": "角色",
|
|
164
167
|
"Reload": "重新加载",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"☕ Buy the (replaced) dev a coffee": "☕ 請(被取代的)開發者喝杯咖啡",
|
|
13
13
|
"☕ This dev was replaced by his agents — buy him a coffee": "☕ 這位開發者被他的代理取代了 — 請他喝杯咖啡吧",
|
|
14
14
|
"🎞 Export video": "🎞 匯出影片",
|
|
15
|
+
"🎬 Clip to share (25 s)": "🎬 分享用短片(25 秒)",
|
|
15
16
|
"📋 Recap": "📋 摘要",
|
|
16
17
|
"Agent voices": "代理語音",
|
|
17
18
|
"Agents": "代理",
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
"Files touched": "異動的檔案",
|
|
65
66
|
"Final report": "最終報告",
|
|
66
67
|
"first error": "首次錯誤",
|
|
68
|
+
"Hide code & file names in videos": "在影片中隱藏程式碼與檔名",
|
|
67
69
|
"Highlights": "精華",
|
|
68
70
|
"Human": "真人",
|
|
69
71
|
"idle": "閒置",
|
|
@@ -165,6 +167,7 @@
|
|
|
165
167
|
"Writing ": "撰寫 ",
|
|
166
168
|
"You": "你",
|
|
167
169
|
"Your agents are done 🎉": "你的代理都完成了 🎉",
|
|
170
|
+
"Your Claude Code agents as a live team call": "把你的 Claude Code 代理變成即時團隊通話",
|
|
168
171
|
"your turn": "換你了",
|
|
169
172
|
"Downloading, you can carry on: system voices are used meanwhile.": "下載中,你可以繼續使用:這段時間會先用系統語音。",
|
|
170
173
|
"Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質不錯的本機語音,以英文為主(法文只有一種聲音)。免費,資料不會離開你的電腦。",
|
package/server.js
CHANGED
|
@@ -46,6 +46,36 @@ const toolLabel = (b, ts) => {
|
|
|
46
46
|
return out;
|
|
47
47
|
};
|
|
48
48
|
const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x.text || '').join('\n') : '');
|
|
49
|
+
// Pictures a tool returned (a screenshot, an image file that was read). They are served from the transcript itself, so nothing outside the session files is ever read, and only bitmap types are sent.
|
|
50
|
+
const IMG_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']), mediaMemo = new Map();
|
|
51
|
+
const isImg = (x) => !!x && x.type === 'image' && !!x.source && x.source.type === 'base64' && typeof x.source.data === 'string' && IMG_TYPES.has(x.source.media_type);
|
|
52
|
+
const resultImages = (c) => (Array.isArray(c) ? c.filter(isImg) : []);
|
|
53
|
+
function mediaFile(proj, sid, agent) {
|
|
54
|
+
if (agent === 'main') return path.join(ROOT, proj, sid + '.jsonl');
|
|
55
|
+
const dir = path.join(ROOT, proj, sid, 'subagents'); let hit = null;
|
|
56
|
+
try { hit = fs.readdirSync(dir).find((x) => x.endsWith('.jsonl') && x.replace(/^agent-/, '').replace(/\.jsonl$/, '').replace(/[^\w.-]/g, '_') === agent); } catch {}
|
|
57
|
+
return hit ? path.join(dir, hit) : null;
|
|
58
|
+
}
|
|
59
|
+
function toolImage(file, toolId, n) { // looks for the tool's result line in the transcript, 8 MB at a time; the few pictures asked last are kept in memory
|
|
60
|
+
const key = file + '|' + toolId + '|' + n; if (mediaMemo.has(key)) return mediaMemo.get(key);
|
|
61
|
+
const needle = Buffer.from(toolId), mark = Buffer.from('"image"'); let fd, found = null, carry = Buffer.alloc(0), pos = 0;
|
|
62
|
+
try {
|
|
63
|
+
fd = fs.openSync(file, 'r'); const size = fs.fstatSync(fd).size;
|
|
64
|
+
while (pos < size && !found) {
|
|
65
|
+
const buf = Buffer.alloc(Math.min(8 * 1024 * 1024, size - pos)); fs.readSync(fd, buf, 0, buf.length, pos); pos += buf.length;
|
|
66
|
+
const all = carry.length ? Buffer.concat([carry, buf]) : buf; let from = 0;
|
|
67
|
+
for (let hit; !found && (hit = all.indexOf(needle, from)) >= 0;) {
|
|
68
|
+
const a = all.lastIndexOf(10, hit) + 1, b = all.indexOf(10, hit); if (b < 0) break; // the line goes on in the next chunk
|
|
69
|
+
from = b + 1; const m = all.indexOf(mark, a); if (m < 0 || m > b) continue;
|
|
70
|
+
let r; try { r = JSON.parse(all.subarray(a, b).toString('utf8')); } catch { continue; }
|
|
71
|
+
for (const blk of blocks(r)) if (blk.type === 'tool_result' && blk.tool_use_id === toolId) { const im = resultImages(blk.content)[n]; if (im) found = { type: im.source.media_type, buf: Buffer.from(im.source.data, 'base64') }; }
|
|
72
|
+
}
|
|
73
|
+
carry = all.subarray(all.lastIndexOf(10) + 1);
|
|
74
|
+
}
|
|
75
|
+
} catch {} finally { if (fd != null) fs.closeSync(fd); }
|
|
76
|
+
if (found) { mediaMemo.set(key, found); if (mediaMemo.size > 12) mediaMemo.delete(mediaMemo.keys().next().value); }
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
49
79
|
const RESULT_MAX = { AskUserQuestion: 900, Skill: 300, ToolSearch: 500, Read: 1900, Bash: 1300, Grep: 900, Glob: 800, WebFetch: 800, WebSearch: 800, Edit: 200, MultiEdit: 200, Write: 200 };
|
|
50
80
|
const NO_RESULT = new Set(['Agent', 'Task', 'SendMessage', 'TodoWrite']); // internal bookkeeping, nothing to show
|
|
51
81
|
const HARNESS = /<(system-reminder|browser_instruction|ide_selection|ide_opened_file|task-notification|command-name|command-message|command-args|local-command-stdout|local-command-caveat)>[\s\S]*?<\/\1>\s*/g; // blocks the harness wraps around what the human typed
|
|
@@ -225,8 +255,8 @@ function parseRecord(st, r) {
|
|
|
225
255
|
const items = []; // tool results: the real output that appeared on the agent's screen
|
|
226
256
|
for (const b of blocks(r)) if (b.type === 'tool_result' && (st.toolName[b.tool_use_id] === 'Agent' || st.toolName[b.tool_use_id] === 'Task') && !(st.bg && st.bg[b.tool_use_id]) && !/^\s*Async agent launched/i.test(resultText(b.content))) st.raw.push({ ts, agent, kind: 'agentdone', toolId: b.tool_use_id }); // a synchronous call: the lead got the sub-agent's answer back, so it has finished (async launches answer immediately and are not a completion)
|
|
227
257
|
for (const b of blocks(r)) if (b.type === 'tool_result' && st.toolName[b.tool_use_id] && !NO_RESULT.has(st.toolName[b.tool_use_id])) {
|
|
228
|
-
const n = st.toolName[b.tool_use_id];
|
|
229
|
-
items.push({ id: b.tool_use_id, err: !!b.is_error, out: clip(resultText(b.content).replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trimEnd(), RESULT_MAX[n] || (/^mcp__/.test(n) ? 900 : 600)) });
|
|
258
|
+
const n = st.toolName[b.tool_use_id], img = resultImages(b.content).length;
|
|
259
|
+
items.push({ id: b.tool_use_id, err: !!b.is_error, out: clip(resultText(b.content).replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trimEnd(), RESULT_MAX[n] || (/^mcp__/.test(n) ? 900 : 600)), ...(img ? { img } : {}) }); // img: how many pictures came back; the page fetches them from /api/media
|
|
230
260
|
}
|
|
231
261
|
if (items.length) st.raw.push({ ts, agent, kind: 'results', items });
|
|
232
262
|
if (agent !== 'main') return; // a sub-agent's first user message is the order, emitted from the parent's side
|
|
@@ -490,9 +520,9 @@ function start(port = PORT) {
|
|
|
490
520
|
let originOk = true; try { originOk = !req.headers.origin || local(new URL(req.headers.origin).host); } catch { originOk = false; }
|
|
491
521
|
if (!local(req.headers.host) || !originOk) { res.writeHead(403); return res.end('forbidden'); }
|
|
492
522
|
const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
|
493
|
-
// Endpoints with side effects (they spawn programs or claim the sound) refuse requests that a browser marks as coming from another site, e.g. an <img> on a web page.
|
|
523
|
+
// Endpoints with side effects (they spawn programs or claim the sound), and the one that serves pictures from a session, refuse requests that a browser marks as coming from another site, e.g. an <img> on a web page.
|
|
494
524
|
const sfs = req.headers['sec-fetch-site'];
|
|
495
|
-
if (sfs && sfs !== 'same-origin' && sfs !== 'none' && ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/voices', '/api/kokoro', '/api/kokoro/install'].includes(url.pathname)) { res.writeHead(403); return res.end('forbidden'); }
|
|
525
|
+
if (sfs && sfs !== 'same-origin' && sfs !== 'none' && ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/voices', '/api/kokoro', '/api/kokoro/install', '/api/media'].includes(url.pathname)) { res.writeHead(403); return res.end('forbidden'); }
|
|
496
526
|
const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
|
|
497
527
|
let body = '', over = false; req.setTimeout(10000, () => req.destroy());
|
|
498
528
|
req.on('data', (d) => { if (over) return; body += d; if (body.length > 262144) { over = true; res.writeHead(413); res.end('too large'); req.destroy(); } });
|
|
@@ -533,6 +563,12 @@ function start(port = PORT) {
|
|
|
533
563
|
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') || '' }));
|
|
534
564
|
if (url.pathname === '/api/board') return json(boardRows());
|
|
535
565
|
if (url.pathname === '/api/characters') return json(listCharacters());
|
|
566
|
+
if (url.pathname === '/api/media') {
|
|
567
|
+
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);
|
|
568
|
+
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);
|
|
569
|
+
if (!im) { res.writeHead(bad ? 400 : 404); return res.end(bad ? 'bad request' : 'not found'); }
|
|
570
|
+
res.writeHead(200, { 'content-type': im.type, 'content-length': im.buf.length, 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff' }); return res.end(im.buf);
|
|
571
|
+
}
|
|
536
572
|
if (url.pathname === '/api/session') {
|
|
537
573
|
const proj = url.searchParams.get('proj') || '', sid = url.searchParams.get('id') || '', SAFE = /^[\w.\-]+$/;
|
|
538
574
|
if (!SAFE.test(proj) || !SAFE.test(sid) || proj === '..' || sid === '..' || proj.length > 200 || sid.length > 200) { res.writeHead(400); return res.end('bad request'); }
|