crewforth 0.0.1 → 3.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +3784 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -7
  4. package/VERSION +1 -0
  5. package/adopt.sh +1670 -0
  6. package/bin/cli.js +497 -0
  7. package/kit/AGENT_TEMPLATE.md +92 -0
  8. package/kit/CLAUDE.md +228 -0
  9. package/kit/README.md +107 -0
  10. package/kit/agents/crew-backend-expert.md +95 -0
  11. package/kit/agents/crew-commit-agent.md +96 -0
  12. package/kit/agents/crew-database-expert.md +84 -0
  13. package/kit/agents/crew-devops-expert.md +82 -0
  14. package/kit/agents/crew-frontend-expert.md +85 -0
  15. package/kit/agents/crew-performance-expert.md +78 -0
  16. package/kit/agents/crew-planner.md +60 -0
  17. package/kit/agents/crew-privacy-agent.md +71 -0
  18. package/kit/agents/crew-review-agent.md +95 -0
  19. package/kit/agents/crew-security-expert.md +73 -0
  20. package/kit/agents/crew-session-manager.md +77 -0
  21. package/kit/agents/crew-test-expert.md +64 -0
  22. package/kit/eval/doctor.sh +465 -0
  23. package/kit/eval/gate-report.sh +137 -0
  24. package/kit/eval/golden-holdout.txt +53 -0
  25. package/kit/eval/golden-routing.txt +199 -0
  26. package/kit/eval/lib/crew-env.sh +14 -0
  27. package/kit/eval/lib/settings-json.awk +154 -0
  28. package/kit/eval/lib/star.sh +70 -0
  29. package/kit/eval/parser-conformance.sh +612 -0
  30. package/kit/eval/preflight.sh +186 -0
  31. package/kit/eval/routing-eval.sh +209 -0
  32. package/kit/eval/scan-skill.sh +162 -0
  33. package/kit/eval/smoke-test.sh +6693 -0
  34. package/kit/eval/update-guard.sh +81 -0
  35. package/kit/eval/utilization.sh +130 -0
  36. package/kit/hooks/board-sync.sh +106 -0
  37. package/kit/hooks/board.sh +962 -0
  38. package/kit/hooks/commit-msg +49 -0
  39. package/kit/hooks/context-usage.sh +353 -0
  40. package/kit/hooks/floor-blocklist.txt +124 -0
  41. package/kit/hooks/guard-bash.sh +1431 -0
  42. package/kit/hooks/guard-commit-scan.sh +511 -0
  43. package/kit/hooks/guard-write.sh +513 -0
  44. package/kit/hooks/pre-commit +577 -0
  45. package/kit/hooks/route-hint.sh +185 -0
  46. package/kit/hooks/secret-blocklist.txt +82 -0
  47. package/kit/hooks/session-guard.sh +149 -0
  48. package/kit/hooks/session-rehydrate.sh +41 -0
  49. package/kit/hooks/session-stats.sh +252 -0
  50. package/kit/hooks/session-update-check.sh +236 -0
  51. package/kit/hooks/skill-trust.sh +150 -0
  52. package/kit/hooks/trace-blocklist.txt +53 -0
  53. package/kit/settings.json +132 -0
  54. package/kit/skills/a11y/SKILL.md +44 -0
  55. package/kit/skills/adr/SKILL.md +74 -0
  56. package/kit/skills/api-design/SKILL.md +50 -0
  57. package/kit/skills/automode-policy/SKILL.md +91 -0
  58. package/kit/skills/automode-policy/references/policy.json +13 -0
  59. package/kit/skills/automode-policy/scripts/apply.sh +84 -0
  60. package/kit/skills/automode-policy/scripts/check.sh +102 -0
  61. package/kit/skills/backend-architecture/SKILL.md +103 -0
  62. package/kit/skills/brainstorm/SKILL.md +47 -0
  63. package/kit/skills/ci-pipeline/SKILL.md +38 -0
  64. package/kit/skills/commit-message/SKILL.md +82 -0
  65. package/kit/skills/commit-message/references/examples.md +8 -0
  66. package/kit/skills/confidence-check/SKILL.md +86 -0
  67. package/kit/skills/crew-board/SKILL.md +55 -0
  68. package/kit/skills/crew-brainstorm/SKILL.md +19 -0
  69. package/kit/skills/crew-code-review/SKILL.md +151 -0
  70. package/kit/skills/crew-code-review/references/history-search.md +19 -0
  71. package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
  72. package/kit/skills/crew-code-review/references/receiving.md +23 -0
  73. package/kit/skills/crew-doctor/SKILL.md +36 -0
  74. package/kit/skills/crew-gates/SKILL.md +25 -0
  75. package/kit/skills/crew-handoff/SKILL.md +14 -0
  76. package/kit/skills/crew-plan/SKILL.md +18 -0
  77. package/kit/skills/crew-review/SKILL.md +23 -0
  78. package/kit/skills/crew-ship/SKILL.md +19 -0
  79. package/kit/skills/crew-skill/SKILL.md +61 -0
  80. package/kit/skills/crew-studio/SKILL.md +129 -0
  81. package/kit/skills/crew-update/SKILL.md +43 -0
  82. package/kit/skills/db-migration/SKILL.md +120 -0
  83. package/kit/skills/db-migration/references/backup-restore.md +33 -0
  84. package/kit/skills/db-migration/references/raw-sql.md +30 -0
  85. package/kit/skills/db-migration/references/tool-matrix.md +35 -0
  86. package/kit/skills/dependency-audit/SKILL.md +61 -0
  87. package/kit/skills/dependency-upgrade/SKILL.md +82 -0
  88. package/kit/skills/deploy/SKILL.md +100 -0
  89. package/kit/skills/deploy/references/proxy-ssl.md +56 -0
  90. package/kit/skills/deploy/references/rollback.md +18 -0
  91. package/kit/skills/deploy/references/scripts.md +9 -0
  92. package/kit/skills/deploy/references/topology-a.md +128 -0
  93. package/kit/skills/deploy/references/topology-b.md +44 -0
  94. package/kit/skills/docs-writer/SKILL.md +53 -0
  95. package/kit/skills/docs-writer/references/coauthoring.md +34 -0
  96. package/kit/skills/eval-grader/SKILL.md +75 -0
  97. package/kit/skills/eval-grader/references/method.md +51 -0
  98. package/kit/skills/frontend/SKILL.md +56 -0
  99. package/kit/skills/frontend/references/verify-contract.md +41 -0
  100. package/kit/skills/frontend-design/SKILL.md +71 -0
  101. package/kit/skills/frontend-design/references/design-review.md +34 -0
  102. package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
  103. package/kit/skills/handoff/SKILL.md +71 -0
  104. package/kit/skills/handoff/references/cold-reader.md +51 -0
  105. package/kit/skills/i18n-integrity/SKILL.md +31 -0
  106. package/kit/skills/incident-runbook/SKILL.md +40 -0
  107. package/kit/skills/incident-runbook/references/postmortem.md +16 -0
  108. package/kit/skills/iterate/SKILL.md +40 -0
  109. package/kit/skills/mcp-builder/SKILL.md +54 -0
  110. package/kit/skills/mcp-builder/references/building.md +71 -0
  111. package/kit/skills/observability/SKILL.md +50 -0
  112. package/kit/skills/performance/SKILL.md +38 -0
  113. package/kit/skills/performance/references/bottlenecks.md +10 -0
  114. package/kit/skills/privacy-compliance/SKILL.md +75 -0
  115. package/kit/skills/red-team/SKILL.md +48 -0
  116. package/kit/skills/reflect/SKILL.md +57 -0
  117. package/kit/skills/release/SKILL.md +44 -0
  118. package/kit/skills/security-scan/SKILL.md +99 -0
  119. package/kit/skills/security-scan/references/ai-agents.md +107 -0
  120. package/kit/skills/security-scan/references/fronts.md +99 -0
  121. package/kit/skills/security-scan/references/prompting.md +23 -0
  122. package/kit/skills/security-scan/references/reporting.md +75 -0
  123. package/kit/skills/security-scan/references/verify.md +131 -0
  124. package/kit/skills/sonarqube-check/SKILL.md +108 -0
  125. package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
  126. package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
  127. package/kit/skills/spec-planning/SKILL.md +92 -0
  128. package/kit/skills/systematic-debugging/SKILL.md +57 -0
  129. package/kit/skills/systematic-debugging/references/techniques.md +44 -0
  130. package/kit/skills/teamboard/SKILL.md +88 -0
  131. package/kit/skills/teamboard/references/setup.md +37 -0
  132. package/kit/skills/testing/SKILL.md +49 -0
  133. package/kit/skills/testing/references/flaky-triage.md +51 -0
  134. package/kit/skills/threat-model/SKILL.md +57 -0
  135. package/kit/skills/threat-model/references/interview.md +51 -0
  136. package/kit/skills/threat-model/references/schema.md +42 -0
  137. package/kit/skills/token-budget/SKILL.md +51 -0
  138. package/kit/skills/trace-scan/SKILL.md +43 -0
  139. package/kit/skills/worktree/SKILL.md +40 -0
  140. package/kit/studio/README.md +266 -0
  141. package/kit/studio/ensure-node.sh +471 -0
  142. package/kit/studio/package.json +14 -0
  143. package/kit/studio/server/hooks/studio-gate.sh +169 -0
  144. package/kit/studio/server/index.js +902 -0
  145. package/kit/studio/server/lib/crew-env.js +11 -0
  146. package/kit/studio/server/lib/fleet.js +146 -0
  147. package/kit/studio/server/lib/graph.js +533 -0
  148. package/kit/studio/server/lib/instance.js +135 -0
  149. package/kit/studio/server/lib/kit-telemetry.js +182 -0
  150. package/kit/studio/server/lib/kit.js +158 -0
  151. package/kit/studio/server/lib/palette.js +118 -0
  152. package/kit/studio/server/lib/peers.js +72 -0
  153. package/kit/studio/server/lib/permissions.js +150 -0
  154. package/kit/studio/server/lib/projects.js +294 -0
  155. package/kit/studio/server/lib/roster.js +191 -0
  156. package/kit/studio/server/lib/session.js +309 -0
  157. package/kit/studio/server/lib/terminal.js +91 -0
  158. package/kit/studio/server/lib/transcript.js +100 -0
  159. package/kit/studio/web/app.js +1098 -0
  160. package/kit/studio/web/canvas.js +1179 -0
  161. package/kit/studio/web/chat.js +543 -0
  162. package/kit/studio/web/index.html +86 -0
  163. package/kit/studio/web/md.js +110 -0
  164. package/kit/studio/web/storage-migrate.js +23 -0
  165. package/kit/studio/web/style.css +1453 -0
  166. package/package.json +46 -8
  167. package/start.sh +748 -0
  168. package/bin/crewforth.js +0 -10
