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