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
@@ -22,9 +22,12 @@ import { typeOf, roleName, shownType } from './names.js';
22
22
  export const SIZE = {
23
23
  padX: 24, padY: 16, // the canvas's own margin
24
24
  colGap: 56, // between one depth and the next
25
+ waveGap: 148, // between one wave and the next: room for the words on an inferred wire
25
26
  rowGap: 12, // between siblings
26
- session: { w: 200, h: 84 },
27
- 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
28
31
  chip: { w: 248, h: 36 }, // an agent at compact density
29
32
  group: { // an open group: a container holding its members
30
33
  padX: 8, top: 10, head: 20, gap: 6, bar: 4, bottom: 8,
@@ -48,7 +51,7 @@ export const AUTO = {
48
51
  stackAt: 3,
49
52
  };
50
53
 
51
- export const GROUPINGS = ['run', 'type', 'parent', 'none'];
54
+ export const GROUPINGS = ['run', 'type', 'parent', 'order', 'none'];
52
55
  export const DENSITIES = ['auto', 'comfortable', 'compact'];
53
56
 
54
57
  // The handful of things a status means to a reader. `killed` and `stopped` come off the transcript verbatim and
@@ -152,7 +155,10 @@ function itemsOf(nodes, { group, density, seq, stack, aside }) {
152
155
  const session = nodes.find((n) => n.kind === 'session');
153
156
  if (session) items.push({ id: session.id, kind: 'session', node: session, parent: null, depth: 0 });
154
157
 
155
- 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);
156
162
 
157
163
  // `anchor` is the item a node's wire comes out of; `via` is the member row inside it, when the parent is one.
158
164
  const place = (parentId, anchor, via, depth) => {
@@ -215,6 +221,23 @@ function itemsOf(nodes, { group, density, seq, stack, aside }) {
215
221
  return items;
216
222
  }
217
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
+
218
241
  function groupItem(id, type, members, anchor, via, depth, seq, extra) {
219
242
  return {
220
243
  id,
@@ -227,10 +250,16 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
227
250
  via,
228
251
  depth,
229
252
  seq: Math.min(...members.map((m) => seq.get(m.id) ?? 0), Infinity),
253
+ // The group stands where its first member does in the order the session called its agents.
254
+ first: members.filter((m) => m.order != null).sort((a, b) => a.order - b.order)[0] ?? null,
255
+ sum: sumOf(members),
230
256
  ...extra,
231
257
  };
232
258
  }
233
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
+
234
263
  /* ----------------------------------------------------------------- plan --- */
235
264
 
236
265
  /**
@@ -248,6 +277,7 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
248
277
  export function plan(nodes, opts = {}) {
249
278
  const group = GROUPINGS.includes(opts.group) ? opts.group : 'run';
250
279
  const asked = DENSITIES.includes(opts.density) ? opts.density : 'auto';
280
+ if (group === 'order') return planOrder(nodes, asked, opts);
251
281
  const seq = opts.seq ?? sequence(nodes);
252
282
  const open = opts.open ?? new Map();
253
283
  const pinned = opts.pinned ?? new Map();
@@ -280,7 +310,7 @@ export function plan(nodes, opts = {}) {
280
310
  /** Size and place everything drawn, at one density. Returns the box the picture needs. */
281
311
  const lay = (drawnItems, compact) => {
282
312
  for (const it of drawnItems) {
283
- 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; }
284
314
  if (it.kind !== 'group') {
285
315
  const sz = compact ? SIZE.chip : SIZE.card;
286
316
  it.w = sz.w; it.h = sz.h; it.compact = compact;
@@ -289,7 +319,8 @@ export function plan(nodes, opts = {}) {
289
319
  it.open = isOpen(it);
290
320
  // `boxed` is the group drawn as a container with its members inside. Open beside itself it stays a card.
291
321
  it.boxed = it.open && !it.aside;
292
- 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; }
293
324
  const n = it.members.length;
294
325
  const grid = compact || n > G.rowsMax;
295
326
  const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
@@ -422,6 +453,108 @@ export function plan(nodes, opts = {}) {
422
453
  };
423
454
  }
424
455
 
456
+ /* ---------------------------------------------------------------- order --- */
457
+
458
+ // What the wire from an agent to a later wave is called, and what it is: a reading of the order of events.
459
+ export const INFERRED_NOTE = 'Inferred from the order of events: this wave was called after that agent reported. It is not a call from one agent to another; the session calls every agent.';
460
+
461
+ /**
462
+ * The same agents by when the session called them.
463
+ *
464
+ * Every agent is called by the session, so the tree is one level deep and says nothing of sequence. Here a
465
+ * column is a WAVE: the agents called in one assistant message, which is what "started together" means. Waves
466
+ * run left to right in the order the messages were written, and a wave's agents stack in the order of the calls.
467
+ * Agents the session's transcript did not call (a workflow's) have no wave and take a last column of their own.
468
+ *
469
+ * Two kinds of wire. The session is wired to the first wave: it called it. A later wave is wired, dotted, from
470
+ * each agent that reported after the wave before it was called and before this one was — an inference from the
471
+ * order of events, drawn as one and labelled as one. A wave nothing reported before has no wire.
472
+ */
473
+ function planOrder(nodes, asked, opts) {
474
+ const pinned = opts.pinned ?? new Map();
475
+ const session = nodes.find((n) => n.kind === 'session') ?? null;
476
+ const sessionH = sessionHeight(session);
477
+ const agents = nodes.filter((n) => n.kind === 'agent');
478
+ const called = agents.filter((n) => n.wave != null);
479
+ const waveIds = [...new Set(called.map((n) => n.wave))].sort((a, b) => a - b);
480
+ const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.id).localeCompare(String(b.id));
481
+ const columns = waveIds.map((w) => ({ wave: w, members: called.filter((n) => n.wave === w).sort(byOrder) }));
482
+ for (const c of columns) c.calledAt = Math.min(...c.members.map((m) => m.calledAt ?? Infinity));
483
+ const rest = agents.filter((n) => n.wave == null)
484
+ .sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
485
+ if (rest.length) columns.push({ wave: null, members: rest, calledAt: Infinity });
486
+
487
+ const lay = (compact) => {
488
+ const sz = compact ? SIZE.chip : SIZE.card;
489
+ const items = [];
490
+ const x0 = SIZE.padX + (session ? SIZE.session.w + SIZE.colGap : 0);
491
+ let height = 0;
492
+ columns.forEach((col, i) => {
493
+ col.members.forEach((n, j) => {
494
+ items.push({
495
+ id: n.id, kind: 'agent', node: n, parent: null, via: null, depth: i + 1, seq: n.order ?? j, compact,
496
+ wave: col.wave, w: sz.w, h: sz.h, x: x0 + i * (sz.w + SIZE.waveGap), y: SIZE.padY + j * (sz.h + SIZE.rowGap),
497
+ });
498
+ });
499
+ height = Math.max(height, SIZE.padY * 2 + col.members.length * (sz.h + SIZE.rowGap) - SIZE.rowGap);
500
+ });
501
+ const width = x0 + Math.max(0, columns.length * (sz.w + SIZE.waveGap) - SIZE.waveGap) + SIZE.padX;
502
+ return { items, width, height };
503
+ };
504
+ let compact = asked === 'compact';
505
+ let box = lay(compact);
506
+ if (asked === 'auto' && box.height > AUTO.tallest) { compact = true; box = lay(true); }
507
+ const { items } = box;
508
+ let { height } = box;
509
+
510
+ let sessionY = opts.sessionY ?? null;
511
+ if (session) {
512
+ const first = items.filter((it) => it.depth === 1);
513
+ if (sessionY == null) {
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));
516
+ }
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);
519
+ }
520
+ for (const it of items) {
521
+ const p = pinned.get(it.id);
522
+ if (p) { it.x = p.x; it.y = p.y; it.pinned = true; }
523
+ }
524
+
525
+ const at = new Map(items.map((it) => [it.id, it]));
526
+ const edges = [];
527
+ const wire = (from, to, extra) => edges.push({
528
+ key: `${from.id}\u0000${to.id}`, source: from.id, target: to.id,
529
+ from: { x: from.x + from.w, y: from.y + from.h / 2 }, to: { x: to.x, y: to.y + to.h / 2 },
530
+ status: to.node.status ?? 'unknown', state: stateOf(to.node.status), ...extra,
531
+ });
532
+ const waves = columns.filter((c) => c.wave != null);
533
+ if (session && waves.length) for (const m of waves[0].members) wire(at.get(session.id), at.get(m.id), {});
534
+ // An agent's report belongs to the first wave called after it: the one that could have been waiting for it.
535
+ for (let i = 1; i < waves.length; i += 1) {
536
+ const since = waves[i - 1].calledAt;
537
+ const reporters = called.filter((n) => n.reportedAt != null && n.reportedAt > since && n.reportedAt <= waves[i].calledAt
538
+ && n.wave < waves[i].wave).sort(byOrder);
539
+ waves[i].after = reporters;
540
+ for (const r of reporters) {
541
+ waves[i].members.forEach((m, k) => wire(at.get(r.id), at.get(m.id), {
542
+ kind: 'inferred', state: 'inferred',
543
+ // Said once for a reporter, on the wire to the wave's first agent; the others carry it on hover.
544
+ label: k === 0 ? `after ${shownType(r)} reported` : null,
545
+ title: `after ${shownType(r)} reported\n${INFERRED_NOTE}`,
546
+ }));
547
+ }
548
+ for (const m of waves[i].members) at.get(m.id).after = reporters.map((r) => shownType(r));
549
+ }
550
+
551
+ return {
552
+ items, edges, width: Math.max(box.width, SIZE.padX * 2), height: Math.max(height, SIZE.padY * 2),
553
+ drawn: agents.length, density: compact ? 'compact' : 'comfortable', group: 'order', stacked: false, aside: false,
554
+ waves: waves.length, sessionY,
555
+ };
556
+ }
557
+
425
558
  /**
426
559
  * Which open groups to fold when a session first opens too tall even as chips: the largest first, until the
427
560
  * picture is short enough to read or there is nothing left to fold. Returns the ids to fold; an empty list when
@@ -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>';
@@ -31,3 +31,68 @@ export const shownType = (n) => roleName(typeOf(n));
31
31
 
32
32
  /** What hovering an agent says: the type as it is declared, and its task in full, since both are cut on screen. */
