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.
- package/CHANGELOG.md +3784 -0
- package/LICENSE +21 -0
- package/README.md +37 -7
- package/VERSION +1 -0
- package/adopt.sh +1670 -0
- package/bin/cli.js +497 -0
- package/kit/AGENT_TEMPLATE.md +92 -0
- package/kit/CLAUDE.md +228 -0
- package/kit/README.md +107 -0
- package/kit/agents/crew-backend-expert.md +95 -0
- package/kit/agents/crew-commit-agent.md +96 -0
- package/kit/agents/crew-database-expert.md +84 -0
- package/kit/agents/crew-devops-expert.md +82 -0
- package/kit/agents/crew-frontend-expert.md +85 -0
- package/kit/agents/crew-performance-expert.md +78 -0
- package/kit/agents/crew-planner.md +60 -0
- package/kit/agents/crew-privacy-agent.md +71 -0
- package/kit/agents/crew-review-agent.md +95 -0
- package/kit/agents/crew-security-expert.md +73 -0
- package/kit/agents/crew-session-manager.md +77 -0
- package/kit/agents/crew-test-expert.md +64 -0
- package/kit/eval/doctor.sh +465 -0
- package/kit/eval/gate-report.sh +137 -0
- package/kit/eval/golden-holdout.txt +53 -0
- package/kit/eval/golden-routing.txt +199 -0
- package/kit/eval/lib/crew-env.sh +14 -0
- package/kit/eval/lib/settings-json.awk +154 -0
- package/kit/eval/lib/star.sh +70 -0
- package/kit/eval/parser-conformance.sh +612 -0
- package/kit/eval/preflight.sh +186 -0
- package/kit/eval/routing-eval.sh +209 -0
- package/kit/eval/scan-skill.sh +162 -0
- package/kit/eval/smoke-test.sh +6693 -0
- package/kit/eval/update-guard.sh +81 -0
- package/kit/eval/utilization.sh +130 -0
- package/kit/hooks/board-sync.sh +106 -0
- package/kit/hooks/board.sh +962 -0
- package/kit/hooks/commit-msg +49 -0
- package/kit/hooks/context-usage.sh +353 -0
- package/kit/hooks/floor-blocklist.txt +124 -0
- package/kit/hooks/guard-bash.sh +1431 -0
- package/kit/hooks/guard-commit-scan.sh +511 -0
- package/kit/hooks/guard-write.sh +513 -0
- package/kit/hooks/pre-commit +577 -0
- package/kit/hooks/route-hint.sh +185 -0
- package/kit/hooks/secret-blocklist.txt +82 -0
- package/kit/hooks/session-guard.sh +149 -0
- package/kit/hooks/session-rehydrate.sh +41 -0
- package/kit/hooks/session-stats.sh +252 -0
- package/kit/hooks/session-update-check.sh +236 -0
- package/kit/hooks/skill-trust.sh +150 -0
- package/kit/hooks/trace-blocklist.txt +53 -0
- package/kit/settings.json +132 -0
- package/kit/skills/a11y/SKILL.md +44 -0
- package/kit/skills/adr/SKILL.md +74 -0
- package/kit/skills/api-design/SKILL.md +50 -0
- package/kit/skills/automode-policy/SKILL.md +91 -0
- package/kit/skills/automode-policy/references/policy.json +13 -0
- package/kit/skills/automode-policy/scripts/apply.sh +84 -0
- package/kit/skills/automode-policy/scripts/check.sh +102 -0
- package/kit/skills/backend-architecture/SKILL.md +103 -0
- package/kit/skills/brainstorm/SKILL.md +47 -0
- package/kit/skills/ci-pipeline/SKILL.md +38 -0
- package/kit/skills/commit-message/SKILL.md +82 -0
- package/kit/skills/commit-message/references/examples.md +8 -0
- package/kit/skills/confidence-check/SKILL.md +86 -0
- package/kit/skills/crew-board/SKILL.md +55 -0
- package/kit/skills/crew-brainstorm/SKILL.md +19 -0
- package/kit/skills/crew-code-review/SKILL.md +151 -0
- package/kit/skills/crew-code-review/references/history-search.md +19 -0
- package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
- package/kit/skills/crew-code-review/references/receiving.md +23 -0
- package/kit/skills/crew-doctor/SKILL.md +36 -0
- package/kit/skills/crew-gates/SKILL.md +25 -0
- package/kit/skills/crew-handoff/SKILL.md +14 -0
- package/kit/skills/crew-plan/SKILL.md +18 -0
- package/kit/skills/crew-review/SKILL.md +23 -0
- package/kit/skills/crew-ship/SKILL.md +19 -0
- package/kit/skills/crew-skill/SKILL.md +61 -0
- package/kit/skills/crew-studio/SKILL.md +129 -0
- package/kit/skills/crew-update/SKILL.md +43 -0
- package/kit/skills/db-migration/SKILL.md +120 -0
- package/kit/skills/db-migration/references/backup-restore.md +33 -0
- package/kit/skills/db-migration/references/raw-sql.md +30 -0
- package/kit/skills/db-migration/references/tool-matrix.md +35 -0
- package/kit/skills/dependency-audit/SKILL.md +61 -0
- package/kit/skills/dependency-upgrade/SKILL.md +82 -0
- package/kit/skills/deploy/SKILL.md +100 -0
- package/kit/skills/deploy/references/proxy-ssl.md +56 -0
- package/kit/skills/deploy/references/rollback.md +18 -0
- package/kit/skills/deploy/references/scripts.md +9 -0
- package/kit/skills/deploy/references/topology-a.md +128 -0
- package/kit/skills/deploy/references/topology-b.md +44 -0
- package/kit/skills/docs-writer/SKILL.md +53 -0
- package/kit/skills/docs-writer/references/coauthoring.md +34 -0
- package/kit/skills/eval-grader/SKILL.md +75 -0
- package/kit/skills/eval-grader/references/method.md +51 -0
- package/kit/skills/frontend/SKILL.md +56 -0
- package/kit/skills/frontend/references/verify-contract.md +41 -0
- package/kit/skills/frontend-design/SKILL.md +71 -0
- package/kit/skills/frontend-design/references/design-review.md +34 -0
- package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
- package/kit/skills/handoff/SKILL.md +71 -0
- package/kit/skills/handoff/references/cold-reader.md +51 -0
- package/kit/skills/i18n-integrity/SKILL.md +31 -0
- package/kit/skills/incident-runbook/SKILL.md +40 -0
- package/kit/skills/incident-runbook/references/postmortem.md +16 -0
- package/kit/skills/iterate/SKILL.md +40 -0
- package/kit/skills/mcp-builder/SKILL.md +54 -0
- package/kit/skills/mcp-builder/references/building.md +71 -0
- package/kit/skills/observability/SKILL.md +50 -0
- package/kit/skills/performance/SKILL.md +38 -0
- package/kit/skills/performance/references/bottlenecks.md +10 -0
- package/kit/skills/privacy-compliance/SKILL.md +75 -0
- package/kit/skills/red-team/SKILL.md +48 -0
- package/kit/skills/reflect/SKILL.md +57 -0
- package/kit/skills/release/SKILL.md +44 -0
- package/kit/skills/security-scan/SKILL.md +99 -0
- package/kit/skills/security-scan/references/ai-agents.md +107 -0
- package/kit/skills/security-scan/references/fronts.md +99 -0
- package/kit/skills/security-scan/references/prompting.md +23 -0
- package/kit/skills/security-scan/references/reporting.md +75 -0
- package/kit/skills/security-scan/references/verify.md +131 -0
- package/kit/skills/sonarqube-check/SKILL.md +108 -0
- package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
- package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
- package/kit/skills/spec-planning/SKILL.md +92 -0
- package/kit/skills/systematic-debugging/SKILL.md +57 -0
- package/kit/skills/systematic-debugging/references/techniques.md +44 -0
- package/kit/skills/teamboard/SKILL.md +88 -0
- package/kit/skills/teamboard/references/setup.md +37 -0
- package/kit/skills/testing/SKILL.md +49 -0
- package/kit/skills/testing/references/flaky-triage.md +51 -0
- package/kit/skills/threat-model/SKILL.md +57 -0
- package/kit/skills/threat-model/references/interview.md +51 -0
- package/kit/skills/threat-model/references/schema.md +42 -0
- package/kit/skills/token-budget/SKILL.md +51 -0
- package/kit/skills/trace-scan/SKILL.md +43 -0
- package/kit/skills/worktree/SKILL.md +40 -0
- package/kit/studio/README.md +266 -0
- package/kit/studio/ensure-node.sh +471 -0
- package/kit/studio/package.json +14 -0
- package/kit/studio/server/hooks/studio-gate.sh +169 -0
- package/kit/studio/server/index.js +902 -0
- package/kit/studio/server/lib/crew-env.js +11 -0
- package/kit/studio/server/lib/fleet.js +146 -0
- package/kit/studio/server/lib/graph.js +533 -0
- package/kit/studio/server/lib/instance.js +135 -0
- package/kit/studio/server/lib/kit-telemetry.js +182 -0
- package/kit/studio/server/lib/kit.js +158 -0
- package/kit/studio/server/lib/palette.js +118 -0
- package/kit/studio/server/lib/peers.js +72 -0
- package/kit/studio/server/lib/permissions.js +150 -0
- package/kit/studio/server/lib/projects.js +294 -0
- package/kit/studio/server/lib/roster.js +191 -0
- package/kit/studio/server/lib/session.js +309 -0
- package/kit/studio/server/lib/terminal.js +91 -0
- package/kit/studio/server/lib/transcript.js +100 -0
- package/kit/studio/web/app.js +1098 -0
- package/kit/studio/web/canvas.js +1179 -0
- package/kit/studio/web/chat.js +543 -0
- package/kit/studio/web/index.html +86 -0
- package/kit/studio/web/md.js +110 -0
- package/kit/studio/web/storage-migrate.js +23 -0
- package/kit/studio/web/style.css +1453 -0
- package/package.json +46 -8
- package/start.sh +748 -0
- package/bin/crewforth.js +0 -10
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Crewforth Studio — the server half.
|
|
3
|
+
//
|
|
4
|
+
// It binds to loopback and nothing else. The panel reads a developer's live
|
|
5
|
+
// sessions, transcripts and, later, drives Claude itself; none of that should
|
|
6
|
+
// be reachable from the network, so the host is not configurable.
|
|
7
|
+
//
|
|
8
|
+
// Zero dependencies, by design: Crewforth ships no npm packages and this stays
|
|
9
|
+
// inside that promise. `node server/index.js` is the whole install step.
|
|
10
|
+
|
|
11
|
+
import './lib/crew-env.js'; // first: resolves the 2.x variable names before anything reads CREW_*
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import fsp from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
18
|
+
|
|
19
|
+
import { getFleet, measureSpawnCost } from './lib/fleet.js';
|
|
20
|
+
import { projectDir, listSessions, findSession, listProjects, sessionCwd } from './lib/projects.js';
|
|
21
|
+
import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
|
|
22
|
+
import { palette } from './lib/palette.js';
|
|
23
|
+
import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
|
|
24
|
+
import { parsePeers, askAll, ask } from './lib/peers.js';
|
|
25
|
+
import {
|
|
26
|
+
createSession, getSession, listSessionsOwned, reap, stopAll, ALLOWED_MODES,
|
|
27
|
+
} from './lib/session.js';
|
|
28
|
+
import { decide, pending, alwaysList } from './lib/permissions.js';
|
|
29
|
+
import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
|
|
30
|
+
import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
|
|
31
|
+
import { writeState, clearStateSync, findRunning } from './lib/instance.js';
|
|
32
|
+
import { remoteRoster } from './lib/roster.js';
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import { spawn } from 'node:child_process';
|
|
35
|
+
|
|
36
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const WEB_ROOT = path.resolve(HERE, '..', 'web');
|
|
38
|
+
const LOOPBACK = '127.0.0.1';
|
|
39
|
+
|
|
40
|
+
const MIME = {
|
|
41
|
+
'.html': 'text/html; charset=utf-8',
|
|
42
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
43
|
+
'.css': 'text/css; charset=utf-8',
|
|
44
|
+
'.json': 'application/json; charset=utf-8',
|
|
45
|
+
'.svg': 'image/svg+xml',
|
|
46
|
+
'.ico': 'image/x-icon',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const out = { port: 7777, selftest: false, open: false, peers: [], name: null };
|
|
51
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === '--port' || a === '-p') {
|
|
54
|
+
const n = Number(argv[i + 1]);
|
|
55
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
56
|
+
throw new Error(`--port needs a number between 1 and 65535, got: ${argv[i + 1]}`);
|
|
57
|
+
}
|
|
58
|
+
out.port = n;
|
|
59
|
+
i += 1;
|
|
60
|
+
} else if (a === '--peer') {
|
|
61
|
+
if (!argv[i + 1]) throw new Error('--peer needs a URL');
|
|
62
|
+
out.peers.push(argv[i + 1]);
|
|
63
|
+
i += 1;
|
|
64
|
+
} else if (a === '--name') {
|
|
65
|
+
if (!argv[i + 1]) throw new Error('--name needs a label');
|
|
66
|
+
out.name = argv[i + 1];
|
|
67
|
+
i += 1;
|
|
68
|
+
} else if (a === '--open' || a === '-o') {
|
|
69
|
+
out.open = true;
|
|
70
|
+
} else if (a === '--selftest') {
|
|
71
|
+
out.selftest = true;
|
|
72
|
+
} else if (a === '--help' || a === '-h') {
|
|
73
|
+
out.help = true;
|
|
74
|
+
} else {
|
|
75
|
+
throw new Error(`unknown argument: ${a}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function send(res, status, body, headers = {}) {
|
|
82
|
+
res.writeHead(status, {
|
|
83
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
84
|
+
'cache-control': 'no-store',
|
|
85
|
+
...headers,
|
|
86
|
+
});
|
|
87
|
+
res.end(body);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sendJson(res, status, value) {
|
|
91
|
+
send(res, status, JSON.stringify(value, null, 2), {
|
|
92
|
+
'content-type': 'application/json; charset=utf-8',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Auth is opt-in while every endpoint is read-only and loopback-bound. It
|
|
97
|
+
// becomes mandatory in the sprint that adds write endpoints; wiring it now
|
|
98
|
+
// means that switch is a default change, not a retrofit.
|
|
99
|
+
// Once the panel can start a session, a token stops being optional: any page
|
|
100
|
+
// in the user's browser can POST to loopback. One is generated when none is
|
|
101
|
+
// supplied and printed with the URL, so the default is safe rather than
|
|
102
|
+
// convenient-and-open.
|
|
103
|
+
const TOKEN = process.env.CREW_STUDIO_TOKEN || randomUUID();
|
|
104
|
+
const TOKEN_GENERATED = !process.env.CREW_STUDIO_TOKEN;
|
|
105
|
+
|
|
106
|
+
// Peers are other machines running Studio, reached over a forwarded port. This
|
|
107
|
+
// server still listens on loopback only; a peer never widens that.
|
|
108
|
+
let PEERS = [];
|
|
109
|
+
let SELF_NAME = 'this machine';
|
|
110
|
+
|
|
111
|
+
function authorised(req, url) {
|
|
112
|
+
const header = req.headers['authorization'];
|
|
113
|
+
if (header === `Bearer ${TOKEN}`) return true;
|
|
114
|
+
return url.searchParams.get('token') === TOKEN;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Extra gate for anything that changes state.
|
|
119
|
+
*
|
|
120
|
+
* The token alone already stops a drive-by page, since it cannot read the
|
|
121
|
+
* query string of another origin. The header requirement makes the attempt
|
|
122
|
+
* fail earlier: a cross-origin POST carrying a custom header must pass a
|
|
123
|
+
* preflight first, and this server answers none.
|
|
124
|
+
*/
|
|
125
|
+
export function writeAllowed(req) {
|
|
126
|
+
if (req.headers['x-crew-studio'] !== '1') {
|
|
127
|
+
return { ok: false, reason: 'missing x-crew-studio header' };
|
|
128
|
+
}
|
|
129
|
+
const origin = req.headers.origin;
|
|
130
|
+
if (origin) {
|
|
131
|
+
let host;
|
|
132
|
+
try { host = new URL(origin).hostname; } catch { return { ok: false, reason: 'bad Origin' }; }
|
|
133
|
+
if (host !== LOOPBACK && host !== 'localhost') return { ok: false, reason: `cross-origin request from ${origin}` };
|
|
134
|
+
}
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
139
|
+
|
|
140
|
+
function readBody(req) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
let size = 0;
|
|
143
|
+
const parts = [];
|
|
144
|
+
req.on('data', (c) => {
|
|
145
|
+
size += c.length;
|
|
146
|
+
if (size > MAX_BODY_BYTES) { req.destroy(); resolve(null); return; }
|
|
147
|
+
parts.push(c);
|
|
148
|
+
});
|
|
149
|
+
req.on('end', () => {
|
|
150
|
+
if (!parts.length) return resolve({});
|
|
151
|
+
try { resolve(JSON.parse(Buffer.concat(parts).toString('utf8'))); } catch { resolve(null); }
|
|
152
|
+
});
|
|
153
|
+
req.on('error', () => resolve(null));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function serveStatic(res, urlPath) {
|
|
158
|
+
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
|
159
|
+
// Resolve, then prove the result is still inside web/. Comparing spellings
|
|
160
|
+
// is not enough; ask the resolver.
|
|
161
|
+
const abs = path.resolve(WEB_ROOT, `.${rel}`);
|
|
162
|
+
if (abs !== WEB_ROOT && !abs.startsWith(WEB_ROOT + path.sep)) {
|
|
163
|
+
return send(res, 403, 'forbidden');
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const data = await fsp.readFile(abs);
|
|
167
|
+
return send(res, 200, data, {
|
|
168
|
+
'content-type': MIME[path.extname(abs)] ?? 'application/octet-stream',
|
|
169
|
+
});
|
|
170
|
+
} catch {
|
|
171
|
+
return send(res, 404, 'not found');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function handle(req, res) {
|
|
176
|
+
const url = new URL(req.url, `http://${LOOPBACK}`);
|
|
177
|
+
|
|
178
|
+
// The token guards data and actions, not the app shell. A browser does not
|
|
179
|
+
// carry the page's query string into its sub-resource requests, so demanding
|
|
180
|
+
// it for /style.css served an unstyled page and protected nothing: the shell
|
|
181
|
+
// holds no secrets, and every /api/ path below is still gated.
|
|
182
|
+
if (url.pathname.startsWith('/api/') && !authorised(req, url)) {
|
|
183
|
+
return sendJson(res, 403, { ok: false, reason: 'token required' });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (url.pathname === '/api/health') {
|
|
187
|
+
return sendJson(res, 200, {
|
|
188
|
+
ok: true,
|
|
189
|
+
node: process.version,
|
|
190
|
+
pid: process.pid,
|
|
191
|
+
uptimeMs: Math.round(process.uptime() * 1000),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (url.pathname === '/api/fleet') {
|
|
196
|
+
const local = await getFleet();
|
|
197
|
+
const mine = (local.sessions ?? []).map((sn) => ({ ...sn, origin: SELF_NAME, local: true }));
|
|
198
|
+
|
|
199
|
+
// Self is always in origins, peers or not: a machine that never names
|
|
200
|
+
// itself cannot be labelled by the one reading it.
|
|
201
|
+
const self = { name: SELF_NAME, local: true, ok: true, sessions: mine.length };
|
|
202
|
+
// Sessions reachable on other machines. Not live and not this machine's —
|
|
203
|
+
// a snapshot of what a Remote-Control-connected session last recorded.
|
|
204
|
+
const roster = await remoteRoster();
|
|
205
|
+
if (!PEERS.length) return sendJson(res, 200, { ...local, sessions: mine, origins: [self], roster });
|
|
206
|
+
|
|
207
|
+
const answers = await askAll(PEERS, '/api/fleet');
|
|
208
|
+
const sessions = [...mine];
|
|
209
|
+
const origins = [self];
|
|
210
|
+
for (const a of answers) {
|
|
211
|
+
if (!a.ok) {
|
|
212
|
+
// An unreachable machine is reported as unreachable. Dropping it would
|
|
213
|
+
// read as "that machine is quiet".
|
|
214
|
+
origins.push({ name: a.peer.name, ok: false, reason: a.reason });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// A machine knows its own name; the URL we reached it on is only a
|
|
218
|
+
// fallback for one that does not say.
|
|
219
|
+
const label = a.body?.origins?.find((o) => o.local)?.name ?? a.peer.name;
|
|
220
|
+
const rows = (a.body?.sessions ?? []).map((sn) => ({ ...sn, origin: label, local: false }));
|
|
221
|
+
origins.push({ name: label, via: a.peer.name, ok: true, sessions: rows.length, measured: a.body?.measured !== false });
|
|
222
|
+
sessions.push(...rows);
|
|
223
|
+
}
|
|
224
|
+
return sendJson(res, 200, { measured: local.measured, sessions, origins, roster, at: Date.now() });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (url.pathname === '/api/projects') {
|
|
228
|
+
const cwd = url.searchParams.get('cwd') || process.cwd();
|
|
229
|
+
const started = Date.now();
|
|
230
|
+
// Cached, not awaited. The list is local data; making it wait on a registry cost 8.37 s of
|
|
231
|
+
// dead time whenever the feed hung, measured. The refresh runs behind this response.
|
|
232
|
+
const latest = latestVersionCached();
|
|
233
|
+
const projects = await listProjects({ currentCwd: cwd, kitOf: (c) => kitStatus(c, latest) });
|
|
234
|
+
for (const p of projects) { p.origin = SELF_NAME; p.local = true; }
|
|
235
|
+
|
|
236
|
+
const origins = [{ name: SELF_NAME, local: true, ok: true, projects: projects.length }];
|
|
237
|
+
if (PEERS.length) {
|
|
238
|
+
for (const a of await askAll(PEERS, '/api/projects')) {
|
|
239
|
+
if (!a.ok) { origins.push({ name: a.peer.name, ok: false, reason: a.reason }); continue; }
|
|
240
|
+
const label = a.body?.origins?.find((o) => o.local)?.name ?? a.peer.name;
|
|
241
|
+
const rows = (a.body?.projects ?? []).map((pr) => ({
|
|
242
|
+
...pr,
|
|
243
|
+
origin: label,
|
|
244
|
+
local: false,
|
|
245
|
+
current: false, // "the project you are standing in" is a local fact
|
|
246
|
+
key: `${label}|${pr.key}`,
|
|
247
|
+
}));
|
|
248
|
+
origins.push({ name: label, via: a.peer.name, ok: true, projects: rows.length });
|
|
249
|
+
projects.push(...rows);
|
|
250
|
+
}
|
|
251
|
+
projects.sort((a, b) =>
|
|
252
|
+
Number(b.current) - Number(a.current) ||
|
|
253
|
+
Number(b.local) - Number(a.local) ||
|
|
254
|
+
Number(b.exists) - Number(a.exists) ||
|
|
255
|
+
b.modifiedAt - a.modifiedAt);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const installed = projects.filter((p) => p.kit?.installed);
|
|
259
|
+
return sendJson(res, 200, {
|
|
260
|
+
measured: true,
|
|
261
|
+
cwd,
|
|
262
|
+
latest,
|
|
263
|
+
origins,
|
|
264
|
+
projects,
|
|
265
|
+
totals: {
|
|
266
|
+
projects: projects.length,
|
|
267
|
+
live: projects.filter((p) => p.exists).length,
|
|
268
|
+
sessions: projects.reduce((n, p) => n + p.total, 0),
|
|
269
|
+
agents: projects.reduce((n, p) => n + p.agentTotal, 0),
|
|
270
|
+
kitInstalled: installed.length,
|
|
271
|
+
kitOutdated: installed.filter((p) => p.kit.outdated).length,
|
|
272
|
+
kitUncompared: installed.filter((p) => p.kit.compared === false).length,
|
|
273
|
+
},
|
|
274
|
+
buildMs: Date.now() - started,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// What Crewforth already measures about itself, reported as Crewforth reports it.
|
|
279
|
+
if (url.pathname === '/api/kit') {
|
|
280
|
+
const cwd = url.searchParams.get('cwd') || process.cwd();
|
|
281
|
+
const sid = url.searchParams.get('session');
|
|
282
|
+
const session = sid ? await findSession(sid) : null;
|
|
283
|
+
const [report, stats, brd] = await Promise.all([
|
|
284
|
+
gateReport(cwd),
|
|
285
|
+
session ? sessionStats(cwd, session.file) : Promise.resolve({ measured: false, reason: 'no session named' }),
|
|
286
|
+
board(cwd),
|
|
287
|
+
]);
|
|
288
|
+
return sendJson(res, 200, { measured: true, cwd, log: gateLog(cwd), report, stats, board: brd });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (url.pathname === '/api/palette') {
|
|
292
|
+
return sendJson(res, 200, palette());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (url.pathname === '/api/sessions') {
|
|
296
|
+
const cwd = url.searchParams.get('cwd') || process.cwd();
|
|
297
|
+
const dir = await projectDir(cwd);
|
|
298
|
+
if (!dir) {
|
|
299
|
+
// Nothing was read. That is not the same as "this project never ran".
|
|
300
|
+
return sendJson(res, 200, {
|
|
301
|
+
measured: false,
|
|
302
|
+
reason: `no transcript directory for ${cwd}`,
|
|
303
|
+
cwd,
|
|
304
|
+
sessions: [],
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const sessions = (await listSessions(dir)).map((s) => ({
|
|
308
|
+
sessionId: s.sessionId,
|
|
309
|
+
bytes: s.bytes,
|
|
310
|
+
modifiedAt: s.modifiedAt,
|
|
311
|
+
agentCount: s.agentCount,
|
|
312
|
+
}));
|
|
313
|
+
return sendJson(res, 200, { measured: true, cwd, dir, sessions });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const graphMatch = url.pathname.match(/^\/api\/session\/([^/]+)\/graph$/);
|
|
317
|
+
if (graphMatch) {
|
|
318
|
+
const session = await findSession(decodeURIComponent(graphMatch[1]));
|
|
319
|
+
if (!session) {
|
|
320
|
+
const relayed = await relay(url.pathname);
|
|
321
|
+
if (relayed) return sendJson(res, 200, relayed);
|
|
322
|
+
return sendJson(res, 404, { measured: false, reason: 'no such session on this machine or any peer' });
|
|
323
|
+
}
|
|
324
|
+
const started = Date.now();
|
|
325
|
+
const graph = await buildGraph(session);
|
|
326
|
+
return sendJson(res, 200, { ...graph, measured: true, buildMs: Date.now() - started });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const convMatch = url.pathname.match(/^\/api\/session\/([^/]+)\/conversation$/);
|
|
330
|
+
if (convMatch) {
|
|
331
|
+
const session = await findSession(decodeURIComponent(convMatch[1]));
|
|
332
|
+
if (!session) {
|
|
333
|
+
const relayed = await relay(url.pathname);
|
|
334
|
+
if (relayed) return sendJson(res, 200, relayed);
|
|
335
|
+
return sendJson(res, 404, { measured: false, reason: 'no such session on this machine or any peer' });
|
|
336
|
+
}
|
|
337
|
+
return sendJson(res, 200, await conversation(session));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const agentMatch = url.pathname.match(/^\/api\/session\/([^/]+)\/agent\/([^/]+)$/);
|
|
341
|
+
if (agentMatch) {
|
|
342
|
+
const session = await findSession(decodeURIComponent(agentMatch[1]));
|
|
343
|
+
if (!session) {
|
|
344
|
+
const relayed = await relay(url.pathname);
|
|
345
|
+
if (relayed) return sendJson(res, 200, relayed);
|
|
346
|
+
return sendJson(res, 404, { measured: false, reason: 'no such session' });
|
|
347
|
+
}
|
|
348
|
+
const detail = await agentDetail(session, decodeURIComponent(agentMatch[2]));
|
|
349
|
+
if (!detail) return sendJson(res, 404, { measured: false, reason: 'no transcript for that agent' });
|
|
350
|
+
return sendJson(res, 200, { ...detail, measured: true });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Hand an observed session back to a real terminal, where it can be driven.
|
|
354
|
+
const termMatch = url.pathname.match(/^\/api\/session\/([^/]+)\/terminal$/);
|
|
355
|
+
if (termMatch) {
|
|
356
|
+
const id = decodeURIComponent(termMatch[1]);
|
|
357
|
+
const session = await findSession(id);
|
|
358
|
+
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
359
|
+
|
|
360
|
+
if (req.method === 'GET') {
|
|
361
|
+
// What WOULD run, so the panel can show it before anything is launched.
|
|
362
|
+
return sendJson(res, 200, { measured: true, cwd, plan: cwd ? terminalPlan({ cwd, sessionId: id }) : null });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const gate = writeAllowed(req);
|
|
366
|
+
if (!gate.ok) return sendJson(res, 403, { ok: false, reason: gate.reason });
|
|
367
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, reason: 'POST only' });
|
|
368
|
+
|
|
369
|
+
const body = await readBody(req);
|
|
370
|
+
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
371
|
+
const out = openTerminal({
|
|
372
|
+
cwd: typeof body.cwd === 'string' ? body.cwd : cwd,
|
|
373
|
+
sessionId: id,
|
|
374
|
+
command: typeof body.command === 'string' ? body.command : undefined,
|
|
375
|
+
});
|
|
376
|
+
return sendJson(res, out.ok ? 200 : 400, out);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (url.pathname === '/api/stream') {
|
|
380
|
+
return stream(req, res, url);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* ---------------------------------------------------------- owned ---
|
|
384
|
+
Sessions the panel started. Their transcripts land in the usual place,
|
|
385
|
+
so the graph endpoints already cover them; only the conversation needs
|
|
386
|
+
a channel of its own. */
|
|
387
|
+
|
|
388
|
+
if (url.pathname === '/api/owned' && req.method === 'GET') {
|
|
389
|
+
reap();
|
|
390
|
+
return sendJson(res, 200, { measured: true, modes: ALLOWED_MODES, sessions: listSessionsOwned() });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (url.pathname === '/api/owned' && req.method === 'POST') {
|
|
394
|
+
const gate = writeAllowed(req);
|
|
395
|
+
if (!gate.ok) return sendJson(res, 403, { ok: false, reason: gate.reason });
|
|
396
|
+
const body = await readBody(req);
|
|
397
|
+
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
398
|
+
const made = await createSession({
|
|
399
|
+
cwd: typeof body.cwd === 'string' ? body.cwd : undefined,
|
|
400
|
+
model: typeof body.model === 'string' ? body.model : undefined,
|
|
401
|
+
permissionMode: typeof body.permissionMode === 'string' ? body.permissionMode : undefined,
|
|
402
|
+
resume: typeof body.resume === 'string' ? body.resume : undefined,
|
|
403
|
+
});
|
|
404
|
+
if (!made.ok) return sendJson(res, 400, made);
|
|
405
|
+
return sendJson(res, 201, { ok: true, session: made.session.summary() });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Permission decisions: read the queue, or answer one item.
|
|
409
|
+
const permMatch = url.pathname.match(/^\/api\/owned\/([^/]+)\/permissions(?:\/([^/]+))?$/);
|
|
410
|
+
if (permMatch) {
|
|
411
|
+
const s = getSession(decodeURIComponent(permMatch[1]));
|
|
412
|
+
if (!s) return sendJson(res, 404, { ok: false, reason: 'no such owned session' });
|
|
413
|
+
|
|
414
|
+
if (req.method === 'GET') {
|
|
415
|
+
return sendJson(res, 200, {
|
|
416
|
+
measured: true,
|
|
417
|
+
gated: Boolean(s.gate),
|
|
418
|
+
waitSeconds: s.gate?.waitSeconds ?? null,
|
|
419
|
+
pending: s.gate ? pending(s.id) : [],
|
|
420
|
+
always: s.gate ? alwaysList(s.id) : [],
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const gate = writeAllowed(req);
|
|
425
|
+
if (!gate.ok) return sendJson(res, 403, { ok: false, reason: gate.reason });
|
|
426
|
+
if (req.method !== 'POST' || !permMatch[2]) return sendJson(res, 405, { ok: false, reason: 'POST to a request id' });
|
|
427
|
+
if (!s.gate) return sendJson(res, 409, { ok: false, reason: 'this session has no gate to answer' });
|
|
428
|
+
|
|
429
|
+
const body = await readBody(req);
|
|
430
|
+
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
431
|
+
const out = decide(s.id, decodeURIComponent(permMatch[2]), body.verdict);
|
|
432
|
+
return sendJson(res, out.ok ? 200 : 400, out);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events))?$/);
|
|
436
|
+
if (ownedMatch) {
|
|
437
|
+
const s = getSession(decodeURIComponent(ownedMatch[1]));
|
|
438
|
+
if (!s) return sendJson(res, 404, { ok: false, reason: 'no such owned session' });
|
|
439
|
+
const verb = ownedMatch[2];
|
|
440
|
+
|
|
441
|
+
if (!verb) return sendJson(res, 200, { measured: true, session: s.summary() });
|
|
442
|
+
|
|
443
|
+
if (verb === 'events') return ownedStream(req, res, url, s);
|
|
444
|
+
|
|
445
|
+
const gate = writeAllowed(req);
|
|
446
|
+
if (!gate.ok) return sendJson(res, 403, { ok: false, reason: gate.reason });
|
|
447
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, reason: 'POST only' });
|
|
448
|
+
|
|
449
|
+
if (verb === 'stop') return sendJson(res, 200, { ...s.stop(), session: s.summary() });
|
|
450
|
+
|
|
451
|
+
const body = await readBody(req);
|
|
452
|
+
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
453
|
+
const sent = s.send(body.text);
|
|
454
|
+
return sendJson(res, sent.ok ? 200 : 400, { ...sent, session: s.summary() });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (url.pathname.startsWith('/api/')) {
|
|
458
|
+
return sendJson(res, 404, { error: `no such endpoint: ${url.pathname}` });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
462
|
+
return send(res, 405, 'method not allowed');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return serveStatic(res, url.pathname);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* ---------------------------------------------------------------- stream
|
|
469
|
+
Server-sent events. A WebSocket would need a dependency or a hand-rolled
|
|
470
|
+
frame codec; SSE is one-way, which is all the graph needs, and it reconnects
|
|
471
|
+
on its own.
|
|
472
|
+
|
|
473
|
+
Files are polled rather than watched: fs.watch's event semantics differ per
|
|
474
|
+
platform, and a graph rebuild measured 17 ms on a 1.5 MB transcript, so a
|
|
475
|
+
cheap signature check plus a conditional rebuild is both simpler and honest
|
|
476
|
+
about its cost. */
|
|
477
|
+
|
|
478
|
+
const STREAM_TICK_MS = 700;
|
|
479
|
+
// How coarsely the clock enters the stream signature while an agent is still
|
|
480
|
+
// recent enough to be called running. Chosen against the two costs it sits
|
|
481
|
+
// between: at the 700 ms tick it would rebuild the graph on every tick for no
|
|
482
|
+
// new information, and at the 120 s stale window it would only ever fire after
|
|
483
|
+
// the transition it exists to announce. Ten seconds is the delay a viewer waits
|
|
484
|
+
// to see "running" become "stale", and the price is one rebuild per ten seconds
|
|
485
|
+
// per watched session, only while something is live.
|
|
486
|
+
const STATUS_BUCKET_MS = 10000;
|
|
487
|
+
|
|
488
|
+
/** Ask each peer for a path this machine could not answer. */
|
|
489
|
+
async function relay(pathname) {
|
|
490
|
+
for (const p of PEERS) {
|
|
491
|
+
const r = await ask(p, pathname);
|
|
492
|
+
if (r.ok && r.body?.measured !== false) return { ...r.body, origin: p.name, relayed: true };
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Async for the same reason projects.js is: this runs on every stream tick — 700 ms,
|
|
498
|
+
// seven times more often than the project list is polled. No stat has been timed on
|
|
499
|
+
// its own: the timed stalls were a tail read, which opens and reads, and whole
|
|
500
|
+
// requests, which stat as well. The rule is the same anyway: a synchronous filesystem
|
|
501
|
+
// call holds the event loop for as long as it takes, and here it would do that on
|
|
502
|
+
// every tick. Keeping a slow one inside the tick that hit it is ours to do; making the
|
|
503
|
+
// filesystem faster is not.
|
|
504
|
+
/** Exported for the selfcheck. What it decides is only visible over a 45-second SSE capture otherwise,
|
|
505
|
+
* and a gate nobody can run in a millisecond is a gate that gets deleted. */
|
|
506
|
+
export async function signature(session) {
|
|
507
|
+
const parts = [];
|
|
508
|
+
try { parts.push(String((await fsp.stat(session.file)).size)); } catch { parts.push('0'); }
|
|
509
|
+
let newest = 0;
|
|
510
|
+
// FILES ONLY, AT ANY DEPTH. A workflow run puts its agents under `subagents/workflows/<id>/` — two levels
|
|
511
|
+
// down, not one — and buildGraph reads them. A flat readdir returns the `workflows` directory, whose size
|
|
512
|
+
// does not move when a transcript inside it grows, so a workflow's agents were invisible here while every
|
|
513
|
+
// plain agent's growth was seen. Recursing only ONE level was my first fix and it was still wrong: it
|
|
514
|
+
// stat'ed `workflows/<id>` as though the directory were the file. The selfcheck caught that, which a
|
|
515
|
+
// 45-second stream capture had not — the capture's extra frames came from the clock bucket below, and I had
|
|
516
|
+
// read them as proof of the traversal. Hence: walk to the leaves, and only ever stat a file.
|
|
517
|
+
const walk = async (dir, prefix) => {
|
|
518
|
+
let entries;
|
|
519
|
+
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
520
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
521
|
+
const p = path.join(dir, e.name);
|
|
522
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
523
|
+
if (e.isDirectory()) { await walk(p, rel); continue; }
|
|
524
|
+
try {
|
|
525
|
+
const st = await fsp.stat(p);
|
|
526
|
+
parts.push(`${rel}:${st.size}`);
|
|
527
|
+
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
528
|
+
} catch { /* vanished mid-scan */ }
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
await walk(session.subagentsDir, '');
|
|
532
|
+
// A STATUS CAN CHANGE WITH NO FILE CHANGING. buildGraph calls an agent
|
|
533
|
+
// `running` while `now - mtime < staleMs` and `stale`/`ended` after, so that
|
|
534
|
+
// transition is driven by the clock alone: nothing is written, no size moves,
|
|
535
|
+
// this signature does not change, and the graph is never rebuilt. The panel
|
|
536
|
+
// then holds `running` for an agent that went quiet, while the stream emits
|
|
537
|
+
// `idle` every tick. A coarse bucket, added ONLY while something is recent
|
|
538
|
+
// enough to still be called running, makes the transition arrive within the
|
|
539
|
+
// bucket and costs one rebuild per bucket (measured at 17 ms on a 1.5 MB
|
|
540
|
+
// transcript) instead of one per tick. When nothing is recent the bucket is
|
|
541
|
+
// absent, so an idle session's signature is as stable as it was before.
|
|
542
|
+
if (newest && Date.now() - newest < STALE_MS) parts.push(`t:${Math.floor(Date.now() / STATUS_BUCKET_MS)}`);
|
|
543
|
+
return parts.join('|');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function stream(req, res, url) {
|
|
547
|
+
const id = url.searchParams.get('session');
|
|
548
|
+
let session = id ? await findSession(id) : null;
|
|
549
|
+
|
|
550
|
+
res.writeHead(200, {
|
|
551
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
552
|
+
'cache-control': 'no-store',
|
|
553
|
+
connection: 'keep-alive',
|
|
554
|
+
'x-accel-buffering': 'no',
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const send = (event, data) => {
|
|
558
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// A session the panel just started has no transcript until its first turn
|
|
562
|
+
// lands. That is a session waiting, not a session missing, and saying "no
|
|
563
|
+
// such session" about one we are holding open would be a plain lie.
|
|
564
|
+
//
|
|
565
|
+
// The wait then has to hand over to the normal watch. Sending one graph and
|
|
566
|
+
// stopping left the canvas frozen on the first frame while the conversation
|
|
567
|
+
// carried on beside it.
|
|
568
|
+
if (!session && id && getSession(id)) {
|
|
569
|
+
send('waiting', { measured: true, sessionId: id, reason: 'no transcript yet — the first turn has not landed' });
|
|
570
|
+
|
|
571
|
+
let timer = null;
|
|
572
|
+
const stopWait = () => { if (timer) clearInterval(timer); try { res.end(); } catch { /* gone */ } };
|
|
573
|
+
req.on('close', stopWait);
|
|
574
|
+
req.on('error', stopWait);
|
|
575
|
+
|
|
576
|
+
let last = null;
|
|
577
|
+
// `setInterval` does not wait for an async callback, so a tick that outlasts the
|
|
578
|
+
// interval is joined by the next one rather than replacing it. Synchronous reads
|
|
579
|
+
// made that impossible — a blocked loop fires nothing — so this guard is repairing
|
|
580
|
+
// something the async conversion introduced, not a pre-existing bug. Measured with
|
|
581
|
+
// a 3 s read against a 700 ms tick: five callbacks in flight at once without it,
|
|
582
|
+
// one with it. A real 31 s read would stack about forty-four, each of them racing
|
|
583
|
+
// the same `last` and queueing another `1 + 1 + N` reads behind the first.
|
|
584
|
+
let busy = false;
|
|
585
|
+
const watch = async (found) => {
|
|
586
|
+
if (busy) return;
|
|
587
|
+
busy = true;
|
|
588
|
+
try {
|
|
589
|
+
const sig = await signature(found);
|
|
590
|
+
if (sig !== last) { last = sig; send('graph', await buildGraph(found)); }
|
|
591
|
+
else send('idle', { at: Date.now() });
|
|
592
|
+
} catch (e) {
|
|
593
|
+
send('fault', { measured: false, reason: String(e?.message ?? e) });
|
|
594
|
+
} finally {
|
|
595
|
+
busy = false;
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
timer = setInterval(async () => {
|
|
600
|
+
const found = await findSession(id);
|
|
601
|
+
if (!found) return; // still waiting for the first turn
|
|
602
|
+
clearInterval(timer);
|
|
603
|
+
watch(found);
|
|
604
|
+
timer = setInterval(() => watch(found), STREAM_TICK_MS);
|
|
605
|
+
}, 1000);
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!session) {
|
|
610
|
+
if (!id) { send('fault', { measured: false, reason: 'no session id given' }); return res.end(); }
|
|
611
|
+
// A remote session is polled through its peer rather than watched: this
|
|
612
|
+
// machine has no file to watch.
|
|
613
|
+
let stopped = false;
|
|
614
|
+
const pull = async () => {
|
|
615
|
+
if (stopped) return;
|
|
616
|
+
const relayed = await relay(`/api/session/${encodeURIComponent(id)}/graph`);
|
|
617
|
+
if (stopped) return;
|
|
618
|
+
if (relayed) send('graph', relayed);
|
|
619
|
+
else { send('fault', { measured: false, reason: 'no such session on this machine or any peer' }); stop(); }
|
|
620
|
+
};
|
|
621
|
+
const timer2 = setInterval(pull, Math.max(STREAM_TICK_MS * 3, 2000));
|
|
622
|
+
const stop = () => { stopped = true; clearInterval(timer2); try { res.end(); } catch { /* gone */ } };
|
|
623
|
+
req.on('close', stop);
|
|
624
|
+
req.on('error', stop);
|
|
625
|
+
pull();
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
let last = null;
|
|
630
|
+
let busy = false; // same re-entrancy guard as the watcher above
|
|
631
|
+
const tick = async () => {
|
|
632
|
+
if (busy) return;
|
|
633
|
+
busy = true;
|
|
634
|
+
try {
|
|
635
|
+
const sig = await signature(session);
|
|
636
|
+
if (sig !== last) {
|
|
637
|
+
last = sig;
|
|
638
|
+
send('graph', await buildGraph(session));
|
|
639
|
+
} else {
|
|
640
|
+
send('idle', { at: Date.now() });
|
|
641
|
+
}
|
|
642
|
+
} catch (e) {
|
|
643
|
+
send('fault', { measured: false, reason: String(e?.message ?? e) });
|
|
644
|
+
} finally {
|
|
645
|
+
busy = false;
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
tick();
|
|
650
|
+
const timer = setInterval(tick, STREAM_TICK_MS);
|
|
651
|
+
const stop = () => { clearInterval(timer); try { res.end(); } catch { /* gone */ } };
|
|
652
|
+
req.on('close', stop);
|
|
653
|
+
req.on('error', stop);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Open the panel in whatever the machine calls a browser.
|
|
658
|
+
*
|
|
659
|
+
* The URL carries a generated token, so copying it by hand is the one step
|
|
660
|
+
* between starting the server and using it. Failure is reported rather than
|
|
661
|
+
* assumed: a machine with no opener should say so, not look like it worked.
|
|
662
|
+
*/
|
|
663
|
+
function openBrowser(url) {
|
|
664
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
665
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
666
|
+
: ['xdg-open', [url]];
|
|
667
|
+
try {
|
|
668
|
+
const child = spawnDetached(cmd, args);
|
|
669
|
+
child.on('error', (e) => {
|
|
670
|
+
process.stdout.write(` (could not open a browser: ${e.code ?? e.message})\n`);
|
|
671
|
+
});
|
|
672
|
+
} catch (e) {
|
|
673
|
+
process.stdout.write(` (could not open a browser: ${e?.message ?? e})\n`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function spawnDetached(cmd, args) {
|
|
678
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
679
|
+
child.unref();
|
|
680
|
+
return child;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** The conversation of one owned session, replayed then followed. */
|
|
684
|
+
function ownedStream(req, res, url, session) {
|
|
685
|
+
res.writeHead(200, {
|
|
686
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
687
|
+
'cache-control': 'no-store',
|
|
688
|
+
connection: 'keep-alive',
|
|
689
|
+
'x-accel-buffering': 'no',
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
const write = (event, data) => {
|
|
693
|
+
try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ }
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// `after` lets a reconnecting client resume without replaying the whole
|
|
697
|
+
// conversation, and without missing what it dropped.
|
|
698
|
+
const after = Number(url.searchParams.get('after')) || 0;
|
|
699
|
+
write('state', session.summary());
|
|
700
|
+
|
|
701
|
+
const unsubscribe = session.subscribe((ev) => {
|
|
702
|
+
write('event', ev);
|
|
703
|
+
if (ev.rec?.type === 'result' || ev.rec?.type === 'exit') write('state', session.summary());
|
|
704
|
+
}, after);
|
|
705
|
+
|
|
706
|
+
const beat = setInterval(() => write('state', session.summary()), 15000);
|
|
707
|
+
const stop = () => { clearInterval(beat); unsubscribe(); try { res.end(); } catch { /* gone */ } };
|
|
708
|
+
req.on('close', stop);
|
|
709
|
+
req.on('error', stop);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function selftest() {
|
|
713
|
+
const checks = [];
|
|
714
|
+
const check = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
715
|
+
// A tool this machine does not have is an honest boundary, not a defect. The
|
|
716
|
+
// spawn-cost probe needs the claude CLI, which CI installs only in the ubuntu
|
|
717
|
+
// job — so on every other runner this said "skipped honestly" in its own detail
|
|
718
|
+
// text and then counted itself a failure, taking e2e down with it.
|
|
719
|
+
const skip = (name, detail) => checks.push({ name, skipped: true, detail });
|
|
720
|
+
|
|
721
|
+
check('web root exists', fs.existsSync(WEB_ROOT), WEB_ROOT);
|
|
722
|
+
check('index.html present', fs.existsSync(path.join(WEB_ROOT, 'index.html')), null);
|
|
723
|
+
|
|
724
|
+
const fleet = await getFleet({ force: true });
|
|
725
|
+
check(
|
|
726
|
+
'fleet read returned a verdict',
|
|
727
|
+
typeof fleet.measured === 'boolean',
|
|
728
|
+
fleet.measured ? `${fleet.sessions.length} session(s)` : `not measured: ${fleet.reason}`,
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
const cost = await measureSpawnCost(3);
|
|
732
|
+
if (cost === null) skip('spawn cost measured', 'claude CLI absent');
|
|
733
|
+
else check('spawn cost measured', true, `min ${cost.minMs}ms · median ${cost.medianMs}ms · max ${cost.maxMs}ms`);
|
|
734
|
+
|
|
735
|
+
for (const c of checks) {
|
|
736
|
+
const tag = c.skipped ? 'SKIP' : c.ok ? 'PASS' : 'FAIL';
|
|
737
|
+
process.stdout.write(`${tag} ${c.name}${c.detail ? ` — ${c.detail}` : ''}\n`);
|
|
738
|
+
}
|
|
739
|
+
const skipped = checks.filter((c) => c.skipped).length;
|
|
740
|
+
const graded = checks.filter((c) => !c.skipped);
|
|
741
|
+
// An empty run is a broken harness, not a clean bill of health — and "empty"
|
|
742
|
+
// has to mean nothing GRADED, not nothing recorded. Counting checks.length
|
|
743
|
+
// instead would let a run where every probe skipped report "0/0 passed" and
|
|
744
|
+
// exit 0. Unreachable today, because the first two checks always grade; the
|
|
745
|
+
// guard is written for the version of this function that has more skips in it.
|
|
746
|
+
if (!graded.length) {
|
|
747
|
+
process.stdout.write(`FAIL selftest graded nothing`
|
|
748
|
+
+ (skipped ? ` — all ${skipped} check(s) skipped` : ' — it ran zero checks')
|
|
749
|
+
+ '; the measurement is broken, not the server\n');
|
|
750
|
+
return 1;
|
|
751
|
+
}
|
|
752
|
+
const failed = graded.filter((c) => !c.ok).length;
|
|
753
|
+
process.stdout.write(`\n${graded.length - failed}/${graded.length} passed`
|
|
754
|
+
+ (skipped ? `, ${skipped} skipped` : '') + '\n');
|
|
755
|
+
return failed ? 1 : 0;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function main() {
|
|
759
|
+
let args;
|
|
760
|
+
try {
|
|
761
|
+
args = parseArgs(process.argv.slice(2));
|
|
762
|
+
} catch (e) {
|
|
763
|
+
process.stderr.write(`crewforth-studio: ${e.message}\n`);
|
|
764
|
+
process.exit(64);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (args.help) {
|
|
768
|
+
process.stdout.write(
|
|
769
|
+
'crewforth-studio — visual orchestration panel for Claude Code\n\n' +
|
|
770
|
+
' --port <n> port to listen on (default 7777, loopback only)\n' +
|
|
771
|
+
' --peer <url> another machine running Studio (repeatable)\n' +
|
|
772
|
+
' --open, -o open the panel in a browser once it is listening\n' +
|
|
773
|
+
' --name <s> label for this machine (default: hostname)\n' +
|
|
774
|
+
' --selftest run offline checks and exit\n' +
|
|
775
|
+
' --help this text\n\n' +
|
|
776
|
+
'Set CREW_STUDIO_TOKEN to require a bearer token, CREW_STUDIO_PEERS for a\n' +
|
|
777
|
+
'comma-separated peer list.\n\n' +
|
|
778
|
+
'Peers are normally reached over a forwarded port, which keeps every\n' +
|
|
779
|
+
'Studio on loopback:\n' +
|
|
780
|
+
' ssh -N -L 7778:127.0.0.1:7777 other-machine\n' +
|
|
781
|
+
' crewforth-studio --peer http://127.0.0.1:7778\n',
|
|
782
|
+
);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (args.selftest) {
|
|
787
|
+
process.exit(await selftest());
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const server = http.createServer((req, res) => {
|
|
791
|
+
handle(req, res).catch((e) => {
|
|
792
|
+
process.stderr.write(`crewforth-studio: unhandled: ${e?.stack ?? e}\n`);
|
|
793
|
+
if (!res.headersSent) send(res, 500, 'internal error');
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// A BUSY PORT IS NOT AUTOMATICALLY A PROBLEM. Before 2.10.2 this printed "try --port n+1" and exited 1, which
|
|
798
|
+
// is the right answer only when a stranger holds the port. When the holder is our OWN panel -- the common case,
|
|
799
|
+
// because a second session in the same project starts at the same default -- the useful answer is the URL of the
|
|
800
|
+
// one that is already running, and that URL used to be unrecoverable: the token lived in the first session's
|
|
801
|
+
// stdout and nowhere else. Measured in the field: two sessions, one live panel, no way to reach it.
|
|
802
|
+
server.on('error', async (e) => {
|
|
803
|
+
if (e.code === 'EADDRINUSE') {
|
|
804
|
+
const running = await findRunning(args.port);
|
|
805
|
+
if (running) {
|
|
806
|
+
process.stdout.write(`crewforth-studio ${running.url}\n`);
|
|
807
|
+
process.stdout.write(` already running on port ${args.port} (pid ${running.pid}${running.name ? `, ${running.name}` : ''}) — reusing it\n`);
|
|
808
|
+
process.stdout.write(' it belongs to whoever started it; stopping this shell does not stop it\n');
|
|
809
|
+
if (args.open) openBrowser(running.url);
|
|
810
|
+
process.exit(0);
|
|
811
|
+
}
|
|
812
|
+
process.stderr.write(`crewforth-studio: port ${args.port} is held by something that is not a crewforth-studio panel — try --port ${args.port + 1}\n`);
|
|
813
|
+
process.exit(1);
|
|
814
|
+
}
|
|
815
|
+
throw e;
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
PEERS = parsePeers([
|
|
819
|
+
...args.peers,
|
|
820
|
+
...(process.env.CREW_STUDIO_PEERS ?? '').split(',').map((x) => x.trim()).filter(Boolean),
|
|
821
|
+
]);
|
|
822
|
+
SELF_NAME = args.name || os.hostname().replace(/\.local$/, '');
|
|
823
|
+
|
|
824
|
+
server.listen(args.port, LOOPBACK, () => {
|
|
825
|
+
const url = `http://${LOOPBACK}:${args.port}/?token=${TOKEN}`;
|
|
826
|
+
process.stdout.write(`crewforth-studio ${url}\n`);
|
|
827
|
+
process.stdout.write(` machine: ${SELF_NAME}\n`);
|
|
828
|
+
if (PEERS.length) {
|
|
829
|
+
for (const p of PEERS) {
|
|
830
|
+
process.stdout.write(` peer: ${p.error ? `${p.spec} — ${p.error}` : p.base}\n`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
process.stdout.write(TOKEN_GENERATED
|
|
834
|
+
? ' (loopback only; token generated for this run)\n'
|
|
835
|
+
: ' (loopback only; token from CREW_STUDIO_TOKEN)\n');
|
|
836
|
+
|
|
837
|
+
// Recorded only after listen() succeeds, so the file never claims a port this process did not get. Failure
|
|
838
|
+
// to write is not fatal: the panel works, the next session simply cannot find it, which is where we started.
|
|
839
|
+
writeState(args.port, { token: TOKEN, name: SELF_NAME }).catch((e) => {
|
|
840
|
+
process.stderr.write(`crewforth-studio: could not record this instance (${e?.message ?? e}); another session will not find it\n`);
|
|
841
|
+
});
|
|
842
|
+
const drop = () => clearStateSync(args.port);
|
|
843
|
+
process.on('exit', drop);
|
|
844
|
+
// Ctrl-C and a kill do not run 'exit' handlers on their own, and this is the ordinary way the panel stops.
|
|
845
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
846
|
+
process.on(sig, () => { drop(); process.exit(0); });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (args.open) openBrowser(url);
|
|
850
|
+
|
|
851
|
+
// Say this once, to whoever is still typing the long path. Suppressed when
|
|
852
|
+
// the process already came in under its bin name, and never written to
|
|
853
|
+
// anyone's shell profile behind their back.
|
|
854
|
+
const launchedAsBin = path.basename(process.argv[1] ?? '') === 'crewforth-studio';
|
|
855
|
+
if (!launchedAsBin) {
|
|
856
|
+
const dir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
857
|
+
// Under npx the directory is npm's own cache (…/_npx/<hash>/…), which npm may clean at any time: installing
|
|
858
|
+
// from it leaves a command pointing at nothing. There, name the package instead of the path.
|
|
859
|
+
const underNpx = /[\\/]_npx[\\/]/.test(dir);
|
|
860
|
+
process.stdout.write(underNpx
|
|
861
|
+
? '\n For a shorter command, once: npm install -g crewforth\n'
|
|
862
|
+
+ ' then, from anywhere: crewforth studio\n'
|
|
863
|
+
: `\n For a shorter command, once: npm install -g ${dir}\n`
|
|
864
|
+
+ ` then, from anywhere: crewforth-studio --open\n`);
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// SIGHUP is the console window being closed — on both platforms the commonest way a panel
|
|
869
|
+
// ends after Ctrl-C, and unhandled it terminates with the children still running.
|
|
870
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
871
|
+
process.on(sig, () => {
|
|
872
|
+
// Children outlive their parent unless told otherwise, and a panel that
|
|
873
|
+
// leaks running sessions is worse than one that never started them.
|
|
874
|
+
stopAll();
|
|
875
|
+
server.close(() => process.exit(0));
|
|
876
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Only when this file is the program. The self-check imports writeAllowed from
|
|
882
|
+
// here to exercise it rather than grep for it, and an import that silently
|
|
883
|
+
// opened a listening socket would make an offline gate not offline.
|
|
884
|
+
// Run only when invoked directly, never on import (the selfcheck imports this
|
|
885
|
+
// file). argv[1] must be resolved first: every global install path — `npm link`,
|
|
886
|
+
// `npm i -g`, Homebrew — puts a symlink on PATH, while import.meta.url is always
|
|
887
|
+
// the real file. Comparing them raw makes the command exit silently with rc 0.
|
|
888
|
+
function isDirectRun() {
|
|
889
|
+
const entry = process.argv[1];
|
|
890
|
+
if (!entry) return false;
|
|
891
|
+
let real;
|
|
892
|
+
try {
|
|
893
|
+
real = fs.realpathSync(entry);
|
|
894
|
+
} catch {
|
|
895
|
+
real = entry;
|
|
896
|
+
}
|
|
897
|
+
return import.meta.url === pathToFileURL(real).href;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (isDirectRun()) {
|
|
901
|
+
main();
|
|
902
|
+
}
|