crewforth 3.1.0-rc.1 → 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.
@@ -15,15 +15,17 @@ import {
15
15
  liveSessions, machines, seenAgo, isFirstRun,
16
16
  } from './nav.js';
17
17
  import { liveness, offlineNote } from './liveness.js';
18
- import { attention, AUTO } from './graph-plan.js';
19
- import { ServerClock, queue, settled, OUTCOME_WORD } from './approvals.js';
18
+ import { attention, settle, AUTO } from './graph-plan.js';
19
+ import { summaryRows, usageLine, costNote } from './usage.js';
20
+ import { ServerClock, queue, settled, terminalWait, OUTCOME_WORD } from './approvals.js';
20
21
  import { Dock } from './dock.js';
21
22
  import { NewSession } from './newsession.js';
22
23
  import { Timeline } from './timeline.js';
23
24
  import { List } from './list.js';
24
25
  import { defaultView, narrowWarning } from './list-plan.js';
25
26
  import { fitLevel, runsOver } from './toolbar-fit.js';
26
- import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
27
+ import { roleName, shownType } from './names.js';
28
+ import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
27
29
 
28
30
  const FLEET_POLL_MS = 2000;
29
31
  const SESSION_POLL_MS = 5000;
@@ -54,6 +56,7 @@ const el = {
54
56
  toast: document.getElementById('toast'),
55
57
  attention: document.getElementById('attention'),
56
58
  dock: document.getElementById('dock'),
59
+ terminalWait: document.getElementById('terminal-wait'),
57
60
  newPanel: document.getElementById('new-panel'),
58
61
  tbGroup: document.getElementById('tb-group'),
59
62
  tbDensity: document.getElementById('tb-density'),
@@ -132,6 +135,8 @@ systemLight.addEventListener('change', relabelTheme);
132
135
  const PANEL = {
133
136
  side: { min: 170, max: 620, def: 272, wide: 460, varName: '--side-w', key: 'crewforth-studio-side-w' },
134
137
  chat: { min: 280, max: 900, def: 480, wide: 720, varName: '--chat-w', key: 'crewforth-studio-chat-w' },
138
+ // The inspector's default is the stylesheet's own --inspector-w.
139
+ inspector: { min: 280, max: 720, def: 344, varName: '--inspector-w', key: 'crewforth-studio-inspector-w' },
135
140
  };
136
141
 
137
142
  function setPanel(which, px, persist = true) {
@@ -142,7 +147,7 @@ function setPanel(which, px, persist = true) {
142
147
  return w;
143
148
  }
144
149
 
145
- for (const which of ['side', 'chat']) {
150
+ for (const which of Object.keys(PANEL)) {
146
151
  setPanel(which, Number(store.get(PANEL[which].key)) || PANEL[which].def, false);
147
152
  }
148
153
 
@@ -209,11 +214,20 @@ function dragPanel(handle, which, edge) {
209
214
  dragPanel(el.resizer, 'side', 'left');
210
215
  dragPanel(el.chatSplit, 'chat', 'right');
211
216
 
217
+ // The inspector is filled again whenever its agent changes; its handle is made once and put back each time, so a
218
+ // drag is not cut short by a poll.
219
+ const inspectorSplit = document.createElement('div');
220
+ inspectorSplit.className = 'resizer inspector-split';
221
+ inspectorSplit.setAttribute('role', 'separator');
222
+ inspectorSplit.setAttribute('aria-orientation', 'vertical');
223
+ inspectorSplit.setAttribute('aria-label', 'Resize inspector');
224
+ inspectorSplit.tabIndex = 0;
225
+ dragPanel(inspectorSplit, 'inspector', 'right');
226
+
212
227
  /* ------------------------------------------------------------ full screen
213
228
  The browser's own, so it hides the browser too — a panel meant to be watched
214
- while work runs should be able to take the whole display. The stylesheet
215
- takes the navigator, the inspector and the conversation off with it, so what
216
- is left is the canvas; Esc brings everything back. */
229
+ while work runs should be able to take the whole display. The panels keep
230
+ their state: what was open stays open, and `[` and `]` still fold the navigator. */
217
231
 
218
232
  async function toggleFullscreen() {
219
233
  try {
@@ -486,6 +500,11 @@ const pickFrom = (button, words, current, set) => button.addEventListener('click
486
500
  })));
487
501
  });
488
502
  pickFrom(el.tbGroup, GROUP_WORD, () => canvas.state().group, (g) => canvas.setGroup(g));
503
+
504
+ // In a window with room an agent group opens into the next column; in the band where the navigator is a rail
505
+ // there is no room for another column, and it opens downward as it always did.
506
+ canvas.setAside(!railBand.matches);
507
+ railBand.addEventListener('change', () => canvas.setAside(!railBand.matches));
489
508
  pickFrom(el.tbDensity, DENSITY_WORD, () => canvas.state().density, (d) => canvas.setDensity(d));
490
509
  pickFrom(el.tbShow, SHOW, () => show, (key) => setShow(key));
491
510
  // The toolbar is measured, not assumed: it gives up a step at a time until what it shows fits (toolbar-fit.js).
@@ -531,6 +550,10 @@ el.tbOptions.addEventListener('click', (e) => {
531
550
  if (view === 'graph' && st.groups > 0) {
532
551
  items.push({ note: 'Groups' }, { label: 'Expand all', run: () => canvas.expandAll() }, { label: 'Fold all', run: () => canvas.foldAll() });
533
552
  }
553
+ if (view === 'graph') {
554
+ const open = canvas.spendOpen;
555
+ items.push({ note: 'Session summary' }, { label: open ? 'Hide time, tokens and cost' : 'Show time, tokens and cost', run: () => canvas.showSpend(!open) });
556
+ }
534
557
  openMenu(el.tbOptions, items);
535
558
  });
536
559
  el.tbExpand.addEventListener('click', () => canvas.expandAll());
@@ -760,14 +783,18 @@ function paintInspector() {
760
783
  back.append(icon(ICON.back));
761
784
  back.addEventListener('click', () => { canvas.clearSelection(); showInspector(null); });
762
785
  close.classList.add('ihead-close');
763
- const name = isSession ? 'Session' : n.kind === 'workflow' ? (n.workflowId ?? 'Workflow run') : (n.agentType ?? 'unknown agent');
764
- head.append(back, canvas.tileFor(n), node('h3', 'iname', name), close);
786
+ const name = isSession ? 'Session' : n.kind === 'workflow' ? (n.workflowId ?? 'Workflow run') : shownType(n);
787
+ const iname = node('h3', 'iname', name);
788
+ if (n.kind === 'agent') iname.title = n.agentType ?? '';
789
+ head.append(back, canvas.tileFor(n), iname, close);
765
790
 
766
791
  const meta = node('div', 'imeta');
767
792
  const pill = node('span', 'pill');
768
793
  // A state nobody read is said, not left as a blank pill.
769
794
  pill.append(dot(st.tone), node('span', null, st.word ?? 'State not measured'));
770
795
  meta.append(pill, node('span', 'sub', timeLine(n, serverNow())));
796
+ // The role is the name; the type as it is declared is said here, where there is room for it.
797
+ if (n.kind === 'agent' && n.agentType) meta.append(node('code', 'ireal', n.agentType));
771
798
 
772
799
  const task = node('div', 'itask', isSession
773
800
  ? (sessionLabel(current) ?? n.sessionId ?? '')
@@ -807,7 +834,7 @@ function paintInspector() {
807
834
  copy.addEventListener('click', () => copyText(file, 'Copied'));
808
835
  foot.append(open, copy);
809
836
 
810
- el.inspector.replaceChildren(head, meta, task, tabs, body, foot);
837
+ el.inspector.replaceChildren(inspectorSplit, head, meta, task, tabs, body, foot);
811
838
  }
812
839
 
813
840
  function paintAgentOverview(body, n, detail) {
@@ -886,6 +913,21 @@ function paintSessionOverview(body, n) {
886
913
  }
887
914
  body.append(metaRows(n));
888
915
 
916
+ // Commands the session sent to the background and that are still going. They are not agents and have no card.
917
+ const running = backgroundLines(n, serverNow());
918
+ if (running.length) {
919
+ const rows = running.map((c) => {
920
+ const row = node('div', 'ibg-row');
921
+ const what = node('code', 'ibg-what', c.what);
922
+ what.title = c.what;
923
+ row.append(what);
924
+ if (c.age) row.append(node('span', 'sub', c.age));
925
+ return row;
926
+ });
927
+ const box = isection('In the background', ...rows);
928
+ body.append(box);
929
+ }
930
+
889
931
  const b = kitData?.board;
890
932
  let board;
891
933
  if (!kitData) board = node('div', 'ihint', 'Reading Crewforth…');
@@ -1056,7 +1098,7 @@ function paintGates(body) {
1056
1098
  for (const a of answers.slice(-12)) {
1057
1099
  const row = node('div', 'gate-row');
1058
1100
  row.append(node('span', 'gate-when', new Date(a.at - clock.offset).toLocaleTimeString()));
1059
- row.append(node('span', 'gate-name', `${a.toolName}${a.agentType ? ` · ${a.agentType}` : ''}`));
1101
+ row.append(node('span', 'gate-name', `${a.toolName}${a.agentType ? ` · ${roleName(a.agentType)}` : ''}`));
1060
1102
  const v = node('span', 'gate-verdict', OUTCOME_WORD[a.outcome]);
1061
1103
  v.dataset.verdict = a.outcome.startsWith('allowed') ? 'ALLOW' : a.outcome === 'answered-elsewhere' ? 'ASK' : 'BLOCK';
1062
1104
  row.append(v);
@@ -1456,6 +1498,14 @@ function sessionRow(project, sn) {
1456
1498
 
1457
1499
  const subText = sessionSub(sn, status) + (ownedIds.has(sn.sessionId) ? ' · started here' : '');
1458
1500
  if (subText) row.append(marked(subText, 'sub srow-sub'));
1501
+ // What it spent, once the server has read it: time · tokens · ~cost. Asked for a few rows at a time.
1502
+ const spent = usageFor(sn);
1503
+ const line = usageLine(spent);
1504
+ if (line) {
1505
+ const u = node('span', 'sub srow-usage', line);
1506
+ u.title = costNote(spent);
1507
+ row.append(u);
1508
+ }
1459
1509
 
1460
1510
  row.title = `${sn.sessionId}${sn.title ? `\n${sn.title}` : ''}`;
1461
1511
  const open = () => selectSession(sn.sessionId);
@@ -1467,6 +1517,36 @@ function sessionRow(project, sn) {
1467
1517
  return row;
1468
1518
  }
1469
1519
 
1520
+ /* What each session spent, for its row. The server reads a transcript once and keeps the answer until it grows, so
1521
+ this asks only for rows that are on screen and whose transcript has changed since it last asked. */
1522
+ const usageRows = new Map(); // sessionId -> { at: modifiedAt it was read for, value }
1523
+ const usageWanted = new Set();
1524
+ let usageBusy = false;
1525
+ function usageFor(sn) {
1526
+ const hit = usageRows.get(sn.sessionId);
1527
+ if (!hit || hit.at !== sn.modifiedAt) { usageWanted.add(sn.sessionId); queueMicrotask(pullUsage); }
1528
+ return hit?.value ?? null;
1529
+ }
1530
+ async function pullUsage() {
1531
+ if (usageBusy || !usageWanted.size) return;
1532
+ usageBusy = true;
1533
+ const ids = [...usageWanted].slice(0, 12);
1534
+ for (const id of ids) usageWanted.delete(id);
1535
+ try {
1536
+ const got = await getJson(`/api/usage?ids=${ids.map(encodeURIComponent).join(',')}`);
1537
+ for (const id of ids) {
1538
+ const sn = findSessionRow(id)?.session;
1539
+ // A session the server did not answer for (another machine's) is remembered as not read, and not asked again
1540
+ // until its row changes.
1541
+ usageRows.set(id, { at: sn?.modifiedAt ?? null, value: got.usage?.[id] ?? null });
1542
+ }
1543
+ paintProjects();
1544
+ } catch { /* the Live indicator says so; the rows keep what they had */ } finally {
1545
+ usageBusy = false;
1546
+ if (usageWanted.size) queueMicrotask(pullUsage);
1547
+ }
1548
+ }
1549
+
1470
1550
  function paintProjects() {
1471
1551
  const data = projectsData;
1472
1552
  if (!data || renaming && document.activeElement?.classList?.contains('srow-rename')) return;
@@ -1844,6 +1924,7 @@ function paintAttention(nodes) {
1844
1924
  const b = node('button', 'att');
1845
1925
  b.type = 'button';
1846
1926
  b.append(dot(a.tone), node('span', 'att-name', a.name), node('span', 'att-says', a.says));
1927
+ b.title = a.real ?? '';
1847
1928
  b.addEventListener('click', () => { attentionAt = attentionIds.indexOf(a.id); goTo(a.id); });
1848
1929
  return b;
1849
1930
  });
@@ -1900,36 +1981,13 @@ function selectSession(sessionId) {
1900
1981
  detailCache.clear();
1901
1982
 
1902
1983
  if (source) source.close();
1984
+ lastGraph = null;
1903
1985
  source = new EventSource(api(`/api/stream?session=${encodeURIComponent(sessionId)}`));
1904
1986
 
1905
1987
  source.addEventListener('graph', (e) => {
1906
1988
  heardNow();
1907
- const g = JSON.parse(e.data);
1908
- canvas.render(g);
1909
- lastNodes = g.nodes;
1910
- timeline.setNodes(g.nodes);
1911
- list.setNodes(g.nodes);
1912
- paintAttention(g.nodes);
1913
- // The conversation's delegation cards and its context figure are the graph's.
1914
- chat.refresh(sessionId, { contextTokens: g.nodes.find((n) => n.kind === 'session')?.tokens ?? null });
1915
- // The inspector holds a node object from an earlier frame; refresh it so
1916
- // status, tokens and tool counts keep moving while it is open.
1917
- if (inspectorNode) {
1918
- const fresh = g.nodes.find((n) => n.id === inspectorNode.id);
1919
- if (fresh) {
1920
- const changed = fresh.status !== inspectorNode.status;
1921
- inspectorNode = fresh;
1922
- paintInspector();
1923
- if (changed && fresh.kind === 'agent') loadDetail(fresh.id, fresh.status);
1924
- }
1925
- }
1926
- // Every status is named, so the chips add up to the total. A summary that
1927
- // reports "250 agents · 7 done" and stops invites the reader to assume the
1928
- // other 243 failed. A count of records that could not be read stays beside
1929
- // them: it is not a status, and it is not nothing.
1930
- const s = g.stats ?? {};
1931
- paintSummary(s, s.malformed ? [`${s.malformed} malformed`] : []);
1932
- el.foot.textContent = '';
1989
+ lastGraph = JSON.parse(e.data);
1990
+ showGraph();
1933
1991
  });
1934
1992
 
1935
1993
  source.addEventListener('idle', heardNow);
@@ -2053,6 +2111,8 @@ const outcomes = new Map(); // sessionId -> what became of its requests, as
2053
2111
  let waitingNow = [];
2054
2112
  let waitingSig = '';
2055
2113
  let lastNodes = null;
2114
+ let lastGraph = null; // what the stream last sent, before it is settled against the fleet
2115
+ let lastLive = null; // whether the session was live when that was last drawn
2056
2116
 
2057
2117
  function sessionLabel(id) {
2058
2118
  const known = findSessionRow(id);
@@ -2216,6 +2276,67 @@ function paintStageNote() {
2216
2276
  stageNote.retry.textContent = off ? off.retry : '';
2217
2277
  stageNote.retry.hidden = !off;
2218
2278
  stageNote.button.hidden = !off;
2279
+ // The fleet can say a session ended, or came back, with no transcript changing.
2280
+ if (lastGraph && sessionIsLive() !== lastLive) showGraph();
2281
+ paintTerminalWait();
2282
+ }
2283
+
2284
+ /**
2285
+ * Draw the graph the stream last sent. What the server read from files is settled against what the machine says
2286
+ * of the session first, so this is called again when that changes with no file changing.
2287
+ */
2288
+ function showGraph() {
2289
+ if (!lastGraph) return;
2290
+ lastLive = sessionIsLive();
2291
+ const g = settle(lastGraph, lastLive);
2292
+ canvas.render(g);
2293
+ canvas.setSpend(summaryRows(g.nodes.find((n) => n.kind === 'session')?.usage ?? null));
2294
+ lastNodes = g.nodes;
2295
+ timeline.setNodes(g.nodes);
2296
+ list.setNodes(g.nodes);
2297
+ paintAttention(g.nodes);
2298
+ // The conversation's delegation cards and its context figure are the graph's.
2299
+ chat.refresh(current, { contextTokens: g.nodes.find((n) => n.kind === 'session')?.tokens ?? null });
2300
+ // The inspector holds a node object from an earlier frame; refresh it so
2301
+ // status, tokens and tool counts keep moving while it is open.
2302
+ if (inspectorNode) {
2303
+ const fresh = g.nodes.find((n) => n.id === inspectorNode.id);
2304
+ if (fresh) {
2305
+ const changed = fresh.status !== inspectorNode.status;
2306
+ inspectorNode = fresh;
2307
+ paintInspector();
2308
+ if (changed && fresh.kind === 'agent') loadDetail(fresh.id, fresh.status);
2309
+ }
2310
+ }
2311
+ // Every status is named, so the chips add up to the total. A summary that
2312
+ // reports "250 agents · 7 done" and stops invites the reader to assume the
2313
+ // other 243 failed. A count of records that could not be read stays beside
2314
+ // them: it is not a status, and it is not nothing.
2315
+ const s = g.stats ?? {};
2316
+ paintSummary(s, s.malformed ? [`${s.malformed} malformed`] : []);
2317
+ el.foot.textContent = '';
2318
+ }
2319
+
2320
+ /** A session waiting on its own terminal is said under the view, with what it asks about. */
2321
+ let terminalWaitSig = '';
2322
+ function paintTerminalWait() {
2323
+ const session = lastNodes?.find((n) => n.kind === 'session') ?? null;
2324
+ const w = current
2325
+ ? terminalWait(sessionStatus(current, fleetData), session, ownedIds.has(current) || owned.has(current))
2326
+ : null;
2327
+ const sig = w ? `${w.text}|${w.what ?? ''}` : '';
2328
+ if (sig === terminalWaitSig) return;
2329
+ terminalWaitSig = sig;
2330
+ el.terminalWait.hidden = !w;
2331
+ if (w) {
2332
+ const what = node('span', 'terminal-wait-what', w.what ?? '');
2333
+ what.title = w.what ?? '';
2334
+ el.terminalWait.replaceChildren(
2335
+ node('span', 'terminal-wait-word', `${w.text}${w.what ? ':' : ''}`), what,
2336
+ node('span', 'sub terminal-wait-note', 'Studio answers only for sessions started here'),
2337
+ );
2338
+ }
2339
+ canvas.fitIfUntouched();
2219
2340
  }
2220
2341
 
2221
2342
  function retryNow() {
@@ -7,6 +7,7 @@
7
7
  // Every number here is the server's. How long the hook waits (`gateWaitSeconds`), when a request was asked
8
8
  // (`askedAt`) and what time it is now (`now`) all come from the machine the hook runs on, so a viewer whose clock
9
9
  // differs — a browser on the far end of a tunnel — still counts down to the moment the hook will really give up.
10
+ import { roleName } from './names.js';
10
11
 
11
12
  /** The server's clock, as seen from this page: the difference is measured each time the server says what time it is. */
12
13
  export class ServerClock {
@@ -70,8 +71,26 @@ export function remaining(item, serverNowMs) {
70
71
  }
71
72
 
72
73
  /** Who asked, in the words the dock uses. A call from the session itself has no agent, and says so. */
74
+ /**
75
+ * A session started in a terminal that is waiting for its user there. Studio cannot answer for it: the question
76
+ * is Claude Code's own prompt, in that terminal, and no hook of the panel's is in that session.
77
+ *
78
+ * @param status the session's state as the machine reports it (nav.js `sessionStatus`)
79
+ * @param session the graph's session node, whose `openCall` is the newest call with no result
80
+ * @param ownedHere true when the panel started this session: its requests are in the dock instead
81
+ * @returns null, or { text, what } — `what` is the tool and its command when the transcript has them, and
82
+ * otherwise what the CLI itself says it waits for; nothing is made up when neither is there.
83
+ */
84
+ export function terminalWait(status, session, ownedHere) {
85
+ if (ownedHere || status?.key !== 'waiting') return null;
86
+ const call = session?.openCall ?? null;
87
+ const who = call?.agentType ? `${roleName(call.agentType)} · ` : '';
88
+ const what = call ? `${who}${call.toolName}${call.detail ? ` · ${call.detail}` : ''}` : (status.waitingFor ?? null);
89
+ return { text: 'Waiting for an answer in the terminal', what };
90
+ }
91
+
73
92
  export function asker(item) {
74
- return item.agentType ?? 'Session';
93
+ return item.agentType ? roleName(item.agentType) : 'Session';
75
94
  }
76
95
 
77
96
  export const VERDICTS = {
@@ -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);