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.
@@ -362,6 +362,9 @@ body.resizing .cv-root { pointer-events: none; }
362
362
  .sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: none; }
363
363
  .srow-sub { padding-left: var(--space-4); flex: initial; overflow: hidden; text-overflow: ellipsis; }
364
364
  .srow.remote .srow-sub { padding-left: var(--space-6); }
365
+ /* What the session spent, under its state: time, tokens and the estimate. */
366
+ .srow-usage { padding-left: var(--space-4); flex: initial; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; color: var(--ink-faint); }
367
+ .srow.remote .srow-usage { padding-left: var(--space-6); }
365
368
  .sub.bad, .nav-line.bad { color: var(--status-bad); }
366
369
  mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); color: inherit; border-radius: 2px; }
367
370
 
@@ -722,6 +725,23 @@ button.tl-lab { cursor: pointer; }
722
725
  .att-says { color: var(--ink-dim); }
723
726
  .attention-hint { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
724
727
 
728
+ /* A session waiting on its own terminal: said where an approval would be, with no answers, because there are
729
+ none to give from here. */
730
+ .terminal-wait {
731
+ flex: none; min-width: 0;
732
+ display: flex; align-items: baseline; gap: var(--space-2);
733
+ padding: var(--space-2) var(--space-4);
734
+ border-top: 1px solid var(--line); background: var(--surface);
735
+ font-size: 13px;
736
+ }
737
+ .terminal-wait[hidden] { display: none; }
738
+ .terminal-wait-word { flex: none; font-weight: 600; color: var(--status-waiting); }
739
+ .terminal-wait-what {
740
+ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
741
+ font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
742
+ }
743
+ .terminal-wait-note { flex: none; margin-left: auto; }
744
+
725
745
  /* -------------------------------------------------------------- dock ---
726
746
  The approval dock: a row under the canvas, there only while a tool call is
727
747
  waiting. It is part of the stage's column, so the canvas above it stays
@@ -990,6 +1010,8 @@ button.tl-lab { cursor: pointer; }
990
1010
  }
991
1011
  .cv-fold .ic { width: 12px; height: 12px; }
992
1012
  .cv-fold:hover { color: var(--ink); background: var(--surface-raised); }
1013
+ /* Open beside itself, the control points back at the card it would fold into. */
1014
+ .cv-group[data-aside="true"] .cv-fold .ic { rotate: 180deg; }
993
1015
 
994
1016
  /* How the group went: one segment per status, as wide as its share. The words
995
1017
  are beside it when the group is folded and in its title when it is open. */
@@ -1052,6 +1074,11 @@ button.tl-lab { cursor: pointer; }
1052
1074
  cursor: default;
1053
1075
  }
1054
1076
  .cv-legend[hidden] { display: none; }
1077
+ /* The session's summary is the same kind of box, in the opposite corner. */
1078
+ .cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 220px; }
1079
+ .cv-spend-rows { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: var(--space-1) var(--space-3); font-size: 12px; }
1080
+ .cv-spend-rows dt { color: var(--ink-dim); }
1081
+ .cv-spend-rows dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; color: var(--ink); }
1055
1082
  .cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
1056
1083
  .cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
1057
1084
  .cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
@@ -1076,6 +1103,9 @@ button.tl-lab { cursor: pointer; }
1076
1103
  }
1077
1104
  .cv-tip[hidden] { display: none; }
1078
1105
  .cv-tip strong { font-family: var(--font-mono); font-weight: 600; }
1106
+ .cv-real { font-family: var(--font-mono); }
1107
+ /* "× 3": how many agents of one type a card stands for. */
1108
+ .cv-times { flex: none; padding: 0 6px; border-radius: 999px; background: var(--surface-raised); color: var(--ink); font-variant-numeric: tabular-nums; }
1079
1109
 
1080
1110
  /* The small chip the inspector's gate summary is made of. */
