crewforth 3.1.0 → 3.2.0-rc.1

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 (70) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +91 -11
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +9 -8
  7. package/kit/agents/crew-commit-agent.md +7 -2
  8. package/kit/agents/crew-docs-agent.md +71 -0
  9. package/kit/agents/crew-planner.md +2 -0
  10. package/kit/agents/crew-refuter.md +77 -0
  11. package/kit/agents/crew-security-expert.md +3 -1
  12. package/kit/agents/crew-session-manager.md +11 -9
  13. package/kit/eval/doctor.sh +91 -3
  14. package/kit/eval/golden-routing.txt +9 -0
  15. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  16. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  17. package/kit/eval/lib/crew-map/narrative.mjs +98 -0
  18. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  19. package/kit/eval/lib/crew-map/project.mjs +163 -0
  20. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  21. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  22. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  23. package/kit/eval/lib/crew-map/scan.mjs +214 -0
  24. package/kit/eval/lib/crew-map/stacks.mjs +441 -0
  25. package/kit/eval/lib/crew-map/surface.mjs +457 -0
  26. package/kit/eval/lib/findings/findings.mjs +410 -0
  27. package/kit/eval/lib/findings/findings.schema.json +122 -0
  28. package/kit/eval/lib/findings/findings.sh +29 -0
  29. package/kit/eval/lib/settings-json.awk +5 -0
  30. package/kit/eval/lib/star.sh +23 -7
  31. package/kit/eval/lib/statusline.settings.json +6 -0
  32. package/kit/eval/lib/statusline.sh +115 -0
  33. package/kit/eval/smoke-test.sh +465 -38
  34. package/kit/hooks/agent-outcome.sh +6 -3
  35. package/kit/hooks/guard-agent-model.sh +15 -5
  36. package/kit/hooks/guard-bash.sh +240 -182
  37. package/kit/hooks/guard-question.sh +73 -0
  38. package/kit/hooks/guard-write.sh +21 -0
  39. package/kit/hooks/prompt-approval.sh +320 -103
  40. package/kit/hooks/refuter-ledger.sh +80 -0
  41. package/kit/owned-blobs.tsv +4 -1
  42. package/kit/settings.json +33 -0
  43. package/kit/skills/crew-approve/SKILL.md +7 -0
  44. package/kit/skills/crew-doctor/SKILL.md +5 -1
  45. package/kit/skills/crew-handoff/SKILL.md +3 -2
  46. package/kit/skills/crew-map/SKILL.md +44 -0
  47. package/kit/skills/crew-review/SKILL.md +2 -0
  48. package/kit/skills/crew-update/SKILL.md +1 -1
  49. package/kit/skills/incident-runbook/SKILL.md +2 -2
  50. package/kit/skills/security-scan/references/verify.md +6 -3
  51. package/kit/studio/README.md +12 -0
  52. package/kit/studio/server/index.js +114 -11
  53. package/kit/studio/server/lib/crew-map.js +113 -0
  54. package/kit/studio/server/lib/model-outcomes.js +130 -15
  55. package/kit/studio/server/lib/pricing.js +3 -0
  56. package/kit/studio/server/lib/remote.js +99 -0
  57. package/kit/studio/server/lib/usage.js +74 -14
  58. package/kit/studio/web/app.js +214 -12
  59. package/kit/studio/web/canvas.js +44 -120
  60. package/kit/studio/web/graph-plan.js +10 -4
  61. package/kit/studio/web/index.html +10 -5
  62. package/kit/studio/web/map-plan.js +341 -0
  63. package/kit/studio/web/map.js +503 -0
  64. package/kit/studio/web/models-plan.js +61 -7
  65. package/kit/studio/web/models.js +19 -3
  66. package/kit/studio/web/style.css +125 -0
  67. package/kit/studio/web/usage.js +12 -0
  68. package/kit/studio/web/viewport.js +160 -0
  69. package/package.json +6 -5
  70. package/start.sh +10 -0
@@ -24,7 +24,8 @@ import { Timeline } from './timeline.js';
24
24
  import { List } from './list.js';
