crewforth 3.1.1 → 3.2.0-rc.2

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 (78) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +56 -5
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +14 -13
  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 +117 -6
  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-default.sh +36 -0
  30. package/kit/eval/lib/settings-json.awk +15 -4
  31. package/kit/eval/lib/star.sh +23 -7
  32. package/kit/eval/lib/statusline.settings.json +6 -0
  33. package/kit/eval/lib/statusline.sh +100 -0
  34. package/kit/eval/lib/version.sh +41 -0
  35. package/kit/eval/smoke-test.sh +576 -41
  36. package/kit/hooks/agent-outcome.sh +26 -12
  37. package/kit/hooks/guard-agent-model.sh +109 -9
  38. package/kit/hooks/guard-bash.sh +240 -182
  39. package/kit/hooks/guard-question.sh +73 -0
  40. package/kit/hooks/guard-write.sh +21 -0
  41. package/kit/hooks/prompt-approval.sh +320 -103
  42. package/kit/hooks/refuter-ledger.sh +80 -0
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/owned-blobs.tsv +4 -4
  45. package/kit/settings.json +33 -0
  46. package/kit/skills/crew-approve/SKILL.md +7 -0
  47. package/kit/skills/crew-doctor/SKILL.md +5 -1
  48. package/kit/skills/crew-handoff/SKILL.md +3 -2
  49. package/kit/skills/crew-map/SKILL.md +44 -0
  50. package/kit/skills/crew-review/SKILL.md +2 -0
  51. package/kit/skills/crew-update/SKILL.md +1 -1
  52. package/kit/skills/incident-runbook/SKILL.md +2 -2
  53. package/kit/skills/security-scan/references/verify.md +6 -3
  54. package/kit/studio/README.md +12 -0
  55. package/kit/studio/server/index.js +125 -11
  56. package/kit/studio/server/lib/code-stamp.js +35 -0
  57. package/kit/studio/server/lib/crew-map.js +113 -0
  58. package/kit/studio/server/lib/model-outcomes.js +130 -15
  59. package/kit/studio/server/lib/owned-stream.js +56 -0
  60. package/kit/studio/server/lib/pricing.js +3 -0
  61. package/kit/studio/server/lib/projects.js +11 -4
  62. package/kit/studio/server/lib/remote.js +99 -0
  63. package/kit/studio/server/lib/usage.js +74 -14
  64. package/kit/studio/web/app.js +223 -15
  65. package/kit/studio/web/canvas.js +44 -120
  66. package/kit/studio/web/chat.js +82 -17
  67. package/kit/studio/web/graph-plan.js +10 -4
  68. package/kit/studio/web/index.html +10 -5
  69. package/kit/studio/web/liveness.js +14 -0
  70. package/kit/studio/web/map-plan.js +341 -0
  71. package/kit/studio/web/map.js +503 -0
  72. package/kit/studio/web/models-plan.js +61 -7
  73. package/kit/studio/web/models.js +19 -3
  74. package/kit/studio/web/style.css +131 -0
  75. package/kit/studio/web/usage.js +12 -0
  76. package/kit/studio/web/viewport.js +160 -0
  77. package/package.json +6 -5
  78. package/start.sh +10 -0
@@ -4,25 +4,41 @@
4
4
  // usage. Measured on one machine before this was written: 23,867 assistant records carried usage, for 11,245
5
5
  // responses, and every repeat carried the same numbers. So a response is counted once, by its message id — and
6
6
  // once across the session's files too, which is what keeps an agent's tokens from being added twice.
7
+ //
8
+ // An advisor call (the advisor tool: the model consults a second one mid-response) is not in that usage. The API
9
+ // keeps it out of the top-level fields, which are the executor's, and lists it in `usage.iterations[]` as an
10
+ // `advisor_message` entry with its own `model`. It is billed at that model's rates, so it is priced on its own.
11
+ // Measured on a real advisor session: the entry is written to the transcript, and repeated, like the rest of the
12
+ // usage, on every record of the response; the message id above counts it once.
7
13
 
