crewforth 3.1.0-rc.4 → 3.1.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/kit/AGENT_TEMPLATE.md +48 -19
  4. package/kit/CLAUDE.md +13 -0
  5. package/kit/agents/crew-backend-expert.md +8 -0
  6. package/kit/agents/crew-commit-agent.md +8 -0
  7. package/kit/agents/crew-database-expert.md +8 -0
  8. package/kit/agents/crew-devops-expert.md +8 -0
  9. package/kit/agents/crew-frontend-expert.md +8 -0
  10. package/kit/agents/crew-performance-expert.md +8 -0
  11. package/kit/agents/crew-planner.md +14 -0
  12. package/kit/agents/crew-privacy-agent.md +10 -2
  13. package/kit/agents/crew-review-agent.md +8 -0
  14. package/kit/agents/crew-security-expert.md +11 -4
  15. package/kit/agents/crew-session-manager.md +10 -3
  16. package/kit/agents/crew-test-expert.md +8 -0
  17. package/kit/eval/doctor.sh +16 -0
  18. package/kit/eval/smoke-test.sh +898 -11
  19. package/kit/hooks/agent-outcome.sh +416 -0
  20. package/kit/hooks/guard-agent-model.sh +819 -0
  21. package/kit/hooks/guard-bash.sh +26 -1
  22. package/kit/hooks/guard-schedule.sh +7 -2
  23. package/kit/hooks/guard-write.sh +31 -0
  24. package/kit/hooks/prompt-approval.sh +55 -5
  25. package/kit/settings.json +54 -0
  26. package/kit/skills/crew-loosen/SKILL.md +28 -0
  27. package/kit/studio/server/hooks/studio-gate.sh +90 -4
  28. package/kit/studio/server/hooks/studio-host.mjs +92 -0
  29. package/kit/studio/server/index.js +86 -7
  30. package/kit/studio/server/lib/agent-origin.js +44 -0
  31. package/kit/studio/server/lib/graph.js +142 -10
  32. package/kit/studio/server/lib/model-outcomes.js +287 -0
  33. package/kit/studio/server/lib/palette.js +5 -18
  34. package/kit/studio/server/lib/permissions.js +260 -17
  35. package/kit/studio/server/lib/session.js +41 -1
  36. package/kit/studio/server/lib/usage.js +103 -25
  37. package/kit/studio/web/app.js +251 -37
  38. package/kit/studio/web/approvals.js +53 -2
  39. package/kit/studio/web/ask.js +137 -0
  40. package/kit/studio/web/canvas.js +220 -59
  41. package/kit/studio/web/chat.js +19 -2
  42. package/kit/studio/web/convo.js +14 -1
  43. package/kit/studio/web/dock.js +30 -9
  44. package/kit/studio/web/graph-plan.js +66 -18
  45. package/kit/studio/web/icons/builtin.svg +1 -1
  46. package/kit/studio/web/index.html +11 -4
  47. package/kit/studio/web/inspect.js +33 -1
  48. package/kit/studio/web/keys.js +29 -0
  49. package/kit/studio/web/list-plan.js +8 -3
  50. package/kit/studio/web/list.js +35 -7
  51. package/kit/studio/web/models-plan.js +167 -0
  52. package/kit/studio/web/models.js +127 -0
  53. package/kit/studio/web/names.js +84 -0
  54. package/kit/studio/web/style.css +134 -15
  55. package/kit/studio/web/timeline-plan.js +8 -0
  56. package/kit/studio/web/timeline.js +36 -10
  57. package/kit/studio/web/usage.js +53 -12
  58. package/package.json +1 -1
