seisin 0.2.0 → 0.4.0

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.
@@ -8,22 +8,25 @@
8
8
  * Everything in here is ordering that was wrong once. The comments say which.
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
- import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
11
+ import { finished } from "node:stream/promises";
12
+ import { constants as osConstants } from "node:os";
13
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
12
14
  import { join, dirname, delimiter } from "node:path";
13
15
  import { fileURLToPath } from "node:url";
14
- import { settingsFor, roleHome } from "../srt.js";
16
+ import { settingsFor, roleHome, loopbackVia } from "../srt.js";
15
17
  import { buildEnv } from "../env.js";
16
18
  import { resolveKeys } from "../keys.js";
17
19
  import { nestedSandboxWarning } from "../nested.js";
18
- import { spool, spoolPath, SOCK_ENV } from "../spool.js";
20
+ import { spool, SOCK_ENV } from "../spool.js";
21
+ import { openRun } from "../rundir.js";
19
22
  import { secretsOf, redactor } from "../redact.js";
20
- import { STATE_DIR } from "../layout.js";
21
23
  import { C, err } from "../render.js";
22
- import { pending, record, requestsPath } from "../requests.js";
23
- import { ownersOf, explain } from "../owners.js";
24
- import { append, logPath } from "../log.js";
24
+ import { pending, requestsPath, markStale } from "../requests.js";
25
+ import { notifier } from "../notify.js";
26
+ import { logPath, read } from "../log.js";
25
27
  import { renderQueue } from "./requests.js";
26
- import { watchDenials, inScope, scopeOf, reachedForContent } from "../violations.js";
28
+ import { watchDenials } from "../violations.js";
29
+ import { intake } from "../intake.js";
27
30
 
28
31
  const HERE = dirname(fileURLToPath(import.meta.url));
29
32
 
@@ -60,14 +63,6 @@ export function resolveSrt() {
60
63
  return null;
61
64
  }
62
65
 
63
- /** Writes `.seisin/<role>.json` and returns its path. */
64
- export function writeSettings(config, role, sock = null, observe = false) {
65
- const dir = join(config.root, STATE_DIR);
66
- mkdirSync(dir, { recursive: true });
67
- const file = join(dir, `${role}.json`);
68
- writeFileSync(file, JSON.stringify(settingsFor(config, role, sock, observe), null, 2) + "\n");
69
- return file;
70
- }
71
66
 