1081
1111
  .cv-bit {
@@ -1110,6 +1140,15 @@ button.tl-lab { cursor: pointer; }
1110
1140
  /* `display: flex` outranks the UA's `[hidden] { display: none }`, so the hidden
1111
1141
  panel kept its width. Restate it explicitly. */
1112
1142
  .inspector[hidden] { display: none; }
1143
+ /* A command the session left running in the background: what it is, cut to one line, and for how long. */
1144
+ .ibg-row { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; }
1145
+ .ibg-what { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); font-size: 12px; }
1146
+ .ibg-row .sub { flex: none; }
1147
+ /* Its left edge is a handle, as the navigator's right edge is: drag to resize, double-click for the default. */
1148
+ .inspector-split {
1149
+ position: absolute; left: -3px; top: 0; bottom: 0; width: 5px; z-index: 6;
1150
+ background: transparent;
1151
+ }
1113
1152
 
1114
1153
  .inspector dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; }
1115
1154
  .inspector dt { font-size: 11px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.05em; }
@@ -1154,6 +1193,9 @@ button.tl-lab { cursor: pointer; }
1154
1193
  .iname { flex: 1; min-width: 0; margin: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1155
1194
  .imeta { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-4) 0; flex: none; }
1156
1195
  .imeta .sub { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
1196
+ /* The agent's type as it is declared, beside the role it is shown as. */
1197
+ .imeta { flex-wrap: wrap; }
1198
+ .ireal { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1157
1199
  /* The task in full: it is the one place the whole sentence is shown. */
1158
1200
  .itask { padding: var(--space-3) var(--space-4); font-size: 16px; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; flex: none; max-height: 30%; overflow-y: auto; }
1159
1201
  .itask:empty { padding-bottom: 0; }
@@ -1587,15 +1629,10 @@ body.vresizing { cursor: row-resize; user-select: none; }
1587
1629
 
1588
1630
  /* --------------------------------------------------------- full screen --- */
1589
1631
 
1590
- /* Full screen is the canvas and nothing beside it: the navigator, its rail,
1591
- the inspector and the conversation step out until Esc. */
1592
- :root:fullscreen .shell { grid-template-columns: 0 0 minmax(0, 1fr) 0 0 0; }
1593
- :root:fullscreen .shell > .side,
1594
- :root:fullscreen .shell > .side-rail,
1595
- :root:fullscreen .shell > .resizer,
1596
- :root:fullscreen .shell > .inspector,
1597
- :root:fullscreen .shell > .chat,
1598
- :root:fullscreen .shell > .newpanel { display: none; }
1632
+ /* Full screen takes the browser away and nothing of the panel: the navigator,
1633
+ the inspector and the conversation stay where they were, and open and close
1634
+ as they do in a window. A rule here used to remove them, and with them the
1635
+ only way to an agent's details or to a conversation that was waiting. */
1599
1636
 
1600
1637
  /* ------------------------------------------------- tab bar that fits --- */
1601
1638
 
@@ -1627,7 +1664,7 @@ body.vresizing { cursor: row-resize; user-select: none; }
1627
1664
 
1628
1665
  @media (min-width: 1280px) {
1629
1666
  /* Docked: back in the grid, in the column kept for it. */
1630
- .shell.no-chat > .inspector { position: static; grid-column: 4; box-shadow: none; }
1667
+ .shell.no-chat > .inspector { position: relative; top: auto; bottom: auto; right: auto; grid-column: 4; box-shadow: none; }
1631
1668
  }
1632
1669
 
1633
1670
  @media (max-width: 1279px) {
@@ -1717,4 +1754,5 @@ body.vresizing { cursor: row-resize; user-select: none; }
1717
1754
  .shell > .newpanel { grid-column: auto; position: absolute; z-index: 35; inset: 0 0 var(--dock-h, 0px) 0; border-left: none; }
1718
1755
  .shell > .inspector,
1719
1756
  .shell.no-chat > .inspector { left: 0; right: 0; top: 0; width: auto; border-left: none; }
1757
+ .inspector-split { display: none; }
1720
1758
  }
@@ -9,6 +9,7 @@
9
9
  // records: the server's log of approvals, for sessions started here. An agent that failed is drawn failed for
10
10
  // its whole length — the transcript says how it ended, not when it started going wrong.
11
11
  import { stateOf, tally } from './graph-plan.js';
12
+ import { typeOf, shownType } from './names.js';
12
13
 
13
14
  export const RANGES = [
14
15
  { key: '5m', ms: 5 * 60_000 },
@@ -24,7 +25,6 @@ export const GROUP_ROWS = { showAllUpTo: 6, shown: 4 };
24
25
  const SESSION = 'session';
25
26
  const TONE = { live: 'busy', waking: 'busy', done: 'done', failed: 'fail', quiet: 'quiet', unknown: 'quiet' };
26
27
 
27
- const typeOf = (n) => n.agentType ?? 'unknown agent';
28
28
  const isLive = (n) => { const s = stateOf(n.status); return s === 'live' || s === 'waking'; };
29
29
 
30
30
  /** Where an agent's bar ends: its recorded end, or now while it is still going. */
@@ -113,11 +113,11 @@ export function groupsOf(nodes, grouping) {
113
113
  const agents = nodes.filter((n) => n.kind === 'agent');
114
114
  const byId = new Map(nodes.map((n) => [n.id, n]));
115
115
  const key = (n) => {
116
- if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: typeOf(n) };
116
+ if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: shownType(n), real: typeOf(n) };
117
117
  if (grouping === 'parent') {
118
118
  const p = byId.get(n.parentId);
119
119
  if (!p || p.kind === 'session') return { id: 'direct', label: 'Session · direct' };
120
- return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${typeOf(p)}` };
120
+ return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${shownType(p)}` };
121
121
  }
122
122
  if (grouping === 'none') return { id: 'all', label: null };
123
123
  // `run`: a workflow run is a group; everything the session started itself is another.
@@ -180,13 +180,13 @@ export function rows(nodes, opts = {}) {
180
180
  for (const m of same) seen.add(m.id);
181
181
  if (same.length > 1) {
182
182
  body.push({
183
- kind: 'merged', id: `${g.id}|${typeOf(n)}`, group: g.id, label: `${typeOf(n)} × ${same.length}`,
183
+ kind: 'merged', id: `${g.id}|${typeOf(n)}`, group: g.id, label: `${shownType(n)} × ${same.length}`, real: typeOf(n),
184
184
  sub: countsSub(same, waiting), members: same, lanes: lanes(same.flatMap(bar)), indent: headed && g.id !== 'direct',
185
185
  });
186
186
  } else {
187
187
  const sub = agentSub(n, waiting);
188
188
  body.push({
189
- kind: 'agent', id: n.id, group: g.id, node: n, label: typeOf(n), sub: sub.text, tone: sub.tone,
189
+ kind: 'agent', id: n.id, group: g.id, node: n, label: shownType(n), real: typeOf(n), sub: sub.text, tone: sub.tone,
190
190
  bars: bar(n), timed: n.startedAt != null, indent: headed && g.id !== 'direct',
191
191
  });
192
192
  }
@@ -6,6 +6,7 @@ import {
6
6
  rows, groupsOf, extent, windowOf, place, ticks, clock, stepRange, waitNote, factsOf, xOf, RANGES, DEFAULT_RANGE,
7
7
  } from './timeline-plan.js';
8
8
  import { Press } from './press.js';
9
+ import { shownType, hoverOf } from './names.js';
9
10
 
10
11
 
11
12
  function mk(tag, cls, text) {
@@ -260,7 +261,7 @@ export class Timeline {
260
261
  b.style.left = `${at.left}px`;
261
262
  b.style.width = `${at.width}px`;
262
263
  const node = this.nodes.find((n) => n.id === seg.id);
263
- b.setAttribute('aria-label', `${node?.agentType ?? 'agent'}, ${seg.tone === 'wait' ? 'waiting for you' : (node?.status ?? '')}, ${clock(seg.from)} to ${clock(seg.to)}`);
264
+ b.setAttribute('aria-label', `${node ? shownType(node) : 'agent'}, ${seg.tone === 'wait' ? 'waiting for you' : (node?.status ?? '')}, ${clock(seg.from)} to ${clock(seg.to)}`);
264
265
  if (node && this.dimmed(node)) b.classList.add('dim');
265
266
  if (seg.id === this.selected) b.classList.add('on');
266
267
  b.addEventListener('click', (e) => { e.stopPropagation(); this.#choose(seg.id); });
@@ -310,6 +311,7 @@ export class Timeline {
310
311
  const tile = this.hooks.tileOf?.(r.members[0]);
311
312
  if (tile) lab.append(tile);
312
313
  lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), mk('span', 'sub', r.sub));
314
+ lab.title = r.real ?? '';
313
315
  this.#bars(track, r.lanes, win, width);
314
316
  if (r.members.every((m) => this.dimmed(m))) row.classList.add('dim');
315
317
  } else {
@@ -319,7 +321,7 @@ export class Timeline {
319
321
  const sub = mk('span', 'sub', r.sub);
320
322
  if (r.tone) sub.dataset.tone = r.tone;
321
323
  lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), sub);
322
- lab.title = r.node.description ?? '';
324
+ lab.title = hoverOf(r.node);
323
325
  // One listener, on the row: the label is a button inside it, and its click arrives here too. Two
324
326
  // listeners chose the agent and un-chose it in the same click.
325
327
  row.addEventListener('click', () => this.#choose(r.id));
@@ -355,8 +357,12 @@ export class Timeline {
355
357
  const dot = mk('span', 'dot');
356
358
  dot.dataset.tone = st.tone ?? 'none';
357
359
  pill.append(dot, mk('span', null, st.word ?? 'State not measured'));
358
- head.append(mk('span', 'tl-d-name', n.agentType ?? 'unknown agent'), pill);
359
- left.append(head, mk('div', 'tl-d-task', n.description ?? ''), mk('div', 'sub tl-d-facts', factsOf(n, now, this.hooks.fmt)));
360
+ const dname = mk('span', 'tl-d-name', shownType(n));
361
+ dname.title = n.agentType ?? '';
362
+ head.append(dname, pill);
363
+ const dtask = mk('div', 'tl-d-task', n.description ?? '');
364
+ dtask.title = n.description ?? '';
365
+ left.append(head, dtask, mk('div', 'sub tl-d-facts', factsOf(n, now, this.hooks.fmt)));
360
366
 
361
367
  const mid = mk('div', 'tl-d-error');
362
368
  mid.append(mk('div', 'isec-h', 'Last error'));
@@ -0,0 +1,77 @@
1
+ // What a session spent, as words: how long, how many tokens, and an estimate of the cost.
2
+ //
3
+ // The numbers are the server's (server/lib/usage.js); the prices are in one file there (server/lib/pricing.js).
4
+ // Nothing here knows a price. The cost is an estimate at API list price and is always written as one: a leading
5
+ // "~", and a note that says what it is. A session with tokens from a model the price table does not have gets
6
+ // a dash, not a smaller number.
7
+
8
+ export const COST_NOTE = 'Estimated at API list price; on a subscription it is not what you are billed';
9
+ export const NO_COST = '—';
10
+
11
+ /** 950 · 12.4k · 3.2M · 1.53B */
12
+ export function fmtCount(n) {
13
+ if (n == null || !Number.isFinite(n)) return null;
14
+ if (n < 1000) return String(Math.round(n));
15
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 100_000 ? 1 : 0)}k`;
16
+ if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(n < 100_000_000 ? 1 : 0)}M`;
17
+ return `${(n / 1_000_000_000).toFixed(2)}B`;
18
+ }
19
+
20
+ /** "~$4.10", "~$1,240", "<$0.01"; a dash when there is no estimate to give. */
21
+ export function fmtCost(usd) {
22
+ if (usd == null || !Number.isFinite(usd)) return NO_COST;
23
+ if (usd > 0 && usd < 0.01) return '<$0.01';
24
+ if (usd >= 1000) return `~$${Math.round(usd).toLocaleString('en-US')}`;
25
+ return `~$${usd.toFixed(2)}`;
26
+ }
27
+
28
+ /** "40s", "12m", "3h 5m", "2d 4h": coarse, for a line with no room. */
29
+ export function fmtSpan(ms) {
30
+ if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
31
+ const s = Math.round(ms / 1000);
32
+ if (s < 60) return `${s}s`;
33
+ const m = Math.floor(s / 60);
34
+ if (m < 60) return `${m}m`;
35
+ const h = Math.floor(m / 60);
36
+ if (h < 24) return `${h}h ${m % 60}m`;
37
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
38
+ }
39
+
40
+ /**
41
+ * How long a session took. The time Claude Code itself timed its turns at, when it recorded any; otherwise the
42
+ * time from the first record to the last, which counts every pause as well.
43
+ * @returns { ms, kind: 'worked' | 'elapsed', note } or null
44
+ */
45
+ export function timeOf(u) {
46
+ if (u?.workedMs) return { ms: u.workedMs, kind: 'worked', note: 'The turns Claude Code timed, added up; a turn still running is not in it yet' };
47
+ if (u?.durationMs != null) return { ms: u.durationMs, kind: 'elapsed', note: 'From the first record to the last, pauses included: no turn was timed' };
48
+ return null;
49
+ }
50
+
51
+ /** Why there is no estimate, when there is none. */
52
+ export function costNote(u) {
53
+ if (!u?.unpriced?.length) return COST_NOTE;
54
+ return `No estimate: the price table has no row for ${u.unpriced.join(', ')}`;
55
+ }
56
+
57
+ /** The navigator's one line: time · tokens · ~cost. Null when nothing was read. */
58
+ export function usageLine(u) {
59
+ if (!u) return null;
60
+ const t = timeOf(u);
61
+ const tokens = typeof u.tokens === 'number' ? u.tokens : u.tokens?.total;
62
+ return [t ? fmtSpan(t.ms) : null, tokens ? fmtCount(tokens) : null, tokens ? fmtCost(u.cost) : null].filter(Boolean).join(' · ') || null;
63
+ }
64
+
65
+ /** The summary box's rows: [label, value, note]. */
66
+ export function summaryRows(u) {
67
+ if (!u) return [];
68
+ const t = timeOf(u);
69
+ const k = u.tokens ?? {};
70
+ const rows = [];
71
+ if (t) rows.push([t.kind === 'worked' ? 'Working time' : 'First to last record', fmtSpan(t.ms), t.note]);
72
+ if (t?.kind === 'worked' && u.durationMs != null) rows.push(['First to last record', fmtSpan(u.durationMs), 'Pauses included']);
73
+ rows.push(['Tokens', fmtCount(k.total ?? 0),
74
+ `Agents included, each response counted once\ninput ${fmtCount(k.input ?? 0)} · output ${fmtCount(k.output ?? 0)} · cache write ${fmtCount(k.cacheWrite ?? 0)} · cache read ${fmtCount(k.cacheRead ?? 0)}`]);
75
+ rows.push(['Cost', k.total ? fmtCost(u.cost) : NO_COST, costNote(u)]);
76
+ return rows;
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.1.0-rc.2",
3
+ "version": "3.1.0-rc.3",
4
4
  "description": "Crewforth — your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"