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.
@@ -0,0 +1,63 @@
1
+ // API list prices, for the estimate the panel shows beside a session's tokens.
2
+ //
3
+ // THIS IS THE ONLY PLACE A PRICE IS WRITTEN. Everything is USD per million tokens, copied from the page named
4
+ // in SOURCE on the day named there. A model that is not in this table has no price here: its cost is not
5
+ // estimated from a neighbour, and the panel shows a dash.
6
+ //
7
+ // What the page says and this file follows:
8
+ // - a cache write costs 1.25x the input price for the 5-minute cache and 2x for the 1-hour cache;
9
+ // - a cache read costs 0.1x the input price, except where a model's row says otherwise (`cacheRead`);
10
+ // - Claude Haiku 5.5 has a second set of prices for a prompt of over 100,000 tokens (`over`);
11
+ // - fast mode has its own input and output prices (`fast`), and the cache multipliers apply on top of them;
12
+ // - US-only inference multiplies every category by 1.1.
13
+ // What it does not cover: the Batch API discount, web search requests ($10 per 1,000), and prices on other
14
+ // cloud platforms. A session billed under any of those is estimated at the standard rate.
15
+
16
+ export const SOURCE = {
17
+ url: 'https://platform.claude.com/docs/en/about-claude/pricing',
18
+ read: '2026-10-08',
19
+ };
20
+
21
+ export const CACHE_WRITE_5M = 1.25;
22
+ export const CACHE_WRITE_1H = 2;
23
+ export const CACHE_READ = 0.1;
24
+ export const US_ONLY = 1.1;
25
+
26
+ // Keyed by the Claude API model id as a transcript records it. Only ids that were checked are here: the four
27
+ // current ones against the models overview page, and the four others against transcripts that name them.
28
+ export const PRICES = {
29
+ 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.025 },
30
+ 'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.05, fast: { input: 8, output: 40 } },
31
+ 'claude-sonnet-5-5': { input: 2, output: 10, cacheRead: 0.05 },
32
+ 'claude-haiku-5-5': { input: 0.10, output: 0.50, over: { promptTokens: 100_000, input: 0.50, output: 2.50 } },
33
+ 'claude-opus-5': { input: 5, output: 25, fast: { input: 10, output: 50 } },
34
+ 'claude-opus-4-8': { input: 5, output: 25, fast: { input: 10, output: 50 } },
35
+ 'claude-sonnet-5': { input: 2, output: 10 },
36
+ 'claude-haiku-4-5': { input: 1, output: 5 },
37
+ };
38
+
39
+ /** The row for a model id, or null. A dated snapshot (`<id>-20260101`) is the same model; nothing else matches. */
40
+ export function priceOf(model) {
41
+ if (typeof model !== 'string') return null;
42
+ if (Object.hasOwn(PRICES, model)) return PRICES[model];
43
+ const m = model.match(/^(.*)-\d{8}$/);
44
+ return m && Object.hasOwn(PRICES, m[1]) ? PRICES[m[1]] : null;
45
+ }
46
+
47
+ /**
48
+ * What one API response cost at list price, in USD. Null when the model has no row.
49
+ * @param u { model, input, output, cacheWrite5m, cacheWrite1h, cacheRead, fast, usOnly } — token counts
50
+ */
51
+ export function costOf(u) {
52
+ const row = priceOf(u.model);
53
+ if (!row) return null;
54
+ const prompt = u.input + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead;
55
+ const tier = (u.fast && row.fast) || (row.over && prompt > row.over.promptTokens ? row.over : row);
56
+ const read = row.cacheRead ?? CACHE_READ;
57
+ const usd = (u.input * tier.input
58
+ + u.cacheWrite5m * tier.input * CACHE_WRITE_5M
59
+ + u.cacheWrite1h * tier.input * CACHE_WRITE_1H
60
+ + u.cacheRead * tier.input * read
61
+ + u.output * tier.output) / 1_000_000;
62
+ return u.usOnly ? usd * US_ONLY : usd;
63
+ }
@@ -0,0 +1,144 @@
1
+ // What a session spent: tokens read from its transcripts, and what they would cost at API list price.
2
+ //
3
+ // Claude Code writes one record per content block, and every record of one API response repeats that response's
4
+ // usage. Measured on one machine before this was written: 23,867 assistant records carried usage, for 11,245
5
+ // responses, and every repeat carried the same numbers. So a response is counted once, by its message id — and
6
+ // once across the session's files too, which is what keeps an agent's tokens from being added twice.
7
+
8
+ import fsp from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ import { readAll } from './transcript.js';
12
+ import { agentMetaFiles } from './projects.js';
13
+ import { costOf, SOURCE } from './pricing.js';
14
+
15
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
16
+
17
+ /** One response's usage, in the shape pricing.js prices. Null when the record carries none. */
18
+ export function usageOfRecord(r) {
19
+ const m = r?.type === 'assistant' ? r.message : null;
20
+ const u = m?.usage;
21
+ if (!u || typeof u !== 'object') return null;
22
+ const split = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
23
+ const w1h = num(split?.ephemeral_1h_input_tokens);
24
+ // With no split recorded, a cache write is the 5-minute kind: that is the default, and the cheaper of the two.
25
+ const w5m = split ? num(split.ephemeral_5m_input_tokens) : num(u.cache_creation_input_tokens);
26
+ return {
27
+ id: typeof m.id === 'string' ? m.id : null,
28
+ model: typeof m.model === 'string' ? m.model : null,
29
+ input: num(u.input_tokens),
30
+ output: num(u.output_tokens),
31
+ cacheWrite5m: w5m,
32
+ cacheWrite1h: w1h,
33
+ cacheRead: num(u.cache_read_input_tokens),
34
+ fast: u.speed === 'fast',
35
+ usOnly: u.inference_geo === 'us',
36
+ };
37
+ }
38
+
39
+ export class Usage {
40
+ constructor() {
41
+ this.seen = new Set();
42
+ this.tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
43
+ this.usd = 0;
44
+ this.responses = 0;
45
+ this.priced = new Set();
46
+ this.unpriced = new Set();
47
+ }
48
+
49
+ /** Add a transcript's records. A response already counted, from this file or another, is not counted again. */
50
+ add(records) {
51
+ for (const r of records ?? []) {
52
+ const u = usageOfRecord(r);
53
+ if (!u) continue;
54
+ if (u.id) { if (this.seen.has(u.id)) continue; this.seen.add(u.id); }
55
+ const total = u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead;
56
+ if (!total) continue; // a record the harness made up for itself carries no tokens
57
+ this.responses += 1;
58
+ this.tokens.input += u.input;
59
+ this.tokens.output += u.output;
60
+ this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
61
+ this.tokens.cacheRead += u.cacheRead;
62
+ const usd = costOf(u);
63
+ if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
64
+ else { this.usd += usd; this.priced.add(u.model); }
65
+ }
66
+ return this;
67
+ }
68
+
69
+ /**
70
+ * @returns tokens by kind and in total; `cost` in USD at list price, or null when any of the tokens came from
71
+ * a model the table has no row for — a sum that leaves some of them out would read as the whole.
72
+ */
73
+ result() {
74
+ const t = this.tokens;
75
+ return {
76
+ tokens: { ...t, total: t.input + t.output + t.cacheWrite + t.cacheRead },
77
+ responses: this.responses,
78
+ cost: this.unpriced.size ? null : this.usd,
79
+ models: [...this.priced].sort(),
80
+ unpriced: [...this.unpriced].sort(),
81
+ priceSource: SOURCE,
82
+ };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * How long a session took, two ways. `durationMs` is from its first record to its last, pauses included.
88
+ * `workedMs` is the turns Claude Code timed itself (`system` / `turn_duration` records), added up; zero when it
89
+ * recorded none, and a turn that is still running is not in it.
90
+ */
91
+ export function timesOf(records) {
92
+ let first = null;
93
+ let last = null;
94
+ let workedMs = 0;
95
+ let turns = 0;
96
+ for (const r of records ?? []) {
97
+ const t = r?.timestamp ? Date.parse(r.timestamp) : NaN;
98
+ if (Number.isFinite(t)) {
99
+ if (first === null || t < first) first = t;
100
+ if (last === null || t > last) last = t;
101
+ }
102
+ if (r?.type === 'system' && r.subtype === 'turn_duration' && typeof r.durationMs === 'number' && r.durationMs > 0) {
103
+ workedMs += r.durationMs;
104
+ turns += 1;
105
+ }
106
+ }
107
+ return { durationMs: first !== null && last !== null ? last - first : null, workedMs, turns };
108
+ }
109
+
110
+ /** A session's usage with its agents', and how long it took. */
111
+ export async function sessionUsage(session) {
112
+ const usage = new Usage();
113
+ const { records } = await readAll(session.file);
114
+ usage.add(records);
115
+ for (const meta of await agentMetaFiles(session.subagentsDir)) {
116
+ const jsonl = meta.replace(/\.meta\.json$/, '.jsonl');
117
+ try { usage.add((await readAll(jsonl)).records); } catch { /* an agent that has not written yet */ }
118
+ }
119
+ return { ...usage.result(), ...timesOf(records) };
120
+ }
121
+
122
+ // One answer per session, kept until one of its files changes. The key is what the stream's own signature
123
+ // watches: the transcript's size, and every agent file's.
124
+ const cache = new Map(); // sessionId -> { sig, value }
125
+
126
+ async function sigOf(session) {
127
+ const parts = [];
128
+ try { parts.push(String((await fsp.stat(session.file)).size)); } catch { parts.push('0'); }
129
+ for (const meta of await agentMetaFiles(session.subagentsDir)) {
130
+ const jsonl = meta.replace(/\.meta\.json$/, '.jsonl');
131
+ try { parts.push(`${path.basename(jsonl)}:${(await fsp.stat(jsonl)).size}`); } catch { /* not written yet */ }
132
+ }
133
+ return parts.join('|');
134
+ }
135
+
136
+ export async function cachedUsage(session) {
137
+ const sig = await sigOf(session);
138
+ const hit = cache.get(session.sessionId);
139
+ if (hit && hit.sig === sig) return hit.value;
140
+ const value = await sessionUsage(session);
141
+ if (cache.size > 500) cache.clear();
142
+ cache.set(session.sessionId, { sig, value });
143
+ return value;
144
+ }
@@ -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 {
@@ -405,7 +419,7 @@ el.newSession.addEventListener('click', () => {
405
419
 
406
420
  /* The toolbar says what the canvas is doing. It is painted from the canvas's own
407
421
  state, so a choice restored from storage shows without being re-made. */
408
- const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', none: 'None' };
422
+ const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', order: 'Order', none: 'None' };
409
423
  const DENSITY_WORD = { auto: 'Auto', comfortable: 'Comfortable', compact: 'Compact' };
410
424
  const SHOW = {
411
425
  all: { word: 'All', statuses: null },
@@ -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 = {