72
67
  export async function run(config, argv) {
73
68
  /**
@@ -120,79 +115,22 @@ export async function run(config, argv) {
120
115
  * on disk is when the decision happened, not when the parent got around to it.
121
116
  */
122
117
  const observe = mine.includes("--observe");
123
- const sockPath = spoolPath();
124
- const audit = await spool((to, entry) => {
125
- /**
126
- * Nothing from inside the box is taken at its word.
127
- *
128
- * The sender is the process being recorded, so every field it supplies is
129
- * a claim. Two of them matter. `role` decides whose request this is — left
130
- * alone, a frontend agent could file one as backend and wait for a human to
131
- * approve it. `owners` decides who the queue says it belongs to, and the
132
- * parent can work that out itself from the policy.
133
- *
134
- * So the role is overwritten with the role of this run, the owners are
135
- * recomputed, and anything shaped wrong is dropped. Forging a *log* line is
136
- * noise. Forging a *request* is a sentence placed in front of a person for
137
- * approval, and that is a different thing entirely.
138
- */
139
- if (!entry || typeof entry !== "object") return;
140
-
141
- if (to === "log") {
142
- // The same treatment the queue already got. The role was overwritten but
143
- // everything else was passed through, so a process inside the box could
144
- // write "allowed" lines for paths it never touched — which would not move
145
- // the boundary, but would move `review`, and `init --from-observations`
146
- // builds a policy out of exactly this.
147
- if (entry.action !== "read" && entry.action !== "write") return;
148
- if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
149
- if (typeof entry.target !== "string" || !entry.target.trim()) return;
150
- return void append(logPath(config.root), {
151
- at: entry.at,
152
- role,
153
- tool: String(entry.tool ?? "").slice(0, 40),
154
- action: entry.action,
155
- kind: entry.kind === "key" ? "key" : "file",
156
- target: entry.target.slice(0, 1000),
157
- verdict: entry.verdict,
158
- owners: ownersOf(config, entry.target), // recomputed, never taken
159
- reason: String(entry.reason ?? "").slice(0, 500),
160
- });
161
- }
162
-
163
- if (entry.action !== "read" && entry.action !== "write") return;
164
- if (typeof entry.target !== "string" || !entry.target.trim()) return;
165
-
166
- /**
167
- * The policy has to actually refuse it, or it does not belong in a queue.
168
- *
169
- * Nothing checked. A process inside the box could file a request for a path
170
- * its own role already owns, and the queue would print *first refused on
171
- * …* about something that was never refused — a sentence placed in front of
172
- * a person for approval, describing an event that did not happen.
173
- *
174
- * Same treatment as `owners` one branch up: the claim is recomputed here
175
- * rather than believed. It is the policy answering a question about itself,
176
- * which is arithmetic, and it is the parent asking — the one process the
177
- * confined side cannot reach.
178
- *
179
- * This does not require the agent to have *tried*. Asking for a permission
180
- * before reaching for it is a reasonable thing to do, and the wording says
181
- * "asked" rather than "refused" for exactly that case. What it rules out is
182
- * a request for something that is not refused at all.
183
- */
184
- if (explain(config, role, entry.action, entry.target).allowed) return;
185
-
186
- record(requestsPath(config.root), {
187
- role,
188
- action: entry.action,
189
- target: entry.target,
190
- owners: ownersOf(config, entry.target),
191
- });
192
- }, sockPath);
193
-
118
+ // Built before the first request can arrive; the POST leaves from here, the
119
+ // parent, never from inside the box (notify.js).
120
+ const notify = notifier(config);
121
+ // Everything that belongs to this run alone — socket, settings, scratch keys
122
+ // — in one private directory, with one id. See rundir.js.
123
+ const theRun = openRun();
124
+ const sockPath = theRun.sock;
194
125
  const settings = settingsFor(config, role, sockPath, observe);
195
- const file = writeSettings(config, role, sockPath, observe);
126
+ // Per run, never per role: `.seisin/<role>.json` was one file for every run
127
+ // of that role, and two at once wrote each other's socket path into it.
128
+ const file = theRun.writeSettings(settings);
129
+
130
+ // What arrives from the hook and from the kernel, checked against the policy
131
+ // this run started with and written with the run's id on it. See intake.js.
132
+ const take = intake({ config, role, runId: theRun.id, observe, settings, notify });
133
+ const audit = await spool(take.fromHook, sockPath);
196
134
 
197
135
  const srt = resolveSrt();
198
136
  if (!srt) throw new Error("sandbox runtime not found. Install it with: npm i -g @anthropic-ai/sandbox-runtime");
@@ -219,7 +157,7 @@ export async function run(config, argv) {
219
157
  * declared fails later, somewhere else, looking like something it is not.
220
158
  */
221
159
  const resolved = resolveKeys(config, config.roles[role]);
222
- const scratchKeys = join(dirname(sockPath), "keys");
160
+ const scratchKeys = theRun.keys;
223
161
  if (resolved.some((r) => r.mode === "scratch")) mkdirSync(scratchKeys, { recursive: true, mode: 0o700 });
224
162
  for (const { entry, mode, value } of resolved) {
225
163
  if (mode === "env") { env[entry.name] = value; continue; }
@@ -328,55 +266,25 @@ export async function run(config, argv) {
328
266
  * there is one — `attributeTo` below — and anything that arrived in between is
329
267
  * held and re-examined rather than credited to this role on faith.
330
268
  *
331
- * These lines go through the same `append()` the spool does, with `owners`
332
- * recomputed here exactly as they are for a hook line. The rule that nothing
333
- * is taken at its word still applies; what changes is who is claiming. The
334
- * hook is the process being recorded, and the kernel is the thing that
335
- * actually refused — so when the two disagree, this is the one that is right.
269
+ * What each denial becomes — a log line, a request, or a count — is decided
270
+ * in intake.js, beside the hook's lines: the kernel is the thing that
271
+ * actually refused, so when the two disagree, it is the one that is right.
336
272
  */
337
- const scope = scopeOf(settings, config.root);
338
- const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
339
- let offPolicy = 0; // refused, but about nothing the policy names
340
- let walks = 0; // a recursive search reaching a closed door
341
- const denials = watchDenials((d) => {
342
- if (!inScope(d.path, scope)) { offPolicy++; return; }
343
- if (!reachedForContent(d)) { walks++; return; }
344
- // Relative inside the repo, absolute outside it. A role can be refused at
345
- // ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
346
- // answer to "what was refused" than the path itself.
347
- const rel = d.path.startsWith(config.root + "/") ? d.path.slice(config.root.length + 1) : d.path;
348
-
349
- /**
350
- * A refusal the hook never saw still leaves a request behind.
351
- *
352
- * Without this the two halves of the record disagree in the worst
353
- * direction: `review` would show a role stopped repeatedly on a directory
354
- * while the queue held nothing to approve, so the one refusal a person most
355
- * needed to see — the one that escaped the hook — would be the one with no
356
- * way to act on it.
357
- *
358
- * There is no double counting to avoid. When the hook catches something it
359
- * denies the tool call outright and the command never runs, so the kernel
360
- * never sees it. These are the ones that got past it, which is the whole
361
- * reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
362
- * under `isolate` and is not a territory anyone is meant to ask for.
363
- */
364
- if (rel !== d.path)
365
- record(requestsPath(config.root), { role, action: d.action, target: rel, owners: ownersOf(config, rel) });
366
-
367
- append(logPath(config.root), {
368
- at: new Date().toISOString(),
369
- role,
370
- tool: "kernel",
371
- source: "kernel",
372
- action: d.action,
373
- kind: keyDirs.some((k) => d.path === k || d.path.startsWith(k + "/")) ? "key" : "file",
374
- target: rel,
375
- verdict: "denied",
376
- owners: ownersOf(config, rel),
377
- reason: d.operation,
378
- });
379
- }, { argv: cmd });
273
+ // What actually runs inside the box, computed once and used by both ends. The
274
+ // watcher recognises a denial by comparing the runtime's command tag with this
275
+ // argv; handed `cmd` while `srt` was handed `env NO_PROXY=… cmd`, it recognised
276
+ // nothing for a role with `local_ports`, and every short refusal of those roles
277
+ // — writes included — left no line (measured: 0 of 5, and 5 of 5 without the key).
278
+ //
279
+ // And a nonce in front, because the tag alone does not tell runs apart: an
280
+ // orchestrator starts several roles with the SAME command, the kernel tags
281
+ // each denial with that command, and every watcher listening recognised it
282
+ // as its own. One write outside territory became one line per role running,
283
+ // one request per role, and a denial logged against the role that owns the
284
+ // path (measured: 3 lines and 3 requests for one write by one of 3 roles).
285
+ // The nonce makes each run's command unique, so the tag names one run.
286
+ const boxed = ["env", `SEISIN_RUN_ID=${theRun.id}`, ...loopbackVia(config.roles[role], cmd)];
287
+ const denials = watchDenials(take.fromKernel, { argv: boxed });
380
288
 
381
289
  // Deliberately NOT announced here. On Linux this branch is taken every time,
382
290
  // so saying it per run puts a line the reader cannot act on in front of every
@@ -395,11 +303,41 @@ export async function run(config, argv) {
395
303
  const nested = nestedSandboxWarning(cmd);
396
304
  if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
397
305
 
398
- const child = spawn(srt, ["--settings", file, "--", ...cmd], {
306
+ const child = spawn(srt, ["--settings", file, "--", ...boxed], {
399
307
  stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
400
308
  env,
401
309
  });
402
310
  denials.attributeTo(child.pid);
311
+
312
+ /**
313
+ * Signals: seisin must not die of one, and must not double one.
314
+ *
315
+ * Without handlers Node dies on SIGTERM and SIGINT at once: the agent kept
316
+ * running with nobody holding its audit channel, and the run's directory
317
+ * stayed behind (74 had piled up on one machine). An orchestrator's timeout
318
+ * is a SIGTERM to seisin's pid alone, so SIGTERM and SIGHUP are passed on,
319
+ * and the run exits 128 + the signal — the runtime itself ends 0 when told
320
+ * to stop, which reported success for a killed run.
321
+ *
322
+ * SIGINT is NOT passed on. It comes from the terminal, which sends it to the
323
+ * whole foreground group: the runtime gets it too and forwards it to the
324
+ * agent. Measured with one Ctrl-C: the agent received 2 SIGINTs before any
325
+ * of this (the runtime already duplicates), and 3 when seisin forwarded as
326
+ * well — and two in a row is how Claude Code tells "cancel" from "quit". So
327
+ * seisin only stops dying of it.
328
+ *
329
+ * And an interrupted run is never reported as a success. The runtime exits 0
330
+ * whenever its child dies of a signal (measured: SIGINT and SIGTERM, to the
331
+ * group or to its pid, all 0), so from here "the agent caught the Ctrl-C and
332
+ * finished" and "the agent was killed by it" look the same. The run exits
333
+ * 130 in both until the runtime passes the status through; the other choice
334
+ * reports a killed run as done, which is the one an orchestrator acts on.
335
+ */
336
+ let stoppedBy = null;
337
+ let interrupted = false;
338
+ process.on("SIGINT", () => { interrupted = true; });
339
+ for (const sig of ["SIGTERM", "SIGHUP"])
340
+ process.on(sig, () => { stoppedBy ??= sig; try { child.kill(sig); } catch {} });
403
341
  if (outStream) { child.stdout.pipe(outStream); child.stderr.pipe(errStream); }
404
342
 
405
343
  // The exit code is the child's, and the output has to be all the way out
@@ -407,15 +345,26 @@ export async function run(config, argv) {
407
345
  //
408
346
  // - Calling exit() the moment the child exits discards whatever is still in
409
347
  // the redaction stream, so the command looks like it produced nothing.
410
- // - Waiting for `finish` but subscribing AFTER end() misses the event when
411
- // it fires synchronously, so nothing ever calls exit and the process
412
- // drifts out of the event loop with status 0 — every failure reported as
413
- // a success. A battery of seven sandbox tests came back green on that.
348
+ // - Waiting for `finish` but subscribing AFTER it fired left nothing to call
349
+ // exit: first the process drifted out with status 0 (every failure
350
+ // reported as a success), later the 2-second guard below caught it and
351
+ // every keyed run paid two seconds. `finished()` has no such window.
414
352
  //
415
- // Subscribe first, end second, and keep a real timer (not unref'd, or it
416
- // cannot save anything) as the floor.
353
+ // Keep a real timer (not unref'd, or it cannot save anything) as the ceiling.
354
+ /**
355
+ * The status is worked out when the run is about to leave, not when the
356
+ * child exits. A Ctrl-C reaches the runtime and seisin at once; when the
357
+ * runtime is quicker, the child's `exit` is handled before seisin's own
358
+ * SIGINT, `interrupted` is still false, and an interrupted run left with 0.
359
+ * Seen once in a full suite run under load; by the end of the drains below
360
+ * the pending signal has been handled.
361
+ */
362
+ const statusOf = (code, signal) => stoppedBy ? 128 + osConstants.signals[stoppedBy]
363
+ : signal ? 1
364
+ : interrupted && !code ? 130
365
+ : code ?? 0;
366
+
417
367
  child.on("exit", async (code, signal) => {
418
- const status = signal ? 1 : code ?? 0;
419
368
  // The notice rides on what you are already looking at. A queue nobody opens
420
369
  // is not human-in-the-loop, and this is the terminal that just showed you
421
370
  // the denial — so it goes to stderr, beside it, not into the agent's stdout
@@ -431,7 +380,7 @@ export async function run(config, argv) {
431
380
  // nothing pays a tick.
432
381
  await denials.close({ drain: DRAIN_MS });
433
382
  // The run made this directory, so the run removes it.
434
- try { rmSync(dirname(sockPath), { recursive: true, force: true }); } catch {}
383
+ theRun.close();
435
384
 
436
385
  // Say what was dropped rather than only what was kept. A filter nobody can
437
386
  // see is indistinguishable from a monitor that is not working, and this one
@@ -441,6 +390,7 @@ export async function run(config, argv) {
441
390
  // policy's paths` is a line that answers a question nobody asked — the
442
391
  // counts exist to explain a filter, not to announce that one ran.
443
392
  const { attributed, foreign } = denials.stats;
393
+ const { offPolicy, walks } = take.stats;
444
394
  const recorded = attributed - offPolicy - walks;
445
395
  if (recorded > 0)
446
396
  err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
@@ -449,15 +399,26 @@ export async function run(config, argv) {
449
399
  `${foreign ? `, ${foreign} from other sandboxes` : ""}${C.off}\n`);
450
400
 
451
401
  const queue = pending(requestsPath(config.root));
452
- if (queue.length) err(renderQueue(queue));
402
+ if (queue.length) err(renderQueue(markStale(queue, read(logPath(config.root)))));
403
+ const failed = await notify.settle();
404
+ if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
405
+ // One more turn of the loop, so a signal that arrived with the child's exit
406
+ // has run its handler before the status is decided.
407
+ await new Promise((r) => setImmediate(r));
408
+ const status = statusOf(code, signal);
453
409
  if (!outStream) return process.exit(status);
454
- let left = 2;
410
+ // `finished()` and not `once("finish")`, because the event is usually over
411
+ // before this line runs: `child.stdout.pipe(outStream)` ends the redactor
412
+ // itself when the child's stdout closes, so the listener was attached to an
413
+ // event that had already fired and the 2-second guard was the only way out.
414
+ // Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
415
+ // `finished()` settles at once for a stream that is already done.
455
416
  const guard = setTimeout(() => process.exit(status), 2000);
456
- const tick = () => { if (--left > 0) return; clearTimeout(guard); process.exit(status); };
457
- outStream.once("finish", tick);
458
- errStream.once("finish", tick);
459
417
  outStream.end();
460
418
  errStream.end();
419
+ await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
420
+ clearTimeout(guard);
421
+ process.exit(status);
461
422
  });
462
423
  child.on("error", (e) => { throw new Error(`could not start the sandbox: ${e.message}`); });
463
424
  }
@@ -5,27 +5,93 @@
5
5
  * foreground command you start and stop, serving one page and one JSON
6
6
  * document, on loopback. Nothing in the enforcement path touches it. If it is
7
7
  * not running you lose a window, not a boundary.
8
+ *
9
+ * The token that authorizes the page travels only in the link's fragment and is
10
+ * never in an HTTP response (see serve.js). So a lost link cannot be recovered
11
+ * over the wire — only from the terminal that printed it. `writeUiLink` keeps a
12
+ * user-only copy on disk so it can be recovered without reopening that leak:
13
+ * `--link` prints it, and starting over a busy port reopens it instead of just
14
+ * failing. See uilink.js.
8
15
  */
9
16
  import { spawnSync } from "node:child_process";
10
17
  import { relative } from "node:path";
11
18
  import { serve } from "../serve.js";
19
+ import { writeUiLink, readUiLink, clearUiLink } from "../uilink.js";
12
20
  import { C, out } from "../render.js";
13
21
 
22
+ function open(url) {
23
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
24
+ spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
25
+ }
26
+
14
27
  export async function ui(config, argv = []) {
15
28
  const i = argv.indexOf("--port");
16
29
  const port = i === -1 ? 4178 : Number(argv[i + 1]);
17
30
 
18
- const server = await serve(config.path, port);
19
- const url = `http://127.0.0.1:${port}`;
31
+ // `seisin ui --link`: print the live link a running console left, and open
32
+ // it, without starting anything. For when the terminal that had it is gone.
33
+ if (argv.includes("--link")) {
34
+ const found = readUiLink(port);
35
+ if (found && found.alive !== false) {
36
+ out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n\n`);
37
+ open(found.url);
38
+ return;
39
+ }
40
+ if (found && found.alive === false) clearUiLink(port);
41
+ out(`\n ${C.dim}no seisin ui running on ${port} — start one with: seisin ui${port === 4178 ? "" : ` --port ${port}`}${C.off}\n\n`);
42
+ return;
43
+ }
44
+
45
+ let server;
46
+ try {
47
+ server = await serve(config.path, port);
48
+ } catch (e) {
49
+ // A console is already on this port: hand back its live link rather than
50
+ // erroring, which is the case that sends people digging in the terminal.
51
+ if (e.code === "EADDRINUSE") {
52
+ const found = readUiLink(port);
53
+ if (found && found.alive !== false) {
54
+ out(
55
+ `\n ${C.b}${found.url}${C.off}\n` +
56
+ ` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n\n`
57
+ );
58
+ open(found.url);
59
+ return;
60
+ }
61
+ // The port is busy but no live console left a link — a stale record, or
62
+ // something else is on the port. Drop the stale one and let the original
63
+ // "port is busy" message stand.
64
+ if (found && found.alive === false) clearUiLink(port);
65
+ }
66
+ throw e;
67
+ }
68
+
69
+ const bound = server.address().port;
70
+ const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
71
+ // Recorded before we print, so `--link` and a second `seisin ui` can find it.
72
+ writeUiLink(bound, url);
73
+
74
+ // local_binding opens every port (macOS); local_ports opens the ones it
75
+ // names, on every platform — so only a role that names THIS port counts.
76
+ const reach = Object.values(config.roles)
77
+ .filter((r) => (r.localBinding && process.platform === "darwin") || (r.localPorts ?? []).includes(bound))
78
+ .map((r) => r.name);
20
79
 
21
80
  out(
22
81
  `\n ${C.b}${url}${C.off}\n` +
23
- ` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n\n`
82
+ ` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n` +
83
+ ` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
84
+ ` ${C.dim}lost it? this terminal has it above, or run: seisin ui --link${bound === 4178 ? "" : ` --port ${bound}`}${C.off}\n` +
85
+ (reach.length
86
+ ? ` ${C.yellow}${reach.join(", ")} can reach this port on localhost: they can load this page, ` +
87
+ `but not the token, which only travels in the link above.${C.off}\n`
88
+ : "") + "\n"
24
89
  );
25
90
 
26
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
27
- spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
91
+ open(url);
28
92
 
29
- process.on("SIGINT", () => { server.close(); process.exit(0); });
93
+ const stop = () => { clearUiLink(bound); server.close(); process.exit(0); };
94
+ process.on("SIGINT", stop);
95
+ process.on("SIGTERM", stop);
30
96
  return server;
31
97
  }
@@ -1,24 +1,30 @@
1
1
  /** `seisin walls <role>` — what that role keeps being refused, and what it cost. */
2
2
  import { walls, render } from "../walls.js";
3
+ import { STALE_RUNS } from "../requests.js";
3
4
  import { logPath } from "../log.js";
4
5
  import { out } from "../render.js";
5
6
 
6
7
  export function wallsCommand(config, argv = []) {
7
8
  const role = argv[0];
8
- if (!role) throw new Error("usage: seisin walls <role> [--since <iso>] [--min <n>]");
9
+ if (!role) throw new Error("usage: seisin walls <role> [--since <iso>] [--min <n>] [--all]");
9
10
  if (!config.roles[role])
10
11
  throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
11
12
  const at = (flag, fallback) => {
12
13
  const i = argv.indexOf(flag);
13
14
  return i === -1 ? fallback : argv[i + 1];
14
15
  };
15
- const list = walls(config, role, {
16
+ const every = walls(config, role, {
16
17
  file: logPath(config.root),
17
18
  min: Number(at("--min", 2)),
18
19
  since: at("--since", null),
20
+ limit: 0,
19
21
  });
22
+ const all = argv.includes("--all");
23
+ const list = (all ? every : every.filter((w) => !w.stale)).slice(0, 6);
24
+ const old = every.filter((w) => w.stale).length;
20
25
  // Nothing to say prints nothing, the way `scan` does when a repo is clean.
21
26
  // A command that always speaks is one whose output stops being read.
22
27
  if (list.length) out(render(list));
28
+ if (old && !all) out(` (${old} older wall(s) not hit in the last ${STALE_RUNS}+ runs: seisin walls ${role} --all)\n`);
23
29
  return list;
24
30
  }
@@ -16,6 +16,7 @@
16
16
  * worth attacking, because unlike everything else in this tool it is meant to
17
17
  * be called *from inside* the box.
18
18
  */
19
+ import { toRepoRelative } from "../paths.js";
19
20
  import { ownersOf, keyHolders } from "../owners.js";
20
21
  import { twinsOf, whereIs } from "../worktree.js";
21
22
  import { C, out } from "../render.js";
@@ -24,7 +25,7 @@ export function whose(config, argv = []) {
24
25
  const target = argv[0];
25
26
  if (!target) throw new Error("usage: seisin whose <path>");
26
27
 
27
- const rel = target.startsWith(config.root + "/") ? target.slice(config.root.length + 1) : target;
28
+ const rel = toRepoRelative(config, target);
28
29
  const isKey = (config.keyDirs ?? []).some((d) => rel === d || rel.startsWith(d + "/"));
29
30
  const owners = isKey ? keyHolders(config, rel) : ownersOf(config, rel);
30
31
 
@@ -20,10 +20,29 @@ import { C, out } from "../render.js";
20
20
  const SETTINGS = join(".claude", "settings.json");
21
21
 
22
22
  /** Where the PreToolUse entry lives, and what it has to say. */
23
- export function hookEntry() {
23
+ export function hookEntry(command = "seisin hook") {
24
24
  return {
25
25
  matcher: "*",
26
- hooks: [{ type: "command", command: "seisin hook" }],
26
+ hooks: [{ type: "command", command }],
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Every event `seisin hook` answers, and the matcher each one needs.
32
+ *
33
+ * PreToolUse explains before an attempt; the other three explain what only the
34
+ * kernel saw (PostToolUseFailure, and PostToolUse for Bash, whose failed
35
+ * command is a result rather than a failure) and hand the agent its map when a
36
+ * session starts, resumes or is compacted. Exported so a harness that writes
37
+ * its own settings per role — rather than the project's — can install the same.
38
+ */
39
+ export function hookEntries(command = "seisin hook") {
40
+ const h = [{ type: "command", command }];
41
+ return {
42
+ PreToolUse: [{ matcher: "*", hooks: h }],
43
+ PostToolUse: [{ matcher: "Bash", hooks: h }],
44
+ PostToolUseFailure: [{ matcher: "*", hooks: h }],
45
+ SessionStart: [{ matcher: "startup|resume|compact", hooks: h }],
27
46
  };
28
47
  }
29
48
 
@@ -32,7 +51,11 @@ export function wired(root) {
32
51
  const file = join(root, SETTINGS);
33
52
  if (!existsSync(file)) return false;
34
53
  try {
35
- return JSON.stringify(JSON.parse(readFileSync(file, "utf8"))).includes("seisin hook");
54
+ // All four events, not any one: a repo wired before the after-the-fact and
55
+ // session-start hooks existed has only PreToolUse, and "already wired" there
56
+ // would keep it from ever getting the rest.
57
+ const hooks = JSON.parse(readFileSync(file, "utf8")).hooks ?? {};
58
+ return Object.keys(hookEntries()).every((ev) => JSON.stringify(hooks[ev] ?? []).includes("seisin hook"));
36
59
  } catch {
37
60
  return false; // unreadable settings is not "wired"
38
61
  }
@@ -58,8 +81,10 @@ export function wire(config) {
58
81
  }
59
82
 
60
83
  settings.hooks ??= {};
61
- settings.hooks.PreToolUse ??= [];
62
- settings.hooks.PreToolUse.push(hookEntry());
84
+ for (const [ev, entries] of Object.entries(hookEntries())) {
85
+ settings.hooks[ev] ??= [];
86
+ if (!JSON.stringify(settings.hooks[ev]).includes("seisin hook")) settings.hooks[ev].push(...entries);
87
+ }
63
88
 
64
89
  mkdirSync(join(config.root, ".claude"), { recursive: true });
65
90
  writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");