crewforth 3.1.1 → 3.2.0-rc.10

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +6 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +66 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/AGENT_TEMPLATE.md +2 -1
  7. package/kit/CLAUDE.md +15 -14
  8. package/kit/agents/crew-commit-agent.md +11 -4
  9. package/kit/agents/crew-docs-agent.md +71 -0
  10. package/kit/agents/crew-planner.md +2 -0
  11. package/kit/agents/crew-refuter.md +77 -0
  12. package/kit/agents/crew-review-agent.md +2 -0
  13. package/kit/agents/crew-security-expert.md +3 -1
  14. package/kit/agents/crew-session-manager.md +11 -9
  15. package/kit/eval/doctor.sh +128 -6
  16. package/kit/eval/gate-report.sh +2 -0
  17. package/kit/eval/golden-routing.txt +9 -0
  18. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  19. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  20. package/kit/eval/lib/crew-map/narrative.mjs +132 -0
  21. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  22. package/kit/eval/lib/crew-map/project.mjs +164 -0
  23. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  24. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  25. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  26. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  27. package/kit/eval/lib/crew-map/scan.mjs +266 -0
  28. package/kit/eval/lib/crew-map/stacks.mjs +547 -0
  29. package/kit/eval/lib/crew-map/surface.mjs +535 -0
  30. package/kit/eval/lib/findings/findings.mjs +410 -0
  31. package/kit/eval/lib/findings/findings.schema.json +122 -0
  32. package/kit/eval/lib/findings/findings.sh +29 -0
  33. package/kit/eval/lib/gate-log.sh +52 -0
  34. package/kit/eval/lib/json-string.awk +27 -0
  35. package/kit/eval/lib/preset.sh +109 -0
  36. package/kit/eval/lib/presets/default.json +7 -0
  37. package/kit/eval/lib/presets/solo.json +7 -0
  38. package/kit/eval/lib/presets/strict.json +11 -0
  39. package/kit/eval/lib/preview.sh +150 -0
  40. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  41. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  42. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  43. package/kit/eval/lib/settings-default.sh +36 -0
  44. package/kit/eval/lib/settings-json.awk +19 -4
  45. package/kit/eval/lib/star.sh +23 -7
  46. package/kit/eval/lib/statusline.settings.json +6 -0
  47. package/kit/eval/lib/statusline.sh +100 -0
  48. package/kit/eval/lib/verify-cmd.sh +17 -0
  49. package/kit/eval/lib/version.sh +41 -0
  50. package/kit/eval/review-bench.sh +26 -0
  51. package/kit/eval/smoke-test.sh +878 -53
  52. package/kit/hooks/agent-outcome.sh +44 -12
  53. package/kit/hooks/board-sync.sh +2 -23
  54. package/kit/hooks/guard-agent-model.sh +177 -24
  55. package/kit/hooks/guard-bash.sh +295 -204
  56. package/kit/hooks/guard-commit-scan.sh +10 -2
  57. package/kit/hooks/guard-question.sh +73 -0
  58. package/kit/hooks/guard-write.sh +24 -7
  59. package/kit/hooks/precompact-snapshot.sh +75 -0
  60. package/kit/hooks/prompt-approval.sh +389 -103
  61. package/kit/hooks/refuter-ledger.sh +80 -0
  62. package/kit/hooks/session-rehydrate.sh +25 -2
  63. package/kit/hooks/session-update-check.sh +9 -8
  64. package/kit/hooks/trace-blocklist.txt +9 -0
  65. package/kit/owned-blobs.tsv +4 -4
  66. package/kit/settings.json +51 -0
  67. package/kit/skills/crew-approve/SKILL.md +7 -0
  68. package/kit/skills/crew-code-review/SKILL.md +16 -0
  69. package/kit/skills/crew-doctor/SKILL.md +5 -1
  70. package/kit/skills/crew-handoff/SKILL.md +3 -2
  71. package/kit/skills/crew-map/SKILL.md +55 -0
  72. package/kit/skills/crew-review/SKILL.md +4 -1
  73. package/kit/skills/crew-ship/SKILL.md +3 -1
  74. package/kit/skills/crew-update/SKILL.md +1 -1
  75. package/kit/skills/incident-runbook/SKILL.md +2 -2
  76. package/kit/skills/security-scan/references/verify.md +6 -3
  77. package/kit/studio/README.md +25 -2
  78. package/kit/studio/server/index.js +187 -21
  79. package/kit/studio/server/lib/code-stamp.js +35 -0
  80. package/kit/studio/server/lib/crew-map.js +113 -0
  81. package/kit/studio/server/lib/graph.js +9 -1
  82. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  83. package/kit/studio/server/lib/lan.js +87 -0
  84. package/kit/studio/server/lib/model-outcomes.js +130 -15
  85. package/kit/studio/server/lib/owned-stream.js +56 -0
  86. package/kit/studio/server/lib/pricing.js +3 -0
  87. package/kit/studio/server/lib/projects.js +11 -4
  88. package/kit/studio/server/lib/redact.js +60 -0
  89. package/kit/studio/server/lib/remote.js +185 -0
  90. package/kit/studio/server/lib/session.js +1 -1
  91. package/kit/studio/server/lib/terminal.js +51 -13
  92. package/kit/studio/server/lib/transcript.js +32 -0
  93. package/kit/studio/server/lib/usage.js +74 -14
  94. package/kit/studio/web/app.js +287 -17
  95. package/kit/studio/web/canvas.js +58 -122
  96. package/kit/studio/web/chat.js +114 -24
  97. package/kit/studio/web/graph-plan.js +13 -6
  98. package/kit/studio/web/index.html +10 -5
  99. package/kit/studio/web/liveness.js +14 -0
  100. package/kit/studio/web/map-plan.js +410 -0
  101. package/kit/studio/web/map.js +596 -0
  102. package/kit/studio/web/models-plan.js +61 -7
  103. package/kit/studio/web/models.js +19 -3
  104. package/kit/studio/web/qr.js +200 -0
  105. package/kit/studio/web/style.css +165 -0
  106. package/kit/studio/web/tokens.css +6 -0
  107. package/kit/studio/web/usage.js +12 -0
  108. package/kit/studio/web/viewport.js +160 -0
  109. package/package.json +6 -5
  110. package/start.sh +24 -1
