crewforth 3.1.0-rc.2 → 3.1.0-rc.3

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.
@@ -14,6 +14,7 @@ import {
14
14
  plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES,
15
15
  } from './graph-plan.js';
16
16
  import { agentStatus } from './nav.js';
17
+ import { roleName, shownType, hoverOf } from './names.js';
17
18
 
18
19
  // How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
19
20
  // and anything a viewer has to wait through is a cost they pay on every spawn.
@@ -84,6 +85,7 @@ export class Canvas {
84
85
  this.mapRects = new Map(); // drawn item id -> its box on the minimap
85
86
  this.seq = new Map(); // id -> arrival number (see graph-plan.js)
86
87
  this.open = new Map(); // group id -> the viewer's own fold
88
+ this.aside = false; // an agent group opens into the next column, not downward
87
89
  // Ids that arrived on the last poll. A wire into one of these draws itself; every other wire is left alone,
88
90
  // so opening a group of 105 does not set 105 animations running at once.
89
91
  this.newborn = new Set();
@@ -139,11 +141,53 @@ export class Canvas {
139
141
  this.mapEl.append(this.mapSvg);
140
142
 
141
143
  this.legendEl = this.#legend();
144
+ this.spendEl = this.#spend();
142
145
  this.tipEl = mk('div', 'cv-tip');
143
146
  this.tipEl.setAttribute('role', 'tooltip');
144
147
  this.tipEl.hidden = true;
145
148
 
146
- this.root.append(this.viewport, this.emptyEl, this.mapEl, this.legendEl, this.tipEl);
149
+ this.root.append(this.viewport, this.emptyEl, this.mapEl, this.legendEl, this.spendEl, this.tipEl);
150
+ }
151
+
152
+ /** What the session spent: time, tokens and an estimate of the cost. Closed, it stays closed until asked for. */
153
+ #spend() {
154
+ const box = mk('div', 'cv-legend cv-spend');
155
+ box.setAttribute('aria-label', 'Session summary');
156
+ const head = mk('div', 'cv-legend-head');
157
+ head.append(mk('span', 'cv-label', 'Session'));
158
+ const close = mk('button', 'cv-x');
159
+ close.type = 'button';
160
+ close.setAttribute('aria-label', 'Close the session summary');
161
+ close.innerHTML = MARK.close;
162
+ close.addEventListener('click', () => this.showSpend(false));
163
+ head.append(close);
164
+ this.spendRows = mk('dl', 'cv-spend-rows');
165
+ box.append(head, this.spendRows);
166
+ this.spendOpen = readFlag('crewforth-studio-spend') !== 'closed';
167
+ box.hidden = true; // until there is a session's usage to show
168
+ return box;
169
+ }
170
+
171
+ showSpend(on) {
172
+ this.spendOpen = Boolean(on);
173
+ writeFlag('crewforth-studio-spend', on ? 'open' : 'closed');
174
+ this.spendEl.hidden = !on || !this.spendCount;
175
+ }
176
+
177
+ /** The rows to show: [label, value, note], from usage.js `summaryRows`. An empty list hides the box. */
178
+ setSpend(rows) {
179
+ const sig = JSON.stringify(rows ?? []);
180
+ if (sig === this.spendSig) return;
181
+ this.spendSig = sig;
182
+ this.spendCount = (rows ?? []).length;
183
+ this.spendRows.replaceChildren(...(rows ?? []).flatMap(([label, value, note]) => {
184
+ const dd = mk('dd', null, value ?? '');
185
+ dd.title = note ?? '';
186
+ const dt = mk('dt', null, label);
187
+ dt.title = note ?? '';
188
+ return [dt, dd];
189
+ }));
190
+ this.spendEl.hidden = !this.spendOpen || !this.spendCount;
147
191
  }
148
192
 
149
193
  /** What the lines and the two marks mean. Shown until it is closed, and then not again unless asked for. */
