@double_geste/agent-standup 1.25.4 → 1.25.6
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 +38 -14
- package/server.js +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@double_geste/agent-standup",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.6",
|
|
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
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
.tile svg { width:100%; height:100%; display:block }
|
|
136
136
|
.tile.lead { order:-2 } .tile.focus { order:-3; grid-column:span var(--fcols,2); grid-row:span var(--fr,2) } .tile.speaking { border-color:var(--accent); box-shadow:0 0 0 3px rgba(124,156,255,.4), 0 10px 30px rgba(0,0,0,.55) }
|
|
137
137
|
.tile.lead.speaking { border-color:var(--gold); box-shadow:0 0 0 3px rgba(245,192,74,.4), 0 10px 30px rgba(0,0,0,.55) }
|
|
138
|
-
body.presenting .tile.focus { order:0; grid-column:auto; grid-row:auto } .tile.focus .bubble { font-size:16px; max-width:72% }
|
|
138
|
+
body.presenting .tile.focus.presenter, body.presenting .tile.focus:not(.speaking) { order:0; grid-column:auto; grid-row:auto } .tile.focus .bubble { font-size:16px; max-width:72% }
|
|
139
139
|
.tile.working { border-color:rgba(240,160,32,.8) } .tile.done svg { filter:saturate(.75) brightness(.93) }
|
|
140
140
|
.tile.pop { animation:pop .6s } @keyframes pop { 35% { transform:scale(1.07); box-shadow:0 0 0 6px rgba(88,214,141,.5) } }
|
|
141
141
|
.tag { position:absolute; left:6px; bottom:6px; right:6px; display:flex; gap:5px; pointer-events:none }
|
|
@@ -519,6 +519,7 @@ const lagMs = (i = S.idx) => { // how much newer the newest event of the sessi
|
|
|
519
519
|
};
|
|
520
520
|
const lagFactor = () => { const l = lagMs() / 1000; return l < 6 ? 1 : l < 15 ? 1.15 : l < 30 ? 1.3 : 1.5; }; // steps, so the cached audio of a line is reused
|
|
521
521
|
const caughtUp = () => !S.live || lagMs() < 6000;
|
|
522
|
+
const nearLive = () => !S.live || lagMs() < 30000; // an answer to what was just said (a word after an order, a thank-you after a report) still fits here: reading the line took longer than 6 s and the session went on meanwhile
|
|
522
523
|
const spd = () => (S.live ? 1.12 * lagFactor() : +$('#speed').value) * (S.boost || 1);
|
|
523
524
|
const nameOf = (id) => (S.agents[id] || {}).name || id;
|
|
524
525
|
|
|
@@ -1188,11 +1189,11 @@ function armPresent() { // stays up while a command is still running, then ~4
|
|
|
1188
1189
|
}
|
|
1189
1190
|
function showPresent(id) {
|
|
1190
1191
|
if (S.boost > 1 || (S.speakers.size && !S.speakers.has(id))) return; // catching up, or someone else has the floor
|
|
1191
|
-
const first = S.present !== id; S.present = id; $('#present').classList.add('on'); document.body.classList.add('presenting'); renderPresent(); if (first) { layout(); AUD.tone(880, .12, 'sine', .05); }
|
|
1192
|
+
const first = S.present !== id; S.present = id; $('#present').classList.add('on'); document.body.classList.add('presenting'); document.querySelectorAll('.tile.presenter').forEach((t) => t.classList.remove('presenter')); const pt = $('#t-' + id); if (pt) pt.classList.add('presenter'); renderPresent(); if (first) { layout(); AUD.tone(880, .12, 'sine', .05); }
|
|
1192
1193
|
armPresent();
|
|
1193
1194
|
}
|
|
1194
1195
|
function hidePresent() {
|
|
1195
|
-
clearTimeout(presentTimer); if (!S.present) return; S.present = null; $('#present').classList.remove('on'); document.body.classList.remove('presenting'); $('#pcap').textContent = ''; layout();
|
|
1196
|
+
clearTimeout(presentTimer); if (!S.present) return; S.present = null; $('#present').classList.remove('on'); document.body.classList.remove('presenting'); document.querySelectorAll('.tile.presenter').forEach((t) => t.classList.remove('presenter')); $('#pcap').textContent = ''; layout();
|
|
1196
1197
|
}
|
|
1197
1198
|
// the speaker always gets the spotlight (and keeps it until someone else talks)
|
|
1198
1199
|
function setFocus(id) {
|
|
@@ -1543,11 +1544,11 @@ function talkLine(g) {
|
|
|
1543
1544
|
return t;
|
|
1544
1545
|
}
|
|
1545
1546
|
async function react(ev, token) {
|
|
1546
|
-
if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry || !
|
|
1547
|
+
if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry || !nearLive()) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line, thanks = false;
|
|
1547
1548
|
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]; }
|
|
1548
1549
|
else if (ev.kind === 'report') {
|
|
1549
1550
|
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
|
|
1550
|
-
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]; }
|
|
1551
|
+
if (r <= joke) { if (!caughtUp()) return; 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]; }
|
|
1551
1552
|
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; }
|
|
1552
1553
|
else return;
|
|
1553
1554
|
}
|
|
@@ -1578,9 +1579,22 @@ async function ponder(i, token) { // replay: an agent that really took a long
|
|
|
1578
1579
|
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;
|
|
1579
1580
|
const line = varied('think', hash(String(ev.ts))); startTalk(ev.agent, line, '💭'); await speakLine(line, S.agents[ev.agent], true, token); stopTalk(ev.agent);
|
|
1580
1581
|
}
|
|
1582
|
+
// Live: you gave an order and the lead starts on its tools without a word (about one time in three): it says what it is starting with, from the tool call itself, so you know it heard you and what it is on
|
|
1583
|
+
const LEAD = {
|
|
1584
|
+
en: { in: ['Got it.', 'Okay.', 'On it.', 'Sure.', 'Right.'], read: 'Let me look at ', edit: 'I’m editing ', write: 'I’m writing ', cmd: 'I’m running ', note: 'I’m running a command: ', grep: 'Let me search the code.', glob: 'Let me see which files we have.', todo: 'Let me lay out a plan.', web: 'Let me check the web.', question: 'I have a question for you.', any: 'Let me get started.', and: ' and ' },
|
|
1585
|
+
fr: { in: ['D’accord.', 'Compris.', 'Très bien.', 'Entendu.', 'Ça marche.'], read: 'Je regarde ', edit: 'Je modifie ', write: 'J’écris ', cmd: 'Je lance ', note: 'Je lance une commande : ', grep: 'Je cherche dans le code.', glob: 'Je regarde quels fichiers on a.', todo: 'Je fais mon plan.', web: 'Je vais voir sur le web.', question: 'J’ai une question pour vous.', any: 'Je m’y mets.', and: ' et ' },
|
|
1586
|
+
};
|
|
1587
|
+
async function leadAck(ev, token) {
|
|
1588
|
+
S.owed = false; if (!prefs.banter || S.hurry || S.boost > 1 || !caughtUp() || S.speakers.size || !S.agents.main) return;
|
|
1589
|
+
const W = LEAD[FR() ? 'fr' : 'en'], d = ev.tools[0].d || {}, k = d.kind === 'text' ? d.what : d.kind; let what = W[k] || W.any;
|
|
1590
|
+
if (k === 'read' || k === 'edit' || k === 'write') { const files = [...new Set(ev.tools.filter((t) => (t.d || {}).kind === k).map((t) => base(t.d.file)).filter(Boolean))].slice(0, 2); what = files.length ? what + files.join(W.and) + '.' : W.any; }
|
|
1591
|
+
else if (k === 'cmd') { const note = (d.note || '').trim().replace(/[.\s]+$/, ''), cmd = String(d.text || '').trim().split(/\s+/).slice(0, 2).join(' ').slice(0, 26); what = note ? W.note + note + '.' : cmd ? what + cmd + '.' : W.any; }
|
|
1592
|
+
const line = `${W.in[hash('k' + ev.ts) % W.in.length]} ${what}`; startTalk('main', line, '💬'); await speakLine(line, S.agents.main, true, token); stopTalk('main');
|
|
1593
|
+
}
|
|
1581
1594
|
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
|
|
1582
1595
|
if (!S.wrap || S.speakers.size || !prefs.voice && !prefs.banter) return false; S.wrap = false;
|
|
1583
1596
|
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;
|
|
1597
|
+
if (S.wrapped === e) return false; S.wrapped = e; // once per message: the lead's state can flip to done several times (a short command, a silence) without a new message
|
|
1584
1598
|
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;
|
|
1585
1599
|
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);
|
|
1586
1600
|
startTalk('main', line, '🎉'); await speakLine(line, S.agents.main, true, token); stopTalk('main'); return true;
|
|
@@ -1608,7 +1622,7 @@ const SPK = new Set(['say', 'report', 'order', 'user']);
|
|
|
1608
1622
|
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; }
|
|
1609
1623
|
const isSub = (id) => id !== 'main' && id !== 'user';
|
|
1610
1624
|
async function run(i) {
|
|
1611
|
-
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();
|
|
1625
|
+
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.owed = false; S.syncTs = (S.events[i] && S.events[i].ts) || Date.now(); syncUI(); AUD.init();
|
|
1612
1626
|
if (i === 0 && prefs.epi && !S.live) { await card(L('ÉPISODE 1', 'EPISODE 1'), S.title); if (token !== S.token) return; }
|
|
1613
1627
|
while (token === S.token) {
|
|
1614
1628
|
const lim = S.stopAt || S.events.length;
|
|
@@ -1624,13 +1638,13 @@ async function run(i) {
|
|
|
1624
1638
|
const q = S.live ? aheadSpeakable(i, 14) : 0; // lines waiting to be said: the more of them, the shorter each one is read, but it is always read
|
|
1625
1639
|
const lg = lagMs(i) / 1000; S.pace = !S.live ? 0 : Math.min(q > 7 ? 70 : q > 3 ? 130 : 260, lg > 30 ? 70 : lg > 15 ? 130 : lg > 6 ? 200 : 260); S.hurry = q > 12 || lg > 45; // voices are only skipped when more than a dozen lines are queued
|
|
1626
1640
|
S.boost = S.hurry || (S.live && S.events.length - i > 60) ? 3 : 1;
|
|
1627
|
-
const ev = S.events[i]; S.idx = i; tally(ev);
|
|
1641
|
+
const ev = S.events[i]; S.idx = i; tally(ev); if (S.live && ev.ts > S.since) S.since = ev.ts; // said once: a reload or a return to this session does not read it again
|
|
1628
1642
|
if (!S.live && (ev.kind === 'say' || ev.kind === 'report' || ev.kind === 'order' || ev.kind === 'tools')) { await ponder(i, token); if (token !== S.token) return; }
|
|
1629
1643
|
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; }
|
|
1630
1644
|
if (ev.kind === 'finish') { applyStatus(ev); paintStatus(); i++; continue; }
|
|
1631
1645
|
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
|
|
1632
1646
|
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
|
|
1633
|
-
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; }
|
|
1647
|
+
if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); if (S.live && S.owed && ev.agent === 'main') { await leadAck(ev, token); if (token !== S.token) return; } if (quick === 1) await smallTalk(token); await sleep((S.demo ? 1100 : S.live ? 110 * quick : 250) / spd()); i++; continue; }
|
|
1634
1648
|
const group = [ev]; let j = i + 1; // sub-agents talking back-to-back were running in parallel: they talk over each other
|
|
1635
1649
|
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
|
|
1636
1650
|
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; }
|
|
@@ -1649,7 +1663,7 @@ async function run(i) {
|
|
|
1649
1663
|
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; }
|
|
1650
1664
|
prefetchQuips(i); warmTTS(i);
|
|
1651
1665
|
const playOne = (g) => {
|
|
1652
|
-
applyStatus(g);
|
|
1666
|
+
applyStatus(g); if (g.agent === 'user') S.owed = true; else if (g.agent === 'main') S.owed = false; // your order waits for a word from the lead
|
|
1653
1667
|
const label = g.kind === 'order' ? `${L('pour', 'to')} ${nameOf(g.to)}` : g.kind === 'report' ? `${L('rapport à', 'report to')} ${nameOf(g.to)}` : '', line = talkLine(g);
|
|
1654
1668
|
startTalk(g.agent, line, label);
|
|
1655
1669
|
if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
|
|
@@ -1665,7 +1679,7 @@ async function run(i) {
|
|
|
1665
1679
|
}
|
|
1666
1680
|
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
|
|
1667
1681
|
S.idx = j - 1; paintStatus(); await Promise.all(plays); if (token !== S.token) return;
|
|
1668
|
-
for (const g of (ev.kind === 'order'
|
|
1682
|
+
for (const g of (ev.kind === 'order' ? group.slice(0, 3) : group)) { await converse(g, token); if (token !== S.token) return; } // a parallel launch: the first three answer, as long as the call keeps up
|
|
1669
1683
|
i = j;
|
|
1670
1684
|
}
|
|
1671
1685
|
if (token === S.token) {
|
|
@@ -1723,8 +1737,18 @@ async function followCurrent() {
|
|
|
1723
1737
|
if (S.pin && cur.id !== S.pin) S.pin = ''; // the pinned session is gone: follow the latest one instead of staying silently pinned
|
|
1724
1738
|
if (S.cur && S.cur.id === cur.id && S.cur.proj === cur.proj) return false;
|
|
1725
1739
|
if (!(await loadSession(cur.proj, cur.id))) return false;
|
|
1726
|
-
const start =
|
|
1727
|
-
|
|
1740
|
+
const start = liveStart(); if (!S.locked && !S.userPaused) run(start); return true;
|
|
1741
|
+
}
|
|
1742
|
+
// Live: a call starts when you join it. Only what happened since then, and was not said yet, is said (the last 16 events at most, when a session is picked up on the way);
|
|
1743
|
+
// the end of a session that was already over is on screen (states, transcript) but is not read again. Joining while the agents are at work, the last few seconds are said too, to know what they are on
|
|
1744
|
+
const JOIN_BACK_MS = 10000;
|
|
1745
|
+
function midWork() { // someone is in the middle of something: the session does not end on a message of the lead (its turn is over then, or about to be)
|
|
1746
|
+
let k = S.events.length - 1; while (k >= 0 && (S.events[k].kind === 'usage' || S.events[k].kind === 'compact')) k--; const e = S.events[k]; return !!e && !(e.agent === 'main' && e.kind === 'say');
|
|
1747
|
+
}
|
|
1748
|
+
function liveStart() {
|
|
1749
|
+
const n = S.events.length; if (!S.locked && !S.since) S.since = Date.now() - (midWork() ? JOIN_BACK_MS : 0); // S.since 0: joining now
|
|
1750
|
+
const since = S.locked ? Date.now() : S.since; let i = n; while (i > Math.max(0, n - 16) && S.events[i - 1].ts > since) i--;
|
|
1751
|
+
recomputeStatus(i); S.idx = i; trNew(Math.max(0, n - 16)); paintStatus(); return i;
|
|
1728
1752
|
}
|
|
1729
1753
|
let polling = false;
|
|
1730
1754
|
setInterval(async () => {
|
|
@@ -1817,7 +1841,7 @@ async function setMode(m) {
|
|
|
1817
1841
|
const live = m === 'live'; if (live === S.live && S.cur) return; pause(); S.live = live; S.userPaused = false;
|
|
1818
1842
|
document.querySelectorAll('#modeSeg button').forEach((b) => b.classList.toggle('on', b.dataset.m === m)); $('#sessions').style.display = live ? 'none' : '';
|
|
1819
1843
|
S.cur = null; syncUI();
|
|
1820
|
-
if (live) { fillProjects(); await followCurrent(); return; }
|
|
1844
|
+
if (live) { S.since = 0; fillProjects(); await followCurrent(); return; }
|
|
1821
1845
|
$('#liveProj').style.display = 'none';
|
|
1822
1846
|
const rows = await (await fetch('/api/sessions')).json();
|
|
1823
1847
|
$('#sessions').innerHTML = rows.map((r) => { const d = new Date(r.mtime).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
@@ -2003,7 +2027,7 @@ $('#eDemo').onclick = () => setMode('demo');
|
|
|
2003
2027
|
$('#gB').onclick = () => {
|
|
2004
2028
|
claimAudio(); AUD.init(); AUD.apply(); S.locked = false; $('#gate').classList.remove('on'); syncUI();
|
|
2005
2029
|
if (synth && !EMBED) { try { synth.speak(new SpeechSynthesisUtterance('')); } catch {} } // unlock browser speech
|
|
2006
|
-
if (S.live && S.events.length) run(
|
|
2030
|
+
S.since = 0; if (S.live && S.events.length) run(liveStart()); // what arrived while the door was closed is past too
|
|
2007
2031
|
};
|
|
2008
2032
|
// J / K (or a click on the “need you” chip) jump to the agents that need you or are stalled, one after the other
|
|
2009
2033
|
function jumpAttn(dir = 1) {
|
package/server.js
CHANGED
|
@@ -251,6 +251,12 @@ function parseRecord(st, r) {
|
|
|
251
251
|
if (id && status && status !== 'running') st.raw.push({ ts, agent, kind: 'agentdone', toolId: id });
|
|
252
252
|
return;
|
|
253
253
|
}
|
|
254
|
+
if (r.type === 'attachment' && r.attachment && r.attachment.type === 'queued_command') { // an order typed while the lead was working: Claude Code hands it over in the middle of the turn, as an attachment and not as a user message
|
|
255
|
+
const q = r.attachment, human = q.origin ? q.origin.kind === 'human' : q.commandMode === 'prompt'; if (agent !== 'main' || !human) return; // the same record also carries task notifications
|
|
256
|
+
const txt = (Array.isArray(q.prompt) ? q.prompt.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n') : typeof q.prompt === 'string' ? q.prompt : '').replace(HARNESS, '').trim();
|
|
257
|
+
if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt)) st.raw.push({ ts, agent: 'user', kind: 'user', text: txt });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
254
260
|
if (r.type === 'user') {
|
|
255
261
|
const items = []; // tool results: the real output that appeared on the agent's screen
|
|
256
262
|
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)
|