crewforth 3.1.0-rc.4 → 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.
@@ -36,55 +36,115 @@ export function usageOfRecord(r) {
36
36
  };
37
37
  }
38
38
 
39
- export class Usage {
39
+ const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
40
+
41
+ /** One running sum: tokens by kind, what they cost, and which models had no price. */
42
+ class Tally {
40
43
  constructor() {
41
- this.seen = new Set();
42
44
  this.tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
43
45
  this.usd = 0;
44
46
  this.responses = 0;
45
- this.priced = new Set();
46
47
  this.unpriced = new Set();
47
48
  }
48
49
 
49
- /** Add a transcript's records. A response already counted, from this file or another, is not counted again. */
50
- add(records) {
50
+ take(u, usd) {
51
+ this.responses += 1;
52
+ this.tokens.input += u.input;
53
+ this.tokens.output += u.output;
54
+ this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
55
+ this.tokens.cacheRead += u.cacheRead;
56
+ if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
57
+ else this.usd += usd;
58
+ }
59
+
60
+ /**
61
+ * `fresh` is what was sent and written for the first time: input, output and cache writes. `cacheRead` is the
62
+ * same context read back from the cache on every later response, which is why it dwarfs the rest in a long
63
+ * session and is kept out of the headline. `cost` is null when any of the tokens came from a model the price
64
+ * table has no row for — a sum that leaves some of them out would read as the whole.
65
+ */
66
+ result() {
67
+ const t = this.tokens;
68
+ return {
69
+ tokens: { ...t, fresh: t.input + t.output + t.cacheWrite, total: KINDS.reduce((a, k) => a + t[k], 0) },
70
+ responses: this.responses,
71
+ cost: this.unpriced.size ? null : this.usd,
72
+ unpriced: [...this.unpriced].sort(),
73
+ };
74
+ }
75
+ }
76
+
77
+ export class Usage {
78
+ constructor() {
79
+ this.seen = new Set();
80
+ this.all = new Tally();
81
+ this.who = new Map(); // 'session', or an agent's id -> Tally
82
+ this.model = new Map(); // model id -> Tally
83
+ this.first = null; // the earliest and the latest record of any file added
84
+ this.last = null;
85
+ }
86
+
87
+ #tally(map, key) {
88
+ if (!map.has(key)) map.set(key, new Tally());
89
+ return map.get(key);
90
+ }
91
+
92
+ /**
93
+ * Add a transcript's records. A response already counted, from this file or another, is not counted again.
94
+ * @param who 'session' for the session's own transcript, or the id of the agent whose transcript this is.
95
+ * A record the session's transcript marks as an agent's (`isSidechain`) is counted as an agent's.
96
+ */
97
+ add(records, who = 'session') {
51
98
  for (const r of records ?? []) {
99
+ const t = r?.timestamp ? Date.parse(r.timestamp) : NaN;
100
+ if (Number.isFinite(t)) {
101
+ if (this.first === null || t < this.first) this.first = t;
102
+ if (this.last === null || t > this.last) this.last = t;
103
+ }
52
104
  const u = usageOfRecord(r);
53
105
  if (!u) continue;
54
106
  if (u.id) { if (this.seen.has(u.id)) continue; this.seen.add(u.id); }
55
- const total = u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead;
56
- if (!total) continue; // a record the harness made up for itself carries no tokens
57
- this.responses += 1;
58
- this.tokens.input += u.input;
59
- this.tokens.output += u.output;
60
- this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
61
- this.tokens.cacheRead += u.cacheRead;
107
+ if (!(u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead)) continue; // a record the harness made up carries no tokens
62
108
  const usd = costOf(u);
63
- if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
64
- else { this.usd += usd; this.priced.add(u.model); }
109
+ this.all.take(u, usd);
110
+ this.#tally(this.who, who === 'session' && r.isSidechain === true ? 'agents:inline' : who).take(u, usd);
111
+ this.#tally(this.model, u.model ?? 'unknown model').take(u, usd);
65
112
  }
66
113
  return this;
67
114
  }
68
115
 
116
+ /** One agent's own share, or null when its transcript carried no usage. */
117
+ of(who) { return this.who.get(who)?.result() ?? null; }
118
+
69
119
  /**
70
- * @returns tokens by kind and in total; `cost` in USD at list price, or null when any of the tokens came from
71
- * a model the table has no row for — a sum that leaves some of them out would read as the whole.
120
+ * @returns the whole session's tally, and the same split two ways: `parts.session` is what the session's own
121
+ * transcript spent and `parts.agents` everything its agents did; `byModel` is one tally per model.
72
122
  */
73
123
  result() {
74
- const t = this.tokens;
124
+ const agents = new Tally();
125
+ for (const [who, t] of this.who) {
126
+ if (who === 'session') continue;
127
+ for (const k of KINDS) agents.tokens[k] += t.tokens[k];
128
+ agents.usd += t.usd;
129
+ agents.responses += t.responses;
130
+ for (const m of t.unpriced) agents.unpriced.add(m);
131
+ }
132
+ const all = this.all.result();
75
133
  return {
76
- tokens: { ...t, total: t.input + t.output + t.cacheWrite + t.cacheRead },
77
- responses: this.responses,
78
- cost: this.unpriced.size ? null : this.usd,
79
- models: [...this.priced].sort(),
80
- unpriced: [...this.unpriced].sort(),
134
+ ...all,
135
+ models: [...this.model.keys()].filter((m) => !all.unpriced.includes(m)).sort(),
136
+ parts: { session: (this.who.get('session') ?? new Tally()).result(), agents: agents.result() },
137
+ byModel: Object.fromEntries([...this.model].map(([m, t]) => [m, t.result()]).sort((a, b) => b[1].tokens.total - a[1].tokens.total)),
138
+ startedAt: this.first,
139
+ lastAt: this.last,
81
140
  priceSource: SOURCE,
82
141
  };
83
142
  }
84
143
  }
85
144
 
86
145
  /**
87
- * How long a session took, two ways. `durationMs` is from its first record to its last, pauses included.
146
+ * How long a session's own transcript says it took. `durationMs` is from its first record to its last, pauses
147
+ * included; with its agents' transcripts counted too, that span is `Usage`'s `startedAt` to `lastAt`.
88
148
  * `workedMs` is the turns Claude Code timed itself (`system` / `turn_duration` records), added up; zero when it
89
149
  * recorded none, and a turn that is still running is not in it.
90
150
  */
@@ -107,6 +167,16 @@ export function timesOf(records) {
107
167
  return { durationMs: first !== null && last !== null ? last - first : null, workedMs, turns };
108
168
  }
109
169
 
170
+ /**
171
+ * The usage with the times beside it. The span runs to the last record of ANY of the session's files: an agent
172
+ * can be writing long after the session's own transcript last moved.
173
+ */
174
+ export function withTimes(usage, mainRecords) {
175
+ const u = usage.result();
176
+ const t = timesOf(mainRecords);
177
+ return { ...u, workedMs: t.workedMs, turns: t.turns, durationMs: u.startedAt !== null && u.lastAt !== null ? u.lastAt - u.startedAt : null };
178
+ }
179
+
110
180
  /** A session's usage with its agents', and how long it took. */
111
181
  export async function sessionUsage(session) {
112
182
  const usage = new Usage();
@@ -114,9 +184,10 @@ export async function sessionUsage(session) {
114
184
  usage.add(records);
115
185
  for (const meta of await agentMetaFiles(session.subagentsDir)) {
116
186
  const jsonl = meta.replace(/\.meta\.json$/, '.jsonl');
117
- try { usage.add((await readAll(jsonl)).records); } catch { /* an agent that has not written yet */ }
187
+ const agentId = path.basename(jsonl).slice('agent-'.length, -'.jsonl'.length);
188
+ try { usage.add((await readAll(jsonl)).records, agentId); } catch { /* an agent that has not written yet */ }
118
189
  }
119
- return { ...usage.result(), ...timesOf(records) };
190
+ return withTimes(usage, records);
120
191
  }
121
192
 
122
193
  // One answer per session, kept until one of its files changes. The key is what the stream's own signature
@@ -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;
@@ -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);
@@ -15,7 +15,8 @@ import {
15
15
  } from './graph-plan.js';
16
16
  import { clock } from './timeline-plan.js';
17
17
  import { agentStatus } from './nav.js';
18
- import { roleName, shownType, hoverOf, orderTag } from './names.js';
18
+ import { roleName, shownType, hoverOf, orderTag, modelOf, modelMix, modelFamily } from './names.js';
19
+ import { fmtCount, fmtCost, COST_NOTE } from './usage.js';
19
20
 
20
21
  // How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
21
22
  // and anything a viewer has to wait through is a cost they pay on every spawn.
@@ -93,6 +94,7 @@ export class Canvas {
93
94
  this.palette = { map: {} };
94
95
  this.icons = {};
95
96
  this.filter = null; // statuses the page asked to isolate
97
+ this.modelFilter = null; // a family of model the page asked to isolate
96
98
  this.waiting = new Set(); // agents parked on an approval
97
99
  this.sessionState = null; // { word, tone } for the session card's pill
98
100
  this.focusId = null; // show only this branch
@@ -162,7 +164,7 @@ export class Canvas {
162
164
  close.innerHTML = MARK.close;
163
165
  close.addEventListener('click', () => this.showSpend(false));
164
166
  head.append(close);
165
- this.spendRows = mk('dl', 'cv-spend-rows');
167
+ this.spendRows = mk('div', 'cv-spend-rows');
166
168
  box.append(head, this.spendRows);
167
169
  this.spendOpen = readFlag('crewforth-studio-spend') !== 'closed';
168
170
  box.hidden = true; // until there is a session's usage to show
@@ -175,19 +177,61 @@ export class Canvas {
175
177
  this.spendEl.hidden = !on || !this.spendCount;
176
178
  }
177
179
 
178
- /** 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
+ */
179
187
  setSpend(rows) {
180
- const sig = JSON.stringify(rows ?? []);
188
+ const list = rows ?? [];
189
+ const sig = JSON.stringify(list);
181
190
  if (sig === this.spendSig) return;
182
191
  this.spendSig = sig;
183
- this.spendCount = (rows ?? []).length;
184
- this.spendRows.replaceChildren(...(rows ?? []).flatMap(([label, value, note]) => {
185
- const dd = mk('dd', null, value ?? '');
186
- dd.title = note ?? '';
187
- const dt = mk('dt', null, label);
188
- dt.title = note ?? '';
189
- return [dt, dd];
190
- }));
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
+ }
191
235
  this.spendEl.hidden = !this.spendOpen || !this.spendCount;
192
236
  }
193
237
 
@@ -401,7 +445,17 @@ export class Canvas {
401
445
  this.#redraw();
402
446
  }
403
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
+
404
457
  dimmed(n) {
458
+ if (n?.kind === 'agent' && this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
405
459
  if (!this.filter || n?.kind !== 'agent') return false;
406
460
  if (this.filterKeepsWaiting && this.waiting.has(n.id)) return false;
407
461
  return !this.filter.has(n.status);
@@ -667,7 +721,11 @@ export class Canvas {
667
721
  row.append(p.tile, mk('span', 'cv-title', 'Session'), mk('span', 'cv-fill'), p.pill.el);
668
722
  p.name = mk('div', 'cv-name cv-indent');
669
723
  p.sub = mk('div', 'cv-sub cv-indent');
670
- 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);
671
729
  } else if (it.kind === 'group') {
672
730
  el.classList.add('cv-group');
673
731
  p.tile = mk('span', 'cv-tile');
@@ -685,7 +743,10 @@ export class Canvas {
685
743
  } else {
686
744
  const row = mk('div', 'cv-row cv-indent');
687
745
  row.append(p.bar, p.count);
688
- 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);
689
750
  }
690
751
  p.fold.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(it.id); });
691
752
  } else if (it.compact) {
@@ -694,8 +755,9 @@ export class Canvas {
694
755
  p.order = mk('span', 'cv-order');
695
756
  p.name = mk('span', 'cv-name');
696
757
  p.task = mk('span', 'cv-task');
758
+ p.model = mk('span', 'cv-model');
697
759
  p.word = mk('span', 'cv-word');
698
- el.append(p.dot, p.order, p.name, p.task, p.word);
760
+ el.append(p.dot, p.order, p.name, p.task, p.model, p.word);
699
761
  } else {
700
762
  p.tile = mk('span', 'cv-tile');
701
763
  p.name = mk('span', 'cv-name');
@@ -708,7 +770,12 @@ export class Canvas {
708
770
  p.task = mk('span', 'cv-task');
709
771
  const under = mk('div', 'cv-under cv-indent');
710
772
  under.append(p.task, p.order);
711
- el.append(row, under);
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);
712
779
  }
713
780
  el.parts = p;
714
781
  }
