crewforth 3.1.0-rc.2 → 3.1.0-rc.4

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.
@@ -15,11 +15,14 @@
15
15
  // view, so zooming cannot move a card.
16
16
  // 3. Colour is not decided here at all. A box carries its status; the stylesheet turns status into colour.
17
17
 
18
+ import { typeOf, roleName, shownType } from './names.js';
19
+
18
20
  /* ------------------------------------------------------------- measures --- */
19
21
 
20
22
  export const SIZE = {
21
23
  padX: 24, padY: 16, // the canvas's own margin
22
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
23
26
  rowGap: 12, // between siblings
24
27
  session: { w: 200, h: 84 },
25
28
  card: { w: 248, h: 64 }, // an agent, and a folded group
@@ -46,7 +49,7 @@ export const AUTO = {
46
49
  stackAt: 3,
47
50
  };
48
51
 
49
- export const GROUPINGS = ['run', 'type', 'parent', 'none'];
52
+ export const GROUPINGS = ['run', 'type', 'parent', 'order', 'none'];
50
53
  export const DENSITIES = ['auto', 'comfortable', 'compact'];
51
54
 
52
55
  // The handful of things a status means to a reader. `killed` and `stopped` come off the transcript verbatim and
@@ -68,7 +71,6 @@ const SESSION = 'session';
68
71
 
69
72
  /* --------------------------------------------------------------- groups --- */
70
73
 
71
- const typeOf = (n) => n.agentType ?? 'unknown agent';
72
74
  const byArrival = (seq) => (a, b) => (seq.get(a.id) ?? 0) - (seq.get(b.id) ?? 0);
73
75
 
74
76
  /** Counts by status, in the order a bar draws them. */
@@ -132,8 +134,11 @@ export function sequence(nodes, seq = new Map()) {
132
134
  *
133
135
  * At `auto` density, once the picture is too tall (`stack`), siblings of one type fold into a group from
134
136
  * AUTO.stackAt whatever the grouping is, except under `none`, which means none.
137
+ *
138
+ * `aside(id)` says a type group is open beside itself: its members are then items of their own, one column to the
139
+ * right of the group's card, and what they spawned is a column further on.
135
140
  */
136
- function itemsOf(nodes, { group, density, seq, stack }) {
141
+ function itemsOf(nodes, { group, density, seq, stack, aside }) {
137
142
  const byId = new Map(nodes.map((n) => [n.id, n]));
138
143
  const kids = new Map();
139
144
  for (const n of nodes) {
@@ -158,7 +163,7 @@ function itemsOf(nodes, { group, density, seq, stack }) {
158
163
  // An agent's children as one group.
159
164
  if (group === 'parent' && list.length >= 2 && byId.get(parentId)?.kind === 'agent') {
160
165
  const g = groupItem(`kids:${parentId}`, 'parent', list, anchor, via, depth, seq, {
161
- label: `${typeOf(byId.get(parentId))} → ${list.length}`, of: byId.get(parentId),
166
+ label: `${shownType(byId.get(parentId))} → ${list.length}`, of: byId.get(parentId),
162
167
  });
163
168
  items.push(g);
164
169
  for (const m of list) place(m.id, g, m.id, depth + 1);
@@ -176,10 +181,18 @@ function itemsOf(nodes, { group, density, seq, stack }) {
176
181
  for (const [t, members] of types) {
177
182
  if (members.length < stackAt) continue;
178
183
  const g = groupItem(`type:${parentId}:${t}`, 'type', members, anchor, via, depth, seq, {
179
- label: `${t} × ${members.length}`, agentType: members[0].agentType ?? null,
184
+ // The role is the name and the number is a badge beside it: `count` is drawn apart from `label`.
185
+ label: roleName(t), count: members.length, real: t, agentType: members[0].agentType ?? null,
180
186
  });
181
187
  items.push(g);
182
- for (const m of members) { stacked.add(m.id); place(m.id, g, m.id, depth + 1); }
188
+ g.aside = Boolean(aside?.(g.id));
189
+ for (const m of members) {
190
+ stacked.add(m.id);
191
+ if (!g.aside) { place(m.id, g, m.id, depth + 1); continue; }
192
+ const item = { id: m.id, kind: 'agent', node: m, parent: g, via: null, depth: depth + 1, seq: seq.get(m.id) ?? 0, member: true };
193
+ items.push(item);
194
+ place(m.id, item, null, depth + 2);
195
+ }
183
196
  }
184
197
 
185
198
  for (const n of list) {
@@ -215,6 +228,8 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
215
228
  via,
216
229
  depth,
217
230
  seq: Math.min(...members.map((m) => seq.get(m.id) ?? 0), Infinity),
231
+ // The group stands where its first member does in the order the session called its agents.
232
+ first: members.filter((m) => m.order != null).sort((a, b) => a.order - b.order)[0] ?? null,
218
233
  ...extra,
219
234
  };
220
235
  }
@@ -229,17 +244,21 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
229
244
  * @param opts.open Map groupId -> boolean, the viewer's own folds; a group not in it takes its default
230
245
  * @param opts.pinned Map itemId -> {x, y}, positions the viewer set by hand
231
246
  * @param opts.sessionY where the session card was put, to keep it there; omitted on a fresh layout
247
+ * @param opts.aside true when an open type group puts its agents in the next column instead of growing
248
+ * downward; a narrow window leaves it off
232
249
  * @returns { items, edges, width, height, drawn, density, sessionY }
233
250
  */
234
251
  export function plan(nodes, opts = {}) {
235
252
  const group = GROUPINGS.includes(opts.group) ? opts.group : 'run';
236
253
  const asked = DENSITIES.includes(opts.density) ? opts.density : 'auto';
254
+ if (group === 'order') return planOrder(nodes, asked, opts);
237
255
  const seq = opts.seq ?? sequence(nodes);
238
256
  const open = opts.open ?? new Map();
239
257
  const pinned = opts.pinned ?? new Map();
240
258
 
241
259
  // A type group starts folded: it is the same thing several times. A run or an agent's children start open.
242
260
  const isOpen = (g) => (open.has(g.id) ? open.get(g.id) : g.type !== 'type');
261
+ const aside = opts.aside ? (id) => open.get(id) === true : null;
243
262
 
244
263
  // What is drawn, and how many agents of it the viewer reads one by one: a card, a row or a chip each.
245
264
  const drawFrom = (items) => {
@@ -254,7 +273,8 @@ export function plan(nodes, opts = {}) {
254
273
  let count = 0;
255
274
  for (const it of shown) {
256
275
  if (it.kind === 'agent' || it.kind === 'run-card') count += 1;
257
- else if (it.kind === 'group') count += isOpen(it) ? it.members.length : 1;
276
+ // A group open beside itself has its agents among the items already.
277
+ else if (it.kind === 'group' && !it.aside) count += isOpen(it) ? it.members.length : 1;
258
278
  }
259
279
  return { shown, count };
260
280
  };
@@ -271,7 +291,9 @@ export function plan(nodes, opts = {}) {
271
291
  continue;
272
292
  }
273
293
  it.open = isOpen(it);
274
- if (!it.open) { it.w = SIZE.card.w; it.h = SIZE.card.h; it.cells = null; continue; }
294
+ // `boxed` is the group drawn as a container with its members inside. Open beside itself it stays a card.
295
+ it.boxed = it.open && !it.aside;
296
+ if (!it.boxed) { it.w = SIZE.card.w; it.h = SIZE.card.h; it.cells = null; continue; }
275
297
  const n = it.members.length;
276
298
  const grid = compact || n > G.rowsMax;
277
299
  const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
@@ -349,13 +371,13 @@ export function plan(nodes, opts = {}) {
349
371
 
350
372
  // Auto, in the order it reaches for things: nothing while the picture is short enough to read; siblings of one
351
373
  // type folded together when it is not; chips if it still is not.
352
- let pass = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: false }));
374
+ let pass = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: false, aside }));
353
375
  let compact = asked === 'compact';
354
376
  let stacked = false;
355
377
  let box = lay(pass.shown, compact);
356
378
  if (asked === 'auto' && box.height > AUTO.tallest) {
357
379
  if (group !== 'none' && group !== 'type') {
358
- const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true }));
380
+ const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true, aside }));
359
381
  if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
