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,150 @@
1
+ // The permission bridge.
2
+ //
3
+ // A PreToolUse hook injected through --settings parks each tool call, writes a
4
+ // request into a spool, and waits for an answer file. The panel reads the spool
5
+ // and writes the answer. The hook's exit code carries the decision: 0 allows,
6
+ // 2 blocks.
7
+ //
8
+ // Why not simply let the hook time out on a "no": measured on this machine, a
9
+ // hook killed at its configured timeout emits nothing and the tool PROCEEDS —
10
+ // permission_denials came back 0 and the command ran. So the hook decides for
11
+ // itself well inside that limit, and a hook the harness never has to kill fails
12
+ // closed. The panel being shut is a denial, not an opening.
13
+
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
20
+ const HOOK = path.resolve(HERE, '..', 'hooks', 'studio-gate.sh');
21
+
22
+ // The hook answers at HOOK_WAIT; the harness is told a larger number, so the
23
+ // two never race and the harness has nothing to kill.
24
+ const HOOK_WAIT_S = 45;
25
+ const HARNESS_TIMEOUT_S = 90;
26
+ const POLL_MS = 250;
27
+
28
+ const ROOT = path.join(os.tmpdir(), 'crew-studio-gate');
29
+
30
+ export function spoolFor(sessionId) {
31
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) throw new Error('bad session id');
32
+ return path.join(ROOT, sessionId);
33
+ }
34
+
35
+ /**
36
+ * Write the settings file a session is spawned with.
37
+ *
38
+ * It is the panel's own file in the panel's own directory: the user's settings
39
+ * are never read, written, or merged. Returns null when the hook is missing, so
40
+ * the caller can say "no gate" instead of quietly running without one.
41
+ */
42
+ export function prepare(sessionId) {
43
+ if (!fs.existsSync(HOOK)) return null;
44
+
45
+ const spool = spoolFor(sessionId);
46
+ for (const d of ['req', 'ans', 'always']) fs.mkdirSync(path.join(spool, d), { recursive: true });
47
+
48
+ const settingsPath = path.join(spool, 'settings.json');
49
+ const settings = {
50
+ hooks: {
51
+ PreToolUse: [{
52
+ // Every tool, not a chosen few: the panel cannot claim to gate a session
53
+ // while quietly exempting whichever tool it forgot to list.
54
+ matcher: '*',
55
+ hooks: [{
56
+ type: 'command',
57
+ command: `CREW_GATE_WAIT=${HOOK_WAIT_S} bash ${JSON.stringify(HOOK)} ${JSON.stringify(spool)}`,
58
+ timeout: HARNESS_TIMEOUT_S,
59
+ }],
60
+ }],
61
+ },
62
+ };
63
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
64
+ return { settingsPath, spool, waitSeconds: HOOK_WAIT_S };
65
+ }
66
+
67
+ /** One pending request, as the panel needs to show it. */
68
+ function readRequest(spool, file) {
69
+ const toolUseId = file.replace(/\.json$/, '');
70
+ let raw;
71
+ try { raw = fs.readFileSync(path.join(spool, 'req', file), 'utf8'); } catch { return null; }
72
+
73
+ let payload = {};
74
+ try { payload = JSON.parse(raw); } catch { /* the hook writes it verbatim; keep going */ }
75
+
76
+ const input = payload.tool_input ?? {};
77
+ return {
78
+ toolUseId,
79
+ toolName: payload.tool_name ?? 'unknown',
80
+ // The most identifying field each tool has. Showing "Bash" alone would ask
81
+ // someone to approve a command they cannot see.
82
+ detail: input.command ?? input.file_path ?? input.pattern ?? input.query ?? input.description ?? null,
83
+ input,
84
+ cwd: payload.cwd ?? null,
85
+ askedAt: (() => {
86
+ try { return fs.statSync(path.join(spool, 'req', file)).mtimeMs; } catch { return Date.now(); }
87
+ })(),
88
+ };
89
+ }
90
+
91
+ export function pending(sessionId) {
92
+ const spool = spoolFor(sessionId);
93
+ let files;
94
+ try { files = fs.readdirSync(path.join(spool, 'req')); } catch { return []; }
95
+ return files
96
+ .filter((f) => f.endsWith('.json'))
97
+ .map((f) => readRequest(spool, f))
98
+ .filter(Boolean)
99
+ .sort((a, b) => a.askedAt - b.askedAt);
100
+ }
101
+
102
+ export function alwaysList(sessionId) {
103
+ try { return fs.readdirSync(path.join(spoolFor(sessionId), 'always')); } catch { return []; }
104
+ }
105
+
106
+ /**
107
+ * Answer one request. `always` also allows that tool for the rest of the
108
+ * session, which is the user widening their own gate deliberately — it is
109
+ * scoped to one session and disappears with it.
110
+ */
111
+ export function decide(sessionId, toolUseId, verdict) {
112
+ if (!/^[A-Za-z0-9_-]+$/.test(toolUseId)) return { ok: false, reason: 'bad tool use id' };
113
+ if (!['allow', 'deny', 'always'].includes(verdict)) return { ok: false, reason: `unknown verdict: ${verdict}` };
114
+
115
+ const spool = spoolFor(sessionId);
116
+ const req = pending(sessionId).find((r) => r.toolUseId === toolUseId);
117
+
118
+ if (verdict === 'always') {
119
+ const tool = req?.toolName;
120
+ if (tool && /^[A-Za-z0-9_-]+$/.test(tool)) {
121
+ try { fs.writeFileSync(path.join(spool, 'always', tool), ''); } catch { /* best effort */ }
122
+ }
123
+ }
124
+
125
+ try {
126
+ fs.writeFileSync(path.join(spool, 'ans', toolUseId), `${verdict}\n`);
127
+ } catch (e) {
128
+ return { ok: false, reason: String(e?.message ?? e) };
129
+ }
130
+ return { ok: true, verdict, toolName: req?.toolName ?? null };
131
+ }
132
+
133
+ /** Watch a session's spool and call back whenever the pending set changes. */
134
+ export function watch(sessionId, onChange) {
135
+ let last = '';
136
+ const tick = () => {
137
+ const now = pending(sessionId);
138
+ const sig = now.map((r) => r.toolUseId).join(',');
139
+ if (sig !== last) { last = sig; onChange(now); }
140
+ };
141
+ tick();
142
+ const timer = setInterval(tick, POLL_MS);
143
+ return () => clearInterval(timer);
144
+ }
145
+
146
+ export function cleanup(sessionId) {
147
+ try { fs.rmSync(spoolFor(sessionId), { recursive: true, force: true }); } catch { /* already gone */ }
148
+ }
149
+
150
+ export const _internals = { HOOK, HOOK_WAIT_S, HARNESS_TIMEOUT_S, ROOT };
@@ -0,0 +1,294 @@
1
+ // Locating a project's transcripts on disk.
2
+ //
3
+ // Claude Code stores them under ~/.claude/projects/<encoded-cwd>/, where the
4
+ // encoding folds `: \ / . _` all down to `-`. That is lossy: two different
5
+ // directories can encode to the same name, so this asks the filesystem rather
6
+ // than trusting the spelling.
7
+ //
8
+ // Crewforth already solves this in bash (utilization.sh, context-usage.sh,
9
+ // session-stats.sh share one byte-identical block that smoke-test §6i3 pins).
10
+ // This is a re-implementation in another language on purpose — a fourth bash
11
+ // copy would break that pin.
12
+
13
+ // EVERY filesystem call here is async, and that is the point rather than a style
14
+ // choice. On a Windows machine a single synchronous 128 KiB tail read once took
15
+ // 31,209 ms, and because it blocked the event loop other requests waited with it:
16
+ // `/api/projects` took 33,391 ms and a separate `/api/health` on its own connection
17
+ // went unanswered for 312 of 324 pings. Measured later on the same machine with no
18
+ // kit code running, a plain `tail -c 131072` of a freshly copied 25 MB transcript
19
+ // waited 63-65 s right after the copy, 0.04 s warm, and 0.044 s when it waited 90 s
20
+ // first. What holds the file was not identified, and the wait could not be produced
21
+ // on demand. Async does not make the read faster; it keeps the wait inside the one
22
+ // request that hit it while the rest of the panel stays up.
23
+ import fsp from 'node:fs/promises';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+
27
+ // Overridable so a second instance can be pointed at a different transcript
28
+ // store — which is also the only way to exercise the peer relay on one machine.
29
+ export const PROJECTS_ROOT = process.env.CREW_STUDIO_PROJECTS_ROOT
30
+ || path.join(os.homedir(), '.claude', 'projects');
31
+
32
+ /** Fold a working directory into the directory name Claude Code uses. */
33
+ export function encodeCwd(cwd) {
34
+ return cwd.replace(/[:\\/._]/g, '-');
35
+ }
36
+
37
+ /** Candidate directory names for a cwd, best first. */
38
+ export function candidateDirs(cwd) {
39
+ const seen = new Set();
40
+ const out = [];
41
+ for (const c of [encodeCwd(cwd), cwd.replace(/[/.]/g, '-'), cwd.replace(/\//g, '-')]) {
42
+ if (c && !seen.has(c)) { seen.add(c); out.push(c); }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** The project directory for a cwd, or null when nothing was recorded yet. */
48
+ export async function projectDir(cwd) {
49
+ for (const name of candidateDirs(cwd)) {
50
+ const p = path.join(PROJECTS_ROOT, name);
51
+ try { if ((await fsp.stat(p)).isDirectory()) return p; } catch { /* next */ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Every project directory on this machine. */
57
+ export async function allProjectDirs() {
58
+ try {
59
+ const entries = await fsp.readdir(PROJECTS_ROOT, { withFileTypes: true });
60
+ return entries.filter((e) => e.isDirectory()).map((e) => path.join(PROJECTS_ROOT, e.name));
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ // Sessions carry three kinds of name, in descending order of intent:
67
+ // a custom title the person typed, the agent name, and an AI-written topic.
68
+ // Reading them means scanning the transcript, and the sidebar refreshes on a
69
+ // timer over every session in the project — so only the tail is read (the
70
+ // latest record wins anyway) and the result is cached against size+mtime.
71
+ const TITLE_TAIL_BYTES = 131072;
72
+ const titleCache = new Map(); // file -> { sig, title }
73
+
74
+ export async function sessionTitle(file, size, mtimeMs) {
75
+ const sig = `${size}:${mtimeMs}`;
76
+ const hit = titleCache.get(file);
77
+ if (hit && hit.sig === sig) return hit.title;
78
+
79
+ let title = null;
80
+ let fh;
81
+ try {
82
+ fh = await fsp.open(file, 'r');
83
+ const len = Math.min(size, TITLE_TAIL_BYTES);
84
+ const buf = Buffer.allocUnsafe(len);
85
+ await fh.read(buf, 0, len, Math.max(0, size - len));
86
+ const text = buf.toString('utf8');
87
+
88
+ // Cheap prefilter: most sessions carry none of these, and parsing every
89
+ // line of a 128 KiB tail for all of them is wasted work.
90
+ const found = { custom: null, agent: null, ai: null };
91
+ if (/"(custom-title|agent-name|ai-title)"/.test(text)) {
92
+ for (const line of text.split('\n')) {
93
+ if (!line.startsWith('{')) continue; // tail cut mid-record
94
+ if (!line.includes('-title"') && !line.includes('agent-name"')) continue;
95
+ let r;
96
+ try { r = JSON.parse(line); } catch { continue; }
97
+ if (r.type === 'custom-title' && r.customTitle) found.custom = r.customTitle;
98
+ else if (r.type === 'agent-name' && r.agentName) found.agent = r.agentName;
99
+ else if (r.type === 'ai-title' && r.aiTitle) found.ai = r.aiTitle;
100
+ }
101
+ }
102
+ title = found.custom ?? found.agent ?? found.ai ?? null;
103
+ } catch {
104
+ title = null;
105
+ } finally {
106
+ if (fh !== undefined) try { await fh.close(); } catch { /* already gone */ }
107
+ }
108
+
109
+ if (titleCache.size > 500) titleCache.clear();
110
+ titleCache.set(file, { sig, title });
111
+ return title;
112
+ }
113
+
114
+ // The working directory a session ran in. The encoded folder name cannot be
115
+ // decoded back — the fold is lossy — so it is read from the first record,
116
+ // which carries `cwd`. Head, not tail: the value never changes mid-session.
117
+ const cwdCache = new Map();
118
+
119
+ export async function sessionCwd(file, size) {
120
+ const hit = cwdCache.get(file);
121
+ if (hit && hit.size === size) return hit.cwd;
122
+
123
+ let cwd = null;
124
+ let fh;
125
+ try {
126
+ fh = await fsp.open(file, 'r');
127
+ const len = Math.min(size, 8192);
128
+ const buf = Buffer.allocUnsafe(len);
129
+ await fh.read(buf, 0, len, 0);
130
+ cwd = buf.toString('utf8').match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/)?.[1] ?? null;
131
+ if (cwd) cwd = cwd.replace(/\\\\/g, '\\').replace(/\\"/g, '"');
132
+ } catch {
133
+ cwd = null;
134
+ } finally {
135
+ if (fh !== undefined) try { await fh.close(); } catch { /* already gone */ }
136
+ }
137
+
138
+ if (cwdCache.size > 500) cwdCache.clear();
139
+ cwdCache.set(file, { size, cwd });
140
+ return cwd;
141
+ }
142
+
143
+ /**
144
+ * Sessions inside one project directory.
145
+ * The tree is `<sid>.jsonl` at the top and a `<sid>/subagents/` directory
146
+ * beside it — counting only the top level is how you end up reporting that
147
+ * nothing ever delegated.
148
+ */
149
+ export async function listSessions(dir) {
150
+ let entries;
151
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return []; }
152
+
153
+ // One transcript's reads do not depend on another's, so they run together.
154
+ // Sequential awaits cost 12 ms here where the synchronous version cost 7 —
155
+ // measured, 10 runs each — and that gap grows with the number of sessions.
156
+ // The order is irrelevant: the list is sorted by mtime below.
157
+ const settled = await Promise.all(entries.map(async (e) => {
158
+ if (!e.isFile() || !e.name.endsWith('.jsonl')) return null;
159
+ const sessionId = e.name.slice(0, -'.jsonl'.length);
160
+ const file = path.join(dir, e.name);
161
+ let st;
162
+ try { st = await fsp.stat(file); } catch { return null; }
163
+
164
+ const subagentsDir = path.join(dir, sessionId, 'subagents');
165
+ const [agentCount, title, cwd] = await Promise.all([
166
+ countAgentMetas(subagentsDir),
167
+ sessionTitle(file, st.size, st.mtimeMs),
168
+ sessionCwd(file, st.size),
169
+ ]);
170
+
171
+ return { sessionId, file, subagentsDir, bytes: st.size, modifiedAt: st.mtimeMs, agentCount, title, cwd };
172
+ }));
173
+
174
+ const out = settled.filter(Boolean);
175
+ out.sort((a, b) => b.modifiedAt - a.modifiedAt);
176
+ return out;
177
+ }
178
+
179
+ /** Find one session anywhere on this machine, by id. */
180
+ export async function findSession(sessionId) {
181
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) return null; // it becomes a path
182
+ for (const dir of await allProjectDirs()) {
183
+ const file = path.join(dir, `${sessionId}.jsonl`);
184
+ try {
185
+ const st = await fsp.stat(file);
186
+ return {
187
+ sessionId,
188
+ file,
189
+ dir,
190
+ subagentsDir: path.join(dir, sessionId, 'subagents'),
191
+ bytes: st.size,
192
+ modifiedAt: st.mtimeMs,
193
+ };
194
+ } catch { /* next project */ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * How many agents a session spawned.
201
+ *
202
+ * Agents land in two places: directly in `subagents/`, and one level deeper
203
+ * under `subagents/workflows/<id>/` when a workflow ran. Measured on this
204
+ * machine, 324 of 596 metas — 54% — were in the nested form, so a flat read
205
+ * silently reported a busy session as having delegated nothing.
206
+ */
207
+ export async function countAgentMetas(subagentsDir) {
208
+ return (await agentMetaFiles(subagentsDir)).length;
209
+ }
210
+
211
+ /** Every agent meta file under a subagents directory, nesting included. */
212
+ export async function agentMetaFiles(subagentsDir, depth = 0) {
213
+ const out = [];
214
+ if (depth > 3) return out; // workflows nest one level; this is slack
215
+ let entries;
216
+ try { entries = await fsp.readdir(subagentsDir, { withFileTypes: true }); } catch { return out; }
217
+ for (const e of entries) {
218
+ const p = path.join(subagentsDir, e.name);
219
+ if (e.isDirectory()) out.push(...await agentMetaFiles(p, depth + 1));
220
+ else if (e.name.endsWith('.meta.json')) out.push(p);
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * Every session on this machine, grouped by the project it ran in.
227
+ *
228
+ * Grouping is by the recorded `cwd` rather than by transcript folder: a folder
229
+ * name is a lossy fold of the path, so two different projects can share one.
230
+ * The folder is only the fallback when no record carried a cwd.
231
+ */
232
+ export async function listProjects({ currentCwd = null, limitPerProject = 60, kitOf = null } = {}) {
233
+ const groups = new Map();
234
+
235
+ const dirs = await allProjectDirs();
236
+ const perDir = await Promise.all(dirs.map(async (dir) => [dir, await listSessions(dir)]));
237
+
238
+ for (const [dir, sessions] of perDir) {
239
+ for (const s of sessions) {
240
+ const key = s.cwd ?? `dir:${path.basename(dir)}`;
241
+ if (!groups.has(key)) {
242
+ groups.set(key, { key, cwd: s.cwd, dir, label: labelFor(s.cwd, dir), sessions: [] });
243
+ }
244
+ groups.get(key).sessions.push(s);
245
+ }
246
+ }
247
+
248
+ const out = [];
249
+ for (const g of groups.values()) {
250
+ g.sessions.sort((a, b) => b.modifiedAt - a.modifiedAt);
251
+ const kit = kitOf ? await kitOf(g.cwd) : null;
252
+ out.push({
253
+ ...g,
254
+ kit,
255
+ // Most transcript folders on a busy machine point at directories that no
256
+ // longer exist — test scratch dirs, moved checkouts. They are counted,
257
+ // not deleted: the panel says how many it is holding back rather than
258
+ // quietly shortening the list.
259
+ exists: kit?.dirExists ?? (g.cwd ? await existsCached(g.cwd) : false),
260
+ total: g.sessions.length,
261
+ agentTotal: g.sessions.reduce((n, s) => n + s.agentCount, 0),
262
+ modifiedAt: g.sessions[0]?.modifiedAt ?? 0,
263
+ current: currentCwd != null && g.cwd === currentCwd,
264
+ // Long histories are truncated rather than silently dropped, and the
265
+ // count above still reports the whole.
266
+ sessions: g.sessions.slice(0, limitPerProject),
267
+ });
268
+ }
269
+
270
+ // Standing-in first, then live directories, then an outdated kit ahead of a
271
+ // current one — the rows that need a decision float up.
272
+ out.sort((a, b) =>
273
+ Number(b.current) - Number(a.current) ||
274
+ Number(b.exists) - Number(a.exists) ||
275
+ Number(Boolean(b.kit?.outdated)) - Number(Boolean(a.kit?.outdated)) ||
276
+ b.modifiedAt - a.modifiedAt);
277
+ return out;
278
+ }
279
+
280
+ function labelFor(cwd, dir) {
281
+ if (!cwd) return path.basename(dir);
282
+ const parts = cwd.split(/[/\\]/).filter(Boolean);
283
+ return parts[parts.length - 1] || cwd;
284
+ }
285
+
286
+ const existsMemo = new Map();
287
+ async function existsCached(p) {
288
+ if (existsMemo.has(p)) return existsMemo.get(p);
289
+ let v = false;
290
+ try { await fsp.stat(p); v = true; } catch { v = false; }
291
+ if (existsMemo.size > 2000) existsMemo.clear();
292
+ existsMemo.set(p, v);
293
+ return v;
294
+ }
@@ -0,0 +1,191 @@
1
+ // Sessions reachable elsewhere.
2
+ //
3
+ // A peer on another machine is visible only to a session connected to Remote
4
+ // Control — measured: a headless session's ListAgents returns three local peers
5
+ // where a connected one returns eight, and passing --remote-control to a
6
+ // headless session does not change that, because the flag starts an
7
+ // INTERACTIVE session.
8
+ //
9
+ // So the panel does not try to be that session. It reads what one already
10
+ // wrote: an interactive session's ListAgents result lands in its transcript as
11
+ // a tool_result, which is structured text on disk rather than a screen to
12
+ // scrape. That makes this a SNAPSHOT — the roster as it stood when some
13
+ // connected session last asked — and it is labelled that way everywhere,
14
+ // because a stale list drawn as a live one is the kind of lie this panel is
15
+ // built to avoid.
16
+
17
+ import fsp from 'node:fs/promises';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ import { allProjectDirs } from './projects.js';
22
+
23
+ const SCAN_FILES = 60; // most recently written transcripts
24
+ // The few newest files are read whole; the rest only from the tail. A live
25
+ // session's transcript grows past any fixed tail, and the note this looks for
26
+ // was written once — it scrolls out of a 2 MB window within an hour of work,
27
+ // which is how the roster went from five machines to none while nothing about
28
+ // the machines had changed.
29
+ const DEEP_FILES = 6;
30
+ const DEEP_BYTES = 24 * 1024 * 1024;
31
+ const TAIL_BYTES = 2 * 1024 * 1024;
32
+ // The transcript holding a roster ages out of the scan as new sessions are
33
+ // written, and the peers do not disappear when it does — the panel just stops
34
+ // being able to see the note. The last one found is kept, with the time it was
35
+ // found, so a stale answer stays a stale answer instead of becoming "none".
36
+ const CACHE_FILE = path.join(os.tmpdir(), 'crew-studio-roster.json');
37
+
38
+ /** Parse the block ListAgents prints. */
39
+ export function parseRoster(text) {
40
+ if (!text || !text.includes('Peer sessions')) return null;
41
+
42
+ const self = text.match(/This session is (\S+) \[([0-9a-f]+)\]/);
43
+ const peers = [];
44
+ // " name [hash] · kind · status · started 3h ago"
45
+ const re = /^\s{2,}(\S+)\s+\[([0-9a-f]+)\]\s+·\s+(.+)$/gm;
46
+ let m;
47
+ while ((m = re.exec(text)) !== null) {
48
+ const parts = m[3].split('·').map((x) => x.trim()).filter(Boolean);
49
+ peers.push({
50
+ name: m[1],
51
+ ref: m[2],
52
+ kind: parts[0] ?? null,
53
+ status: parts[1] ?? null,
54
+ // Everything after is free text ("started 3h ago"); kept, not parsed into
55
+ // a time it might not be.
56
+ note: parts.slice(2).join(' · ') || null,
57
+ remote: /remote control/i.test(parts[0] ?? ''),
58
+ });
59
+ }
60
+ if (!peers.length) return null;
61
+ return { self: self ? { name: self[1], ref: self[2] } : null, peers };
62
+ }
63
+
64
+ // Async for the reason projects.js gives: this reads transcript tails too (TAIL_BYTES, and
65
+ // DEEP_BYTES for the newest few), and it runs on the `/api/fleet` path the panel polls every 2 s.
66
+ async function readTail(file, size, limit = TAIL_BYTES) {
67
+ let fh;
68
+ try {
69
+ fh = await fsp.open(file, 'r');
70
+ const len = Math.min(size, limit);
71
+ const buf = Buffer.allocUnsafe(len);
72
+ await fh.read(buf, 0, len, Math.max(0, size - len));
73
+ return buf.toString('utf8');
74
+ } catch {
75
+ return '';
76
+ } finally {
77
+ if (fh !== undefined) try { await fh.close(); } catch { /* gone */ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The most recent roster any session recorded.
83
+ *
84
+ * Returns not-measured rather than an empty list when nothing has asked yet:
85
+ * "no session has run ListAgents" and "you have no peers" are different facts.
86
+ */
87
+ export async function remoteRoster() {
88
+ const files = [];
89
+ for (const dir of await allProjectDirs()) {
90
+ let entries;
91
+ try { entries = await fsp.readdir(dir); } catch { continue; }
92
+ const stats = await Promise.all(entries.map(async (e) => {
93
+ if (!e.endsWith('.jsonl')) return null;
94
+ const p = path.join(dir, e);
95
+ try {
96
+ const st = await fsp.stat(p);
97
+ return { path: p, mtime: st.mtimeMs, size: st.size, sessionId: e.slice(0, -6) };
98
+ } catch { return null; } // raced
99
+ }));
100
+ for (const s of stats) if (s) files.push(s);
101
+ }
102
+ files.sort((a, b) => b.mtime - a.mtime);
103
+
104
+ let best = null;
105
+ for (const [i, f] of files.slice(0, SCAN_FILES).entries()) {
106
+ const text = await readTail(f.path, f.size, i < DEEP_FILES ? DEEP_BYTES : TAIL_BYTES);
107
+ if (!text.includes('Peer sessions')) continue;
108
+
109
+ // The block lives INSIDE a JSON string, so its line breaks are the two
110
+ // characters \\n rather than real ones. Reading the raw tail with a
111
+ // line-anchored regex found nothing at all, which is how this was caught:
112
+ // the records have to be parsed, not grepped.
113
+ for (const line of text.split('\n')) {
114
+ if (!line.includes('Peer sessions') || !line.startsWith('{')) continue;
115
+ let rec;
116
+ try { rec = JSON.parse(line); } catch { continue; }
117
+
118
+ const chunks = [];
119
+ const content = rec?.message?.content;
120
+ if (typeof content === 'string') chunks.push(content);
121
+ else if (Array.isArray(content)) {
122
+ for (const c of content) {
123
+ if (typeof c?.text === 'string') chunks.push(c.text);
124
+ const inner = c?.content;
125
+ if (typeof inner === 'string') chunks.push(inner);
126
+ else if (Array.isArray(inner)) for (const x of inner) if (typeof x?.text === 'string') chunks.push(x.text);
127
+ }
128
+ }
129
+
130
+ for (const chunk of chunks) {
131
+ const parsed = parseRoster(chunk);
132
+ if (!parsed) continue;
133
+ // Prefer the roster that actually saw remote machines: a local-only one
134
+ // is a session that was not connected, and reporting it as the answer
135
+ // would say the peers had gone away.
136
+ const remotes = parsed.peers.filter((p) => p.remote).length;
137
+ const cand = { ...parsed, sessionId: f.sessionId, at: f.mtime, remotes };
138
+ if (!best || remotes > best.remotes || (remotes === best.remotes && cand.at > best.at)) best = cand;
139
+ }
140
+ }
141
+ }
142
+
143
+ const cached = await readCache();
144
+
145
+ if (!best) {
146
+ if (cached) return { ...cached, fromCache: true };
147
+ return {
148
+ measured: false,
149
+ reason: 'no session on this machine has recorded a peer list yet — run /list-agents in a session connected to Remote Control',
150
+ peers: [],
151
+ };
152
+ }
153
+
154
+ const found = {
155
+ measured: true,
156
+ // Said plainly: this is when a session last looked, not now.
157
+ seenAt: best.at,
158
+ seenBy: best.sessionId,
159
+ self: best.self,
160
+ peers: best.peers,
161
+ remotes: best.remotes,
162
+ live: false,
163
+ };
164
+
165
+ // A fresh scan that saw no remote machines does not overrule a remembered one
166
+ // that did: the newest note is usually from a headless session, which cannot
167
+ // see them at all.
168
+ if (cached && cached.remotes > found.remotes && cached.seenAt > Date.now() - CACHE_MAX_AGE_MS) {
169
+ return { ...cached, fromCache: true };
170
+ }
171
+
172
+ await writeCache(found);
173
+ return found;
174
+ }
175
+
176
+ const CACHE_MAX_AGE_MS = 12 * 60 * 60 * 1000;
177
+
178
+ async function readCache() {
179
+ try {
180
+ const c = JSON.parse(await fsp.readFile(CACHE_FILE, 'utf8'));
181
+ if (!c?.measured || !Array.isArray(c.peers)) return null;
182
+ if (typeof c.seenAt !== 'number' || c.seenAt < Date.now() - CACHE_MAX_AGE_MS) return null;
183
+ return c;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ async function writeCache(roster) {
190
+ try { await fsp.writeFile(CACHE_FILE, JSON.stringify(roster)); } catch { /* best effort */ }
191
+ }