crewforth 0.0.1 → 3.0.0-rc.2
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 +3805 -0
- package/LICENSE +21 -0
- package/README.md +37 -7
- package/VERSION +1 -0
- package/adopt.sh +1739 -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 +639 -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/skill-listing.awk +43 -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 +6736 -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 +1433 -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 +163 -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 +46 -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 +26 -0
- package/kit/skills/crew-ship/SKILL.md +19 -0
- package/kit/skills/crew-skill/SKILL.md +62 -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 +764 -0
- package/bin/crewforth.js +0 -10
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Sessions the panel owns.
|
|
2
|
+
//
|
|
3
|
+
// An owned session is a `claude -p` child driven over stdin/stdout in
|
|
4
|
+
// stream-json. It is given a session id up front, which means it writes its
|
|
5
|
+
// transcript to the same place every other session does — so the canvas needs
|
|
6
|
+
// no special case: the existing file-watched graph reads it like any other.
|
|
7
|
+
// Only the conversation itself comes from the child's stdout, because that is
|
|
8
|
+
// the one thing the file does not carry live.
|
|
9
|
+
//
|
|
10
|
+
// Permission modes are allow-listed, never deny-listed. A list of forbidden
|
|
11
|
+
// modes would have to name them, and a panel that names a bypass is one
|
|
12
|
+
// grep away from offering it.
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
|
|
18
|
+
import { prepare, pending, watch, cleanup } from './permissions.js';
|
|
19
|
+
import { getFleet } from './fleet.js';
|
|
20
|
+
|
|
21
|
+
export const ALLOWED_MODES = ['plan', 'acceptEdits', 'default'];
|
|
22
|
+
const DEFAULT_MODE = 'plan';
|
|
23
|
+
const MAX_EVENTS = 4000; // ring buffer per session
|
|
24
|
+
const MAX_MESSAGE_BYTES = 512 * 1024;
|
|
25
|
+
|
|
26
|
+
const sessions = new Map(); // sessionId -> OwnedSession
|
|
27
|
+
|
|
28
|
+
class OwnedSession {
|
|
29
|
+
constructor({ cwd, model, permissionMode, sessionId, resume, forked = false }) {
|
|
30
|
+
this.id = sessionId;
|
|
31
|
+
this.resumedFrom = resume ?? null;
|
|
32
|
+
// Whether this is a copy or the session itself. A copy is not a defeat but
|
|
33
|
+
// it is not the same thing either, and the UI has to be able to say which.
|
|
34
|
+
this.forked = forked;
|
|
35
|
+
this.cwd = cwd;
|
|
36
|
+
this.model = model ?? null;
|
|
37
|
+
this.permissionMode = permissionMode;
|
|
38
|
+
this.startedAt = Date.now();
|
|
39
|
+
this.state = 'starting'; // starting | idle | working | exited | failed
|
|
40
|
+
this.events = [];
|
|
41
|
+
this.seq = 0;
|
|
42
|
+
this.subscribers = new Set();
|
|
43
|
+
this.carry = '';
|
|
44
|
+
this.exit = null;
|
|
45
|
+
this.lastError = null;
|
|
46
|
+
this.turns = 0;
|
|
47
|
+
this.costUsd = 0;
|
|
48
|
+
|
|
49
|
+
// The gate is prepared before the child exists, so its very first tool call
|
|
50
|
+
// is already covered. A missing hook is reported, never worked around: a
|
|
51
|
+
// panel that silently ran ungated would be worse than one that cannot run.
|
|
52
|
+
this.gate = prepare(this.id);
|
|
53
|
+
this.pendingPermissions = [];
|
|
54
|
+
this.gateEvents = []; // hook lifecycle, newest last
|
|
55
|
+
this.unwatch = this.gate
|
|
56
|
+
? watch(this.id, (reqs) => {
|
|
57
|
+
this.pendingPermissions = reqs;
|
|
58
|
+
this.#emit({ type: 'permissions', pending: reqs });
|
|
59
|
+
})
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
const args = [
|
|
63
|
+
'-p', '--verbose',
|
|
64
|
+
'--input-format', 'stream-json',
|
|
65
|
+
'--output-format', 'stream-json',
|
|
66
|
+
'--include-partial-messages',
|
|
67
|
+
'--replay-user-messages',
|
|
68
|
+
'--forward-subagent-text',
|
|
69
|
+
// Gate decisions with the moment they happened. gate-log.tsv records the
|
|
70
|
+
// same decisions but carries no timestamp, so for a session we own this
|
|
71
|
+
// is the difference between "this happened at 13:42" and "this was in
|
|
72
|
+
// the log when we looked".
|
|
73
|
+
'--include-hook-events',
|
|
74
|
+
'--permission-mode', this.permissionMode,
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// Three shapes, and the difference matters to whoever is typing.
|
|
78
|
+
//
|
|
79
|
+
// A terminal continues a session by resuming it outright, and so can we:
|
|
80
|
+
// measured, a bare --resume kept the session_id, appended to the same
|
|
81
|
+
// transcript (13 lines became 20), opened no second file and answered from
|
|
82
|
+
// the earlier context. The panel used to fork every time, which is why
|
|
83
|
+
// continuing a session felt like being moved to a stranger.
|
|
84
|
+
//
|
|
85
|
+
// The one case that must fork is a session another process still holds.
|
|
86
|
+
// Two writers on one transcript is the corruption this guard exists for,
|
|
87
|
+
// and liveness is asked of `claude agents --json`, not assumed.
|
|
88
|
+
if (this.resumedFrom && !this.forked) {
|
|
89
|
+
// No --session-id here: it asks for a new session, which is the opposite
|
|
90
|
+
// of resuming one.
|
|
91
|
+
args.push('--resume', this.resumedFrom);
|
|
92
|
+
} else {
|
|
93
|
+
args.push('--session-id', this.id);
|
|
94
|
+
if (this.resumedFrom) args.push('--resume', this.resumedFrom, '--fork-session');
|
|
95
|
+
}
|
|
96
|
+
if (this.model) args.push('--model', this.model);
|
|
97
|
+
// Our own settings file in our own directory. The user's settings are never
|
|
98
|
+
// read, written or merged.
|
|
99
|
+
if (this.gate) args.push('--settings', this.gate.settingsPath);
|
|
100
|
+
|
|
101
|
+
this.child = spawn('claude', args, {
|
|
102
|
+
cwd: this.cwd,
|
|
103
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
104
|
+
env: process.env,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this.child.stdout.setEncoding('utf8');
|
|
108
|
+
this.child.stdout.on('data', (chunk) => this.#ingest(chunk));
|
|
109
|
+
|
|
110
|
+
this.child.stderr.setEncoding('utf8');
|
|
111
|
+
this.child.stderr.on('data', (t) => {
|
|
112
|
+
this.lastError = String(t).slice(0, 2000);
|
|
113
|
+
this.#emit({ type: 'stderr', text: this.lastError });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.child.on('error', (e) => {
|
|
117
|
+
this.state = 'failed';
|
|
118
|
+
this.lastError = String(e?.message ?? e);
|
|
119
|
+
this.#emit({ type: 'fault', reason: this.lastError });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
this.child.on('exit', (code, signal) => {
|
|
123
|
+
this.state = 'exited';
|
|
124
|
+
this.exit = { code, signal };
|
|
125
|
+
if (this.unwatch) { this.unwatch(); this.unwatch = null; }
|
|
126
|
+
this.#emit({ type: 'exit', code, signal });
|
|
127
|
+
// A spool outliving its session would leave stale requests behind for a
|
|
128
|
+
// hook that will never run again.
|
|
129
|
+
cleanup(this.id);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#ingest(chunk) {
|
|
134
|
+
const text = this.carry + chunk;
|
|
135
|
+
const lines = text.split('\n');
|
|
136
|
+
this.carry = lines.pop() ?? '';
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
if (!line.trim()) continue;
|
|
139
|
+
let rec;
|
|
140
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
141
|
+
this.#absorb(rec);
|
|
142
|
+
this.#emit(rec);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// State the UI needs but the raw stream only implies.
|
|
147
|
+
#absorb(rec) {
|
|
148
|
+
if (rec?.type === 'system' && (rec.subtype === 'hook_started' || rec.subtype === 'hook_response')) {
|
|
149
|
+
// The bridge's own hook is noise here: the panel already shows those as
|
|
150
|
+
// permission cards, and listing them again would double every decision.
|
|
151
|
+
const ours = typeof rec.hook_name === 'string' && this.gate;
|
|
152
|
+
const ev = {
|
|
153
|
+
at: Date.now(),
|
|
154
|
+
phase: rec.subtype === 'hook_started' ? 'started' : 'finished',
|
|
155
|
+
hookId: rec.hook_id ?? null,
|
|
156
|
+
name: rec.hook_name ?? null,
|
|
157
|
+
event: rec.hook_event ?? null,
|
|
158
|
+
exitCode: rec.exit_code ?? null,
|
|
159
|
+
outcome: rec.outcome ?? null,
|
|
160
|
+
stderr: (rec.stderr ?? '').slice(0, 300) || null,
|
|
161
|
+
ours,
|
|
162
|
+
};
|
|
163
|
+
this.gateEvents.push(ev);
|
|
164
|
+
if (this.gateEvents.length > 500) this.gateEvents.shift();
|
|
165
|
+
}
|
|
166
|
+
if (rec?.type === 'system' && rec.subtype === 'init') this.state = 'idle';
|
|
167
|
+
else if (rec?.type === 'stream_event' && rec.event?.type === 'message_start') this.state = 'working';
|
|
168
|
+
else if (rec?.type === 'result') {
|
|
169
|
+
this.state = 'idle';
|
|
170
|
+
this.turns = rec.num_turns ?? this.turns;
|
|
171
|
+
if (typeof rec.total_cost_usd === 'number') this.costUsd = rec.total_cost_usd;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#emit(rec) {
|
|
176
|
+
const ev = { seq: (this.seq += 1), at: Date.now(), rec };
|
|
177
|
+
this.events.push(ev);
|
|
178
|
+
if (this.events.length > MAX_EVENTS) this.events.splice(0, this.events.length - MAX_EVENTS);
|
|
179
|
+
for (const fn of this.subscribers) {
|
|
180
|
+
try { fn(ev); } catch { /* a dead subscriber must not stall the stream */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Replay from `after`, then follow. Returns an unsubscribe function. */
|
|
185
|
+
subscribe(fn, after = 0) {
|
|
186
|
+
for (const ev of this.events) if (ev.seq > after) fn(ev);
|
|
187
|
+
this.subscribers.add(fn);
|
|
188
|
+
return () => this.subscribers.delete(fn);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
send(text) {
|
|
192
|
+
if (this.state === 'exited' || this.state === 'failed') {
|
|
193
|
+
return { ok: false, reason: `session has ${this.state}` };
|
|
194
|
+
}
|
|
195
|
+
const body = String(text ?? '');
|
|
196
|
+
if (!body.trim()) return { ok: false, reason: 'empty message' };
|
|
197
|
+
if (Buffer.byteLength(body, 'utf8') > MAX_MESSAGE_BYTES) {
|
|
198
|
+
return { ok: false, reason: 'message too large' };
|
|
199
|
+
}
|
|
200
|
+
const line = `${JSON.stringify({ type: 'user', message: { role: 'user', content: body } })}\n`;
|
|
201
|
+
try {
|
|
202
|
+
this.child.stdin.write(line);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return { ok: false, reason: String(e?.message ?? e) };
|
|
205
|
+
}
|
|
206
|
+
this.state = 'working';
|
|
207
|
+
return { ok: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
stop() {
|
|
211
|
+
try { this.child.stdin.end(); } catch { /* already closed */ }
|
|
212
|
+
try { this.child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
213
|
+
// A child that ignores SIGTERM still has to go; the panel promised to stop it.
|
|
214
|
+
setTimeout(() => { try { this.child.kill('SIGKILL'); } catch { /* gone */ } }, 4000).unref();
|
|
215
|
+
return { ok: true };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
summary() {
|
|
219
|
+
return {
|
|
220
|
+
sessionId: this.id,
|
|
221
|
+
cwd: this.cwd,
|
|
222
|
+
model: this.model,
|
|
223
|
+
permissionMode: this.permissionMode,
|
|
224
|
+
resumedFrom: this.resumedFrom,
|
|
225
|
+
// A copy, or the session itself continued. The UI must not blur these.
|
|
226
|
+
forked: this.forked,
|
|
227
|
+
state: this.state,
|
|
228
|
+
startedAt: this.startedAt,
|
|
229
|
+
turns: this.turns,
|
|
230
|
+
costUsd: this.costUsd,
|
|
231
|
+
exit: this.exit,
|
|
232
|
+
lastError: this.lastError,
|
|
233
|
+
events: this.seq,
|
|
234
|
+
// Stated rather than implied: a session without a gate must say so.
|
|
235
|
+
gated: Boolean(this.gate),
|
|
236
|
+
gateWaitSeconds: this.gate?.waitSeconds ?? null,
|
|
237
|
+
pendingPermissions: this.pendingPermissions,
|
|
238
|
+
gateEvents: this.gateEvents.slice(-40),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Is this session still held by a running process?
|
|
245
|
+
*
|
|
246
|
+
* Answered by the fleet, never guessed. When the fleet cannot be read the
|
|
247
|
+
* answer is "yes": an unreadable fleet must not be the reason two processes
|
|
248
|
+
* end up writing one transcript.
|
|
249
|
+
*/
|
|
250
|
+
async function isHeldOpen(id) {
|
|
251
|
+
try {
|
|
252
|
+
const fleet = await getFleet();
|
|
253
|
+
const rows = fleet?.sessions ?? fleet ?? [];
|
|
254
|
+
if (!Array.isArray(rows)) return true;
|
|
255
|
+
if (fleet && fleet.measured === false) return true;
|
|
256
|
+
return rows.some((r) => r.sessionId === id);
|
|
257
|
+
} catch {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function createSession({ cwd, model, permissionMode, resume } = {}) {
|
|
263
|
+
const dir = cwd || process.cwd();
|
|
264
|
+
if (!fs.existsSync(dir)) return { ok: false, reason: `no such directory: ${dir}` };
|
|
265
|
+
|
|
266
|
+
const mode = permissionMode ?? DEFAULT_MODE;
|
|
267
|
+
if (!ALLOWED_MODES.includes(mode)) {
|
|
268
|
+
// Allow-list, not deny-list: unknown modes are refused without this file
|
|
269
|
+
// ever having to write down the dangerous ones.
|
|
270
|
+
return { ok: false, reason: `permission mode not offered here: ${mode}` };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (resume != null && !/^[A-Za-z0-9-]+$/.test(String(resume))) {
|
|
274
|
+
return { ok: false, reason: 'the session to resume is not an identifier' };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Fork only when something else still has the session open.
|
|
278
|
+
const forked = resume ? await isHeldOpen(String(resume)) : false;
|
|
279
|
+
const sessionId = resume && !forked ? String(resume) : randomUUID();
|
|
280
|
+
if (sessions.has(sessionId)) {
|
|
281
|
+
return { ok: false, reason: 'the panel is already running that session' };
|
|
282
|
+
}
|
|
283
|
+
const s = new OwnedSession({
|
|
284
|
+
cwd: dir, model: model || null, permissionMode: mode, sessionId, resume: resume || null, forked,
|
|
285
|
+
});
|
|
286
|
+
sessions.set(sessionId, s);
|
|
287
|
+
return { ok: true, session: s };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function getSession(id) { return sessions.get(id) ?? null; }
|
|
291
|
+
|
|
292
|
+
export function listSessionsOwned() {
|
|
293
|
+
return [...sessions.values()].map((s) => s.summary());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Drop finished sessions that nobody is watching, so the map does not grow. */
|
|
297
|
+
export function reap(maxAgeMs = 30 * 60 * 1000) {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
for (const [id, s] of sessions) {
|
|
300
|
+
const dead = s.state === 'exited' || s.state === 'failed';
|
|
301
|
+
if (dead && !s.subscribers.size && now - s.startedAt > maxAgeMs) sessions.delete(id);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function stopAll() {
|
|
306
|
+
for (const s of sessions.values()) {
|
|
307
|
+
if (s.state !== 'exited' && s.state !== 'failed') s.stop();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Handing a session back to a real terminal.
|
|
2
|
+
//
|
|
3
|
+
// The panel cannot drive a session it did not start — measured: no command
|
|
4
|
+
// enumerates or writes to another session, and Remote Control controls your own
|
|
5
|
+
// session from elsewhere rather than the other way round. So for an observed
|
|
6
|
+
// session the honest offer is not a disabled input box, it is a button that
|
|
7
|
+
// opens the session where it can actually be driven.
|
|
8
|
+
//
|
|
9
|
+
// What gets run is returned to the caller verbatim. A panel that launches a
|
|
10
|
+
// terminal window should be able to show exactly what it launched.
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
|
|
15
|
+
/** Shell-quote for the command line the terminal will interpret. */
|
|
16
|
+
function q(s) {
|
|
17
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** AppleScript string literal: backslashes and quotes, nothing else. */
|
|
21
|
+
function osaQuote(s) {
|
|
22
|
+
return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the launch for this platform.
|
|
27
|
+
* Returns null when nothing here knows how to open a terminal, so the caller
|
|
28
|
+
* can say so rather than fail silently.
|
|
29
|
+
*/
|
|
30
|
+
export function plan({ cwd, sessionId, command }) {
|
|
31
|
+
const inner = command ?? `claude --resume ${q(sessionId)}`;
|
|
32
|
+
const line = `cd ${q(cwd)} && ${inner}`;
|
|
33
|
+
|
|
34
|
+
if (process.platform === 'darwin') {
|
|
35
|
+
return {
|
|
36
|
+
platform: 'darwin',
|
|
37
|
+
via: 'Terminal.app',
|
|
38
|
+
line,
|
|
39
|
+
argv: ['osascript', '-e', `tell application "Terminal" to do script ${osaQuote(line)}`,
|
|
40
|
+
'-e', 'tell application "Terminal" to activate'],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (process.platform === 'win32') {
|
|
45
|
+
// Windows Terminal when present, the console host otherwise.
|
|
46
|
+
const wt = ['wt.exe', '-d', cwd, 'cmd.exe', '/k', inner];
|
|
47
|
+
return { platform: 'win32', via: 'wt.exe', line, argv: wt };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Linux and the rest: whichever emulator is actually installed. Names are
|
|
51
|
+
// tried by running them, not by resolving them — a name on PATH that fails
|
|
52
|
+
// to start is the failure mode this ordering exists for.
|
|
53
|
+
const candidates = [
|
|
54
|
+
['x-terminal-emulator', ['-e', 'bash', '-lc', line]],
|
|
55
|
+
['gnome-terminal', ['--working-directory', cwd, '--', 'bash', '-lc', line]],
|
|
56
|
+
['konsole', ['--workdir', cwd, '-e', 'bash', '-lc', line]],
|
|
57
|
+
['xfce4-terminal', ['--working-directory', cwd, '-e', `bash -lc ${q(line)}`]],
|
|
58
|
+
['xterm', ['-e', 'bash', '-lc', line]],
|
|
59
|
+
];
|
|
60
|
+
return { platform: process.platform, via: 'candidates', line, candidates };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Launch it, detached, and report what was run. */
|
|
64
|
+
export function open({ cwd, sessionId, command }) {
|
|
65
|
+
if (!cwd || !fs.existsSync(cwd)) return { ok: false, reason: `no such directory: ${cwd}` };
|
|
66
|
+
|
|
67
|
+
const p = plan({ cwd, sessionId, command });
|
|
68
|
+
if (!p) return { ok: false, reason: `no terminal launcher known for ${process.platform}` };
|
|
69
|
+
|
|
70
|
+
const attempts = p.argv ? [p.argv] : p.candidates.map(([bin, args]) => [bin, ...args]);
|
|
71
|
+
const tried = [];
|
|
72
|
+
|
|
73
|
+
for (const argv of attempts) {
|
|
74
|
+
const [bin, ...args] = argv;
|
|
75
|
+
try {
|
|
76
|
+
const child = spawn(bin, args, { cwd, detached: true, stdio: 'ignore' });
|
|
77
|
+
child.unref();
|
|
78
|
+
// A spawn that fails asynchronously (ENOENT) reports through 'error';
|
|
79
|
+
// by then this call has returned, so the caller is told what was tried
|
|
80
|
+
// rather than promised that it worked.
|
|
81
|
+
let failed = null;
|
|
82
|
+
child.on('error', (e) => { failed = e; });
|
|
83
|
+
tried.push(bin);
|
|
84
|
+
return { ok: true, via: p.via, ran: argv, line: p.line, tried, asyncError: failed };
|
|
85
|
+
} catch (e) {
|
|
86
|
+
tried.push(`${bin} (${e?.code ?? 'failed'})`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { ok: false, reason: 'no terminal emulator could be started', tried };
|
|
91
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Incremental JSONL reading.
|
|
2
|
+
//
|
|
3
|
+
// Transcripts are append-only and grow to megabytes, so re-parsing from byte
|
|
4
|
+
// zero on every change is not an option. Each reader keeps a byte offset and
|
|
5
|
+
// consumes only what was appended.
|
|
6
|
+
//
|
|
7
|
+
// A single record can itself be enormous (a pasted payload), so lines are
|
|
8
|
+
// assembled from a carry buffer rather than assumed to fit in one read.
|
|
9
|
+
|
|
10
|
+
import fsp from 'node:fs/promises';
|
|
11
|
+
|
|
12
|
+
export class JsonlReader {
|
|
13
|
+
constructor(file) {
|
|
14
|
+
this.file = file;
|
|
15
|
+
this.offset = 0;
|
|
16
|
+
this.carry = '';
|
|
17
|
+
this.malformed = 0; // counted, never silently swallowed
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read everything appended since the last call. Returns parsed records.
|
|
21
|
+
*
|
|
22
|
+
* Async because a transcript read has stalled for tens of seconds on a Windows
|
|
23
|
+
* machine, and this sits under the stream tick — see the note at the top of
|
|
24
|
+
* projects.js. The read is no faster; it just no longer stops the loop.
|
|
25
|
+
*/
|
|
26
|
+
async read() {
|
|
27
|
+
let st;
|
|
28
|
+
try { st = await fsp.stat(this.file); } catch { return []; }
|
|
29
|
+
|
|
30
|
+
// A shrunk file means it was replaced, not appended to. Start over rather
|
|
31
|
+
// than reading from a stale offset into the middle of a record.
|
|
32
|
+
if (st.size < this.offset) {
|
|
33
|
+
this.offset = 0;
|
|
34
|
+
this.carry = '';
|
|
35
|
+
}
|
|
36
|
+
if (st.size === this.offset) return [];
|
|
37
|
+
|
|
38
|
+
let chunk;
|
|
39
|
+
let fh;
|
|
40
|
+
try {
|
|
41
|
+
fh = await fsp.open(this.file, 'r');
|
|
42
|
+
const len = st.size - this.offset;
|
|
43
|
+
const buf = Buffer.allocUnsafe(len);
|
|
44
|
+
const { bytesRead } = await fh.read(buf, 0, len, this.offset);
|
|
45
|
+
chunk = buf.subarray(0, bytesRead).toString('utf8');
|
|
46
|
+
this.offset += bytesRead;
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
} finally {
|
|
50
|
+
if (fh !== undefined) try { await fh.close(); } catch { /* already gone */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const text = this.carry + chunk;
|
|
54
|
+
const lines = text.split('\n');
|
|
55
|
+
// The last element is either '' (clean boundary) or a partial record.
|
|
56
|
+
this.carry = lines.pop() ?? '';
|
|
57
|
+
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
if (!line) continue;
|
|
61
|
+
try {
|
|
62
|
+
out.push(JSON.parse(line));
|
|
63
|
+
} catch {
|
|
64
|
+
this.malformed += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One-shot full read. Used for files we do not intend to follow. */
|
|
72
|
+
export async function readAll(file) {
|
|
73
|
+
const r = new JsonlReader(file);
|
|
74
|
+
const records = await r.read();
|
|
75
|
+
return { records, malformed: r.malformed };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Session context fill, measured the way Crewforth measures it.
|
|
80
|
+
*
|
|
81
|
+
* The trap this avoids cost Crewforth a 92%-full context reported as 0.9%: when
|
|
82
|
+
* a subagent returns, its tool_result lands in the MAIN transcript as a
|
|
83
|
+
* `type:"user"` record carrying `toolUseResult.usage`. That is the SUBAGENT's
|
|
84
|
+
* spend, not the session's. Only `.message.usage` on a non-sidechain assistant
|
|
85
|
+
* record counts.
|
|
86
|
+
*/
|
|
87
|
+
export function contextFill(records) {
|
|
88
|
+
let total = null;
|
|
89
|
+
for (const r of records) {
|
|
90
|
+
if (r?.isSidechain === true) continue;
|
|
91
|
+
if (r?.type !== 'assistant') continue;
|
|
92
|
+
const u = r?.message?.usage;
|
|
93
|
+
if (!u || u.cache_read_input_tokens == null) continue;
|
|
94
|
+
total =
|
|
95
|
+
(u.input_tokens ?? 0) +
|
|
96
|
+
(u.cache_read_input_tokens ?? 0) +
|
|
97
|
+
(u.cache_creation_input_tokens ?? 0);
|
|
98
|
+
}
|
|
99
|
+
return total;
|
|
100
|
+
}
|