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
@@ -14,7 +14,7 @@ import {
14
14
  summaryChips, sessionStatus, sessionName, sessionSub, ago, matchesSession, highlight, badgeFor,
15
15
  liveSessions, machines, seenAgo, isFirstRun,
16
16
  } from './nav.js';
17
- import { liveness, offlineNote } from './liveness.js';
17
+ import { liveness, offlineNote, staleServerNote } from './liveness.js';
18
18
  import { attention, settle, AUTO } from './graph-plan.js';
19
19
  import { summaryRows, usageLine, costNote } from './usage.js';
20
20
  import { ServerClock, queue, settled, terminalWait, OUTCOME_WORD } from './approvals.js';
@@ -24,10 +24,12 @@ 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';
32
+ import { qrSvg } from './qr.js';
31
33
  import { SESSION_MODES } from './convo.js';
32
34
  import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
33
35
 
@@ -79,6 +81,8 @@ const el = {
79
81
  viewList: document.getElementById('view-list'),
80
82
  viewModels: document.getElementById('view-models'),
81
83
  models: document.getElementById('models'),
84
+ viewMap: document.getElementById('view-map'),
85
+ map: document.getElementById('map'),
82
86
  list: document.getElementById('list'),
83
87
  firstRun: document.getElementById('first-run'),
84
88
  stageNote: document.getElementById('stage-note'),
@@ -110,12 +114,37 @@ const getJson = async (p) => {
110
114
  heard.failed = true;
111
115
  throw e;
112
116
  }
113
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
117
+ if (!r.ok) throw await statusError(r);
114
118
  const body = await r.json();
115
119
  heardNow();
116
120
  return body;
117
121
  };
118
122
 
123
+ // What the server said about this page's link: a viewer whose link hides transcripts is sent no transcript text.
124
+ let transcriptsHidden = false;
125
+
126
+ /** The error for an answer with an error status. A viewer link whose eight hours are up covers the page, once. */
127
+ async function statusError(r) {
128
+ if (r.status === 401) {
129
+ let b = null; try { b = await r.json(); } catch { /* not JSON */ }
130
+ if (b?.expired) showExpired();
131
+ }
132
+ if (r.status === 403 && transcriptsHidden) return new Error('Transcripts are hidden on this link');
133
+ return new Error(`HTTP ${r.status}`);
134
+ }
135
+
136
+ function showExpired() {
137
+ if (document.getElementById('expired')) return;
138
+ const box = document.createElement('div');
139
+ box.id = 'expired';
140
+ box.className = 'expired';
141
+ box.setAttribute('role', 'alert');
142
+ const h = document.createElement('h1'); h.textContent = 'Session expired';
143
+ const p = document.createElement('p'); p.textContent = 'Viewer links last eight hours. Ask for a new one on the machine running Studio.';
144
+ box.append(h, p);
145
+ document.body.append(box);
146
+ }
147
+
119
148
  /* ---------------------------------------------------------------- theme */
120
149
 
121
150
  try { migrateStorage(localStorage); } catch { /* blocked storage */ }
@@ -608,16 +637,27 @@ el.tbOptions.addEventListener('click', (e) => {
608
637
  });
609
638
  el.tbExpand.addEventListener('click', () => { (foldTarget() ?? canvas).expandAll(); paintFoldButtons(); });
610
639
  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());
640
+ // The zoom, Fit and Reset controls are the graph's and the map's: they act on whichever of the two is open.
641
+ const zoomed = () => (view === 'map' ? mapView : canvas);
642
+ el.tbZoomOut.addEventListener('click', () => zoomed().zoomBy(1 / 1.2));
643
+ el.tbZoomIn.addEventListener('click', () => zoomed().zoomBy(1.2));
644
+ el.tbZoom.addEventListener('click', () => zoomed().zoomTo(1));
645
+ el.tbFit.addEventListener('click', () => zoomed().fit());
615
646
 
616
647
  /* Reset layout: the cards moved by hand go back where the layout puts them, and for a moment that can be undone. */