@@ -0,0 +1,137 @@
1
+ // The two requests that are not answered with allow or deny: a question the model asks, and a plan it wants
2
+ // approved. This file draws the form for each and hands back what the viewer chose.
3
+ //
4
+ // A form is built once for a request and kept while the request waits. The dock and the List redraw around it every
5
+ // second; a form rebuilt on each of those would lose what was picked, and what was being typed.
6
+ import { answersOf, PLAN_CHOICES } from './approvals.js';
7
+
8
+ function mk(tag, cls, text) {
9
+ const n = document.createElement(tag);
10
+ if (cls) n.className = cls;
11
+ if (text != null) n.textContent = text;
12
+ return n;
13
+ }
14
+
15
+ export class AskForms {
16
+ /** @param onDecide (item, verdict, extra) => void — 'answer' with { answers }, 'plan' with { mode }, or 'deny' */
17
+ constructor(onDecide) {
18
+ this.onDecide = onDecide ?? (() => {});
19
+ this.forms = new Map(); // request key -> { el, sent(on) }
20
+ }
21
+
22
+ /** The form for a request, the same element every time it is asked for. Null for an ordinary tool call. */
23
+ get(item) {
24
+ if (!item?.ask) return null;
25
+ let f = this.forms.get(item.key);
26
+ if (!f) {
27
+ f = item.ask.kind === 'plan' ? this.#plan(item) : this.#questions(item);
28
+ this.forms.set(item.key, f);
29
+ }
30
+ return f.el;
31
+ }
32
+
33
+ /** An answer was sent, or could not be delivered: the form's controls go off, or come back. */
34
+ setSent(key, on) { this.forms.get(key)?.sent(on); }
35
+
36
+ /** Forget the forms of requests that are no longer waiting. */
37
+ keep(keys) {
38
+ const alive = new Set(keys);
39
+ for (const k of [...this.forms.keys()]) if (!alive.has(k)) this.forms.delete(k);
40
+ }
41
+
42
+ #questions(item) {
43
+ const el = mk('div', 'ask');
44
+ const picked = new Map(); // question -> Set of labels
45
+ const typed = new Map(); // question -> the viewer's own text
46
+ const controls = [];
47
+ const send = mk('button', 'btn sm primary', 'Send answers');
48
+ const refresh = () => { send.disabled = send.dataset.sent === '1' || answersOf(item.ask, picked, typed) === null; };
49
+
50
+ for (const q of item.ask.questions) {
51
+ picked.set(q.question, new Set());
52
+ const box = mk('div', 'ask-q');
53
+ const head = mk('div', 'ask-head');
54
+ if (q.header) head.append(mk('span', 'ask-tag', q.header));
55
+ head.append(mk('span', 'ask-text', q.question));
56
+ if (q.multiSelect) head.append(mk('span', 'sub', 'choose any'));
57
+ const opts = mk('div', 'ask-opts');
58
+ opts.setAttribute('role', q.multiSelect ? 'group' : 'radiogroup');
59
+ const buttons = [];
60
+ for (const o of q.options) {
61
+ const b = mk('button', 'ask-opt', o.label);
62
+ b.type = 'button';
63
+ b.title = o.description;
64
+ b.setAttribute('role', q.multiSelect ? 'checkbox' : 'radio');
65
+ b.setAttribute('aria-checked', 'false');
66
+ b.addEventListener('click', () => {
67
+ const set = picked.get(q.question);
68
+ if (q.multiSelect) { if (set.has(o.label)) set.delete(o.label); else set.add(o.label); } else {
69
+ // One answer: choosing an option takes the place of anything typed, and of the option chosen before.
70
+ set.clear(); set.add(o.label);
71
+ own.value = ''; typed.delete(q.question);
72
+ }
73
+ for (const x of buttons) x.el.setAttribute('aria-checked', String(set.has(x.label)));
74
+ refresh();
75
+ });
76
+ buttons.push({ el: b, label: o.label });
77
+ controls.push(b);
78
+ opts.append(b);
79
+ }
80
+ // The model's options do not always hold the answer: the viewer's own words are an answer too.
81
+ const own = mk('input', 'ask-own');
82
+ own.type = 'text';
83
+ own.placeholder = q.multiSelect ? 'Something else, as well…' : 'Something else…';
84
+ own.setAttribute('aria-label', `Your own answer to: ${q.question}`);
85
+ own.addEventListener('input', () => {
86
+ typed.set(q.question, own.value);
87
+ if (!q.multiSelect && own.value.trim()) {
88
+ picked.get(q.question).clear();
89
+ for (const x of buttons) x.el.setAttribute('aria-checked', 'false');
90
+ }
91
+ refresh();
92
+ });
93
+ controls.push(own);
94
+ box.append(head, opts, own);
95
+ el.append(box);
96
+ }
97
+
98
+ const acts = mk('div', 'ask-acts');
99
+ const dismiss = mk('button', 'btn sm', 'Dismiss');
100
+ dismiss.type = 'button';
101
+ dismiss.title = 'Refuse the question. The model is told nobody answered and may ask in words instead.';
102
+ send.type = 'button';
103
+ send.addEventListener('click', () => {
104
+ const answers = answersOf(item.ask, picked, typed);
105
+ if (answers) this.onDecide(item, 'answer', { answers });
106
+ });
107
+ dismiss.addEventListener('click', () => this.onDecide(item, 'deny', {}));
108
+ controls.push(dismiss);
109
+ acts.append(dismiss, send);
110
+ el.append(acts);
111
+ refresh();
112
+ return { el, sent: (on) => { send.dataset.sent = on ? '1' : '0'; for (const c of controls) c.disabled = on; refresh(); } };
113
+ }
114
+
115
+ #plan(item) {
116
+ const el = mk('div', 'ask');
117
+ const plan = mk('pre', 'ask-plan', item.ask.plan || 'The plan was not included in the request.');
118
+ plan.tabIndex = 0;
119
+ const acts = mk('div', 'ask-acts');
120
+ const keep = mk('button', 'btn sm', 'Keep planning');
121
+ keep.type = 'button';
122
+ keep.title = 'Refuse to leave plan mode. The model stays in it and goes on planning.';
123
+ keep.addEventListener('click', () => this.onDecide(item, 'deny', {}));
124
+ const controls = [keep];
125
+ acts.append(keep);
126
+ PLAN_CHOICES.forEach((c, i) => {
127
+ const b = mk('button', i === PLAN_CHOICES.length - 1 ? 'btn sm primary' : 'btn sm', c.label);
128
+ b.type = 'button';
129
+ b.title = c.title;
130
+ b.addEventListener('click', () => this.onDecide(item, 'plan', { mode: c.mode }));
131
+ controls.push(b);
132
+ acts.append(b);
133
+ });
134
+ el.append(plan, acts);
135
+ return { el, sent: (on) => { for (const c of controls) c.disabled = on; } };
136
+ }
137
+ }
@@ -11,11 +11,12 @@
11
11
  // A status is never colour alone: every dot has its word beside it.