@@ -0,0 +1,1098 @@
1
+ // Crewforth Studio — wiring.
2
+ //
3
+ // One rule runs through the rendering: "not measured" and "nothing running"
4
+ // are different facts and never share a screen state. An empty list because
5
+ // the CLI is missing would read as a quiet, healthy machine, which is exactly
6
+ // the kind of lie this panel exists to stop telling.
7
+
8
+ import { migrateStorage } from './storage-migrate.js';
9
+ import { Canvas } from './canvas.js';
10
+ import { renderMarkdown } from './md.js';
11
+ import { Chat } from './chat.js';
12
+
13
+ const FLEET_POLL_MS = 2000;
14
+ const SESSION_POLL_MS = 5000;
15
+
16
+ const el = {
17
+ fleet: document.getElementById('fleet'),
18
+ fleetMeta: document.getElementById('fleet-meta'),
19
+ sessions: document.getElementById('sessions'),
20
+ filter: document.getElementById('filter'),
21
+ kit: document.getElementById('kitline'),
22
+ reach: document.getElementById('reach'),
23
+ reachHead: document.getElementById('reach-head'),
24
+ reachMeta: document.getElementById('reach-meta'),
25
+ chat: document.getElementById('chat'),
26
+ chatSplit: document.getElementById('chat-split'),
27
+ resizer: document.getElementById('resizer'),
28
+ sideWide: document.getElementById('side-wide'),
29
+ sideHide: document.getElementById('side-hide'),
30
+ sideShow: document.getElementById('side-show'),
31
+ fullscreen: document.getElementById('fullscreen'),
32
+ newSession: document.getElementById('new-session'),
33
+ continueSession: document.getElementById('continue-session'),
34
+ sessionsMeta: document.getElementById('sessions-meta'),
35
+ summary: document.getElementById('graph-summary'),
36
+ pulse: document.getElementById('pulse'),
37
+ foot: document.getElementById('foot-note'),
38
+ theme: document.getElementById('theme'),
39
+ inspector: document.getElementById('inspector'),
40
+ };
41
+
42
+ const token = new URLSearchParams(location.search).get('token');
43
+ const api = (p) => (token ? `${p}${p.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : p);
44
+ const getJson = async (p) => {
45
+ const r = await fetch(api(p), { cache: 'no-store' });
46
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
47
+ return r.json();
48
+ };
49
+
50
+ /* ---------------------------------------------------------------- theme */
51
+
52
+ try { migrateStorage(localStorage); } catch { /* blocked storage */ }
53
+ const store = {
54
+ get(k) { try { return localStorage.getItem(k); } catch { return null; } },
55
+ set(k, v) { try { localStorage.setItem(k, v); } catch { /* blocked storage */ } },
56
+ };
57
+
58
+ const savedTheme = store.get('crewforth-studio-theme');
59
+ if (savedTheme === 'light' || savedTheme === 'dark') document.documentElement.dataset.theme = savedTheme;
60
+
61
+ el.theme.addEventListener('click', () => {
62
+ const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
63
+ document.documentElement.dataset.theme = next;
64
+ store.set('crewforth-studio-theme', next);
65
+ });
66
+
67
+ /* --------------------------------------------------------------- panels
68
+ Three columns and two dividers. Each width is the user's, remembered, and
69
+ clamped so neither side panel can be dragged to nothing by accident or made
70
+ to swallow the graph. Collapsing is a separate, deliberate act with its own
71
+ control. */
72
+
73
+ const PANEL = {
74
+ side: { min: 170, max: 620, def: 260, wide: 460, varName: '--side-w', key: 'crewforth-studio-side-w' },
75
+ chat: { min: 280, max: 900, def: 420, wide: 720, varName: '--chat-w', key: 'crewforth-studio-chat-w' },
76
+ };
77
+
78
+ function setPanel(which, px, persist = true) {
79
+ const p = PANEL[which];
80
+ const w = Math.round(Math.min(p.max, Math.max(p.min, px)));
81
+ document.documentElement.style.setProperty(p.varName, `${w}px`);
82
+ if (persist) store.set(p.key, String(w));
83
+ return w;
84
+ }
85
+
86
+ for (const which of ['side', 'chat']) {
87
+ setPanel(which, Number(store.get(PANEL[which].key)) || PANEL[which].def, false);
88
+ }
89
+
90
+ const shell = document.querySelector('.shell');
91
+
92
+ // `refit` is off for the first call, which restores the remembered state before
93
+ // the canvas exists. `typeof canvas` is not a guard here: a const in its
94
+ // temporal dead zone throws on typeof too, which is what took the whole page
95
+ // down rather than skipping one re-fit.
96
+ function setSideHidden(hidden, refit = true) {
97
+ shell.classList.toggle('no-side', hidden);
98
+ el.sideShow.hidden = !hidden;
99
+ store.set('crewforth-studio-side-hidden', hidden ? '1' : '0');
100
+ if (refit) canvas.fitIfUntouched();
101
+ }
102
+ setSideHidden(store.get('crewforth-studio-side-hidden') === '1', false);
103
+
104
+ function dragPanel(handle, which, edge) {
105
+ let drag = null;
106
+ handle.addEventListener('pointerdown', (e) => {
107
+ if (e.button !== 0) return;
108
+ drag = { px: e.clientX, w: parseInt(getComputedStyle(document.documentElement).getPropertyValue(PANEL[which].varName), 10) || PANEL[which].def };
109
+ handle.setPointerCapture(e.pointerId);
110
+ handle.classList.add('dragging');
111
+ document.body.classList.add('resizing');
112
+ });
113
+ handle.addEventListener('pointermove', (e) => {
114
+ if (!drag) return;
115
+ // The right-hand panel grows as the pointer moves left, so its delta is
116
+ // inverted. Sharing one handler without this made the conversation shrink
117
+ // when it was dragged open.
118
+ const delta = (e.clientX - drag.px) * (edge === 'right' ? -1 : 1);
119
+ setPanel(which, drag.w + delta);
120
+ });
121
+ const stop = () => {
122
+ if (!drag) return;
123
+ drag = null;
124
+ handle.classList.remove('dragging');
125
+ document.body.classList.remove('resizing');
126
+ canvas.fitIfUntouched();
127
+ };
128
+ handle.addEventListener('pointerup', stop);
129
+ handle.addEventListener('pointercancel', stop);
130
+ handle.addEventListener('dblclick', () => { setPanel(which, PANEL[which].def); canvas.fitIfUntouched(); });
131
+ handle.addEventListener('keydown', (e) => {
132
+ const cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue(PANEL[which].varName), 10) || PANEL[which].def;
133
+ const step = edge === 'right' ? -16 : 16;
134
+ if (e.key === 'ArrowLeft') { setPanel(which, cur + step); e.preventDefault(); }
135
+ if (e.key === 'ArrowRight') { setPanel(which, cur - step); e.preventDefault(); }
136
+ });
137
+ }
138
+
139
+ dragPanel(el.resizer, 'side', 'left');
140
+ dragPanel(el.chatSplit, 'chat', 'right');
141
+
142
+ // Widen toggles between the default and a roomier width rather than growing
143
+ // without end: a panel you have to drag back is not a convenience.
144
+ function wideToggle(btn, which) {
145
+ btn.addEventListener('click', () => {
146
+ const cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue(PANEL[which].varName), 10) || PANEL[which].def;
147
+ setPanel(which, Math.abs(cur - PANEL[which].wide) < 24 ? PANEL[which].def : PANEL[which].wide);
148
+ canvas.fitIfUntouched();
149
+ });
150
+ }
151
+ wideToggle(el.sideWide, 'side');
152
+
153
+ /* ------------------------------------------------------------ full screen
154
+ The browser's own, so it hides the browser too — a panel meant to be watched
155
+ while work runs should be able to take the whole display. Collapsing the side
156
+ panels is a separate control on purpose: the two compose, and folding one
157
+ into the other would make each less predictable. */
158
+
159
+ async function toggleFullscreen() {
160
+ try {
161
+ if (document.fullscreenElement) await document.exitFullscreen();
162
+ else await document.documentElement.requestFullscreen();
163
+ } catch (e) {
164
+ // Denied by policy, or unsupported. Say so rather than appear inert.
165
+ el.foot.textContent = `full screen refused: ${e?.message ?? e}`;
166
+ }
167
+ }
168
+
169
+ el.fullscreen.addEventListener('click', toggleFullscreen);
170
+
171
+ document.addEventListener('fullscreenchange', () => {
172
+ const on = Boolean(document.fullscreenElement);
173
+ el.fullscreen.textContent = on ? '⛶' : '⛶';
174
+ el.fullscreen.classList.toggle('on', on);
175
+ el.fullscreen.title = on ? 'Leave full screen (f or Esc)' : 'Full screen (f)';
176
+ canvas.fitIfUntouched();
177
+ });
178
+
179
+ // `f` toggles it, unless something is being typed into.
180
+ document.addEventListener('keydown', (e) => {
181
+ if (e.key !== 'f' || e.metaKey || e.ctrlKey || e.altKey) return;
182
+ const t = e.target;
183
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
184
+ e.preventDefault();
185
+ toggleFullscreen();
186
+ });
187
+
188
+ el.sideHide.addEventListener('click', () => setSideHidden(true));
189
+ el.sideShow.addEventListener('click', () => setSideHidden(false));
190
+
191
+ /* ------------------------------------------------------ sidebar sections
192
+ Fleet, peers and Projects each fold independently, so a machine with many
193
+ projects can hide the fleet and vice versa. The choice is per-browser and
194
+ survives a reload; nothing about it reaches the server. */
195
+
196
+ function setFold(key, folded) {
197
+ for (const body of document.querySelectorAll(`[data-fold-body="${key}"]`)) {
198
+ body.classList.toggle('is-folded', folded);
199
+ }
200
+ for (const t of document.querySelectorAll(`[data-fold="${key}"]`)) {
201
+ t.setAttribute('aria-expanded', String(!folded));
202
+ t.textContent = folded ? '▸' : '▾';
203
+ const name = t.getAttribute('aria-label')?.replace(/^(Collapse|Expand) /, '') ?? key;
204
+ t.setAttribute('aria-label', `${folded ? 'Expand' : 'Collapse'} ${name}`);
205
+ }
206
+ // A folded Projects list must stop claiming the leftover height, or the
207
+ // sidebar keeps a tall empty gap where the tree used to be.
208
+ const sect = document.querySelector(`.side-block[data-sect="${key}"]`);
209
+ if (sect) sect.classList.toggle('folded', folded);
210
+ try {
211
+ localStorage.setItem(`crewforth-studio-fold-${key}`, folded ? '1' : '0');
212
+ } catch { /* private mode: the fold still works, it just is not remembered */ }
213
+ }
214
+
215
+ function foldState(key) {
216
+ try {
217
+ return localStorage.getItem(`crewforth-studio-fold-${key}`) === '1';
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ for (const t of document.querySelectorAll('[data-fold]')) {
224
+ const key = t.getAttribute('data-fold');
225
+ t.addEventListener('click', (e) => {
226
+ e.stopPropagation();
227
+ setFold(key, t.getAttribute('aria-expanded') !== 'false');
228
+ });
229
+ setFold(key, foldState(key));
230
+ }
231
+
232
+ // The heading is the bigger target, so it toggles too — but only the heading:
233
+ // the meta count and the pin buttons beside it keep their own behaviour.
234
+ for (const h of document.querySelectorAll('[data-fold-for]')) {
235
+ h.addEventListener('click', () => {
236
+ const key = h.getAttribute('data-fold-for');
237
+ const t = document.querySelector(`[data-fold="${key}"]`);
238
+ setFold(key, t?.getAttribute('aria-expanded') !== 'false');
239
+ });
240
+ }
241
+
242
+ /* ----------------------------------------------------------------- chat
243
+ Write endpoints need the token and a header that a cross-origin page cannot
244
+ attach without a preflight this server never answers. */
245
+
246
+ const writeHeaders = { 'x-crew-studio': '1', ...(token ? { authorization: `Bearer ${token}` } : {}) };
247
+ const chat = new Chat(el.chat, { api, headers: writeHeaders });
248
+
249
+ const ownedIds = new Set();
250
+
251
+ // Switching tabs points the canvas at that session too: the graph and the
252
+ // conversation are two views of one thing.
253
+ // The conversation's own widen control lives in its header, beside the tabs.
254
+ chat.onGrow = () => {
255
+ const cur = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--chat-w'), 10) || PANEL.chat.def;
256
+ setPanel('chat', Math.abs(cur - PANEL.chat.wide) < 24 ? PANEL.chat.def : PANEL.chat.wide);
257
+ canvas.fitIfUntouched();
258
+ };
259
+
260
+ chat.onActivate = (sessionId) => {
261
+ const any = chat.ids.length > 0;
262
+ el.chat.hidden = !any;
263
+ el.chatSplit.hidden = !any; shell.classList.toggle('no-chat', !any);
264
+ // Activating a tab is not a claim of ownership. It used to add the id here,
265
+ // which meant opening a read-only conversation marked that session as one the
266
+ // panel had started — and every later attempt to open it went down the owned
267
+ // path, got a 404, and gave up without saying anything.
268
+ if (sessionId) selectSession(sessionId);
269
+ };
270
+
271
+ /** Bring a session's conversation forward, however the panel can reach it. */
272
+ function openConversation(sessionId) {
273
+ if (chat.activeId === sessionId) return;
274
+ if (ownedIds.has(sessionId)) { openOwned(sessionId); return; }
275
+ openReadOnlyPane(sessionId);
276
+ }
277
+
278
+ function openReadOnlyPane(sessionId) {
279
+ const row = el.sessions.querySelector(`.srow[data-id="${CSS.escape(sessionId)}"] .sname`);
280
+ chat.openReadOnly(sessionId, row?.textContent ?? null);
281
+ el.chat.hidden = false;
282
+ el.chatSplit.hidden = false;
283
+ shell.classList.remove('no-chat');
284
+ }
285
+
286
+ async function openOwned(sessionId) {
287
+ if (chat.panes.has(sessionId)) { chat.activate(sessionId); return true; }
288
+ try {
289
+ const r = await getJson(`/api/owned/${encodeURIComponent(sessionId)}`);
290
+ if (!r.session) throw new Error('not an owned session');
291
+ chat.open(r.session);
292
+ return true;
293
+ } catch {
294
+ // Reaped, or never ours. Either way the conversation is still readable, and
295
+ // falling through to that beats leaving the panel blank with no reason.
296
+ ownedIds.delete(sessionId);
297
+ openReadOnlyPane(sessionId);
298
+ return false;
299
+ }
300
+ }
301
+
302
+ // Continuing a conversation the panel did not start. It forks rather than
303
+ // writing into the original transcript, so a session still open in a terminal
304
+ // somewhere is not being written to by two things at once.
305
+ el.continueSession.addEventListener('click', async () => {
306
+ const from = current;
307
+ if (!from) return;
308
+ el.continueSession.disabled = true;
309
+ el.continueSession.textContent = 'continuing…';
310
+ try {
311
+ const r = await chat.start({ cwd: projectsData?.cwd ?? null, permissionMode: 'plan', resume: from });
312
+ if (!r.ok) {
313
+ el.foot.textContent = `could not continue: ${r.reason}`;
314
+ } else {
315
+ ownedIds.add(r.session.sessionId);
316
+ // Two genuinely different outcomes, so say which one happened rather
317
+ // than printing a sentence that is true either way.
318
+ el.foot.textContent = r.session.forked
319
+ ? `${from.slice(0, 8)} is open in another process, so this is a copy — `
320
+ + `messages here do not reach it.`
321
+ : `continued ${from.slice(0, 8)} itself — same session, same transcript.`;
322
+ }
323
+ } finally {
324
+ el.continueSession.disabled = false;
325
+ el.continueSession.textContent = '⑂ fork & continue';
326
+ }
327
+ });
328
+
329
+ el.newSession.addEventListener('click', async () => {
330
+ const cwd = projectsData?.cwd ?? null;
331
+ el.newSession.disabled = true;
332
+ el.newSession.textContent = 'starting…';
333
+ try {
334
+ // `plan` by default: a panel that can start a session must not also be the
335
+ // reason one got write access nobody asked for.
336
+ const r = await chat.start({ cwd, permissionMode: 'plan' });
337
+ if (!r.ok) el.foot.textContent = `could not start a session: ${r.reason}`;
338
+ else ownedIds.add(r.session.sessionId);
339
+ } finally {
340
+ el.newSession.disabled = false;
341
+ el.newSession.textContent = '+ session';
342
+ }
343
+ });
344
+
345
+ /* --------------------------------------------------------------- canvas */
346
+
347
+ const canvas = new Canvas(document.getElementById('canvas'), { onSelect: showInspector });
348
+
349
+ getJson('/api/palette')
350
+ .then((p) => {
351
+ canvas.setPalette(p);
352
+ // Twelve unrecognised agents and zero agents read as the same grey. Which
353
+ // one it is has to be said out loud, or the panel is drawing "not measured"
354
+ // as a fact about the agents.
355
+ if (p && p.measured === false) {
356
+ document.body.dataset.paletteMeasured = 'false';
357
+ el.foot.textContent = `agent colours Not measured — ${p.reason}`;
358
+ }
359
+ })
360
+ .catch(() => { /* neutral colours; the canvas already defaults safely */ });
361
+
362
+ let inspectorTab = 'report';
363
+ let inspectorNode = null;
364
+ let detailCache = new Map();
365
+
366
+ function showInspector(node) {
367
+ inspectorNode = node;
368
+ // The session node IS the conversation. Clicking it opened a details panel
369
+ // and nothing else, which asked the reader to go and find the talking
370
+ // elsewhere.
371
+ if (node?.kind === 'session' && current) openConversation(current);
372
+ if (!node) { el.inspector.hidden = true; el.inspector.classList.remove('wide'); return; }
373
+ el.inspector.hidden = false;
374
+ inspectorTab = node.kind === 'session' ? 'meta' : 'report';
375
+ paintInspector();
376
+ if (node.kind === 'agent') loadDetail(node.id, node.status);
377
+ if (node.kind === 'session' && current) loadKit(current);
378
+ }
379
+
380
+ // Keyed by status as well as id: a report fetched while the agent was still
381
+ // running says "no report yet", and that answer must not outlive the run.
382
+ // Failures are not cached at all — a network blip should not permanently hide
383
+ // a report behind a stale error.
384
+ async function loadDetail(agentId, status) {
385
+ const key = `${agentId}:${status ?? '?'}`;
386
+ if (detailCache.has(key)) return;
387
+ detailCache.set(key, { loading: true });
388
+ paintInspector();
389
+ try {
390
+ const d = await getJson(`/api/session/${encodeURIComponent(current)}/agent/${encodeURIComponent(agentId)}`);
391
+ detailCache.set(key, d);
392
+ } catch (e) {
393
+ detailCache.delete(key);
394
+ detailCache.set(key, { measured: false, reason: e.message, transient: true });
395
+ }
396
+ if (inspectorNode?.id === agentId) paintInspector();
397
+ }
398
+
399
+ function metaRows(n) {
400
+ const rows = [];
401
+ const row = (k, v) => { if (v != null && v !== '') rows.push([k, String(v)]); };
402
+ if (n.kind === 'session') {
403
+ row('session', n.sessionId); row('cwd', n.cwd); row('branch', n.gitBranch);
404
+ row('model', n.model); row('turns', n.turns);
405
+ row('context', n.tokens?.toLocaleString()); row('cli', n.version);
406
+ } else {
407
+ row('type', n.agentType ?? 'unknown'); row('status', n.status);
408
+ row('depth', n.spawnDepth); row('model', n.model);
409
+ row('turns', n.turns); row('tool calls', n.toolCount); row('last tool', n.lastTool);
410
+ row('tokens', n.tokens?.toLocaleString());
411
+ row('duration', n.durationMs != null ? fmtDur(n.durationMs) : null);
412
+ row('errors', n.errors || null);
413
+ row('agent id', n.id);
414
+ }
415
+ const dl = document.createElement('dl');
416
+ for (const [k, v] of rows) {
417
+ const dt = document.createElement('dt'); dt.textContent = k;
418
+ const dd = document.createElement('dd'); dd.textContent = v;
419
+ dl.append(dt, dd);
420
+ }
421
+ return dl;
422
+ }
423
+
424
+ function fmtDur(ms) {
425
+ const sec = Math.round(ms / 1000);
426
+ return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m ${sec % 60}s`;
427
+ }
428
+
429
+ function paintInspector() {
430
+ const n = inspectorNode;
431
+ if (!n) return;
432
+ const detail = n.kind === 'agent' ? detailCache.get(`${n.id}:${n.status ?? '?'}`) : null;
433
+
434
+ const head = document.createElement('div');
435
+ head.className = 'ihead';
436
+ const h3 = document.createElement('h3');
437
+ h3.textContent = n.kind === 'session' ? 'Session' : (n.agentType ?? 'Agent');
438
+ const chip = node('span', 'cv-chip', n.kind === 'session' ? `${n.turns ?? 0} turns` : (n.status ?? '?'));
439
+ chip.dataset.status = n.status ?? 'unknown';
440
+ const wide = node('button', 'ghost iwide', el.inspector.classList.contains('wide') ? '›' : '‹');
441
+ wide.title = 'Widen';
442
+ wide.addEventListener('click', () => { el.inspector.classList.toggle('wide'); paintInspector(); });
443
+ // The inspector floats over the graph, so it needs a way out that is not
444
+ // "click the node again and hope you hit it".
445
+ const close = node('button', 'ghost iwide', '×');
446
+ close.title = 'Close';
447
+ close.addEventListener('click', () => { canvas.clearSelection(); showInspector(null); });
448
+ head.append(h3, chip, wide, close);
449
+
450
+ const sub = node('div', 'isub', n.description ?? n.cwd ?? '');
451
+
452
+ const tabs = document.createElement('div');
453
+ tabs.className = 'itabs';
454
+ const available = n.kind === 'session'
455
+ ? ['meta', 'gates', 'stats', 'board']
456
+ : ['report', 'activity', 'prompt', 'meta'];
457
+ for (const t of available) {
458
+ const b = node('button', `itab${inspectorTab === t ? ' on' : ''}`, t);
459
+ b.addEventListener('click', () => { inspectorTab = t; paintInspector(); });
460
+ tabs.append(b);
461
+ }
462
+
463
+ const body = document.createElement('div');
464
+ body.className = 'ibody';
465
+
466
+ if (['gates', 'stats', 'board'].includes(inspectorTab)) {
467
+ paintKit(body, inspectorTab);
468
+ } else if (inspectorTab === 'meta') {
469
+ body.append(metaRows(n));
470
+ const tools = Object.entries(n.tools ?? {});
471
+ if (tools.length) {
472
+ const wrap = node('div', 'tools');
473
+ for (const [name, c] of tools.sort((a, b) => b[1] - a[1])) wrap.append(node('span', 'cv-bit', `${name} ×${c}`));
474
+ body.append(wrap);
475
+ }
476
+ } else if (!detail) {
477
+ body.append(node('div', 'ihint', 'Loading…'));
478
+ } else if (detail.loading) {
479
+ body.append(node('div', 'ihint', 'Reading the agent transcript…'));
480
+ } else if (detail.measured === false) {
481
+ const hint = node('div', 'ihint', `Not measured — ${detail.reason}`);
482
+ if (detail.transient) {
483
+ const retry = node('button', 'ghost', 'retry');
484
+ retry.addEventListener('click', () => {
485
+ detailCache.delete(`${n.id}:${n.status ?? '?'}`);
486
+ loadDetail(n.id, n.status);
487
+ });
488
+ hint.append(' ', retry);
489
+ }
490
+ body.append(hint);
491
+ } else if (inspectorTab === 'report') {
492
+ if (detail.report) {
493
+ const bar = node('div', 'ibar');
494
+ bar.append(node('span', 'meta', `${detail.report.length.toLocaleString()} chars`));
495
+ const copy = node('button', 'ghost', 'copy');
496
+ copy.addEventListener('click', async () => {
497
+ try { await navigator.clipboard.writeText(detail.report); copy.textContent = 'copied'; }
498
+ catch { copy.textContent = 'blocked'; }
499
+ setTimeout(() => { copy.textContent = 'copy'; }, 1400);
500
+ });
501
+ bar.append(copy);
502
+ const md = node('div', 'md');
503
+ md.innerHTML = renderMarkdown(detail.report);
504
+ body.append(bar, md);
505
+ } else {
506
+ // An agent still working has narration but no conclusion. Showing the
507
+ // narration as a report would be inventing a result it never gave.
508
+ body.append(node('div', 'ihint',
509
+ n.status === 'running'
510
+ ? 'Still working — no report yet. What it has said so far is under Activity.'
511
+ : 'This agent produced no closing report.'));
512
+ }
513
+ } else if (inspectorTab === 'activity') {
514
+ const list = node('div', 'itimeline');
515
+ if (!detail.timeline?.length) list.append(node('div', 'ihint', 'No tool calls recorded.'));
516
+ for (const [i, step] of (detail.timeline ?? []).entries()) {
517
+ const r = node('div', 'istep');
518
+ r.append(node('span', 'istep-n', String(i + 1)));
519
+ r.append(node('span', 'istep-tool', step.name));
520
+ r.append(node('span', 'istep-label', step.label ?? ''));
521
+ list.append(r);
522
+ }
523
+ if (detail.narration?.length) {
524
+ list.append(node('div', 'ihint', `${detail.narration.length} progress note(s)`));
525
+ for (const t of detail.narration) {
526
+ const md = node('div', 'md md-narration');
527
+ md.innerHTML = renderMarkdown(t);
528
+ list.append(md);
529
+ }
530
+ }
531
+ body.append(list);
532
+ } else if (inspectorTab === 'prompt') {
533
+ const md = node('div', 'md');
534
+ md.innerHTML = renderMarkdown(detail.prompt ?? '');
535
+ body.append(detail.prompt ? md : node('div', 'ihint', 'No prompt recorded.'));
536
+ }
537
+
538
+ el.inspector.replaceChildren(head, sub, tabs, body);
539
+ }
540
+
541
+ /* ------------------------------------------------------------------ kit
542
+ What Crewforth already measures about itself. Nothing here is recomputed —
543
+ each panel shows what Crewforth's own tool said, including when it said it
544
+ could not answer. */
545
+
546
+ let kitData = null;
547
+ let kitFor = null;
548
+
549
+ async function loadKit(sessionId) {
550
+ if (kitFor === sessionId && kitData) return;
551
+ kitFor = sessionId;
552
+ kitData = null;
553
+ try {
554
+ kitData = await getJson(`/api/kit?session=${encodeURIComponent(sessionId)}`);
555
+ } catch (e) {
556
+ kitData = { measured: false, reason: e.message };
557
+ }
558
+ if (inspectorNode?.kind === 'session') paintInspector();
559
+ }
560
+
561
+ function unmeasured(reason) {
562
+ const d = node('div', 'ihint');
563
+ d.append(node('strong', null, 'Not measured'));
564
+ d.append(node('div', null, reason || 'no reason given'));
565
+ d.append(node('div', 'why', 'Which is not the same as nothing having happened.'));
566
+ return d;
567
+ }
568
+
569
+ function paintKit(body, tab) {
570
+ if (!kitData) { body.append(node('div', 'ihint', 'Reading Crewforth…')); return; }
571
+
572
+ if (tab === 'stats') {
573
+ const s = kitData.stats;
574
+ if (!s?.measured) { body.append(unmeasured(s?.reason)); return; }
575
+ const dl = document.createElement('dl');
576
+ for (const [k, v] of Object.entries(s.metrics)) {
577
+ const dt = node('dt', null, k.replace(/_/g, ' '));
578
+ const dd = node('dd', null, v.toLocaleString());
579
+ if (v > 0 && /runaway|errors|interrupts/.test(k)) dd.classList.add('bad');
580
+ dl.append(dt, dd);
581
+ }
582
+ body.append(dl);
583
+ body.append(node('div', 'ihint', 'session-stats.sh --raw, over this transcript.'));
584
+ return;
585
+ }
586
+
587
+ if (tab === 'board') {
588
+ const b = kitData.board;
589
+ if (!b?.measured) { body.append(unmeasured(b?.reason)); return; }
590
+ if (!b.present) {
591
+ body.append(node('div', 'ihint', b.text));
592
+ return;
593
+ }
594
+ body.append(node('pre', 'md-code', b.text));
595
+ return;
596
+ }
597
+
598
+ // gates
599
+ const log = kitData.log;
600
+ const rep = kitData.report;
601
+
602
+ if (rep?.measured) {
603
+ const head = node('div', 'kit-sum');
604
+ head.append(node('span', 'cv-bit', `${rep.rules} rules`));
605
+ head.append(node('span', 'cv-bit', `${(rep.decisions ?? 0).toLocaleString()} decisions`));
606
+ body.append(head);
607
+ } else {
608
+ body.append(unmeasured(rep?.reason));
609
+ }
610
+
611
+ // Owned sessions carry the moment each gate ran; the log does not.
612
+ const owned = chat.panes.get(current);
613
+ const live = owned?.session?.gateEvents ?? [];
614
+ if (live.length) {
615
+ body.append(node('h4', 'md-h', 'Happened'));
616
+ for (const e of live.slice(-12).reverse()) {
617
+ const row = node('div', 'gate-row');
618
+ row.append(node('span', 'gate-when', new Date(e.at).toLocaleTimeString()));
619
+ row.append(node('span', 'gate-name', e.name ?? e.event ?? 'hook'));
620
+ const v = node('span', 'gate-verdict', e.phase === 'started' ? 'ran' : (e.exitCode === 2 ? 'blocked' : e.outcome ?? 'done'));
621
+ v.dataset.verdict = e.exitCode === 2 ? 'BLOCK' : 'ALLOW';
622
+ row.append(v);
623
+ body.append(row);
624
+ }
625
+ }
626
+
627
+ if (!log?.measured) { body.append(unmeasured(log?.reason)); return; }
628
+
629
+ const h = node('h4', 'md-h', 'Observed');
630
+ body.append(h);
631
+ // The distinction is the point: this file has no timestamp column, so these
632
+ // are decisions found in the log, not decisions seen happening.
633
+ body.append(node('div', 'ihint',
634
+ `${log.total.toLocaleString()} in the tail of gate-log.tsv${log.truncated ? ' (truncated)' : ''} · `
635
+ + 'no timestamps in this format, so these are what the log holds, not when they ran'
636
+ + (log.commandsRecorded ? '' : ' · commands not recorded (CREW_GATE_LOG_CMD=1 records them)')));
637
+
638
+ const counts = node('div', 'kit-sum');
639
+ for (const [k, v] of Object.entries(log.counts ?? {})) {
640
+ const b = node('span', 'cv-bit', `${v.toLocaleString()} ${k}`);
641
+ b.dataset.verdict = k;
642
+ counts.append(b);
643
+ }
644
+ body.append(counts);
645
+
646
+ for (const e of (log.entries ?? []).slice(0, 40)) {
647
+ const row = node('div', 'gate-row');
648
+ const v = node('span', 'gate-verdict', e.verdict);
649
+ v.dataset.verdict = e.verdict;
650
+ row.append(v);
651
+ row.append(node('span', 'gate-name', e.rule ?? '—'));
652
+ if (e.section) row.append(node('span', 'gate-sec', e.section));
653
+ body.append(row);
654
+ }
655
+ }
656
+
657
+ /* ---------------------------------------------------------------- fleet */
658
+
659
+ function node(tag, cls, text) {
660
+ const n = document.createElement(tag);
661
+ if (cls) n.className = cls;
662
+ if (text != null) n.textContent = text;
663
+ return n;
664
+ }
665
+
666
+ function renderNote(host, { kind, title, body, why }) {
667
+ const wrap = node('div', `note${kind ? ` ${kind}` : ''}`);
668
+ wrap.append(node('strong', null, title));
669
+ if (body) wrap.append(node('div', null, body));
670
+ if (why) wrap.append(node('div', 'why', why));
671
+ host.replaceChildren(wrap);
672
+ }
673
+
674
+ function shortPath(p, max = 34) {
675
+ if (!p) return '';
676
+ const home = p.match(/^\/(Users|home)\/[^/]+/);
677
+ const s = home ? `~${p.slice(home[0].length)}` : p;
678
+ if (s.length <= max) return s;
679
+ const keep = Math.floor((max - 1) / 2);
680
+ return `${s.slice(0, keep)}…${s.slice(-keep)}`;
681
+ }
682
+
683
+ // Sessions on other machines. The panel cannot reach them and does not pretend
684
+ // to: their transcripts are on those machines' disks, so there is nothing to
685
+ // draw but the roster itself, and even that is a snapshot rather than a feed.
686
+ function renderReach(roster) {
687
+ if (!roster) { el.reachHead.hidden = true; el.reach.replaceChildren(); return; }
688
+
689
+ const remote = (roster.peers ?? []).filter((p) => p.remote);
690
+ if (!roster.measured || !remote.length) {
691
+ // A roster nobody has recorded is not "no peers". Say which it is.
692
+ el.reachHead.hidden = false;
693
+ el.reachMeta.textContent = roster.measured ? '0' : 'not measured';
694
+ el.reachMeta.className = 'meta warn';
695
+ renderNote(el.reach, {
696
+ kind: roster.measured ? '' : 'unmeasured',
697
+ title: roster.measured ? 'None reachable' : 'Not measured',
698
+ body: roster.measured
699
+ ? 'A connected session looked and found no machines besides this one.'
700
+ : 'No session here has recorded a peer list yet.',
701
+ why: roster.measured ? null : roster.reason,
702
+ });
703
+ return;
704
+ }
705
+
706
+ el.reachHead.hidden = false;
707
+ const when = new Date(roster.seenAt).toLocaleTimeString();
708
+ el.reachMeta.textContent = `${remote.length} · seen ${when}`;
709
+ el.reachMeta.className = 'meta';
710
+ el.reachMeta.title =
711
+ `Recorded by session ${String(roster.seenBy).slice(0, 8)} at ${when}. `
712
+ + 'Only a session connected to Remote Control can see these, so this is what one last reported — not a live feed.';
713
+
714
+ el.reach.replaceChildren(...remote.map((p) => {
715
+ const row = node('div', 'session reach-row');
716
+ const ring = node('span', 'ring');
717
+ ring.dataset.status = p.status === 'running' ? 'busy' : (p.status === 'offline' ? 'unknown' : 'idle');
718
+ ring.title = p.status ?? '';
719
+
720
+ const who = node('div', 'who');
721
+ const nm = node('div', 'name');
722
+ nm.append(document.createTextNode(p.name));
723
+ nm.append(node('span', 'origin', 'remote'));
724
+ who.append(nm);
725
+ who.append(node('div', 'path', p.note || p.kind || ''));
726
+
727
+ const stat = node('div', 'stat');
728
+ stat.append(node('span', 'status', p.status ?? '?'));
729
+ row.append(ring, who, stat);
730
+ // There is nothing to open: the transcript is on that machine.
731
+ row.title = `${p.name} [${p.ref}] — on another machine. Its transcript lives there, so the panel can list it but not draw it.`;
732
+ return row;
733
+ }));
734
+ }
735
+
736
+ function renderFleet(data) {
737
+ if (!data.measured) {
738
+ el.fleetMeta.textContent = 'not measured';
739
+ renderNote(el.fleet, {
740
+ kind: 'unmeasured',
741
+ title: 'Not measured',
742
+ body: 'This is not the same as "nothing is running" — nothing was read.',
743
+ why: data.reason || 'no reason reported',
744
+ });
745
+ return;
746
+ }
747
+
748
+ const sessions = data.sessions ?? [];
749
+ const origins = data.origins ?? [];
750
+ const down = origins.filter((o) => o.ok === false);
751
+ el.fleetMeta.textContent = origins.length > 1
752
+ ? `${sessions.length} · ${origins.length - down.length}/${origins.length} machines`
753
+ : `${sessions.length}`;
754
+ el.fleetMeta.className = down.length ? 'meta warn' : 'meta';
755
+ el.fleetMeta.title = origins
756
+ .map((o) => `${o.name}${o.local ? ' (this machine)' : ''}: ${o.ok === false ? `unreachable — ${o.reason}` : 'ok'}`)
757
+ .join('\n');
758
+
759
+ if (!sessions.length) {
760
+ renderNote(el.fleet, { title: 'No sessions running', body: 'Measured — the machine has none open.' });
761
+ return;
762
+ }
763
+
764
+ const order = { busy: 0, waiting: 1, idle: 2 };
765
+ sessions.sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
766
+
767
+ el.fleet.replaceChildren(...sessions.map((s) => {
768
+ const row = node('div', 'session fleet-row');
769
+ const ring = node('span', 'ring');
770
+ ring.dataset.status = ['busy', 'waiting', 'idle'].includes(s.status) ? s.status : 'unknown';
771
+ ring.title = s.status;
772
+
773
+ const who = node('div', 'who');
774
+ const nm = node('div', 'name');
775
+ nm.append(document.createTextNode(s.name || s.sessionId.slice(0, 8)));
776
+ if (s.origin && s.local === false) nm.append(node('span', 'origin', s.origin));
777
+ who.append(nm);
778
+ who.append(node('div', 'path', shortPath(s.cwd) || '—'));
779
+ if (s.waitingFor) who.append(node('div', 'waiting-for', `⏸ ${s.waitingFor}`));
780
+
781
+ const stat = node('div', 'stat');
782
+ stat.append(node('span', 'status', s.status));
783
+ row.append(ring, who, stat);
784
+
785
+ // A live session in the fleet is the one most likely to be wanted, so
786
+ // clicking it does what clicking it anywhere else does: opens it.
787
+ if (s.local !== false && s.sessionId) {
788
+ row.classList.add('clickable');
789
+ row.setAttribute('aria-current', String(s.sessionId === current));
790
+ row.title = `Open ${s.name ?? s.sessionId.slice(0, 8)}`;
791
+ row.addEventListener('click', () => selectSession(s.sessionId));
792
+ } else {
793
+ // A session on another machine has no transcript here to open.
794
+ row.title = `${s.name ?? ''} — on ${s.origin}. Its transcript is on that machine.`;
795
+ }
796
+ return row;
797
+ }));
798
+ }
799
+
800
+ /* ------------------------------------------------------------- sessions */
801
+
802
+ let current = null;
803
+ let source = null;
804
+
805
+ // Selecting a session points the graph at it. It does NOT open the
806
+ // conversation: the sidebar is for choosing what to look at, and having a
807
+ // reading panel appear on every click there made choosing expensive. The
808
+ // conversation is opened from the session node on the canvas, which is the
809
+ // thing that represents it.
810
+ function selectSession(sessionId) {
811
+ if (current === sessionId) return;
812
+ current = sessionId;
813
+ canvas.setSession(sessionId);
814
+ showInspector(null);
815
+
816
+ for (const r of el.sessions.querySelectorAll('.srow')) {
817
+ r.setAttribute('aria-current', String(r.dataset.id === sessionId));
818
+ }
819
+ // A new session means the cached agent reports belong to someone else.
820
+ detailCache.clear();
821
+
822
+ const any = chat.ids.length > 0;
823
+ el.chat.hidden = !any;
824
+ el.chatSplit.hidden = !any;
825
+ shell.classList.toggle('no-chat', !any);
826
+ // Offered only where it means something: a session the panel already owns is
827
+ // already here, and there is nothing to continue.
828
+ el.continueSession.hidden = ownedIds.has(sessionId);
829
+ el.continueSession.title = `Continue ${sessionId.slice(0, 8)} in the panel — forks it, so the original transcript is not written to`;
830
+
831
+ if (source) source.close();
832
+ source = new EventSource(api(`/api/stream?session=${encodeURIComponent(sessionId)}`));
833
+
834
+ source.addEventListener('graph', (e) => {
835
+ const g = JSON.parse(e.data);
836
+ canvas.render(g);
837
+ // The inspector holds a node object from an earlier frame; refresh it so
838
+ // status, tokens and tool counts keep moving while it is open.
839
+ if (inspectorNode) {
840
+ const fresh = g.nodes.find((n) => n.id === inspectorNode.id);
841
+ if (fresh) {
842
+ const changed = fresh.status !== inspectorNode.status;
843
+ inspectorNode = fresh;
844
+ paintInspector();
845
+ if (changed && fresh.kind === 'agent') loadDetail(fresh.id, fresh.status);
846
+ }
847
+ }
848
+ const s = g.stats ?? {};
849
+ // Every status is named, so the parts add up to the total. A summary that
850
+ // reports "250 agents · 7 done" and stops invites the reader to assume the
851
+ // other 243 failed.
852
+ const parts = [`${s.agents ?? 0} agents`];
853
+ if (s.workflows) parts.push(`${s.workflows} workflows`);
854
+ for (const [k, v] of Object.entries(s.byStatus ?? {})) if (v) parts.push(`${v} ${k}`);
855
+ if (g.contextTokens != null) parts.push(`${(g.contextTokens / 1000).toFixed(0)}k ctx`);
856
+ if (s.malformed) parts.push(`${s.malformed} malformed`);
857
+ el.summary.textContent = parts.join(' · ');
858
+ setPulse('on', 'live');
859
+ el.foot.textContent = `graph updated ${new Date().toLocaleTimeString()}`;
860
+ });
861
+
862
+ source.addEventListener('idle', () => setPulse('on', 'live'));
863
+
864
+ source.addEventListener('waiting', (e) => {
865
+ let reason = '';
866
+ try { reason = JSON.parse(e.data).reason ?? ''; } catch { /* keep default */ }
867
+ setPulse('on', 'waiting');
868
+ el.summary.textContent = 'no agents yet';
869
+ el.foot.textContent = reason;
870
+ canvas.render({ nodes: [], edges: [] });
871
+ });
872
+
873
+ // A server-side fault and a dropped connection are different facts. They
874
+ // used to share EventSource's 'error' event, so "no such session" was shown
875
+ // as "reconnecting" and the reason was thrown away.
876
+ source.addEventListener('fault', (e) => {
877
+ let reason = 'unknown';
878
+ try { reason = JSON.parse(e.data).reason ?? reason; } catch { /* keep default */ }
879
+ setPulse('off', 'fault');
880
+ el.foot.textContent = `stream fault: ${reason}`;
881
+ source.close();
882
+ source = null;
883
+ });
884
+
885
+ source.onerror = () => {
886
+ if (!source) return; // already closed by a fault
887
+ setPulse('off', 'reconnecting');
888
+ el.foot.textContent = 'stream dropped — reconnecting';
889
+ };
890
+ }
891
+
892
+ function setPulse(cls, text) {
893
+ el.pulse.className = `pulse ${cls}`;
894
+ el.pulse.textContent = text;
895
+ }
896
+
897
+ let projectsData = null;
898
+ let expanded = new Set();
899
+ let filterText = '';
900
+ let showMissing = false;
901
+
902
+ el.filter.addEventListener('input', () => {
903
+ filterText = el.filter.value.trim().toLowerCase();
904
+ paintProjects();
905
+ });
906
+
907
+ function fmtSize(bytes) {
908
+ if (bytes < 1024) return `${bytes}B`;
909
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
910
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
911
+ }
912
+
913
+ function renderSessions(data) {
914
+ if (!data.measured) {
915
+ el.sessionsMeta.textContent = 'not measured';
916
+ renderNote(el.sessions, {
917
+ kind: 'unmeasured',
918
+ title: 'No transcripts here',
919
+ body: 'Nothing was read.',
920
+ why: data.reason,
921
+ });
922
+ return;
923
+ }
924
+ projectsData = data;
925
+ const t0 = data.totals ?? {};
926
+ if (t0.kitInstalled) {
927
+ const bits = [`Crewforth in ${t0.kitInstalled}`];
928
+ if (t0.kitOutdated) bits.push(`${t0.kitOutdated} behind ${data.latest?.version ?? '?'}`);
929
+ if (t0.kitUncompared) bits.push(`${t0.kitUncompared} not compared`);
930
+ el.kit.textContent = bits.join(' · ');
931
+ el.kit.className = `kitline${t0.kitOutdated ? ' warn' : ''}`;
932
+ } else {
933
+ el.kit.textContent = '';
934
+ }
935
+ // The project you are standing in starts open; the rest stay folded, or a
936
+ // machine with 175 projects buries the one you are working in.
937
+ if (!expanded.size) {
938
+ const cur = data.projects.find((p) => p.current) ?? data.projects[0];
939
+ if (cur) expanded.add(cur.key);
940
+ }
941
+ paintProjects();
942
+ }
943
+
944
+ function paintProjects() {
945
+ const data = projectsData;
946
+ if (!data) return;
947
+
948
+ // Typing a filter is an explicit request, so it searches everything —
949
+ // including the projects whose directories are gone. Hiding a result someone
950
+ // asked for by name is worse than showing a dead path.
951
+ let projects = (showMissing || filterText) ? data.projects : data.projects.filter((p) => p.exists);
952
+ const hidden = data.projects.length - projects.length;
953
+
954
+ if (filterText) {
955
+ projects = projects.filter((p) =>
956
+ p.label.toLowerCase().includes(filterText) ||
957
+ (p.cwd ?? '').toLowerCase().includes(filterText) ||
958
+ (p.origin ?? '').toLowerCase().includes(filterText) ||
959
+ p.sessions.some((x) => (x.title ?? '').toLowerCase().includes(filterText)));
960
+ }
961
+
962
+ const t = data.totals ?? {};
963
+ el.sessionsMeta.textContent = filterText
964
+ ? `${projects.length}/${t.live ?? 0}`
965
+ : `${t.live ?? 0} · ${t.sessions ?? 0} sessions`;
966
+
967
+ if (!projects.length) {
968
+ renderNote(el.sessions, { title: 'Nothing matches', body: `No project matches “${filterText}”.` });
969
+ return;
970
+ }
971
+
972
+ const frag = document.createDocumentFragment();
973
+ for (const p of projects) {
974
+ const open = expanded.has(p.key) || Boolean(filterText);
975
+
976
+ const head = node('div', `pgroup${p.current ? ' current' : ''}`);
977
+ head.setAttribute('role', 'treeitem');
978
+ head.setAttribute('aria-expanded', String(open));
979
+ head.append(node('span', 'pcaret', open ? '▾' : '▸'));
980
+ const lbl = node('span', 'plabel');
981
+ lbl.append(document.createTextNode(p.label));
982
+ // Where a project lives is part of its identity once more than one machine
983
+ // is in view.
984
+ if (p.origin && p.local === false) lbl.append(node('span', 'origin', p.origin));
985
+ head.append(lbl);
986
+ const counts = node('span', 'pcount');
987
+ counts.append(node('span', null, String(p.total)));
988
+ if (p.agentTotal) counts.append(node('span', 'sbadge', `${p.agentTotal} ▸`));
989
+ head.append(counts);
990
+ // Kit version sits with the project, because that is the thing that is or
991
+ // is not up to date.
992
+ if (p.kit?.installed) {
993
+ const k = node('span', 'kitbadge');
994
+ if (p.kit.compared === false) {
995
+ k.classList.add('unknown');
996
+ k.textContent = `${p.kit.version} ?`;
997
+ k.title = `Crewforth ${p.kit.version} — not compared: ${p.kit.reason}`;
998
+ } else if (p.kit.outdated) {
999
+ k.classList.add('old');
1000
+ k.textContent = `${p.kit.version} → ${p.kit.latest}`;
1001
+ k.title = `Crewforth ${p.kit.version} is behind ${p.kit.latest} — run /crew-update in this project`;
1002
+ } else if (p.kit.ahead) {
1003
+ k.classList.add('ahead');
1004
+ k.textContent = p.kit.version;
1005
+ k.title = `Crewforth ${p.kit.version} is ahead of the published ${p.kit.latest}`;
1006
+ } else {
1007
+ k.classList.add('ok');
1008
+ k.textContent = p.kit.version;
1009
+ k.title = `Crewforth ${p.kit.version} — current`;
1010
+ }
1011
+ counts.prepend(k);
1012
+ }
1013
+ if (!p.exists) head.classList.add('gone');
1014
+ head.title = (p.cwd ?? p.dir) + (p.exists ? '' : ' — directory no longer exists');
1015
+ head.addEventListener('click', () => {
1016
+ if (expanded.has(p.key)) expanded.delete(p.key); else expanded.add(p.key);
1017
+ paintProjects();
1018
+ });
1019
+ frag.append(head);
1020
+
1021
+ if (!open) continue;
1022
+
1023
+ for (const sn of p.sessions) {
1024
+ const row = node('div', 'srow');
1025
+ row.dataset.id = sn.sessionId;
1026
+ row.setAttribute('role', 'treeitem');
1027
+ row.setAttribute('aria-current', String(sn.sessionId === current));
1028
+ // The name people actually gave the session, with the id as the fallback
1029
+ // it always was.
1030
+ row.append(node('span', 'sname', sn.title || sn.sessionId.slice(0, 8)));
1031
+ const right = node('span', 'sright');
1032
+ if (sn.agentCount) right.append(node('span', 'sbadge', `${sn.agentCount} ▸`));
1033
+ right.append(node('span', 'sagents', fmtSize(sn.bytes)));
1034
+ row.append(right);
1035
+ if (ownedIds.has(sn.sessionId)) row.classList.add('owned');
1036
+ row.title = `${sn.sessionId}\n${sn.title ?? ''}`;
1037
+ row.addEventListener('click', () => selectSession(sn.sessionId));
1038
+ frag.append(row);
1039
+ }
1040
+
1041
+ if (p.total > p.sessions.length) {
1042
+ frag.append(node('div', 'ptrunc', `${p.total - p.sessions.length} older session(s) not listed`));
1043
+ }
1044
+ }
1045
+ for (const o of (data.origins ?? []).filter((x) => x.ok === false)) {
1046
+ const w = node('div', 'ptrunc pdown');
1047
+ w.textContent = `${o.name} unreachable — ${o.reason}`;
1048
+ frag.append(w);
1049
+ }
1050
+
1051
+ if (hidden > 0 && !filterText) {
1052
+ const t2 = node('div', 'ptrunc pmissing');
1053
+ t2.textContent = showMissing
1054
+ ? `hiding nothing — ${hidden} directory(ies) no longer exist`
1055
+ : `${hidden} project(s) hidden — their directories no longer exist`;
1056
+ const b = node('button', 'ghost', showMissing ? 'hide' : 'show');
1057
+ b.addEventListener('click', () => { showMissing = !showMissing; paintProjects(); });
1058
+ t2.append(b);
1059
+ frag.append(t2);
1060
+ }
1061
+ el.sessions.replaceChildren(frag);
1062
+
1063
+ if (!current) {
1064
+ const cur = data.projects.find((x) => x.current) ?? data.projects[0];
1065
+ const best = cur?.sessions.find((x) => x.agentCount > 0) ?? cur?.sessions[0];
1066
+ if (best) selectSession(best.sessionId);
1067
+ }
1068
+ }
1069
+
1070
+ /* --------------------------------------------------------------- polling */
1071
+
1072
+ async function pollFleet() {
1073
+ try {
1074
+ const fleet = await getJson('/api/fleet');
1075
+ renderFleet(fleet);
1076
+ renderReach(fleet.roster);
1077
+ } catch (e) {
1078
+ el.fleetMeta.textContent = 'offline';
1079
+ renderNote(el.fleet, { kind: 'unmeasured', title: 'Server unreachable', why: e.message });
1080
+ }
1081
+ }
1082
+
1083
+ async function pollSessions() {
1084
+ try {
1085
+ renderSessions(await getJson('/api/projects'));
1086
+ } catch (e) {
1087
+ el.sessionsMeta.textContent = 'offline';
1088
+ }
1089
+ }
1090
+
1091
+ // Sessions this panel owns survive a page reload as long as the server does.
1092
+ getJson('/api/owned')
1093
+ .then((r) => { for (const sn of r.sessions ?? []) ownedIds.add(sn.sessionId); })
1094
+ .catch(() => { /* none yet */ });
1095
+
1096
+ pollFleet(); pollSessions();
1097
+ setInterval(pollFleet, FLEET_POLL_MS);
1098
+ setInterval(pollSessions, SESSION_POLL_MS);