617
648
  function resetLayout() {
618
- const was = canvas.resetLayout();
649
+ const target = zoomed();
650
+ const was = target.resetLayout();
619
651
  if (!was) return;
620
- toast('Layout reset', { label: 'Undo', run: () => { if (canvas.restoreLayout(was)) toast('Layout restored'); } });
652
+ toast('Layout reset', { label: 'Undo', run: () => { if (target.restoreLayout(was)) toast('Layout restored'); } });
653
+ if (target === mapView) paintZoom(mapView.zoom, mapView.moved);
654
+ }
655
+ /** The zoom and Reset as the map has them: the graph's go through paintToolbar. */
656
+ function paintZoom(k, moved) {
657
+ const pct = `${Math.round(k * 100)}%`;
658
+ el.tbZoom.textContent = pct;
659
+ el.tbZoom.setAttribute('aria-label', `Zoom ${pct}. Back to 100%`);
660
+ if (el.tbReset) el.tbReset.disabled = !(moved > 0);
621
661
  }
622
662
  el.tbReset.addEventListener('click', resetLayout);
623
663
 
@@ -679,6 +719,37 @@ async function loadCosts(force = false) {
679
719
  const COSTS_POLL_MS = 60000;
680
720
  const MODELS_POLL_MS = 5000;
681
721
 
722
+ /* The project's crew map, as Crewforth's scanner wrote it. Read when the view opens, when the comparison changes, and every
723
+ half minute while it stays open; nothing is scanned from here. */
724
+ let mapFor = null;
725
+ let mapAt = 0;
726
+ let mapBusy = false;
727
+ let mapCompare = null;
728
+ const mapView = new MapView(el.map, {
729
+ onCopy: (text, what) => navigator.clipboard.writeText(text).then(
730
+ () => toast(what === 'path' ? `Copied ${text}` : 'Copied — paste it into a session as a task'),
731
+ () => toast(`Not copied — select it: ${text}`)),
732
+ onCompare: (cwd) => { mapCompare = cwd; loadMap(true); },
733
+ onZoom: (k) => { if (view === 'map') paintZoom(k, mapView.moved); },
734
+ });
735
+ async function loadMap(force = false) {
736
+ if (!current || mapBusy) return;
737
+ if (!force && mapFor === current && Date.now() - mapAt < MAP_POLL_MS) return;
738
+ const asked = current;
739
+ if (mapFor !== asked) mapCompare = null;
740
+ mapAt = Date.now();
741
+ mapBusy = true;
742
+ let data;
743
+ const q = `session=${encodeURIComponent(asked)}${mapCompare ? `&compare=${encodeURIComponent(mapCompare)}` : ''}`;
744
+ // A server older than this page has no Map route: say that, not its 404.
745
+ try { data = await getJson(`/api/map?${q}`); } catch (e) { data = { measured: false, reason: restartNote ?? e.message, others: [] }; }
746
+ finally { mapBusy = false; }
747
+ if (asked !== current) return;
748
+ mapFor = asked;
749
+ mapView.setData(data);
750
+ }
751
+ const MAP_POLL_MS = 30000;
752
+
682
753
  const list = new List(el.list, {
683
754
  tileOf: (n) => canvas.tileFor(n),
684
755
  dimmed: (n) => canvas.dimmed(n),
@@ -713,7 +784,7 @@ function paintTimelineBar(st) {
713
784
  */
714
785
  function setView(next, select = null, remember = true) {
715
786
  const was = view;
716
- view = ['graph', 'timeline', 'list', 'models'].includes(next) ? next : 'graph';
787
+ view = ['graph', 'timeline', 'list', 'models', 'map'].includes(next) ? next : 'graph';
717
788
  // A view the window opened on by default is not a choice, and is not remembered as one.
718
789
  if (remember) store.set('crewforth-studio-view', view);
719
790
  const carried = select ?? (was === 'timeline' ? timeline.selected : was === 'list' ? list.selected : canvas.selected) ?? null;
@@ -722,6 +793,8 @@ function setView(next, select = null, remember = true) {
722
793
  el.list.hidden = view !== 'list';
723
794
  el.models.hidden = view !== 'models';
724
795
  el.viewModels.setAttribute('aria-selected', String(view === 'models'));
796
+ el.map.hidden = view !== 'map';
797
+ el.viewMap.setAttribute('aria-selected', String(view === 'map'));
725
798
  el.viewGraph.setAttribute('aria-selected', String(view === 'graph'));
726
799
  el.viewTimeline.setAttribute('aria-selected', String(view === 'timeline'));
727
800
  el.viewList.setAttribute('aria-selected', String(view === 'list'));
@@ -751,7 +824,14 @@ function setView(next, select = null, remember = true) {
751
824
  if (costsFor !== current) models.setCosts(null);
752
825
  loadModels(true);
753
826
  loadCosts(costsFor !== current);
827
+ } else if (view === 'map') {
828
+ // The map is the project's: read when the view opens, drawn with what was last read until the answer comes.
829
+ mapView.render();
830
+ paintZoom(mapView.zoom, mapView.moved);
831
+ loadMap(true);
754
832
  } else {
833
+ // Back on the graph: the zoom and Reset say the graph's again.
834
+ paintToolbar(canvas.state());
755
835
  canvas.fitIfUntouched();
756
836
  if (carried && canvas.nodes.has(carried)) canvas.focus(carried);
757
837
  }
@@ -768,13 +848,14 @@ el.viewGraph.addEventListener('click', () => setView('graph'));
768
848
  el.viewTimeline.addEventListener('click', () => setView('timeline'));
769
849
  el.viewList.addEventListener('click', () => setView('list'));
770
850
  el.viewModels.addEventListener('click', () => setView('models'));
851
+ el.viewMap.addEventListener('click', () => setView('map'));
771
852
  el.tbFollow.addEventListener('click', () => timeline.setFollow(el.tbFollow.getAttribute('aria-checked') !== 'true'));
772
853
  // "Out" is more time on screen, "in" is less: the same way round as the graph's zoom.
773
854
  el.tbRangeOut.addEventListener('click', () => timeline.zoom(1));
774
855
  el.tbRangeIn.addEventListener('click', () => timeline.zoom(-1));
775
856
  if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => timeline.render())).observe(el.timeline);
776
857
  // 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);
858
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => canvas.paneResized())).observe(el.canvasEl);
778
859
 
779
860
  getJson('/api/palette')
780
861
  .then((p) => {
@@ -980,11 +1061,15 @@ function paintAgentOverview(body, n, detail) {
980
1061
  if (lines.length) {
981
1062
  const dl = node('dl', 'icard');
982
1063
  for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
1064
+ // Why the gate called it critical, in its own words (`files ... is on a critical path: ...` or `change: ...`).
1065
+ if (work.riskWhy) dl.append(node('dt', null, 'why critical'), node('dd', null, work.riskWhy));
983
1066
  const sec = isection('Task card', dl);
984
1067
  if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
985
1068
  body.append(sec);
986
1069
  }
987
- // No card and no line: an agent that is not a crew agent, and nothing is said about it here.
1070
+ // Not a crew agent: no card and no line, and that is said, so the empty space is not read as a record lost.
1071
+ const outside = !row && !lines.length ? outsideRouting(n.agentType) : null;
1072
+ if (outside) body.append(isection('Model routing', node('p', 'istep-note', outside)));
988
1073
  if (row || lines.length) {
989
1074
  const ol = node('ol', 'isteps');
990
1075
  work.steps.forEach((st, i) => {
@@ -1381,6 +1466,8 @@ const ICON = {
1381
1466
  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
1467
  chevron: 'M6 4l4 4-4 4',
1383
1468
  more: 'M3.5 8h.01M8 8h.01M12.5 8h.01',
1469
+ // The Map view's tab mark: three cards and the lines between them.
1470
+ map: 'M2 2.5h5v3.5H2zM9 2.5h5v3.5H9zM5.5 10h5v3.5h-5zM4.5 6v2h7V6M8 8v2',
1384
1471
  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
1472
  };
1386
1473
 
@@ -1611,6 +1698,23 @@ function versionBadge(kit) {
1611
1698
  return tag;
1612
1699
  }
1613
1700
 
1701
+ /** The Map entry under a project: opens the project's map, through its latest session. */
1702
+ function mapRow(p, latest) {
1703
+ const row = node('div', 'srow map-row');
1704
+ row.tabIndex = 0;
1705
+ row.setAttribute('role', 'treeitem');
1706
+ row.dataset.map = p.key;
1707
+ const words = node('span', 'map-word', 'Map');
1708
+ row.append(icon(ICON.map), words, node('span', 'row-fill'));
1709
+ 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); }
1710
+ else if (!p.map) row.append(node('span', 'sub', 'no map'));
1711
+ row.title = p.map ? 'Open the project map' : 'This project has no .crewforth/crew-map.json yet';
1712
+ const go = () => { selectSession(latest.sessionId); setView('map'); };
1713
+ row.addEventListener('click', go);
1714
+ row.addEventListener('keydown', (e) => { if (e.target === row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); go(); } });
1715
+ return row;
1716
+ }
1717
+
1614
1718
  function sessionRow(project, sn) {
1615
1719
  const status = sessionStatus(sn.sessionId, fleetData);
1616
1720
  const name = sessionName(sn, labels.all());
@@ -1775,6 +1879,8 @@ function paintProjects() {
1775
1879
  group.append(head);
1776
1880
 
1777
1881
  if (open) {
1882
+ // Under the project, its map (spec §11): the orange number is how many findings it shows unasked.
1883
+ if (p.local !== false && p.exists && sessions.length) group.append(mapRow(p, sessions[0]));
1778
1884
  for (const sn of sessions) group.append(sessionRow(p, sn));
1779
1885
  if (p.total > p.sessions.length) {
1780
1886
  group.append(node('div', 'nav-line', `${p.total - p.sessions.length} older session(s) not listed`));
@@ -1960,10 +2066,149 @@ function paintMachines() {
1960
2066
  });
1961
2067
  frag.append(note);
1962
2068
  }
2069
+ if (role === 'owner') frag.append(remoteSection());
1963
2070
  el.reach.replaceChildren(frag);
1964
2071
  paintOtherMachines();
1965
2072
  }
1966
2073
 
2074
+ /* ------------------------------------------------------- remote viewers
2075
+ A viewer is a second token that reads and changes nothing: the way to watch
2076
+ this panel from another device without handing that device the panel. The
2077
+ server refuses a viewer's writes itself; what is hidden here for a viewer is
2078
+ only what would be refused anyway. */
2079
+
2080
+ let role = null; // 'owner' or 'viewer', once the server has said which
2081
+ let remote = null; // the server's list of viewers, or null before it has answered
2082
+ let handed = null; // the link of the viewer just added: the server says a token once
2083
+
2084
+ async function pollRemote() {
2085
+ if (role !== 'owner') return;
2086
+ try { remote = await getJson('/api/remote'); } catch { return; }
2087
+ // The link shown once leaves with its viewer: revoked from elsewhere (the API, another tab) or past its eight
2088
+ // hours, it no longer opens anything, and a QR code left on screen would say it does.
2089
+ if (handed && !(remote.viewers ?? []).some((v) => v.id === handed.id)) handed = null;
2090
+ paintMachines();
2091
+ }
2092
+
2093
+ async function remoteWrite(method, path, body) {
2094
+ try {
2095
+ const r = await fetch(api(path), {
2096
+ method,
2097
+ headers: { ...writeHeaders, ...(body ? { 'content-type': 'application/json' } : {}) },
2098
+ ...(body ? { body: JSON.stringify(body) } : {}),
2099
+ });
2100
+ return await r.json();
2101
+ } catch (e) {
2102
+ return { ok: false, reason: e.message };
2103
+ }
2104
+ }
2105
+
2106
+ /** A time today or tomorrow as the clock on the wall says it, e.g. 23:40. */
2107
+ const clockTime = (ms) => (Number.isFinite(ms) ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '—');
2108
+
2109
+ let showTranscripts = false; // the owner's choice for the next viewer: transcripts stay hidden unless ticked
2110
+
2111
+ async function addViewer(button) {
2112
+ button.disabled = true;
2113
+ const res = await remoteWrite('POST', '/api/remote', { transcripts: showTranscripts });
2114
+ if (!res.ok) {
2115
+ toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
2116
+ button.disabled = false;
2117
+ return;
2118
+ }
2119
+ remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
2120
+ // The Wi-Fi link when the server listens on one; this machine's own address only when it found no Wi-Fi address.
2121
+ const wifi = res.lanUrls?.[0] ?? null;
2122
+ handed = { id: res.viewer.id, label: res.viewer.label, wifi, expiresAt: res.viewer.expiresAt, transcripts: res.viewer.transcripts,
2123
+ url: wifi ?? `${location.origin}/?token=${encodeURIComponent(res.viewer.token)}`, lanErrors: res.lan?.errors ?? [] };
2124
+ showTranscripts = false;
2125
+ paintMachines();
2126
+ }
2127
+
2128
+ async function revokeViewer(v, button) {
2129
+ button.disabled = true;
2130
+ const res = await remoteWrite('DELETE', `/api/remote/${encodeURIComponent(v.id)}`);
2131
+ if (!res.ok) {
2132
+ toast(`Not revoked — ${res.reason ?? 'no reason given'}`);
2133
+ button.disabled = false;
2134
+ return;
2135
+ }
2136
+ remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
2137
+ if (handed?.id === v.id) handed = null;
2138
+ toast(res.revoked ? `${v.label} can no longer read this panel` : `${v.label} was already revoked`);
2139
+ paintMachines();
2140
+ }
2141
+
2142
+ function viewerRow(v) {
2143
+ const row = node('div', 'srow remote');
2144
+ const top = node('span', 'srow-top');
2145
+ const revoke = node('button', 'btn sm ghost', 'Revoke');
2146
+ revoke.type = 'button';
2147
+ revoke.setAttribute('aria-label', `Revoke ${v.label}`);
2148
+ revoke.addEventListener('click', () => revokeViewer(v, revoke));
2149
+ top.append(icon(ICON.machine), node('span', 'mname', v.label), node('span', 'row-fill'), revoke);
2150
+ row.append(top);
2151
+ const seen = v.lastSeenAt
2152
+ ? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
2153
+ : 'link not opened yet';
2154
+ row.append(node('div', 'sub srow-sub', seen));
2155
+ row.append(node('div', 'sub srow-sub', `until ${clockTime(v.expiresAt)} · transcripts ${v.transcripts ? 'shown' : 'hidden'}${v.addresses?.length ? ` · from ${v.addresses.join(', ')}` : ''}`));
2156
+ return row;
2157
+ }
2158
+
2159
+ function remoteSection() {
2160
+ const box = node('div', 'nav-foot rv');
2161
+ const head = node('div', 'rv-head');
2162
+ const add = node('button', 'btn sm', 'Add viewer');
2163
+ add.type = 'button';
2164
+ const list = remote?.viewers ?? [];
2165
+ if (remote?.max != null && list.length >= remote.max) {
2166
+ add.disabled = true;
2167
+ add.title = `${remote.max} viewers already; revoke one first`;
2168
+ }
2169
+ add.addEventListener('click', () => addViewer(add));
2170
+ head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
2171
+ box.append(head);
2172
+
2173
+ // Transcripts stay hidden on a new link unless this is ticked before Add viewer.
2174
+ const opt = node('label', 'rv-opt');
2175
+ const tick = document.createElement('input');
2176
+ tick.type = 'checkbox'; tick.checked = showTranscripts;
2177
+ tick.addEventListener('change', () => { showTranscripts = tick.checked; });
2178
+ opt.append(tick, document.createTextNode(' Show transcripts on the next link'));
2179
+ box.append(opt);
2180
+
2181
+ if (handed) {
2182
+ const give = node('div', 'rv-give');
2183
+ give.append(node('div', 'sub rv-say', handed.wifi
2184
+ ? `Link for ${handed.label}, shown once. Scan it with a phone on the same Wi-Fi. It lasts until ${clockTime(handed.expiresAt)}; transcripts ${handed.transcripts ? 'shown' : 'hidden'}.`
2185
+ : `Link for ${handed.label}, shown once. No Wi-Fi address was found${handed.lanErrors.length ? ` (${handed.lanErrors.join('; ')})` : ''}, so it opens on this machine only.`));
2186
+ if (handed.wifi) {
2187
+ const code = node('div', 'rv-qr');
2188
+ code.innerHTML = qrSvg(handed.wifi, { px: 3 });
2189
+ give.append(code);
2190
+ }
2191
+ const url = node('code', 'rv-url', handed.url);
2192
+ const copy = node('button', 'btn sm', 'Copy link');
2193
+ copy.type = 'button';
2194
+ copy.addEventListener('click', () => copyText(handed.url, 'Link copied'));
2195
+ const done = node('button', 'btn sm ghost', 'Done');
2196
+ done.type = 'button';
2197
+ done.addEventListener('click', () => { handed = null; paintMachines(); });
2198
+ const acts = node('div', 'rv-acts');
2199
+ acts.append(copy, done);
2200
+ give.append(url, acts);
2201
+ box.append(give);
2202
+ }
2203
+
2204
+ for (const v of list) box.append(viewerRow(v));
2205
+ if (!list.length) {
2206
+ box.append(node('div', 'sub rv-say',
2207
+ 'A viewer watches this panel from a phone on the same Wi-Fi and changes nothing. Its link lasts eight hours. With no viewer, the server listens on 127.0.0.1 only.'));
2208
+ }
2209
+ return box;
2210
+ }
2211
+
1967
2212
  /** The short form under the project list: the first two, and the way to the rest. */
1968
2213
  function paintOtherMachines() {
1969
2214
  const m = machines(fleetData);
@@ -2226,7 +2471,7 @@ function paintPulse() {
2226
2471
  pulse.detail.textContent = l.detail ? `· ${l.detail}` : '';
2227
2472
  }
2228
2473
  paintPulse();
2229
- setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } }, 1000);
2474
+ setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } if (view === 'map') loadMap(); }, 1000);
2230
2475
 
2231
2476
  /* -------------------------------------------------------------- home, keys */
2232
2477
 
@@ -2254,6 +2499,7 @@ document.addEventListener('keydown', (e) => {
2254
2499
  if (shell.classList.contains('side-open')) { setDrawer(false); return; }
2255
2500
  if (newPanel.isOpen) { newPanel.close(); return; }
2256
2501
  if (view === 'timeline' && timeline.selected) { timeline.select(null); return; }
2502
+ if (view === 'map' && mapView.escape()) return;
2257
2503
  // Close the inspector and let go of the selection.
2258
2504
  if (!el.inspector.hidden) { canvas.clearSelection(); showInspector(null); }
2259
2505
  return;
@@ -2277,9 +2523,11 @@ document.addEventListener('keydown', (e) => {
2277
2523
  } else if (key === 'l') {
2278
2524
  setView('list');
2279
2525
  } else if (key === 'r') {
2280
- if (view === 'graph') resetLayout();
2526
+ if (view === 'graph' || view === 'map') resetLayout();
2281
2527
  } else if (key === 'm') {
2282
2528
  setView('models');
2529
+ } else if (key === 'p') {
2530
+ setView('map');
2283
2531
  } else if (key === 'j') {
2284
2532
  stepAttention(1);
2285
2533
  } else if (key === 'k') {
@@ -2442,7 +2690,9 @@ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnima
2442
2690
  // They survive a page reload as long as the server does.
2443
2691
  let ownedBusy = false;
2444
2692
  async function pollOwned() {
2445
- if (ownedBusy) return;
2693
+ // A link that hides transcripts is refused the sessions the panel runs (their conversation is all transcript):
2694
+ // asking every few seconds would only fill the machine's log with the same refusal.
2695
+ if (ownedBusy || transcriptsHidden) return;
2446
2696
  ownedBusy = true;
2447
2697
  try {
2448
2698
  const r = await getJson('/api/owned');
@@ -2465,6 +2715,7 @@ chat.onPermissions = () => pollOwned();
2465
2715
  view is being shown in a window too narrow for it. Offline is said first. */
2466
2716
 
2467
2717
  let nextPollAt = null;
2718
+ let restartNote = null; // staleServerNote(/api/health): the server runs older code than the page
2468
2719
  const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
2469
2720
  stageNote.button.type = 'button';
2470
2721
  stageNote.button.addEventListener('click', () => retryNow());
@@ -2473,11 +2724,12 @@ el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), s
2473
2724
  function paintStageNote() {
2474
2725
  const l = liveness(Date.now(), heard.okAt, heard.failed);
2475
2726
  const off = offlineNote(l.state, heard.okAt, nextPollAt, Date.now());
2476
- const warn = narrowWarning(view, phoneBand.matches);
2727
+ // Offline first, then a server older than this page, then a window too narrow for the view.
2728
+ const warn = restartNote ?? narrowWarning(view, phoneBand.matches);
2477
2729
  // The last picture stays on screen while the server is away, and is drawn as old.
2478
2730
  el.stage.dataset.offline = String(Boolean(off));
2479
2731
  el.stageNote.hidden = !off && !warn;
2480
- el.stageNote.dataset.kind = off ? 'offline' : 'narrow';
2732
+ el.stageNote.dataset.kind = off ? 'offline' : restartNote ? 'restart' : 'narrow';
2481
2733
  stageNote.text.textContent = off ? off.text : (warn ?? '');
2482
2734
  stageNote.retry.textContent = off ? off.retry : '';
2483
2735
  stageNote.retry.hidden = !off;
@@ -2619,7 +2871,25 @@ applyPhoneBand(true);
2619
2871
  setView(defaultView(stored, phoneBand.matches), null, Boolean(stored));
2620
2872
  }
2621
2873
  pollFleet(); pollSessions(); pollOwned();
2874
+ // Who this page is to the server. A viewer's page says so and offers no way to start a session; the server
2875
+ // would refuse it either way.
2876
+ getJson('/api/health').then((h) => {
2877
+ restartNote = staleServerNote(h);
2878
+ // A Map read before this answer showed the old server's 404; read it again so it says why.
2879
+ if (restartNote) { paintStageNote(); if (view === 'map') loadMap(true); }
2880
+ role = h.role === 'viewer' ? 'viewer' : 'owner';
2881
+ transcriptsHidden = role === 'viewer' && h.transcripts === false;
2882
+ document.body.dataset.role = role;
2883
+ const badge = document.getElementById('role');
2884
+ badge.hidden = role !== 'viewer';
2885
+ if (transcriptsHidden) {
2886
+ badge.textContent = 'Read-only · transcripts hidden';
2887
+ badge.title = 'This link reads the panel and changes nothing; no transcript text is sent to it';
2888
+ }
2889
+ if (role === 'owner') pollRemote(); else paintMachines();
2890
+ }).catch(() => { /* the Live indicator says the server is unreachable */ });
2622
2891
  setInterval(() => { nextPollAt = Date.now() + FLEET_POLL_MS; pollFleet(); }, FLEET_POLL_MS);
2892
+ setInterval(pollRemote, FLEET_POLL_MS);
2623
2893
  nextPollAt = Date.now() + FLEET_POLL_MS;
2624
2894
  setInterval(pollOwned, FLEET_POLL_MS);
2625
2895
  setInterval(pollSessions, SESSION_POLL_MS);