@@ -11,9 +11,10 @@
11
11
  // A status is never colour alone: every dot has its word beside it.
12
12
  // A poll that changed nothing writes nothing. Each element remembers what it last drew.
13
13
  import {
14
- plan, sequence, crowdFolds, fitZoom, tallestFor, stateOf, ZOOM, GROUPINGS, DENSITIES, INFERRED_NOTE,
14
+ plan, sequence, crowdFolds, tallestFor, stateOf, GROUPINGS, DENSITIES, INFERRED_NOTE, AUTO,
15
15
  } from './graph-plan.js';
16
16
  import { clock } from './timeline-plan.js';
17
+ import { Viewport, draggable, swallowClick } from './viewport.js';
17
18
  import { agentStatus } from './nav.js';
18
19
  import { roleName, shownType, hoverOf, originOf, orderTag, modelOf, modelMix, modelFamily, modelBadge } from './names.js';
19
20
  import { fmtCount, fmtCost, COST_NOTE } from './usage.js';
@@ -38,6 +39,11 @@ const MOTION_BUDGET = 60;
38
39
  // How long the pointer rests on a card before its details are shown.
39
40
  const TIP_MS = 400;
40
41
 
42
+ // How long a pane has to stay one size before Auto decides again between cards and chips. While its edge is being
43
+ // dragged the view follows at once, but the choice waits: measured 2026-10-14 with 43 agents, an edge moved to and
44
+ // fro across Auto's limit for 60 frames turned every card into a chip and back 57 times, each a full redraw.
45
+ export const RESIZE_SETTLE_MS = 150;
46
+
41
47
  // Marks, so a card says what kind of thing it is before it is read.
42
48
  //
43
49
  // Crewforth's own is the three chevrons, drawn here because the shape is three strokes and its colours are the