12
12
  // A poll that changed nothing writes nothing. Each element remembers what it last drew.
13
13
  import {
14
- plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES, INFERRED_NOTE,
14
+ plan, sequence, crowdFolds, fitZoom, tallestFor, stateOf, ZOOM, GROUPINGS, DENSITIES, INFERRED_NOTE,
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, originOf, orderTag, modelOf, modelMix, modelFamily, modelBadge } 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.
@@ -87,12 +88,15 @@ export class Canvas {
87
88
  this.seq = new Map(); // id -> arrival number (see graph-plan.js)
88
89
  this.open = new Map(); // group id -> the viewer's own fold
89
90
  this.aside = false; // an agent group opens into the next column, not downward
91
+ this.all = null; // true after Expand all, false after Fold all: what an untouched group is
90
92
  // Ids that arrived on the last poll. A wire into one of these draws itself; every other wire is left alone,
91
93
  // so opening a group of 105 does not set 105 animations running at once.
92
94
  this.newborn = new Set();
93
95
  this.palette = { map: {} };
96
+ this.origins = null;
94
97
  this.icons = {};
95
98
  this.filter = null; // statuses the page asked to isolate
99
+ this.modelFilter = null; // a family of model the page asked to isolate
96
100
  this.waiting = new Set(); // agents parked on an approval
97
101
  this.sessionState = null; // { word, tone } for the session card's pill
98
102
  this.focusId = null; // show only this branch
@@ -162,7 +166,7 @@ export class Canvas {
162
166
  close.innerHTML = MARK.close;
163
167
  close.addEventListener('click', () => this.showSpend(false));
164
168
  head.append(close);
165
- this.spendRows = mk('dl', 'cv-spend-rows');
169
+ this.spendRows = mk('div', 'cv-spend-rows');
166
170
  box.append(head, this.spendRows);
167
171
  this.spendOpen = readFlag('crewforth-studio-spend') !== 'closed';
168
172
  box.hidden = true; // until there is a session's usage to show
@@ -175,19 +179,61 @@ export class Canvas {
175
179
  this.spendEl.hidden = !on || !this.spendCount;
176
180
  }
177
181
 
178
- /** The rows to show: [label, value, note], from usage.js `summaryRows`. An empty list hides the box. */
182
+ /**
183
+ * The rows to show, from usage.js `summaryRows`: { label, value, note, detail }. An empty list hides the box.
184
+ *
185
+ * A row with a `detail` is a control: a click opens the breakdown under it, and it stays open. Rows are kept and
186
+ * updated, never rebuilt — a live session's time changes every second, and a row replaced under a press would
187
+ * lose the click.
188
+ */
179
189
  setSpend(rows) {
180
- const sig = JSON.stringify(rows ?? []);
190
+ const list = rows ?? [];
191
+ const sig = JSON.stringify(list);
181
192
  if (sig === this.spendSig) return;
182
193
  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
- }));
194
+ this.spendCount = list.length;
195
+ this.spendParts ??= new Map();
196
+ this.spendShown ??= new Set();
197
+ const alive = new Set();
198
+ for (const r of list) {
199
+ alive.add(r.label);
200
+ let p = this.spendParts.get(r.label);
201
+ if (!p) {
202
+ 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') };
203
+ p.row.type = 'button';
204
+ p.row.append(p.label, p.value);
205
+ p.row.addEventListener('click', () => {
206
+ if (!p.row.classList.contains('cv-spend-more')) return;
207
+ if (this.spendShown.has(r.label)) this.spendShown.delete(r.label); else this.spendShown.add(r.label);
208
+ p.detail.hidden = !this.spendShown.has(r.label);
209
+ p.row.setAttribute('aria-expanded', String(!p.detail.hidden));
210
+ });
211
+ this.spendParts.set(r.label, p);
212
+ }
213
+ const more = Boolean(r.detail?.length);
214
+ p.label.textContent = r.label;
215
+ p.value.textContent = r.value ?? '';
216
+ p.row.title = more ? `${r.note ?? ''}\nClick for the breakdown` : (r.note ?? '');
217
+ p.row.classList.toggle('cv-spend-more', more);
218
+ if (more) p.row.setAttribute('aria-expanded', String(this.spendShown.has(r.label))); else p.row.removeAttribute?.('aria-expanded');
219
+ p.detail.hidden = !more || !this.spendShown.has(r.label);
220
+ const dsig = JSON.stringify(r.detail ?? []);
221
+ if (p.dsig !== dsig) {
222
+ p.dsig = dsig;
223
+ p.detail.replaceChildren(...(r.detail ?? []).flatMap(([k, v]) => [mk('dt', null, k), mk('dd', null, v)]));
224
+ }
225
+ }
226
+ for (const [label, p] of this.spendParts) {
227
+ if (alive.has(label)) continue;
228
+ this.spendParts.delete(label);
229
+ }
230
+ // Which rows there are changes rarely (a session's first agent, its first timed turn); what they say changes
231
+ // every second. Only the first puts elements back into the box, and it puts the same elements back.
232
+ const order = list.map((r) => r.label).join('\u0000');
233
+ if (order !== this.spendOrder) {
234
+ this.spendOrder = order;
235
+ this.spendRows.replaceChildren(...list.flatMap((r) => { const p = this.spendParts.get(r.label); return [p.row, p.detail]; }));
236
+ }
191
237
  this.spendEl.hidden = !this.spendOpen || !this.spendCount;
