crewforth 3.1.0-rc.3 → 3.1.0-rc.5

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 (41) hide show
  1. package/CHANGELOG.md +66 -9
  2. package/README.md +1 -1
  3. package/kit/AGENT_TEMPLATE.md +26 -17
  4. package/kit/CLAUDE.md +11 -0
  5. package/kit/agents/crew-backend-expert.md +5 -0
  6. package/kit/agents/crew-commit-agent.md +8 -3
  7. package/kit/agents/crew-database-expert.md +5 -0
  8. package/kit/agents/crew-devops-expert.md +5 -0
  9. package/kit/agents/crew-frontend-expert.md +5 -0
  10. package/kit/agents/crew-performance-expert.md +5 -0
  11. package/kit/agents/crew-planner.md +5 -0
  12. package/kit/agents/crew-privacy-agent.md +7 -2
  13. package/kit/agents/crew-review-agent.md +5 -0
  14. package/kit/agents/crew-security-expert.md +8 -4
  15. package/kit/agents/crew-session-manager.md +7 -3
  16. package/kit/agents/crew-test-expert.md +5 -0
  17. package/kit/eval/doctor.sh +23 -7
  18. package/kit/eval/smoke-test.sh +377 -27
  19. package/kit/hooks/context-usage.sh +1 -1
  20. package/kit/hooks/guard-agent-model.sh +392 -0
  21. package/kit/hooks/guard-bash.sh +61 -5
  22. package/kit/hooks/guard-schedule.sh +92 -0
  23. package/kit/hooks/prompt-approval.sh +68 -21
  24. package/kit/settings.json +22 -0
  25. package/kit/skills/crew-approve/SKILL.md +26 -0
  26. package/kit/studio/server/index.js +1 -1
  27. package/kit/studio/server/lib/graph.js +127 -10
  28. package/kit/studio/server/lib/usage.js +96 -25
  29. package/kit/studio/web/app.js +49 -6
  30. package/kit/studio/web/canvas.js +195 -30
  31. package/kit/studio/web/chat.js +8 -1
  32. package/kit/studio/web/convo.js +5 -1
  33. package/kit/studio/web/graph-plan.js +139 -6
  34. package/kit/studio/web/inspect.js +33 -1
  35. package/kit/studio/web/list.js +8 -2
  36. package/kit/studio/web/names.js +65 -0
  37. package/kit/studio/web/style.css +65 -6
  38. package/kit/studio/web/timeline-plan.js +8 -0
  39. package/kit/studio/web/timeline.js +10 -3
  40. package/kit/studio/web/usage.js +53 -12
  41. package/package.json +1 -1
@@ -24,8 +24,8 @@ import { Timeline } from './timeline.js';
24
24
  import { List } from './list.js';
25
25
  import { defaultView, narrowWarning } from './list-plan.js';
26
26
  import { fitLevel, runsOver } from './toolbar-fit.js';
27
- import { roleName, shownType } from './names.js';
28
- import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
27
+ import { roleName, shownType, modelFamily } from './names.js';
28
+ import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
29
29
 
30
30
  const FLEET_POLL_MS = 2000;
31
31
  const SESSION_POLL_MS = 5000;
