crewforth 3.2.0-rc.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 (74) hide show
  1. package/CHANGELOG.md +190 -5
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +55 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +6 -6
  7. package/kit/agents/crew-commit-agent.md +6 -4
  8. package/kit/agents/crew-review-agent.md +2 -0
  9. package/kit/eval/doctor.sh +37 -3
  10. package/kit/eval/gate-report.sh +2 -0
  11. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  12. package/kit/eval/lib/crew-map/project.mjs +3 -2
  13. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  14. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  15. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  16. package/kit/eval/lib/crew-map/surface.mjs +81 -3
  17. package/kit/eval/lib/gate-log.sh +52 -0
  18. package/kit/eval/lib/json-string.awk +27 -0
  19. package/kit/eval/lib/preset.sh +109 -0
  20. package/kit/eval/lib/presets/default.json +7 -0
  21. package/kit/eval/lib/presets/solo.json +7 -0
  22. package/kit/eval/lib/presets/strict.json +11 -0
  23. package/kit/eval/lib/preview.sh +150 -0
  24. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  25. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  26. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  27. package/kit/eval/lib/settings-default.sh +36 -0
  28. package/kit/eval/lib/settings-json.awk +14 -4
  29. package/kit/eval/lib/statusline.sh +4 -19
  30. package/kit/eval/lib/verify-cmd.sh +17 -0
  31. package/kit/eval/lib/version.sh +41 -0
  32. package/kit/eval/review-bench.sh +26 -0
  33. package/kit/eval/smoke-test.sh +424 -26
  34. package/kit/hooks/agent-outcome.sh +38 -9
  35. package/kit/hooks/board-sync.sh +2 -23
  36. package/kit/hooks/guard-agent-model.sh +163 -20
  37. package/kit/hooks/guard-bash.sh +69 -36
  38. package/kit/hooks/guard-commit-scan.sh +10 -2
  39. package/kit/hooks/guard-write.sh +3 -7
  40. package/kit/hooks/precompact-snapshot.sh +75 -0
  41. package/kit/hooks/prompt-approval.sh +74 -5
  42. package/kit/hooks/session-rehydrate.sh +25 -2
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/hooks/trace-blocklist.txt +9 -0
  45. package/kit/settings.json +18 -0
  46. package/kit/skills/crew-code-review/SKILL.md +16 -0
  47. package/kit/skills/crew-map/SKILL.md +16 -5
  48. package/kit/skills/crew-review/SKILL.md +2 -1
  49. package/kit/skills/crew-ship/SKILL.md +3 -1
  50. package/kit/studio/README.md +21 -10
  51. package/kit/studio/server/index.js +85 -22
  52. package/kit/studio/server/lib/code-stamp.js +35 -0
  53. package/kit/studio/server/lib/graph.js +9 -1
  54. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  55. package/kit/studio/server/lib/lan.js +87 -0
  56. package/kit/studio/server/lib/owned-stream.js +56 -0
  57. package/kit/studio/server/lib/projects.js +11 -4
  58. package/kit/studio/server/lib/redact.js +60 -0
  59. package/kit/studio/server/lib/remote.js +104 -18
  60. package/kit/studio/server/lib/session.js +1 -1
  61. package/kit/studio/server/lib/terminal.js +51 -13
  62. package/kit/studio/server/lib/transcript.js +32 -0
  63. package/kit/studio/web/app.js +79 -11
  64. package/kit/studio/web/canvas.js +14 -2
  65. package/kit/studio/web/chat.js +114 -24
  66. package/kit/studio/web/graph-plan.js +3 -2
  67. package/kit/studio/web/liveness.js +14 -0
  68. package/kit/studio/web/map-plan.js +72 -3
  69. package/kit/studio/web/map.js +103 -10
  70. package/kit/studio/web/qr.js +200 -0
  71. package/kit/studio/web/style.css +40 -0
  72. package/kit/studio/web/tokens.css +6 -0
  73. package/package.json +1 -1
  74. package/start.sh +14 -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';
@@ -29,6 +29,7 @@ import { cardOf, cardLines, workSteps, outsideRouting } from './models-plan.js';
29
29
  import { fitLevel, runsOver } from './toolbar-fit.js';