192
238
  }
193
239
 
@@ -223,7 +269,7 @@ export class Canvas {
223
269
  const crew = mk('span', 'cv-tile cv-tile-sm');
224
270
  crew.innerHTML = MARK.kit;
225
271
  const a = mk('span', 'cv-legend-item');
226
- a.append(this.legendBuiltin, mk('span', null, 'Claude Code built-in'));
272
+ a.append(this.legendBuiltin, mk('span', null, 'Claude Code agent'));
227
273
  const b = mk('span', 'cv-legend-item');
228
274
  b.append(crew, mk('span', null, 'Crewforth'));
229
275
  marks.append(a, b);
@@ -354,12 +400,20 @@ export class Canvas {
354
400
  drawn: this.drawn,
355
401
  focus: this.focusId ? (roleName(this.nodes.get(this.focusId)?.agentType) || this.focusId) : null,
356
402
  groups: (this.lastPlan?.items ?? []).filter((it) => it.kind === 'group').length,
403
+ // Whether there is anything left for each control to do. A group inside a folded one is not drawn, and
404
+ // opens with its holder: it is not something "Fold all" has left undone.
405
+ // How many cards the viewer has placed by hand: what "Reset layout" would forget.
406
+ moved: this.pinned.size,
407
+ canExpand: (this.lastPlan?.items ?? []).some((it) => it.kind === 'group' && !it.open),
408
+ canFold: (this.lastPlan?.items ?? []).some((it) => it.kind === 'group' && it.open),
357
409
  };
358
410
  }
359
411
 
360
412
  /* ---------------------------------------------------------- choices */
361
413
 