@@ -769,12 +836,16 @@ export class Canvas {
769
836
  p.pill.dot.dataset.tone = st?.tone ?? 'none';
770
837
  p.pill.word.textContent = st?.word ?? '';
771
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;
772
841
  const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
773
842
  if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
774
- // What the session has running that is not an agent: commands sent to the background.
775
- if (bg) bits.push(`${bg} in background`);
776
843
  p.sub.textContent = bits.join(' · ');
777
- 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') : '';
778
849
  el.title = 'Click to inspect this session';
779
850
  el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
780
851
  }
@@ -787,7 +858,9 @@ export class Canvas {
787
858
  const dim = this.dimmed(n);
788
859
  // Its place in the order the session called its agents, and when: "#3 · 09:14". A chip has room for the number.
789
860
  const tag = orderTag(n, compact ? null : clock);
790
- const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status, tag, after]);
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]);
791
864
  if (el.sig === sig) return;
792
865
  el.sig = sig;
793
866
  const p = el.parts;
@@ -804,6 +877,13 @@ export class Canvas {
804
877
  p.task.title = task;
805
878
  p.order.textContent = tag;
806
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); }
807
887
  p.order.title = tag ? `Called ${orderTag(n, clock)} by the session` : '';
808
888
  // In the Order view: what had reported before this wave was called. A reading of the order of events.