360
382
  }
361
383
  if (box.height > AUTO.tallest) { compact = true; box = lay(pass.shown, true); }
@@ -400,7 +422,108 @@ export function plan(nodes, opts = {}) {
400
422
 
401
423
  return {
402
424
  items: drawnItems, edges, width: Math.max(width, SIZE.padX * 2), height: Math.max(height, SIZE.padY * 2),
403
- drawn, density, group, stacked, sessionY: sessionY ?? null,
425
+ drawn, density, group, stacked, aside: Boolean(aside), sessionY: sessionY ?? null,
426
+ };
427
+ }
428
+
429
+ /* ---------------------------------------------------------------- order --- */
430
+
431
+ // What the wire from an agent to a later wave is called, and what it is: a reading of the order of events.
432
+ 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.';
433
+
434
+ /**
435
+ * The same agents by when the session called them.
436
+ *
437
+ * Every agent is called by the session, so the tree is one level deep and says nothing of sequence. Here a
438
+ * column is a WAVE: the agents called in one assistant message, which is what "started together" means. Waves
439
+ * run left to right in the order the messages were written, and a wave's agents stack in the order of the calls.
440
+ * Agents the session's transcript did not call (a workflow's) have no wave and take a last column of their own.
441
+ *
442
+ * Two kinds of wire. The session is wired to the first wave: it called it. A later wave is wired, dotted, from
443
+ * each agent that reported after the wave before it was called and before this one was — an inference from the
444
+ * order of events, drawn as one and labelled as one. A wave nothing reported before has no wire.
445
+ */
446
+ function planOrder(nodes, asked, opts) {
447
+ const pinned = opts.pinned ?? new Map();
448
+ const session = nodes.find((n) => n.kind === 'session') ?? null;
449
+ const agents = nodes.filter((n) => n.kind === 'agent');
450
+ const called = agents.filter((n) => n.wave != null);
451
+ const waveIds = [...new Set(called.map((n) => n.wave))].sort((a, b) => a - b);
452
+ const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.id).localeCompare(String(b.id));
453
+ const columns = waveIds.map((w) => ({ wave: w, members: called.filter((n) => n.wave === w).sort(byOrder) }));
454
+ for (const c of columns) c.calledAt = Math.min(...c.members.map((m) => m.calledAt ?? Infinity));
455
+ const rest = agents.filter((n) => n.wave == null)
456
+ .sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
457
+ if (rest.length) columns.push({ wave: null, members: rest, calledAt: Infinity });
458
+
459
+ const lay = (compact) => {
460
+ const sz = compact ? SIZE.chip : SIZE.card;
461
+ const items = [];
462
+ const x0 = SIZE.padX + (session ? SIZE.session.w + SIZE.colGap : 0);
463
+ let height = 0;
464
+ columns.forEach((col, i) => {
465
+ col.members.forEach((n, j) => {
466
+ items.push({
467
+ id: n.id, kind: 'agent', node: n, parent: null, via: null, depth: i + 1, seq: n.order ?? j, compact,
468
+ wave: col.wave, w: sz.w, h: sz.h, x: x0 + i * (sz.w + SIZE.waveGap), y: SIZE.padY + j * (sz.h + SIZE.rowGap),
469
+ });
470
+ });
471
+ height = Math.max(height, SIZE.padY * 2 + col.members.length * (sz.h + SIZE.rowGap) - SIZE.rowGap);
472
+ });
473
+ const width = x0 + Math.max(0, columns.length * (sz.w + SIZE.waveGap) - SIZE.waveGap) + SIZE.padX;
474
+ return { items, width, height };
475
+ };
476
+ let compact = asked === 'compact';
477
+ let box = lay(compact);
478
+ if (asked === 'auto' && box.height > AUTO.tallest) { compact = true; box = lay(true); }
479
+ const { items } = box;
480
+ let { height } = box;
481
+
482
+ let sessionY = opts.sessionY ?? null;
483
+ if (session) {
484
+ const first = items.filter((it) => it.depth === 1);
485
+ 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));
488
+ }
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);
491
+ }
492
+ for (const it of items) {
493
+ const p = pinned.get(it.id);
494
+ if (p) { it.x = p.x; it.y = p.y; it.pinned = true; }
495
+ }
496
+
497
+ const at = new Map(items.map((it) => [it.id, it]));
498
+ const edges = [];
499
+ const wire = (from, to, extra) => edges.push({
500
+ key: `${from.id}\u0000${to.id}`, source: from.id, target: to.id,
501
+ from: { x: from.x + from.w, y: from.y + from.h / 2 }, to: { x: to.x, y: to.y + to.h / 2 },
502
+ status: to.node.status ?? 'unknown', state: stateOf(to.node.status), ...extra,
503
+ });
504
+ const waves = columns.filter((c) => c.wave != null);
505
+ if (session && waves.length) for (const m of waves[0].members) wire(at.get(session.id), at.get(m.id), {});
506
+ // An agent's report belongs to the first wave called after it: the one that could have been waiting for it.
507
+ for (let i = 1; i < waves.length; i += 1) {
508
+ const since = waves[i - 1].calledAt;
509
+ const reporters = called.filter((n) => n.reportedAt != null && n.reportedAt > since && n.reportedAt <= waves[i].calledAt
510
+ && n.wave < waves[i].wave).sort(byOrder);
511
+ waves[i].after = reporters;
512
+ for (const r of reporters) {
513
+ waves[i].members.forEach((m, k) => wire(at.get(r.id), at.get(m.id), {
514
+ kind: 'inferred', state: 'inferred',
515
+ // Said once for a reporter, on the wire to the wave's first agent; the others carry it on hover.
516
+ label: k === 0 ? `after ${shownType(r)} reported` : null,
517
+ title: `after ${shownType(r)} reported\n${INFERRED_NOTE}`,
518
+ }));
519
+ }
520
+ for (const m of waves[i].members) at.get(m.id).after = reporters.map((r) => shownType(r));
521
+ }
522
+
523
+ return {
524
+ items, edges, width: Math.max(box.width, SIZE.padX * 2), height: Math.max(height, SIZE.padY * 2),
525
+ drawn: agents.length, density: compact ? 'compact' : 'comfortable', group: 'order', stacked: false, aside: false,
526
+ waves: waves.length, sessionY,
404
527
  };
