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.
- package/README.md +72 -10
- package/package.json +7 -6
- package/src/cli.js +4 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/log.js +28 -1
- package/src/commands/requests.js +7 -2
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +112 -151
- package/src/commands/ui.js +72 -6
- package/src/commands/walls.js +8 -2
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +150 -2
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +62 -3
- package/src/inspect.js +178 -5
- package/src/intake.js +243 -0
- package/src/keys.js +108 -6
- package/src/log.js +203 -3
- package/src/mcp.js +28 -10
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +29 -0
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +134 -22
- package/src/spool.js +98 -20
- package/src/srt.js +123 -225
- package/src/surface.js +518 -0
- package/src/uilink.js +55 -0
- package/src/violations.js +37 -5
- package/src/walls.js +19 -5
- package/ui/favicon.svg +1 -1
- package/ui/index.html +221 -28
package/src/commands/run.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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,
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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, "--", ...
|
|
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
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
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
|
-
//
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/ui.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
|
|
91
|
+
open(url);
|
|
28
92
|
|
|
29
|
-
|
|
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
|
}
|
package/src/commands/walls.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/commands/whose.js
CHANGED
|
@@ -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 =
|
|
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
|
|
package/src/commands/wire.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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");
|