@@ -196,7 +240,7 @@ export class Canvas {
196
240
  this.root.addEventListener('pointerdown', (e) => {
197
241
  if (e.button !== 0) return; // right/middle must not pan
198
242
  const t = e.target;
199
- if (t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')) return;
243
+ if (t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')) return; // the summary box is a .cv-legend too
200
244
  panning = { px: e.clientX, py: e.clientY, x: this.view.x, y: this.view.y };
201
245
  this.root.setPointerCapture(e.pointerId);
202
246
  this.root.classList.add('cv-panning');
@@ -303,7 +347,7 @@ export class Canvas {
303
347
  drawnAs: this.lastPlan?.density ?? null,
304
348
  stacked: Boolean(this.lastPlan?.stacked),
305
349
  drawn: this.drawn,
306
- focus: this.focusId ? (this.nodes.get(this.focusId)?.agentType ?? this.focusId) : null,
350
+ focus: this.focusId ? (roleName(this.nodes.get(this.focusId)?.agentType) || this.focusId) : null,
307
351
  groups: (this.lastPlan?.items ?? []).filter((it) => it.kind === 'group').length,
308
352
  };
309
353
  }
@@ -384,12 +428,14 @@ export class Canvas {
384
428
  }
385
429
 
386
430
  expandAll() {
387
- for (const it of this.lastPlan?.items ?? []) if (it.kind === 'group') this.open.set(it.id, true);
431
+ // Beside itself an agent group opens one at a time, by its own card; "all" is then every other kind of group.
432
+ const mine = (it) => it.kind === 'group' && !(this.aside && it.type === 'type');
433
+ for (const it of this.lastPlan?.items ?? []) if (mine(it)) this.open.set(it.id, true);
388
434
  // Opening one level can reveal groups inside it; open those too.
389
435
  for (let i = 0; i < 4; i += 1) {
390
436
  const p = this.#plan();
391
437
  let more = false;
392
- for (const it of p.items) if (it.kind === 'group' && !it.open) { this.open.set(it.id, true); more = true; }
438
+ for (const it of p.items) if (mine(it) && !it.open) { this.open.set(it.id, true); more = true; }
393
439
  if (!more) break;
394
440
  }
395
441
  this.sessionY = null;
@@ -443,6 +489,7 @@ export class Canvas {
443
489
  #plan() {
444
490
  return plan(this.#visibleNodes(), {
445
491
  group: this.group, density: this.density, seq: this.seq, open: this.open, pinned: this.pinned, sessionY: this.sessionY,
492
+ aside: this.aside,
446
493
  });
447
494
  }
448
495
 
@@ -569,7 +616,7 @@ export class Canvas {
569
616
 
570
617
  #drawItem(it, liveCells) {
571
618
  let el = this.els.get(it.id);
572
- const shape = it.kind === 'group' ? `group:${it.open ? `open:${it.grid ? 'grid' : 'rows'}` : 'folded'}`
619
+ const shape = it.kind === 'group' ? `group:${it.boxed ? `open:${it.grid ? 'grid' : 'rows'}` : 'folded'}`
573
620
  : `${it.kind}:${it.compact ? 'chip' : 'card'}`;
574
621
  // A card that changes what it IS (an agent that becomes a chip, a group that opens) is rebuilt; one that only
575
622
  // changes what it says is updated in place.
@@ -627,7 +674,7 @@ export class Canvas {
627
674
  p.head.append(p.tile, p.name, mk('span', 'cv-fill'), p.sub, p.fold);
628
675
  p.bar = mk('div', 'cv-bar');
629
676
  p.count = mk('span', 'cv-sub');
630
- if (it.open) {
677
+ if (it.boxed) {
631
678
  el.classList.add('cv-open');
632
679
  el.append(p.head, p.bar);
633
680
  } else {
@@ -701,7 +748,8 @@ export class Canvas {
701
748
  #fillSession(el, it) {
702
749
  const n = it.node;
703
750
  const st = this.sessionState;
704
- const sig = JSON.stringify([n.gitBranch, n.cwd, n.turns, n.tokens, st?.word, st?.tone]);
751
+ const bg = n.backgroundNow?.length ?? 0;
752
+ const sig = JSON.stringify([n.gitBranch, n.cwd, n.turns, n.tokens, st?.word, st?.tone, bg]);
705
753
  if (el.sig === sig) return;
706
754
  el.sig = sig;
707
755
  const p = el.parts;
@@ -712,7 +760,10 @@ export class Canvas {
712
760
  p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
713
761
  const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
714
762
  if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
763
+ // What the session has running that is not an agent: commands sent to the background.
764
+ if (bg) bits.push(`${bg} in background`);
715
765
  p.sub.textContent = bits.join(' · ');
766
+ p.sub.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
716
767
  el.title = 'Click to inspect this session';
717
768
  el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
718
769
  }
@@ -720,7 +771,7 @@ export class Canvas {
720
771
  #fillAgent(el, n, compact) {
721
772
  const st = this.#statusOf(n);
722
773
  const who = compact ? this.#identity(n) : this.#fillTile(el.parts.tile, n);
723
- const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : (n.agentType ?? 'unknown agent');
774
+ const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : shownType(n);
724
775
  const task = n.kind === 'workflow' ? `${n.members ?? 0} agents` : (n.description ?? '');
725
776
  const dim = this.dimmed(n);
726
777
  const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status]);
@@ -735,6 +786,9 @@ export class Canvas {
735
786
  el.classList.toggle('cv-dim', dim);
736
787
  p.name.textContent = type;
737
788
  p.task.textContent = task;
789
+ // Both are cut to the card's width; hovering says them whole, and the type as it is declared.
790
+ p.name.title = n.kind === 'workflow' ? '' : (n.agentType ?? '');
791
+ p.task.title = task;
738
792
  if (compact) {
739
793
  p.dot.dataset.tone = st.tone ?? 'none';
740
794
  p.word.textContent = st.word;
@@ -752,19 +806,26 @@ export class Canvas {
752
806
  const p = el.parts;
753
807
  const counts = it.counts;
754
808
  const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
755
- const sig = JSON.stringify([it.label, says, it.open, it.type, it.status]);
809
+ const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status]);
756
810
  if (el.sig !== sig) {
757
811
  el.sig = sig;
758
812
  el.dataset.state = stateOf(it.status);
759
813
  el.dataset.status = it.status;
760
814
  el.dataset.group = it.type;
761
815
  el.dataset.open = String(Boolean(it.open));
816
+ // Open beside itself: the card stays a card and its agents are the next column.
817
+ el.dataset.aside = String(Boolean(it.open && !it.boxed));
762
818
  p.name.textContent = it.label;
819
+ p.name.title = it.real ?? '';
763
820
  if (it.type === 'run') { p.tile.innerHTML = MARK.run; p.tile.title = 'Workflow run'; }
764
821
  else this.#fillTile(p.tile, it.type === 'type' ? { agentType: it.agentType } : (it.of ?? {}));
765
- p.sub.textContent = it.type === 'type' ? '' : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
766
- p.fold.innerHTML = it.open ? MARK.down : MARK.chevron;
767
- p.fold.setAttribute('aria-label', `${it.open ? 'Fold' : 'Open'} ${it.label}`);
822
+ // Several agents of one type: the number is a badge beside the role. Any other group says how many it holds.
823
+ p.sub.textContent = it.type === 'type' ? `× ${it.count}` : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
824
+ p.sub.classList.toggle('cv-times', it.type === 'type');
825
+ p.fold.innerHTML = it.boxed ? MARK.down : MARK.chevron;
826
+ // Read aloud, the badge is part of the name.
827
+ const said = it.type === 'type' ? `${it.label} × ${it.count}` : it.label;
828
+ p.fold.setAttribute('aria-label', `${it.open ? 'Fold' : 'Open'} ${said}`);
768
829
  p.fold.setAttribute('aria-expanded', String(Boolean(it.open)));
769
830
  // The bar: how the group went, in the order the eye wants it.
770
831
  const order = ['done', 'running', 'starting', 'failed', 'killed', 'stopped', 'stale', 'ended'];
@@ -776,11 +837,11 @@ export class Canvas {
776
837
  return seg;
777
838
  }));
778
839
  p.bar.title = says;
779
- if (!it.open) p.count.textContent = says;
780
- el.title = it.open ? '' : `Click to show ${it.members.length} agents`;
781
- el.setAttribute('aria-label', `${it.label}, ${says}, ${it.open ? 'open' : 'folded'}`);
840
+ if (!it.boxed) p.count.textContent = says;
841
+ el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
842
+ el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
782
843
  }
783
- if (!it.open) return;
844
+ if (!it.boxed) return;
784
845
 
785
846
  for (const cell of it.cells) {
786
847
  liveCells.add(cell.id);
@@ -816,7 +877,7 @@ export class Canvas {
816
877
  const st = this.#statusOf(n);
817
878
  const who = it.grid ? this.#identity(n) : this.#fillTile(c.parts.tile, n);
818
879
  // Inside a group of one type the type is the group's name, so a member is told apart by its task.
819
- const text = it.type === 'type' ? (n.description || n.agentType || n.id) : (n.agentType ?? 'unknown agent');
880
+ const text = it.type === 'type' ? (n.description || shownType(n) || n.id) : shownType(n);
820
881
  const dim = this.dimmed(n);
821
882
  const csig = JSON.stringify([text, st.word, st.tone, st.state, dim, this.selected === n.id]);
822
883
  if (c.sig === csig) continue;
@@ -826,9 +887,10 @@ export class Canvas {
826
887
  c.classList.toggle('cv-dim', dim);
827
888
  c.classList.toggle('cv-selected', this.selected === n.id);
828
889
  c.parts.name.textContent = text;
890
+ c.title = hoverOf(n);
829
891
  c.parts.dot.dataset.tone = st.tone ?? 'none';
830
892
  c.parts.word.textContent = st.word;
831
- c.setAttribute('aria-label', `${n.agentType ?? 'unknown agent'}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
893
+ c.setAttribute('aria-label', `${shownType(n)}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
832
894
  }
833
895
  }
834
896
 
@@ -986,11 +1048,23 @@ export class Canvas {
986
1048
  const it = this.lastPlan?.items.find((x) => x.id === groupId);
987
1049
  if (!it || it.kind !== 'group') return;
988
1050
  this.open.set(groupId, !it.open);
1051
+ // Beside itself, one agent's tasks at a time: a second column of them from two groups reads as one list.
1052
+ if (!it.open && it.type === 'type' && this.aside) {
1053
+ for (const other of this.lastPlan.items) if (other.kind === 'group' && other.type === 'type' && other.id !== groupId) this.open.set(other.id, false);
1054
+ }
989
1055
  this.sessionY = null;
990
1056
  this.#persist();
991
1057
  this.#redraw();
992
1058
  }
993
1059
 
1060
+ /** Whether an agent group opens into the next column (a window with room) or downward (a narrow one). */
1061
+ setAside(on) {
1062
+ if (this.aside === Boolean(on)) return;
1063
+ this.aside = Boolean(on);
1064
+ this.sessionY = null;
1065
+ if (this.lastPlan) this.#redraw({ fit: true });
1066
+ }
1067
+
994
1068
  /** Drop the selection without pretending a card was clicked. */
995
1069
  clearSelection() {
996
1070
  this.selected = null;
@@ -1141,7 +1215,8 @@ export class Canvas {
1141
1215
  if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} tokens`);
1142
1216
  if (n.errors) bits.push(`${n.errors} ${n.errors === 1 ? 'error' : 'errors'}`);
1143
1217
  this.tipEl.replaceChildren(
1144
- mk('strong', null, n.agentType ?? 'unknown agent'),
1218
+ mk('strong', null, shownType(n)),
1219
+ mk('div', 'cv-sub cv-real', n.agentType ?? ''),
1145
1220
  mk('div', null, n.description || 'No task recorded'),
1146
1221
  mk('div', 'cv-sub', bits.length ? bits.join(' · ') : 'No numbers yet'),
1147
1222
  );
@@ -375,6 +375,7 @@ class Pane {
375
375
  node.sig = sig;
376
376
  const text = el('span', 'deleg-text');
377
377
  text.append(el('span', 'deleg-type', card.type), el('span', 'deleg-task', card.task));
378
+ text.title = [card.real, card.task].filter(Boolean).join('\n');
378
379
  const parts = [];
379
380
  const tile = card.agentId ? this.hooks.tileOf?.(this.id, card.agentId) : null;
380
381
  if (tile) parts.push(tile);
@@ -3,6 +3,7 @@
3
3
  // The same shapes come from two places: the server's reading of a transcript (history, and sessions Studio only
4
4
  // watches) and the live stream of a session started here. Both go through these functions, so a tool row reads
5
5
  // the same whichever way it arrived.
6
+ import { roleName } from './names.js';
6
7
 
7
8
  // How much of a tool's output the page keeps for a live result: the same tail the server keeps for history.
8
9
  const RESULT_TAIL = 4096;
@@ -76,7 +77,8 @@ export function outputOf(block) {
76
77
  export function delegationCard(block, nodes) {
77
78
  const node = block.id ? (nodes ?? []).find((n) => n.kind === 'agent' && n.toolUseId === block.id) ?? null : null;
78
79
  return {
79
- type: block.subagentType ?? node?.agentType ?? 'agent',
80
+ type: roleName(block.subagentType ?? node?.agentType ?? 'agent'),
81
+ real: block.subagentType ?? node?.agentType ?? null,
80
82
  task: block.label ?? node?.description ?? '',
81
83
  agentId: node?.id ?? null,
82
84
  status: node?.status ?? null,
@@ -121,7 +123,7 @@ export function refusals(denials, wasAllowed, resultOf = () => null) {
121
123
  export function reminderOf(waiting) {
122
124
  const list = waiting ?? [];
123
125
  if (!list.length) return null;
124
- const who = list[0].agentType ?? 'This session';
126
+ const who = list[0].agentType ? roleName(list[0].agentType) : 'This session';
125
127
  const more = list.length - 1;
126
128
  return `${who} is waiting for approval${more > 0 ? `, and ${more} more ${more === 1 ? 'request is' : 'requests are'}` : ''}`;
127
129
  }
@@ -184,6 +184,7 @@ export class Dock {
184
184
  p.word.textContent = 'Needs you';
185
185
 
186
186
  p.who.textContent = asker(item);
187
+ p.who.title = item.agentType ? `${item.agentType} — show on the canvas` : 'Show on the canvas';
187
188
  p.tool.textContent = item.toolName;
188
189
  const where = this.where(item);
189
190
  p.where.hidden = !where;
@@ -15,6 +15,8 @@
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 = {
@@ -68,7 +70,6 @@ const SESSION = 'session';
68
70
 
69
71
  /* --------------------------------------------------------------- groups --- */
70
72
 
71
- const typeOf = (n) => n.agentType ?? 'unknown agent';
72
73
  const byArrival = (seq) => (a, b) => (seq.get(a.id) ?? 0) - (seq.get(b.id) ?? 0);
73
74
 
74
75
  /** Counts by status, in the order a bar draws them. */
@@ -132,8 +133,11 @@ export function sequence(nodes, seq = new Map()) {
132
133
  *
133
134
  * At `auto` density, once the picture is too tall (`stack`), siblings of one type fold into a group from
134
135
  * AUTO.stackAt whatever the grouping is, except under `none`, which means none.
136
+ *
137
+ * `aside(id)` says a type group is open beside itself: its members are then items of their own, one column to the
138
+ * right of the group's card, and what they spawned is a column further on.
135
139
  */
136
- function itemsOf(nodes, { group, density, seq, stack }) {
140
+ function itemsOf(nodes, { group, density, seq, stack, aside }) {
137
141
  const byId = new Map(nodes.map((n) => [n.id, n]));
138
142
  const kids = new Map();
139
143
  for (const n of nodes) {
@@ -158,7 +162,7 @@ function itemsOf(nodes, { group, density, seq, stack }) {
158
162
  // An agent's children as one group.
159
163
  if (group === 'parent' && list.length >= 2 && byId.get(parentId)?.kind === 'agent') {
160
164
  const g = groupItem(`kids:${parentId}`, 'parent', list, anchor, via, depth, seq, {
161
- label: `${typeOf(byId.get(parentId))} → ${list.length}`, of: byId.get(parentId),
165
+ label: `${shownType(byId.get(parentId))} → ${list.length}`, of: byId.get(parentId),
162
166
  });
163
167
  items.push(g);
164
168
  for (const m of list) place(m.id, g, m.id, depth + 1);
@@ -176,10 +180,18 @@ function itemsOf(nodes, { group, density, seq, stack }) {
176
180
  for (const [t, members] of types) {
177
181
  if (members.length < stackAt) continue;
178
182
  const g = groupItem(`type:${parentId}:${t}`, 'type', members, anchor, via, depth, seq, {
179
- label: `${t} × ${members.length}`, agentType: members[0].agentType ?? null,
183
+ // The role is the name and the number is a badge beside it: `count` is drawn apart from `label`.
184
+ label: roleName(t), count: members.length, real: t, agentType: members[0].agentType ?? null,
180
185
  });
181
186
  items.push(g);
182
- for (const m of members) { stacked.add(m.id); place(m.id, g, m.id, depth + 1); }
187
+ g.aside = Boolean(aside?.(g.id));
188
+ for (const m of members) {
189
+ stacked.add(m.id);
190
+ if (!g.aside) { place(m.id, g, m.id, depth + 1); continue; }
191
+ const item = { id: m.id, kind: 'agent', node: m, parent: g, via: null, depth: depth + 1, seq: seq.get(m.id) ?? 0, member: true };
192
+ items.push(item);
193
+ place(m.id, item, null, depth + 2);
194
+ }
183
195
  }
184
196
 
185
197
  for (const n of list) {
@@ -229,6 +241,8 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
229
241
  * @param opts.open Map groupId -> boolean, the viewer's own folds; a group not in it takes its default
230
242
  * @param opts.pinned Map itemId -> {x, y}, positions the viewer set by hand
231
243
  * @param opts.sessionY where the session card was put, to keep it there; omitted on a fresh layout
244
+ * @param opts.aside true when an open type group puts its agents in the next column instead of growing
245
+ * downward; a narrow window leaves it off
232
246
  * @returns { items, edges, width, height, drawn, density, sessionY }
233
247
  */
234
248
  export function plan(nodes, opts = {}) {
@@ -240,6 +254,7 @@ export function plan(nodes, opts = {}) {
240
254
 
241
255
  // A type group starts folded: it is the same thing several times. A run or an agent's children start open.
242
256
  const isOpen = (g) => (open.has(g.id) ? open.get(g.id) : g.type !== 'type');
257
+ const aside = opts.aside ? (id) => open.get(id) === true : null;
243
258
 
244
259
  // What is drawn, and how many agents of it the viewer reads one by one: a card, a row or a chip each.
245
260
  const drawFrom = (items) => {
@@ -254,7 +269,8 @@ export function plan(nodes, opts = {}) {
254
269
  let count = 0;
255
270
  for (const it of shown) {
256
271
  if (it.kind === 'agent' || it.kind === 'run-card') count += 1;
257
- else if (it.kind === 'group') count += isOpen(it) ? it.members.length : 1;
272
+ // A group open beside itself has its agents among the items already.
273
+ else if (it.kind === 'group' && !it.aside) count += isOpen(it) ? it.members.length : 1;
258
274
  }
259
275
  return { shown, count };
260
276
  };
@@ -271,7 +287,9 @@ export function plan(nodes, opts = {}) {
271
287
  continue;
272
288
  }
273
289
  it.open = isOpen(it);
274
- if (!it.open) { it.w = SIZE.card.w; it.h = SIZE.card.h; it.cells = null; continue; }
290
+ // `boxed` is the group drawn as a container with its members inside. Open beside itself it stays a card.
291
+ it.boxed = it.open && !it.aside;
292
+ if (!it.boxed) { it.w = SIZE.card.w; it.h = SIZE.card.h; it.cells = null; continue; }
275
293
  const n = it.members.length;
276
294
  const grid = compact || n > G.rowsMax;
277
295
  const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
@@ -349,13 +367,13 @@ export function plan(nodes, opts = {}) {
349
367
 
350
368
  // Auto, in the order it reaches for things: nothing while the picture is short enough to read; siblings of one
351
369
  // 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 }));
370
+ let pass = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: false, aside }));
353
371
  let compact = asked === 'compact';
354
372
  let stacked = false;
355
373
  let box = lay(pass.shown, compact);
356
374
  if (asked === 'auto' && box.height > AUTO.tallest) {
357
375
  if (group !== 'none' && group !== 'type') {
358
- const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true }));
376
+ const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true, aside }));
359
377
  if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
360
378
  }
361
379
  if (box.height > AUTO.tallest) { compact = true; box = lay(pass.shown, true); }
@@ -400,7 +418,7 @@ export function plan(nodes, opts = {}) {
400
418
 
401
419
  return {
402
420
  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,
421
+ drawn, density, group, stacked, aside: Boolean(aside), sessionY: sessionY ?? null,
404
422
  };
405
423
  }
406
424
 
@@ -432,6 +450,42 @@ export function fitZoom(width, height, paneW, paneH) {
432
450
  return Math.min(ZOOM.max, Math.max(ZOOM.min, k));
433
451
  }
434
452
 
453
+ /* --------------------------------------------------------------- settle --- */
454
+
455
+ /**
456
+ * What the transcript cannot say and the live session can.
457
+ *
458
+ * An agent started in the background can sit inside one tool call for twenty minutes and write nothing. The
459
+ * server, reading files, calls that `stale` or `ended`; it cannot tell it from an agent whose session was closed
460
+ * under it. The machine can: while the session is running, an agent whose transcript stops in the middle of a
461
+ * turn is working. So is a command sent to the background that no notice has ended.
462
+ *
463
+ * @param graph the server's { nodes, stats }
464
+ * @param live true when the machine reports the session as running; anything else changes nothing
465
+ * @returns the graph to draw: the same object when nothing in it needed saying differently
466
+ */
467
+ export function settle(graph, live) {
468
+ const nodes = graph?.nodes ?? [];
469
+ const quiet = (n) => n.kind === 'agent' && n.background && n.midTurn && (n.status === 'stale' || n.status === 'ended');
470
+ const session = nodes.find((n) => n.kind === 'session');
471
+ const commands = live === true ? (session?.backgroundCommands ?? []) : [];
472
+ if (live !== true || !nodes.some(quiet)) {
473
+ if (!session || (session.backgroundNow ?? []).length === commands.length) {
474
+ return session ? { ...graph, nodes: nodes.map((n) => (n === session ? { ...n, backgroundNow: commands } : n)) } : graph;
475
+ }
476
+ }
477
+ const byStatus = { ...(graph.stats?.byStatus ?? {}) };
478
+ const out = nodes.map((n) => {
479
+ if (n === session) return { ...n, backgroundNow: commands };
480
+ if (live !== true || !quiet(n)) return n;
481
+ byStatus[n.status] -= 1;
482
+ if (!byStatus[n.status]) delete byStatus[n.status];
483
+ byStatus.running = (byStatus.running ?? 0) + 1;
484
+ return { ...n, status: 'running', quiet: true };
485
+ });
486
+ return { ...graph, nodes: out, stats: graph.stats ? { ...graph.stats, byStatus } : graph.stats };
487
+ }
488
+
435
489
  /* ------------------------------------------------------------ attention --- */
436
490
 
437
491
  /**
@@ -445,11 +499,12 @@ export function attention(nodes, waiting = new Set()) {
445
499
  const needs = agents.filter((n) => waiting.has(n.id)).sort((a, b) => when(b) - when(a));
446
500
  const failed = agents.filter((n) => !waiting.has(n.id) && stateOf(n.status) === 'failed').sort((a, b) => when(b) - when(a));
447
501
  return [
448
- ...needs.map((n) => ({ id: n.id, tone: 'waiting', name: typeOf(n), says: 'waiting for you' })),
502
+ ...needs.map((n) => ({ id: n.id, tone: 'waiting', name: shownType(n), real: typeOf(n), says: 'waiting for you' })),
449
503
  ...failed.map((n) => ({
450
504
  id: n.id,
451
505
  tone: 'fail',
452
- name: typeOf(n),
506
+ name: shownType(n),
507
+ real: typeOf(n),
453
508
  says: [n.status, n.errors ? `${n.errors} ${n.errors === 1 ? 'error' : 'errors'}` : null,
454
509
  n.workflow ? `in ${n.workflow}` : null].filter(Boolean).join(' · '),
455
510
  })),
@@ -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,33 @@
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');