405
528
  }
406
529
 
@@ -432,6 +555,42 @@ export function fitZoom(width, height, paneW, paneH) {
432
555
  return Math.min(ZOOM.max, Math.max(ZOOM.min, k));
433
556
  }
434
557
 
558
+ /* --------------------------------------------------------------- settle --- */
559
+
560
+ /**
561
+ * What the transcript cannot say and the live session can.
562
+ *
563
+ * An agent started in the background can sit inside one tool call for twenty minutes and write nothing. The
564
+ * server, reading files, calls that `stale` or `ended`; it cannot tell it from an agent whose session was closed
565
+ * under it. The machine can: while the session is running, an agent whose transcript stops in the middle of a
566
+ * turn is working. So is a command sent to the background that no notice has ended.
567
+ *
568
+ * @param graph the server's { nodes, stats }
569
+ * @param live true when the machine reports the session as running; anything else changes nothing
570
+ * @returns the graph to draw: the same object when nothing in it needed saying differently
571
+ */
572
+ export function settle(graph, live) {
573
+ const nodes = graph?.nodes ?? [];
574
+ const quiet = (n) => n.kind === 'agent' && n.background && n.midTurn && (n.status === 'stale' || n.status === 'ended');
575
+ const session = nodes.find((n) => n.kind === 'session');
576
+ const commands = live === true ? (session?.backgroundCommands ?? []) : [];
577
+ if (live !== true || !nodes.some(quiet)) {
578
+ if (!session || (session.backgroundNow ?? []).length === commands.length) {
579
+ return session ? { ...graph, nodes: nodes.map((n) => (n === session ? { ...n, backgroundNow: commands } : n)) } : graph;
580
+ }
581
+ }
582
+ const byStatus = { ...(graph.stats?.byStatus ?? {}) };
583
+ const out = nodes.map((n) => {
584
+ if (n === session) return { ...n, backgroundNow: commands };
585
+ if (live !== true || !quiet(n)) return n;
586
+ byStatus[n.status] -= 1;
587
+ if (!byStatus[n.status]) delete byStatus[n.status];
588
+ byStatus.running = (byStatus.running ?? 0) + 1;
589
+ return { ...n, status: 'running', quiet: true };
590
+ });
591
+ return { ...graph, nodes: out, stats: graph.stats ? { ...graph.stats, byStatus } : graph.stats };
592
+ }
593
+
435
594
  /* ------------------------------------------------------------ attention --- */