33
33
  export const hoverOf = (n) => [n?.agentType ?? null, n?.description ?? null].filter(Boolean).join('\n');
34
+
35
+ /**
36
+ * Where an agent stands among the ones the session called, as a tag: "#3 · 09:14". The number is the order of
37
+ * the calls; the time is when the call was made, in the viewer's own zone. An agent the session's transcript did
38
+ * not call has no place to state, and gets no tag.
39
+ * @param clock (ms) => "HH:MM"
40
+ */
41
+ export function orderTag(n, clock = null) {
42
+ if (n?.order == null) return '';
43
+ return `#${n.order}${clock && n.calledAt != null ? ` · ${clock(n.calledAt)}` : ''}`;
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
+ }
@@ -836,6 +836,42 @@ button.tl-lab { cursor: pointer; }
836
836
  }
837
837
 
838
838
  /* Working. The brightest and the only one that travels. */
839
+ /* A wire that is an inference, not a call: dotted, still, and it can be hovered for what it means. A working wire
840
+ is dashed and moves; this one is neither. */
841
+ .cv-edge[data-kind="inferred"] {
842
+ stroke: var(--ink-faint);
843
+ stroke-dasharray: 1.5 5;
844
+ pointer-events: stroke;
845
+ }
846
+ .cv-edge-label { font-size: 10px; fill: var(--ink-faint); pointer-events: none; }
847
+ /* "#3 · 09:14": where a card stands in the order the session called its agents. */
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); }
870
+ .cv-under .cv-task { flex: 1 1 auto; min-width: 0; }
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; }
872
+ .cv-order[hidden] { display: none; }
873
+ .cv-chip .cv-order, .cv-member .cv-order { margin-left: 0; margin-right: 6px; }
874
+
839
875
  .cv-edge[data-state="live"] {
840
876
  stroke: var(--status-busy);
841
877
  opacity: var(--cv-edge-live);
@@ -946,7 +982,7 @@ button.tl-lab { cursor: pointer; }
946
982
  .cv-row { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
947
983
  .cv-fill { flex: 1; min-width: 0; }
948
984
  .cv-indent { padding-left: 32px; }
949
- .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; }
950
986
  .cv-name {
951
987
  min-width: 0; font-family: var(--font-mono); font-size: 12px; line-height: 16px; color: var(--ink);
952
988
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@@ -955,7 +991,12 @@ button.tl-lab { cursor: pointer; }
955
991
  min-width: 0; font-size: 13px; line-height: 18px; color: var(--ink-dim);
956
992
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
957
993
  }
958
- .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%; }
959
1000
  .cv-word { font-size: 11px; color: var(--ink-dim); white-space: nowrap; flex: none; }