8
14
  import fsp from 'node:fs/promises';
9
15
  import path from 'node:path';
10
16
 
11
17
  import { readAll } from './transcript.js';
12
18
  import { agentMetaFiles } from './projects.js';
13
- import { costOf, SOURCE } from './pricing.js';
19
+ import { costOf, SOURCE, NORMAL_TIER_MODEL } from './pricing.js';
14
20
 
15
21
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
16
22
 
23
+ /**
24
+ * A usage's cache writes by kind. A write the split does not account for is the 5-minute kind: that is the default,
25
+ * and the cheaper of the two. The split can fall short of the total: measured on a real response, the split said
26
+ * 12,400 one-hour and 0 five-minute against a total of 12,670, and the 270 missing were billed as five-minute
27
+ * writes (its iterations listed them so, and Claude Code's own cost for the session matched only with them in).
28
+ */
29
+ function writesOf(u) {
30
+ const split = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
31
+ const w1h = num(split?.ephemeral_1h_input_tokens);
32
+ const w5m = num(split?.ephemeral_5m_input_tokens);
33
+ return { cacheWrite5m: w5m + Math.max(0, num(u.cache_creation_input_tokens) - w5m - w1h), cacheWrite1h: w1h };
34
+ }
35
+
17
36
  /** One response's usage, in the shape pricing.js prices. Null when the record carries none. */