30
30
  import { roleName, shownType, modelFamily } from './names.js';
31
31
  import { shortcutOf } from './keys.js';
32
+ import { qrSvg } from './qr.js';
32
33
  import { SESSION_MODES } from './convo.js';
33
34
  import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
34
35
 
@@ -113,12 +114,37 @@ const getJson = async (p) => {
113
114
  heard.failed = true;
114
115
  throw e;
115
116
  }
116
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
117
+ if (!r.ok) throw await statusError(r);
117
118
  const body = await r.json();
118
119
  heardNow();
119
120
  return body;
120
121
  };
121
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
+
122
148
  /* ---------------------------------------------------------------- theme */
123
149
 
124
150
  try { migrateStorage(localStorage); } catch { /* blocked storage */ }
@@ -715,7 +741,8 @@ async function loadMap(force = false) {
715
741
  mapBusy = true;
716
742
  let data;
717
743
  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: [] }; }
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: [] }; }
719
746
  finally { mapBusy = false; }
720
747
  if (asked !== current) return;
721
748
  mapFor = asked;
@@ -2057,6 +2084,9 @@ let handed = null; // the link of the viewer just added: the server s
2057
2084
  async function pollRemote() {
2058
2085
  if (role !== 'owner') return;
2059
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;
2060
2090
  paintMachines();
2061
2091
  }
2062
2092
 
@@ -2073,16 +2103,25 @@ async function remoteWrite(method, path, body) {
2073
2103
  }
2074
2104
  }
2075
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
+
2076
2111
  async function addViewer(button) {
2077
2112
  button.disabled = true;
2078
- const res = await remoteWrite('POST', '/api/remote', {});
2113
+ const res = await remoteWrite('POST', '/api/remote', { transcripts: showTranscripts });
2079
2114
  if (!res.ok) {
2080
2115
  toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
2081
2116
  button.disabled = false;
2082
2117
  return;
2083
2118
  }
2084
2119
  remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
2085
- handed = { id: res.viewer.id, label: res.viewer.label, url: `${location.origin}/?token=${encodeURIComponent(res.viewer.token)}` };
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;
2086
2125
  paintMachines();
2087
2126
  }
2088
2127
 
@@ -2113,6 +2152,7 @@ function viewerRow(v) {
2113
2152
  ? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
2114
2153
  : 'link not opened yet';
2115
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(', ')}` : ''}`));
2116
2156
  return row;
2117
2157
  }
2118
2158
 