960
1001
 
961
1002
  /* The tile says whose agent it is. Crewforth's mark keeps its own colours; the
@@ -1075,10 +1116,27 @@ button.tl-lab { cursor: pointer; }
1075
1116
  }
1076
1117
  .cv-legend[hidden] { display: none; }
1077
1118
  /* The session's summary is the same kind of box, in the opposite corner. */
1078
- .cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 220px; }
1079
- .cv-spend-rows { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: var(--space-1) var(--space-3); font-size: 12px; }
1080
- .cv-spend-rows dt { color: var(--ink-dim); }
1081
- .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); }
1082
1140
  .cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
1083
1141
  .cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
1084
1142
  .cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
@@ -1086,6 +1144,7 @@ button.tl-lab { cursor: pointer; }
1086
1144
  .cv-legend-line[data-state="live"] { border-top: 1.5px dashed var(--status-busy); }
1087
1145
  .cv-legend-line[data-state="waiting"] { border-top: 1.5px dashed var(--status-waiting); }
1088
1146
  .cv-legend-line[data-state="failed"] { border-top-color: var(--cv-fail); }
1147
+ .cv-legend-line[data-state="inferred"] { border-top: 1.5px dotted var(--ink-faint); }
1089
1148
  .cv-x {
1090
1149
  width: 20px; height: 20px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
1091
1150
  background: none; border: 0; border-radius: var(--radius-sm); color: var(--ink-dim); cursor: pointer;
@@ -120,6 +120,8 @@ export function groupsOf(nodes, grouping) {
120
120
  return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${shownType(p)}` };
121
121
  }
122
122
  if (grouping === 'none') return { id: 'all', label: null };
123
+ // `order`: the agents called in one assistant message are a wave, and a wave is a group.
124
+ if (grouping === 'order') return n.wave != null ? { id: `wave:${n.wave}`, label: `Wave ${n.wave}` } : { id: 'wave:none', label: 'Not called by the session' };
123
125
  // `run`: a workflow run is a group; everything the session started itself is another.
124
126
  if (n.workflow) return { id: `run:${n.workflow}`, label: String(n.workflow) };
125
127
  return { id: 'direct', label: 'Session · direct' };
@@ -135,6 +137,12 @@ export function groupsOf(nodes, grouping) {
135
137
  for (const g of out) g.members.sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
136
138
  // What the session did itself comes first; the rest in the order they began.
137
139
  out.sort((a, b) => Number(b.id === 'direct') - Number(a.id === 'direct') || first(a) - first(b) || a.id.localeCompare(b.id));
140
+ // Waves are in the order they were called, which is not always the order their agents first wrote.
141
+ if (grouping === 'order') {
142
+ const wave = (g) => g.members[0].wave ?? Infinity;
143
+ out.sort((a, b) => wave(a) - wave(b));
144
+ for (const g of out) g.members.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
145
+ }
138
146
  return out;
139
147
  }
140
148
 
@@ -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.3",
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"