crewforth 3.1.1 → 3.2.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +6 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +66 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/AGENT_TEMPLATE.md +2 -1
  7. package/kit/CLAUDE.md +15 -14
  8. package/kit/agents/crew-commit-agent.md +11 -4
  9. package/kit/agents/crew-docs-agent.md +71 -0
  10. package/kit/agents/crew-planner.md +2 -0
  11. package/kit/agents/crew-refuter.md +77 -0
  12. package/kit/agents/crew-review-agent.md +2 -0
  13. package/kit/agents/crew-security-expert.md +3 -1
  14. package/kit/agents/crew-session-manager.md +11 -9
  15. package/kit/eval/doctor.sh +128 -6
  16. package/kit/eval/gate-report.sh +2 -0
  17. package/kit/eval/golden-routing.txt +9 -0
  18. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  19. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  20. package/kit/eval/lib/crew-map/narrative.mjs +132 -0
  21. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  22. package/kit/eval/lib/crew-map/project.mjs +164 -0
  23. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  24. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  25. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  26. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  27. package/kit/eval/lib/crew-map/scan.mjs +266 -0
  28. package/kit/eval/lib/crew-map/stacks.mjs +547 -0
  29. package/kit/eval/lib/crew-map/surface.mjs +535 -0
  30. package/kit/eval/lib/findings/findings.mjs +410 -0
  31. package/kit/eval/lib/findings/findings.schema.json +122 -0
  32. package/kit/eval/lib/findings/findings.sh +29 -0
  33. package/kit/eval/lib/gate-log.sh +52 -0
  34. package/kit/eval/lib/json-string.awk +27 -0
  35. package/kit/eval/lib/preset.sh +109 -0
  36. package/kit/eval/lib/presets/default.json +7 -0
  37. package/kit/eval/lib/presets/solo.json +7 -0
  38. package/kit/eval/lib/presets/strict.json +11 -0
  39. package/kit/eval/lib/preview.sh +150 -0
  40. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  41. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  42. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  43. package/kit/eval/lib/settings-default.sh +36 -0
  44. package/kit/eval/lib/settings-json.awk +19 -4
  45. package/kit/eval/lib/star.sh +23 -7
  46. package/kit/eval/lib/statusline.settings.json +6 -0
  47. package/kit/eval/lib/statusline.sh +100 -0
  48. package/kit/eval/lib/verify-cmd.sh +17 -0
  49. package/kit/eval/lib/version.sh +41 -0
  50. package/kit/eval/review-bench.sh +26 -0
  51. package/kit/eval/smoke-test.sh +878 -53
  52. package/kit/hooks/agent-outcome.sh +44 -12
  53. package/kit/hooks/board-sync.sh +2 -23
  54. package/kit/hooks/guard-agent-model.sh +177 -24
  55. package/kit/hooks/guard-bash.sh +295 -204
  56. package/kit/hooks/guard-commit-scan.sh +10 -2
  57. package/kit/hooks/guard-question.sh +73 -0
  58. package/kit/hooks/guard-write.sh +24 -7
  59. package/kit/hooks/precompact-snapshot.sh +75 -0
  60. package/kit/hooks/prompt-approval.sh +389 -103
  61. package/kit/hooks/refuter-ledger.sh +80 -0
  62. package/kit/hooks/session-rehydrate.sh +25 -2
  63. package/kit/hooks/session-update-check.sh +9 -8
  64. package/kit/hooks/trace-blocklist.txt +9 -0
  65. package/kit/owned-blobs.tsv +4 -4
  66. package/kit/settings.json +51 -0
  67. package/kit/skills/crew-approve/SKILL.md +7 -0
  68. package/kit/skills/crew-code-review/SKILL.md +16 -0
  69. package/kit/skills/crew-doctor/SKILL.md +5 -1
  70. package/kit/skills/crew-handoff/SKILL.md +3 -2
  71. package/kit/skills/crew-map/SKILL.md +55 -0
  72. package/kit/skills/crew-review/SKILL.md +4 -1
  73. package/kit/skills/crew-ship/SKILL.md +3 -1
  74. package/kit/skills/crew-update/SKILL.md +1 -1
  75. package/kit/skills/incident-runbook/SKILL.md +2 -2
  76. package/kit/skills/security-scan/references/verify.md +6 -3
  77. package/kit/studio/README.md +25 -2
  78. package/kit/studio/server/index.js +187 -21
  79. package/kit/studio/server/lib/code-stamp.js +35 -0
  80. package/kit/studio/server/lib/crew-map.js +113 -0
  81. package/kit/studio/server/lib/graph.js +9 -1
  82. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  83. package/kit/studio/server/lib/lan.js +87 -0
  84. package/kit/studio/server/lib/model-outcomes.js +130 -15
  85. package/kit/studio/server/lib/owned-stream.js +56 -0
  86. package/kit/studio/server/lib/pricing.js +3 -0
  87. package/kit/studio/server/lib/projects.js +11 -4
  88. package/kit/studio/server/lib/redact.js +60 -0
  89. package/kit/studio/server/lib/remote.js +185 -0
  90. package/kit/studio/server/lib/session.js +1 -1
  91. package/kit/studio/server/lib/terminal.js +51 -13
  92. package/kit/studio/server/lib/transcript.js +32 -0
  93. package/kit/studio/server/lib/usage.js +74 -14
  94. package/kit/studio/web/app.js +287 -17
  95. package/kit/studio/web/canvas.js +58 -122
  96. package/kit/studio/web/chat.js +114 -24
  97. package/kit/studio/web/graph-plan.js +13 -6
  98. package/kit/studio/web/index.html +10 -5
  99. package/kit/studio/web/liveness.js +14 -0
  100. package/kit/studio/web/map-plan.js +410 -0
  101. package/kit/studio/web/map.js +596 -0
  102. package/kit/studio/web/models-plan.js +61 -7
  103. package/kit/studio/web/models.js +19 -3
  104. package/kit/studio/web/qr.js +200 -0
  105. package/kit/studio/web/style.css +165 -0
  106. package/kit/studio/web/tokens.css +6 -0
  107. package/kit/studio/web/usage.js +12 -0
  108. package/kit/studio/web/viewport.js +160 -0
  109. package/package.json +6 -5
  110. package/start.sh +24 -1