809
889
  el.title = after?.length ? `Called after ${after.join(', ')} reported.\n${INFERRED_NOTE}` : '';
@@ -824,7 +904,7 @@ export class Canvas {
824
904
  const p = el.parts;
825
905
  const counts = it.counts;
826
906
  const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
827
- const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status, it.first?.order ?? null]);
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)]);
828
908
  if (el.sig !== sig) {
829
909
  el.sig = sig;
830
910
  el.dataset.state = stateOf(it.status);
@@ -858,6 +938,7 @@ export class Canvas {
858
938
  }));
859
939
  p.bar.title = says;
860
940
  if (!it.boxed) p.count.textContent = says;
941
+ if (!it.boxed) this.#fillTotals(p, it);
861
942
  el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
862
943
  el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
863
944
  }
@@ -911,13 +992,30 @@ export class Canvas {
911
992
  c.parts.name.textContent = text;
912
993
  c.parts.order.textContent = ctag;
913
994
  c.parts.order.hidden = !ctag;
914
- c.title = hoverOf(n);
995
+ c.title = [hoverOf(n), [modelOf(n).ran, factsOf(n)].filter(Boolean).join(' \u00b7 ')].filter(Boolean).join('\n');
915
996
  c.parts.dot.dataset.tone = st.tone ?? 'none';
916
997
  c.parts.word.textContent = st.word;
917
998
  c.setAttribute('aria-label', `${shownType(n)}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
918
999
  }
919
1000
  }
920
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
+
921
1019
  /** What an inferred wire says: on hover that it is an inference, and once per reporter, in words beside it. */
922
1020
  #edgeWords(path, e, c) {
923
1021
  const title = e.title ?? '';
@@ -1330,6 +1428,20 @@ function readPref(key, allowed, fallback) {
1330
1428
  return allowed.includes(v) ? v : fallback;
1331
1429
  }
1332
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
+
1333
1445
  function fmtTokens(t) {
1334
1446
  if (t == null) return '';
1335
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