436
595
 
437
596
  /**
@@ -445,11 +604,12 @@ export function attention(nodes, waiting = new Set()) {
445
604
  const needs = agents.filter((n) => waiting.has(n.id)).sort((a, b) => when(b) - when(a));
446
605
  const failed = agents.filter((n) => !waiting.has(n.id) && stateOf(n.status) === 'failed').sort((a, b) => when(b) - when(a));
447
606
  return [
448
- ...needs.map((n) => ({ id: n.id, tone: 'waiting', name: typeOf(n), says: 'waiting for you' })),
607
+ ...needs.map((n) => ({ id: n.id, tone: 'waiting', name: shownType(n), real: typeOf(n), says: 'waiting for you' })),
449
608
  ...failed.map((n) => ({
450
609
  id: n.id,
451
610
  tone: 'fail',
452
- name: typeOf(n),
611
+ name: shownType(n),
612
+ real: typeOf(n),
453
613
  says: [n.status, n.errors ? `${n.errors} ${n.errors === 1 ? 'error' : 'errors'}` : null,
454
614
  n.workflow ? `in ${n.workflow}` : null].filter(Boolean).join(' · '),
455
615
  })),
@@ -133,6 +133,7 @@
133
133
  <div id="timeline" role="tabpanel" aria-labelledby="view-timeline" hidden></div>
134
134
  <div id="list" role="tabpanel" aria-labelledby="view-list" hidden></div>
135
135
  <div id="first-run" class="first-run" hidden></div>
136
+ <div id="terminal-wait" class="terminal-wait" role="status" hidden></div>
136
137
  <div id="dock" role="region" aria-label="Waiting for approval" hidden></div>
137
138
  </main>
138
139
 
@@ -3,6 +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
7
 
7
8
  const NOT_MEASURED = 'Not measured';
8
9
 
@@ -87,13 +88,25 @@ export function skillsOf(detail) {
87
88
  return seen;
88
89
  }
89
90
 
91
+ /**
92
+ * The commands a session has running in the background, as lines: what runs, and for how long.
93
+ * `backgroundNow` is empty unless the session is live (graph-plan.js `settle`), so a session that is over lists none.
94
+ */
95
+ export function backgroundLines(session, now) {
96
+ return (session?.backgroundNow ?? []).map((c) => ({
97
+ id: c.id,
98
+ what: `${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`,
99
+ age: c.startedAt != null && now >= c.startedAt ? fmtDuration(now - c.startedAt) : null,
100
+ }));
101
+ }
102
+
90
103
  /** Who handed this agent its work: the node it hangs from. */
