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.
@@ -24,8 +24,10 @@ export const SIZE = {
24
24
  colGap: 56, // between one depth and the next
25
25
  waveGap: 148, // between one wave and the next: room for the words on an inferred wire
26
26
  rowGap: 12, // between siblings
27
- session: { w: 200, h: 84 },
28
- card: { w: 248, h: 64 }, // an agent, and a folded group
27
+ session: { w: 200, h: 84, line: 20 }, // `line`: the extra line a session with background commands has
28
+ card: { w: 248, h: 84 }, // an agent: its name, its task, and a line of facts (model, time, tokens)
29
+ stack: { w: 248, h: 116, tight: 64 }, // a group drawn as one card: its name, its bar, its totals, its models;
30
+ // `tight` at compact density, where the two lines of totals go
29
31
  chip: { w: 248, h: 36 }, // an agent at compact density
30
32
  group: { // an open group: a container holding its members
31
33
  padX: 8, top: 10, head: 20, gap: 6, bar: 4, bottom: 8,
@@ -153,7 +155,10 @@ function itemsOf(nodes, { group, density, seq, stack, aside }) {
153
155
  const session = nodes.find((n) => n.kind === 'session');
154
156
  if (session) items.push({ id: session.id, kind: 'session', node: session, parent: null, depth: 0 });
155
157
 
156
- const stackAt = group === 'type' ? 2 : (density === 'auto' && group !== 'none' && stack ? AUTO.stackAt : Infinity);
158
+ // Agents of one type under one parent are one card under `type` and under `run`, from two and wherever they
159
+ // stand in the order: three Privacy agents called fifth, eighth and ninth are the same thing three times.
160
+ // Under `parent` they fold only when the picture is too tall; under `none`, never.
161
+ const stackAt = group === 'type' || group === 'run' ? 2 : (density === 'auto' && group !== 'none' && stack ? AUTO.stackAt : Infinity);
157
162
 
158
163
  // `anchor` is the item a node's wire comes out of; `via` is the member row inside it, when the parent is one.
159
164
  const place = (parentId, anchor, via, depth) => {
@@ -216,6 +221,23 @@ function itemsOf(nodes, { group, density, seq, stack, aside }) {
216
221
  return items;
217
222
  }
218
223
 
224
+ /**
225
+ * What a group's members add up to: time, new tokens, tokens read from the cache, and the estimate. A member with
226
+ * no usage read adds nothing; the cost is null when any member's is, so a sum never passes for the whole.
227
+ */
228
+ export function sumOf(members) {
229
+ const sum = { durationMs: 0, fresh: 0, cacheRead: 0, cost: 0, timed: 0, counted: 0 };
230
+ for (const m of members ?? []) {
231
+ if (m.durationMs != null) { sum.durationMs += m.durationMs; sum.timed += 1; }
232
+ if (!m.usage) continue;
233
+ sum.counted += 1;
234
+ sum.fresh += m.usage.tokens?.fresh ?? 0;
235
+ sum.cacheRead += m.usage.tokens?.cacheRead ?? 0;
236
+ sum.cost = sum.cost === null || m.usage.cost == null ? null : sum.cost + m.usage.cost;
237
+ }
238
+ return sum;
239
+ }
240
+
219
241
  function groupItem(id, type, members, anchor, via, depth, seq, extra) {
220
242
  return {
221
243
  id,
@@ -230,10 +252,14 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
230
252
  seq: Math.min(...members.map((m) => seq.get(m.id) ?? 0), Infinity),
231
253
  // The group stands where its first member does in the order the session called its agents.
232
254
  first: members.filter((m) => m.order != null).sort((a, b) => a.order - b.order)[0] ?? null,
255
+ sum: sumOf(members),
233
256
  ...extra,
234
257
  };
235
258
  }
236
259
 
260
+ /** A session's card: one line taller while it has commands running in the background, and never wider. */
261
+ export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0);
262
+
237
263
  /* ----------------------------------------------------------------- plan --- */
238
264
 
239
265
  /**
@@ -284,7 +310,7 @@ export function plan(nodes, opts = {}) {
284
310
  /** Size and place everything drawn, at one density. Returns the box the picture needs. */
285
311
  const lay = (drawnItems, compact) => {
286
312
  for (const it of drawnItems) {
287
- if (it.kind === 'session') { it.w = SIZE.session.w; it.h = SIZE.session.h; continue; }
313
+ if (it.kind === 'session') { it.w = SIZE.session.w; it.h = sessionHeight(it.node); continue; }
288
314
  if (it.kind !== 'group') {
289
315
  const sz = compact ? SIZE.chip : SIZE.card;
290
316
  it.w = sz.w; it.h = sz.h; it.compact = compact;
@@ -293,7 +319,8 @@ export function plan(nodes, opts = {}) {
293
319
  it.open = isOpen(it);
294
320
  // `boxed` is the group drawn as a container with its members inside. Open beside itself it stays a card.
295
321
  it.boxed = it.open && !it.aside;
296
- if (!it.boxed) { it.w = SIZE.card.w; it.h = SIZE.card.h; it.cells = null; continue; }
322
+ it.compact = compact;
323
+ if (!it.boxed) { it.w = SIZE.stack.w; it.h = compact ? SIZE.stack.tight : SIZE.stack.h; it.cells = null; continue; }
297
324
  const n = it.members.length;
298
325
  const grid = compact || n > G.rowsMax;
299
326
  const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
@@ -446,6 +473,7 @@ export const INFERRED_NOTE = 'Inferred from the order of events: this wave was c
446
473
  function planOrder(nodes, asked, opts) {
447
474
  const pinned = opts.pinned ?? new Map();
448
475
  const session = nodes.find((n) => n.kind === 'session') ?? null;
476
+ const sessionH = sessionHeight(session);
449
477
  const agents = nodes.filter((n) => n.kind === 'agent');
450
478
  const called = agents.filter((n) => n.wave != null);
451
479
  const waveIds = [...new Set(called.map((n) => n.wave))].sort((a, b) => a - b);
@@ -483,11 +511,11 @@ function planOrder(nodes, asked, opts) {
483
511
  if (session) {
484
512
  const first = items.filter((it) => it.depth === 1);
485
513
  if (sessionY == null) {
486
- const bottom = first.length ? Math.max(...first.map((it) => it.y + it.h)) : SIZE.padY + SIZE.session.h;
487
- sessionY = Math.max(SIZE.padY, Math.round((SIZE.padY + bottom) / 2 - SIZE.session.h / 2));
514
+ const bottom = first.length ? Math.max(...first.map((it) => it.y + it.h)) : SIZE.padY + sessionH;
515
+ sessionY = Math.max(SIZE.padY, Math.round((SIZE.padY + bottom) / 2 - sessionH / 2));
488
516
  }
489
- items.unshift({ id: session.id, kind: 'session', node: session, parent: null, depth: 0, w: SIZE.session.w, h: SIZE.session.h, x: SIZE.padX, y: sessionY });
490
- height = Math.max(height, sessionY + SIZE.session.h + SIZE.padY);
517
+ items.unshift({ id: session.id, kind: 'session', node: session, parent: null, depth: 0, w: SIZE.session.w, h: sessionH, x: SIZE.padX, y: sessionY });
518
+ height = Math.max(height, sessionY + sessionH + SIZE.padY);
491
519
  }
492
520
  for (const it of items) {
493
521
  const p = pinned.get(it.id);
@@ -3,7 +3,7 @@
3
3
  // Functions of the server's data, with no page in them: the four tiles, what the agent is doing right now, which
4
4
  // skills it applied, who delegated to it. The rule they share is the panel's own: a value that was not read is
5
5
  // written "Not measured", with why — never as 0 and never as an empty list.
6
- import { roleName } from './names.js';
6
+ import { roleName, modelName, modelFamily, modelOf } from './names.js';
7
7
 
8
8
  const NOT_MEASURED = 'Not measured';
9
9
 
@@ -100,6 +100,38 @@ export function backgroundLines(session, now) {
100
100
  }));
101
101
  }
102
102
 
103
+ /**
104
+ * What the inspector says about an agent's model, as lines: { label, text, goto }.
105
+ *
106
+ * Two facts are kept apart: what the call ASKED for (its own `model` field; none means the agent's default) and
107
+ * what the agent RAN on (the model its own transcript names). Then, when the transcript shows them: the call the
108
+ * model gate refused first, and the same work repeated one model up after a report that closed `confidence: low`.
109
+ * `goto` is the id of the other agent a line is about, for a line that leads somewhere.
110
+ */
111
+ export function modelLines(n, nodes) {
112
+ if (n?.kind !== 'agent') return [];
113
+ const m = modelOf(n);
114
+ const lines = [
115
+ { label: 'Ran on', text: n.model ? `${m.ran} \u00b7 ${n.model}` : 'not recorded' },
116
+ { label: 'Asked for', text: n.modelAsked ? `${n.modelAsked}${m.differs ? ' \u2014 differs from what ran' : ''}` : 'no model named (the agent\'s default)' },
117
+ ];
118
+ // The family is the word the choice is made in (haiku, sonnet, opus); a model with none keeps its own name.
119
+ const short = (model) => (model ? (modelFamily(model) ?? modelName(model).toLowerCase()) : 'no model');
120
+ if (n.firstTry) {
121
+ lines.push({
122
+ label: 'Model gate',
123
+ text: `first asked: ${short(n.firstTry.asked)} \u2192 refused${n.firstTry.floor ? ` (floor: ${n.firstTry.floor})` : ''} \u2192 ${short(n.modelAsked ?? n.model)}`,
124
+ note: n.firstTry.text,
125
+ });
126
+ }
127
+ const tagOf = (id) => { const o = (nodes ?? []).find((x) => x.id === id); return o?.order != null ? ` (#${o.order})` : ''; };
128
+ if (n.escalatedFrom) {
129
+ lines.push({ label: 'Escalated', text: `escalated: ${short(n.escalatedFrom.from)} \u2192 ${short(n.escalatedFrom.to)}, after${tagOf(n.escalatedFrom.id)} closed "confidence: low"`, goto: n.escalatedFrom.id });
130
+ }
131
+ if (n.escalatedTo) lines.push({ label: 'Escalated', text: `closed "confidence: low"; repeated one model up${tagOf(n.escalatedTo)}`, goto: n.escalatedTo });
132
+ return lines;
133
+ }
134
+
103
135
  /** Who handed this agent its work: the node it hangs from. */
104
136
  export function delegatedBy(n, nodes) {
105
137
  const p = (nodes ?? []).find((x) => x.id === n.parentId) ?? null;
@@ -4,7 +4,7 @@
4
4
  // own three answers, so in this view the approval dock is not needed and is not shown.
5
5
  import { sections, wants } from './list-plan.js';
6
6
  import { Press } from './press.js';
7
- import { hoverOf } from './names.js';
7
+ import { hoverOf, modelOf } from './names.js';
8
8
  import { remaining, asker, allowSessionLabel, VERDICTS } from './approvals.js';
9
9
 
10
10
  function mk(tag, cls, text) {
@@ -147,8 +147,14 @@ export class List {
147
147
  if (tile) row.append(tile);
148
148
  const text = mk('span', 'ls-text');
149
149
  text.append(mk('span', 'ls-name', r.type), mk('span', 'ls-line', r.line));
150
- row.title = hoverOf(r.node);
150
+ const mdl = modelOf(r.node);
151
+ row.title = [hoverOf(r.node), mdl.ran ? mdl.title : null].filter(Boolean).join('\n');
151
152
  row.append(text);
153
+ if (mdl.ran) {
154
+ const badge = mk('span', 'sub ls-model', `${mdl.ran}${mdl.differs ? ' \u2260' : ''}`);
155
+ badge.title = mdl.title;
156
+ row.append(badge);
157
+ }
152
158
  if (r.aside) row.append(mk('span', 'sub ls-aside', r.aside));
153
159
  const chev = mk('span', 'ls-chev');
154
160
  chev.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>';
@@ -42,3 +42,57 @@ export function orderTag(n, clock = null) {
42
42
  if (n?.order == null) return '';
43
43
  return `#${n.order}${clock && n.calledAt != null ? ` · ${clock(n.calledAt)}` : ''}`;
44
44
  }
45
+
46
+ /* ---------------------------------------------------------------- models --- */
47
+
48
+ const MODEL_FAMILIES = ['haiku', 'sonnet', 'opus', 'fable', 'mythos'];
49
+
50
+ /** The family a model belongs to, from its id or from the short name a call asks with: 'sonnet'. Null when none. */
51
+ export function modelFamily(model) {
52
+ if (typeof model !== 'string') return null;
53
+ return model.toLowerCase().split(/[^a-z]+/).find((w) => MODEL_FAMILIES.includes(w)) ?? null;
54
+ }
55
+
56
+ /**
57
+ * A model as a badge says it: `claude-haiku-5-5` → Haiku 5.5 · `claude-haiku-4-5-20251001` → Haiku 4.5 ·
58
+ * `sonnet` → Sonnet. The vendor prefix and a snapshot date go; the words lead and the numbers follow with a dot.
59
+ * A name that is not made of such parts is returned as it came.
60
+ */
61
+ export function modelName(model) {
62
+ if (typeof model !== 'string' || !model) return '';
63
+ const parts = model.replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-');
64
+ if (!parts.every((p) => /^[a-z]+$/.test(p) || /^\d+$/.test(p))) return model;
65
+ const words = parts.filter((p) => /^[a-z]+$/.test(p)).map((w) => w[0].toUpperCase() + w.slice(1));
66
+ const nums = parts.filter((p) => /^\d+$/.test(p));
67
+ return [words.join(' '), nums.join('.')].filter(Boolean).join(' ');
68
+ }
69
+
70
+ /**
71
+ * What a node ran on and what its call asked for, and whether the two agree.
72
+ * A call asks with a family ("sonnet") or with an id; it agrees with what ran when the family is the same and,
73
+ * if it named an id, the id is. A call that named nothing asked for the agent's default, and agrees with anything.
74
+ * @returns { ran, asked, differs, title } — `ran` and `asked` are badge words, '' when not known
75
+ */
76
+ export function modelOf(n) {
77
+ const ran = modelName(n?.model);
78
+ const asked = n?.modelAsked ? modelName(n.modelAsked) : '';
79
+ const differs = Boolean(n?.model && n?.modelAsked)
80
+ && (modelFamily(n.model) !== modelFamily(n.modelAsked) || (n.modelAsked.includes('-') && n.modelAsked !== n.model));
81
+ const title = [
82
+ `Ran on: ${n?.model ?? 'not recorded'}`,
83
+ `Asked for: ${n?.modelAsked ?? 'no model named (the agent\'s default)'}`,
84
+ differs ? 'The two differ.' : null,
85
+ ].filter(Boolean).join('\n');
86
+ return { ran, asked, differs, title };
87
+ }
88
+
89
+ /** "2 Sonnet, 1 Opus": how many of a group's agents ran on each family, most first. */
90
+ export function modelMix(members) {
91
+ const counts = new Map();
92
+ for (const m of members ?? []) {
93
+ const f = modelFamily(m.model);
94
+ const word = f ? f[0].toUpperCase() + f.slice(1) : (m.model ? modelName(m.model) : null);
95
+ if (word) counts.set(word, (counts.get(word) ?? 0) + 1);
96
+ }
97
+ return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([w, c]) => `${c} ${w}`).join(', ');
98
+ }
@@ -846,6 +846,27 @@ button.tl-lab { cursor: pointer; }
846
846
  .cv-edge-label { font-size: 10px; fill: var(--ink-faint); pointer-events: none; }
847
847
  /* "#3 · 09:14": where a card stands in the order the session called its agents. */
848
848
  .cv-under { display: flex; align-items: baseline; min-width: 0; }
849
+ /* The model a card ran on: a small badge, whole. What follows it gives way. */
850
+ .cv-model {
851
+ flex: none; padding: 0 5px; border-radius: var(--radius-sm); background: var(--surface-raised);
852
+ font-size: 10px; line-height: 16px; color: var(--ink-dim); white-space: nowrap;
853
+ }
854
+ .cv-model[hidden] { display: none; }
855
+ .cv-model[data-differs="true"] { color: var(--status-waiting); }
856
+ .cv-third { gap: 6px; }
857
+ .cv-facts { flex: 1 1 auto; min-width: 0; font-size: 11px; line-height: 16px; color: var(--ink-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
858
+ .cv-totals { font-variant-numeric: tabular-nums; }
859
+ /* The same badge outside the graph: a Timeline row, a List row, a delegation card. */
860
+ .tl-model, .ls-model, .deleg-model {
861
+ flex: none; padding: 0 5px; margin-right: 6px; border-radius: var(--radius-sm); background: var(--surface-raised);
862
+ font-size: 10px; line-height: 16px; color: var(--ink-dim); white-space: nowrap;
863
+ }
864
+ .tl-model[hidden] { display: none; }
865
+ .imodel { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 12px; }
866
+ .imodel dt { color: var(--ink-faint); }
867
+ .imodel dd { margin: 0; word-break: break-word; }
868
+ .ilink { border: 0; padding: 0; background: none; font: inherit; color: var(--studio-accent); cursor: pointer; text-decoration: underline; }
869
+ .cv-mix { color: var(--ink-faint); }
849
870
  .cv-under .cv-task { flex: 1 1 auto; min-width: 0; }
850
871
  .cv-order { flex: none; margin-left: 6px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
851
872
  .cv-order[hidden] { display: none; }
@@ -961,7 +982,7 @@ button.tl-lab { cursor: pointer; }
961
982
  .cv-row { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
962
983
  .cv-fill { flex: 1; min-width: 0; }
963
984
  .cv-indent { padding-left: 32px; }
964
- .cv-title { font-size: 13px; font-weight: 600; }
985
+ .cv-title { font-size: 13px; font-weight: 600; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
965
986
  .cv-name {
966
987
  min-width: 0; font-family: var(--font-mono); font-size: 12px; line-height: 16px; color: var(--ink);
967
988
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@@ -970,7 +991,12 @@ button.tl-lab { cursor: pointer; }
970
991
  min-width: 0; font-size: 13px; line-height: 18px; color: var(--ink-dim);
971
992
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
972
993
  }
973
- .cv-sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: none; }
994
+ /* Nothing a card says may run out of it. Every piece of text in a card is one line that gives way with an
995
+ ellipsis; the pieces that must stay whole (the status pill, the tile, the order tag) are short by construction
996
+ and do not shrink. */
997
+ .cv-sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
998
+ .cv-sub[hidden] { display: none; }
999
+ .cv-node > .cv-row, .cv-node > .cv-under, .cv-node > .cv-name, .cv-node > .cv-sub { max-width: 100%; }
974
1000
  .cv-word { font-size: 11px; color: var(--ink-dim); white-space: nowrap; flex: none; }
975
1001
 
976
1002
  /* The tile says whose agent it is. Crewforth's mark keeps its own colours; the
@@ -1090,10 +1116,27 @@ button.tl-lab { cursor: pointer; }
1090
1116
  }
1091
1117
  .cv-legend[hidden] { display: none; }
1092
1118
  /* The session's summary is the same kind of box, in the opposite corner. */
1093
- .cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 220px; }
1094
- .cv-spend-rows { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: var(--space-1) var(--space-3); font-size: 12px; }
1095
- .cv-spend-rows dt { color: var(--ink-dim); }
1096
- .cv-spend-rows dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; color: var(--ink); }
1119
+ .cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 240px; }
1120
+ .cv-spend-rows { display: flex; flex-direction: column; gap: 2px; font-size: 12px; min-width: 0; }
1121
+ .cv-spend-row {
1122
+ display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; width: 100%;
1123
+ padding: 2px 4px; margin: 0 -4px; border: 0; border-radius: var(--radius-sm);
1124
+ background: none; font: inherit; color: inherit; text-align: left; cursor: default;
1125
+ }
1126
+ .cv-spend-label { flex: 1 1 auto; min-width: 0; color: var(--ink-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1127
+ .cv-spend-value { flex: none; font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; }
1128
+ /* A row that opens: it looks like something to press, and says so with a mark that turns. */
1129
+ .cv-spend-more { cursor: pointer; }
1130
+ .cv-spend-more:hover { background: var(--surface-raised); }
1131
+ .cv-spend-more .cv-spend-label::before { content: '\203A'; display: inline-block; width: 10px; color: var(--ink-faint); }
1132
+ .cv-spend-more[aria-expanded="true"] .cv-spend-label::before { rotate: 90deg; }
1133
+ .cv-spend-detail {
1134
+ margin: 0 0 var(--space-1) 10px; padding-left: var(--space-2); border-left: 1px solid var(--line);
1135
+ display: grid; grid-template-columns: 1fr auto; gap: 2px var(--space-3);
1136
+ }
1137
+ .cv-spend-detail[hidden] { display: none; }
1138
+ .cv-spend-detail dt { color: var(--ink-faint); }
1139
+ .cv-spend-detail dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; color: var(--ink-dim); }
1097
1140
  .cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
1098
1141
  .cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
1099
1142
  .cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
@@ -6,7 +6,7 @@ import {
6
6
  rows, groupsOf, extent, windowOf, place, ticks, clock, stepRange, waitNote, factsOf, xOf, RANGES, DEFAULT_RANGE,
7
7
  } from './timeline-plan.js';
8
8
  import { Press } from './press.js';
9
- import { shownType, hoverOf } from './names.js';
9
+ import { shownType, hoverOf, modelOf, modelFamily } from './names.js';
10
10
 
11
11
 
12
12
  function mk(tag, cls, text) {
@@ -116,6 +116,7 @@ export class Timeline {
116
116
  setGroup(g) { if (g === this.group) return; this.group = g; this.folded = new Set(); this.expanded = new Set(); this.render(); }
117
117
  setWaiting(ids) { this.waiting = new Set(ids ?? []); this.render(); }
118
118
  setFilter(statuses, { keepWaiting = false } = {}) { this.filter = statuses ? new Set(statuses) : null; this.keepWaiting = keepWaiting; this.render(); }
119
+ setModelFilter(family) { this.modelFilter = family ?? null; this.render(); }
119
120
  setOwned(summary) { this.owned = summary ?? null; this.render(); }
120
121
  setLive(live) { if (live === this.live) return; this.live = live; this.render(); this.#changed(); }
121
122
 
@@ -159,6 +160,7 @@ export class Timeline {
159
160
  }
160
161
 
161
162
  dimmed(n) {
163
+ if (this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
162
164
  if (!this.filter) return false;
163
165
  if (this.keepWaiting && this.waiting.has(n.id)) return false;
164
166
  return !this.filter.has(n.status);
@@ -320,8 +322,13 @@ export class Timeline {
320
322
  if (tile) lab.append(tile);
321
323
  const sub = mk('span', 'sub', r.sub);
322
324
  if (r.tone) sub.dataset.tone = r.tone;
323
- lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), sub);
324
- lab.title = hoverOf(r.node);
325
+ // The model the row's agent ran on, before its state.
326
+ const mdl = modelOf(r.node);
327
+ const badge = mk('span', 'tl-model', `${mdl.ran}${mdl.differs ? ' \u2260' : ''}`);
328
+ badge.hidden = !mdl.ran;
329
+ badge.title = mdl.title;
330
+ lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), badge, sub);
331
+ lab.title = [hoverOf(r.node), mdl.ran ? mdl.title : null].filter(Boolean).join('\n');
325
332
  // One listener, on the row: the label is a button inside it, and its click arrives here too. Two
326
333
  // listeners chose the agent and un-chose it in the same click.
327
334
  row.addEventListener('click', () => this.#choose(r.id));
@@ -5,6 +5,8 @@
5
5
  // "~", and a note that says what it is. A session with tokens from a model the price table does not have gets
6
6
  // a dash, not a smaller number.
7
7
 
8
+ import { modelName } from './names.js';
9
+
8
10
  export const COST_NOTE = 'Estimated at API list price; on a subscription it is not what you are billed';
9
11
  export const NO_COST = '—';
10
12
 
@@ -54,24 +56,63 @@ export function costNote(u) {
54
56
  return `No estimate: the price table has no row for ${u.unpriced.join(', ')}`;
55
57
  }
56
58
 
57
- /** The navigator's one line: time · tokens · ~cost. Null when nothing was read. */
58
- export function usageLine(u) {
59
+ /** "40s", "4m 40s", "3h 5m": a span that is being counted, so the seconds show while they matter. */
60
+ export function fmtRunning(ms) {
61
+ if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
62
+ const s = Math.floor(ms / 1000);
63
+ if (s < 60) return `${s}s`;
64
+ if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
65
+ return fmtSpan(ms);
66
+ }
67
+
68
+ /**
69
+ * The navigator's one line: time · new tokens · ~cost. Null when nothing was read.
70
+ * @param live the session is running: its time is counted from its first record to now
71
+ */
72
+ export function usageLine(u, { live = false, now = null } = {}) {
59
73
  if (!u) return null;
60
74
  const t = timeOf(u);
61
- const tokens = typeof u.tokens === 'number' ? u.tokens : u.tokens?.total;
62
- return [t ? fmtSpan(t.ms) : null, tokens ? fmtCount(tokens) : null, tokens ? fmtCost(u.cost) : null].filter(Boolean).join(' · ') || null;
75
+ const span = live && u.startedAt != null && now != null ? fmtSpan(now - u.startedAt) : (t ? fmtSpan(t.ms) : null);
76
+ const tokens = typeof u.tokens === 'number' ? u.tokens : u.tokens?.fresh;
77
+ return [span, tokens ? fmtCount(tokens) : null, tokens ? fmtCost(u.cost) : null].filter(Boolean).join(' · ') || null;
63
78
  }
64
79
 
65
- /** The summary box's rows: [label, value, note]. */
66
- export function summaryRows(u) {
80
+ const kinds = (k, cached = true) => [
81
+ ['Input', fmtCount(k?.input ?? 0)], ['Output', fmtCount(k?.output ?? 0)], ['Cache write', fmtCount(k?.cacheWrite ?? 0)],
82
+ ...(cached ? [['Cache read', fmtCount(k?.cacheRead ?? 0)]] : []),
83
+ ];
84
+
85
+ /**
86
+ * The summary box's rows: { label, value, note, detail }. `detail` is what a click on the row opens: the same
87
+ * number broken down, as [name, value] pairs.
88
+ *
89
+ * "Tokens" is what was new: input, output and cache writes. What was read back from the cache is its own row —
90
+ * it is the same context counted again on every response, and added in it made a session that had just opened
91
+ * read as millions.
92
+ *
93
+ * @param opts.live the session is running: its time is counted from its first record to `opts.now`
94
+ */
95
+ export function summaryRows(u, { live = false, now = null } = {}) {
67
96
  if (!u) return [];
68
- const t = timeOf(u);
69
97
  const k = u.tokens ?? {};
70
98
  const rows = [];
71
- if (t) rows.push([t.kind === 'worked' ? 'Working time' : 'First to last record', fmtSpan(t.ms), t.note]);
72
- if (t?.kind === 'worked' && u.durationMs != null) rows.push(['First to last record', fmtSpan(u.durationMs), 'Pauses included']);
73
- rows.push(['Tokens', fmtCount(k.total ?? 0),
74
- `Agents included, each response counted once\ninput ${fmtCount(k.input ?? 0)} · output ${fmtCount(k.output ?? 0)} · cache write ${fmtCount(k.cacheWrite ?? 0)} · cache read ${fmtCount(k.cacheRead ?? 0)}`]);
75
- rows.push(['Cost', k.total ? fmtCost(u.cost) : NO_COST, costNote(u)]);
99
+ const t = timeOf(u);
100
+ if (live && u.startedAt != null && now != null) {
101
+ rows.push({ label: 'Running for', value: fmtRunning(now - u.startedAt), note: 'From the session\'s first record to now' });
102
+ } else if (t) {
103
+ rows.push({ label: t.kind === 'worked' ? 'Working time' : 'First to last record', value: fmtSpan(t.ms), note: t.note });
104
+ if (t.kind === 'worked' && u.durationMs != null) rows.push({ label: 'First to last record', value: fmtSpan(u.durationMs), note: 'Pauses included; the agents\' transcripts too' });
105
+ }
106
+ rows.push({ label: 'Tokens', value: fmtCount(k.fresh ?? 0), note: 'New tokens: input, output and cache writes. Agents included, each response counted once', detail: kinds(k, false) });
107
+ rows.push({ label: 'From cache', value: fmtCount(k.cacheRead ?? 0), note: 'Read back from the prompt cache: the same context, counted on every response that reused it' });
108
+ const part = (label, p, note) => {
109
+ if (!p?.tokens?.total) return;
110
+ rows.push({ label, value: `${fmtCount(p.tokens.fresh)} · ${fmtCost(p.cost)}`, note: `${note}: new tokens and estimated cost\n${costNote(p)}`, detail: kinds(p.tokens) });
111
+ };
112
+ part('Session', u.parts?.session, 'The session\'s own transcript');
113
+ part('Agents', u.parts?.agents, 'Everything its agents did');
114
+ // Behind the cost: each model's new tokens and what they come to. A model with no price says so with a dash.
115
+ const perModel = Object.entries(u.byModel ?? {}).map(([model, t]) => [modelName(model), `${fmtCount(t.tokens.fresh)} \u00b7 ${fmtCost(t.cost)}`]);
116
+ rows.push({ label: 'Cost', value: k.total ? fmtCost(u.cost) : NO_COST, note: costNote(u), detail: perModel.length ? perModel : undefined });
76
117
  return rows;
77
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.1.0-rc.4",
3
+ "version": "3.1.0-rc.5",
4
4
  "description": "Crewforth — your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"