@@ -21,25 +21,34 @@ import { projectDir, listSessions, findSession, listProjects, sessionCwd, agentM
21
21
  import { agentOrigins } from './lib/agent-origin.js';
22
22
  import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
23
23
  import { cachedUsage } from './lib/usage.js';
24
- import { readModels, readRecord, readCosts } from './lib/model-outcomes.js';
24
+ import { streamOwned } from './lib/owned-stream.js';
25
+ import { stampAtStart } from './lib/code-stamp.js';
26
+ import { readModels, readRecord, readCosts, readAboveCosts } from './lib/model-outcomes.js';
27
+ import { readMap, behindOf, compareMaps, mapSummary, MAP_FILE } from './lib/crew-map.js';
25
28
 
26
29
  const USAGE_BATCH = 12;
27
30
  import { palette } from './lib/palette.js';
28
31
  import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
29
32
  import { parsePeers, askAll, ask } from './lib/peers.js';
30
33
  import {
31
- createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES,
34
+ createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES, isHeldOpen,
32
35
  } from './lib/session.js';
33
36
  import { decide, pending, alwaysList, revoke, sweepLeft } from './lib/permissions.js';
34
- import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
37
+ import { open as openTerminal, plan as terminalPlan, desktopLine } from './lib/terminal.js';
35
38
  import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
36
39
  import { writeState, clearStateSync, findRunning } from './lib/instance.js';
37
40
  import { remoteRoster } from './lib/roster.js';
41
+ import { addViewer, listViewers, viewerByToken, revokeViewer, noteRequest, noteRefusal, viewerMay, MAX_VIEWERS, VIEWER_TTL_MS,
42
+ liveCount, remoteLog, sweep as sweepViewers, onViewersChange, onLog } from './lib/remote.js';
43
+ import { redact, transcriptOnly } from './lib/redact.js';
44
+ import { lanListener } from './lib/lan.js';
38
45
  import { randomUUID } from 'node:crypto';
39
46
  import { spawn } from 'node:child_process';
40
47
 
41
48
  const HERE = path.dirname(fileURLToPath(import.meta.url));
42
49
  const WEB_ROOT = path.resolve(HERE, '..', 'web');
50
+ // The server's own code as it was when it started: an update on disk since then is said to the page (code-stamp.js).
51
+ const codeChanged = stampAtStart(HERE);
43
52
  const LOOPBACK = '127.0.0.1';
44
53
 
45
54
  const MIME = {
@@ -92,8 +101,12 @@ function send(res, status, body, headers = {}) {
92
101
  res.end(body);
93
102
  }
94
103
 
104
+ // The answers that go to a viewer whose link hides transcripts: every one of them passes through redact() on its way
105
+ // out, here and in the event stream, so no route has to remember to.
106
+ const REDACTED = new WeakSet();
107
+
95
108
  function sendJson(res, status, value) {
96
- send(res, status, JSON.stringify(value, null, 2), {
109
+ send(res, status, JSON.stringify(REDACTED.has(res) ? redact(value) : value, null, 2), {
97
110
  'content-type': 'application/json; charset=utf-8',
98
111
  });
99
112
  }
@@ -108,15 +121,35 @@ function sendJson(res, status, value) {
108
121
  const TOKEN = process.env.CREW_STUDIO_TOKEN || randomUUID();
109
122
  const TOKEN_GENERATED = !process.env.CREW_STUDIO_TOKEN;
110
123
 
124
+ // The Wi-Fi listener (lib/lan.js): set by main() once the port is known, and opened and closed with the number of
125
+ // live viewers. Null in the self-check unless it makes one.
126
+ let LAN = null;
127
+
128
+ /** For the self-check, which asks a real server as the owner and as a viewer. */
129
+ export const _internals = { token: TOKEN, setLan: (l) => { LAN = l; } };
130
+
111
131
  // Peers are other machines running Studio, reached over a forwarded port. This
112
132
  // server still listens on loopback only; a peer never widens that.
113
133
  let PEERS = [];
114
134
  let SELF_NAME = 'this machine';
115
135
 
116
- function authorised(req, url) {
136
+ /**
137
+ * Whose request this is: the owner's, a read-only viewer's (lib/remote.js), or nobody's.
138
+ * The token is read from the same two places for both, so a viewer's link works exactly as the owner's does.
139
+ */
140
+ function whoAsks(req, url) {
117
141
  const header = req.headers['authorization'];
118
- if (header === `Bearer ${TOKEN}`) return true;
119
- return url.searchParams.get('token') === TOKEN;
142
+ const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : null;
143
+ const given = [bearer, url.searchParams.get('token')].filter(Boolean);
144
+ // The panel's own token is accepted on loopback only. From the Wi-Fi it is not a token at all: a phone holding it
145
+ // would hold the panel, and nothing on the network should.
146
+ if (!req.crewLan && given.includes(TOKEN)) return { role: 'owner' };
147
+ for (const t of given) {
148
+ const viewer = viewerByToken(t);
149
+ if (viewer?.expired) return { role: 'expired', label: viewer.expired };
150
+ if (viewer) return { role: 'viewer', viewer };
151
+ }
152
+ return null;
120
153
  }
121
154
 
122
155
  /**
@@ -167,10 +200,16 @@ async function modelCosts(cwd) {
167
200
  for (const s of (await listSessions(dir)).filter((x) => x.modifiedAt >= since).slice(0, MODELS_SESSIONS_MAX)) {
168
201
  const u = await cachedUsage(s);
169
202
  read += 1;
170
- for (const [id, a] of Object.entries(u.agents ?? {})) per.set(id, a.cost);
203
+ for (const [id, a] of Object.entries(u.agents ?? {})) per.set(id, a);
171
204
  }
172
205
  }
173
- return { measured: true, costs: readCosts(cwd, (id) => (per.has(id) ? per.get(id) : null)), sessionsRead: read };
206
+ return {
207
+ measured: true,
208
+ costs: readCosts(cwd, (id) => per.get(id)?.cost ?? null,
209
+ (id) => (per.get(id)?.advisorCalls ? { calls: per.get(id).advisorCalls, cost: per.get(id).advisorCost } : null)),
210
+ above: readAboveCosts(cwd, (id) => (per.has(id) ? { cost: per.get(id).cost, atNormal: per.get(id).costAtNormal } : null)),
211
+ sessionsRead: read,
212
+ };
174
213
  }
175
214
  const MODELS_SESSIONS_MAX = 60;
176
215
 
@@ -214,23 +253,52 @@ async function serveStatic(res, urlPath) {
214
253
  }
215
254
  }
216
255
 
217
- async function handle(req, res) {
256
+ export async function handle(req, res) {
218
257
  const url = new URL(req.url, `http://${LOOPBACK}`);
219
258
 
220
259
  // The token guards data and actions, not the app shell. A browser does not
221
260
  // carry the page's query string into its sub-resource requests, so demanding
222
261
  // it for /style.css served an unstyled page and protected nothing: the shell
223
262
  // holds no secrets, and every /api/ path below is still gated.
224
- if (url.pathname.startsWith('/api/') && !authorised(req, url)) {
225
- return sendJson(res, 403, { ok: false, reason: 'token required' });
263
+ const who = url.pathname.startsWith('/api/') ? whoAsks(req, url) : null;
264
+ // From the Wi-Fi, no token (or the panel's own) is 401: the network is asked who it is. On loopback the answer
265
+ // stays 403, as it always was.
266
+ if (url.pathname.startsWith('/api/') && !who) {
267
+ if (req.crewLan) noteRefusal(req, { status: 401, reason: 'no viewer token' });
268
+ return sendJson(res, req.crewLan ? 401 : 403, { ok: false, reason: 'token required' });
269
+ }
270
+ if (who?.role === 'expired') {
271
+ noteRefusal(req, { viewer: who.label, status: 401, reason: 'expired' });
272
+ return sendJson(res, 401, { ok: false, expired: true, reason: 'this link has expired: viewer links last eight hours' });
273
+ }
274
+ // A viewer is refused HERE, before any handler: no route below has to remember that a viewer exists, and a
275
+ // route added tomorrow is read-only for a viewer without anyone writing a line for it. The same holds for a
276
+ // link that hides transcripts: the routes that are nothing but transcript are refused here, and every other
277
+ // answer is filtered on its way out (REDACTED).
278
+ if (who?.role === 'viewer') {
279
+ const may = viewerMay(req.method, url.pathname);
280
+ if (!may.ok) { noteRefusal(req, { viewer: who.viewer.label, status: 403, reason: may.reason }); return sendJson(res, 403, { ok: false, reason: may.reason }); }
281
+ if (!who.viewer.transcripts) {
282
+ if (transcriptOnly(url.pathname)) {
283
+ noteRefusal(req, { viewer: who.viewer.label, status: 403, reason: 'transcripts hidden' });
284
+ return sendJson(res, 403, { ok: false, transcriptsHidden: true, reason: 'transcripts are hidden on this link' });
285
+ }
286
+ REDACTED.add(res);
287
+ }
288
+ noteRequest(who.viewer, req, res);
226
289
  }
227
290
 
228
291
  if (url.pathname === '/api/health') {
229
292
  return sendJson(res, 200, {
230
293
  ok: true,
294
+ role: who.role,
295
+ ...(who.role === 'viewer' ? { transcripts: who.viewer.transcripts, expiresAt: who.viewer.expiresAt } : {}),
231
296
  node: process.version,
232
297
  pid: process.pid,
233
298
  uptimeMs: Math.round(process.uptime() * 1000),
299
+ // `changed`: the server's files on disk are not the ones it started with. A page that finds no `code` at all
300
+ // is talking to a server older than this field, which is the same thing.
301
+ code: codeChanged(),
234
302
  });
235
303
  }
236
304
 
@@ -288,7 +356,12 @@ async function handle(req, res) {
288
356
  // dead time whenever the feed hung, measured. The refresh runs behind this response.
289
357
  const latest = latestVersionCached();
290
358
  const projects = await listProjects({ currentCwd: cwd, kitOf: (c) => kitStatus(c, latest) });
291
- for (const p of projects) { p.origin = SELF_NAME; p.local = true; }
359
+ for (const p of projects) {
360
+ p.origin = SELF_NAME; p.local = true;
361
+ // The navigator's Map entry and its count: the findings the map shows unasked. A stat per project; the map
362
+ // is parsed again only when its file changes (crew-map.js).
363
+ p.map = p.exists && p.cwd ? mapSummary(p.cwd) : null;
364
+ }
292
365
 
293
366
  const origins = [{ name: SELF_NAME, local: true, ok: true, projects: projects.length }];
294
367
  if (PEERS.length) {
@@ -339,7 +412,7 @@ async function handle(req, res) {
339
412
  const sid = url.searchParams.get('session');
340
413
  const session = sid ? await findSession(sid) : null;
341
414
  const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
342
- if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', classes: [], suggestions: [], byAgent: {} });
415
+ if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', classes: [], suggestions: [], above: [], byAgent: {} });
343
416
  return sendJson(res, 200, readModels(cwd, { agents: await agentIdsOf(session) }));
344
417
  }
345
418
 
@@ -347,10 +420,29 @@ async function handle(req, res) {
347
420
  const sid = url.searchParams.get('session');
348
421
  const session = sid ? await findSession(sid) : null;
349
422
  const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
350
- if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', costs: {}, sessionsRead: 0 });
423
+ if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', costs: {}, above: {}, sessionsRead: 0 });
351
424
  return sendJson(res, 200, await modelCosts(cwd));
352
425
  }
353
426
 
427
+ // The project's crew map, as Crewforth's scanner wrote it, and how far HEAD has moved since. Nothing is scanned here.
428
+ // `compare` names another project of this machine by its directory; only a project the panel lists, with a map
429
+ // of its own, is read, so the route cannot be pointed at an arbitrary path.
430
+ if (url.pathname === '/api/map') {
431
+ const sid = url.searchParams.get('session');
432
+ const session = sid ? await findSession(sid) : null;
433
+ const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
434
+ if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', others: [] });
435
+ const map = readMap(cwd);
436
+ const others = (await listProjects({ currentCwd: cwd })).filter((p) => p.cwd && p.cwd !== cwd && readMap(p.cwd).measured)
437
+ .map((p) => ({ label: p.label, cwd: p.cwd }));
438
+ if (!map.measured) return sendJson(res, 200, { ...map, cwd, others });
439
+ const asked = url.searchParams.get('compare');
440
+ const peer = asked ? others.find((o) => o.cwd === asked) : null;
441
+ const compare = peer ? { label: peer.label, cwd: peer.cwd, clients: compareMaps(map.doc, readMap(peer.cwd).doc) }
442
+ : asked ? { label: null, cwd: asked, clients: [], reason: 'not a project of this machine with a crew map' } : null;
443
+ return sendJson(res, 200, { ...map, cwd, behind: await behindOf(cwd, map.doc.source?.commit), others, compare });
444
+ }
445
+
354
446
  if (url.pathname === '/api/kit') {
355
447
  const cwd = url.searchParams.get('cwd') || process.cwd();
356
448
  const sid = url.searchParams.get('session');
@@ -441,8 +533,12 @@ async function handle(req, res) {
441
533
  const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
442
534
 
443
535
  if (req.method === 'GET') {
444
- // What WOULD run, so the panel can show it before anything is launched.
445
- return sendJson(res, 200, { measured: true, cwd, plan: cwd ? terminalPlan({ cwd, sessionId: id }) : null });
536
+ // What WOULD run, so the panel can show it before anything is launched; and, before Continue here is pressed,
537
+ // what it will do: continue this same session (nothing else holds it), or start a copy (something does). The
538
+ // answer is the one createSession acts on, asked the same way.
539
+ const held = await isHeldOpen(id);
540
+ return sendJson(res, 200, { measured: true, cwd, plan: cwd ? terminalPlan({ cwd, sessionId: id }) : null,
541
+ continueAs: held ? 'copy' : 'same', desktop: cwd ? desktopLine({ cwd, sessionId: id }) : null });
446
542
  }
447
543
 
448
544
  const gate = writeAllowed(req);
@@ -454,7 +550,6 @@ async function handle(req, res) {
454
550
  const out = openTerminal({
455
551
  cwd: typeof body.cwd === 'string' ? body.cwd : cwd,
456
552
  sessionId: id,
457
- command: typeof body.command === 'string' ? body.command : undefined,
458
553
  });
459
554
  return sendJson(res, out.ok ? 200 : 400, out);
460
555
  }
@@ -463,6 +558,36 @@ async function handle(req, res) {
463
558
  return stream(req, res, url);
464
559
  }
465
560
 
561
+ /* --------------------------------------------------------- remote ---
562
+ Read-only viewers: who holds a link, and the way to take it back. Only
563
+ the owner reaches this far; a viewer was refused above. */
564
+
565
+ if (url.pathname === '/api/remote' && req.method === 'GET') {
566
+ return sendJson(res, 200, { measured: true, viewers: listViewers(), max: MAX_VIEWERS, ttlMs: VIEWER_TTL_MS, now: Date.now(),
567
+ lan: LAN ? LAN.state() : { listening: false, addresses: [], errors: [] }, log: remoteLog(30) });
568
+ }
569
+
570
+ const remoteMatch = url.pathname.match(/^\/api\/remote(?:\/([^/]+))?$/);
571
+ if (remoteMatch) {
572
+ const gate = writeAllowed(req);
573
+ if (!gate.ok) return sendJson(res, 403, { ok: false, reason: gate.reason });
574
+ if (req.method === 'POST' && !remoteMatch[1]) {
575
+ const body = await readBody(req);
576
+ if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
577
+ const made = addViewer(body.label, { transcripts: body.transcripts === true });
578
+ // The Wi-Fi listener opens with the first viewer; its links are known once it listens.
579
+ if (made.ok && LAN) await LAN.sync(liveCount());
580
+ const lanUrls = made.ok && LAN ? LAN.urls(made.viewer.token) : [];
581
+ return sendJson(res, made.ok ? 201 : 409, { ...made, lanUrls, lan: LAN ? LAN.state() : null, viewers: listViewers() });
582
+ }
583
+ if (req.method === 'DELETE' && remoteMatch[1]) {
584
+ const out = revokeViewer(decodeURIComponent(remoteMatch[1]));
585
+ if (LAN) await LAN.sync(liveCount());
586
+ return sendJson(res, 200, { ...out, lan: LAN ? LAN.state() : null, viewers: listViewers() });
587
+ }
588
+ return sendJson(res, 405, { ok: false, reason: 'POST to add a viewer, or DELETE a viewer id' });
589
+ }
590
+
466
591
  /* ---------------------------------------------------------- owned ---
467
592
  Sessions the panel started. Their transcripts land in the usual place,
468
593
  so the graph endpoints already cover them; only the conversation needs
@@ -525,6 +650,10 @@ async function handle(req, res) {
525
650
  return sendJson(res, out.ok ? 200 : 400, out);
526
651
  }
527
652
 
653
+ // Every open conversation on ONE connection (lib/owned-stream.js). Reading, so it sits beside /events, not
654
+ // behind the write gate.
655
+ if (url.pathname === '/api/owned-events') return streamOwned(req, res, url, { getSession, stateAfter: STATE_AFTER });
656
+
528
657
  const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events|mode))?$/);
529
658
  if (ownedMatch) {
530
659
  const s = getSession(decodeURIComponent(ownedMatch[1]));
@@ -655,7 +784,7 @@ async function stream(req, res, url) {
655
784
  });
656
785
 
657
786
  const send = (event, data) => {
658
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
787
+ res.write(`event: ${event}\ndata: ${JSON.stringify(REDACTED.has(res) ? redact(data) : data)}\n\n`);
659
788
  };
660
789
 
661
790
  // A session the panel just started has no transcript until its first turn
@@ -830,6 +959,28 @@ async function selftest() {
830
959
  fleet.measured ? `${fleet.sessions.length} session(s)` : `not measured: ${fleet.reason}`,
831
960
  );
832
961
 
962
+ // The Map view: its files are installed, and the reader takes a crew map and refuses one of another schema. On a
963
+ // scratch map of its own, so the verdict does not depend on whether this project has one; this project's own map,
964
+ // when there is one, is reported beside it.
965
+ check('map view installed', ['map.js', 'map-plan.js', 'viewport.js'].every((f) => fs.existsSync(path.join(WEB_ROOT, f))), null);
966
+ {
967
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crew-studio-map-'));
968
+ try {
969
+ fs.mkdirSync(path.join(dir, '.crewforth'));
970
+ const doc = { schema: 'crewforth.crew-map/1', nodes: [{ id: 'mod:x:a', kind: 'module', name: 'a' }], edges: [], findings: [], notMeasured: [] };
971
+ fs.writeFileSync(path.join(dir, MAP_FILE), JSON.stringify(doc));
972
+ const read = readMap(dir);
973
+ // A different size, not only a different schema: the reader caches on mtime and size, and on Windows both writes
974
+ // land on one timestamp, so a same-size rewrite read back the first map (seen on a CI runner).
975
+ fs.writeFileSync(path.join(dir, MAP_FILE), JSON.stringify({ ...doc, schema: 'crewforth.crew-map/99' }));
976
+ const other = readMap(dir);
977
+ check('map read', read.measured === true && read.doc.nodes.length === 1 && other.measured === false,
978
+ read.measured ? `a scratch map read; one of another schema refused (${other.reason ?? 'read'})` : read.reason);
979
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
980
+ const own = readMap(process.cwd());
981
+ process.stdout.write(`INFO this project's map — ${own.measured ? `${own.doc.nodes.length} node(s), ${own.doc.findings.length} finding(s)` : own.reason}\n`);
982
+ }
983
+
833
984
  const cost = await measureSpawnCost(3);
834
985
  if (cost === null) skip('spawn cost measured', 'claude CLI absent');
835
986
  else check('spawn cost measured', true, `min ${cost.minMs}ms · median ${cost.medianMs}ms · max ${cost.maxMs}ms`);
@@ -877,6 +1028,11 @@ async function main() {
877
1028
  ' --help this text\n\n' +
878
1029
  'Set CREW_STUDIO_TOKEN to require a bearer token, CREW_STUDIO_PEERS for a\n' +
879
1030
  'comma-separated peer list.\n\n' +
1031
+ 'To watch from a phone on the same Wi-Fi, add a viewer under Machines in\n' +
1032
+ 'the panel and scan its QR code. A viewer reads and changes nothing, its\n' +
1033
+ 'link lasts eight hours, and transcripts stay hidden unless you show them.\n' +
1034
+ 'While a viewer is live the server also listens on this machine\'s Wi-Fi\n' +
1035
+ 'address, for viewer links only; with none it is on loopback alone.\n\n' +
880
1036
  'Peers are normally reached over a forwarded port, which keeps every\n' +
881
1037
  'Studio on loopback:\n' +
882
1038
  ' ssh -N -L 7778:127.0.0.1:7777 other-machine\n' +
@@ -893,12 +1049,21 @@ async function main() {
893
1049
  // Only a server that is about to run does this; asking for --help or --selftest removes nothing.
894
1050
  sweepLeft();
895
1051
 
896
- const server = http.createServer((req, res) => {
1052
+ const handler = (req, res) => {
897
1053
  handle(req, res).catch((e) => {
898
1054
  process.stderr.write(`crewforth-studio: unhandled: ${e?.stack ?? e}\n`);
899
1055
  if (!res.headersSent) send(res, 500, 'internal error');
900
1056
  });
901
- });
1057
+ };
1058
+ const server = http.createServer(handler);
1059
+
1060
+ // Viewers on the same Wi-Fi: a second listener on this machine's private address, open only while a viewer is
1061
+ // live (lib/lan.js). It closes when the last one is revoked or expires; the sweep ends expired ones even when no
1062
+ // request comes to notice them.
1063
+ LAN = lanListener({ handler, port: args.port });
1064
+ onViewersChange((n) => { LAN.sync(n); });
1065
+ onLog((e) => process.stdout.write(` viewer ${e.event}: ${e.viewer ?? 'unknown'}${e.ip ? ` from ${e.ip}` : ''}${e.over ? ` (${e.over})` : ''}${e.result ? ` — ${e.result}` : ''}\n`));
1066
+ setInterval(sweepViewers, 60 * 1000).unref();
902
1067
 
903
1068
  // A BUSY PORT IS NOT AUTOMATICALLY A PROBLEM. Before 2.10.2 this printed "try --port n+1" and exited 1, which
904
1069
  // is the right answer only when a stranger holds the port. When the holder is our OWN panel -- the common case,
@@ -979,6 +1144,7 @@ async function main() {
979
1144
  // leaks running sessions is worse than one that never started them.
980
1145
  stopAll();
981
1146
  dropSpools();
1147
+ LAN?.close();
982
1148
  server.close(() => process.exit(0));
983
1149
  setTimeout(() => process.exit(0), 2000).unref();
984
1150
  });
@@ -0,0 +1,35 @@
1
+ // code-stamp.js — is the code this server runs still the code on disk?
2
+ //
3
+ // A server keeps the code it started with. An update replaces the files on disk, the page is served fresh from
4
+ // them, and the two disagree: measured in a real project, a server started before 3.2.0 answered the new Map
5
+ // view's /api/map with 404, which the view showed as "Not read — HTTP 404". The stamp is the size and mtime of
6
+ // every file of the server, taken at start and again when asked; when they differ, the page says to restart.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ /** Size and mtime of every .js/.mjs/.sh file under `dir` (the server's own code), as one string. */
11
+ export function codeStamp(dir) {
12
+ const parts = [];
13
+ const walk = (d) => {
14
+ let entries;
15
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
16
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
17
+ const p = path.join(d, e.name);
18
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); continue; }
19
+ if (!/\.(m?js|sh)$/.test(e.name)) continue;
20
+ try { const st = fs.statSync(p); parts.push(`${path.relative(dir, p)}:${st.size}:${Math.round(st.mtimeMs)}`); } catch { /* gone */ }
21
+ }
22
+ };
23
+ walk(dir);
24
+ return parts.join('|');
25
+ }
26
+
27
+ /** Stamp the code now, and answer later whether it has changed on disk (re-read at most every `everyMs`). */
28
+ export function stampAtStart(dir, { everyMs = 5000, now = () => Date.now() } = {}) {
29
+ const boot = codeStamp(dir);
30
+ let last = boot; let at = now();
31
+ return () => {
32
+ if (now() - at >= everyMs) { last = codeStamp(dir); at = now(); }
33
+ return { changed: last !== boot };
34
+ };
35
+ }
@@ -0,0 +1,113 @@
1
+ // What Crewforth's crew map says about a project, for the Map view.
2
+ //
3
+ // The map is `.crewforth/crew-map.json`, written by Crewforth's scanner (kit/eval/lib/crew-map/scan.mjs): the
4
+ // project's modules, the external resources it touches, the endpoints it provides and calls, and the findings
5
+ // between them, every one citing file:line. This file reads it and runs nothing that writes: no scan is started
6
+ // from here, and a map that is not on disk is said to be missing. The one process it starts is `git rev-list
7
+ // --count`, to say how far the project has moved since the commit the map was scanned at.
8
+ //
9
+ // Compare with: another project's map is read the same way. The scanner already says which of a project's calls
10
+ // no endpoint in that project provides (they go somewhere else); those are the calls compared here. One the other
11
+ // project provides is a match, one it does not is missing.
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { execFile } from 'node:child_process';
16
+
17
+ export const MAP_FILE = path.join('.crewforth', 'crew-map.json');
18
+ // How a project writes its map, said wherever a map is missing. The one place it is written: the view and the
19
+ // page show what the server sends.
20
+ // /crew-map in Claude Code: the scan, then the descriptions, in either edition (the plugin has no .claude/eval).
21
+ export const WRITER = '/crew-map in Claude Code';
22
+ export const SCHEMA = /^crewforth\.crew-map\/1$/;
23
+ const MAX_BYTES = 8 * 1024 * 1024;
24
+ const GIT_TIMEOUT_MS = 5000;
25
+
26
+ const maps = new Map(); // cwd -> { sig, value }
27
+ export const _stats = { parsed: 0 }; // how many times a map was really read: the claims count it
28
+
29
+ /**
30
+ * The map of one project, read once and kept until the file changes (its time or its size).
31
+ * @returns { measured: true, file, bytes, doc } or { measured: false, reason }
32
+ */
33
+ export function readMap(cwd) {
34
+ const file = path.join(cwd, MAP_FILE);
35
+ let st;
36
+ try { st = fs.statSync(file); } catch {
37
+ return { measured: false, missing: true, reason: `no ${MAP_FILE.replaceAll('\\', '/')} in this project. Write one there with: ${WRITER}` };
38
+ }
39
+ const sig = `${st.mtimeMs}:${st.size}`;
40
+ const hit = maps.get(cwd);
41
+ if (hit && hit.sig === sig) return hit.value;
42
+ const value = parseMap(file, st.size);
43
+ _stats.parsed += 1;
44
+ if (maps.size > 50) maps.clear();
45
+ maps.set(cwd, { sig, value });
46
+ return value;
47
+ }
48
+
49
+ function parseMap(file, size) {
50
+ const shown = MAP_FILE.replaceAll('\\', '/');
51
+ // Read whole or not at all: half a map would draw modules without the edges that belong to them.
52
+ if (size > MAX_BYTES) return { measured: false, reason: `${shown} is ${size} bytes, over the ${MAX_BYTES} Studio reads` };
53
+ let doc;
54
+ try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return { measured: false, reason: `${shown} is not JSON (${e.message.slice(0, 80)})` }; }
55
+ if (!SCHEMA.test(String(doc?.schema))) return { measured: false, reason: `${shown} says schema ${JSON.stringify(doc?.schema ?? null)}, and Studio reads crewforth.crew-map/1` };
56
+ for (const k of ['nodes', 'edges', 'findings', 'notMeasured']) {
57
+ if (!Array.isArray(doc[k])) return { measured: false, reason: `${shown} has no ${k} list` };
58
+ }
59
+ return { measured: true, file: shown, bytes: size, doc };
60
+ }
61
+
62
+ /** What the navigator says of a project's map: null with none, else how many findings it shows unasked. */
63
+ export function mapSummary(cwd) {
64
+ const m = readMap(cwd);
65
+ if (!m.measured) return m.missing ? null : { findings: null, reason: m.reason };
66
+ return { findings: m.doc.findings.filter((f) => !f?.data?.defaultHidden).length };
67
+ }
68
+
69
+ /**
70
+ * How many commits HEAD is past the commit the map was scanned at: { count } or { count: null, reason }.
71
+ * Only a commit id is handed to git, so nothing in the file can become an option.
72
+ */
73
+ export function behindOf(cwd, commit) {
74
+ if (typeof commit !== 'string' || !/^[0-9a-f]{7,64}$/.test(commit)) return Promise.resolve({ count: null, reason: 'the map names no commit' });
75
+ return new Promise((resolve) => {
76
+ execFile('git', ['rev-list', '--count', `${commit}..HEAD`], { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true }, (err, stdout) => {
77
+ const n = Number.parseInt(String(stdout).trim(), 10);
78
+ if (err || !Number.isFinite(n)) resolve({ count: null, reason: 'git could not say (the commit may not be in this clone)' });
79
+ else resolve({ count: n });
80
+ });
81
+ });
82
+ }
83
+
84
+ /** One endpoint as it is matched: the method, and the path with each parameter written as one `*`. */
85
+ export function endpointKey(e) {
86
+ const p = String(e?.path ?? '').split('?')[0].replace(/\/+$/, '') || '/';
87
+ const segs = p.split('/').map((s) => (/^(:\w+|\{[^}]*\}|<[^>]*>|\*)$/.test(s) ? '*' : s));
88
+ return `${String(e?.method ?? '').toUpperCase()} ${segs.join('/')}`;
89
+ }
90
+
91
+ /**
92
+ * Another project's calls into this one. `doc` is this project's map, `other` the other project's. A call of the
93
+ * other project is compared when no endpoint of its own provides it (the scanner's own endpoint-missing). Each
94
+ * calling module of the other project is one client: what it calls here that this project provides (`calls`,
95
+ * each with `to`, the module of this project that provides it),
96
+ * and what else it calls that its own project does not provide either (`missing`). A module none of whose calls
97
+ * this project provides is not a client of it, and is not listed: its calls go to a third place, and naming them
98
+ * missing here would say something the two maps do not.
99
+ */
100
+ export function compareMaps(doc, other) {
101
+ const own = new Set((other?.provides?.endpoints ?? []).map(endpointKey));
102
+ const here = new Map((doc?.provides?.endpoints ?? []).map((e) => [endpointKey(e), e.module]));
103
+ const names = new Map((other?.nodes ?? []).filter((n) => n.kind === 'module').map((n) => [n.id, n]));
104
+ const by = new Map();
105
+ for (const e of other?.consumes?.endpoints ?? []) {
106
+ const k = endpointKey(e);
107
+ if (own.has(k)) continue;
108
+ const c = by.get(e.module) ?? by.set(e.module, { id: e.module, name: names.get(e.module)?.name ?? e.module, tech: names.get(e.module)?.tech ?? [], calls: [], missing: [] }).get(e.module);
109
+ const call = { method: e.method, path: e.path, evidence: (e.evidence ?? []).slice(0, 1) };
110
+ if (here.has(k)) c.calls.push({ ...call, to: here.get(k) }); else c.missing.push(call);
111
+ }
112
+ return [...by.values()].filter((c) => c.calls.length).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
113
+ }
@@ -17,7 +17,7 @@
17
17
  import fsp from 'node:fs/promises';
18
18
  import path from 'node:path';
19
19
 
20
- import { readAll, contextFill } from './transcript.js';
20
+ import { readAll, contextFill, contextMix } from './transcript.js';
21
21
  import { agentMetaFiles } from './projects.js';
22
22
  import { Usage, withTimes } from './usage.js';
23
23
  import { detailOf } from './permissions.js';
@@ -139,6 +139,7 @@ export function linkRetries(calls) {
139
139
  if (!c.refused) return;
140
140
  const again = list.slice(i + 1).find((n) => n.subagentType === c.subagentType && n.description === c.description);
141
141
  if (!again) return;
142
+ c.retried = true;
142
143
  // A call refused twice keeps the first thing that was asked.
143
144
  again.firstTry = c.firstTry ?? { asked: c.model, floor: c.refused.floor, text: c.refused.text };
144
145
  });