91
104
  export function delegatedBy(n, nodes) {
92
105
  const p = (nodes ?? []).find((x) => x.id === n.parentId) ?? null;
93
106
  if (!p) return null;
94
107
  if (p.kind === 'session') return { id: p.id, text: `Session${p.gitBranch ? ` · ${p.gitBranch}` : ''}` };
95
108
  if (p.kind === 'workflow') return { id: p.id, text: `Workflow run · ${p.workflowId ?? p.id}` };
96
- return { id: p.id, text: p.agentType ?? p.id };
109
+ return { id: p.id, text: p.agentType ? roleName(p.agentType) : p.id };
97
110
  }
98
111
 
99
112
  /** The report, or the plain statement that there is none yet and why. */
@@ -4,8 +4,7 @@
4
4
  // needs someone is always at the top. A status this panel has no word for is not put into one of the four: it
5
5
  // gets a section of its own, under its own name, after them.
6
6
  import { stateOf } from './graph-plan.js';
7
-
8
- const typeOf = (n) => n.agentType ?? 'unknown agent';
7
+ import { typeOf, shownType } from './names.js';
9
8
 
10
9
  /** "8m", "1h 4m", "40s": how long, in as little room as a row has. */
11
10
  export function short(ms) {
@@ -28,13 +27,14 @@ function rowOf(n, now) {
28
27
  // The count the transcript gave. An agent can fail without a tool error; then there is none to report.
29
28
  if (n.errors) bits.push(`${n.errors} ${n.errors === 1 ? 'error' : 'errors'}`);
30
29
  if (n.status !== 'failed') bits.push(n.status);
31
- return { id: n.id, node: n, type: typeOf(n), line: bits.join(' · ') || (n.description ?? ''), aside: null };
30
+ return { id: n.id, node: n, type: shownType(n), real: typeOf(n), line: bits.join(' · ') || (n.description ?? ''), aside: null };
32
31
  }
33
32
  const live = state === 'live' || state === 'waking';
34
33
  return {
35
34
  id: n.id,
36
35
  node: n,
37
- type: typeOf(n),
36
+ type: shownType(n),
37
+ real: typeOf(n),
38
38
  line: n.description ?? '',
39
39
  // How long it has been going. With no start recorded there is nothing to count from, and nothing is shown.
40
40
  aside: state === 'waking' ? 'starting' : live && n.startedAt != null ? short(now - n.startedAt) : null,
@@ -4,6 +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
8
  import { remaining, asker, allowSessionLabel, VERDICTS } from './approvals.js';
8
9
 
9
10
  function mk(tag, cls, text) {
@@ -146,6 +147,7 @@ export class List {
146
147
  if (tile) row.append(tile);
147
148
  const text = mk('span', 'ls-text');
148
149
  text.append(mk('span', 'ls-name', r.type), mk('span', 'ls-line', r.line));
150
+ row.title = hoverOf(r.node);
149
151
  row.append(text);
150
152
  if (r.aside) row.append(mk('span', 'sub ls-aside', r.aside));
151
153
  const chev = mk('span', 'ls-chev');
@@ -0,0 +1,44 @@
1
+ // What an agent is called on screen.
2
+ //
3
+ // An agent's type is a file name: `crew-test-expert`, `general-purpose`. Drawn as it is, a column of cards reads
4
+ // "crew-…-expert" twelve times and the word that tells them apart is cut off first. The views show the role
5
+ // instead ("Test", "General Purpose"); the type itself is in the inspector and on hover, and nothing is renamed
6
+ // anywhere but on screen. Keys, ids and comparisons use the type.
7
+
8
+ // A word that is not written the way capitalising its first letter would write it.
9
+ const WORD = { devops: 'DevOps' };
10
+
11
+ /** The type as the transcript has it; an agent with none is said to have none. */
12
+ export const typeOf = (n) => n?.agentType ?? 'unknown agent';
13
+
14
+ /**
15
+ * `crew-backend-expert` → Backend · `crew-session-manager` → Session Manager · `general-purpose` → General Purpose.
16
+ * Crewforth's own prefix and its `-expert` / `-agent` suffix go; what is left is the role. A name that is not
17
+ * made of hyphenated words (it has a space, or nothing to split) is returned as it came.
18
+ */
19
+ export function roleName(type) {
20
+ if (typeof type !== 'string' || !type || /\s/.test(type)) return type ?? '';
21
+ const crew = type.startsWith('crew-');
22
+ const core = crew ? type.slice(5).replace(/-(expert|agent)$/, '') : type;
23
+ if (!core) return type;
24
+ return core.split('-').filter(Boolean)
25
+ .map((w) => WORD[w] ?? (w[0].toUpperCase() + w.slice(1)))
26
+ .join(' ');
27
+ }
28
+
29
+ /** The name a node is drawn with. */
30
+ export const shownType = (n) => roleName(typeOf(n));
31
+
32
+ /** What hovering an agent says: the type as it is declared, and its task in full, since both are cut on screen. */
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
+ }
@@ -362,6 +362,9 @@ body.resizing .cv-root { pointer-events: none; }
362
362
  .sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: none; }
363
363
  .srow-sub { padding-left: var(--space-4); flex: initial; overflow: hidden; text-overflow: ellipsis; }
364
364
  .srow.remote .srow-sub { padding-left: var(--space-6); }
365
+ /* What the session spent, under its state: time, tokens and the estimate. */
366
+ .srow-usage { padding-left: var(--space-4); flex: initial; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; color: var(--ink-faint); }
367
+ .srow.remote .srow-usage { padding-left: var(--space-6); }
365
368
  .sub.bad, .nav-line.bad { color: var(--status-bad); }
366
369
  mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); color: inherit; border-radius: 2px; }