25
25
  import { defaultView, narrowWarning } from './list-plan.js';
26
26
  import { Models } from './models.js';
27
- import { cardOf, cardLines, workSteps } from './models-plan.js';
27
+ import { MapView } from './map.js';
28
+ import { cardOf, cardLines, workSteps, outsideRouting } from './models-plan.js';
28
29
  import { fitLevel, runsOver } from './toolbar-fit.js';
29
30
  import { roleName, shownType, modelFamily } from './names.js';
30
31
  import { shortcutOf } from './keys.js';
@@ -79,6 +80,8 @@ const el = {
79
80
  viewList: document.getElementById('view-list'),
80
81
  viewModels: document.getElementById('view-models'),
81
82
  models: document.getElementById('models'),
83
+ viewMap: document.getElementById('view-map'),
84
+ map: document.getElementById('map'),
82
85
  list: document.getElementById('list'),
83
86
  firstRun: document.getElementById('first-run'),
84
87
  stageNote: document.getElementById('stage-note'),
@@ -608,16 +611,27 @@ el.tbOptions.addEventListener('click', (e) => {
608
611
  });
609
612
  el.tbExpand.addEventListener('click', () => { (foldTarget() ?? canvas).expandAll(); paintFoldButtons(); });
610
613
  el.tbFold.addEventListener('click', () => { (foldTarget() ?? canvas).foldAll(); paintFoldButtons(); });
611
- el.tbZoomOut.addEventListener('click', () => canvas.zoomBy(1 / 1.2));
612
- el.tbZoomIn.addEventListener('click', () => canvas.zoomBy(1.2));
613
- el.tbZoom.addEventListener('click', () => canvas.zoomTo(1));
614
- el.tbFit.addEventListener('click', () => canvas.fit());
614
+ // The zoom, Fit and Reset controls are the graph's and the map's: they act on whichever of the two is open.
615
+ const zoomed = () => (view === 'map' ? mapView : canvas);
616
+ el.tbZoomOut.addEventListener('click', () => zoomed().zoomBy(1 / 1.2));
617
+ el.tbZoomIn.addEventListener('click', () => zoomed().zoomBy(1.2));
618
+ el.tbZoom.addEventListener('click', () => zoomed().zoomTo(1));
619
+ el.tbFit.addEventListener('click', () => zoomed().fit());
615
620
 
616
621
  /* Reset layout: the cards moved by hand go back where the layout puts them, and for a moment that can be undone. */
617
622
  function resetLayout() {
618
- const was = canvas.resetLayout();
623
+ const target = zoomed();
624
+ const was = target.resetLayout();
619
625
  if (!was) return;
620
- toast('Layout reset', { label: 'Undo', run: () => { if (canvas.restoreLayout(was)) toast('Layout restored'); } });
626
+ toast('Layout reset', { label: 'Undo', run: () => { if (target.restoreLayout(was)) toast('Layout restored'); } });
627
+ if (target === mapView) paintZoom(mapView.zoom, mapView.moved);
628
+ }
629
+ /** The zoom and Reset as the map has them: the graph's go through paintToolbar. */
630
+ function paintZoom(k, moved) {
631
+ const pct = `${Math.round(k * 100)}%`;
632
+ el.tbZoom.textContent = pct;
633
+ el.tbZoom.setAttribute('aria-label', `Zoom ${pct}. Back to 100%`);
634
+ if (el.tbReset) el.tbReset.disabled = !(moved > 0);
621
635
  }
622
636
  el.tbReset.addEventListener('click', resetLayout);
623
637
 
@@ -679,6 +693,36 @@ async function loadCosts(force = false) {
679
693
  const COSTS_POLL_MS = 60000;
680
694
  const MODELS_POLL_MS = 5000;
681
695
 
696
+ /* The project's crew map, as Crewforth's scanner wrote it. Read when the view opens, when the comparison changes, and every
697
+ half minute while it stays open; nothing is scanned from here. */
698
+ let mapFor = null;
699
+ let mapAt = 0;
700
+ let mapBusy = false;
701
+ let mapCompare = null;
702
+ const mapView = new MapView(el.map, {
703
+ onCopy: (text, what) => navigator.clipboard.writeText(text).then(
704
+ () => toast(what === 'path' ? `Copied ${text}` : 'Copied — paste it into a session as a task'),
705
+ () => toast(`Not copied — select it: ${text}`)),
706
+ onCompare: (cwd) => { mapCompare = cwd; loadMap(true); },
707
+ onZoom: (k) => { if (view === 'map') paintZoom(k, mapView.moved); },
708
+ });
709
+ async function loadMap(force = false) {
710
+ if (!current || mapBusy) return;
711
+ if (!force && mapFor === current && Date.now() - mapAt < MAP_POLL_MS) return;
712
+ const asked = current;
713
+ if (mapFor !== asked) mapCompare = null;
714
+ mapAt = Date.now();
715
+ mapBusy = true;
716
+ let data;
717
+ const q = `session=${encodeURIComponent(asked)}${mapCompare ? `&compare=${encodeURIComponent(mapCompare)}` : ''}`;
718
+ try { data = await getJson(`/api/map?${q}`); } catch (e) { data = { measured: false, reason: e.message, others: [] }; }
719
+ finally { mapBusy = false; }
720
+ if (asked !== current) return;
721
+ mapFor = asked;
722
+ mapView.setData(data);
723
+ }
724
+ const MAP_POLL_MS = 30000;
725
+
682
726
  const list = new List(el.list, {
683
727
  tileOf: (n) => canvas.tileFor(n),
684
728
  dimmed: (n) => canvas.dimmed(n),
@@ -713,7 +757,7 @@ function paintTimelineBar(st) {
713
757
  */
714
758
  function setView(next, select = null, remember = true) {
715
759
  const was = view;
716
- view = ['graph', 'timeline', 'list', 'models'].includes(next) ? next : 'graph';
760
+ view = ['graph', 'timeline', 'list', 'models', 'map'].includes(next) ? next : 'graph';
717
761
  // A view the window opened on by default is not a choice, and is not remembered as one.
718
762
  if (remember) store.set('crewforth-studio-view', view);
719
763
  const carried = select ?? (was === 'timeline' ? timeline.selected : was === 'list' ? list.selected : canvas.selected) ?? null;
@@ -722,6 +766,8 @@ function setView(next, select = null, remember = true) {
722
766
  el.list.hidden = view !== 'list';
723
767
  el.models.hidden = view !== 'models';
724
768
  el.viewModels.setAttribute('aria-selected', String(view === 'models'));
769
+ el.map.hidden = view !== 'map';
770
+ el.viewMap.setAttribute('aria-selected', String(view === 'map'));
725
771
  el.viewGraph.setAttribute('aria-selected', String(view === 'graph'));
726
772
  el.viewTimeline.setAttribute('aria-selected', String(view === 'timeline'));
727
773
  el.viewList.setAttribute('aria-selected', String(view === 'list'));
@@ -751,7 +797,14 @@ function setView(next, select = null, remember = true) {
751
797
  if (costsFor !== current) models.setCosts(null);
752
798
  loadModels(true);
753
799
  loadCosts(costsFor !== current);
800
+ } else if (view === 'map') {
801
+ // The map is the project's: read when the view opens, drawn with what was last read until the answer comes.
802
+ mapView.render();
803
+ paintZoom(mapView.zoom, mapView.moved);
804
+ loadMap(true);
754
805
  } else {
806
+ // Back on the graph: the zoom and Reset say the graph's again.
807
+ paintToolbar(canvas.state());
755
808
  canvas.fitIfUntouched();
756
809
  if (carried && canvas.nodes.has(carried)) canvas.focus(carried);
757
810
  }
@@ -768,13 +821,14 @@ el.viewGraph.addEventListener('click', () => setView('graph'));
768
821
  el.viewTimeline.addEventListener('click', () => setView('timeline'));
769
822
  el.viewList.addEventListener('click', () => setView('list'));
770
823
  el.viewModels.addEventListener('click', () => setView('models'));
824
+ el.viewMap.addEventListener('click', () => setView('map'));
771
825
  el.tbFollow.addEventListener('click', () => timeline.setFollow(el.tbFollow.getAttribute('aria-checked') !== 'true'));
772
826
  // "Out" is more time on screen, "in" is less: the same way round as the graph's zoom.
773
827
  el.tbRangeOut.addEventListener('click', () => timeline.zoom(1));
774
828
  el.tbRangeIn.addEventListener('click', () => timeline.zoom(-1));
775
829
  if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => timeline.render())).observe(el.timeline);
776
830
  // The canvas's pane changing size changes what fits in it, and with that what Auto density draws.
777
- if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => canvas.fitIfUntouched())).observe(el.canvasEl);
831
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => canvas.paneResized())).observe(el.canvasEl);
778
832
 
779
833
  getJson('/api/palette')
780
834
  .then((p) => {
@@ -980,11 +1034,15 @@ function paintAgentOverview(body, n, detail) {
980
1034
  if (lines.length) {
981
1035
  const dl = node('dl', 'icard');
982
1036
  for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
1037
+ // Why the gate called it critical, in its own words (`files ... is on a critical path: ...` or `change: ...`).
1038
+ if (work.riskWhy) dl.append(node('dt', null, 'why critical'), node('dd', null, work.riskWhy));
983
1039
  const sec = isection('Task card', dl);
984
1040
  if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
985
1041
  body.append(sec);
986
1042
  }
987
- // No card and no line: an agent that is not a crew agent, and nothing is said about it here.
1043
+ // Not a crew agent: no card and no line, and that is said, so the empty space is not read as a record lost.
1044
+ const outside = !row && !lines.length ? outsideRouting(n.agentType) : null;
1045
+ if (outside) body.append(isection('Model routing', node('p', 'istep-note', outside)));
988
1046
  if (row || lines.length) {
989
1047
  const ol = node('ol', 'isteps');
990
1048
  work.steps.forEach((st, i) => {
@@ -1381,6 +1439,8 @@ const ICON = {
1381
1439
  copy: 'M5.5 5.5v-2A1.5 1.5 0 0 1 7 2h5.5A1.5 1.5 0 0 1 14 3.5V9a1.5 1.5 0 0 1-1.5 1.5h-2M3.5 5.5H9A1.5 1.5 0 0 1 10.5 7v5.5A1.5 1.5 0 0 1 9 14H3.5A1.5 1.5 0 0 1 2 12.5V7a1.5 1.5 0 0 1 1.5-1.5z',
1382
1440
  chevron: 'M6 4l4 4-4 4',
1383
1441
  more: 'M3.5 8h.01M8 8h.01M12.5 8h.01',
1442
+ // The Map view's tab mark: three cards and the lines between them.
1443
+ map: 'M2 2.5h5v3.5H2zM9 2.5h5v3.5H9zM5.5 10h5v3.5h-5zM4.5 6v2h7V6M8 8v2',
1384
1444
  machine: 'M3.5 3h9A1.5 1.5 0 0 1 14 4.5v5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 9.5v-5A1.5 1.5 0 0 1 3.5 3zM6 14h4M8 11v3',
1385
1445
  };
1386
1446
 
@@ -1611,6 +1671,23 @@ function versionBadge(kit) {
1611
1671
  return tag;
1612
1672
  }
1613
1673
 
1674
+ /** The Map entry under a project: opens the project's map, through its latest session. */
1675
+ function mapRow(p, latest) {
1676
+ const row = node('div', 'srow map-row');
1677
+ row.tabIndex = 0;
1678
+ row.setAttribute('role', 'treeitem');
1679
+ row.dataset.map = p.key;
1680
+ const words = node('span', 'map-word', 'Map');
1681
+ row.append(icon(ICON.map), words, node('span', 'row-fill'));
1682
+ if (p.map?.findings) { const n = node('span', 'map-count', String(p.map.findings)); n.title = `${p.map.findings} finding(s) on the map`; row.append(n); }
1683
+ else if (!p.map) row.append(node('span', 'sub', 'no map'));
1684
+ row.title = p.map ? 'Open the project map' : 'This project has no .crewforth/crew-map.json yet';
1685
+ const go = () => { selectSession(latest.sessionId); setView('map'); };
1686
+ row.addEventListener('click', go);
1687
+ row.addEventListener('keydown', (e) => { if (e.target === row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); go(); } });
1688
+ return row;
1689
+ }
1690
+
1614
1691
  function sessionRow(project, sn) {
1615
1692
  const status = sessionStatus(sn.sessionId, fleetData);
1616
1693
  const name = sessionName(sn, labels.all());
@@ -1775,6 +1852,8 @@ function paintProjects() {
1775
1852
  group.append(head);
1776
1853
 
1777
1854
  if (open) {
1855
+ // Under the project, its map (spec §11): the orange number is how many findings it shows unasked.
1856
+ if (p.local !== false && p.exists && sessions.length) group.append(mapRow(p, sessions[0]));
1778
1857
  for (const sn of sessions) group.append(sessionRow(p, sn));
1779
1858
  if (p.total > p.sessions.length) {
1780
1859
  group.append(node('div', 'nav-line', `${p.total - p.sessions.length} older session(s) not listed`));
@@ -1960,10 +2039,121 @@ function paintMachines() {
1960
2039
  });
1961
2040
  frag.append(note);
1962
2041
  }
2042
+ if (role === 'owner') frag.append(remoteSection());
1963
2043
  el.reach.replaceChildren(frag);
1964
2044
  paintOtherMachines();
1965
2045
  }
1966
2046
 
2047
+ /* ------------------------------------------------------- remote viewers
2048
+ A viewer is a second token that reads and changes nothing: the way to watch
2049
+ this panel from another device without handing that device the panel. The
2050
+ server refuses a viewer's writes itself; what is hidden here for a viewer is
2051
+ only what would be refused anyway. */
2052
+
2053
+ let role = null; // 'owner' or 'viewer', once the server has said which
2054
+ let remote = null; // the server's list of viewers, or null before it has answered
2055
+ let handed = null; // the link of the viewer just added: the server says a token once
2056
+
2057
+ async function pollRemote() {
2058
+ if (role !== 'owner') return;
2059
+ try { remote = await getJson('/api/remote'); } catch { return; }
2060
+ paintMachines();
2061
+ }
2062
+
2063
+ async function remoteWrite(method, path, body) {
2064
+ try {
2065
+ const r = await fetch(api(path), {
2066
+ method,
2067
+ headers: { ...writeHeaders, ...(body ? { 'content-type': 'application/json' } : {}) },
2068
+ ...(body ? { body: JSON.stringify(body) } : {}),
2069
+ });
2070
+ return await r.json();
2071
+ } catch (e) {
2072
+ return { ok: false, reason: e.message };
2073
+ }
2074
+ }
2075
+
2076
+ async function addViewer(button) {
2077
+ button.disabled = true;
2078
+ const res = await remoteWrite('POST', '/api/remote', {});
2079
+ if (!res.ok) {
2080
+ toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
2081
+ button.disabled = false;
2082
+ return;
2083
+ }
2084
+ remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
2085
+ handed = { id: res.viewer.id, label: res.viewer.label, url: `${location.origin}/?token=${encodeURIComponent(res.viewer.token)}` };
2086
+ paintMachines();
2087
+ }
2088
+
2089
+ async function revokeViewer(v, button) {
2090
+ button.disabled = true;
2091
+ const res = await remoteWrite('DELETE', `/api/remote/${encodeURIComponent(v.id)}`);
2092
+ if (!res.ok) {
2093
+ toast(`Not revoked — ${res.reason ?? 'no reason given'}`);
2094
+ button.disabled = false;
2095
+ return;
2096
+ }
2097
+ remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
2098
+ if (handed?.id === v.id) handed = null;
2099
+ toast(res.revoked ? `${v.label} can no longer read this panel` : `${v.label} was already revoked`);
2100
+ paintMachines();
2101
+ }
2102
+
2103
+ function viewerRow(v) {
2104
+ const row = node('div', 'srow remote');
2105
+ const top = node('span', 'srow-top');
2106
+ const revoke = node('button', 'btn sm ghost', 'Revoke');
2107
+ revoke.type = 'button';
2108
+ revoke.setAttribute('aria-label', `Revoke ${v.label}`);
2109
+ revoke.addEventListener('click', () => revokeViewer(v, revoke));
2110
+ top.append(icon(ICON.machine), node('span', 'mname', v.label), node('span', 'row-fill'), revoke);
2111
+ row.append(top);
2112
+ const seen = v.lastSeenAt
2113
+ ? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
2114
+ : 'link not opened yet';
2115
+ row.append(node('div', 'sub srow-sub', seen));
2116
+ return row;
2117
+ }
2118
+
2119
+ function remoteSection() {
2120
+ const box = node('div', 'nav-foot rv');
2121
+ const head = node('div', 'rv-head');
2122
+ const add = node('button', 'btn sm', 'Add viewer');
2123
+ add.type = 'button';
2124
+ const list = remote?.viewers ?? [];
2125
+ if (remote?.max != null && list.length >= remote.max) {
2126
+ add.disabled = true;
2127
+ add.title = `${remote.max} viewers already; revoke one first`;
2128
+ }
2129
+ add.addEventListener('click', () => addViewer(add));
2130
+ head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
2131
+ box.append(head);
2132
+
2133
+ if (handed) {
2134
+ const give = node('div', 'rv-give');
2135
+ give.append(node('div', 'sub rv-say', `Link for ${handed.label}. Shown once; open it on the other device, through your tunnel.`));
2136
+ const url = node('code', 'rv-url', handed.url);
2137
+ const copy = node('button', 'btn sm', 'Copy link');
2138
+ copy.type = 'button';
2139
+ copy.addEventListener('click', () => copyText(handed.url, 'Link copied'));
2140
+ const done = node('button', 'btn sm ghost', 'Done');
2141
+ done.type = 'button';
2142
+ done.addEventListener('click', () => { handed = null; paintMachines(); });
2143
+ const acts = node('div', 'rv-acts');
2144
+ acts.append(copy, done);
2145
+ give.append(url, acts);
2146
+ box.append(give);
2147
+ }
2148
+
2149
+ for (const v of list) box.append(viewerRow(v));
2150
+ if (!list.length) {
2151
+ box.append(node('div', 'sub rv-say',
2152
+ 'A viewer reads this panel from another device and changes nothing. The server stays on 127.0.0.1; the link opens through a tunnel you run.'));
2153
+ }
2154
+ return box;
2155
+ }
2156
+
1967
2157
  /** The short form under the project list: the first two, and the way to the rest. */
1968
2158
  function paintOtherMachines() {
1969
2159
  const m = machines(fleetData);
@@ -2226,7 +2416,7 @@ function paintPulse() {
2226
2416
  pulse.detail.textContent = l.detail ? `· ${l.detail}` : '';
2227
2417
  }
2228
2418
  paintPulse();
2229
- setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } }, 1000);
2419
+ setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } if (view === 'map') loadMap(); }, 1000);
2230
2420
 
2231
2421
  /* -------------------------------------------------------------- home, keys */
2232
2422
 
@@ -2254,6 +2444,7 @@ document.addEventListener('keydown', (e) => {
2254
2444
  if (shell.classList.contains('side-open')) { setDrawer(false); return; }
2255
2445
  if (newPanel.isOpen) { newPanel.close(); return; }
2256
2446
  if (view === 'timeline' && timeline.selected) { timeline.select(null); return; }
2447
+ if (view === 'map' && mapView.escape()) return;
2257
2448
  // Close the inspector and let go of the selection.
2258
2449
  if (!el.inspector.hidden) { canvas.clearSelection(); showInspector(null); }
2259
2450
  return;
@@ -2277,9 +2468,11 @@ document.addEventListener('keydown', (e) => {
2277
2468
  } else if (key === 'l') {
2278
2469
  setView('list');
2279
2470
  } else if (key === 'r') {
2280
- if (view === 'graph') resetLayout();
2471
+ if (view === 'graph' || view === 'map') resetLayout();
2281
2472
  } else if (key === 'm') {
2282
2473
  setView('models');
2474
+ } else if (key === 'p') {
2475
+ setView('map');
2283
2476
  } else if (key === 'j') {
2284
2477
  stepAttention(1);
2285
2478
  } else if (key === 'k') {
@@ -2619,7 +2812,16 @@ applyPhoneBand(true);
2619
2812
  setView(defaultView(stored, phoneBand.matches), null, Boolean(stored));
2620
2813
  }
2621
2814
  pollFleet(); pollSessions(); pollOwned();
2815
+ // Who this page is to the server. A viewer's page says so and offers no way to start a session; the server
2816
+ // would refuse it either way.
2817
+ getJson('/api/health').then((h) => {
2818
+ role = h.role === 'viewer' ? 'viewer' : 'owner';
2819
+ document.body.dataset.role = role;
2820
+ document.getElementById('role').hidden = role !== 'viewer';
2821
+ if (role === 'owner') pollRemote(); else paintMachines();
2822
+ }).catch(() => { /* the Live indicator says the server is unreachable */ });
2622
2823
  setInterval(() => { nextPollAt = Date.now() + FLEET_POLL_MS; pollFleet(); }, FLEET_POLL_MS);
2824
+ setInterval(pollRemote, FLEET_POLL_MS);
2623
2825
  nextPollAt = Date.now() + FLEET_POLL_MS;
2624
2826
  setInterval(pollOwned, FLEET_POLL_MS);
2625
2827
  setInterval(pollSessions, SESSION_POLL_MS);
@@ -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. */
@@ -983,7 +936,7 @@ export class Canvas {
983
936
  // Beside the bar: how the members went — or, at compact density where the card has no line for it, the
984
937
  // models they ran on, with how they went left to the bar and its tooltip.
985
938
  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` : ''; }
939
+ 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
940
  if (!it.boxed) this.#fillTotals(p, it);
988
941
  el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
989
942
  el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
@@ -1302,13 +1255,8 @@ export class Canvas {
1302
1255
  const cell = item ? null : host.cells?.find((c) => c.id === id);
1303
1256
  const cx = host.x + (cell ? cell.x + cell.w / 2 : host.w / 2);
1304
1257
  const cy = host.y + (cell ? cell.y + cell.h / 2 : host.h / 2);
1305
- const r = this.root.getBoundingClientRect();
1306
1258
  // 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();
1259
+ this.vp.centerOn(cx, cy, 1);
1312
1260
  }
1313
1261
  this.#persist();
1314
1262
  this.onSelect(this.nodes.get(id) ?? null);
@@ -1327,38 +1275,14 @@ export class Canvas {
1327
1275
  /* ------------------------------------------------------ interaction */
1328
1276
 
1329
1277
  #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);
1278
+ draggable(el, {
1279
+ scale: () => this.view.k,
1280
+ at: () => this.pos.get(id),
1281
+ move: (x, y) => { this.pinned.set(id, { x, y }); this.#redraw(); },
1282
+ end: () => this.#persist(),
1283
+ ignore: (t) => Boolean(t.closest?.('.cv-fold')),
1339
1284
  });
1340
1285
 
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
1286
  const act = () => {
1363
1287
  const it = this.lastPlan?.items.find((x) => x.id === id);
1364
1288
  if (!it) return;
@@ -1368,7 +1292,7 @@ export class Canvas {
1368
1292
  this.#select(id);
1369
1293
  };
1370
1294
  el.addEventListener('click', (e) => {
1371
- if (el.dataset.suppressClick === '1') { el.dataset.suppressClick = '0'; return; }
1295
+ if (swallowClick(el)) return;
1372
1296
  if (e.target.closest?.('.cv-member')) return; // the member answers for itself
1373
1297
  act();
1374
1298
  });