@@ -456,7 +457,14 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
456
457
  version: main.version,
457
458
  status: 'session',
458
459
  turns: main.userTurns,
460
+ // Agent calls the model gate refused and the session has not made again: no agent started, so nothing else on
461
+ // the canvas says they happened (seen on a real project: two refused calls, an empty canvas, "2 failed" only in
462
+ // the session's own view). A refused call made again is shown on the call that ran (`firstTry`).
463
+ refusedCalls: [...main.agentCalls.values()].filter((c) => c.refused && !c.retried)
464
+ .map((c) => ({ agent: c.subagentType ?? null, model: c.model ?? null, text: c.refused.text })),
459
465
  tokens: contextFill(records),
466
+ // What that context is made of: the tool-output share since the last compaction, and the compactions so far.
467
+ context: contextMix(records),
460
468
  startedAt: main.startedAt,
461
469
  endedAt: main.updatedAt,
462
470
  usage: withTimes(usage, records),
@@ -52,11 +52,36 @@ function kitPaths(cwd) {
52
52
  * empty unless CREW_GATE_LOG_CMD=1 was set, and there is no timestamp at all —
53
53
  * both facts are returned rather than papered over.
54
54
  */
55
+ /**
56
+ * Where the gate log is, by the rule the gates write by (eval/lib/gate-log.sh): .claude/gate-log.tsv, else the
57
+ * repository's own .git/crewforth-gate-log.tsv (a project that tracks .claude/ has it there), searched from the
58
+ * project root up. null when there is none yet.
59
+ */
60
+ export function gateLogPath(cwd) {
61
+ const own = path.join(cwd, '.claude', 'gate-log.tsv');
62
+ if (fs.existsSync(own)) return own;
63
+ let d = cwd;
64
+ for (let n = 0; n < 12; n += 1) {
65
+ const g = path.join(d, '.git');
66
+ let st = null;
67
+ try { st = fs.statSync(g); } catch { /* not here */ }
68
+ if (st?.isDirectory()) { const f = path.join(g, 'crewforth-gate-log.tsv'); return fs.existsSync(f) ? f : null; }
69
+ if (st?.isFile()) {
70
+ const m = /^gitdir: (.+)$/m.exec(fs.readFileSync(g, 'utf8'));
71
+ if (!m) return null;
72
+ const gd = path.resolve(d, m[1].trim()); const f = path.join(gd, 'crewforth-gate-log.tsv');
73
+ return fs.existsSync(f) ? f : null;
74
+ }
75
+ const up = path.dirname(d); if (up === d) return null; d = up;
76
+ }
77
+ return null;
78
+ }
79
+
55
80
  export function gateLog(cwd, { limit = 200 } = {}) {
56
- const file = path.join(cwd, '.claude', 'gate-log.tsv');
81
+ const file = gateLogPath(cwd);
57
82
  let st;
58
- try { st = fs.statSync(file); } catch {
59
- return { measured: false, reason: 'no .claude/gate-log.tsv in this project', entries: [] };
83
+ try { st = fs.statSync(file ?? ''); } catch {
84
+ return { measured: false, reason: 'no gate log in this project yet (.claude/gate-log.tsv, or .git/crewforth-gate-log.tsv where .claude/ is tracked)', entries: [] };
60
85
  }
61
86
 
62
87
  // Only the tail: this file reached 21,730 lines in Crewforth's own checkout and
@@ -119,9 +144,9 @@ export async function gateReport(cwd) {
119
144
  // should read is one level up. Both layouts are given what they expect rather
120
145
  // than the script being asked to guess.
121
146
  const from = kit.kind === 'installed' ? cwd : path.dirname(kit.hooks);
122
- const logPath = path.join(cwd, '.claude', 'gate-log.tsv');
147
+ const logPath = gateLogPath(cwd);
123
148
  const env = { ...process.env };
124
- if (kit.kind !== 'installed' && fs.existsSync(logPath)) env.CREW_GATE_LOG = logPath;
149
+ if (logPath) env.CREW_GATE_LOG = logPath;
125
150
 
126
151
  const r = await run('bash', [script, '--json'], { cwd: from, env });
127
152
  // 3 means there is no log to read, which the script calls "not an error"; 4