367
370
 
@@ -722,6 +725,23 @@ button.tl-lab { cursor: pointer; }
722
725
  .att-says { color: var(--ink-dim); }
723
726
  .attention-hint { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
724
727
 
728
+ /* A session waiting on its own terminal: said where an approval would be, with no answers, because there are
729
+ none to give from here. */
730
+ .terminal-wait {
731
+ flex: none; min-width: 0;
732
+ display: flex; align-items: baseline; gap: var(--space-2);
733
+ padding: var(--space-2) var(--space-4);
734
+ border-top: 1px solid var(--line); background: var(--surface);
735
+ font-size: 13px;
736
+ }
737
+ .terminal-wait[hidden] { display: none; }
738
+ .terminal-wait-word { flex: none; font-weight: 600; color: var(--status-waiting); }
739
+ .terminal-wait-what {
740
+ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
741
+ font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
742
+ }
743
+ .terminal-wait-note { flex: none; margin-left: auto; }
744
+
725
745
  /* -------------------------------------------------------------- dock ---
726
746
  The approval dock: a row under the canvas, there only while a tool call is
727
747
  waiting. It is part of the stage's column, so the canvas above it stays
@@ -816,6 +836,21 @@ button.tl-lab { cursor: pointer; }
816
836
  }
817
837
 
818
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
+ .cv-under .cv-task { flex: 1 1 auto; min-width: 0; }
850
+ .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
+ .cv-order[hidden] { display: none; }
852
+ .cv-chip .cv-order, .cv-member .cv-order { margin-left: 0; margin-right: 6px; }
853
+
819
854
  .cv-edge[data-state="live"] {
820
855
  stroke: var(--status-busy);
821
856
  opacity: var(--cv-edge-live);
@@ -990,6 +1025,8 @@ button.tl-lab { cursor: pointer; }
990
1025
  }
991
1026
  .cv-fold .ic { width: 12px; height: 12px; }
992
1027
  .cv-fold:hover { color: var(--ink); background: var(--surface-raised); }
1028
+ /* Open beside itself, the control points back at the card it would fold into. */
1029
+ .cv-group[data-aside="true"] .cv-fold .ic { rotate: 180deg; }
993
1030
 
994
1031
  /* How the group went: one segment per status, as wide as its share. The words
995
1032
  are beside it when the group is folded and in its title when it is open. */
@@ -1052,6 +1089,11 @@ button.tl-lab { cursor: pointer; }
1052
1089
  cursor: default;
1053
1090
  }