362
414
  setPalette(p) { if (p?.map) { this.palette = p; this.#redraw(); } }
415
+ /** Where the session's agents are defined (see `originOf`); null while it has not been read. */
416
+ setOrigins(o) { this.origins = o ?? null; this.#redraw(); }
363
417
 
364
418
  /** The marks that live in files. `builtin` is the markup of web/icons/builtin.svg. */
365
419
  setIcons(icons) {
@@ -376,6 +430,7 @@ export class Canvas {
376
430
  // Positions set by hand and folds chosen for one grouping say nothing about another.
377
431
  this.pinned.clear();
378
432
  this.open.clear();
433
+ this.all = null;
379
434
  this.sessionY = null;
380
435
  this.#crowdFold();
381
436
  this.#persist();
@@ -401,7 +456,17 @@ export class Canvas {
401
456
  this.#redraw();
402
457
  }
403
458
 
459
+ /** Show only the agents that ran on one family of model (`'opus'`), stepping the rest back. `null` shows all. */
460
+ setModelFilter(family) {
461
+ if ((family ?? null) === this.modelFilter) return;
462
+ this.modelFilter = family ?? null;
463
+ for (const el of this.els.values()) el.sig = null;
464
+ for (const el of this.cellEls.values()) el.sig = null;
465
+ if (this.lastPlan) this.#redraw();
466
+ }
467
+
404
468
  dimmed(n) {
469
+ if (n?.kind === 'agent' && this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
405
470
  if (!this.filter || n?.kind !== 'agent') return false;
406
471
  if (this.filterKeepsWaiting && this.waiting.has(n.id)) return false;
407
472
  return !this.filter.has(n.status);
@@ -432,24 +497,23 @@ export class Canvas {
432
497
  return this.#statusOf(n);
433
498
  }
434
499
 
435
- expandAll() {
436
- // Beside itself an agent group opens one at a time, by its own card; "all" is then every other kind of group.
437
- const mine = (it) => it.kind === 'group' && !(this.aside && it.type === 'type');
438
- for (const it of this.lastPlan?.items ?? []) if (mine(it)) this.open.set(it.id, true);
439
- // Opening one level can reveal groups inside it; open those too.
440
- for (let i = 0; i < 4; i += 1) {
441
- const p = this.#plan();
442
- let more = false;
443
- for (const it of p.items) if (mine(it) && !it.open) { this.open.set(it.id, true); more = true; }
444
- if (!more) break;
445
- }
446
- this.sessionY = null;
447
- this.#persist();
448
- this.#redraw({ fit: true });
449
- }
500
+ /**
501
+ * Open every group, of every kind, at every depth — and keep doing so: a group that arrives later arrives open.
502
+ * What the viewer then folds by hand stays folded.
503
+ *
504
+ * It used to pass over the cards that stand for agents of one type whenever such a card opens beside itself,
505
+ * on the reasoning that those open one at a time. That left "Expand all" doing nothing in a session whose
506
+ * only groups were of that kind.
507
+ */
508
+ expandAll() { this.#setAll(true); }
450
509
 
451
- foldAll() {
452
- for (const it of this.lastPlan?.items ?? []) if (it.kind === 'group') this.open.set(it.id, false);
510
+ /** Fold every group, and keep doing so for the ones that arrive later. */
511
+ foldAll() { this.#setAll(false); }
512
+
513
+ #setAll(open) {
514
+ this.all = open;
515
+ // Every group follows the one choice; what was opened or folded by hand before it is forgotten.
516
+ this.open.clear();
453
517
  this.sessionY = null;
454
518
  this.#persist();
455
519
  this.#redraw({ fit: true });
@@ -462,20 +526,39 @@ export class Canvas {
462
526
  this.#redraw({ fit: true });
463
527
  }
464
528
 
465
- /** Forget every hand-placed card and lay the graph out again. */
529
+ /**
530
+ * Forget every hand-placed card and lay the graph out again. Only the positions go: what is selected, what is
531
+ * filtered, the zoom, and which groups are open stay as they are. This session's saved layout loses its
532
+ * positions with it, and no other session's is touched.
533
+ * @returns what was forgotten, for `restoreLayout` to put back; null when no card had been moved
534
+ */
466
535
  resetLayout() {
536
+ if (!this.pinned.size) return null;
537
+ const was = { pins: new Map(this.pinned), sessionY: this.sessionY, session: this.sessionKey, group: this.group };
467
538
  this.pinned.clear();
468
539
  this.sessionY = null;
469
- this.touched = false;
470
540
  this.#persist();
471
- this.#redraw({ fit: true });
541
+ this.#redraw();
542
+ this.onChange?.(this.state());
543
+ return was;
544
+ }
545
+
546
+ /** Put back the positions `resetLayout` returned. Nothing happens if the session or the grouping has changed. */
547
+ restoreLayout(was) {
548
+ if (!was || was.session !== this.sessionKey || was.group !== this.group) return false;
549
+ this.pinned = new Map(was.pins);
550
+ this.sessionY = was.sessionY;
551
+ this.#persist();
552
+ this.#redraw();
553
+ this.onChange?.(this.state());
554
+ return true;
472
555
  }
473
556
 
474
557
  /* ----------------------------------------------------------- layout */
475
558
 
476
559
  #crowdFold() {
477
560
  const nodes = this.#visibleNodes();
478
- for (const id of crowdFolds(nodes, { group: this.group, density: this.density, seq: this.seq })) {
561
+ for (const id of crowdFolds(nodes, { group: this.group, density: this.density, seq: this.seq, tallest: this.#tallest() })) {
479
562
  if (!this.open.has(id)) this.open.set(id, false);
480
563
  }
481
564
  }
@@ -494,13 +577,22 @@ export class Canvas {
494
577
  #plan() {
495
578
  return plan(this.#visibleNodes(), {
496
579
  group: this.group, density: this.density, seq: this.seq, open: this.open, pinned: this.pinned, sessionY: this.sessionY,
497
- aside: this.aside,
580
+ aside: this.aside, all: this.all, tallest: this.#tallest(),
498
581
  });
499
582
  }
500
583
 
584
+ /** How tall a picture may be here before Auto turns cards into chips: what this pane fits, read off the pane. */
585
+ #tallest() {
586
+ this.paneHeight = this.root.getBoundingClientRect?.().height ?? 0;
587
+ return tallestFor(this.paneHeight);
588
+ }
589
+
501
590
  /** Re-fit only while the view is still the canvas's own. Once the viewer has panned, zoomed or placed a card,
502
591
  * moving the view under them is not helpful. */
503
592
  fitIfUntouched() {
593
+ // The pane changed size: what fits in it may have changed with it, and so may Auto's answer.
594
+ const h = this.root.getBoundingClientRect?.().height ?? 0;
595
+ if (this.lastPlan && this.density === 'auto' && tallestFor(h) !== tallestFor(this.paneHeight ?? 0)) this.#redraw();
504
596
  if (!this.touched && !this.pinned.size) this.fit();
505
597
  }
506
598
 
@@ -522,6 +614,7 @@ export class Canvas {
522
614
  if (this.sessionKey === sessionId) return;
523
615
  this.sessionKey = sessionId;
524
616
  this.nodes.clear(); this.pos.clear(); this.pinned.clear(); this.open.clear(); this.seq.clear();
617
+ this.all = null;
525
618
  this.els.clear(); this.cellEls.clear(); this.nodeLayer.replaceChildren(); this.edgeG.replaceChildren();
526
619
  this.edgeEls.clear(); this.newborn.clear();
527
620
  this.mapRects.clear(); this.mapBoxes.replaceChildren();
@@ -667,7 +760,11 @@ export class Canvas {
667
760
  row.append(p.tile, mk('span', 'cv-title', 'Session'), mk('span', 'cv-fill'), p.pill.el);
668
761
  p.name = mk('div', 'cv-name cv-indent');
669
762
  p.sub = mk('div', 'cv-sub cv-indent');
670
- el.append(row, p.name, p.sub);
763
+ // What it has running in the background takes a line of its own, when there is any: on the line above it
764
+ // ran out of the card.
765
+ p.bg = mk('div', 'cv-sub cv-indent cv-bg');
766
+ p.bg.hidden = true;
767
+ el.append(row, p.name, p.sub, p.bg);
671
768
  } else if (it.kind === 'group') {
672
769
  el.classList.add('cv-group');
673
770
  p.tile = mk('span', 'cv-tile');
@@ -685,7 +782,10 @@ export class Canvas {
685
782
  } else {
686
783
  const row = mk('div', 'cv-row cv-indent');
687
784
  row.append(p.bar, p.count);
688
- el.append(p.head, row);
785
+ // What its members add up to, and what they ran on. At compact density the card has no room for them.
786
+ p.totals = mk('div', 'cv-sub cv-indent cv-totals');
787
+ p.mix = mk('div', 'cv-sub cv-indent cv-mix');
788
+ el.append(p.head, row, p.totals, p.mix);
689
789
  }
690
790
  p.fold.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(it.id); });
691
791
  } else if (it.compact) {
@@ -694,8 +794,9 @@ export class Canvas {
694
794
  p.order = mk('span', 'cv-order');
695
795
  p.name = mk('span', 'cv-name');
696
796
  p.task = mk('span', 'cv-task');
797
+ p.model = mk('span', 'cv-model');
697
798
  p.word = mk('span', 'cv-word');
698
- el.append(p.dot, p.order, p.name, p.task, p.word);
799
+ el.append(p.dot, p.order, p.name, p.task, p.model, p.word);
699
800
  } else {
700
801
  p.tile = mk('span', 'cv-tile');
701
802
  p.name = mk('span', 'cv-name');
@@ -708,7 +809,12 @@ export class Canvas {
708
809
  p.task = mk('span', 'cv-task');
709
810
  const under = mk('div', 'cv-under cv-indent');
710
811
  under.append(p.task, p.order);
711
- el.append(row, under);
812
+ // The third line: what it ran on, how long it took and what it spent.
813
+ p.model = mk('span', 'cv-model');
814
+ p.facts = mk('span', 'cv-facts');
815
+ const facts = mk('div', 'cv-under cv-indent cv-third');
816
+ facts.append(p.model, p.facts);
817
+ el.append(row, under, facts);
712
818
  }
713
819
  el.parts = p;
714
820
  }
@@ -731,28 +837,33 @@ export class Canvas {
731
837
  }
732
838
 
733
839
  /**
734
- * Whose agent this is. The palette says where a type was declared; a type it does not know is drawn as unknown
735
- * rather than guessed into one camp or the other, and a palette that could not be read makes every agent
736
- * unknown, with the reason.
840
+ * Whose agent this is, and there are two answers. Crewforth's: named `crew-…` and in its agents directory,
841
+ * which is what the palette's map holds. Everything else is a Claude Code agent — built in, or defined by the
842
+ * project, the user or a plugin; this panel keeps no list of those, so a type Claude Code adds tomorrow is
843
+ * drawn like the ones it has today.
844
+ *
845
+ * One thing can be unknown: whether a `crew-…` agent is Crewforth's, when its agents could not be read.
846
+ * It is then drawn with the common mark and says what was not measured. A type that is not `crew-…` needs no
847
+ * palette to be placed.
737
848
  */
738
849
  #identity(n) {
739
- if (this.palette.measured === false) {
740
- return { source: 'unknown', title: `Agent identity not measured — ${this.palette.reason ?? 'no reason given'}` };
850
+ const type = n?.agentType ?? '';
851
+ if (this.palette.map?.[type]?.source === 'kit') return { source: 'kit', title: 'Crewforth agent' };
852
+ if (this.palette.measured === false && type.startsWith('crew-')) {
853
+ return { source: 'agent', title: `Agent identity not measured — ${this.palette.reason ?? 'no reason given'}` };
741
854
  }
742
- const source = this.palette.map?.[n.agentType]?.source ?? null;
743
- if (source === 'kit') return { source: 'kit', title: 'Crewforth agent' };
744
- if (source === 'builtin') return { source: 'builtin', title: 'Claude Code built-in agent' };
745
- return { source: 'unknown', title: 'Not declared by Crewforth or by Claude Code — an agent type this panel does not recognise' };
855
+ return { source: 'agent', title: 'Claude Code agent', where: originOf(type, this.origins) };
746
856
  }
747
857
 
748
858
  #fillTile(tile, n) {
749
859
  const who = this.#identity(n);
750
- const sig = `${who.source}|${who.title}|${this.icons.builtin ? 1 : 0}`;
860
+ const sig = `${who.source}|${who.title}|${who.where ?? ''}|${this.icons.builtin ? 1 : 0}`;
751
861
  if (tile.sig === sig) return who;
752
862
  tile.sig = sig;
753
863
  tile.innerHTML = who.source === 'kit' ? MARK.kit : (this.icons.builtin ?? '');
754
864
  tile.dataset.source = who.source;
755
- tile.title = who.title;
865
+ // Whose it is, and under that where it is defined, once that has been read.
866
+ tile.title = who.where ? `${who.title}\n${who.where}` : who.title;
756
867
  return who;
757
868
  }
758
869
 
@@ -769,12 +880,16 @@ export class Canvas {
769
880
  p.pill.dot.dataset.tone = st?.tone ?? 'none';
770
881
  p.pill.word.textContent = st?.word ?? '';
771
882
  p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
883
+ // Cut to the card's width; hovering says it whole.
884
+ p.name.title = p.name.textContent;
772
885
  const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
773
886
  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
887
  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') : '';
888
+ p.sub.title = p.sub.textContent;
889
+ // What the session has running that is not an agent: commands sent to the background.
890
+ p.bg.hidden = !bg;
891
+ p.bg.textContent = bg ? `${bg} in background` : '';
892
+ p.bg.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
778
893
  el.title = 'Click to inspect this session';
779
894
  el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
780
895
  }
@@ -787,7 +902,9 @@ export class Canvas {
787
902
  const dim = this.dimmed(n);
788
903
  // Its place in the order the session called its agents, and when: "#3 · 09:14". A chip has room for the number.
789
904
  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]);
905
+ const mdl = modelOf(n);
906
+ const facts = compact ? '' : factsOf(n);
907
+ const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status, tag, after, modelBadge(n), facts]);
791
908
  if (el.sig === sig) return;
792
909
  el.sig = sig;
793
910
  const p = el.parts;
@@ -804,6 +921,12 @@ export class Canvas {
804
921
  p.task.title = task;
805
922
  p.order.textContent = tag;
806
923
  p.order.hidden = !tag;
924
+ // The model it ran on, in full at every density: on a chip it is the task that gives way, not the model.
925
+ p.model.textContent = modelBadge(n);
926
+ p.model.hidden = !mdl.ran;
927
+ p.model.title = mdl.title;
928
+ p.model.dataset.differs = String(mdl.differs);
929
+ if (!compact) { p.facts.textContent = facts; p.facts.title = factsTitle(n); }
807
930
  p.order.title = tag ? `Called ${orderTag(n, clock)} by the session` : '';
808
931
  // In the Order view: what had reported before this wave was called. A reading of the order of events.
809
932
  el.title = after?.length ? `Called after ${after.join(', ')} reported.\n${INFERRED_NOTE}` : '';
@@ -824,7 +947,7 @@ export class Canvas {
824
947
  const p = el.parts;
825
948
  const counts = it.counts;
826
949
  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]);
950
+ 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
951
  if (el.sig !== sig) {
829
952
  el.sig = sig;
830
953
  el.dataset.state = stateOf(it.status);
@@ -857,7 +980,11 @@ export class Canvas {
857
980
  return seg;
858
981
  }));
859
982
  p.bar.title = says;
860
- if (!it.boxed) p.count.textContent = says;
983
+ // Beside the bar: how the members went — or, at compact density where the card has no line for it, the
984
+ // models they ran on, with how they went left to the bar and its tooltip.
985
+ const mix = modelMix(it.members).replace(/, /g, ' \u00b7 ');
986
+ if (!it.boxed) { p.count.textContent = it.compact && mix ? mix : says; p.count.title = it.compact && mix ? `${says}\nThe models its agents ran on` : ''; }
987
+ if (!it.boxed) this.#fillTotals(p, it);
861
988
  el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
862
989
  el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
863
990
  }
@@ -911,13 +1038,30 @@ export class Canvas {
911
1038
  c.parts.name.textContent = text;
912
1039
  c.parts.order.textContent = ctag;
913
1040
  c.parts.order.hidden = !ctag;
914
- c.title = hoverOf(n);
1041
+ c.title = [hoverOf(n), [modelOf(n).ran, factsOf(n)].filter(Boolean).join(' \u00b7 ')].filter(Boolean).join('\n');
915
1042
  c.parts.dot.dataset.tone = st.tone ?? 'none';
916
1043
  c.parts.word.textContent = st.word;
917
1044
  c.setAttribute('aria-label', `${shownType(n)}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
918
1045
  }
919
1046
  }
920
1047
 
1048
+ /** A group as one card: the time, the tokens and the estimate its members add up to, and the models they ran on. */
1049
+ #fillTotals(p, it) {
1050
+ const s = it.sum ?? {};
1051
+ p.totals.hidden = Boolean(it.compact);
1052
+ p.mix.hidden = Boolean(it.compact);
1053
+ if (it.compact) return;
1054
+ p.totals.textContent = [
1055
+ s.timed ? fmtDuration(s.durationMs) : null,
1056
+ s.counted ? `${fmtCount(s.fresh)} new` : null,
1057
+ s.counted ? `${fmtCount(s.cacheRead)} cache` : null,
1058
+ s.counted ? fmtCost(s.cost) : null,
1059
+ ].filter(Boolean).join(' \u00b7 ');
1060
+ p.totals.title = `${it.members.length} calls. Time, new tokens, tokens read from the cache, and cost: ${COST_NOTE}`;
1061
+ p.mix.textContent = modelMix(it.members);
1062
+ p.mix.title = 'The models its agents ran on';
1063
+ }
1064
+
921
1065
  /** What an inferred wire says: on hover that it is an inference, and once per reporter, in words beside it. */
922
1066
  #edgeWords(path, e, c) {
923
1067
  const title = e.title ?? '';
@@ -1102,7 +1246,8 @@ export class Canvas {
1102
1246
  if (!it || it.kind !== 'group') return;
1103
1247
  this.open.set(groupId, !it.open);
1104
1248
  // Beside itself, one agent's tasks at a time: a second column of them from two groups reads as one list.
1105
- if (!it.open && it.type === 'type' && this.aside) {
1249
+ // After "Expand all" that is not the rule: every group is open because the viewer asked for all of them.
1250
+ if (!it.open && it.type === 'type' && this.aside && this.all !== true) {
1106
1251
  for (const other of this.lastPlan.items) if (other.kind === 'group' && other.type === 'type' && other.id !== groupId) this.open.set(other.id, false);
1107
1252
  }
1108
1253
  this.sessionY = null;
@@ -1298,6 +1443,7 @@ export class Canvas {
1298
1443
  group: this.group,
1299
1444
  pins: Object.fromEntries([...this.pinned].map(([id, p]) => [id, [p.x, p.y]])),
1300
1445
  open: Object.fromEntries(this.open),
1446
+ all: this.all,
1301
1447
  }));
1302
1448
  } catch { /* kept for this page view only */ }
1303
1449
  }
@@ -1311,6 +1457,7 @@ export class Canvas {
1311
1457
  if (GROUPINGS.includes(saved.group)) this.group = saved.group;
1312
1458
  for (const [id, [x, y]] of Object.entries(saved.pins ?? {})) this.pinned.set(id, { x, y });
1313
1459
  for (const [id, on] of Object.entries(saved.open ?? {})) this.open.set(id, Boolean(on));
1460
+ this.all = typeof saved.all === 'boolean' ? saved.all : null;
1314
1461
  } catch { /* start from the canvas's own layout */ }
1315
1462
  }
1316
1463
  }
@@ -1330,6 +1477,20 @@ function readPref(key, allowed, fallback) {
1330
1477
  return allowed.includes(v) ? v : fallback;
1331
1478
  }
1332
1479
 
1480
+ /** An agent's third line: how long it took and what it spent. */
1481
+ function factsOf(n) {
1482
+ return [
1483
+ n.durationMs != null ? fmtDuration(n.durationMs) : null,
1484
+ n.usage ? `${fmtCount(n.usage.tokens.fresh)} new` : null,
1485
+ n.usage ? fmtCost(n.usage.cost) : null,
1486
+ ].filter(Boolean).join(' \u00b7 ');
1487
+ }
1488
+ function factsTitle(n) {
1489
+ if (!n.usage) return '';
1490
+ const t = n.usage.tokens;
1491
+ 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}`;
1492
+ }
1493
+
1333
1494
  function fmtTokens(t) {
1334
1495
  if (t == null) return '';
1335
1496
  if (t < 1000) return `${t}`;