@@ -419,7 +419,7 @@ el.newSession.addEventListener('click', () => {
419
419
 
420
420
  /* The toolbar says what the canvas is doing. It is painted from the canvas's own
421
421
  state, so a choice restored from storage shows without being re-made. */
422
- const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', none: 'None' };
422
+ const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', order: 'Order', none: 'None' };
423
423
  const DENSITY_WORD = { auto: 'Auto', comfortable: 'Comfortable', compact: 'Compact' };
424
424
  const SHOW = {
425
425
  all: { word: 'All', statuses: null },
@@ -506,7 +506,25 @@ pickFrom(el.tbGroup, GROUP_WORD, () => canvas.state().group, (g) => canvas.setGr
506
506
  canvas.setAside(!railBand.matches);
507
507
  railBand.addEventListener('change', () => canvas.setAside(!railBand.matches));
508
508
  pickFrom(el.tbDensity, DENSITY_WORD, () => canvas.state().density, (d) => canvas.setDensity(d));
509
- pickFrom(el.tbShow, SHOW, () => show, (key) => setShow(key));
509
+ // Show: by state, and by the model the work ran on. The two are separate choices and both hold at once.
510
+ let showModel = null;
511
+ function setShowModel(family) {
512
+ showModel = family;
513
+ canvas.setModelFilter(family);
514
+ timeline.setModelFilter(family);
515
+ list.render?.();
516
+ tbValue.show.textContent = [SHOW[show].word, family ? `${family[0].toUpperCase()}${family.slice(1)}` : null].filter(Boolean).join(' \u00b7 ');
517
+ }
518
+ el.tbShow.addEventListener('click', (e) => {
519
+ e.stopPropagation();
520
+ const items = Object.entries(SHOW).map(([key, w]) => ({ label: w.word, checked: show === key, run: () => { setShow(key); setShowModel(showModel); } }));
521
+ const families = [...new Set((lastNodes ?? []).filter((n) => n.kind === 'agent').map((n) => modelFamily(n.model)).filter(Boolean))].sort();
522
+ if (families.length) {
523
+ items.push({ note: 'Model' }, { label: 'Every model', checked: showModel === null, run: () => setShowModel(null) },
524
+ ...families.map((f) => ({ label: `Only ${f[0].toUpperCase()}${f.slice(1)}`, checked: showModel === f, run: () => setShowModel(f) })));
525
+ }
526
+ openMenu(el.tbShow, items);
527
+ });
510
528
  // The toolbar is measured, not assumed: it gives up a step at a time until what it shows fits (toolbar-fit.js).
511
529
  // Every step is tried from the first, so a toolbar that has room again takes its words back.
512
530
  function fitToolbar() {
@@ -849,6 +867,24 @@ function paintAgentOverview(body, n, detail) {
849
867
  }
850
868
  body.append(grid);
851
869
 
870
+ // What it ran on and what its call asked for, and what the model gate or a low-confidence report did to it.
871
+ const models = modelLines(n, lastNodes);
872
+ if (models.length) {
873
+ const dl = node('dl', 'imodel');
874
+ for (const m of models) {
875
+ const dd = node('dd', null, m.text);
876
+ if (m.note) dd.title = m.note;
877
+ if (m.goto) {
878
+ const go = node('button', 'ilink', 'show');
879
+ go.type = 'button';
880
+ go.addEventListener('click', () => goTo(m.goto));
881
+ dd.append(' ', go);
882
+ }
883
+ dl.append(node('dt', null, m.label), dd);
884
+ }
885
+ body.append(isection('Model', dl));
886
+ }
887
+
852
888
  const gap = n.kind === 'agent' ? detailGap(n, detail) : null;
853
889
  const read = n.kind === 'agent' && !gap ? detail : null;
854
890
 
@@ -1500,7 +1536,7 @@ function sessionRow(project, sn) {
1500
1536
  if (subText) row.append(marked(subText, 'sub srow-sub'));
1501
1537
  // What it spent, once the server has read it: time · tokens · ~cost. Asked for a few rows at a time.
1502
1538
  const spent = usageFor(sn);
1503
- const line = usageLine(spent);
1539
+ const line = usageLine(spent, { live: sessionStatus(sn.sessionId, fleetData).known && sessionStatus(sn.sessionId, fleetData).key !== 'ended', now: serverNow() });
1504
1540
  if (line) {
1505
1541
  const u = node('span', 'sub srow-usage', line);
1506
1542
  u.title = costNote(spent);
@@ -2278,6 +2314,7 @@ function paintStageNote() {
2278
2314
  stageNote.button.hidden = !off;
2279
2315
  // The fleet can say a session ended, or came back, with no transcript changing.
2280
2316
  if (lastGraph && sessionIsLive() !== lastLive) showGraph();
2317
+ else if (lastLive === true) paintSpend();
2281
2318
  paintTerminalWait();
2282
2319
  }
2283
2320
 
@@ -2285,13 +2322,19 @@ function paintStageNote() {
2285
2322
  * Draw the graph the stream last sent. What the server read from files is settled against what the machine says
2286
2323
  * of the session first, so this is called again when that changes with no file changing.
2287
2324
  */
2325
+ /** The summary box, from the session on screen. A live session's time is counted to now, so this runs on the tick. */
2326
+ function paintSpend() {
2327
+ const usage = lastNodes?.find((n) => n.kind === 'session')?.usage ?? null;
2328
+ canvas.setSpend(summaryRows(usage, { live: lastLive === true, now: serverNow() }));
2329
+ }
2330
+
2288
2331
  function showGraph() {
2289
2332
  if (!lastGraph) return;
2290
2333
  lastLive = sessionIsLive();
2291
2334
  const g = settle(lastGraph, lastLive);
2292
2335
  canvas.render(g);
2293
- canvas.setSpend(summaryRows(g.nodes.find((n) => n.kind === 'session')?.usage ?? null));
2294
2336
  lastNodes = g.nodes;
2337
+ paintSpend();
2295
2338
  timeline.setNodes(g.nodes);
2296
2339
  list.setNodes(g.nodes);
2297
2340
  paintAttention(g.nodes);
@@ -11,10 +11,12 @@
11
11
  // A status is never colour alone: every dot has its word beside it.
12
12
  // A poll that changed nothing writes nothing. Each element remembers what it last drew.
13
13
  import {
14
- plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES,
14
+ plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES, INFERRED_NOTE,
15
15
  } from './graph-plan.js';
16
+ import { clock } from './timeline-plan.js';
16
17
  import { agentStatus } from './nav.js';
17
- import { roleName, shownType, hoverOf } from './names.js';
18
+ import { roleName, shownType, hoverOf, orderTag, modelOf, modelMix, modelFamily } from './names.js';
19
+ import { fmtCount, fmtCost, COST_NOTE } from './usage.js';
18
20
 
19
21
  // How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
20
22
  // and anything a viewer has to wait through is a cost they pay on every spawn.
@@ -92,6 +94,7 @@ export class Canvas {
92
94
  this.palette = { map: {} };
93
95
  this.icons = {};
94
96
  this.filter = null; // statuses the page asked to isolate
97
+ this.modelFilter = null; // a family of model the page asked to isolate
95
98
  this.waiting = new Set(); // agents parked on an approval
96
99
  this.sessionState = null; // { word, tone } for the session card's pill
97
100
  this.focusId = null; // show only this branch
@@ -161,7 +164,7 @@ export class Canvas {
161
164
  close.innerHTML = MARK.close;
162
165
  close.addEventListener('click', () => this.showSpend(false));
163
166
  head.append(close);
164
- this.spendRows = mk('dl', 'cv-spend-rows');
167
+ this.spendRows = mk('div', 'cv-spend-rows');
165
168
  box.append(head, this.spendRows);
166
169
  this.spendOpen = readFlag('crewforth-studio-spend') !== 'closed';
167
170
  box.hidden = true; // until there is a session's usage to show
@@ -174,19 +177,61 @@ export class Canvas {
174
177
  this.spendEl.hidden = !on || !this.spendCount;
175
178
  }
176
179
 
177
- /** The rows to show: [label, value, note], from usage.js `summaryRows`. An empty list hides the box. */
180
+ /**
181
+ * The rows to show, from usage.js `summaryRows`: { label, value, note, detail }. An empty list hides the box.
182
+ *
183
+ * A row with a `detail` is a control: a click opens the breakdown under it, and it stays open. Rows are kept and
184
+ * updated, never rebuilt — a live session's time changes every second, and a row replaced under a press would
185
+ * lose the click.
186
+ */
178
187
  setSpend(rows) {
179
- const sig = JSON.stringify(rows ?? []);
188
+ const list = rows ?? [];
189
+ const sig = JSON.stringify(list);
180
190
  if (sig === this.spendSig) return;
181
191
  this.spendSig = sig;
182
- this.spendCount = (rows ?? []).length;
183
- this.spendRows.replaceChildren(...(rows ?? []).flatMap(([label, value, note]) => {
184
- const dd = mk('dd', null, value ?? '');
185
- dd.title = note ?? '';
186
- const dt = mk('dt', null, label);
187
- dt.title = note ?? '';
188
- return [dt, dd];
189
- }));
192
+ this.spendCount = list.length;
193
+ this.spendParts ??= new Map();
194
+ this.spendShown ??= new Set();
195
+ const alive = new Set();
196
+ for (const r of list) {
197
+ alive.add(r.label);
198
+ let p = this.spendParts.get(r.label);
199
+ if (!p) {
200
+ p = { row: mk('button', 'cv-spend-row'), label: mk('span', 'cv-spend-label'), value: mk('span', 'cv-spend-value'), detail: mk('dl', 'cv-spend-detail') };
201
+ p.row.type = 'button';
202
+ p.row.append(p.label, p.value);
203
+ p.row.addEventListener('click', () => {
204
+ if (!p.row.classList.contains('cv-spend-more')) return;
205
+ if (this.spendShown.has(r.label)) this.spendShown.delete(r.label); else this.spendShown.add(r.label);
206
+ p.detail.hidden = !this.spendShown.has(r.label);
207
+ p.row.setAttribute('aria-expanded', String(!p.detail.hidden));
208
+ });
209
+ this.spendParts.set(r.label, p);
210
+ }
211
+ const more = Boolean(r.detail?.length);
212
+ p.label.textContent = r.label;
213
+ p.value.textContent = r.value ?? '';
214
+ p.row.title = more ? `${r.note ?? ''}\nClick for the breakdown` : (r.note ?? '');
215
+ p.row.classList.toggle('cv-spend-more', more);
216
+ if (more) p.row.setAttribute('aria-expanded', String(this.spendShown.has(r.label))); else p.row.removeAttribute?.('aria-expanded');
217
+ p.detail.hidden = !more || !this.spendShown.has(r.label);
218
+ const dsig = JSON.stringify(r.detail ?? []);
219
+ if (p.dsig !== dsig) {
220
+ p.dsig = dsig;
221
+ p.detail.replaceChildren(...(r.detail ?? []).flatMap(([k, v]) => [mk('dt', null, k), mk('dd', null, v)]));
222
+ }
223
+ }
224
+ for (const [label, p] of this.spendParts) {
225
+ if (alive.has(label)) continue;
226
+ this.spendParts.delete(label);
227
+ }
228
+ // Which rows there are changes rarely (a session's first agent, its first timed turn); what they say changes
229
+ // every second. Only the first puts elements back into the box, and it puts the same elements back.
230
+ const order = list.map((r) => r.label).join('\u0000');
231
+ if (order !== this.spendOrder) {
232
+ this.spendOrder = order;
233
+ this.spendRows.replaceChildren(...list.flatMap((r) => { const p = this.spendParts.get(r.label); return [p.row, p.detail]; }));
234
+ }
190
235
  this.spendEl.hidden = !this.spendOpen || !this.spendCount;
191
236
  }
192
237
 
@@ -213,6 +258,10 @@ export class Canvas {
213
258
  const rows = mk('div', 'cv-legend-grid');
214
259
  rows.append(line('live', 'working now'), line('waiting', 'needs you'), line('done', 'finished'), line('failed', 'failed'));
215
260
  box.append(rows);
261
+ // The one wire that is not a call: in the Order view, from an agent to the wave called after it reported.
262
+ const inferred = line('inferred', 'after it reported (inferred, not a call)');
263
+ inferred.title = INFERRED_NOTE;
264
+ box.append(inferred);
216
265
  const marks = mk('div', 'cv-legend-grid');
217
266
  this.legendBuiltin = mk('span', 'cv-tile cv-tile-sm');
218
267
  const crew = mk('span', 'cv-tile cv-tile-sm');
@@ -396,7 +445,17 @@ export class Canvas {
396
445
  this.#redraw();
397
446
  }
398
447
 
448
+ /** Show only the agents that ran on one family of model (`'opus'`), stepping the rest back. `null` shows all. */
449
+ setModelFilter(family) {
450
+ if ((family ?? null) === this.modelFilter) return;
451
+ this.modelFilter = family ?? null;
452
+ for (const el of this.els.values()) el.sig = null;
453
+ for (const el of this.cellEls.values()) el.sig = null;
454
+ if (this.lastPlan) this.#redraw();
455
+ }
456
+
399
457
  dimmed(n) {
458
+ if (n?.kind === 'agent' && this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
400
459
  if (!this.filter || n?.kind !== 'agent') return false;
401
460
  if (this.filterKeepsWaiting && this.waiting.has(n.id)) return false;
402
461
  return !this.filter.has(n.status);
@@ -647,7 +706,7 @@ export class Canvas {
647
706
  el.classList.toggle('cv-pinned', Boolean(it.pinned));
648
707
  if (it.kind === 'session') this.#fillSession(el, it);
649
708
  else if (it.kind === 'group') this.#fillGroup(el, it, liveCells);
650
- else this.#fillAgent(el, it.node, it.compact);
709
+ else this.#fillAgent(el, it.node, it.compact, it.after ?? null);
651
710
  }
652
711
 
653
712
  /** The skeleton of a card: the parts it will fill. */
@@ -662,7 +721,11 @@ export class Canvas {
662
721
  row.append(p.tile, mk('span', 'cv-title', 'Session'), mk('span', 'cv-fill'), p.pill.el);
663
722
  p.name = mk('div', 'cv-name cv-indent');
664
723
  p.sub = mk('div', 'cv-sub cv-indent');
665
- el.append(row, p.name, p.sub);
724
+ // What it has running in the background takes a line of its own, when there is any: on the line above it
725
+ // ran out of the card.
726
+ p.bg = mk('div', 'cv-sub cv-indent cv-bg');
727
+ p.bg.hidden = true;
728
+ el.append(row, p.name, p.sub, p.bg);
666
729
  } else if (it.kind === 'group') {
667
730
  el.classList.add('cv-group');
668
731
  p.tile = mk('span', 'cv-tile');
@@ -680,24 +743,39 @@ export class Canvas {
680
743
  } else {
681
744
  const row = mk('div', 'cv-row cv-indent');
682
745
  row.append(p.bar, p.count);
683
- el.append(p.head, row);
746
+ // What its members add up to, and what they ran on. At compact density the card has no room for them.
747
+ p.totals = mk('div', 'cv-sub cv-indent cv-totals');
748
+ p.mix = mk('div', 'cv-sub cv-indent cv-mix');
749
+ el.append(p.head, row, p.totals, p.mix);
684
750
  }
685
751
  p.fold.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(it.id); });
686
752
  } else if (it.compact) {
687
753
  el.classList.add('cv-chip');
688
754
  p.dot = mk('span', 'dot');
755
+ p.order = mk('span', 'cv-order');
689
756
  p.name = mk('span', 'cv-name');
690
757
  p.task = mk('span', 'cv-task');
758
+ p.model = mk('span', 'cv-model');
691
759
  p.word = mk('span', 'cv-word');
692
- el.append(p.dot, p.name, p.task, p.word);
760
+ el.append(p.dot, p.order, p.name, p.task, p.model, p.word);
693
761
  } else {
694
762
  p.tile = mk('span', 'cv-tile');
695
763
  p.name = mk('span', 'cv-name');
764
+ p.order = mk('span', 'cv-order');
696
765
  p.pill = this.#pill();
697
766
  const row = mk('div', 'cv-row');
698
767
  row.append(p.tile, p.name, mk('span', 'cv-fill'), p.pill.el);
699
- p.task = mk('div', 'cv-task cv-indent');
700
- el.append(row, p.task);
768
+ // The second line: the task, and at its end where the card stands in the order of the calls. The name
769
+ // keeps the first line to itself.
770
+ p.task = mk('span', 'cv-task');
771
+ const under = mk('div', 'cv-under cv-indent');
772
+ under.append(p.task, p.order);
773
+ // The third line: what it ran on, how long it took and what it spent.
774
+ p.model = mk('span', 'cv-model');
775
+ p.facts = mk('span', 'cv-facts');
776
+ const facts = mk('div', 'cv-under cv-indent cv-third');
777
+ facts.append(p.model, p.facts);
778
+ el.append(row, under, facts);
701
779
  }
702
780
  el.parts = p;
703
781
  }
@@ -758,23 +836,31 @@ export class Canvas {
758
836
  p.pill.dot.dataset.tone = st?.tone ?? 'none';
759
837
  p.pill.word.textContent = st?.word ?? '';
760
838
  p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
839
+ // Cut to the card's width; hovering says it whole.
840
+ p.name.title = p.name.textContent;
761
841
  const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
762
842
  if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
763
- // What the session has running that is not an agent: commands sent to the background.
764
- if (bg) bits.push(`${bg} in background`);
765
843
  p.sub.textContent = bits.join(' · ');
766
- p.sub.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
844
+ p.sub.title = p.sub.textContent;
845
+ // What the session has running that is not an agent: commands sent to the background.
846
+ p.bg.hidden = !bg;
847
+ p.bg.textContent = bg ? `${bg} in background` : '';
848
+ p.bg.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
767
849
  el.title = 'Click to inspect this session';
768
850
  el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
769
851
  }
770
852
 
771
- #fillAgent(el, n, compact) {
853
+ #fillAgent(el, n, compact, after = null) {
772
854
  const st = this.#statusOf(n);
773
855
  const who = compact ? this.#identity(n) : this.#fillTile(el.parts.tile, n);
774
856
  const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : shownType(n);
775
857
  const task = n.kind === 'workflow' ? `${n.members ?? 0} agents` : (n.description ?? '');
776
858
  const dim = this.dimmed(n);
777
- const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status]);
859
+ // Its place in the order the session called its agents, and when: "#3 · 09:14". A chip has room for the number.
860
+ const tag = orderTag(n, compact ? null : clock);
861
+ const mdl = modelOf(n);
862
+ const facts = compact ? '' : factsOf(n);
863
+ const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status, tag, after, mdl.ran, mdl.differs, facts]);
778
864
  if (el.sig === sig) return;
779
865
  el.sig = sig;
780
866
  const p = el.parts;
@@ -789,6 +875,18 @@ export class Canvas {
789
875
  // Both are cut to the card's width; hovering says them whole, and the type as it is declared.
790
876
  p.name.title = n.kind === 'workflow' ? '' : (n.agentType ?? '');
791
877
  p.task.title = task;
878
+ p.order.textContent = tag;
879
+ p.order.hidden = !tag;
880
+ // The model it ran on, as a badge; marked when the call asked for another. A chip has room for the family.
881
+ const family = modelFamily(n.model);
882
+ p.model.textContent = `${compact ? (family ? family[0].toUpperCase() + family.slice(1) : mdl.ran) : mdl.ran}${mdl.differs ? ' \u2260' : ''}`;
883
+ p.model.hidden = !mdl.ran;
884
+ p.model.title = mdl.title;
885
+ p.model.dataset.differs = String(mdl.differs);
886
+ if (!compact) { p.facts.textContent = facts; p.facts.title = factsTitle(n); }
887
+ p.order.title = tag ? `Called ${orderTag(n, clock)} by the session` : '';
888
+ // In the Order view: what had reported before this wave was called. A reading of the order of events.
889
+ el.title = after?.length ? `Called after ${after.join(', ')} reported.\n${INFERRED_NOTE}` : '';
792
890
  if (compact) {
793
891
  p.dot.dataset.tone = st.tone ?? 'none';
794
892
  p.word.textContent = st.word;
@@ -806,7 +904,7 @@ export class Canvas {
806
904
  const p = el.parts;
807
905
  const counts = it.counts;
808
906
  const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
809
- const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status]);
907
+ const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status, it.first?.order ?? null, it.compact, it.sum, modelMix(it.members)]);
810
908
  if (el.sig !== sig) {
811
909
  el.sig = sig;
812
910
  el.dataset.state = stateOf(it.status);
@@ -820,7 +918,9 @@ export class Canvas {
820
918
  if (it.type === 'run') { p.tile.innerHTML = MARK.run; p.tile.title = 'Workflow run'; }
821
919
  else this.#fillTile(p.tile, it.type === 'type' ? { agentType: it.agentType } : (it.of ?? {}));
822
920
  // Several agents of one type: the number is a badge beside the role. Any other group says how many it holds.
823
- p.sub.textContent = it.type === 'type' ? `× ${it.count}` : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
921
+ const size = it.type === 'type' ? `× ${it.count}` : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
922
+ // The group stands where its first member does in the order of the calls.
923
+ p.sub.textContent = [orderTag(it.first), size].filter(Boolean).join(' · ');
824
924
  p.sub.classList.toggle('cv-times', it.type === 'type');
825
925
  p.fold.innerHTML = it.boxed ? MARK.down : MARK.chevron;
826
926
  // Read aloud, the badge is part of the name.
@@ -838,6 +938,7 @@ export class Canvas {
838
938
  }));
839
939
  p.bar.title = says;
840
940
  if (!it.boxed) p.count.textContent = says;
941
+ if (!it.boxed) this.#fillTotals(p, it);
841
942
  el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
842
943
  el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
843
944
  }
@@ -856,8 +957,9 @@ export class Canvas {
856
957
  c.tabIndex = 0;
857
958
  const q = {};
858
959
  if (!it.grid) { q.tile = mk('span', 'cv-tile'); c.append(q.tile); } else { q.dot = mk('span', 'dot'); c.append(q.dot); }
960
+ q.order = mk('span', 'cv-order');
859
961
  q.name = mk('span', 'cv-name');
860
- c.append(q.name);
962
+ c.append(q.order, q.name);
861
963
  if (!it.grid) { c.append(mk('span', 'cv-fill')); q.dot = mk('span', 'dot'); c.append(q.dot); }
862
964
  q.word = mk('span', 'cv-word');
863
965
  c.append(q.word);
@@ -879,7 +981,8 @@ export class Canvas {
879
981
  // Inside a group of one type the type is the group's name, so a member is told apart by its task.
880
982
  const text = it.type === 'type' ? (n.description || shownType(n) || n.id) : shownType(n);
881
983
  const dim = this.dimmed(n);
882
- const csig = JSON.stringify([text, st.word, st.tone, st.state, dim, this.selected === n.id]);
984
+ const ctag = orderTag(n, it.grid ? null : clock);
985
+ const csig = JSON.stringify([text, st.word, st.tone, st.state, dim, this.selected === n.id, ctag]);
883
986
  if (c.sig === csig) continue;
884
987
  c.sig = csig;
885
988
  c.dataset.state = st.state;
@@ -887,13 +990,56 @@ export class Canvas {
887
990
  c.classList.toggle('cv-dim', dim);
888
991
  c.classList.toggle('cv-selected', this.selected === n.id);
889
992
  c.parts.name.textContent = text;
890
- c.title = hoverOf(n);
993
+ c.parts.order.textContent = ctag;
994
+ c.parts.order.hidden = !ctag;
995
+ c.title = [hoverOf(n), [modelOf(n).ran, factsOf(n)].filter(Boolean).join(' \u00b7 ')].filter(Boolean).join('\n');
891
996
  c.parts.dot.dataset.tone = st.tone ?? 'none';
892
997
  c.parts.word.textContent = st.word;
893
998
  c.setAttribute('aria-label', `${shownType(n)}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
894
999
  }
895
1000
  }
896
1001
 
1002
+ /** A group as one card: the time, the tokens and the estimate its members add up to, and the models they ran on. */
1003
+ #fillTotals(p, it) {
1004
+ const s = it.sum ?? {};
1005
+ p.totals.hidden = Boolean(it.compact);
1006
+ p.mix.hidden = Boolean(it.compact);
1007
+ if (it.compact) return;
1008
+ p.totals.textContent = [
1009
+ s.timed ? fmtDuration(s.durationMs) : null,
1010
+ s.counted ? `${fmtCount(s.fresh)} new` : null,
1011
+ s.counted ? `${fmtCount(s.cacheRead)} cache` : null,
1012
+ s.counted ? fmtCost(s.cost) : null,
1013
+ ].filter(Boolean).join(' \u00b7 ');
1014
+ p.totals.title = `${it.members.length} calls. Time, new tokens, tokens read from the cache, and cost: ${COST_NOTE}`;
1015
+ p.mix.textContent = modelMix(it.members);
1016
+ p.mix.title = 'The models its agents ran on';
1017
+ }
1018
+
1019
+ /** What an inferred wire says: on hover that it is an inference, and once per reporter, in words beside it. */
1020
+ #edgeWords(path, e, c) {
1021
+ const title = e.title ?? '';
1022
+ if (path.said !== title) {
1023
+ path.said = title;
1024
+ path.querySelector?.('title')?.remove();
1025
+ if (title) {
1026
+ const t = document.createElementNS(SVG, 'title');
1027
+ t.textContent = title;
1028
+ path.append(t);
1029
+ }
1030
+ }
1031
+ if (!e.label) { path.label?.remove(); path.label = null; return; }
1032
+ if (!path.label) {
1033
+ path.label = document.createElementNS(SVG, 'text');
1034
+ path.label.setAttribute('class', 'cv-edge-label');
1035
+ this.edgeG.append(path.label);
1036
+ }
1037
+ path.label.textContent = e.label;
1038
+ // Just off the reporter's edge, above the wire, where it does not sit on a card.
1039
+ path.label.setAttribute('x', String(e.from.x + 8));
1040
+ path.label.setAttribute('y', String(e.from.y - 6));
1041
+ }
1042
+
897
1043
  // Wires are kept and updated, never rebuilt.
898
1044
  //
899
1045
  // Replacing the whole layer each pass was the reason motion could not live here: a CSS animation restarts when
@@ -932,9 +1078,13 @@ export class Canvas {
932
1078
 
933
1079
  const target = this.nodes.get(e.target);
934
1080
  const waiting = target && this.waiting.has(target.id);
935
- const state = waiting ? 'waiting' : e.state;
1081
+ // A wire that is a reading of the order of events says nothing about what its target is doing now.
1082
+ const inferred = e.kind === 'inferred';
1083
+ const state = inferred ? 'inferred' : (waiting ? 'waiting' : e.state);
936
1084
  path.dataset.status = e.status;
937
1085
  path.dataset.state = state;
1086
+ path.dataset.kind = inferred ? 'inferred' : 'call';
1087
+ this.#edgeWords(path, e, c);
938
1088
  path.classList.toggle('cv-dim', Boolean(target) && this.dimmed(target));
939
1089
  path.classList.toggle('cv-path', onPath.has(e.target));
940
1090
 
@@ -950,6 +1100,7 @@ export class Canvas {
950
1100
 
951
1101
  for (const [key, path] of this.edgeEls) {
952
1102
  if (alive.has(key)) continue;
1103
+ path.label?.remove();
953
1104
  path.remove();
954
1105
  this.edgeEls.delete(key);
955
1106
  }
@@ -1277,6 +1428,20 @@ function readPref(key, allowed, fallback) {
1277
1428
  return allowed.includes(v) ? v : fallback;
1278
1429
  }
1279
1430
 
1431
+ /** An agent's third line: how long it took and what it spent. */
1432
+ function factsOf(n) {
1433
+ return [
1434
+ n.durationMs != null ? fmtDuration(n.durationMs) : null,
1435
+ n.usage ? `${fmtCount(n.usage.tokens.fresh)} new` : null,
1436
+ n.usage ? fmtCost(n.usage.cost) : null,
1437
+ ].filter(Boolean).join(' \u00b7 ');
1438
+ }
1439
+ function factsTitle(n) {
1440
+ if (!n.usage) return '';
1441
+ const t = n.usage.tokens;
1442
+ return `New tokens ${fmtCount(t.fresh)} (input ${fmtCount(t.input)}, output ${fmtCount(t.output)}, cache write ${fmtCount(t.cacheWrite)}); read from cache ${fmtCount(t.cacheRead)}\n${COST_NOTE}`;
1443
+ }
1444
+
1280
1445
  function fmtTokens(t) {
1281
1446
  if (t == null) return '';
1282
1447
  if (t < 1000) return `${t}`;
@@ -370,7 +370,7 @@ class Pane {
370
370
 
371
371
  paintCard({ block, node }) {
372
372
  const card = delegationCard(block, this.hooks.agentsOf?.(this.id));
373
- const sig = `${card.type}|${card.task}|${card.agentId}|${card.status}`;
373
+ const sig = `${card.type}|${card.task}|${card.agentId}|${card.status}|${card.model.ran}|${card.model.asked}`;
374
374
  if (node.sig === sig) return;
375
375
  node.sig = sig;
376
376
  const text = el('span', 'deleg-text');
@@ -380,6 +380,13 @@ class Pane {
380
380
  const tile = card.agentId ? this.hooks.tileOf?.(this.id, card.agentId) : null;
381
381
  if (tile) parts.push(tile);
382
382
  parts.push(text);
383
+ // The model: what it ran on, or what the call asks for while the agent has not been seen yet.
384
+ const mword = card.model.ran || card.model.asked;
385
+ if (mword) {
386
+ const badge = el('span', 'deleg-model', `${mword}${card.model.differs ? ' \u2260' : ''}`);
387
+ badge.title = card.model.title;
388
+ parts.push(badge);
389
+ }
383
390
  // The agent's state is the graph's. Before the graph has seen the agent there is none to show.
384
391
  const st = card.agentId ? this.hooks.statusOf?.(this.id, card.agentId) : null;
385
392
  if (st?.word) {
@@ -3,7 +3,7 @@
3
3
  // The same shapes come from two places: the server's reading of a transcript (history, and sessions Studio only
4
4
  // watches) and the live stream of a session started here. Both go through these functions, so a tool row reads
5
5
  // the same whichever way it arrived.
6
- import { roleName } from './names.js';
6
+ import { roleName, modelOf } from './names.js';
7
7
 
8
8
  // How much of a tool's output the page keeps for a live result: the same tail the server keeps for history.
9
9
  const RESULT_TAIL = 4096;
@@ -20,6 +20,8 @@ export function toolBlock(c) {
20
20
  label: i.description ?? i.file_path ?? i.command ?? i.pattern ?? i.query ?? null,
21
21
  id: typeof c?.id === 'string' ? c.id : null,
22
22
  subagentType: typeof i.subagent_type === 'string' ? i.subagent_type : null,
23
+ // The model a delegation asks for; null when the call names none.
24
+ model: typeof i.model === 'string' ? i.model : null,
23
25
  result: null,
24
26
  };
25
27
  }
@@ -82,6 +84,8 @@ export function delegationCard(block, nodes) {
82
84
  task: block.label ?? node?.description ?? '',
83
85
  agentId: node?.id ?? null,
84
86
  status: node?.status ?? null,
87
+ // What it ran on once the graph knows the agent; before that, what the call asked for.
88
+ model: modelOf({ model: node?.model ?? null, modelAsked: block.model ?? node?.modelAsked ?? null }),
85
89
  };
86
90
  }
87
91