1054
1091
  .cv-legend[hidden] { display: none; }
1092
+ /* 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); }
1055
1097
  .cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
1056
1098
  .cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
1057
1099
  .cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
@@ -1059,6 +1101,7 @@ button.tl-lab { cursor: pointer; }
1059
1101
  .cv-legend-line[data-state="live"] { border-top: 1.5px dashed var(--status-busy); }
1060
1102
  .cv-legend-line[data-state="waiting"] { border-top: 1.5px dashed var(--status-waiting); }
1061
1103
  .cv-legend-line[data-state="failed"] { border-top-color: var(--cv-fail); }
1104
+ .cv-legend-line[data-state="inferred"] { border-top: 1.5px dotted var(--ink-faint); }
1062
1105
  .cv-x {
1063
1106
  width: 20px; height: 20px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
1064
1107
  background: none; border: 0; border-radius: var(--radius-sm); color: var(--ink-dim); cursor: pointer;
@@ -1076,6 +1119,9 @@ button.tl-lab { cursor: pointer; }
1076
1119
  }
1077
1120
  .cv-tip[hidden] { display: none; }
1078
1121
  .cv-tip strong { font-family: var(--font-mono); font-weight: 600; }
1122
+ .cv-real { font-family: var(--font-mono); }
1123
+ /* "× 3": how many agents of one type a card stands for. */
1124
+ .cv-times { flex: none; padding: 0 6px; border-radius: 999px; background: var(--surface-raised); color: var(--ink); font-variant-numeric: tabular-nums; }
1079
1125
 
1080
1126
  /* The small chip the inspector's gate summary is made of. */
1081
1127
  .cv-bit {
@@ -1110,6 +1156,15 @@ button.tl-lab { cursor: pointer; }
1110
1156
  /* `display: flex` outranks the UA's `[hidden] { display: none }`, so the hidden
1111
1157
  panel kept its width. Restate it explicitly. */
1112
1158
  .inspector[hidden] { display: none; }
1159
+ /* A command the session left running in the background: what it is, cut to one line, and for how long. */
1160
+ .ibg-row { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; }
1161
+ .ibg-what { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); font-size: 12px; }
1162
+ .ibg-row .sub { flex: none; }
1163
+ /* Its left edge is a handle, as the navigator's right edge is: drag to resize, double-click for the default. */
1164
+ .inspector-split {
1165
+ position: absolute; left: -3px; top: 0; bottom: 0; width: 5px; z-index: 6;
1166
+ background: transparent;
1167
+ }
1113
1168
 
1114
1169
  .inspector dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; }
1115
1170
  .inspector dt { font-size: 11px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.05em; }
@@ -1154,6 +1209,9 @@ button.tl-lab { cursor: pointer; }
1154
1209
  .iname { flex: 1; min-width: 0; margin: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1155
1210
  .imeta { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-4) 0; flex: none; }