18
37
  export function usageOfRecord(r) {
19
38
  const m = r?.type === 'assistant' ? r.message : null;
20
39
  const u = m?.usage;
21
40
  if (!u || typeof u !== 'object') return null;
22
- const split = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
23
- const w1h = num(split?.ephemeral_1h_input_tokens);
24
- // With no split recorded, a cache write is the 5-minute kind: that is the default, and the cheaper of the two.
25
- const w5m = split ? num(split.ephemeral_5m_input_tokens) : num(u.cache_creation_input_tokens);
41
+ const { cacheWrite5m: w5m, cacheWrite1h: w1h } = writesOf(u);
26
42
  return {
27
43
  id: typeof m.id === 'string' ? m.id : null,
28
44
  model: typeof m.model === 'string' ? m.model : null,
@@ -33,9 +49,28 @@ export function usageOfRecord(r) {
33
49
  cacheRead: num(u.cache_read_input_tokens),
34
50
  fast: u.speed === 'fast',
35
51
  usOnly: u.inference_geo === 'us',
52
+ advisor: advisorCallsOf(u),
36
53
  };
37
54
  }
38
55
 
56
+ /** The advisor calls one response's usage lists, each in the shape pricing.js prices: [] when it made none. */
57
+ export function advisorCallsOf(u) {
58
+ const out = [];
59
+ for (const it of Array.isArray(u?.iterations) ? u.iterations : []) {
60
+ if (it?.type !== 'advisor_message') continue;
61
+ out.push({
62
+ model: typeof it.model === 'string' ? it.model : null,
63
+ input: num(it.input_tokens),
64
+ output: num(it.output_tokens),
65
+ ...writesOf(it),
66
+ cacheRead: num(it.cache_read_input_tokens),
67
+ fast: false,
68
+ usOnly: u.inference_geo === 'us',
69
+ });
70
+ }
71
+ return out;
72
+ }
73
+
39
74
  const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
40
75
 
41
76
  /** One running sum: tokens by kind, what they cost, and which models had no price. */
@@ -43,25 +78,29 @@ class Tally {
43
78
  constructor() {
44
79
  this.tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
45
80
  this.usd = 0;
81
+ this.usdAtNormal = 0; // the same tokens at NORMAL_TIER_MODEL's price
46
82
  this.responses = 0;
47
83
  this.unpriced = new Set();
48
84
  }
49
85
 
50
- take(u, usd) {
51
- this.responses += 1;
86
+ /** @param response false for an advisor call: its tokens and cost are added, but it is part of a response, not one. */
87
+ take(u, usd, atNormal = null, response = true) {
88
+ if (response) this.responses += 1;
52
89
  this.tokens.input += u.input;
53
90
  this.tokens.output += u.output;
54
91
  this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
55
92
  this.tokens.cacheRead += u.cacheRead;
56
93
  if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
57
94
  else this.usd += usd;
95
+ if (atNormal !== null) this.usdAtNormal += atNormal;
58
96
  }
59
97
 
60
98
  /**
61
99
  * `fresh` is what was sent and written for the first time: input, output and cache writes. `cacheRead` is the
62
100
  * same context read back from the cache on every later response, which is why it dwarfs the rest in a long
63
101
  * session and is kept out of the headline. `cost` is null when any of the tokens came from a model the price
64
- * table has no row for — a sum that leaves some of them out would read as the whole.
102
+ * table has no row for — a sum that leaves some of them out would read as the whole. `costAtNormal` is the same
103
+ * tokens at NORMAL_TIER_MODEL's list price, null under the same rule.
65
104
  */
66
105
  result() {
67
106
  const t = this.tokens;
@@ -69,6 +108,7 @@ class Tally {
69
108
  tokens: { ...t, fresh: t.input + t.output + t.cacheWrite, total: KINDS.reduce((a, k) => a + t[k], 0) },
70
109
  responses: this.responses,
71
110
  cost: this.unpriced.size ? null : this.usd,
111
+ costAtNormal: this.unpriced.size ? null : this.usdAtNormal,
72
112
  unpriced: [...this.unpriced].sort(),
73
113
  };
74
114
  }
@@ -80,6 +120,9 @@ export class Usage {
80
120
  this.all = new Tally();
81
121
  this.who = new Map(); // 'session', or an agent's id -> Tally
82
122
  this.model = new Map(); // model id -> Tally
123
+ this.advisor = new Tally(); // the advisor calls alone, one take per call
124
+ this.advisorModels = new Map(); // advisor model id -> Tally
125
+ this.advisorWho = new Map(); // 'session' or an agent's id -> Tally of its advisor calls alone
83
126
  this.first = null; // the earliest and the latest record of any file added
84
127
  this.last = null;
85
128
  }
@@ -104,11 +147,22 @@ export class Usage {
104
147
  const u = usageOfRecord(r);
105
148
  if (!u) continue;
106
149
  if (u.id) { if (this.seen.has(u.id)) continue; this.seen.add(u.id); }
107
- if (!(u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead)) continue; // a record the harness made up carries no tokens
108
- const usd = costOf(u);
109
- this.all.take(u, usd);
110
- this.#tally(this.who, who === 'session' && r.isSidechain === true ? 'agents:inline' : who).take(u, usd);
111
- this.#tally(this.model, u.model ?? 'unknown model').take(u, usd);
150
+ if (!(u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead) && !u.advisor.length) continue; // a record the harness made up carries no tokens
151
+ const mine = this.#tally(this.who, who === 'session' && r.isSidechain === true ? 'agents:inline' : who);
152
+ // The response, then each advisor call it made, priced at the advisor's own model.
153
+ for (const x of [u, ...u.advisor]) {
154
+ const usd = costOf(x);
155
+ const atNormal = costOf({ ...x, model: NORMAL_TIER_MODEL });
156
+ const own = x === u;
157
+ this.all.take(x, usd, atNormal, own);
158
+ mine.take(x, usd, atNormal, own);
159
+ this.#tally(this.model, x.model ?? 'unknown model').take(x, usd, atNormal, own);
160
+ if (!own) { // here each call is one
161
+ this.advisor.take(x, usd, atNormal);
162
+ this.#tally(this.advisorWho, who === 'session' && r.isSidechain === true ? 'agents:inline' : who).take(x, usd, atNormal);
163
+ this.#tally(this.advisorModels, x.model ?? 'unknown model').take(x, usd, atNormal);
164
+ }
165
+ }
112
166
  }
113
167
  return this;
114
168
  }
@@ -134,6 +188,9 @@ export class Usage {
134
188
  ...all,
135
189
  models: [...this.model.keys()].filter((m) => !all.unpriced.includes(m)).sort(),
136
190
  parts: { session: (this.who.get('session') ?? new Tally()).result(), agents: agents.result() },
191
+ // Null when no advisor call is recorded: a session without one has no advisor line, not a zero.
192
+ advisor: this.advisor.responses ? { ...this.advisor.result(), calls: this.advisor.responses } : null,
193
+ advisorModels: Object.fromEntries([...this.advisorModels].map(([m, t]) => [m, { ...t.result(), calls: t.responses }])),
137
194
  byModel: Object.fromEntries([...this.model].map(([m, t]) => [m, t.result()]).sort((a, b) => b[1].tokens.total - a[1].tokens.total)),
138
195
  startedAt: this.first,
139
196
  lastAt: this.last,
@@ -192,7 +249,10 @@ export async function sessionUsage(session) {
192
249
  for (const [who, t] of usage.who) {
193
250
  if (who === 'session' || who.startsWith('agents:')) continue;
194
251
  const r = t.result();
195
- agents[who] = { fresh: r.tokens.fresh, cacheRead: r.tokens.cacheRead, cost: r.cost };
252
+ // What of it was the advisor: its calls and their cost, already in `cost`. Zero calls when it consulted none.
253
+ const adv = usage.advisorWho.get(who)?.result() ?? null;
254
+ agents[who] = { fresh: r.tokens.fresh, cacheRead: r.tokens.cacheRead, cost: r.cost, costAtNormal: r.costAtNormal,
255
+ advisorCalls: adv?.responses ?? 0, advisorCost: adv?.cost ?? null };
196
256
  }
197
257
  return { ...withTimes(usage, records), agents };
198
258
  }
@@ -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,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,37 @@ 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
+ // A server older than this page has no Map route: say that, not its 404.
719
+ try { data = await getJson(`/api/map?${q}`); } catch (e) { data = { measured: false, reason: restartNote ?? e.message, others: [] }; }
720
+ finally { mapBusy = false; }
721
+ if (asked !== current) return;
722
+ mapFor = asked;
723
+ mapView.setData(data);
724
+ }
725
+ const MAP_POLL_MS = 30000;
726
+
682
727
  const list = new List(el.list, {
683
728
  tileOf: (n) => canvas.tileFor(n),
684
729
  dimmed: (n) => canvas.dimmed(n),
@@ -713,7 +758,7 @@ function paintTimelineBar(st) {
713
758
  */
714
759
  function setView(next, select = null, remember = true) {
715
760
  const was = view;
716
- view = ['graph', 'timeline', 'list', 'models'].includes(next) ? next : 'graph';
761
+ view = ['graph', 'timeline', 'list', 'models', 'map'].includes(next) ? next : 'graph';
717
762
  // A view the window opened on by default is not a choice, and is not remembered as one.
718
763
  if (remember) store.set('crewforth-studio-view', view);
719
764
  const carried = select ?? (was === 'timeline' ? timeline.selected : was === 'list' ? list.selected : canvas.selected) ?? null;
@@ -722,6 +767,8 @@ function setView(next, select = null, remember = true) {
722
767
  el.list.hidden = view !== 'list';
723
768
  el.models.hidden = view !== 'models';
724
769
  el.viewModels.setAttribute('aria-selected', String(view === 'models'));
770
+ el.map.hidden = view !== 'map';
771
+ el.viewMap.setAttribute('aria-selected', String(view === 'map'));
725
772
  el.viewGraph.setAttribute('aria-selected', String(view === 'graph'));
726
773
  el.viewTimeline.setAttribute('aria-selected', String(view === 'timeline'));
727
774
  el.viewList.setAttribute('aria-selected', String(view === 'list'));
@@ -751,7 +798,14 @@ function setView(next, select = null, remember = true) {
751
798
  if (costsFor !== current) models.setCosts(null);
752
799
  loadModels(true);
753
800
  loadCosts(costsFor !== current);
801
+ } else if (view === 'map') {
802
+ // The map is the project's: read when the view opens, drawn with what was last read until the answer comes.
803
+ mapView.render();
804
+ paintZoom(mapView.zoom, mapView.moved);
805
+ loadMap(true);
754
806
  } else {
807
+ // Back on the graph: the zoom and Reset say the graph's again.
808
+ paintToolbar(canvas.state());
755
809
  canvas.fitIfUntouched();
756
810
  if (carried && canvas.nodes.has(carried)) canvas.focus(carried);
757
811
  }
@@ -768,13 +822,14 @@ el.viewGraph.addEventListener('click', () => setView('graph'));
768
822
  el.viewTimeline.addEventListener('click', () => setView('timeline'));
769
823
  el.viewList.addEventListener('click', () => setView('list'));
770
824
  el.viewModels.addEventListener('click', () => setView('models'));
825
+ el.viewMap.addEventListener('click', () => setView('map'));
771
826
  el.tbFollow.addEventListener('click', () => timeline.setFollow(el.tbFollow.getAttribute('aria-checked') !== 'true'));
772
827
  // "Out" is more time on screen, "in" is less: the same way round as the graph's zoom.
773
828
  el.tbRangeOut.addEventListener('click', () => timeline.zoom(1));
774
829
  el.tbRangeIn.addEventListener('click', () => timeline.zoom(-1));
775
830
  if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => timeline.render())).observe(el.timeline);
776
831
  // 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);
832
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => canvas.paneResized())).observe(el.canvasEl);
778
833
 
779
834
  getJson('/api/palette')
780
835
  .then((p) => {
@@ -980,11 +1035,15 @@ function paintAgentOverview(body, n, detail) {
980
1035
  if (lines.length) {
981
1036
  const dl = node('dl', 'icard');
982
1037
  for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
1038
+ // Why the gate called it critical, in its own words (`files ... is on a critical path: ...` or `change: ...`).
1039
+ if (work.riskWhy) dl.append(node('dt', null, 'why critical'), node('dd', null, work.riskWhy));
983
1040
  const sec = isection('Task card', dl);
984
1041
  if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
985
1042
  body.append(sec);
986
1043
  }
987
- // No card and no line: an agent that is not a crew agent, and nothing is said about it here.
1044
+ // Not a crew agent: no card and no line, and that is said, so the empty space is not read as a record lost.
1045
+ const outside = !row && !lines.length ? outsideRouting(n.agentType) : null;
1046
+ if (outside) body.append(isection('Model routing', node('p', 'istep-note', outside)));
988
1047
  if (row || lines.length) {
989
1048
  const ol = node('ol', 'isteps');
990
1049
  work.steps.forEach((st, i) => {
@@ -1381,6 +1440,8 @@ const ICON = {
1381
1440
  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
1441
  chevron: 'M6 4l4 4-4 4',
1383
1442
  more: 'M3.5 8h.01M8 8h.01M12.5 8h.01',
1443
+ // The Map view's tab mark: three cards and the lines between them.
1444
+ map: 'M2 2.5h5v3.5H2zM9 2.5h5v3.5H9zM5.5 10h5v3.5h-5zM4.5 6v2h7V6M8 8v2',
1384
1445
  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
1446
  };
1386
1447
 
@@ -1611,6 +1672,23 @@ function versionBadge(kit) {
1611
1672
  return tag;
1612
1673
  }
1613
1674
 
1675
+ /** The Map entry under a project: opens the project's map, through its latest session. */
1676
+ function mapRow(p, latest) {
1677
+ const row = node('div', 'srow map-row');
1678
+ row.tabIndex = 0;
1679
+ row.setAttribute('role', 'treeitem');
1680
+ row.dataset.map = p.key;
1681
+ const words = node('span', 'map-word', 'Map');
1682
+ row.append(icon(ICON.map), words, node('span', 'row-fill'));
1683
+ 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); }
1684
+ else if (!p.map) row.append(node('span', 'sub', 'no map'));
1685
+ row.title = p.map ? 'Open the project map' : 'This project has no .crewforth/crew-map.json yet';
1686
+ const go = () => { selectSession(latest.sessionId); setView('map'); };
1687
+ row.addEventListener('click', go);
1688
+ row.addEventListener('keydown', (e) => { if (e.target === row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); go(); } });
1689
+ return row;
1690
+ }
1691
+
1614
1692
  function sessionRow(project, sn) {
1615
1693
  const status = sessionStatus(sn.sessionId, fleetData);
1616
1694
  const name = sessionName(sn, labels.all());
@@ -1775,6 +1853,8 @@ function paintProjects() {
1775
1853
  group.append(head);
1776
1854
 
1777
1855
  if (open) {
1856
+ // Under the project, its map (spec §11): the orange number is how many findings it shows unasked.
1857
+ if (p.local !== false && p.exists && sessions.length) group.append(mapRow(p, sessions[0]));
1778
1858
  for (const sn of sessions) group.append(sessionRow(p, sn));
1779
1859
  if (p.total > p.sessions.length) {
1780
1860
  group.append(node('div', 'nav-line', `${p.total - p.sessions.length} older session(s) not listed`));
@@ -1960,10 +2040,121 @@ function paintMachines() {
1960
2040
  });
1961
2041
  frag.append(note);
1962
2042
  }
2043
+ if (role === 'owner') frag.append(remoteSection());
1963
2044
  el.reach.replaceChildren(frag);
1964
2045
  paintOtherMachines();
1965
2046
  }
1966
2047
 
2048
+ /* ------------------------------------------------------- remote viewers
2049
+ A viewer is a second token that reads and changes nothing: the way to watch
2050
+ this panel from another device without handing that device the panel. The
2051
+ server refuses a viewer's writes itself; what is hidden here for a viewer is
2052
+ only what would be refused anyway. */
2053
+
2054
+ let role = null; // 'owner' or 'viewer', once the server has said which
2055
+ let remote = null; // the server's list of viewers, or null before it has answered
2056
+ let handed = null; // the link of the viewer just added: the server says a token once
2057
+
2058
+ async function pollRemote() {
2059
+ if (role !== 'owner') return;
2060
+ try { remote = await getJson('/api/remote'); } catch { return; }
2061
+ paintMachines();
2062
+ }
2063
+
2064
+ async function remoteWrite(method, path, body) {
2065
+ try {
2066
+ const r = await fetch(api(path), {
2067
+ method,
2068
+ headers: { ...writeHeaders, ...(body ? { 'content-type': 'application/json' } : {}) },
2069
+ ...(body ? { body: JSON.stringify(body) } : {}),
2070
+ });
2071
+ return await r.json();
2072
+ } catch (e) {
2073
+ return { ok: false, reason: e.message };
2074
+ }
2075
+ }
2076
+
2077
+ async function addViewer(button) {
2078
+ button.disabled = true;
2079
+ const res = await remoteWrite('POST', '/api/remote', {});
2080
+ if (!res.ok) {
2081
+ toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
2082
+ button.disabled = false;
2083
+ return;
2084
+ }
2085
+ remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
2086
+ handed = { id: res.viewer.id, label: res.viewer.label, url: `${location.origin}/?token=${encodeURIComponent(res.viewer.token)}` };
2087
+ paintMachines();
2088
+ }
2089
+
2090
+ async function revokeViewer(v, button) {
2091
+ button.disabled = true;
2092
+ const res = await remoteWrite('DELETE', `/api/remote/${encodeURIComponent(v.id)}`);
2093
+ if (!res.ok) {
2094
+ toast(`Not revoked — ${res.reason ?? 'no reason given'}`);
2095
+ button.disabled = false;
2096
+ return;
2097
+ }
2098
+ remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
2099
+ if (handed?.id === v.id) handed = null;
2100
+ toast(res.revoked ? `${v.label} can no longer read this panel` : `${v.label} was already revoked`);
2101
+ paintMachines();
2102
+ }
2103
+
2104
+ function viewerRow(v) {
2105
+ const row = node('div', 'srow remote');
2106
+ const top = node('span', 'srow-top');
2107
+ const revoke = node('button', 'btn sm ghost', 'Revoke');
2108
+ revoke.type = 'button';
2109
+ revoke.setAttribute('aria-label', `Revoke ${v.label}`);
2110
+ revoke.addEventListener('click', () => revokeViewer(v, revoke));
2111
+ top.append(icon(ICON.machine), node('span', 'mname', v.label), node('span', 'row-fill'), revoke);
2112
+ row.append(top);
2113
+ const seen = v.lastSeenAt
2114
+ ? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
2115
+ : 'link not opened yet';
2116
+ row.append(node('div', 'sub srow-sub', seen));
2117
+ return row;
2118
+ }
2119
+
2120
+ function remoteSection() {
2121
+ const box = node('div', 'nav-foot rv');
2122
+ const head = node('div', 'rv-head');
2123
+ const add = node('button', 'btn sm', 'Add viewer');
2124
+ add.type = 'button';
2125
+ const list = remote?.viewers ?? [];
2126
+ if (remote?.max != null && list.length >= remote.max) {
2127
+ add.disabled = true;
2128
+ add.title = `${remote.max} viewers already; revoke one first`;
2129
+ }
2130
+ add.addEventListener('click', () => addViewer(add));
2131
+ head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
2132
+ box.append(head);
2133
+
2134
+ if (handed) {
2135
+ const give = node('div', 'rv-give');
2136
+ give.append(node('div', 'sub rv-say', `Link for ${handed.label}. Shown once; open it on the other device, through your tunnel.`));
2137
+ const url = node('code', 'rv-url', handed.url);
2138
+ const copy = node('button', 'btn sm', 'Copy link');
2139
+ copy.type = 'button';
2140
+ copy.addEventListener('click', () => copyText(handed.url, 'Link copied'));
2141
+ const done = node('button', 'btn sm ghost', 'Done');
2142
+ done.type = 'button';
2143
+ done.addEventListener('click', () => { handed = null; paintMachines(); });
2144
+ const acts = node('div', 'rv-acts');
2145
+ acts.append(copy, done);
2146
+ give.append(url, acts);
2147
+ box.append(give);
2148
+ }
2149
+
2150
+ for (const v of list) box.append(viewerRow(v));
2151
+ if (!list.length) {
2152
+ box.append(node('div', 'sub rv-say',
2153
+ '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.'));
2154
+ }
2155
+ return box;
2156
+ }
2157
+
1967
2158
  /** The short form under the project list: the first two, and the way to the rest. */
1968
2159
  function paintOtherMachines() {
1969
2160
  const m = machines(fleetData);
@@ -2226,7 +2417,7 @@ function paintPulse() {
2226
2417
  pulse.detail.textContent = l.detail ? `· ${l.detail}` : '';
2227
2418
  }
2228
2419
  paintPulse();
2229
- setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } }, 1000);
2420
+ setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } if (view === 'map') loadMap(); }, 1000);
2230
2421
 
2231
2422
  /* -------------------------------------------------------------- home, keys */
2232
2423
 
@@ -2254,6 +2445,7 @@ document.addEventListener('keydown', (e) => {
2254
2445
  if (shell.classList.contains('side-open')) { setDrawer(false); return; }
2255
2446
  if (newPanel.isOpen) { newPanel.close(); return; }
2256
2447
  if (view === 'timeline' && timeline.selected) { timeline.select(null); return; }
2448
+ if (view === 'map' && mapView.escape()) return;
2257
2449
  // Close the inspector and let go of the selection.
2258
2450
  if (!el.inspector.hidden) { canvas.clearSelection(); showInspector(null); }
2259
2451
  return;
@@ -2277,9 +2469,11 @@ document.addEventListener('keydown', (e) => {
2277
2469
  } else if (key === 'l') {
2278
2470
  setView('list');
2279
2471
  } else if (key === 'r') {
2280
- if (view === 'graph') resetLayout();
2472
+ if (view === 'graph' || view === 'map') resetLayout();
2281
2473
  } else if (key === 'm') {
2282
2474
  setView('models');
2475
+ } else if (key === 'p') {
2476
+ setView('map');
2283
2477
  } else if (key === 'j') {
2284
2478
  stepAttention(1);
2285
2479
  } else if (key === 'k') {
@@ -2465,6 +2659,7 @@ chat.onPermissions = () => pollOwned();
2465
2659
  view is being shown in a window too narrow for it. Offline is said first. */
2466
2660
 
2467
2661
  let nextPollAt = null;
2662
+ let restartNote = null; // staleServerNote(/api/health): the server runs older code than the page
2468
2663
  const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
2469
2664
  stageNote.button.type = 'button';
2470
2665
  stageNote.button.addEventListener('click', () => retryNow());
@@ -2473,11 +2668,12 @@ el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), s
2473
2668
  function paintStageNote() {
2474
2669
  const l = liveness(Date.now(), heard.okAt, heard.failed);
2475
2670
  const off = offlineNote(l.state, heard.okAt, nextPollAt, Date.now());
2476
- const warn = narrowWarning(view, phoneBand.matches);
2671
+ // Offline first, then a server older than this page, then a window too narrow for the view.
2672
+ const warn = restartNote ?? narrowWarning(view, phoneBand.matches);
2477
2673
  // The last picture stays on screen while the server is away, and is drawn as old.
2478
2674
  el.stage.dataset.offline = String(Boolean(off));
2479
2675
  el.stageNote.hidden = !off && !warn;
2480
- el.stageNote.dataset.kind = off ? 'offline' : 'narrow';
2676
+ el.stageNote.dataset.kind = off ? 'offline' : restartNote ? 'restart' : 'narrow';
2481
2677
  stageNote.text.textContent = off ? off.text : (warn ?? '');
2482
2678
  stageNote.retry.textContent = off ? off.retry : '';
2483
2679
  stageNote.retry.hidden = !off;
@@ -2619,7 +2815,19 @@ applyPhoneBand(true);
2619
2815
  setView(defaultView(stored, phoneBand.matches), null, Boolean(stored));
2620
2816
  }
2621
2817
  pollFleet(); pollSessions(); pollOwned();
2818
+ // Who this page is to the server. A viewer's page says so and offers no way to start a session; the server
2819
+ // would refuse it either way.
2820
+ getJson('/api/health').then((h) => {
2821
+ restartNote = staleServerNote(h);
2822
+ // A Map read before this answer showed the old server's 404; read it again so it says why.
2823
+ if (restartNote) { paintStageNote(); if (view === 'map') loadMap(true); }
2824
+ role = h.role === 'viewer' ? 'viewer' : 'owner';
2825
+ document.body.dataset.role = role;
2826
+ document.getElementById('role').hidden = role !== 'viewer';
2827
+ if (role === 'owner') pollRemote(); else paintMachines();
2828
+ }).catch(() => { /* the Live indicator says the server is unreachable */ });
2622
2829
  setInterval(() => { nextPollAt = Date.now() + FLEET_POLL_MS; pollFleet(); }, FLEET_POLL_MS);
2830
+ setInterval(pollRemote, FLEET_POLL_MS);
2623
2831
  nextPollAt = Date.now() + FLEET_POLL_MS;
2624
2832
  setInterval(pollOwned, FLEET_POLL_MS);
2625
2833
  setInterval(pollSessions, SESSION_POLL_MS);