@@ -2130,9 +2170,24 @@ function remoteSection() {
2130
2170
  head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
2131
2171
  box.append(head);
2132
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
+
2133
2181
  if (handed) {
2134
2182
  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.`));
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
+ }
2136
2191
  const url = node('code', 'rv-url', handed.url);
2137
2192
  const copy = node('button', 'btn sm', 'Copy link');
2138
2193
  copy.type = 'button';
@@ -2149,7 +2204,7 @@ function remoteSection() {
2149
2204
  for (const v of list) box.append(viewerRow(v));
2150
2205
  if (!list.length) {
2151
2206
  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.'));
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.'));
2153
2208
  }
2154
2209
  return box;
2155
2210
  }
@@ -2635,7 +2690,9 @@ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnima
2635
2690
  // They survive a page reload as long as the server does.
2636
2691
  let ownedBusy = false;
2637
2692
  async function pollOwned() {
2638
- 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;
2639
2696
  ownedBusy = true;
2640
2697
  try {
2641
2698
  const r = await getJson('/api/owned');
@@ -2658,6 +2715,7 @@ chat.onPermissions = () => pollOwned();
2658
2715
  view is being shown in a window too narrow for it. Offline is said first. */
2659
2716
 
2660
2717
  let nextPollAt = null;
2718
+ let restartNote = null; // staleServerNote(/api/health): the server runs older code than the page
2661
2719
  const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
2662
2720
  stageNote.button.type = 'button';
2663
2721
  stageNote.button.addEventListener('click', () => retryNow());
@@ -2666,11 +2724,12 @@ el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), s
2666
2724
  function paintStageNote() {
2667
2725
  const l = liveness(Date.now(), heard.okAt, heard.failed);
2668
2726
  const off = offlineNote(l.state, heard.okAt, nextPollAt, Date.now());
2669
- 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);
2670
2729
  // The last picture stays on screen while the server is away, and is drawn as old.
2671
2730
  el.stage.dataset.offline = String(Boolean(off));
2672
2731
  el.stageNote.hidden = !off && !warn;
2673
- el.stageNote.dataset.kind = off ? 'offline' : 'narrow';
2732
+ el.stageNote.dataset.kind = off ? 'offline' : restartNote ? 'restart' : 'narrow';
2674
2733
  stageNote.text.textContent = off ? off.text : (warn ?? '');
2675
2734
  stageNote.retry.textContent = off ? off.retry : '';
2676
2735
  stageNote.retry.hidden = !off;
@@ -2815,9 +2874,18 @@ pollFleet(); pollSessions(); pollOwned();
2815
2874
  // Who this page is to the server. A viewer's page says so and offers no way to start a session; the server
2816
2875
  // would refuse it either way.
2817
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); }
2818
2880
  role = h.role === 'viewer' ? 'viewer' : 'owner';
2881
+ transcriptsHidden = role === 'viewer' && h.transcripts === false;
2819
2882
  document.body.dataset.role = role;
2820
- document.getElementById('role').hidden = role !== 'viewer';
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
+ }
2821
2889
  if (role === 'owner') pollRemote(); else paintMachines();
2822
2890
  }).catch(() => { /* the Live indicator says the server is unreachable */ });
2823
2891
  setInterval(() => { nextPollAt = Date.now() + FLEET_POLL_MS; pollFleet(); }, FLEET_POLL_MS);
@@ -717,7 +717,10 @@ export class Canvas {
717
717
  // ran out of the card.
718
718
  p.bg = mk('div', 'cv-sub cv-indent cv-bg');
719
719
  p.bg.hidden = true;
720
- el.append(row, p.name, p.sub, p.bg);
720
+ // Agent calls the model gate refused, which started no agent: a line of their own, the gate's words on hover.
721
+ p.refused = mk('div', 'cv-sub cv-indent cv-refused');
722
+ p.refused.hidden = true;
723
+ el.append(row, p.name, p.sub, p.bg, p.refused);
721
724
  } else if (it.kind === 'group') {
722
725
  el.classList.add('cv-group');
723
726
  p.tile = mk('span', 'cv-tile');
@@ -837,12 +840,21 @@ export class Canvas {
837
840
  p.name.title = p.name.textContent;
838
841
  const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
839
842
  if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
843
+ if (n.context?.toolShare != null) bits.push(`${n.context.toolShare}% tool output`);
840
844
  p.sub.textContent = bits.join(' · ');
841
- p.sub.title = p.sub.textContent;
845
+ // The share is of the text since the last compaction, in characters; the compactions are the transcript's own.
846
+ const lc = n.context?.lastCompact;
847
+ p.sub.title = [p.sub.textContent,
848
+ n.context?.toolShare != null ? `${n.context.toolShare}% of the text in context since the last compaction is tool output (characters, not tokens)` : null,
849
+ n.context?.compactions ? `compacted ${n.context.compactions}×${lc?.before != null ? `, last ${lc.trigger ?? ''} ${fmtTokens(lc.before)} → ${fmtTokens(lc.after)}` : ''}` : null].filter(Boolean).join('\n');
842
850
  // What the session has running that is not an agent: commands sent to the background.
843
851
  p.bg.hidden = !bg;
844
852
  p.bg.textContent = bg ? `${bg} in background` : '';
845
853
  p.bg.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
854
+ const refused = n.refusedCalls ?? [];
855
+ p.refused.hidden = !refused.length;
856
+ p.refused.textContent = refused.length ? `${refused.length} refused by the model gate` : '';
857
+ p.refused.title = refused.map((r) => `${r.agent ?? 'an agent'}${r.model ? ` (${r.model})` : ''}: ${r.text}`).join('\n');
846
858
  el.title = 'Click to inspect this session';
847
859
  el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
848
860
  }
@@ -48,10 +48,76 @@ export function quickReplies(text) {
48
48
  return (opts.length >= 2 && opts.length <= 5 && /\?/.test(text)) ? opts : [];
49
49
  }
50
50
 
51
+ /* ============================================== every open conversation === */
52
+
53
+ /**
54
+ * One connection for every open conversation. A pane per EventSource reached the browser's six connections to a
55
+ * host at five panes beside the page's own stream, and from there a message sent and every request the page made
56
+ * waited for a pane to close (measured on Windows). The panes register here; the stream is opened again, with
57
+ * each session's last event number, whenever the set changes or the connection drops, so nothing is replayed
58
+ * twice and nothing is missed.
59
+ */
60
+ export class OwnedStream {
61
+ constructor(api, { EventSourceImpl = globalThis.EventSource, retryMs = 1500 } = {}) {
62
+ this.api = api;
63
+ this.ES = EventSourceImpl;
64
+ this.retryMs = retryMs;
65
+ this.panes = new Map(); // session id -> pane
66
+ this.seq = new Map(); // session id -> the last event number taken
67
+ this.source = null;
68
+ this.timer = null;
69
+ }
70
+
71
+ attach(pane) { this.panes.set(pane.id, pane); this.#soon(0); }
72
+
73
+ detach(pane) {
74
+ if (this.panes.get(pane.id) !== pane) return;
75
+ this.panes.delete(pane.id); this.seq.delete(pane.id); this.#soon(0);
76
+ }
77
+
78
+ /** The URL the stream is opened at now: what a reconnect resumes from. */
79
+ url() {
80
+ const ids = [...this.panes.keys()];
81
+ const after = ids.map((id) => `${id}:${this.seq.get(id) ?? 0}`).join(',');
82
+ return this.api(`/api/owned-events?ids=${encodeURIComponent(ids.join(','))}&after=${encodeURIComponent(after)}`);
83
+ }
84
+
85
+ // Panes opened together (a page restoring its tabs) cost one reconnect, not one each.
86
+ #soon(ms) { clearTimeout(this.timer); this.timer = setTimeout(() => this.#open(), ms); }
87
+
88
+ #open() {
89
+ if (this.source) { this.source.close(); this.source = null; }
90
+ if (!this.panes.size) return;
91
+ const src = new this.ES(this.url());
92
+ const data = (e) => { try { return JSON.parse(e.data); } catch { return null; } };
93
+ src.addEventListener('state', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.takeState(d.session); });
94
+ src.addEventListener('event', (e) => {
95
+ const d = data(e); const pane = d && this.panes.get(d.id);
96
+ if (!pane || !d.ev) return;
97
+ if (Number.isFinite(d.ev.seq)) {
98
+ if (d.ev.seq <= (this.seq.get(d.id) ?? 0)) return; // already taken before a reconnect
99
+ this.seq.set(d.id, d.ev.seq);
100
+ }
101
+ pane.takeEvent(d.ev);
102
+ });
103
+ src.addEventListener('gone', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.streamLost(); });
104
+ // The browser would reconnect at the same URL, from the same event numbers, and replay what was taken since.
105
+ // So the stream is closed and opened again here, from where each session is now.
106
+ src.onerror = () => {
107
+ if (this.source !== src) return;
108
+ for (const pane of this.panes.values()) pane.streamLost();
109
+ this.#soon(this.retryMs);
110
+ };
111
+ this.source = src;
112
+ }
113
+
114
+ close() { clearTimeout(this.timer); if (this.source) { this.source.close(); this.source = null; } this.panes.clear(); this.seq.clear(); }
115
+ }
116
+
51
117
  /* ========================================================== one session === */
52
118
 
53
119
  class Pane {
54
- constructor(session, { api, headers, onChange, onPermissions, hooks = {}, readOnly = false }) {
120
+ constructor(session, { api, headers, onChange, onPermissions, hub = null, hooks = {}, readOnly = false }) {
55
121
  this.api = api;
56
122
  this.headers = headers;
57
123
  this.onChange = onChange;
@@ -62,7 +128,8 @@ class Pane {
62
128
  this.readOnly = readOnly;
63
129
  this.session = session;
64
130
  this.id = session.sessionId;
65
- this.source = null;
131
+ this.hub = hub;
132
+ this.connected = false;
66
133
  this.messages = [];
67
134
  this.streaming = null;
68
135
  this.permissions = [];
@@ -141,6 +208,7 @@ class Pane {
141
208
  this.note(`History not read: ${e.message}`, 'bad');
142
209
  return;
143
210
  }
211
+ if (conv?.transcriptsHidden) { this.note('Transcripts are hidden on this link', 'muted'); return; }
144
212
  if (conv?.measured === false) { this.note(`History not measured — ${conv.reason}`, 'bad'); return; }
145
213
 
146
214
  const frag = document.createDocumentFragment();
@@ -163,25 +231,22 @@ class Pane {
163
231
  }
164
232
 
165
233
  connect() {
166
- if (this.source) return;
167
- this.source = new EventSource(this.api(`/api/owned/${encodeURIComponent(this.id)}/events`));
168
- this.source.addEventListener('state', (e) => {
169
- try { this.session = JSON.parse(e.data); } catch { return; }
170
- this.paintState();
171
- this.onChange(this);
172
- });
173
- this.source.addEventListener('event', (e) => {
174
- let ev;
175
- try { ev = JSON.parse(e.data); } catch { return; }
176
- this.absorb(ev.rec);
177
- });
178
- this.source.onerror = () => { this.streamBroken = true; this.onChange(this); };
234
+ if (this.connected || !this.hub) return;
235
+ this.connected = true;
236
+ this.hub.attach(this);
179
237
  }
180
238
 
181
239
  disconnect() {
182
- if (this.source) { this.source.close(); this.source = null; }
240
+ if (!this.connected) return;
241
+ this.connected = false;
242
+ this.hub.detach(this);
183
243
  }
184
244
 
245
+ // What the shared stream hands this pane.
246
+ takeState(session) { if (!session) return; this.session = session; this.streamBroken = false; this.paintState(); this.onChange(this); }
247
+ takeEvent(ev) { this.streamBroken = false; this.absorb(ev.rec); }
248
+ streamLost() { this.streamBroken = true; this.onChange(this); }
249
+
185
250
  /* --------------------------------------------------------- ingestion */
186
251
 
187
252
  absorb(rec) {
@@ -495,9 +560,12 @@ class Pane {
495
560
  cont.type = 'button';
496
561
  cont.addEventListener('click', () => this.hooks.onContinue?.(this, cont));
497
562
  const contRow = el('div', 'chat-ro-row');
498
- contRow.append(cont, el('span', 'sub',
499
- 'Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: '
500
- + 'the original does not see this, and messages here never reach your terminal.'));
563
+ // Said before the press, from the server's own check (the one Continue here acts on): the same session, or a copy.
564
+ // Until the server answers, and if it cannot: the sentence true of both outcomes.
565
+ const FALLBACK = 'Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: '
566
+ + 'the original does not see this, and messages here never reach your terminal.';
567
+ const contText = el('span', 'sub', FALLBACK);
568
+ contRow.append(cont, contText);
501
569
 
502
570
  const term = el('button', 'btn sm', 'Open in terminal');
503
571
  term.type = 'button';
@@ -508,13 +576,34 @@ class Pane {
508
576
  const termRow = el('div', 'chat-ro-row');
509
577
  termRow.append(term, termText);
510
578
 
511
- this.roEl.replaceChildren(lead, contRow, termRow);
579
+ // Back to the desktop app: the command, to copy and run where the app is.
580
+ const deskLine = el('code', 'chat-ro-cmd');
581
+ const deskCopy = el('button', 'btn sm', 'Copy');
582
+ deskCopy.type = 'button';
583
+ deskCopy.addEventListener('click', () => { if (deskLine.dataset.cmd) navigator.clipboard?.writeText(deskLine.dataset.cmd).then(() => { deskCopy.textContent = 'Copied'; }, () => {}); });
584
+ const deskText = el('span', 'sub');
585
+ deskText.append('Open it in the desktop app again: ', deskLine);
586
+ const deskRow = el('div', 'chat-ro-row');
587
+ deskRow.append(deskCopy, deskText);
588
+
589
+ this.roEl.replaceChildren(lead, contRow, termRow, deskRow);
512
590
 
513
- // The command is the server's, read before anything runs. One that could not be worked out is said so.
591
+ // The commands are the server's, read before anything runs. One that could not be worked out is said so.
514
592
  fetch(this.api(`/api/session/${encodeURIComponent(this.id)}/terminal`), { cache: 'no-store' })
515
593
  .then((r) => r.json())
516
- .then((r) => { line.textContent = r?.plan?.line ?? 'not measured — no working directory recorded'; })
517
- .catch((e) => { line.textContent = `not read — ${e.message}`; });
594
+ .then((r) => {
595
+ line.textContent = r?.plan?.line ?? 'not measured — no working directory or session id recorded';
596
+ // On Windows the command is a PowerShell line (no one line works in cmd and PowerShell 5.1 both): say so.
597
+ deskLine.textContent = r?.desktop?.line ?? 'not measured — no working directory or session id recorded';
598
+ if (r?.desktop?.line) deskLine.dataset.cmd = r.desktop.line; else deskCopy.disabled = true;
599
+ if (r?.desktop?.shell === 'PowerShell') deskText.append(' (in PowerShell)');
600
+ contText.textContent = r?.continueAs === 'same'
601
+ ? 'Continues this same session in Studio: its id and history stay, and the desktop app or a terminal that opens it later sees what you add here. Nothing else has it open now.'
602
+ : r?.continueAs === 'copy'
603
+ ? 'It is open somewhere else right now, so Studio starts a copy: the original does not see this, and messages here never reach your terminal or the desktop app.'
604
+ : FALLBACK;
605
+ })
606
+ .catch((e) => { line.textContent = `not read — ${e.message}`; deskLine.textContent = `not read — ${e.message}`; deskCopy.disabled = true; });
518
607
  }
519
608
 
520
609
  paintState() {
@@ -591,6 +680,7 @@ export class Chat {
591
680
  this.headers = headers;
592
681
  this.hooks = hooks;
593
682
  this.panes = new Map();
683
+ this.hub = new OwnedStream(api);
594
684
  this.activeId = null;
595
685
  this.onActivate = () => {};
596
686
  // (pane, { pending }) — a session's waiting requests changed.
@@ -654,7 +744,7 @@ export class Chat {
654
744
  }
655
745
  if (!pane) {
656
746
  pane = new Pane(session, {
657
- api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(),
747
+ api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(), hub: this.hub,
658
748
  onPermissions: (p, rec) => this.onPermissions(p, rec),
659
749
  });
660
750
  this.panesEl.append(pane.root);
@@ -276,8 +276,9 @@ export function tallestFor(paneHeight) {
276
276
  return Math.max(AUTO.tallest, fits);
277
277
  }
278
278
 
279
- /** A session's card: one line taller while it has commands running in the background, and never wider. */
280
- export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0);
279
+ /** A session's card: one line taller for commands running in the background, one for refused agent calls. */
280
+ export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0)
281
+ + (n?.refusedCalls?.length ? SIZE.session.line : 0);
281
282
 
282
283
  /* ----------------------------------------------------------------- plan --- */
283
284
 
@@ -64,3 +64,17 @@ function clock(ms) {
64
64
  const two = (n) => String(n).padStart(2, '0');
65
65
  return `${two(d.getHours())}:${two(d.getMinutes())}`;
66
66
  }
67
+
68
+ /**
69
+ * Is the server older than the files it serves? The page is read from disk on every load; the server keeps the
70
+ * code it started with. After an update the two disagree, and a view the old server has no route for answered
71
+ * 404 (seen as "Not read — HTTP 404" on the Map view). /api/health says `code.changed` when the server's files
72
+ * changed since it started; a server with no `code` at all predates the field, and is older than this page.
73
+ * Returns the sentence to show, or null.
74
+ */
75
+ export const RESTART_NOTE = 'Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.';
76
+ export function staleServerNote(health) {
77
+ if (!health || typeof health !== 'object') return null;
78
+ if (!health.code || typeof health.code !== 'object') return RESTART_NOTE;
79
+ return health.code.changed === true ? RESTART_NOTE : null;
80
+ }
@@ -1,14 +1,24 @@
1
1
  // What the Map view draws, worked out from a crew map. No page in it.
2
2
  //
3
3
  // The map is Crewforth's (server/lib/crew-map.js reads it). This file puts each node in a band, says which lines
4
- // a selection lights, orders the findings and words them. It invents no layer: a module whose place the map does
5
- // not show goes to "Unplaced", and a finding the scanner hides by default stays hidden until asked for.
4
+ // a selection lights, orders the findings and words them. It invents no layer: a module's layer is the one /crew-map
5
+ // wrote for it (citing the lines it rests on); without one, its band is what the scan shows of it, and a module whose
6
+ // place the scan does not show goes to "Unplaced". A finding the scanner hides by default stays hidden until asked.
6
7
 
8
+ // The layers /crew-map may write (eval/lib/crew-map/narrative.mjs LAYERS), set between the bands the scan alone
9
+ // gives, so a map with no layer is drawn in the same order as before.
10
+ export const LAYER_KEYS = ['ui', 'api', 'service', 'data', 'infra', 'tool', 'library'];
7
11
  export const BANDS = [
12
+ ['ui', 'UI'],
8
13
  ['clients', 'Clients'],
14
+ ['api', 'API'],
9
15
  ['entry', 'Entry points'],
16
+ ['service', 'Service'],
10
17
  ['core', 'Core'],
18
+ ['data', 'Data'],
11
19
  ['infra', 'Infrastructure'],
20
+ ['library', 'Library'],
21
+ ['tool', 'Tools'],
12
22
  ['external', 'External'],
13
23
  ['unplaced', 'Unplaced'],
14
24
  ];
@@ -37,8 +47,10 @@ export function findingModules(f, modules) {
37
47
  * one that only calls endpoints → Clients
38
48
  * one another module of this project references → Core · one with an entry point and nothing above → Entry points
39
49
  * anything else → Unplaced, so a guess is never drawn as a fact.
50
+ * A module /crew-map gave a layer goes to that layer's band first.
40
51
  */
41
52
  export function bandOf(node, doc) {
53
+ if (node.kind === 'module' && LAYER_KEYS.includes(node.narrative?.layer)) return node.narrative.layer;
42
54
  if (node.kind === 'external') return 'external';
43
55
  if (node.kind === 'service') return 'infra';
44
56
  if (node.kind === 'client') return 'clients';
@@ -201,6 +213,52 @@ export function cardSub(node, doc) {
201
213
  return [tech || node.stack, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
202
214
  }
203
215
 
216
+ /**
217
+ * What a module's card says, as the Map draws it: a kind line (its domain, else its stack, and how many endpoints it
218
+ * provides), up to three of what it is built with, and the outside services it calls, folded into one line under it.
219
+ */
220
+ export const CARD_CHIPS = 3;
221
+ export function cardOf(node, doc) {
222
+ const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
223
+ const kind = [node.narrative?.domain ?? node.stack ?? null, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
224
+ const tech = (node.tech ?? []).map((t) => t.replace(/@.*$/, ''));
225
+ return { kind, chips: tech.slice(0, CARD_CHIPS), more: Math.max(0, tech.length - CARD_CHIPS), outside: outsideOf(node, doc) };
226
+ }
227
+
228
+ /** The outside services a module calls, by name, in the order they are named: what its card folds under it. */
229
+ export function outsideOf(node, doc) {
230
+ const byId = new Map((doc.nodes ?? []).map((n) => [n.id, n]));
231
+ const names = [];
232
+ for (const e of doc.edges ?? []) {
233
+ if (e.from !== node.id) continue;
234
+ const to = byId.get(e.to);
235
+ if (to?.kind !== 'external') continue;
236
+ const name = to.label && to.label !== 'unknown' ? to.label : (to.hosts ?? [])[0] ?? to.id.replace(/^ext:[^:]+:/, '');
237
+ if (!names.includes(name)) names.push(name);
238
+ }
239
+ return names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
240
+ }
241
+
242
+ /** The tour /crew-map wrote, the stops whose node is on this map, each with the node's name. */
243
+ export function tourOf(doc) {
244
+ const byId = new Map((doc.nodes ?? []).map((n) => [n.id, n]));
245
+ return (Array.isArray(doc.tour) ? doc.tour : []).filter((s) => s && byId.has(s.node) && typeof s.text === 'string')
246
+ .map((s) => ({ node: s.node, name: byId.get(s.node).name ?? byId.get(s.node).label ?? s.node, text: s.text, cites: (s.cites ?? []).filter((c) => typeof c === 'string') }));
247
+ }
248
+
249
+ /**
250
+ * The nodes a search names: by name, folder, what they are built with, their domain, the folders inside them and
251
+ * what their README says. Case does not matter; an empty search names none.
252
+ */
253
+ export function searchOf(query, nodes) {
254
+ const q = String(query ?? '').trim().toLowerCase();
255
+ if (!q) return new Set();
256
+ const hay = (n) => [n.name, n.label, n.path, n.stack, ...(n.tech ?? []), ...(n.hosts ?? []), n.narrative?.domain, n.narrative?.layer,
257
+ ...(n.structure?.folders ?? []).map((f) => f.folder), ...(n.about?.evidence ?? []).map((e) => e.text)]
258
+ .filter((x) => typeof x === 'string').join('\n').toLowerCase();
259
+ return new Set(nodes.filter((n) => hay(n).includes(q)).map((n) => n.id));
260
+ }
261
+
204
262
  /** The summary pills: what the map holds, and how many findings the list shows. */
205
263
  export function summaryOf(doc, findings) {
206
264
  const n = (k) => (doc.nodes ?? []).filter((x) => x.kind === k).length;
@@ -247,6 +305,16 @@ export function inspectOf(node, doc, edges, findings) {
247
305
  narrative: typeof node.narrative?.text === 'string' ? node.narrative.text : null,
248
306
  narrativeCites: Array.isArray(node.narrative?.cites) ? node.narrative.cites.filter((c) => typeof c === 'string') : [],
249
307
  narrativeNote: 'Not written yet: this map holds no description of the module, only what its files show.',
308
+ // /crew-map's layer and domain, when it wrote them; the band is the layer's (bandOf).
309
+ layer: LAYER_KEYS.includes(node.narrative?.layer) ? (BANDS.find(([k]) => k === node.narrative.layer)?.[1] ?? null) : null,
310
+ domain: typeof node.narrative?.domain === 'string' ? node.narrative.domain : null,
311
+ // What is inside it: the entry point the scan found, its folders with how many source files each holds, tests apart.
312
+ inside: node.kind === 'module' ? {
313
+ entry: node.entryPoint?.evidence?.[0] ? `${node.entryPoint.evidence[0].file}:${node.entryPoint.evidence[0].line}` : (node.entryPoint?.file ?? null),
314
+ folders: (node.structure?.folders ?? []).map((f) => ({ folder: f.folder, files: f.files })),
315
+ other: node.structure?.otherFiles ?? 0,
316
+ tests: node.structure?.testFiles ?? 0,
317
+ } : null,
250
318
  connectsTo: out,
251
319
  connections: { out, in: into },
252
320
  byKind: Object.entries(byKind).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
@@ -274,7 +342,8 @@ export function emptyNote(data) {
274
342
  return null;
275
343
  }
276
344
 
277
- export const CARD = { module: [200, 64], service: [200, 64], client: [200, 64], external: [104, 52] };
345
+ // A module card holds its name and kind, a row of what it is built with, and the line of outside services under it.
346
+ export const CARD = { module: [280, 104], service: [280, 104], client: [280, 104], group: [280, 104], external: [104, 52] };
278
347
  const GAP_X = 24;
279
348
  const GAP_Y = 20;
280
349
  const LEFT = 140; // the band's label sits in this column