crewforth 3.1.0-rc.4 → 3.1.0-rc.6
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 +59 -0
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +48 -19
- package/kit/CLAUDE.md +13 -0
- package/kit/agents/crew-backend-expert.md +8 -0
- package/kit/agents/crew-commit-agent.md +8 -0
- package/kit/agents/crew-database-expert.md +8 -0
- package/kit/agents/crew-devops-expert.md +8 -0
- package/kit/agents/crew-frontend-expert.md +8 -0
- package/kit/agents/crew-performance-expert.md +8 -0
- package/kit/agents/crew-planner.md +14 -0
- package/kit/agents/crew-privacy-agent.md +10 -2
- package/kit/agents/crew-review-agent.md +8 -0
- package/kit/agents/crew-security-expert.md +11 -4
- package/kit/agents/crew-session-manager.md +10 -3
- package/kit/agents/crew-test-expert.md +8 -0
- package/kit/eval/doctor.sh +16 -0
- package/kit/eval/smoke-test.sh +898 -11
- package/kit/hooks/agent-outcome.sh +416 -0
- package/kit/hooks/guard-agent-model.sh +819 -0
- package/kit/hooks/guard-bash.sh +26 -1
- package/kit/hooks/guard-schedule.sh +7 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +55 -5
- package/kit/settings.json +54 -0
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +86 -7
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +142 -10
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +103 -25
- package/kit/studio/web/app.js +251 -37
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +220 -59
- package/kit/studio/web/chat.js +19 -2
- package/kit/studio/web/convo.js +14 -1
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +66 -18
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +35 -7
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +84 -0
- package/kit/studio/web/style.css +134 -15
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +36 -10
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
|
@@ -18,14 +18,74 @@ import { fileURLToPath } from 'node:url';
|
|
|
18
18
|
|
|
19
19
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
20
|
const HOOK = path.resolve(HERE, '..', 'hooks', 'studio-gate.sh');
|
|
21
|
+
const HOST = path.resolve(HERE, '..', 'hooks', 'studio-host.mjs');
|
|
21
22
|
|
|
22
23
|
// The hook answers at HOOK_WAIT; the harness is told a larger number, so the
|
|
23
24
|
// two never race and the harness has nothing to kill.
|
|
24
25
|
const HOOK_WAIT_S = 45;
|
|
26
|
+
// A question to the viewer, and a plan to read, are not a yes-or-no on one command: they get longer. The hook still
|
|
27
|
+
// answers by itself, with a denial, when this runs out.
|
|
28
|
+
const ASK_WAIT_S = 300;
|
|
25
29
|
const HARNESS_TIMEOUT_S = 90;
|
|
30
|
+
// The longer limit is given to the harness for those two tools alone, on a hook entry of their own: every other
|
|
31
|
+
// call keeps the 90 seconds it had.
|
|
32
|
+
const ASK_HARNESS_TIMEOUT_S = ASK_WAIT_S + 45;
|
|
33
|
+
|
|
34
|
+
// The two tools Claude Code offers only when someone can answer them. Their answer is not allow-or-deny: a
|
|
35
|
+
// question is answered with the answers, a plan with the mode to go on in.
|
|
36
|
+
export const INTERACTIVE = ['AskUserQuestion', 'ExitPlanMode'];
|
|
37
|
+
// The modes a plan can be approved into. `plan` is not one (that is "keep planning"), and nothing else exists here.
|
|
38
|
+
export const PLAN_MODES = ['acceptEdits', 'default'];
|
|
39
|
+
// The name the permission host has to Claude Code, and its one tool's: mcp__<server>__<tool>. The server's name
|
|
40
|
+
// carries the session's id, so it cannot be the name of a server the user's own configuration already has, and
|
|
41
|
+
// two sessions' hosts are never one name.
|
|
42
|
+
export function hostNames(sessionId) {
|
|
43
|
+
const server = `crew_studio_host_${String(sessionId).replace(/[^A-Za-z0-9]/g, '').slice(0, 16)}`;
|
|
44
|
+
return { server, tool: `mcp__${server}__approve` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Is this directory ours alone? The spool holds the answers a hook acts on: a file dropped into it is an answer.
|
|
49
|
+
* It sits under the system's temporary directory, which on some systems every user can write to, so a directory
|
|
50
|
+
* of that name is not taken on trust: it must be a real directory (not a link to one), owned by this user, and
|
|
51
|
+
* closed to everyone else. One that is ours and too open is closed; one that is somebody else's is refused.
|
|
52
|
+
* Where the platform has no owner to ask about (Windows), only "a real directory" is asked.
|
|
53
|
+
* @param uid whose it has to be: this process's user. A parameter so the rule can be asked about another one.
|
|
54
|
+
* @returns null when it is, or the reason it is not
|
|
55
|
+
*/
|
|
56
|
+
export function spoolProblem(dir, uid = typeof process.getuid === 'function' ? process.getuid() : null) {
|
|
57
|
+
let st;
|
|
58
|
+
try { st = fs.lstatSync(dir); } catch { return 'it does not exist'; }
|
|
59
|
+
if (st.isSymbolicLink()) return 'it is a symbolic link';
|
|
60
|
+
if (!st.isDirectory()) return 'it is not a directory';
|
|
61
|
+
if (uid === null) return null;
|
|
62
|
+
if (st.uid !== uid) return 'it belongs to another user';
|
|
63
|
+
if ((st.mode & 0o077) !== 0) {
|
|
64
|
+
try { fs.chmodSync(dir, 0o700); } catch { return 'it is open to other users and could not be closed'; }
|
|
65
|
+
if ((fs.lstatSync(dir).mode & 0o077) !== 0) return 'it is open to other users and could not be closed';
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Make a directory that is ours alone, or say why the one that is there is not. */
|
|
71
|
+
function ownDir(dir) {
|
|
72
|
+
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* spoolProblem says what is there */ }
|
|
73
|
+
return spoolProblem(dir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The spool of a session, checked each time it is about to be read or written. */
|
|
77
|
+
function spoolUsable(sessionId) {
|
|
78
|
+
const spool = spoolFor(sessionId);
|
|
79
|
+
return spoolProblem(ROOT) === null && spoolProblem(spool) === null ? spool : null;
|
|
80
|
+
}
|
|
26
81
|
const POLL_MS = 250;
|
|
27
82
|
|
|
28
|
-
|
|
83
|
+
// One root per user: on a machine with a shared temp directory, two users' panels do not meet in one directory.
|
|
84
|
+
// Where the platform has no user id to ask for (Windows), the temp directory is already the user's own.
|
|
85
|
+
const UID = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
86
|
+
const ROOT = path.join(os.tmpdir(), UID === null ? 'crew-studio-gate' : `crew-studio-gate-${UID}`);
|
|
87
|
+
// Where spools were kept before the root carried the user: swept, never written to.
|
|
88
|
+
const OLD_ROOT = path.join(os.tmpdir(), 'crew-studio-gate');
|
|
29
89
|
|
|
30
90
|
export function spoolFor(sessionId) {
|
|
31
91
|
if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) throw new Error('bad session id');
|
|
@@ -43,9 +103,22 @@ export function prepare(sessionId, mode = null) {
|
|
|
43
103
|
if (!fs.existsSync(HOOK)) return null;
|
|
44
104
|
|
|
45
105
|
const spool = spoolFor(sessionId);
|
|
46
|
-
|
|
106
|
+
// A spool that is not ours alone is not used: the session starts without a gate, and says so, rather than
|
|
107
|
+
// with one whose answers somebody else can write.
|
|
108
|
+
for (const d of [ROOT, spool, ...['req', 'ans', 'always', 'host'].map((x) => path.join(spool, x))]) {
|
|
109
|
+
const problem = ownDir(d);
|
|
110
|
+
if (problem) { prepare.refused = `${d}: ${problem}`; return null; }
|
|
111
|
+
}
|
|
112
|
+
prepare.refused = null;
|
|
113
|
+
// Whose spool this is: the server that made it. A later start reads this to tell a spool that was left behind
|
|
114
|
+
// (see `sweep`) from one a running server is still using.
|
|
115
|
+
try { fs.writeFileSync(path.join(spool, OWNER), `${process.pid}\n`, { mode: 0o600 }); } catch { /* swept by age instead */ }
|
|
116
|
+
// The mode an allowance in the panel is said out loud in. It starts as the mode the session starts in, and
|
|
117
|
+
// changes only when the viewer approves a plan into another one (see `decide`).
|
|
118
|
+
writeMode(spool, mode);
|
|
47
119
|
|
|
48
120
|
const settingsPath = path.join(spool, 'settings.json');
|
|
121
|
+
const ask = (role) => `CREW_GATE_WAIT=${HOOK_WAIT_S} CREW_GATE_WAIT_ASK=${ASK_WAIT_S} CREW_GATE_ROLE=${role} ${gateMode(mode)}bash ${JSON.stringify(HOOK)} ${JSON.stringify(spool)}`;
|
|
49
122
|
const settings = {
|
|
50
123
|
hooks: {
|
|
51
124
|
PreToolUse: [{
|
|
@@ -60,14 +133,42 @@ export function prepare(sessionId, mode = null) {
|
|
|
60
133
|
// The mode goes to the hook so that an allowance given in the panel can be an approval the harness
|
|
61
134
|
// honours — in the modes that may write, and never in plan. A mode that is not a plain word is not
|
|
62
135
|
// passed at all, and the hook then approves nothing.
|
|
63
|
-
|
|
136
|
+
// `general`: it answers every tool but the two below, and for those two it says nothing and leaves at
|
|
137
|
+
// once, so it is never held past this entry's limit.
|
|
138
|
+
command: ask('general'),
|
|
64
139
|
timeout: HARNESS_TIMEOUT_S,
|
|
65
140
|
}],
|
|
141
|
+
}, {
|
|
142
|
+
// The two tools that wait for a person to read something: the same hook, with the longer limit that only
|
|
143
|
+
// they need. If this entry did not fire for them, nothing would answer them and the permission host
|
|
144
|
+
// would deny them: the failure is a refusal.
|
|
145
|
+
matcher: INTERACTIVE.join('|'),
|
|
146
|
+
hooks: [{ type: 'command', shell: 'bash', command: ask('ask'), timeout: ASK_HARNESS_TIMEOUT_S }],
|
|
66
147
|
}],
|
|
67
148
|
},
|
|
68
149
|
};
|
|
69
150
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
70
|
-
|
|
151
|
+
|
|
152
|
+
// The permission host: what makes Claude Code offer AskUserQuestion and ExitPlanMode in this session at all. It
|
|
153
|
+
// denies whatever reaches it (hooks/studio-host.mjs says why). Without the file the session starts as it did
|
|
154
|
+
// before there was one: the two tools are not offered, and nothing else changes.
|
|
155
|
+
let host = null;
|
|
156
|
+
if (fs.existsSync(HOST)) {
|
|
157
|
+
const names = hostNames(sessionId);
|
|
158
|
+
const mcpPath = path.join(spool, 'mcp.json');
|
|
159
|
+
fs.writeFileSync(mcpPath, JSON.stringify({ mcpServers: { [names.server]: { command: process.execPath, args: [HOST, spool] } } }, null, 2));
|
|
160
|
+
host = { mcpPath, tool: names.tool, server: names.server };
|
|
161
|
+
}
|
|
162
|
+
return { settingsPath, spool, waitSeconds: HOOK_WAIT_S, askWaitSeconds: ASK_WAIT_S, host };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Record the mode the hook may approve in. A word that is not one of the session's modes is not written. */
|
|
166
|
+
export function writeMode(spool, mode) {
|
|
167
|
+
const file = path.join(spool, 'mode');
|
|
168
|
+
try {
|
|
169
|
+
if (typeof mode === 'string' && /^[A-Za-z]+$/.test(mode)) fs.writeFileSync(file, `${mode}\n`);
|
|
170
|
+
else fs.rmSync(file, { force: true });
|
|
171
|
+
} catch { /* the hook then approves nothing, which is the safe side */ }
|
|
71
172
|
}
|
|
72
173
|
|
|
73
174
|
function gateMode(mode) {
|
|
@@ -104,6 +205,8 @@ function readRequest(spool, file) {
|
|
|
104
205
|
// their absence means the session itself asked — it is not a missing answer.
|
|
105
206
|
agentId: typeof payload.agent_id === 'string' && /^[A-Za-z0-9_-]+$/.test(payload.agent_id) ? payload.agent_id : null,
|
|
106
207
|
agentType: typeof payload.agent_type === 'string' ? payload.agent_type.slice(0, 120) : null,
|
|
208
|
+
// How long this one waits before the hook denies it: longer for a question and for a plan.
|
|
209
|
+
waitSeconds: INTERACTIVE.includes(payload.tool_name) ? ASK_WAIT_S : HOOK_WAIT_S,
|
|
107
210
|
askedAt: (() => {
|
|
108
211
|
try { return fs.statSync(path.join(spool, 'req', file)).mtimeMs; } catch { return Date.now(); }
|
|
109
212
|
})(),
|
|
@@ -111,7 +214,8 @@ function readRequest(spool, file) {
|
|
|
111
214
|
}
|
|
112
215
|
|
|
113
216
|
export function pending(sessionId) {
|
|
114
|
-
const spool =
|
|
217
|
+
const spool = spoolUsable(sessionId);
|
|
218
|
+
if (!spool) return [];
|
|
115
219
|
let files;
|
|
116
220
|
try { files = fs.readdirSync(path.join(spool, 'req')); } catch { return []; }
|
|
117
221
|
return files
|
|
@@ -125,31 +229,105 @@ export function alwaysList(sessionId) {
|
|
|
125
229
|
try { return fs.readdirSync(path.join(spoolFor(sessionId), 'always')); } catch { return []; }
|
|
126
230
|
}
|
|
127
231
|
|
|
232
|
+
const QUESTIONS_MAX = 8; // the tool takes one to four questions; twice that is refused as not a question
|
|
233
|
+
const ANSWER_MAX = 2000; // one answer's length; a question's options are short, free text may not be
|
|
234
|
+
|
|
128
235
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* scoped to one session and disappears with it.
|
|
236
|
+
* The answers to a question, checked: one per question of the request, keyed by the question's own text, each a
|
|
237
|
+
* string or (for a question that takes several) a list of strings. Returns null when what was sent is not that.
|
|
132
238
|
*/
|
|
133
|
-
export function
|
|
239
|
+
export function cleanAnswers(questions, answers) {
|
|
240
|
+
if (!Array.isArray(questions) || questions.length > QUESTIONS_MAX || !answers || typeof answers !== 'object' || Array.isArray(answers)) return null;
|
|
241
|
+
// No prototype: a question whose text is `__proto__` or `constructor` is a key like any other, and nothing
|
|
242
|
+
// an answer is called can reach an inherited one.
|
|
243
|
+
const out = Object.create(null);
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
for (const q of questions) {
|
|
246
|
+
const key = q?.question;
|
|
247
|
+
if (typeof key !== 'string' || seen.has(key) || !Object.hasOwn(answers, key)) return null;
|
|
248
|
+
seen.add(key);
|
|
249
|
+
const v = answers[key];
|
|
250
|
+
const ok = (x) => typeof x === 'string' && x.trim() !== '' && x.length <= ANSWER_MAX;
|
|
251
|
+
// Several answers only to a question that takes several, and no more of them than it has options plus the
|
|
252
|
+
// viewer's own one.
|
|
253
|
+
const most = (Array.isArray(q.options) ? q.options.length : 0) + 1;
|
|
254
|
+
if (Array.isArray(v) ? !(q.multiSelect === true && v.length > 0 && v.length <= most && v.every(ok)) : !ok(v)) return null;
|
|
255
|
+
out[key] = v;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const hookAllow = (updatedInput) => JSON.stringify({
|
|
261
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'answered in Crewforth Studio', updatedInput },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Answer one request.
|
|
266
|
+
*
|
|
267
|
+
* allow | deny | always an ordinary tool call. `always` also allows that tool for the rest of the session,
|
|
268
|
+
* which is the user widening their own gate deliberately — it is scoped to one session
|
|
269
|
+
* and disappears with it.
|
|
270
|
+
* answer AskUserQuestion: `extra.answers` are the viewer's answers. The hook hands them to
|
|
271
|
+
* Claude Code as the tool's input, which is how the hooks reference says a hook answers it.
|
|
272
|
+
* plan ExitPlanMode: the plan is approved, to go on in `extra.mode` — `default` (the hook
|
|
273
|
+
* approves it; that is where Claude Code lands) or `acceptEdits` (the hook stays silent
|
|
274
|
+
* and the permission host approves it with the change of mode, which only it can make).
|
|
275
|
+
*
|
|
276
|
+
* The two interactive tools take only their own verdict or `deny`: a bare allow does not answer them, and neither
|
|
277
|
+
* may be allowed "for the session". No other tool takes `answer` or `plan`.
|
|
278
|
+
*/
|
|
279
|
+
export function decide(sessionId, toolUseId, verdict, extra = {}) {
|
|
134
280
|
if (!/^[A-Za-z0-9_-]+$/.test(toolUseId)) return { ok: false, reason: 'bad tool use id' };
|
|
135
|
-
if (!['allow', 'deny', 'always'].includes(verdict)) return { ok: false, reason: `unknown verdict: ${verdict}` };
|
|
281
|
+
if (!['allow', 'deny', 'always', 'answer', 'plan'].includes(verdict)) return { ok: false, reason: `unknown verdict: ${verdict}` };
|
|
136
282
|
|
|
137
|
-
const spool =
|
|
283
|
+
const spool = spoolUsable(sessionId);
|
|
284
|
+
if (!spool) return { ok: false, reason: 'the gate\'s spool is not a directory of this user\'s alone' };
|
|
138
285
|
const req = pending(sessionId).find((r) => r.toolUseId === toolUseId);
|
|
286
|
+
const tool = req?.toolName ?? null;
|
|
287
|
+
const own = { AskUserQuestion: 'answer', ExitPlanMode: 'plan' }[tool] ?? null;
|
|
288
|
+
if (verdict === 'answer' || verdict === 'plan') {
|
|
289
|
+
if (!req) return { ok: false, reason: 'no such request is waiting' };
|
|
290
|
+
if (own !== verdict) return { ok: false, reason: `${tool} is not answered with "${verdict}"` };
|
|
291
|
+
} else if (own && verdict !== 'deny') {
|
|
292
|
+
return { ok: false, reason: `${tool} is answered with "${own}" or denied, not with "${verdict}"` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let line = `${verdict}\n`;
|
|
296
|
+
let mode = null;
|
|
297
|
+
if (verdict === 'answer') {
|
|
298
|
+
const answers = cleanAnswers(req.input?.questions, extra?.answers);
|
|
299
|
+
if (!answers) return { ok: false, reason: 'the answers do not match the questions' };
|
|
300
|
+
// Verdict on the first line, the hook's whole output on the second: the hook prints it and does nothing else.
|
|
301
|
+
line = `output\n${hookAllow({ questions: req.input.questions, answers })}\n`;
|
|
302
|
+
} else if (verdict === 'plan') {
|
|
303
|
+
mode = extra?.mode;
|
|
304
|
+
if (!PLAN_MODES.includes(mode)) return { ok: false, reason: `a plan is approved into ${PLAN_MODES.join(' or ')}` };
|
|
305
|
+
try {
|
|
306
|
+
if (mode === 'default') {
|
|
307
|
+
line = `output\n${hookAllow(req.input ?? {})}\n`;
|
|
308
|
+
} else {
|
|
309
|
+
// Only the permission host can change the mode: the hook says nothing, and the host answers from this.
|
|
310
|
+
fs.writeFileSync(path.join(spool, 'host', `${toolUseId}.json`), JSON.stringify({ mode }));
|
|
311
|
+
line = 'host\n';
|
|
312
|
+
}
|
|
313
|
+
writeMode(spool, mode);
|
|
314
|
+
} catch (e) {
|
|
315
|
+
return { ok: false, reason: String(e?.message ?? e) };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
139
318
|
|
|
140
319
|
if (verdict === 'always') {
|
|
141
|
-
const tool = req?.toolName;
|
|
142
320
|
if (tool && /^[A-Za-z0-9_-]+$/.test(tool)) {
|
|
143
321
|
try { fs.writeFileSync(path.join(spool, 'always', tool), ''); } catch { /* best effort */ }
|
|
144
322
|
}
|
|
145
323
|
}
|
|
146
324
|
|
|
147
325
|
try {
|
|
148
|
-
fs.writeFileSync(path.join(spool, 'ans', toolUseId),
|
|
326
|
+
fs.writeFileSync(path.join(spool, 'ans', toolUseId), line);
|
|
149
327
|
} catch (e) {
|
|
150
328
|
return { ok: false, reason: String(e?.message ?? e) };
|
|
151
329
|
}
|
|
152
|
-
return { ok: true, verdict, toolName:
|
|
330
|
+
return { ok: true, verdict, toolName: tool, mode };
|
|
153
331
|
}
|
|
154
332
|
|
|
155
333
|
/**
|
|
@@ -174,7 +352,7 @@ export const APPROVAL_LOG_MAX = 200;
|
|
|
174
352
|
// A request that vanished this close to the hook's own deadline, with no answer recorded, timed out. The hook
|
|
175
353
|
// polls and so does the watcher; this is their combined slack.
|
|
176
354
|
const TIMEOUT_SLACK_MS = 2500;
|
|
177
|
-
const VERDICT_OUTCOME = { allow: 'allowed', always: 'allowed-session', deny: 'denied' };
|
|
355
|
+
const VERDICT_OUTCOME = { allow: 'allowed', always: 'allowed-session', deny: 'denied', answer: 'allowed', plan: 'allowed' };
|
|
178
356
|
|
|
179
357
|
/**
|
|
180
358
|
* Fold the requests waiting now into a session's record of approvals.
|
|
@@ -196,7 +374,7 @@ export function logApprovals(log, pending, decisions, now, waitSeconds) {
|
|
|
196
374
|
e.endedAt = now;
|
|
197
375
|
const verdict = decisions?.get(e.toolUseId);
|
|
198
376
|
if (verdict && VERDICT_OUTCOME[verdict]) e.outcome = VERDICT_OUTCOME[verdict];
|
|
199
|
-
else if (typeof waitSeconds === 'number' && now >= e.askedAt + waitSeconds * 1000 - TIMEOUT_SLACK_MS) e.outcome = 'timed-out';
|
|
377
|
+
else if (typeof (e.waitSeconds ?? waitSeconds) === 'number' && now >= e.askedAt + (e.waitSeconds ?? waitSeconds) * 1000 - TIMEOUT_SLACK_MS) e.outcome = 'timed-out';
|
|
200
378
|
else e.outcome = 'unanswered';
|
|
201
379
|
decisions?.delete(e.toolUseId);
|
|
202
380
|
}
|
|
@@ -205,6 +383,8 @@ export function logApprovals(log, pending, decisions, now, waitSeconds) {
|
|
|
205
383
|
log.push({
|
|
206
384
|
toolUseId: r.toolUseId, toolName: r.toolName, agentId: r.agentId ?? null, agentType: r.agentType ?? null,
|
|
207
385
|
askedAt: r.askedAt, endedAt: null, outcome: null,
|
|
386
|
+
// A question and a plan wait longer than a command; each entry times out on its own clock.
|
|
387
|
+
waitSeconds: r.waitSeconds ?? null,
|
|
208
388
|
});
|
|
209
389
|
}
|
|
210
390
|
// The oldest closed entries go first; a request still waiting is never dropped.
|
|
@@ -233,4 +413,67 @@ export function cleanup(sessionId) {
|
|
|
233
413
|
try { fs.rmSync(spoolFor(sessionId), { recursive: true, force: true }); } catch { /* already gone */ }
|
|
234
414
|
}
|
|
235
415
|
|
|
236
|
-
|
|
416
|
+
const OWNER = 'owner';
|
|
417
|
+
const STALE_MS = 24 * 60 * 60 * 1000;
|
|
418
|
+
|
|
419
|
+
/** Is a process with this id running? One that exists and is somebody else's counts: it is not ours to judge. */
|
|
420
|
+
function pidAlive(pid) {
|
|
421
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e?.code === 'EPERM'; }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The latest change time of a directory and of what is in it, two levels down: a spool is no deeper. Links are not followed. */
|
|
425
|
+
function newest(dir, seen, depth = 2) {
|
|
426
|
+
let at = seen;
|
|
427
|
+
let names;
|
|
428
|
+
try { names = fs.readdirSync(dir); } catch { return at; }
|
|
429
|
+
for (const n of names) {
|
|
430
|
+
let st;
|
|
431
|
+
try { st = fs.lstatSync(path.join(dir, n)); } catch { continue; }
|
|
432
|
+
if (st.mtimeMs > at) at = st.mtimeMs;
|
|
433
|
+
if (depth > 1 && st.isDirectory()) at = newest(path.join(dir, n), at, depth - 1);
|
|
434
|
+
}
|
|
435
|
+
return at;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** What `sweep` is run on when a server starts: this user's root, and the one from before roots carried the user. */
|
|
439
|
+
export function sweepLeft(opts = {}) {
|
|
440
|
+
return [...sweep({ ...opts, root: ROOT }), ...(OLD_ROOT === ROOT ? [] : sweep({ ...opts, root: OLD_ROOT }))];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Remove the spools a server left behind.
|
|
445
|
+
*
|
|
446
|
+
* A session's spool goes when the session exits (`cleanup`). A server that is killed never sees that exit, and on
|
|
447
|
+
* Windows a kill runs no handler at all, so its spools stay under the temp directory: settings, the mode, and
|
|
448
|
+
* whatever was waiting. This is run when a server starts.
|
|
449
|
+
*
|
|
450
|
+
* What is removed, and nothing else: a real directory (not a link) directly under the root, named like a session
|
|
451
|
+
* id, that is this user's alone, and whose owner — the server that made it — is no longer running. A spool with
|
|
452
|
+
* no owner written (made by a version before this) goes once it has not been touched for a day. A spool whose
|
|
453
|
+
* owner is running is another panel's, or this one's, and is left.
|
|
454
|
+
*
|
|
455
|
+
* @returns the names removed
|
|
456
|
+
*/
|
|
457
|
+
export function sweep({ root = ROOT, alive = pidAlive, self = process.pid, now = Date.now(), staleMs = STALE_MS } = {}) {
|
|
458
|
+
const gone = [];
|
|
459
|
+
if (spoolProblem(root) !== null) return gone;
|
|
460
|
+
let names;
|
|
461
|
+
try { names = fs.readdirSync(root); } catch { return gone; }
|
|
462
|
+
for (const name of names) {
|
|
463
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) continue;
|
|
464
|
+
const dir = path.join(root, name);
|
|
465
|
+
let st;
|
|
466
|
+
try { st = fs.lstatSync(dir); } catch { continue; }
|
|
467
|
+
if (!st.isDirectory() || spoolProblem(dir) !== null) continue;
|
|
468
|
+
let pid = null;
|
|
469
|
+
try { pid = Number.parseInt(fs.readFileSync(path.join(dir, OWNER), 'utf8'), 10); } catch { /* no owner written */ }
|
|
470
|
+
// With no owner to ask, age decides, and the age is the newest thing in the spool: a hook that wrote a request
|
|
471
|
+
// a minute ago touched a file two levels down, not the directory at the top.
|
|
472
|
+
const left = Number.isInteger(pid) && pid > 0 ? pid !== self && !alive(pid) : now - newest(dir, st.mtimeMs) > staleMs;
|
|
473
|
+
if (!left) continue;
|
|
474
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); gone.push(name); } catch { /* still there; the next start tries again */ }
|
|
475
|
+
}
|
|
476
|
+
return gone;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export const _internals = { HOOK, HOST, HOOK_WAIT_S, ASK_WAIT_S, HARNESS_TIMEOUT_S, ASK_HARNESS_TIMEOUT_S, ROOT, OLD_ROOT, UID };
|
|
@@ -15,7 +15,7 @@ import { spawn } from 'node:child_process';
|
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
|
|
18
|
-
import { prepare, pending, watch, cleanup, alwaysList, logApprovals } from './permissions.js';
|
|
18
|
+
import { prepare, pending, watch, cleanup, alwaysList, logApprovals, writeMode } from './permissions.js';
|
|
19
19
|
import { getFleet } from './fleet.js';
|
|
20
20
|
|
|
21
21
|
export const ALLOWED_MODES = ['plan', 'acceptEdits', 'default'];
|
|
@@ -102,6 +102,9 @@ class OwnedSession {
|
|
|
102
102
|
// Our own settings file in our own directory. The user's settings are never
|
|
103
103
|
// read, written or merged.
|
|
104
104
|
if (this.gate) args.push('--settings', this.gate.settingsPath);
|
|
105
|
+
// The permission host: with one, Claude Code offers the two tools that need a person (a question, leaving
|
|
106
|
+
// plan mode). It denies every prompt that reaches it; the dock is where a call is allowed.
|
|
107
|
+
if (this.gate?.host) args.push('--mcp-config', this.gate.host.mcpPath, '--permission-prompt-tool', this.gate.host.tool);
|
|
105
108
|
|
|
106
109
|
this.child = spawn('claude', args, {
|
|
107
110
|
cwd: this.cwd,
|
|
@@ -168,6 +171,8 @@ class OwnedSession {
|
|
|
168
171
|
this.gateEvents.push(ev);
|
|
169
172
|
if (this.gateEvents.length > 500) this.gateEvents.shift();
|
|
170
173
|
}
|
|
174
|
+
// The mode the session is in is what Claude Code says it is in: at the start, and each time it changes.
|
|
175
|
+
if (rec?.type === 'system' && typeof rec.permissionMode === 'string') this.#seeMode(rec.permissionMode);
|
|
171
176
|
if (rec?.type === 'system' && rec.subtype === 'init') this.state = 'idle';
|
|
172
177
|
else if (rec?.type === 'stream_event' && rec.event?.type === 'message_start') this.state = 'working';
|
|
173
178
|
else if (rec?.type === 'result') {
|
|
@@ -189,6 +194,30 @@ class OwnedSession {
|
|
|
189
194
|
/** The panel recorded an answer to a request. Remembered until the request is seen to have gone. */
|
|
190
195
|
noteDecision(toolUseId, verdict) { this.decisions.set(toolUseId, verdict); }
|
|
191
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Claude Code reported the session's mode. That is the mode: the header shows it, and the gate's hook is told it,
|
|
199
|
+
* so an allowance in the dock is said out loud in the mode the session is really in and in no other.
|
|
200
|
+
*/
|
|
201
|
+
#seeMode(mode) {
|
|
202
|
+
if (mode === this.permissionMode) return;
|
|
203
|
+
this.permissionMode = mode;
|
|
204
|
+
if (this.gate) writeMode(this.gate.spool, mode);
|
|
205
|
+
this.#emit({ type: 'mode', permissionMode: mode });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Ask Claude Code to put the session in another mode: one of the modes a session may be started in, and no
|
|
210
|
+
* other. It is asked the way the Agent SDK's `setPermissionMode()` asks, as a control request on the session's
|
|
211
|
+
* input; nothing here is changed until Claude Code reports the new mode on its stream (`#seeMode`).
|
|
212
|
+
*/
|
|
213
|
+
requestMode(mode) {
|
|
214
|
+
if (!ALLOWED_MODES.includes(mode)) return { ok: false, reason: `the mode is one of ${ALLOWED_MODES.join(', ')}` };
|
|
215
|
+
if (this.state === 'exited' || this.state === 'failed') return { ok: false, reason: `session has ${this.state}` };
|
|
216
|
+
const line = `${JSON.stringify({ type: 'control_request', request_id: randomUUID(), request: { subtype: 'set_permission_mode', mode } })}\n`;
|
|
217
|
+
try { this.child.stdin.write(line); } catch (e) { return { ok: false, reason: String(e?.message ?? e) }; }
|
|
218
|
+
return { ok: true, requested: mode };
|
|
219
|
+
}
|
|
220
|
+
|
|
192
221
|
/** Replay from `after`, then follow. Returns an unsubscribe function. */
|
|
193
222
|
subscribe(fn, after = 0) {
|
|
194
223
|
for (const ev of this.events) if (ev.seq > after) fn(ev);
|
|
@@ -303,6 +332,9 @@ export async function createSession({ cwd, model, permissionMode, resume } = {})
|
|
|
303
332
|
|
|
304
333
|
export function getSession(id) { return sessions.get(id) ?? null; }
|
|
305
334
|
|
|
335
|
+
// For the selfcheck: what a session writes to Claude Code when a mode is asked for, without starting one.
|
|
336
|
+
export const _internals = { OwnedSession };
|
|
337
|
+
|
|
306
338
|
export function listSessionsOwned() {
|
|
307
339
|
return [...sessions.values()].map((s) => s.summary());
|
|
308
340
|
}
|
|
@@ -321,3 +353,11 @@ export function stopAll() {
|
|
|
321
353
|
if (s.state !== 'exited' && s.state !== 'failed') s.stop();
|
|
322
354
|
}
|
|
323
355
|
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Remove the spool of every session this server started. For the server's own way out: it stops its sessions and
|
|
359
|
+
* leaves before their exits are seen, and the exit is where a spool is otherwise removed.
|
|
360
|
+
*/
|
|
361
|
+
export function dropSpools() {
|
|
362
|
+
for (const s of sessions.values()) cleanup(s.id);
|
|
363
|
+
}
|
|
@@ -36,55 +36,115 @@ export function usageOfRecord(r) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
|
|
40
|
+
|
|
41
|
+
/** One running sum: tokens by kind, what they cost, and which models had no price. */
|
|
42
|
+
class Tally {
|
|
40
43
|
constructor() {
|
|
41
|
-
this.seen = new Set();
|
|
42
44
|
this.tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
|
|
43
45
|
this.usd = 0;
|
|
44
46
|
this.responses = 0;
|
|
45
|
-
this.priced = new Set();
|
|
46
47
|
this.unpriced = new Set();
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
take(u, usd) {
|
|
51
|
+
this.responses += 1;
|
|
52
|
+
this.tokens.input += u.input;
|
|
53
|
+
this.tokens.output += u.output;
|
|
54
|
+
this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
|
|
55
|
+
this.tokens.cacheRead += u.cacheRead;
|
|
56
|
+
if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
|
|
57
|
+
else this.usd += usd;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `fresh` is what was sent and written for the first time: input, output and cache writes. `cacheRead` is the
|
|
62
|
+
* same context read back from the cache on every later response, which is why it dwarfs the rest in a long
|
|
63
|
+
* session and is kept out of the headline. `cost` is null when any of the tokens came from a model the price
|
|
64
|
+
* table has no row for — a sum that leaves some of them out would read as the whole.
|
|
65
|
+
*/
|
|
66
|
+
result() {
|
|
67
|
+
const t = this.tokens;
|
|
68
|
+
return {
|
|
69
|
+
tokens: { ...t, fresh: t.input + t.output + t.cacheWrite, total: KINDS.reduce((a, k) => a + t[k], 0) },
|
|
70
|
+
responses: this.responses,
|
|
71
|
+
cost: this.unpriced.size ? null : this.usd,
|
|
72
|
+
unpriced: [...this.unpriced].sort(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class Usage {
|
|
78
|
+
constructor() {
|
|
79
|
+
this.seen = new Set();
|
|
80
|
+
this.all = new Tally();
|
|
81
|
+
this.who = new Map(); // 'session', or an agent's id -> Tally
|
|
82
|
+
this.model = new Map(); // model id -> Tally
|
|
83
|
+
this.first = null; // the earliest and the latest record of any file added
|
|
84
|
+
this.last = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#tally(map, key) {
|
|
88
|
+
if (!map.has(key)) map.set(key, new Tally());
|
|
89
|
+
return map.get(key);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Add a transcript's records. A response already counted, from this file or another, is not counted again.
|
|
94
|
+
* @param who 'session' for the session's own transcript, or the id of the agent whose transcript this is.
|
|
95
|
+
* A record the session's transcript marks as an agent's (`isSidechain`) is counted as an agent's.
|
|
96
|
+
*/
|
|
97
|
+
add(records, who = 'session') {
|
|
51
98
|
for (const r of records ?? []) {
|
|
99
|
+
const t = r?.timestamp ? Date.parse(r.timestamp) : NaN;
|
|
100
|
+
if (Number.isFinite(t)) {
|
|
101
|
+
if (this.first === null || t < this.first) this.first = t;
|
|
102
|
+
if (this.last === null || t > this.last) this.last = t;
|
|
103
|
+
}
|
|
52
104
|
const u = usageOfRecord(r);
|
|
53
105
|
if (!u) continue;
|
|
54
106
|
if (u.id) { if (this.seen.has(u.id)) continue; this.seen.add(u.id); }
|
|
55
|
-
|
|
56
|
-
if (!total) continue; // a record the harness made up for itself carries no tokens
|
|
57
|
-
this.responses += 1;
|
|
58
|
-
this.tokens.input += u.input;
|
|
59
|
-
this.tokens.output += u.output;
|
|
60
|
-
this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
|
|
61
|
-
this.tokens.cacheRead += u.cacheRead;
|
|
107
|
+
if (!(u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead)) continue; // a record the harness made up carries no tokens
|
|
62
108
|
const usd = costOf(u);
|
|
63
|
-
|
|
64
|
-
|
|
109
|
+
this.all.take(u, usd);
|
|
110
|
+
this.#tally(this.who, who === 'session' && r.isSidechain === true ? 'agents:inline' : who).take(u, usd);
|
|
111
|
+
this.#tally(this.model, u.model ?? 'unknown model').take(u, usd);
|
|
65
112
|
}
|
|
66
113
|
return this;
|
|
67
114
|
}
|
|
68
115
|
|
|
116
|
+
/** One agent's own share, or null when its transcript carried no usage. */
|
|
117
|
+
of(who) { return this.who.get(who)?.result() ?? null; }
|
|
118
|
+
|
|
69
119
|
/**
|
|
70
|
-
* @returns
|
|
71
|
-
*
|
|
120
|
+
* @returns the whole session's tally, and the same split two ways: `parts.session` is what the session's own
|
|
121
|
+
* transcript spent and `parts.agents` everything its agents did; `byModel` is one tally per model.
|
|
72
122
|
*/
|
|
73
123
|
result() {
|
|
74
|
-
const
|
|
124
|
+
const agents = new Tally();
|
|
125
|
+
for (const [who, t] of this.who) {
|
|
126
|
+
if (who === 'session') continue;
|
|
127
|
+
for (const k of KINDS) agents.tokens[k] += t.tokens[k];
|
|
128
|
+
agents.usd += t.usd;
|
|
129
|
+
agents.responses += t.responses;
|
|
130
|
+
for (const m of t.unpriced) agents.unpriced.add(m);
|
|
131
|
+
}
|
|
132
|
+
const all = this.all.result();
|
|
75
133
|
return {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
134
|
+
...all,
|
|
135
|
+
models: [...this.model.keys()].filter((m) => !all.unpriced.includes(m)).sort(),
|
|
136
|
+
parts: { session: (this.who.get('session') ?? new Tally()).result(), agents: agents.result() },
|
|
137
|
+
byModel: Object.fromEntries([...this.model].map(([m, t]) => [m, t.result()]).sort((a, b) => b[1].tokens.total - a[1].tokens.total)),
|
|
138
|
+
startedAt: this.first,
|
|
139
|
+
lastAt: this.last,
|
|
81
140
|
priceSource: SOURCE,
|
|
82
141
|
};
|
|
83
142
|
}
|
|
84
143
|
}
|
|
85
144
|
|
|
86
145
|
/**
|
|
87
|
-
* How long a session
|
|
146
|
+
* How long a session's own transcript says it took. `durationMs` is from its first record to its last, pauses
|
|
147
|
+
* included; with its agents' transcripts counted too, that span is `Usage`'s `startedAt` to `lastAt`.
|
|
88
148
|
* `workedMs` is the turns Claude Code timed itself (`system` / `turn_duration` records), added up; zero when it
|
|
89
149
|
* recorded none, and a turn that is still running is not in it.
|
|
90
150
|
*/
|
|
@@ -107,6 +167,16 @@ export function timesOf(records) {
|
|
|
107
167
|
return { durationMs: first !== null && last !== null ? last - first : null, workedMs, turns };
|
|
108
168
|
}
|
|
109
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The usage with the times beside it. The span runs to the last record of ANY of the session's files: an agent
|
|
172
|
+
* can be writing long after the session's own transcript last moved.
|
|
173
|
+
*/
|
|
174
|
+
export function withTimes(usage, mainRecords) {
|
|
175
|
+
const u = usage.result();
|
|
176
|
+
const t = timesOf(mainRecords);
|
|
177
|
+
return { ...u, workedMs: t.workedMs, turns: t.turns, durationMs: u.startedAt !== null && u.lastAt !== null ? u.lastAt - u.startedAt : null };
|
|
178
|
+
}
|
|
179
|
+
|
|
110
180
|
/** A session's usage with its agents', and how long it took. */
|
|
111
181
|
export async function sessionUsage(session) {
|
|
112
182
|
const usage = new Usage();
|
|
@@ -114,9 +184,17 @@ export async function sessionUsage(session) {
|
|
|
114
184
|
usage.add(records);
|
|
115
185
|
for (const meta of await agentMetaFiles(session.subagentsDir)) {
|
|
116
186
|
const jsonl = meta.replace(/\.meta\.json$/, '.jsonl');
|
|
117
|
-
|
|
187
|
+
const agentId = path.basename(jsonl).slice('agent-'.length, -'.jsonl'.length);
|
|
188
|
+
try { usage.add((await readAll(jsonl)).records, agentId); } catch { /* an agent that has not written yet */ }
|
|
189
|
+
}
|
|
190
|
+
// Each agent's own share, by its id: what the Models view prices a class of work with.
|
|
191
|
+
const agents = {};
|
|
192
|
+
for (const [who, t] of usage.who) {
|
|
193
|
+
if (who === 'session' || who.startsWith('agents:')) continue;
|
|
194
|
+
const r = t.result();
|
|
195
|
+
agents[who] = { fresh: r.tokens.fresh, cacheRead: r.tokens.cacheRead, cost: r.cost };
|
|
118
196
|
}
|
|
119
|
-
return { ...usage
|
|
197
|
+
return { ...withTimes(usage, records), agents };
|
|
120
198
|
}
|
|
121
199
|
|
|
122
200
|
// One answer per session, kept until one of its files changes. The key is what the stream's own signature
|