1156
1211
  .imeta .sub { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
1212
+ /* The agent's type as it is declared, beside the role it is shown as. */
1213
+ .imeta { flex-wrap: wrap; }
1214
+ .ireal { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1157
1215
  /* The task in full: it is the one place the whole sentence is shown. */
1158
1216
  .itask { padding: var(--space-3) var(--space-4); font-size: 16px; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; flex: none; max-height: 30%; overflow-y: auto; }
1159
1217
  .itask:empty { padding-bottom: 0; }
@@ -1587,15 +1645,10 @@ body.vresizing { cursor: row-resize; user-select: none; }
1587
1645
 
1588
1646
  /* --------------------------------------------------------- full screen --- */
1589
1647
 
1590
- /* Full screen is the canvas and nothing beside it: the navigator, its rail,
1591
- the inspector and the conversation step out until Esc. */
1592
- :root:fullscreen .shell { grid-template-columns: 0 0 minmax(0, 1fr) 0 0 0; }
1593
- :root:fullscreen .shell > .side,
1594
- :root:fullscreen .shell > .side-rail,
1595
- :root:fullscreen .shell > .resizer,
1596
- :root:fullscreen .shell > .inspector,
1597
- :root:fullscreen .shell > .chat,
1598
- :root:fullscreen .shell > .newpanel { display: none; }
1648
+ /* Full screen takes the browser away and nothing of the panel: the navigator,
1649
+ the inspector and the conversation stay where they were, and open and close
1650
+ as they do in a window. A rule here used to remove them, and with them the
1651
+ only way to an agent's details or to a conversation that was waiting. */
1599
1652
 
1600
1653
  /* ------------------------------------------------- tab bar that fits --- */
1601
1654
 
@@ -1627,7 +1680,7 @@ body.vresizing { cursor: row-resize; user-select: none; }
1627
1680
 
1628
1681
  @media (min-width: 1280px) {
1629
1682
  /* Docked: back in the grid, in the column kept for it. */
1630
- .shell.no-chat > .inspector { position: static; grid-column: 4; box-shadow: none; }
1683
+ .shell.no-chat > .inspector { position: relative; top: auto; bottom: auto; right: auto; grid-column: 4; box-shadow: none; }
1631
1684
  }
1632
1685
 
1633
1686
  @media (max-width: 1279px) {
@@ -1717,4 +1770,5 @@ body.vresizing { cursor: row-resize; user-select: none; }
1717
1770
  .shell > .newpanel { grid-column: auto; position: absolute; z-index: 35; inset: 0 0 var(--dock-h, 0px) 0; border-left: none; }
1718
1771
  .shell > .inspector,
1719
1772
  .shell.no-chat > .inspector { left: 0; right: 0; top: 0; width: auto; border-left: none; }
1773
+ .inspector-split { display: none; }
1720
1774
  }
@@ -9,6 +9,7 @@
9
9
  // records: the server's log of approvals, for sessions started here. An agent that failed is drawn failed for
10
10
  // its whole length — the transcript says how it ended, not when it started going wrong.
11
11
  import { stateOf, tally } from './graph-plan.js';
12
+ import { typeOf, shownType } from './names.js';
12
13
 
13
14
  export const RANGES = [
14
15
  { key: '5m', ms: 5 * 60_000 },
@@ -24,7 +25,6 @@ export const GROUP_ROWS = { showAllUpTo: 6, shown: 4 };
24
25
  const SESSION = 'session';
25
26
  const TONE = { live: 'busy', waking: 'busy', done: 'done', failed: 'fail', quiet: 'quiet', unknown: 'quiet' };
26
27
 
27
- const typeOf = (n) => n.agentType ?? 'unknown agent';
28
28
  const isLive = (n) => { const s = stateOf(n.status); return s === 'live' || s === 'waking'; };
29
29
 
30
30
  /** Where an agent's bar ends: its recorded end, or now while it is still going. */
@@ -113,13 +113,15 @@ export function groupsOf(nodes, grouping) {
113
113
  const agents = nodes.filter((n) => n.kind === 'agent');
114
114
  const byId = new Map(nodes.map((n) => [n.id, n]));
115
115
  const key = (n) => {
116
- if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: typeOf(n) };
116
+ if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: shownType(n), real: typeOf(n) };
117
117
  if (grouping === 'parent') {
118
118
  const p = byId.get(n.parentId);
119
119
  if (!p || p.kind === 'session') return { id: 'direct', label: 'Session · direct' };
120
- return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${typeOf(p)}` };
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
 
@@ -180,13 +188,13 @@ export function rows(nodes, opts = {}) {
180
188
  for (const m of same) seen.add(m.id);
181
189
  if (same.length > 1) {
182
190
  body.push({
183
- kind: 'merged', id: `${g.id}|${typeOf(n)}`, group: g.id, label: `${typeOf(n)} × ${same.length}`,
191
+ kind: 'merged', id: `${g.id}|${typeOf(n)}`, group: g.id, label: `${shownType(n)} × ${same.length}`, real: typeOf(n),
184
192
  sub: countsSub(same, waiting), members: same, lanes: lanes(same.flatMap(bar)), indent: headed && g.id !== 'direct',
185
193
  });
186
194
  } else {
187
195
  const sub = agentSub(n, waiting);
188
196
  body.push({
189
- kind: 'agent', id: n.id, group: g.id, node: n, label: typeOf(n), sub: sub.text, tone: sub.tone,
197
+ kind: 'agent', id: n.id, group: g.id, node: n, label: shownType(n), real: typeOf(n), sub: sub.text, tone: sub.tone,
190
198
  bars: bar(n), timed: n.startedAt != null, indent: headed && g.id !== 'direct',
191
199
  });
192
200
  }