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.
Files changed (58) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/kit/AGENT_TEMPLATE.md +48 -19
  4. package/kit/CLAUDE.md +13 -0
  5. package/kit/agents/crew-backend-expert.md +8 -0
  6. package/kit/agents/crew-commit-agent.md +8 -0
  7. package/kit/agents/crew-database-expert.md +8 -0
  8. package/kit/agents/crew-devops-expert.md +8 -0
  9. package/kit/agents/crew-frontend-expert.md +8 -0
  10. package/kit/agents/crew-performance-expert.md +8 -0
  11. package/kit/agents/crew-planner.md +14 -0
  12. package/kit/agents/crew-privacy-agent.md +10 -2
  13. package/kit/agents/crew-review-agent.md +8 -0
  14. package/kit/agents/crew-security-expert.md +11 -4
  15. package/kit/agents/crew-session-manager.md +10 -3
  16. package/kit/agents/crew-test-expert.md +8 -0
  17. package/kit/eval/doctor.sh +16 -0
  18. package/kit/eval/smoke-test.sh +898 -11
  19. package/kit/hooks/agent-outcome.sh +416 -0
  20. package/kit/hooks/guard-agent-model.sh +819 -0
  21. package/kit/hooks/guard-bash.sh +26 -1
  22. package/kit/hooks/guard-schedule.sh +7 -2
  23. package/kit/hooks/guard-write.sh +31 -0
  24. package/kit/hooks/prompt-approval.sh +55 -5
  25. package/kit/settings.json +54 -0
  26. package/kit/skills/crew-loosen/SKILL.md +28 -0
  27. package/kit/studio/server/hooks/studio-gate.sh +90 -4
  28. package/kit/studio/server/hooks/studio-host.mjs +92 -0
  29. package/kit/studio/server/index.js +86 -7
  30. package/kit/studio/server/lib/agent-origin.js +44 -0
  31. package/kit/studio/server/lib/graph.js +142 -10
  32. package/kit/studio/server/lib/model-outcomes.js +287 -0
  33. package/kit/studio/server/lib/palette.js +5 -18
  34. package/kit/studio/server/lib/permissions.js +260 -17
  35. package/kit/studio/server/lib/session.js +41 -1
  36. package/kit/studio/server/lib/usage.js +103 -25
  37. package/kit/studio/web/app.js +251 -37
  38. package/kit/studio/web/approvals.js +53 -2
  39. package/kit/studio/web/ask.js +137 -0
  40. package/kit/studio/web/canvas.js +220 -59
  41. package/kit/studio/web/chat.js +19 -2
  42. package/kit/studio/web/convo.js +14 -1
  43. package/kit/studio/web/dock.js +30 -9
  44. package/kit/studio/web/graph-plan.js +66 -18
  45. package/kit/studio/web/icons/builtin.svg +1 -1
  46. package/kit/studio/web/index.html +11 -4
  47. package/kit/studio/web/inspect.js +33 -1
  48. package/kit/studio/web/keys.js +29 -0
  49. package/kit/studio/web/list-plan.js +8 -3
  50. package/kit/studio/web/list.js +35 -7
  51. package/kit/studio/web/models-plan.js +167 -0
  52. package/kit/studio/web/models.js +127 -0
  53. package/kit/studio/web/names.js +84 -0
  54. package/kit/studio/web/style.css +134 -15
  55. package/kit/studio/web/timeline-plan.js +8 -0
  56. package/kit/studio/web/timeline.js +36 -10
  57. package/kit/studio/web/usage.js +53 -12
  58. 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
- const ROOT = path.join(os.tmpdir(), 'crew-studio-gate');
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
- for (const d of ['req', 'ans', 'always']) fs.mkdirSync(path.join(spool, d), { recursive: true });
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
- command: `CREW_GATE_WAIT=${HOOK_WAIT_S} ${gateMode(mode)}bash ${JSON.stringify(HOOK)} ${JSON.stringify(spool)}`,
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
- return { settingsPath, spool, waitSeconds: HOOK_WAIT_S };
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 = spoolFor(sessionId);
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
- * Answer one request. `always` also allows that tool for the rest of the
130
- * session, which is the user widening their own gate deliberately — it is
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 decide(sessionId, toolUseId, verdict) {
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 = spoolFor(sessionId);
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), `${verdict}\n`);
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: req?.toolName ?? null };
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
- export const _internals = { HOOK, HOOK_WAIT_S, HARNESS_TIMEOUT_S, ROOT };
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
- export class Usage {
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
- /** Add a transcript's records. A response already counted, from this file or another, is not counted again. */
50
- add(records) {
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
- const total = u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead;
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
- if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
64
- else { this.usd += usd; this.priced.add(u.model); }
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 tokens by kind and in total; `cost` in USD at list price, or null when any of the tokens came from
71
- * a model the table has no row for — a sum that leaves some of them out would read as the whole.
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 t = this.tokens;
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
- tokens: { ...t, total: t.input + t.output + t.cacheWrite + t.cacheRead },
77
- responses: this.responses,
78
- cost: this.unpriced.size ? null : this.usd,
79
- models: [...this.priced].sort(),
80
- unpriced: [...this.unpriced].sort(),
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 took, two ways. `durationMs` is from its first record to its last, pauses included.
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
- try { usage.add((await readAll(jsonl)).records); } catch { /* an agent that has not written yet */ }
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.result(), ...timesOf(records) };
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