@@ -77,7 +83,6 @@ export class Canvas {
77
83
  this.onSelect = onSelect ?? (() => {});
78
84
  this.onMenu = onMenu ?? (() => {});
79
85
  this.onChange = onChange ?? (() => {});
80
- this.view = { x: 0, y: 0, k: 1 };
81
86
  this.nodes = new Map(); // id -> the server's node
82
87
  this.pos = new Map(); // drawn item id -> {x, y}
83
88
  this.pinned = new Map(); // item id -> {x, y} the viewer set by hand
@@ -108,7 +113,6 @@ export class Canvas {
108
113
  this.sessionY = null;
109
114
  this.selected = null;
110
115
  this.firstRender = true;
111
- this.touched = false; // the viewer moved the view themselves
112
116
  this.drawn = 0;
113
117
 
114
118
  this.#build();
@@ -286,51 +290,13 @@ export class Canvas {
286
290
  /* ------------------------------------------------------------ input */
287
291
 
288
292
  #wire() {
289
- let panning = null;
290
-
291
- this.root.addEventListener('pointerdown', (e) => {
292
- if (e.button !== 0) return; // right/middle must not pan
293
- const t = e.target;
294
- if (t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')) return; // the summary box is a .cv-legend too
295
- panning = { px: e.clientX, py: e.clientY, x: this.view.x, y: this.view.y };
296
- this.root.setPointerCapture(e.pointerId);
297
- this.root.classList.add('cv-panning');
298
- });
299
-
300
- this.root.addEventListener('pointermove', (e) => {
301
- if (!panning) return;
302
- this.view.x = panning.x + (e.clientX - panning.px);
303
- this.view.y = panning.y + (e.clientY - panning.py);
304
- this.touched = true;
305
- this.applyView();
293
+ // Pan, zoom and Fit are the shared viewport's: the same on the graph and on the project map.
294
+ this.vp = new Viewport(this.root, this.viewport, {
295
+ // the summary box is a .cv-legend too
296
+ skip: (t) => Boolean(t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')),
297
+ onApply: () => { this.#mapView(); this.onChange(this.state()); },
306
298
  });
307
299
 
308
- const endPan = () => { panning = null; this.root.classList.remove('cv-panning'); };
309
- this.root.addEventListener('pointerup', endPan);
310
- this.root.addEventListener('pointercancel', endPan);
311
-
312
- this.root.addEventListener('wheel', (e) => {
313
- e.preventDefault();
314
- const r = this.root.getBoundingClientRect();
315
- const mx = e.clientX - r.left;
316
- const my = e.clientY - r.top;
317
-
318
- // deltaMode 1 is lines and 2 is pages; Firefox reports ~3 where Chrome reports ~100, which made zoom
319
- // imperceptible and pan crawl.
320
- const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? r.height : 1;
321
- const dY = e.deltaY * unit;
322
- const dX = e.deltaX * unit;
323
-
324
- if (e.ctrlKey || e.metaKey || Math.abs(dY) > Math.abs(dX)) {
325
- this.#zoomAbout(this.view.k * Math.exp(-dY * 0.0016), mx, my);
326
- } else {
327
- this.view.x -= dX;
328
- this.view.y -= dY;
329
- }
330
- this.touched = true;
331
- this.applyView();
332
- }, { passive: false });
333
-
334
300
  // The minimap's frame is the pane: put the pointer somewhere on the map and the pane goes there.
335
301
  let mapping = false;
336
302
  const mapTo = (e) => {
@@ -352,28 +318,14 @@ export class Canvas {
352
318
  this.mapEl.addEventListener('pointercancel', () => { mapping = false; });
353
319
  }
354
320
 
355
- /** Zoom to `k`, keeping the world point under (mx, my) where it is. */
356
- #zoomAbout(k, mx, my) {
357
- const next = clamp(k, ZOOM.min, ZOOM.max);
358
- this.view.x = mx - (mx - this.view.x) * (next / this.view.k);
359
- this.view.y = my - (my - this.view.y) * (next / this.view.k);
360
- this.view.k = next;
361
- }
321
+ /** Where the pane looks: the viewport's, kept under these names because the page and the claims use them. */
322
+ get view() { return this.vp.view; }
323
+ get touched() { return this.vp.touched; }
324
+ set touched(v) { this.vp.touched = v; }
362
325
 
363
326
  /** Step the zoom from the toolbar: about the middle of the pane. */
364
- zoomBy(factor) {
365
- const r = this.root.getBoundingClientRect();
366
- this.#zoomAbout(this.view.k * factor, r.width / 2, r.height / 2);
367
- this.touched = true;
368
- this.applyView();
369
- }
370
-
371
- zoomTo(k) {
372
- const r = this.root.getBoundingClientRect();
373
- this.#zoomAbout(k, r.width / 2, r.height / 2);
374
- this.touched = true;
375
- this.applyView();
376
- }
327
+ zoomBy(factor) { this.vp.zoomBy(factor); }
328
+ zoomTo(k) { this.vp.zoomTo(k); }
377
329
 
378
330
  /**
379
331
  * Turn `view` into pixels. Public because it is the single place that does so — the wheel, the pan, fit() and
@@ -382,12 +334,7 @@ export class Canvas {
382
334
  * It writes one transform. Nothing a card draws depends on the zoom, so a zoom restyles nothing: that was the
383
335
  * cost of the old counter-scaled labels, 454 ms of style work per second at 250 nodes.
384
336
  */
385
- applyView() {
386
- const { x, y, k } = this.view;
387
- this.viewport.style.transform = `translate(${x}px, ${y}px) scale(${k})`;
388
- this.#mapView();
389
- this.onChange(this.state());
390
- }
337
+ applyView() { this.vp.apply(); }
391
338
 
392
339
  /** What the toolbar shows. */
393
340
  state() {
@@ -587,25 +534,31 @@ export class Canvas {
587
534
  return tallestFor(this.paneHeight);
588
535
  }
589
536
 
537
+ /** The pane is changing size. The view follows it now; whether it is cards or chips is asked once it stops. */
538
+ paneResized() {
539
+ if (!this.touched && !this.pinned.size) this.fit({ readable: true });
540
+ clearTimeout(this.settleTimer);
541
+ this.settleTimer = setTimeout(() => { this.settleTimer = null; this.fitIfUntouched(); }, RESIZE_SETTLE_MS);
542
+ }
543
+
590
544
  /** Re-fit only while the view is still the canvas's own. Once the viewer has panned, zoomed or placed a card,
591
545
  * moving the view under them is not helpful. */
592
546
  fitIfUntouched() {
593
547
  // The pane changed size: what fits in it may have changed with it, and so may Auto's answer.
594
548
  const h = this.root.getBoundingClientRect?.().height ?? 0;
595
549
  if (this.lastPlan && this.density === 'auto' && tallestFor(h) !== tallestFor(this.paneHeight ?? 0)) this.#redraw();
596
- if (!this.touched && !this.pinned.size) this.fit();
550
+ if (!this.touched && !this.pinned.size) this.fit({ readable: true });
597
551
  }
598
552
 
599
- fit() {
553
+ /**
554
+ * Fit the picture to the pane. On its own (`readable`), the view does not go under the zoom a card is read at
555
+ * when Auto kept cards for a picture taller than the pane: that is the overflow Auto allows, panned to, not
556
+ * shrunk away. The toolbar's Fit asks for the whole picture.
557
+ */
558
+ fit({ readable = false } = {}) {
600
559
  const p = this.lastPlan;
601
560
  if (!p || !p.items.length) return;
602
- const r = this.root.getBoundingClientRect();
603
- const k = fitZoom(p.width, p.height, r.width, r.height);
604
- this.view.k = k;
605
- this.view.x = Math.max(ZOOM.pad, (r.width - p.width * k) / 2);
606
- this.view.y = Math.max(ZOOM.pad, (r.height - p.height * k) / 2);
607
- this.touched = false;
608
- this.applyView();
561
+ this.vp.fit(p.width, p.height, readable && this.density === 'auto' && p.density === 'comfortable' ? AUTO.readable : 0);
609
562
  }
610
563
 
611
564
  /* -------------------------------------------------------- rendering */
@@ -699,7 +652,7 @@ export class Canvas {
699
652
 
700
653
  this.#drawEdges(p);
701
654
  this.#drawMap(p);
702
- if (fit) this.fit(); else this.applyView();
655
+ if (fit) this.fit({ readable: true }); else this.applyView();
703
656
  }
704
657
 
705
658
  /** The card's position and size, and whatever only changes with them. */
@@ -764,7 +717,10 @@ export class Canvas {
764
717
  // ran out of the card.
765
718
  p.bg = mk('div', 'cv-sub cv-indent cv-bg');
766
719
  p.bg.hidden = true;
767
- el.append(row, p.name, p.sub, p.bg);
720
+ // Agent calls the model gate refused, which started no agent: a line of their own, the gate's words on hover.
721
+ p.refused = mk('div', 'cv-sub cv-indent cv-refused');
722
+ p.refused.hidden = true;
723
+ el.append(row, p.name, p.sub, p.bg, p.refused);
768
724
  } else if (it.kind === 'group') {
769
725
  el.classList.add('cv-group');
770
726
  p.tile = mk('span', 'cv-tile');
@@ -884,12 +840,21 @@ export class Canvas {
884
840
  p.name.title = p.name.textContent;
885
841
  const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
886
842
  if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
843
+ if (n.context?.toolShare != null) bits.push(`${n.context.toolShare}% tool output`);
887
844
  p.sub.textContent = bits.join(' · ');
888
- p.sub.title = p.sub.textContent;
845
+ // The share is of the text since the last compaction, in characters; the compactions are the transcript's own.
846
+ const lc = n.context?.lastCompact;
847
+ p.sub.title = [p.sub.textContent,
848
+ n.context?.toolShare != null ? `${n.context.toolShare}% of the text in context since the last compaction is tool output (characters, not tokens)` : null,
849
+ n.context?.compactions ? `compacted ${n.context.compactions}×${lc?.before != null ? `, last ${lc.trigger ?? ''} ${fmtTokens(lc.before)} → ${fmtTokens(lc.after)}` : ''}` : null].filter(Boolean).join('\n');
889
850
  // What the session has running that is not an agent: commands sent to the background.
890
851
  p.bg.hidden = !bg;
891
852
  p.bg.textContent = bg ? `${bg} in background` : '';
892
853
  p.bg.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
854
+ const refused = n.refusedCalls ?? [];
855
+ p.refused.hidden = !refused.length;
856
+ p.refused.textContent = refused.length ? `${refused.length} refused by the model gate` : '';
857
+ p.refused.title = refused.map((r) => `${r.agent ?? 'an agent'}${r.model ? ` (${r.model})` : ''}: ${r.text}`).join('\n');
893
858
  el.title = 'Click to inspect this session';
894
859
  el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
895
860
  }
@@ -983,7 +948,7 @@ export class Canvas {
983
948
  // Beside the bar: how the members went — or, at compact density where the card has no line for it, the
984
949
  // models they ran on, with how they went left to the bar and its tooltip.
985
950
  const mix = modelMix(it.members).replace(/, /g, ' \u00b7 ');
986
- if (!it.boxed) { p.count.textContent = it.compact && mix ? mix : says; p.count.title = it.compact && mix ? `${says}\nThe models its agents ran on` : ''; }
951
+ if (!it.boxed) { p.count.textContent = it.compact && mix ? `${says} \u00b7 ${mix}` : says; p.count.title = it.compact && mix ? `${says}\nThe models its agents ran on: ${mix}` : ''; }
987
952
  if (!it.boxed) this.#fillTotals(p, it);
988
953
  el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
989
954
  el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
@@ -1302,13 +1267,8 @@ export class Canvas {
1302
1267
  const cell = item ? null : host.cells?.find((c) => c.id === id);
1303
1268
  const cx = host.x + (cell ? cell.x + cell.w / 2 : host.w / 2);
1304
1269
  const cy = host.y + (cell ? cell.y + cell.h / 2 : host.h / 2);
1305
- const r = this.root.getBoundingClientRect();
1306
1270
  // Close enough to read, and no closer than the viewer already was.
1307
- this.view.k = clamp(Math.max(this.view.k, 1), ZOOM.min, ZOOM.max);
1308
- this.view.x = r.width / 2 - cx * this.view.k;
1309
- this.view.y = r.height / 2 - cy * this.view.k;
1310
- this.touched = true;
1311
- this.applyView();
1271
+ this.vp.centerOn(cx, cy, 1);
1312
1272
  }
1313
1273
  this.#persist();
1314
1274
  this.onSelect(this.nodes.get(id) ?? null);
@@ -1327,38 +1287,14 @@ export class Canvas {
1327
1287
  /* ------------------------------------------------------ interaction */
1328
1288
 
1329
1289
  #interact(el, id) {
1330
- let drag = null;
1331
-
1332
- el.addEventListener('pointerdown', (e) => {
1333
- if (e.button !== 0) return;
1334
- e.stopPropagation(); // do not pan the canvas too
1335
- if (e.target.closest?.('.cv-fold')) return;
1336
- const p = this.pos.get(id) ?? { x: 0, y: 0 };
1337
- drag = { px: e.clientX, py: e.clientY, x: p.x, y: p.y, moved: false };
1338
- el.setPointerCapture(e.pointerId);
1290
+ draggable(el, {
1291
+ scale: () => this.view.k,
1292
+ at: () => this.pos.get(id),
1293
+ move: (x, y) => { this.pinned.set(id, { x, y }); this.#redraw(); },
1294
+ end: () => this.#persist(),
1295
+ ignore: (t) => Boolean(t.closest?.('.cv-fold')),
1339
1296
  });
1340
1297
 
1341
- el.addEventListener('pointermove', (e) => {
1342
- if (!drag) return;
1343
- const dx = (e.clientX - drag.px) / this.view.k;
1344
- const dy = (e.clientY - drag.py) / this.view.k;
1345
- if (!drag.moved && Math.abs(dx) <= 3 && Math.abs(dy) <= 3) return;
1346
- drag.moved = true;
1347
- el.classList.add('cv-dragging');
1348
- this.pinned.set(id, { x: Math.round(drag.x + dx), y: Math.round(drag.y + dy) });
1349
- this.#redraw();
1350
- });
1351
-
1352
- const stop = () => {
1353
- if (!drag) return;
1354
- // A drag ends with a click on the same element. Without this guard every reposition also toggled selection.
1355
- if (drag.moved) { this.#persist(); el.dataset.suppressClick = '1'; }
1356
- drag = null;
1357
- el.classList.remove('cv-dragging');
1358
- };
1359
- el.addEventListener('pointerup', stop);
1360
- el.addEventListener('pointercancel', stop);
1361
-
1362
1298
  const act = () => {
1363
1299
  const it = this.lastPlan?.items.find((x) => x.id === id);
1364
1300
  if (!it) return;
@@ -1368,7 +1304,7 @@ export class Canvas {
1368
1304
  this.#select(id);
1369
1305
  };
1370
1306
  el.addEventListener('click', (e) => {
1371
- if (el.dataset.suppressClick === '1') { el.dataset.suppressClick = '0'; return; }
1307
+ if (swallowClick(el)) return;
1372
1308
  if (e.target.closest?.('.cv-member')) return; // the member answers for itself
1373
1309
  act();
1374
1310
  });
@@ -48,10 +48,76 @@ export function quickReplies(text) {
48
48
  return (opts.length >= 2 && opts.length <= 5 && /\?/.test(text)) ? opts : [];
49
49
  }
50
50
 
51
+ /* ============================================== every open conversation === */
52
+
53
+ /**
54
+ * One connection for every open conversation. A pane per EventSource reached the browser's six connections to a
55
+ * host at five panes beside the page's own stream, and from there a message sent and every request the page made
56
+ * waited for a pane to close (measured on Windows). The panes register here; the stream is opened again, with
57
+ * each session's last event number, whenever the set changes or the connection drops, so nothing is replayed
58
+ * twice and nothing is missed.
59
+ */
60
+ export class OwnedStream {
61
+ constructor(api, { EventSourceImpl = globalThis.EventSource, retryMs = 1500 } = {}) {
62
+ this.api = api;
63
+ this.ES = EventSourceImpl;
64
+ this.retryMs = retryMs;
65
+ this.panes = new Map(); // session id -> pane
66
+ this.seq = new Map(); // session id -> the last event number taken
67
+ this.source = null;
68
+ this.timer = null;
69
+ }
70
+
71
+ attach(pane) { this.panes.set(pane.id, pane); this.#soon(0); }
72
+
73
+ detach(pane) {
74
+ if (this.panes.get(pane.id) !== pane) return;
75
+ this.panes.delete(pane.id); this.seq.delete(pane.id); this.#soon(0);
76
+ }
77
+
78
+ /** The URL the stream is opened at now: what a reconnect resumes from. */
79
+ url() {
80
+ const ids = [...this.panes.keys()];
81
+ const after = ids.map((id) => `${id}:${this.seq.get(id) ?? 0}`).join(',');
82
+ return this.api(`/api/owned-events?ids=${encodeURIComponent(ids.join(','))}&after=${encodeURIComponent(after)}`);
83
+ }
84
+
85
+ // Panes opened together (a page restoring its tabs) cost one reconnect, not one each.
86
+ #soon(ms) { clearTimeout(this.timer); this.timer = setTimeout(() => this.#open(), ms); }
87
+
88
+ #open() {
89
+ if (this.source) { this.source.close(); this.source = null; }
90
+ if (!this.panes.size) return;
91
+ const src = new this.ES(this.url());
92
+ const data = (e) => { try { return JSON.parse(e.data); } catch { return null; } };
93
+ src.addEventListener('state', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.takeState(d.session); });
94
+ src.addEventListener('event', (e) => {
95
+ const d = data(e); const pane = d && this.panes.get(d.id);
96
+ if (!pane || !d.ev) return;
97
+ if (Number.isFinite(d.ev.seq)) {
98
+ if (d.ev.seq <= (this.seq.get(d.id) ?? 0)) return; // already taken before a reconnect
99
+ this.seq.set(d.id, d.ev.seq);
100
+ }
101
+ pane.takeEvent(d.ev);
102
+ });
103
+ src.addEventListener('gone', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.streamLost(); });
104
+ // The browser would reconnect at the same URL, from the same event numbers, and replay what was taken since.
105
+ // So the stream is closed and opened again here, from where each session is now.
106
+ src.onerror = () => {
107
+ if (this.source !== src) return;
108
+ for (const pane of this.panes.values()) pane.streamLost();
109
+ this.#soon(this.retryMs);
110
+ };
111
+ this.source = src;
112
+ }
113
+
114
+ close() { clearTimeout(this.timer); if (this.source) { this.source.close(); this.source = null; } this.panes.clear(); this.seq.clear(); }
115
+ }
116
+
51
117
  /* ========================================================== one session === */
52
118
 
53
119
  class Pane {
54
- constructor(session, { api, headers, onChange, onPermissions, hooks = {}, readOnly = false }) {
120
+ constructor(session, { api, headers, onChange, onPermissions, hub = null, hooks = {}, readOnly = false }) {
55
121
  this.api = api;
56
122
  this.headers = headers;
57
123
  this.onChange = onChange;
@@ -62,7 +128,8 @@ class Pane {
62
128
  this.readOnly = readOnly;
63
129
  this.session = session;
64
130
  this.id = session.sessionId;
65
- this.source = null;
131
+ this.hub = hub;
132
+ this.connected = false;
66
133
  this.messages = [];
67
134
  this.streaming = null;
68
135
  this.permissions = [];
@@ -141,6 +208,7 @@ class Pane {
141
208
  this.note(`History not read: ${e.message}`, 'bad');
142
209
  return;
143
210
  }
211
+ if (conv?.transcriptsHidden) { this.note('Transcripts are hidden on this link', 'muted'); return; }
144
212
  if (conv?.measured === false) { this.note(`History not measured — ${conv.reason}`, 'bad'); return; }
145
213
 
146
214
  const frag = document.createDocumentFragment();
@@ -163,25 +231,22 @@ class Pane {
163
231
  }
164
232
 
165
233
  connect() {
166
- if (this.source) return;
167
- this.source = new EventSource(this.api(`/api/owned/${encodeURIComponent(this.id)}/events`));
168
- this.source.addEventListener('state', (e) => {
169
- try { this.session = JSON.parse(e.data); } catch { return; }
170
- this.paintState();
171
- this.onChange(this);
172
- });
173
- this.source.addEventListener('event', (e) => {
174
- let ev;
175
- try { ev = JSON.parse(e.data); } catch { return; }
176
- this.absorb(ev.rec);
177
- });
178
- this.source.onerror = () => { this.streamBroken = true; this.onChange(this); };
234
+ if (this.connected || !this.hub) return;
235
+ this.connected = true;
236
+ this.hub.attach(this);
179
237
  }
180
238
 
181
239
  disconnect() {
182
- if (this.source) { this.source.close(); this.source = null; }
240
+ if (!this.connected) return;
241
+ this.connected = false;
242
+ this.hub.detach(this);
183
243
  }
184
244
 
245
+ // What the shared stream hands this pane.
246
+ takeState(session) { if (!session) return; this.session = session; this.streamBroken = false; this.paintState(); this.onChange(this); }
247
+ takeEvent(ev) { this.streamBroken = false; this.absorb(ev.rec); }
248
+ streamLost() { this.streamBroken = true; this.onChange(this); }
249
+
185
250
  /* --------------------------------------------------------- ingestion */
186
251
 
187
252
  absorb(rec) {
@@ -495,9 +560,12 @@ class Pane {
495
560
  cont.type = 'button';
496
561
  cont.addEventListener('click', () => this.hooks.onContinue?.(this, cont));
497
562
  const contRow = el('div', 'chat-ro-row');
498
- contRow.append(cont, el('span', 'sub',
499
- 'Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: '
500
- + 'the original does not see this, and messages here never reach your terminal.'));
563
+ // Said before the press, from the server's own check (the one Continue here acts on): the same session, or a copy.
564
+ // Until the server answers, and if it cannot: the sentence true of both outcomes.
565
+ const FALLBACK = 'Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: '
566
+ + 'the original does not see this, and messages here never reach your terminal.';
567
+ const contText = el('span', 'sub', FALLBACK);
568
+ contRow.append(cont, contText);
501
569
 
502
570
  const term = el('button', 'btn sm', 'Open in terminal');
503
571
  term.type = 'button';
@@ -508,13 +576,34 @@ class Pane {
508
576
  const termRow = el('div', 'chat-ro-row');
509
577
  termRow.append(term, termText);
510
578
 
511
- this.roEl.replaceChildren(lead, contRow, termRow);
579
+ // Back to the desktop app: the command, to copy and run where the app is.
580
+ const deskLine = el('code', 'chat-ro-cmd');
581
+ const deskCopy = el('button', 'btn sm', 'Copy');
582
+ deskCopy.type = 'button';
583
+ deskCopy.addEventListener('click', () => { if (deskLine.dataset.cmd) navigator.clipboard?.writeText(deskLine.dataset.cmd).then(() => { deskCopy.textContent = 'Copied'; }, () => {}); });
584
+ const deskText = el('span', 'sub');
585
+ deskText.append('Open it in the desktop app again: ', deskLine);
586
+ const deskRow = el('div', 'chat-ro-row');
587
+ deskRow.append(deskCopy, deskText);
588
+
589
+ this.roEl.replaceChildren(lead, contRow, termRow, deskRow);
512
590
 
513
- // The command is the server's, read before anything runs. One that could not be worked out is said so.
591
+ // The commands are the server's, read before anything runs. One that could not be worked out is said so.
514
592
  fetch(this.api(`/api/session/${encodeURIComponent(this.id)}/terminal`), { cache: 'no-store' })
515
593
  .then((r) => r.json())
516
- .then((r) => { line.textContent = r?.plan?.line ?? 'not measured — no working directory recorded'; })
517
- .catch((e) => { line.textContent = `not read — ${e.message}`; });
594
+ .then((r) => {
595
+ line.textContent = r?.plan?.line ?? 'not measured — no working directory or session id recorded';
596
+ // On Windows the command is a PowerShell line (no one line works in cmd and PowerShell 5.1 both): say so.
597
+ deskLine.textContent = r?.desktop?.line ?? 'not measured — no working directory or session id recorded';
598
+ if (r?.desktop?.line) deskLine.dataset.cmd = r.desktop.line; else deskCopy.disabled = true;
599
+ if (r?.desktop?.shell === 'PowerShell') deskText.append(' (in PowerShell)');
600
+ contText.textContent = r?.continueAs === 'same'
601
+ ? 'Continues this same session in Studio: its id and history stay, and the desktop app or a terminal that opens it later sees what you add here. Nothing else has it open now.'
602
+ : r?.continueAs === 'copy'
603
+ ? 'It is open somewhere else right now, so Studio starts a copy: the original does not see this, and messages here never reach your terminal or the desktop app.'
604
+ : FALLBACK;
605
+ })
606
+ .catch((e) => { line.textContent = `not read — ${e.message}`; deskLine.textContent = `not read — ${e.message}`; deskCopy.disabled = true; });
518
607
  }
519
608
 
520
609
  paintState() {
@@ -591,6 +680,7 @@ export class Chat {
591
680
  this.headers = headers;
592
681
  this.hooks = hooks;
593
682
  this.panes = new Map();
683
+ this.hub = new OwnedStream(api);
594
684
  this.activeId = null;
595
685
  this.onActivate = () => {};
596
686
  // (pane, { pending }) — a session's waiting requests changed.
@@ -654,7 +744,7 @@ export class Chat {
654
744
  }
655
745
  if (!pane) {
656
746
  pane = new Pane(session, {
657
- api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(),
747
+ api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(), hub: this.hub,
658
748
  onPermissions: (p, rec) => this.onPermissions(p, rec),
659
749
  });
660
750
  this.panesEl.append(pane.root);
@@ -52,7 +52,13 @@ export const AUTO = {
52
52
  readable: 0.75,
53
53
  // Same-type siblings fold into one card from this many, once the picture is too tall.
54
54
  stackAt: 3,
55
+ // How far past what fits a picture may run and still be drawn as cards: the rest is a pan away, at a zoom the
56
+ // cards are read at (the canvas's own fit does not go under `readable` then). A long column of agent types beside
57
+ // an empty half of the pane turned to chips at 1.0; 1.5 is a starting point, not a measured one.
58
+ overflow: 1.5,
55
59
  };
60
+ /** The height past which Auto gives cards up: what fits, and the overflow it allows on top. */
61
+ const overLimit = (h, tallest) => h > tallest * AUTO.overflow;
56
62
 
57
63
  export const GROUPINGS = ['run', 'type', 'parent', 'order', 'none'];
58
64
  export const DENSITIES = ['auto', 'comfortable', 'compact'];
@@ -270,8 +276,9 @@ export function tallestFor(paneHeight) {
270
276
  return Math.max(AUTO.tallest, fits);
271
277
  }
272
278
 
273
- /** A session's card: one line taller while it has commands running in the background, and never wider. */
274
- export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0);
279
+ /** A session's card: one line taller for commands running in the background, one for refused agent calls. */
280
+ export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0)
281
+ + (n?.refusedCalls?.length ? SIZE.session.line : 0);
275
282
 
276
283
  /* ----------------------------------------------------------------- plan --- */
277
284
 
@@ -422,12 +429,12 @@ export function plan(nodes, opts = {}) {
422
429
  let compact = asked === 'compact';
423
430
  let stacked = false;
424
431
  let box = lay(pass.shown, compact);
425
- if (asked === 'auto' && box.height > tallest) {
432
+ if (asked === 'auto' && overLimit(box.height, tallest)) {
426
433
  if (group !== 'none' && group !== 'type') {
427
434
  const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true, aside }));
428
435
  if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
429
436
  }
430
- if (box.height > tallest) { compact = true; box = lay(pass.shown, true); }
437
+ if (overLimit(box.height, tallest)) { compact = true; box = lay(pass.shown, true); }
431
438
  }
432
439
  const drawnItems = pass.shown;
433
440
  const drawn = pass.count;
@@ -523,7 +530,7 @@ function planOrder(nodes, asked, opts, tallest = AUTO.tallest) {
523
530
  };
524
531
  let compact = asked === 'compact';
525
532
  let box = lay(compact);
526
- if (asked === 'auto' && box.height > tallest) { compact = true; box = lay(true); }
533
+ if (asked === 'auto' && overLimit(box.height, tallest)) { compact = true; box = lay(true); }
527
534
  const { items } = box;
528
535
  let { height } = box;
529
536
 
@@ -586,7 +593,7 @@ export function crowdFolds(nodes, opts = {}) {
586
593
  const folds = [];
587
594
  for (let guard = 0; guard < 64; guard += 1) {
588
595
  const p = plan(nodes, { ...opts, open, pinned: new Map(), sessionY: null });
589
- if (p.height <= Math.max(AUTO.tallest, opts.tallest ?? 0)) break;
596
+ if (!overLimit(p.height, Math.max(AUTO.tallest, opts.tallest ?? 0))) break;
590
597
  const next = p.items.filter((it) => it.kind === 'group' && it.open && it.members.length > 1)
591
598
  .sort((a, b) => b.members.length - a.members.length || String(a.id).localeCompare(String(b.id)))[0];
592
599
  if (!next) break;
@@ -25,6 +25,7 @@
25
25
  <span class="bar-fill"></span>
26
26
  <div id="graph-summary" class="chips" role="group" aria-label="Session summary"></div>
27
27
  <span class="bar-sep" aria-hidden="true"></span>
28
+ <span id="role" class="badge" title="This link reads the panel and changes nothing" hidden>Read-only</span>
28
29
  <span id="pulse" class="live" role="status" data-state="connecting"><span class="dot"></span><strong>Connecting</strong><span class="live-detail"></span></span>
29
30
  <button id="theme" class="btn icon ghost" type="button" aria-label="Switch theme">
30
31
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5z"/></svg>
@@ -95,6 +96,9 @@
95
96
  <button id="view-models" class="seg-btn" type="button" role="tab" aria-label="Models view" aria-selected="false" aria-controls="models">
96
97
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 13V8M8 13V3M13 13V6"/></svg><span class="tb-words">Models</span>
97
98
  </button>
99
+ <button id="view-map" class="seg-btn" type="button" role="tab" aria-label="Map view" aria-selected="false" aria-controls="map">
100
+ <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="5" height="3.5" rx="1"/><rect x="9" y="2.5" width="5" height="3.5" rx="1"/><rect x="5.5" y="10" width="5" height="3.5" rx="1"/><path d="M4.5 6v2h7V6M8 8v2"/></svg><span class="tb-words">Map</span>
101
+ </button>
98
102
  </div>
99
103
  <button id="tb-group" data-view="graph timeline" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Group <span>Workflow run</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
100
104
  <button id="tb-density" data-view="graph" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Density <span>Auto</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
@@ -119,17 +123,17 @@
119
123
  <button id="tb-fold" data-view="graph timeline list" class="btn sm" type="button" aria-label="Fold all groups">
120
124
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg><span class="tb-words">Fold all</span>
121
125
  </button>
122
- <button id="tb-zoom-out" data-view="graph" class="btn icon sm ghost" type="button" aria-label="Zoom out">
126
+ <button id="tb-zoom-out" data-view="graph map" class="btn icon sm ghost" type="button" aria-label="Zoom out">
123
127
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10"/></svg>
124
128
  </button>
125
- <button id="tb-zoom" data-view="graph" class="tb-zoom" type="button" title="Back to 100%" aria-label="Zoom 100%. Back to 100%">100%</button>
126
- <button id="tb-zoom-in" data-view="graph" class="btn icon sm ghost" type="button" aria-label="Zoom in">
129
+ <button id="tb-zoom" data-view="graph map" class="tb-zoom" type="button" title="Back to 100%" aria-label="Zoom 100%. Back to 100%">100%</button>
130
+ <button id="tb-zoom-in" data-view="graph map" class="btn icon sm ghost" type="button" aria-label="Zoom in">
127
131
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>
128
132
  </button>
129
- <button id="tb-reset" data-view="graph" class="btn sm" type="button" title="Reset layout (r)" aria-label="Reset layout: put the cards moved by hand back where they belong" disabled>
133
+ <button id="tb-reset" data-view="graph map" class="btn sm" type="button" title="Reset layout (r)" aria-label="Reset layout: put the cards moved by hand back where they belong" disabled>
130
134
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8a5 5 0 1 0 1.6-3.7M3 3v3h3"/></svg><span class="tb-words">Reset layout</span>
131
135
  </button>
132
- <button id="tb-fit" data-view="graph" class="btn sm" type="button" aria-label="Fit the graph to the view">
136
+ <button id="tb-fit" data-view="graph map" class="btn sm" type="button" aria-label="Fit the graph to the view">
133
137
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 5.5v-3h3M10.5 2.5h3v3M13.5 10.5v3h-3M5.5 13.5h-3v-3"/></svg><span class="tb-words">Fit</span>
134
138
  </button>
135
139
  </div>
@@ -139,6 +143,7 @@
139
143
  <div id="timeline" role="tabpanel" aria-labelledby="view-timeline" hidden></div>
140
144
  <div id="list" role="tabpanel" aria-labelledby="view-list" hidden></div>
141
145
  <div id="models" role="tabpanel" aria-labelledby="view-models" hidden></div>
146
+ <div id="map" role="tabpanel" aria-labelledby="view-map" hidden></div>
142
147
  <div id="first-run" class="first-run" hidden></div>
143
148
  <div id="terminal-wait" class="terminal-wait" role="status" hidden></div>
144
149
  <div id="dock" role="region" aria-label="Waiting for approval" hidden></div>
@@ -64,3 +64,17 @@ function clock(ms) {
64
64
  const two = (n) => String(n).padStart(2, '0');
65
65
  return `${two(d.getHours())}:${two(d.getMinutes())}`;
66
66
  }
67
+
68
+ /**
69
+ * Is the server older than the files it serves? The page is read from disk on every load; the server keeps the
70
+ * code it started with. After an update the two disagree, and a view the old server has no route for answered
71
+ * 404 (seen as "Not read — HTTP 404" on the Map view). /api/health says `code.changed` when the server's files
72
+ * changed since it started; a server with no `code` at all predates the field, and is older than this page.
73
+ * Returns the sentence to show, or null.
74
+ */
75
+ export const RESTART_NOTE = 'Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.';
76
+ export function staleServerNote(health) {
77
+ if (!health || typeof health !== 'object') return null;
78
+ if (!health.code || typeof health.code !== 'object') return RESTART_NOTE;
79
+ return health.code.changed === true ? RESTART_NOTE : null;
80
+ }