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,135 @@
|
|
|
1
|
+
// instance.js — let a second session find the panel that is already running.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. The panel printed its tokenised URL once, to the stdout of whoever started it, and kept the
|
|
4
|
+
// token nowhere else. So a second session that wanted the SAME panel had no way in: it could see the port was
|
|
5
|
+
// taken, but not that the holder was our own panel, and not what URL would open it. Measured in the field —
|
|
6
|
+
// port 7777 was held by another session's crewforth-studio, the user asked for the panel, and the answer was a dead
|
|
7
|
+
// end. The server said "try --port 7778", 7778 was held by something unrelated, and the command's own rule is
|
|
8
|
+
// to retry once. Two ports, no panel, and a running instance nobody could reach.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IT WRITES. One small JSON file per listening port, 0600, under the same runtime directory ensure-node.sh
|
|
11
|
+
// already owns. It holds the token, which is the part that matters and the part that decides the mode: this is
|
|
12
|
+
// a credential, so it is written to $HOME and never into the repo, where a stray `git add -A` would publish it.
|
|
13
|
+
//
|
|
14
|
+
// A STATE FILE IS A CLAIM, NOT A FACT. Processes are killed, machines lose power, and a file outlives both, so
|
|
15
|
+
// nothing here trusts what it reads. A record is believed only when the port answers /api/health with the token
|
|
16
|
+
// it names AND reports the pid it names. Anything else is treated as stale and removed, which also means a
|
|
17
|
+
// crashed panel cleans itself up the next time anyone looks.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import fsp from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
|
|
24
|
+
// Deliberately the same directory ensure-node.sh uses, and overridden by the same variable: one place under
|
|
25
|
+
// $HOME that the panel owns, that a user can delete wholesale, and that no installer has to know about.
|
|
26
|
+
export function runtimeDir() {
|
|
27
|
+
return process.env.CREW_STUDIO_RUNTIME || path.join(os.homedir(), '.claude', 'studio-runtime');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function statePath(port) {
|
|
31
|
+
return path.join(runtimeDir(), `instance-${port}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 0600 twice: in the open flags, and again with chmod. The flag alone loses to a permissive umask on some
|
|
35
|
+
// systems, and chmod alone leaves a window where the token sits world-readable.
|
|
36
|
+
//
|
|
37
|
+
// ON WINDOWS THIS IS NOT 0600 AND SHOULD NOT BE CALLED IT. POSIX mode bits are advisory there; what actually
|
|
38
|
+
// protects the file is the ACL it inherits from the user's profile directory. Measured with icacls on Windows
|
|
39
|
+
// 11: the record carries SYSTEM, Administrators and the owning user, all inherited, and no Everyone or Users
|
|
40
|
+
// entry — so another standard user cannot read it, and a local administrator can. That is weaker than 0600,
|
|
41
|
+
// which is why it is written down rather than rounded off, and why it is not the only defence: the panel
|
|
42
|
+
// listens on loopback and the token is regenerated every run.
|
|
43
|
+
export async function writeState(port, { token, name, pid = process.pid } = {}) {
|
|
44
|
+
const dir = runtimeDir();
|
|
45
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
const body = JSON.stringify({ pid, port, token, name, startedAt: new Date().toISOString() }, null, 2);
|
|
47
|
+
const file = statePath(port);
|
|
48
|
+
await fsp.writeFile(file, body, { mode: 0o600, flag: 'w' });
|
|
49
|
+
await fsp.chmod(file, 0o600).catch(() => {});
|
|
50
|
+
return file;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readState(port) {
|
|
54
|
+
try {
|
|
55
|
+
const raw = await fsp.readFile(statePath(port), 'utf8');
|
|
56
|
+
const s = JSON.parse(raw);
|
|
57
|
+
if (!s || typeof s.token !== 'string' || !s.token) return null;
|
|
58
|
+
if (Number(s.port) !== Number(port)) return null; // a file renamed by hand is not a record of this port
|
|
59
|
+
return s;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function clearState(port) {
|
|
66
|
+
await fsp.rm(statePath(port), { force: true }).catch(() => {});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Synchronous twin, for process exit. `process.on('exit')` runs no promises — an async unlink there is queued
|
|
70
|
+
// and then dropped when the event loop stops, which would leave exactly the stale file this module refuses to
|
|
71
|
+
// trust. Learned by leaving one behind.
|
|
72
|
+
export function clearStateSync(port) {
|
|
73
|
+
try { fs.rmSync(statePath(port), { force: true }); } catch { /* exiting anyway */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Ask the port whether it is ours. A short timeout because this sits between the user and an error message:
|
|
77
|
+
// the interesting answers (a live local server, or nothing at all) both arrive in milliseconds, and anything
|
|
78
|
+
// slower is not something to wait on while someone is watching a prompt.
|
|
79
|
+
//
|
|
80
|
+
// node:http rather than fetch, and that is not style. fetch is a GLOBAL, and a global can be replaced: the
|
|
81
|
+
// panel's own offline suite installs a DOM stub so the browser modules can be exercised under Node, and that
|
|
82
|
+
// stub owns `fetch`. The first version of this function used it and the suite reported a live panel as
|
|
83
|
+
// unreachable -- the harness was wrong, not the code, which is the failure this repo has been bitten by
|
|
84
|
+
// before. A module that reaches for a global is a module whose behaviour depends on who else is loaded.
|
|
85
|
+
export function probe(port, token, { timeoutMs = 1500, host = '127.0.0.1' } = {}) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
let settled = false;
|
|
88
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
89
|
+
const req = http.request(
|
|
90
|
+
{ host, port, path: `/api/health?token=${encodeURIComponent(token)}`, method: 'GET', timeout: timeoutMs },
|
|
91
|
+
(res) => {
|
|
92
|
+
if (res.statusCode !== 200) { res.resume(); return done(null); }
|
|
93
|
+
let body = '';
|
|
94
|
+
res.setEncoding('utf8');
|
|
95
|
+
res.on('data', (c) => { body += c; if (body.length > 64 * 1024) req.destroy(); });
|
|
96
|
+
res.on('end', () => {
|
|
97
|
+
try {
|
|
98
|
+
const j = JSON.parse(body);
|
|
99
|
+
done(j && j.ok ? j : null);
|
|
100
|
+
} catch { done(null); }
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
req.on('timeout', () => { req.destroy(); done(null); });
|
|
105
|
+
req.on('error', () => done(null));
|
|
106
|
+
req.end();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The whole point, in one call: "port N is busy — is it us?"
|
|
111
|
+
//
|
|
112
|
+
// Returns { url, pid, name } when a panel we recorded is genuinely answering there, and null otherwise. Null
|
|
113
|
+
// covers both shapes the caller has to tell the user apart from success, and they are NOT the same thing:
|
|
114
|
+
// no record at all means something else owns the port, while a record that fails to answer means our own panel
|
|
115
|
+
// died without cleaning up. The second case removes the file, so the next start is not misled by it.
|
|
116
|
+
export async function findRunning(port) {
|
|
117
|
+
const s = await readState(port);
|
|
118
|
+
if (!s) return null;
|
|
119
|
+
const health = await probe(port, s.token);
|
|
120
|
+
if (!health) {
|
|
121
|
+
await clearState(port);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
// The pid is the last check rather than the first: a port can be recycled to a different process quickly
|
|
125
|
+
// enough that a health answer alone is not proof of identity, and /api/health reports its own pid precisely
|
|
126
|
+
// so this comparison is possible. A mismatch means the record describes a process that no longer owns this
|
|
127
|
+
// port, so the record is stale even though something answered.
|
|
128
|
+
if (Number(health.pid) !== Number(s.pid)) {
|
|
129
|
+
await clearState(port);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return { url: `http://127.0.0.1:${port}/?token=${s.token}`, pid: s.pid, name: s.name, startedAt: s.startedAt };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const _internals = { runtimeDir, statePath };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// What Crewforth already measures about itself.
|
|
2
|
+
//
|
|
3
|
+
// The panel does not recompute any of it. Each of these shells out to Crewforth's
|
|
4
|
+
// own tool and reports what it said, including when it said it could not
|
|
5
|
+
// answer — `gate-report.sh` exits 4 in a checkout that has no installed hooks,
|
|
6
|
+
// and that is a real answer, not an empty one.
|
|
7
|
+
//
|
|
8
|
+
// One distinction runs through the whole file. A gate event from an owned
|
|
9
|
+
// session's stream carries the moment it happened; a line in gate-log.tsv does
|
|
10
|
+
// not, because the format has no timestamp. Those are labelled differently all
|
|
11
|
+
// the way to the screen, because drawing an observation as an event would be
|
|
12
|
+
// the first thing here that is not true.
|
|
13
|
+
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const EXEC_TIMEOUT_MS = 10000;
|
|
19
|
+
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
function run(cmd, args, opts = {}) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
execFile(cmd, args, { timeout: EXEC_TIMEOUT_MS, maxBuffer: MAX_BUFFER, windowsHide: true, ...opts },
|
|
24
|
+
(err, stdout, stderr) => resolve({
|
|
25
|
+
code: err?.code ?? 0,
|
|
26
|
+
stdout: stdout ?? '',
|
|
27
|
+
stderr: stderr ?? '',
|
|
28
|
+
failed: Boolean(err),
|
|
29
|
+
}));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where Crewforth's own scripts live for a given project, if they do. */
|
|
34
|
+
function kitPaths(cwd) {
|
|
35
|
+
const installed = path.join(cwd, '.claude');
|
|
36
|
+
const source = path.join(cwd, 'kit');
|
|
37
|
+
if (fs.existsSync(path.join(installed, 'hooks', 'guard-bash.sh'))) {
|
|
38
|
+
return { kind: 'installed', hooks: path.join(installed, 'hooks'), evalDir: path.join(installed, 'eval') };
|
|
39
|
+
}
|
|
40
|
+
if (fs.existsSync(path.join(source, 'hooks', 'guard-bash.sh'))) {
|
|
41
|
+
return { kind: 'source', hooks: path.join(source, 'hooks'), evalDir: path.join(source, 'eval') };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* --------------------------------------------------------------- gate log */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The gate decisions recorded on disk.
|
|
50
|
+
*
|
|
51
|
+
* Four tab-separated columns: verdict, section, rule, command. The command is
|
|
52
|
+
* empty unless CREW_GATE_LOG_CMD=1 was set, and there is no timestamp at all —
|
|
53
|
+
* both facts are returned rather than papered over.
|
|
54
|
+
*/
|
|
55
|
+
export function gateLog(cwd, { limit = 200 } = {}) {
|
|
56
|
+
const file = path.join(cwd, '.claude', 'gate-log.tsv');
|
|
57
|
+
let st;
|
|
58
|
+
try { st = fs.statSync(file); } catch {
|
|
59
|
+
return { measured: false, reason: 'no .claude/gate-log.tsv in this project', entries: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Only the tail: this file reached 21,730 lines in Crewforth's own checkout and
|
|
63
|
+
// the panel wants the recent end of it.
|
|
64
|
+
const TAIL = 256 * 1024;
|
|
65
|
+
let text = '';
|
|
66
|
+
let fd;
|
|
67
|
+
try {
|
|
68
|
+
fd = fs.openSync(file, 'r');
|
|
69
|
+
const len = Math.min(st.size, TAIL);
|
|
70
|
+
const buf = Buffer.allocUnsafe(len);
|
|
71
|
+
fs.readSync(fd, buf, 0, len, Math.max(0, st.size - len));
|
|
72
|
+
text = buf.toString('utf8');
|
|
73
|
+
} catch {
|
|
74
|
+
return { measured: false, reason: 'gate log could not be read', entries: [] };
|
|
75
|
+
} finally {
|
|
76
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* gone */ }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const lines = text.split('\n');
|
|
80
|
+
if (st.size > TAIL) lines.shift(); // the first line was cut mid-record
|
|
81
|
+
const entries = [];
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
if (!line.trim()) continue;
|
|
84
|
+
const [verdict, section, rule, command] = line.split('\t');
|
|
85
|
+
if (!verdict) continue;
|
|
86
|
+
entries.push({ verdict, section: section ?? null, rule: rule ?? null, command: command || null });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const counts = {};
|
|
90
|
+
for (const e of entries) counts[e.verdict] = (counts[e.verdict] ?? 0) + 1;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
measured: true,
|
|
94
|
+
file,
|
|
95
|
+
total: entries.length,
|
|
96
|
+
truncated: st.size > TAIL,
|
|
97
|
+
counts,
|
|
98
|
+
// Stated, not implied. The panel renders these as "observed", never as
|
|
99
|
+
// something that happened at a known time.
|
|
100
|
+
timestamped: false,
|
|
101
|
+
commandsRecorded: entries.some((e) => e.command),
|
|
102
|
+
entries: entries.slice(-limit).reverse(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ------------------------------------------------------------ gate report */
|
|
107
|
+
|
|
108
|
+
/** Crewforth's own inventory of gates, via gate-report.sh --json. */
|
|
109
|
+
export async function gateReport(cwd) {
|
|
110
|
+
const kit = kitPaths(cwd);
|
|
111
|
+
if (!kit) return { measured: false, reason: 'Crewforth is not installed in this project' };
|
|
112
|
+
|
|
113
|
+
const script = path.join(kit.evalDir, 'gate-report.sh');
|
|
114
|
+
if (!fs.existsSync(script)) return { measured: false, reason: 'gate-report.sh is not present' };
|
|
115
|
+
|
|
116
|
+
// The script finds the hooks relative to where it is run: `./.claude/hooks`
|
|
117
|
+
// in an installed project, `./hooks` otherwise. In Crewforth's own checkout
|
|
118
|
+
// that second shape only resolves from inside kit/, and the log it
|
|
119
|
+
// should read is one level up. Both layouts are given what they expect rather
|
|
120
|
+
// than the script being asked to guess.
|
|
121
|
+
const from = kit.kind === 'installed' ? cwd : path.dirname(kit.hooks);
|
|
122
|
+
const logPath = path.join(cwd, '.claude', 'gate-log.tsv');
|
|
123
|
+
const env = { ...process.env };
|
|
124
|
+
if (kit.kind !== 'installed' && fs.existsSync(logPath)) env.CREW_GATE_LOG = logPath;
|
|
125
|
+
|
|
126
|
+
const r = await run('bash', [script, '--json'], { cwd: from, env });
|
|
127
|
+
// 3 means there is no log to read, which the script calls "not an error"; 4
|
|
128
|
+
// means it could not find the hooks. Both are answers.
|
|
129
|
+
if (r.code === 3) return { measured: false, reason: 'no gate log to report on' };
|
|
130
|
+
if (r.code === 4) return { measured: false, reason: 'gate-report could not find the hooks from here' };
|
|
131
|
+
try {
|
|
132
|
+
return { measured: true, ...JSON.parse(r.stdout) };
|
|
133
|
+
} catch {
|
|
134
|
+
return { measured: false, reason: r.stderr.trim().slice(0, 200) || 'gate-report produced no JSON' };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* --------------------------------------------------------- session stats */
|
|
139
|
+
|
|
140
|
+
/** session-stats.sh --raw: fifteen key=value metrics about one transcript. */
|
|
141
|
+
export async function sessionStats(cwd, transcriptFile) {
|
|
142
|
+
const kit = kitPaths(cwd);
|
|
143
|
+
if (!kit) return { measured: false, reason: 'Crewforth is not installed in this project' };
|
|
144
|
+
|
|
145
|
+
const script = path.join(kit.hooks, 'session-stats.sh');
|
|
146
|
+
if (!fs.existsSync(script)) return { measured: false, reason: 'session-stats.sh is not present' };
|
|
147
|
+
if (!transcriptFile || !fs.existsSync(transcriptFile)) {
|
|
148
|
+
return { measured: false, reason: 'no transcript to read' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const r = await run('bash', [script, '--raw', transcriptFile], { cwd });
|
|
152
|
+
const metrics = {};
|
|
153
|
+
for (const line of r.stdout.split('\n')) {
|
|
154
|
+
const m = line.match(/^([a-z_]+)=(-?\d+)$/);
|
|
155
|
+
if (m) metrics[m[1]] = Number(m[2]);
|
|
156
|
+
}
|
|
157
|
+
if (!Object.keys(metrics).length) {
|
|
158
|
+
return { measured: false, reason: r.stderr.trim().slice(0, 200) || 'session-stats produced no metrics' };
|
|
159
|
+
}
|
|
160
|
+
return { measured: true, metrics };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ----------------------------------------------------------------- board */
|
|
164
|
+
|
|
165
|
+
/** The team board, if this repo has one. */
|
|
166
|
+
export async function board(cwd) {
|
|
167
|
+
const kit = kitPaths(cwd);
|
|
168
|
+
if (!kit) return { measured: false, reason: 'Crewforth is not installed in this project' };
|
|
169
|
+
|
|
170
|
+
const script = path.join(kit.hooks, 'board.sh');
|
|
171
|
+
if (!fs.existsSync(script)) return { measured: false, reason: 'board.sh is not present' };
|
|
172
|
+
|
|
173
|
+
const r = await run('bash', [script, 'status'], { cwd });
|
|
174
|
+
const text = r.stdout.trim();
|
|
175
|
+
// "No board in this repo yet" is a state, not a failure — a project that
|
|
176
|
+
// never opened one should read as exactly that.
|
|
177
|
+
if (/no board/i.test(text)) return { measured: true, present: false, text };
|
|
178
|
+
if (!text) return { measured: false, reason: r.stderr.trim().slice(0, 200) || 'board.sh said nothing' };
|
|
179
|
+
return { measured: true, present: true, text };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const _internals = { kitPaths };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Which projects run Crewforth, and which of them are behind.
|
|
2
|
+
//
|
|
3
|
+
// An installed project records its version in `.claude/VERSION` and its
|
|
4
|
+
// install shape in `.claude/kit.conf`. The published version comes from the
|
|
5
|
+
// same npm dist-tags feed Crewforth's own SessionStart hook reads, so the panel
|
|
6
|
+
// and the hook can never disagree about what "latest" means.
|
|
7
|
+
//
|
|
8
|
+
// Read-only. Detecting that a project is behind and updating it are different
|
|
9
|
+
// acts, and only the first one happens here.
|
|
10
|
+
|
|
11
|
+
import fsp from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const FEED = process.env.CREW_UPDATE_URL
|
|
15
|
+
?? 'https://registry.npmjs.org/-/package/crewforth/dist-tags';
|
|
16
|
+
const FEED_TTL_MS = 60 * 60 * 1000; // the hook uses a day; a panel refreshes hourly
|
|
17
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
18
|
+
|
|
19
|
+
let latestCache = null; // { at, value }
|
|
20
|
+
let inflight = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The published version WITHOUT waiting for the network.
|
|
24
|
+
*
|
|
25
|
+
* Returns whatever is cached, and starts a refresh if the cache is cold or stale — but never
|
|
26
|
+
* awaits it. Measured on a corporate machine: with the feed hanging, /api/projects took 8.37 s
|
|
27
|
+
* because it awaited this, which is the whole FETCH_TIMEOUT_MS. The project list is local data;
|
|
28
|
+
* it has no business waiting on a registry, and a user watching a 12-second blank reads it as a
|
|
29
|
+
* hung panel rather than as a slow lookup they never asked for.
|
|
30
|
+
*
|
|
31
|
+
* The honesty rule is unchanged: an unfetched answer says so with a reason. It never renders as
|
|
32
|
+
* "up to date". The reason simply becomes "not fetched yet" until the first refresh lands, and
|
|
33
|
+
* the next request serves the real answer.
|
|
34
|
+
*/
|
|
35
|
+
export function latestVersionCached() {
|
|
36
|
+
const fresh = latestCache && Date.now() - latestCache.at < FEED_TTL_MS;
|
|
37
|
+
// Deferred to a later tick, not merely un-awaited, so the request path does no work of its own.
|
|
38
|
+
//
|
|
39
|
+
// The panel stall once pinned on this feed was never the feed: a run with the feed disabled
|
|
40
|
+
// entirely stalled the same way. It was a synchronous transcript read on one Windows machine: one
|
|
41
|
+
// 128 KiB tail took 31,209 ms, and the event loop waited with it. projects.js has the panel's
|
|
42
|
+
// numbers and, kept apart from them, what was measured on that machine later. The reads are async
|
|
43
|
+
// now, so such a wait stays inside one request. This line stays because starting work on a request
|
|
44
|
+
// path is wrong regardless.
|
|
45
|
+
if (!fresh && !inflight) {
|
|
46
|
+
setTimeout(() => { latestVersion().catch(() => {}); }, 0).unref();
|
|
47
|
+
}
|
|
48
|
+
if (latestCache) return latestCache.value;
|
|
49
|
+
return { measured: false, reason: 'not fetched yet', at: Date.now(), pending: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The published version, or a stated reason it could not be read. Awaits the network. */
|
|
53
|
+
export async function latestVersion({ force = false } = {}) {
|
|
54
|
+
if (!force && latestCache && Date.now() - latestCache.at < FEED_TTL_MS) return latestCache.value;
|
|
55
|
+
if (inflight) return inflight;
|
|
56
|
+
|
|
57
|
+
inflight = (async () => {
|
|
58
|
+
const ctl = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(FEED, { signal: ctl.signal });
|
|
62
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
63
|
+
const body = await res.json();
|
|
64
|
+
const v = sane(body?.latest ?? body?.version);
|
|
65
|
+
if (!v) throw new Error('feed carried no version');
|
|
66
|
+
return { measured: true, version: v, at: Date.now() };
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// Offline is a legitimate answer. It is reported, never rendered as
|
|
69
|
+
// "everything is up to date".
|
|
70
|
+
return { measured: false, reason: String(e?.message ?? e), at: Date.now() };
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
})()
|
|
75
|
+
.then((value) => { latestCache = { at: Date.now(), value }; return value; })
|
|
76
|
+
.finally(() => { inflight = null; });
|
|
77
|
+
|
|
78
|
+
return inflight;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sane(v) {
|
|
82
|
+
if (typeof v !== 'string') return null;
|
|
83
|
+
const m = v.trim().match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
|
84
|
+
return m ? m[0] : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Numeric-segment comparison. Returns <0, 0, >0. A pre-release sorts below its release. */
|
|
88
|
+
export function compareVersions(a, b) {
|
|
89
|
+
const split = (v) => {
|
|
90
|
+
const [core, pre] = String(v).split(/[-+]/, 2);
|
|
91
|
+
return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), pre: pre ?? null };
|
|
92
|
+
};
|
|
93
|
+
const x = split(a);
|
|
94
|
+
const y = split(b);
|
|
95
|
+
for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i += 1) {
|
|
96
|
+
const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
|
|
97
|
+
if (d !== 0) return d;
|
|
98
|
+
}
|
|
99
|
+
if (x.pre && !y.pre) return -1;
|
|
100
|
+
if (!x.pre && y.pre) return 1;
|
|
101
|
+
if (x.pre && y.pre) return x.pre < y.pre ? -1 : x.pre > y.pre ? 1 : 0;
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What Crewforth looks like inside one working directory.
|
|
106
|
+
*
|
|
107
|
+
* Async because it runs once per project on the `/api/projects` path, and a
|
|
108
|
+
* synchronous read there blocks the whole panel when one read stalls —
|
|
109
|
+
* see the note at the top of projects.js for the measurement.
|
|
110
|
+
*/
|
|
111
|
+
export async function kitStatus(cwd, latest) {
|
|
112
|
+
if (!cwd) return { installed: false, reason: 'no working directory recorded' };
|
|
113
|
+
|
|
114
|
+
const claude = path.join(cwd, '.claude');
|
|
115
|
+
let version = null;
|
|
116
|
+
try { version = sane(await fsp.readFile(path.join(claude, 'VERSION'), 'utf8')); } catch { /* not installed */ }
|
|
117
|
+
|
|
118
|
+
if (!version) {
|
|
119
|
+
// Distinguish "no kit here" from "the directory is gone" — one is a choice,
|
|
120
|
+
// the other is a dead path.
|
|
121
|
+
let present = false;
|
|
122
|
+
try { await fsp.stat(cwd); present = true; } catch { present = false; }
|
|
123
|
+
return { installed: false, dirExists: present, reason: present ? 'Crewforth not installed' : 'directory no longer exists' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const conf = {};
|
|
127
|
+
try {
|
|
128
|
+
for (const line of (await fsp.readFile(path.join(claude, 'kit.conf'), 'utf8')).split('\n')) {
|
|
129
|
+
const m = line.match(/^\s*([A-Za-z_]+)\s*=\s*(.*)$/);
|
|
130
|
+
if (m) conf[m[1]] = m[2].trim();
|
|
131
|
+
}
|
|
132
|
+
} catch { /* older installs have no kit.conf */ }
|
|
133
|
+
|
|
134
|
+
const out = {
|
|
135
|
+
installed: true,
|
|
136
|
+
dirExists: true,
|
|
137
|
+
version,
|
|
138
|
+
stack: conf.stack ?? null,
|
|
139
|
+
installer: conf.installer ?? null,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (!latest?.measured) {
|
|
143
|
+
// Not measured is not "current". Saying a project is up to date because
|
|
144
|
+
// the network was down is the one answer worse than saying nothing.
|
|
145
|
+
out.compared = false;
|
|
146
|
+
out.reason = latest?.reason ?? 'latest version not read';
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const d = compareVersions(version, latest.version);
|
|
151
|
+
out.compared = true;
|
|
152
|
+
out.latest = latest.version;
|
|
153
|
+
out.outdated = d < 0;
|
|
154
|
+
out.ahead = d > 0; // a dev machine can legitimately run ahead of npm
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const _internals = { sane, FEED };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Node colours.
|
|
2
|
+
//
|
|
3
|
+
// Crewforth's agents already declare a colour in their frontmatter; the hex
|
|
4
|
+
// values below give each of those names one shade, so an agent keeps the same
|
|
5
|
+
// colour in every session the panel draws.
|
|
6
|
+
//
|
|
7
|
+
// An agent type nobody declared gets a neutral grey and a marked flag. Guessing
|
|
8
|
+
// a colour would make an unknown agent look like a known one, which is the
|
|
9
|
+
// visual form of the thing this project refuses to do.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where Crewforth's agents sit, relative to this file.
|
|
19
|
+
*
|
|
20
|
+
* One rule, no candidate list: the panel lives inside the component tree it
|
|
21
|
+
* reads, so `<studio>/../agents` is the answer in the repo (`kit/`),
|
|
22
|
+
* in an install (`.claude/`) and in a plugin root alike. A two-candidate
|
|
23
|
+
* resolver would be a branch exercised in one layout and rotting in the other.
|
|
24
|
+
*
|
|
25
|
+
* `here` is a parameter rather than a closed-over constant so the rule can be
|
|
26
|
+
* measured against a synthetic tree instead of only against this checkout.
|
|
27
|
+
* Returns null when the directory is not there — the caller must report that,
|
|
28
|
+
* never render it as "no kit agents".
|
|
29
|
+
*/
|
|
30
|
+
function agentsDirFor(here) {
|
|
31
|
+
const dir = path.resolve(here, '..', '..', '..', 'agents');
|
|
32
|
+
try { return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const AGENTS_DIR = agentsDirFor(HERE);
|
|
36
|
+
|
|
37
|
+
// The names agents declare, mapped onto hex values.
|
|
38
|
+
const NAMED = {
|
|
39
|
+
green: '#35c874',
|
|
40
|
+
cyan: '#26c6e6',
|
|
41
|
+
blue: '#5b8cff',
|
|
42
|
+
orange: '#f2a65a',
|
|
43
|
+
purple: '#b07cf6',
|
|
44
|
+
red: '#ff8a4d',
|
|
45
|
+
pink: '#ffbc8a',
|
|
46
|
+
yellow: '#db5f1c',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Claude Code's built-in agent types are not kit components and declare
|
|
50
|
+
// nothing, so they take the diagram's "core" tones.
|
|
51
|
+
const BUILTIN = {
|
|
52
|
+
Explore: '#26c6e6',
|
|
53
|
+
Plan: '#a874f5',
|
|
54
|
+
'general-purpose': '#c79bff',
|
|
55
|
+
claude: '#5b8cff',
|
|
56
|
+
'code-simplifier': '#84e6b0',
|
|
57
|
+
'statusline-setup': '#94a3c8',
|
|
58
|
+
'claude-code-guide': '#8659ee',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const UNKNOWN = '#94a3c8';
|
|
62
|
+
|
|
63
|
+
let cached = null;
|
|
64
|
+
|
|
65
|
+
function readKitAgents() {
|
|
66
|
+
const out = {};
|
|
67
|
+
if (!AGENTS_DIR) return null;
|
|
68
|
+
let files;
|
|
69
|
+
// null, not {}: an empty map and an unreadable directory are the same picture
|
|
70
|
+
// on screen — twelve grey rings — and only one of them is the truth.
|
|
71
|
+
try { files = fs.readdirSync(AGENTS_DIR); } catch { return null; }
|
|
72
|
+
|
|
73
|
+
for (const f of files) {
|
|
74
|
+
if (!f.endsWith('.md')) continue;
|
|
75
|
+
let src;
|
|
76
|
+
try { src = fs.readFileSync(path.join(AGENTS_DIR, f), 'utf8'); } catch { continue; }
|
|
77
|
+
// Frontmatter only: stop at the closing fence so a colon in the body cannot
|
|
78
|
+
// masquerade as a field.
|
|
79
|
+
const fm = src.split(/^---$/m)[1] ?? '';
|
|
80
|
+
const name = fm.match(/^name:\s*(.+)$/m)?.[1]?.trim();
|
|
81
|
+
const color = fm.match(/^color:\s*(.+)$/m)?.[1]?.trim();
|
|
82
|
+
if (name) out[name] = { color: color ?? null, hex: color ? (NAMED[color] ?? null) : null, source: 'kit' };
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function palette() {
|
|
88
|
+
if (cached) return cached;
|
|
89
|
+
const kit = readKitAgents();
|
|
90
|
+
const measured = kit !== null;
|
|
91
|
+
const map = {};
|
|
92
|
+
for (const [name, v] of Object.entries(kit ?? {})) {
|
|
93
|
+
map[name] = { hex: v.hex ?? UNKNOWN, declared: v.color, known: Boolean(v.hex), source: 'kit' };
|
|
94
|
+
}
|
|
95
|
+
for (const [name, hex] of Object.entries(BUILTIN)) {
|
|
96
|
+
if (!map[name]) map[name] = { hex, declared: null, known: true, source: 'builtin' };
|
|
97
|
+
}
|
|
98
|
+
cached = {
|
|
99
|
+
map,
|
|
100
|
+
unknown: UNKNOWN,
|
|
101
|
+
named: NAMED,
|
|
102
|
+
kitAgents: measured ? Object.keys(kit).length : 0,
|
|
103
|
+
agentsDir: AGENTS_DIR ?? path.resolve(HERE, '..', '..', '..', 'agents'),
|
|
104
|
+
measured,
|
|
105
|
+
reason: measured ? null : `no agents directory beside the panel at ${path.resolve(HERE, '..', '..', '..', 'agents')}`,
|
|
106
|
+
};
|
|
107
|
+
return cached;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function colorFor(agentType) {
|
|
111
|
+
const p = palette();
|
|
112
|
+
return p.map[agentType]?.hex ?? p.unknown;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The resolver, exported for measurement: the pin drives it against synthetic
|
|
116
|
+
// trees, which is the only way to prove the "one rule, every layout" claim
|
|
117
|
+
// without three checkouts.
|
|
118
|
+
export const _internals = { agentsDirFor };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Other machines.
|
|
2
|
+
//
|
|
3
|
+
// Transcripts are local files. `claude agents --json` reports only the machine
|
|
4
|
+
// it runs on, and no CLI lists another machine's sessions — measured, not
|
|
5
|
+
// assumed. So the only way to see a second machine is to run Studio there too
|
|
6
|
+
// and have this one read it.
|
|
7
|
+
//
|
|
8
|
+
// The server still binds to loopback, and that is not negotiable: it serves a
|
|
9
|
+
// developer's transcripts. A peer is therefore normally a forwarded port —
|
|
10
|
+
//
|
|
11
|
+
// ssh -N -L 7778:127.0.0.1:7777 other-machine
|
|
12
|
+
// node studio/server/index.js --peer http://127.0.0.1:7778
|
|
13
|
+
//
|
|
14
|
+
// which needs no listening socket on any network and no new credentials. A
|
|
15
|
+
// peer that is unreachable is reported as unreachable, never as empty.
|
|
16
|
+
|
|
17
|
+
const PEER_TIMEOUT_MS = 6000;
|
|
18
|
+
|
|
19
|
+
export function parsePeers(list) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const raw of list) {
|
|
22
|
+
const spec = String(raw).trim();
|
|
23
|
+
if (!spec) continue;
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(spec.includes('://') ? spec : `http://${spec}`);
|
|
27
|
+
} catch {
|
|
28
|
+
out.push({ spec, error: 'not a URL' });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// A token may ride along as ?token=… or as the URL's password field.
|
|
32
|
+
const token = url.searchParams.get('token') || url.password || null;
|
|
33
|
+
url.search = '';
|
|
34
|
+
url.username = '';
|
|
35
|
+
url.password = '';
|
|
36
|
+
const name = url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
|
37
|
+
out.push({ spec, base: url.origin, token, name });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function ask(peer, path) {
|
|
43
|
+
if (peer.error) return { ok: false, reason: peer.error };
|
|
44
|
+
const ctl = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => ctl.abort(), PEER_TIMEOUT_MS);
|
|
46
|
+
try {
|
|
47
|
+
const headers = peer.token ? { authorization: `Bearer ${peer.token}` } : {};
|
|
48
|
+
const res = await fetch(`${peer.base}${path}`, { headers, signal: ctl.signal });
|
|
49
|
+
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
|
|
50
|
+
return { ok: true, body: await res.json() };
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { ok: false, reason: e?.name === 'AbortError' ? 'timed out' : String(e?.message ?? e) };
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Ask every peer the same question, in parallel. Never throws. */
|
|
59
|
+
export async function askAll(peers, path) {
|
|
60
|
+
return Promise.all(peers.map(async (p) => ({ peer: p, ...(await ask(p, path)) })));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Find which peer holds a session, so graph and detail reads can be routed. */
|
|
64
|
+
export async function locateSession(peers, sessionId) {
|
|
65
|
+
for (const p of peers) {
|
|
66
|
+
const r = await ask(p, `/api/session/${encodeURIComponent(sessionId)}/graph`);
|
|
67
|
+
if (r.ok && r.body?.measured) return p;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { ask };
|