seisin 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -280,6 +280,7 @@ seisin explain frontend write src/api/x # ask one question, exit 0 or 1
280
280
  seisin review --since 7d # what the log says about the policy, this week
281
281
  seisin wire # install the PreToolUse hook in this repo
282
282
  seisin ui # a console for deciding what is waiting
283
+ seisin ui --runs <dir> # read only, over many runs a launcher archived (docs/runs.md)
283
284
  ```
284
285
 
285
286
  **Three ways in, and they say the same thing.** The **CLI** is what a person types and what
@@ -1036,9 +1037,9 @@ the same kernel or workspace, and which one you want depends on what you are pro
1036
1037
 
1037
1038
  ## Status
1038
1039
 
1039
- **1090 tests on macOS, 1083 passing and 7 platform skips** (0.6.1, 2026-10-26, local); in CI the
1040
- same day, on `ubuntu-latest` **1089, 1060 passing and 29 platform skips**, and on macOS 1090,
1041
- 1081 passing and 9 skips, Node 18/20/22/24, with the 26 core boundary tests skipping none. On 2026-10-12, 91 of the suite's tests
1040
+ **1101 tests on macOS, 1094 passing and 7 platform skips** (0.6.2, 2026-10-26, local); in CI the
1041
+ same day, on `ubuntu-latest` **1100, 1071 passing and 29 platform skips**, and on macOS 1101,
1042
+ 1092 passing and 9 skips, Node 18/20/22/24, with the 26 core boundary tests skipping none. On 2026-10-12, 91 of the suite's tests
1042
1043
  needed `@anthropic-ai/sandbox-runtime` installed — counted by running it with the runtime
1043
1044
  hidden — and run real commands through the real kernel; 26 of them are the core boundary file,
1044
1045
  `test/sandbox.test.js`, which the CI gate fails if it *skips*, because a green run that quietly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it denies something it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -45,7 +45,7 @@
45
45
  "@anthropic-ai/sandbox-runtime": "0.0.81"
46
46
  },
47
47
  "optionalDependencies": {
48
- "@carlostapiacl/tartaros": "0.1.0-alpha.14"
48
+ "@carlostapiacl/tartaros": "0.1.0-alpha.15"
49
49
  },
50
50
  "scripts": {
51
51
  "test": "node --test test/*.test.js",
package/src/cli.js CHANGED
@@ -57,6 +57,7 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
57
57
  seisin init [--force] propose a ${CONFIG_NAME} for this repo
58
58
  seisin init --from-observations write ${CONFIG_NAME}.observed from observed runs
59
59
  seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
60
+ seisin ui --runs <dir> [--link --json] a read-only console over a directory of runs (docs/runs.md)
60
61
  seisin mcp an MCP server on stdio — read-only
61
62
 
62
63
  seisin <command> --help lists a command's flags, examples and exit codes.
@@ -105,7 +106,7 @@ const COMMANDS = {
105
106
  },
106
107
  // --link only reads a record; it answers without a policy here too, and
107
108
  // says so when the console it finds serves another one.
108
- ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") ? maybeConfig() : config(), argv),
109
+ ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") || argv.includes("--runs") ? maybeConfig() : config(), argv),
109
110
  whose: async () => void (await import("./commands/whose.js")).whose(config(), argv),
110
111
  wire: async () => void (await import("./commands/wire.js")).wire(config()),
111
112
  review: async () => ((await import("./commands/review.js")).reviewCommand(config(), argv).friction.length ? 1 : 0),
@@ -14,6 +14,8 @@
14
14
  * failing. See uilink.js.
15
15
  */
16
16
  import { spawnSync } from "node:child_process";
17
+ import { statSync } from "node:fs";
18
+ import { resolve } from "node:path";
17
19
  import { serve } from "../serve.js";
18
20
  import { writeUiLink, readUiLink, clearUiLink } from "../uilink.js";
19
21
  import { C, out } from "../render.js";
@@ -23,28 +25,58 @@ function open(url) {
23
25
  spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
24
26
  }
25
27
 
28
+ /**
29
+ * `--runs <dir>`: the console over a directory of runs, read only (runs.js,
30
+ * docs/runs.md), on 4179 unless told otherwise, so it does not take the port a
31
+ * repository's own console would want.
32
+ */
33
+ function runsDirOf(argv) {
34
+ const i = argv.indexOf("--runs");
35
+ if (i === -1) return null;
36
+ const dir = argv[i + 1];
37
+ if (!dir || dir.startsWith("--")) throw new Error("--runs needs a directory: seisin ui --runs <dir>");
38
+ let st = null;
39
+ try { st = statSync(dir); } catch {}
40
+ if (!st?.isDirectory()) throw new Error(`--runs ${dir}: not a directory`);
41
+ return resolve(dir);
42
+ }
43
+
26
44
  export async function ui(config, argv = []) {
45
+ const runsDir = runsDirOf(argv);
46
+ const mode = runsDir ? "runs" : "policy";
27
47
  const i = argv.indexOf("--port");
28
- const port = i === -1 ? 4178 : Number(argv[i + 1]);
48
+ const port = i === -1 ? (runsDir ? 4179 : 4178) : Number(argv[i + 1]);
49
+ const json = argv.includes("--json");
29
50
 
30
51
  // `seisin ui --link`: print the live link a running console left, and open
31
52
  // it, without starting anything. For when the terminal that had it is gone.
53
+ // With --json, the record for a program to read — `{url, port, mode}` — and
54
+ // nothing opened: the launcher's own panel links to it.
32
55
  if (argv.includes("--link")) {
33
56
  const found = readUiLink(port);
57
+ if (json) {
58
+ if (found && found.alive === false) clearUiLink(port);
59
+ const live = found && found.alive !== false;
60
+ out(JSON.stringify(live
61
+ ? { running: true, url: found.url, port, mode: found.mode, ...(found.dir && { dir: found.dir }), ...(found.policy && { policy: found.policy }) }
62
+ : { running: false, port }) + "\n");
63
+ return;
64
+ }
34
65
  if (found && found.alive !== false) {
35
66
  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` +
36
- servesWhich(found.policy, config?.path) + "\n");
67
+ servesWhich(found.policy, runsDir ? null : config?.path, found) + "\n");
37
68
  open(found.url);
38
69
  return;
39
70
  }
40
71
  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`);
72
+ const start = runsDir ? `seisin ui --runs ${runsDir}${port === 4179 ? "" : ` --port ${port}`}` : `seisin ui${port === 4178 ? "" : ` --port ${port}`}`;
73
+ out(`\n ${C.dim}no seisin ui running on ${port} — start one with: ${start}${C.off}\n\n`);
42
74
  return;
43
75
  }
44
76
 
45
77
  let server;
46
78
  try {
47
- server = await serve(config.path, port);
79
+ server = await serve(runsDir ? null : config.path, port, { runsDir });
48
80
  } catch (e) {
49
81
  // A console is already on this port: hand back its live link rather than
50
82
  // erroring, which is the case that sends people digging in the terminal.
@@ -54,7 +86,7 @@ export async function ui(config, argv = []) {
54
86
  out(
55
87
  `\n ${C.b}${found.url}${C.off}\n` +
56
88
  ` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n` +
57
- servesWhich(found.policy, config.path) + "\n"
89
+ servesWhich(found.policy, runsDir ? null : config.path, found) + "\n"
58
90
  );
59
91
  open(found.url);
60
92
  return;
@@ -70,19 +102,22 @@ export async function ui(config, argv = []) {
70
102
  const bound = server.address().port;
71
103
  const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
72
104
  // Recorded before we print, so `--link` and a second `seisin ui` can find it.
73
- writeUiLink(bound, url, undefined, config.path);
105
+ writeUiLink(bound, url, undefined, runsDir ? null : config.path, { mode, dir: runsDir });
74
106
 
75
107
  // local_binding opens every port (macOS); local_ports opens the ones it
76
108
  // names, on every platform — so only a role that names THIS port counts.
77
- const reach = Object.values(config.roles)
109
+ const reach = Object.values(config?.roles ?? {})
78
110
  .filter((r) => (r.localBinding && process.platform === "darwin") || (r.localPorts ?? []).includes(bound))
79
111
  .map((r) => r.name);
80
112
 
81
113
  out(
82
114
  `\n ${C.b}${url}${C.off}\n` +
83
- ` ${C.dim}reading ${config.path} and the log, live. ctrl-c to stop.${C.off}\n` +
115
+ (runsDir
116
+ ? ` ${C.dim}reading the runs in ${runsDir}, live and read only: nothing is granted from here. ctrl-c to stop.${C.off}\n` +
117
+ ` ${C.dim}one run: add &run=<run_id> to the link.${C.off}\n`
118
+ : ` ${C.dim}reading ${config.path} and the log, live. ctrl-c to stop.${C.off}\n`) +
84
119
  ` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
85
- ` ${C.dim}lost it? this terminal has it above, or run: seisin ui --link${bound === 4178 ? "" : ` --port ${bound}`}${C.off}\n` +
120
+ ` ${C.dim}lost it? this terminal has it above, or run: seisin ui${runsDir ? ` --runs ${runsDir}` : ""} --link${bound === (runsDir ? 4179 : 4178) ? "" : ` --port ${bound}`}${C.off}\n` +
86
121
  (reach.length
87
122
  ? ` ${C.yellow}${reach.join(", ")} can reach this port on localhost: they can load this page, ` +
88
123
  `but not the token, which only travels in the link above.${C.off}\n`
@@ -101,7 +136,8 @@ export async function ui(config, argv = []) {
101
136
  * Which policy a running console serves, and a warning when it is not the one
102
137
  * this directory would use. `here` is null when there is no policy here.
103
138
  */
104
- export function servesWhich(served, here) {
139
+ export function servesWhich(served, here, found = null) {
140
+ if (found?.mode === "runs") return ` ${C.dim}serving the runs in ${found.dir} (read only)${C.off}\n`;
105
141
  if (!served) return ` ${C.dim}(started by an older seisin: which seisin.toml it serves is not recorded)${C.off}\n`;
106
142
  if (here && served !== here)
107
143
  return ` ${C.yellow}it serves ${served} — not ${here}, the policy for this directory.${C.off}\n` +
package/src/help.js CHANGED
@@ -123,10 +123,12 @@ export const COMMAND_HELP = {
123
123
  exits: [["0", "written"], ["2", "a policy is already there, or nothing was observed"]],
124
124
  },
125
125
  ui: {
126
- usage: ["seisin ui [--port <n>] [--link]"],
127
- values: ["--port"],
128
- flags: [["--port <n>", "port on 127.0.0.1 (default 4178)"], ["--link", "print the link of a console already running, start nothing"]],
129
- examples: ["seisin ui", "seisin ui --link"],
126
+ usage: ["seisin ui [--port <n>] [--link]", "seisin ui --runs <dir> [--port <n>] [--link [--json]]"],
127
+ values: ["--port", "--runs"],
128
+ flags: [["--port <n>", "port on 127.0.0.1 (default 4178; 4179 with --runs)"], ["--link", "print the link of a console already running, start nothing"],
129
+ ["--runs <dir>", "a read-only console over a directory of runs, one folder each (docs/runs.md)"],
130
+ ["--json", "with --link: {running, url, port, mode} for a program to read, nothing opened"]],
131
+ examples: ["seisin ui", "seisin ui --link", "seisin ui --runs ~/.wapentake/seisin/runs", "seisin ui --runs ~/.wapentake/seisin/runs --link --json"],
130
132
  exits: [["0", "stopped"], ["2", "could not start"]],
131
133
  },
132
134
  mcp: {
package/src/runs.js ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * `seisin ui --runs <dir>`: the console over many runs, each with its own policy.
3
+ *
4
+ * A launcher that lends each run a territory of its own (wapentake does) has no
5
+ * single seisin.toml to open: every run gets one written for it at the root of
6
+ * a worktree, runs there, and the worktree is gone a day or two later. What is
7
+ * worth keeping is copied out — the policy, the `.seisin/` beside it and a
8
+ * `run.json` the launcher writes — one folder per run, under one directory.
9
+ * This reads that directory. The contract is docs/runs.md.
10
+ *
11
+ * Read only, on purpose. The territory is the launcher's to lend: a grant
12
+ * written into an archived run's seisin.toml changes nothing anywhere, so the
13
+ * console in this mode offers none and the server refuses every write.
14
+ *
15
+ * A run that is archived does not change, so what is worked out from it is
16
+ * kept for as long as its files' signature stays the same — the poll every two
17
+ * seconds over hundreds of runs re-reads a directory listing and some stats,
18
+ * not every log.
19
+ */
20
+ import { readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { loadConfig } from "./config.js";
23
+ import { read, logPath } from "./log.js";
24
+ import { requestsPath } from "./requests.js";
25
+
26
+ /** The name a run's folder may have: what becomes `run=` in a link. */
27
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
28
+
29
+ /** The fields of run.json the console reads; anything else in it is ignored. */
30
+ const META = ["run_id", "team", "work_item", "role", "repo", "started_at", "ended_at", "status", "launcher"];
31
+
32
+ /** How many causes the global view is sent, summed over the runs shown. */
33
+ const CAUSES_SHOWN = 50;
34
+
35
+ /** How many of a run's own causes travel with it in the list. */
36
+ const RUN_CAUSES = 5;
37
+
38
+ export function isRunId(id) {
39
+ return typeof id === "string" && RUN_ID.test(id) && id !== "." && id !== "..";
40
+ }
41
+
42
+ function statSig(p) {
43
+ try { const s = statSync(p); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return "-"; }
44
+ }
45
+
46
+ /** The run folders under `dir`, by name; a file or a name that is not a run id is skipped. */
47
+ export function runFolders(dir) {
48
+ let names;
49
+ try { names = readdirSync(dir, { withFileTypes: true }); }
50
+ catch (e) { throw Object.assign(new Error(`cannot read the runs directory ${dir}: ${e.message}`), { status: 500 }); }
51
+ return names.filter((d) => d.isDirectory() && isRunId(d.name)).map((d) => d.name).sort();
52
+ }
53
+
54
+ /** Where a run's policy is. */
55
+ export function runPolicy(dir, id) {
56
+ return join(dir, id, "seisin.toml");
57
+ }
58
+
59
+ /**
60
+ * What a run's summary is made from, as stats only: the policy, run.json, the
61
+ * queue, the current log and the `.seisin/` directory itself, whose mtime
62
+ * moves when a segment is rotated in or out. No directory is listed: six
63
+ * hundred `readdir`s were most of a warm poll (~0.7 s).
64
+ */
65
+ function signatureOf(dir, id) {
66
+ const root = join(dir, id);
67
+ const log = logPath(root);
68
+ return [
69
+ statSig(join(root, "seisin.toml")),
70
+ statSig(join(root, "run.json")),
71
+ statSig(requestsPath(root)),
72
+ statSig(log),
73
+ statSig(dirname(log)),
74
+ ].join("|");
75
+ }
76
+
77
+ /** run.json, the launcher's word on the run — or null when it is missing or not JSON. */
78
+ function metaOf(dir, id) {
79
+ let raw;
80
+ try { raw = JSON.parse(readFileSync(join(dir, id, "run.json"), "utf8")); } catch { return null; }
81
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
82
+ const out = {};
83
+ for (const k of META) if (raw[k] !== undefined && raw[k] !== null) out[k] = typeof raw[k] === "string" ? raw[k] : String(raw[k]);
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * What one run comes to: who it was, what it was denied, what the denials
89
+ * cost in retries (every denial after the first of the same thing, system
90
+ * denials aside), and whether it was let start at all.
91
+ *
92
+ * `outcome` is seisin's word, beside the launcher's `status`:
93
+ * refused — its canary failed, so nothing ran;
94
+ * denied — it ran and was denied a file or a host;
95
+ * clean — it ran and was denied neither (system denials, the OS's own
96
+ * housekeeping, and the canary's expected probes do not count);
97
+ * unread — its policy could not be read.
98
+ */
99
+ function summarize(dir, id) {
100
+ const meta = metaOf(dir, id);
101
+ const base = {
102
+ id,
103
+ run_id: meta?.run_id ?? id,
104
+ team: meta?.team ?? null,
105
+ work_item: meta?.work_item ?? null,
106
+ role: meta?.role ?? null,
107
+ repo: meta?.repo ?? null,
108
+ started_at: meta?.started_at ?? null,
109
+ ended_at: meta?.ended_at ?? null,
110
+ status: meta?.status ?? null,
111
+ launcher: meta?.launcher ?? null,
112
+ meta: meta !== null,
113
+ };
114
+ let cfg;
115
+ try { cfg = loadConfig(runPolicy(dir, id)); }
116
+ catch (e) {
117
+ return { ...base, outcome: "unread", error: e.code === "ENOENT" ? "no seisin.toml in this run's folder" : e.message,
118
+ roles: [], denied: 0, byKind: {}, expected: 0, retried: 0, causes: [], hosts: [], entries: 0, canary: null };
119
+ }
120
+ const all = read(logPath(cfg.root));
121
+ /**
122
+ * Grouped here, by what was denied, rather than through causesOf and walls:
123
+ * those ask the policy and the disk about every path (~130 ms a run on the
124
+ * pilot's logs, a minute and more over six hundred), to say whether a
125
+ * denial would still be one today. An archived run's policy does not change,
126
+ * so every repeat of a denial is a retry against a standing wall, and the
127
+ * per-run view, which is one run, still gets the full answer from state().
128
+ */
129
+ const byKind = {};
130
+ let denied = 0, expected = 0;
131
+ const groups = new Map();
132
+ for (const e of all) {
133
+ if (e.verdict !== "denied") continue;
134
+ if (e.expected === true) { expected++; continue; }
135
+ const k = e.kind ?? "file";
136
+ byKind[k] = (byKind[k] ?? 0) + 1;
137
+ if (k !== "system") denied++;
138
+ if (!e.action || !e.target) continue;
139
+ const key = `${e.action}\u0000${e.target}`;
140
+ const g = groups.get(key) ?? { about: k, action: e.action, target: e.target, times: 0, owners: e.owners ?? [] };
141
+ g.times++;
142
+ groups.set(key, g);
143
+ }
144
+ const causes = [...groups.values()].sort((a, b) => b.times - a.times);
145
+ const retried = causes.reduce((n, g) => n + (g.about === "system" ? 0 : g.times - 1), 0);
146
+ const hosts = causes.filter((g) => g.about === "network").slice(0, 10).map(({ target, times }) => ({ target, times }));
147
+ const canaries = all.filter((e) => e.event === "canary");
148
+ const canary = canaries.length ? canaries[canaries.length - 1] : null;
149
+ const outcome = canaries.some((e) => e.confined === false) ? "refused" : denied ? "denied" : "clean";
150
+ return {
151
+ ...base,
152
+ // A run.json that did not say when it started: the log's first line did.
153
+ started_at: base.started_at ?? all.find((e) => e.at)?.at ?? null,
154
+ ended_at: base.ended_at ?? [...all].reverse().find((e) => e.at)?.at ?? null,
155
+ outcome,
156
+ roles: Object.keys(cfg.roles),
157
+ sandbox: canary?.sandbox ?? null,
158
+ entries: all.length,
159
+ denied,
160
+ expected,
161
+ byKind,
162
+ retried,
163
+ hosts,
164
+ // Kept whole for the sum across runs, cut to RUN_CAUSES when sent.
165
+ causes,
166
+ canary: canary ? { confined: canary.confined !== false, reasons: canary.reasons ?? [] } : null,
167
+ };
168
+ }
169
+
170
+ /** Summaries kept by run id, with the signature they were made from. */
171
+ const cache = new Map();
172
+
173
+ /** Every run under `dir`, summarized, re-read only where a file moved. */
174
+ export function scanRuns(dir) {
175
+ const ids = runFolders(dir);
176
+ const seen = new Set(ids);
177
+ for (const k of cache.keys()) if (k.startsWith(`${dir}\u0000`) && !seen.has(k.slice(dir.length + 1))) cache.delete(k);
178
+ const sigs = [];
179
+ const runs = ids.map((id) => {
180
+ const sig = signatureOf(dir, id);
181
+ sigs.push(`${id}=${sig}`);
182
+ const k = `${dir}\u0000${id}`;
183
+ const hit = cache.get(k);
184
+ if (hit && hit.sig === sig) return hit.summary;
185
+ const summary = summarize(dir, id);
186
+ cache.set(k, { sig, summary });
187
+ return summary;
188
+ });
189
+ return { runs, sig: sigs.join("\n") };
190
+ }
191
+
192
+ /** The filters the global view takes, from a query string. Unknown keys are ignored. */
193
+ export const FILTERS = ["team", "role", "work_item", "status", "outcome"];
194
+
195
+ export function filtersOf(query) {
196
+ const q = new URLSearchParams(query ?? "");
197
+ const out = {};
198
+ for (const f of FILTERS) { const v = q.get(f); if (v) out[f] = v; }
199
+ const text = q.get("q");
200
+ if (text) out.q = text.toLowerCase();
201
+ return out;
202
+ }
203
+
204
+ function matches(run, filters) {
205
+ for (const f of FILTERS) if (filters[f] !== undefined && (run[f] ?? "") !== filters[f]) return false;
206
+ if (filters.q) {
207
+ const hay = [run.id, run.run_id, run.work_item, run.repo, run.team, run.role].filter(Boolean).join(" ").toLowerCase();
208
+ if (!hay.includes(filters.q)) return false;
209
+ }
210
+ return true;
211
+ }
212
+
213
+ /** How many runs have each value of each filter — over every run, so a filter never hides its siblings. */
214
+ function facetsOf(runs) {
215
+ const out = {};
216
+ for (const f of FILTERS) {
217
+ const m = new Map();
218
+ for (const r of runs) { const v = r[f]; if (v) m.set(v, (m.get(v) ?? 0) + 1); }
219
+ out[f] = [...m].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))).map(([value, runs]) => ({ value, runs }));
220
+ }
221
+ return out;
222
+ }
223
+
224
+ /**
225
+ * The same refusal across runs is one cause: summed by what was refused, with
226
+ * how many runs hit it and which teams and roles they were.
227
+ */
228
+ function causesAcross(runs) {
229
+ const by = new Map();
230
+ for (const r of runs) {
231
+ for (const c of r.causes) {
232
+ const k = `${c.action}\u0000${c.target}`;
233
+ const g = by.get(k) ?? { action: c.action, target: c.target, about: c.about, owners: c.owners, times: 0, runs: 0, teams: new Set(), roles: new Set(), last: null };
234
+ g.times += c.times;
235
+ g.runs++;
236
+ if (r.team) g.teams.add(r.team);
237
+ if (r.role) g.roles.add(r.role);
238
+ if (!g.last || (r.started_at && r.started_at > g.last.at)) g.last = { run: r.id, at: r.started_at };
239
+ by.set(k, g);
240
+ }
241
+ }
242
+ return [...by.values()]
243
+ // The OS's own housekeeping last: it is in every run and nobody grants it.
244
+ .sort((a, b) => (a.about === "system") - (b.about === "system") || b.runs - a.runs || b.times - a.times)
245
+ .slice(0, CAUSES_SHOWN)
246
+ .map((g) => ({ ...g, teams: [...g.teams], roles: [...g.roles], last: g.last?.run ?? null }));
247
+ }
248
+
249
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
250
+
251
+ /** The global view: the runs that pass the filters, newest first, and what they add up to. */
252
+ const views = new Map();
253
+
254
+ export function runsView(dir, filters = {}, { scanned = scanRuns(dir) } = {}) {
255
+ // The same runs and the same filters are the same answer: the poll asks
256
+ // every two seconds, and summing the causes of six hundred runs was most
257
+ // of what it cost once the runs themselves were remembered.
258
+ const key = `${dir}\u0000${JSON.stringify(filters)}\u0000${scanned.sig}`;
259
+ const hit = views.get(key);
260
+ if (hit) return hit;
261
+ const view = viewOf(dir, filters, scanned.runs);
262
+ views.set(key, view);
263
+ while (views.size > 8) views.delete(views.keys().next().value);
264
+ return view;
265
+ }
266
+
267
+ function viewOf(dir, filters, every) {
268
+ const shown = every.filter((r) => matches(r, filters))
269
+ .sort((a, b) => cmp(b.started_at ?? "", a.started_at ?? "") || cmp(b.id, a.id));
270
+ const totals = { runs: shown.length, denied: 0, retried: 0, expected: 0, byKind: {}, outcome: {} };
271
+ for (const r of shown) {
272
+ totals.denied += r.denied;
273
+ totals.retried += r.retried;
274
+ totals.expected += r.expected;
275
+ for (const [k, n] of Object.entries(r.byKind)) totals.byKind[k] = (totals.byKind[k] ?? 0) + n;
276
+ totals.outcome[r.outcome] = (totals.outcome[r.outcome] ?? 0) + 1;
277
+ }
278
+ return {
279
+ mode: "runs",
280
+ dir,
281
+ filters,
282
+ total: every.length,
283
+ facets: facetsOf(every),
284
+ totals,
285
+ causes: causesAcross(shown),
286
+ runs: shown.map((r) => ({ ...r, causes: r.causes.slice(0, RUN_CAUSES) })),
287
+ readOnly: true,
288
+ };
289
+ }
290
+
291
+ /** One run's summary, by id; throws 404 for a name that is not a run here. */
292
+ export function runOf(dir, id) {
293
+ if (!isRunId(id)) throw Object.assign(new Error(`not a run id: ${JSON.stringify(id)}`), { status: 400 });
294
+ if (!runFolders(dir).includes(id)) throw Object.assign(new Error(`no run ${id} in ${dir}`), { status: 404 });
295
+ const { runs } = scanRuns(dir);
296
+ const r = runs.find((x) => x.id === id);
297
+ return { ...r, causes: r.causes.slice(0, RUN_CAUSES) };
298
+ }
package/src/serve.js CHANGED
@@ -23,6 +23,7 @@ import { pending, requestsPath, settle, applyGrant, refuseIfBarred, editPolicy,
23
23
  import { causesOf, describeWalls, parseSince, queue, verdicts, wallsByRole } from "./views.js";
24
24
  import { planEdit, hashOf } from "./controls.js";
25
25
  import { inspect } from "./inspect.js";
26
+ import { scanRuns, runsView, runOf, runPolicy, filtersOf, isRunId } from "./runs.js";
26
27
 
27
28
  // Moved to views.js, which the MCP server shares; kept importable from here.
28
29
  export { causesOf } from "./views.js";
@@ -104,7 +105,7 @@ function remember(map, key, value, max = 4) {
104
105
  * page downloaded every two seconds and it drew none of them. One role's are
105
106
  * served on demand by /api/settings.
106
107
  */
107
- export function state(configPath, { since = null, snap = snapshot(configPath) } = {}) {
108
+ export function state(configPath, { since = null, snap = snapshot(configPath), readOnly = false } = {}) {
108
109
  const { cfg, text, all } = snap;
109
110
 
110
111
  // The date filter narrows everything read from the log: activity, causes,
@@ -162,7 +163,9 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
162
163
  // A write keyed by a folder can be granted as its one file instead; the
163
164
  // line that choice carries is said here, once, so the page and the CLI
164
165
  // say the same thing about Claude Code.
165
- requests: queue(cfg, all).map((r) => ({
166
+ // Read only (an archived run): a queue an older seisin left is an error to
167
+ // show, never a rewrite of a file that is not this console's (requests.js).
168
+ requests: queue(cfg, all, { readOnly }).map((r) => ({
166
169
  ...r,
167
170
  // Said by the server in the same words as the CLI, so the page never
168
171
  // works out where a request goes on its own (handoff.js).
@@ -197,14 +200,14 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
197
200
  * moved — and a tag naming it, so a poll that already has it gets a 304 and
198
201
  * no body at all.
199
202
  */
200
- function stateBody(configPath, since) {
203
+ function stateBody(configPath, since, { readOnly = false } = {}) {
201
204
  // One snapshot for the tag and the body. Each used to take its own, so a
202
205
  // file that moved between the two calls got a body filed under the tag of
203
206
  // what was there before — and a 304 for it until something else moved.
204
207
  const snap = snapshot(configPath);
205
- const k = `${snap.key}|${since}`;
208
+ const k = `${snap.key}|${since}|${readOnly}`;
206
209
  return memo.bodies.get(k) ?? remember(memo.bodies, k, {
207
- body: JSON.stringify(state(configPath, { since, snap })),
210
+ body: JSON.stringify(state(configPath, { since, snap, readOnly })),
208
211
  etag: `"${createHash("sha256").update(k).digest("hex").slice(0, 24)}"`,
209
212
  });
210
213
  }
@@ -458,7 +461,37 @@ function declineAll(configPath, { keys, reason }) {
458
461
  return { ok: true, declined };
459
462
  }
460
463
 
461
- export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
464
+ /**
465
+ * The console over a directory of runs (`seisin ui --runs`, runs.js), read
466
+ * only: the territory each run had was lent by whoever launched it, and a
467
+ * grant written into an archived policy changes nothing. So in this mode
468
+ * every route that writes answers 405, and the page draws no grant.
469
+ */
470
+ function runsBody(dir, query) {
471
+ const filters = filtersOf(query);
472
+ const scanned = scanRuns(dir);
473
+ const k = `${JSON.stringify(filters)}|${scanned.sig}`;
474
+ return runsMemo.get(k) ?? remember(runsMemo, k, {
475
+ body: JSON.stringify(runsView(dir, filters, { scanned })),
476
+ etag: `"${createHash("sha256").update(k).digest("hex").slice(0, 24)}"`,
477
+ });
478
+ }
479
+ const runsMemo = new Map();
480
+
481
+ /** One run as the page draws it: its own state, read only, with what run.json said. */
482
+ function runBody(dir, id, since) {
483
+ const run = runOf(dir, id);
484
+ if (run.outcome === "unread") throw Object.assign(new Error(`run ${id}: ${run.error}`), { status: 422 });
485
+ const got = stateBody(runPolicy(dir, id), since, { readOnly: true });
486
+ // The state is already a string; the run is stitched onto it rather than
487
+ // parsed and serialized again on every poll.
488
+ return {
489
+ body: `${got.body.slice(0, -1)},"mode":"runs","readOnly":true,"run":${JSON.stringify(run)}}`,
490
+ etag: `"${createHash("sha256").update(`${got.etag}|${JSON.stringify(run)}`).digest("hex").slice(0, 24)}"`,
491
+ };
492
+ }
493
+
494
+ export function serve(configPath, port = 4178, { lockWaitMs = 10000, runsDir = null } = {}) {
462
495
  const page = join(HERE, "..", "ui", "index.html");
463
496
  if (!existsSync(page)) throw new Error("the console is missing from this install");
464
497
 
@@ -553,6 +586,12 @@ export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
553
586
  return answer(statusOf(e), { error: e.message });
554
587
  }
555
588
  };
589
+ const query = (req.url ?? "").split("?")[1] ?? "";
590
+ // Over runs, nothing is written: not a decision, not a control file.
591
+ if (runsDir && req.method !== "GET" && req.method !== "HEAD") {
592
+ res.writeHead(405, { allow: "GET", "content-type": "application/json" });
593
+ return res.end(JSON.stringify({ error: "read only: this console shows archived runs. Access is asked for where the run was launched, not here." }));
594
+ }
556
595
  // ~85 bytes per key: 1 MB is ten thousand requests on one screen.
557
596
  if (req.method === "POST" && pathOf(req.url) === "/api/decline-all")
558
597
  return post(1024 * 1024, (args) => declineAll(configPath, args));
@@ -571,12 +610,23 @@ export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
571
610
  res.writeHead(405, { allow: "GET" });
572
611
  return res.end();
573
612
  }
574
- if (pathOf(req.url) === "/api/state") {
613
+ if (pathOf(req.url) === "/api/info")
614
+ return answer(200, runsDir ? { mode: "runs", dir: runsDir, readOnly: true } : { mode: "policy", config: configPath, readOnly: false });
615
+ const routes = runsDir
616
+ ? { "/api/runs": () => runsBody(runsDir, query),
617
+ "/api/run": () => {
618
+ const id = new URLSearchParams(query).get("run");
619
+ if (!isRunId(id)) throw bad("run must name one of the runs: ?run=<run_id>");
620
+ return runBody(runsDir, id, sinceOf(req.url));
621
+ } }
622
+ : { "/api/state": () => stateBody(configPath, sinceOf(req.url)) };
623
+ const route = routes[pathOf(req.url)];
624
+ if (route) {
575
625
  let got;
576
626
  try {
577
- got = stateBody(configPath, sinceOf(req.url));
627
+ got = route();
578
628
  } catch (e) {
579
- return answer(500, { error: e.message });
629
+ return answer(e.status ?? 500, { error: e.message });
580
630
  }
581
631
  // The page polls, so tell every cache in between to stay out of it;
582
632
  // the page itself sends the tag back and is told when nothing moved.
@@ -587,8 +637,16 @@ export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
587
637
  }
588
638
  if (pathOf(req.url) === "/api/settings") {
589
639
  try {
590
- const role = new URLSearchParams(req.url.split("?")[1] ?? "").get("role");
591
- return answer(200, { role, settings: settingsOf(configPath, role) });
640
+ const params = new URLSearchParams(query);
641
+ const role = params.get("role");
642
+ let policy = configPath;
643
+ if (runsDir) {
644
+ const id = params.get("run");
645
+ if (!isRunId(id)) throw bad("run must name one of the runs: ?run=<run_id>");
646
+ runOf(runsDir, id);
647
+ policy = runPolicy(runsDir, id);
648
+ }
649
+ return answer(200, { role, settings: settingsOf(policy, role) });
592
650
  } catch (e) {
593
651
  return answer(e.status ?? 500, { error: e.message });
594
652
  }
@@ -611,6 +669,9 @@ export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
611
669
  // For `seisin ui`, which builds the URL, and for tests. Never sent.
612
670
  Object.defineProperty(server, "seisinToken", { value: token });
613
671
  ok(server);
672
+ // The first read of a few hundred runs takes seconds; started now, it is
673
+ // mostly done by the time the page asks.
674
+ if (runsDir) setImmediate(() => { try { scanRuns(runsDir); } catch {} });
614
675
  });
615
676
  server.on("error", (e) =>
616
677
  fail(e.code === "EADDRINUSE"
package/src/uilink.js CHANGED
@@ -35,10 +35,12 @@ function linkFile(port, base) {
35
35
  * console another repo had started — and opened a page about the wrong policy
36
36
  * with nothing on it to say so. The policy's path travels with the link now.
37
37
  */
38
- export function writeUiLink(port, url, base, policy = null) {
38
+ export function writeUiLink(port, url, base, policy = null, { mode = "policy", dir = null } = {}) {
39
39
  const file = linkFile(port, base);
40
40
  const temp = `${file}.tmp-${randomBytes(12).toString("hex")}`;
41
- writeFileSync(temp, JSON.stringify({ url, pid: process.pid, policy }) + "\n", { flag: "wx", mode: 0o600 });
41
+ // `mode` and `dir`: a console over a directory of runs (`--runs`) serves no
42
+ // one policy, and whoever reads this record has to be able to tell.
43
+ writeFileSync(temp, JSON.stringify({ url, pid: process.pid, policy, mode, dir }) + "\n", { flag: "wx", mode: 0o600 });
42
44
  try {
43
45
  // Replacing an existing record must neither inherit its mode nor follow
44
46
  // its symlink. Readers see one complete record, including after a crash.
@@ -61,7 +63,12 @@ export function readUiLink(port, base) {
61
63
  if (!e || typeof e.url !== "string") return null;
62
64
  let alive = null;
63
65
  if (Number.isInteger(e.pid) && e.pid > 0) alive = processAlive(e.pid);
64
- return { url: e.url, pid: e.pid ?? null, alive, policy: typeof e.policy === "string" ? e.policy : null };
66
+ return {
67
+ url: e.url, pid: e.pid ?? null, alive,
68
+ policy: typeof e.policy === "string" ? e.policy : null,
69
+ mode: e.mode === "runs" ? "runs" : "policy",
70
+ dir: typeof e.dir === "string" ? e.dir : null,
71
+ };
65
72
  }
66
73
 
67
74
  /** Removes the record. Called on exit, and when a stale one is found. */
package/ui/index.html CHANGED
@@ -447,6 +447,28 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
447
447
  .preview input{width:100%}
448
448
  .places li{flex-direction:column;gap:2px}
449
449
  }
450
+
451
+ /* ── a directory of runs (seisin ui --runs) ─────────────────────────────── */
452
+ /* `hidden` has to win over the display these set, or the runs view keeps the policy's nav and cards. */
453
+ .side a[hidden],.side .cap[hidden],.cards[hidden],.range[hidden]{display:none}
454
+ .banner.run{border-left:3px solid var(--blue)}
455
+ .banner.run .meta{color:var(--mid);font-size:12px;margin-top:4px}
456
+ .runs-filters{display:flex;flex-wrap:wrap;gap:10px 16px;align-items:center}
457
+ .runs-filters label{display:flex;gap:6px;align-items:center;font-size:12px;color:var(--mid)}
458
+ .runs-filters select,.runs-filters input{font:inherit;font-size:12px;padding:4px 6px;border:1px solid var(--line);
459
+ border-radius:6px;background:var(--panel);color:var(--fg);max-width:220px}
460
+ table.runs{width:100%;border-collapse:collapse;font-size:12px}
461
+ table.runs th{text-align:left;font-weight:600;color:var(--dim);padding:6px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
462
+ table.runs td{padding:6px 10px;border-bottom:1px solid var(--line2);vertical-align:top}
463
+ table.runs td.num,table.runs th.num{text-align:right;font-variant-numeric:tabular-nums}
464
+ table.runs td code{font-family:var(--mono);font-size:11px;word-break:break-all}
465
+ table.runs tr.sys td{color:var(--dim)}
466
+ .oc{font-weight:600;font-size:11px;padding:1px 6px;border-radius:9px;border:1px solid var(--line)}
467
+ .oc.clean{color:var(--green);border-color:var(--green)}
468
+ .oc.denied{color:var(--amber);border-color:var(--amber)}
469
+ .oc.refused,.oc.unread{color:var(--fg);background:var(--amber-bg);border-color:var(--amber)}
470
+ .ro-ask{font-size:12px;color:var(--mid);max-width:320px}
471
+ .ro-ask code{font-family:var(--mono)}
450
472
  </style>
451
473
  </head>
452
474
  <body>
@@ -462,7 +484,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
462
484
  </div>
463
485
  </div>
464
486
  <div class="list">
465
- <div class="cap">Policy</div>
487
+ <!-- Over a directory of runs (seisin ui --runs) the first item is every run, and the
488
+ rest is the one run that is open; with none open, only this one is shown. -->
489
+ <div id="nav-runs" hidden>
490
+ <div class="cap">Runs</div>
491
+ <a href="./" id="nav-all" title="Every run in the directory, read only"><i>☰</i>All runs<span class="pin" id="runs-pin"></span></a>
492
+ </div>
493
+ <div class="cap" id="nav-cap">Policy</div>
466
494
  <a class="on" href="#requests" id="nav-req" aria-current="page" title="Requests: access an agent asked for and cannot have"><i>⚖</i>Waiting on you<span class="pin" id="req-pin"></span></a>
467
495
  <a href="#denied" title="Every denial in the window: what an agent tried and the OS denied"><i>⊘</i>Denied<span class="pin" id="dn-pin"></span></a>
468
496
  <a href="#walls" title="A wall: something a role was denied twice or more and would still be denied today"><i>▦</i>Walls<span class="pin" id="wl-pin"></span></a>
@@ -490,6 +518,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
490
518
  <code>seisin ui</code> in it.
491
519
  </div>
492
520
  <div class="banner off" id="off-banner" role="alert" hidden></div>
521
+ <!-- One run out of a directory of them: who it was, and that nothing here is granted. -->
522
+ <div class="banner run" id="run-banner" hidden></div>
493
523
 
494
524
  <div class="head">
495
525
  <div>
@@ -533,6 +563,34 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
533
563
  <h2>spent retrying</h2><div class="n" id="c-retry">0</div><div class="s" id="c-retry-s">calls on known walls</div></a>
534
564
  </div>
535
565
 
566
+ <!-- Every run in the directory (seisin ui --runs): filtered, summed, newest first. -->
567
+ <section data-view="runs" hidden>
568
+ <div class="box">
569
+ <header>
570
+ <h2>Filter</h2>
571
+ <span class="note" id="rn-count"></span>
572
+ </header>
573
+ <div class="pad runs-filters" id="rn-filters">
574
+ <label>team <select data-f="team"></select></label>
575
+ <label>role <select data-f="role"></select></label>
576
+ <label>work item <select data-f="work_item"></select></label>
577
+ <label>status <select data-f="status"></select></label>
578
+ <label>outcome <select data-f="outcome"></select></label>
579
+ <label>search <input type="search" id="rn-q" spellcheck="false" autocomplete="off" placeholder="run id, work item, repo"></label>
580
+ </div>
581
+ </div>
582
+ <div class="cards" id="rn-kpis"></div>
583
+ <div class="box">
584
+ <header><h2>Denied across these runs</h2><span class="note">the same denial in many runs is one line · the OS's own housekeeping last</span></header>
585
+ <div class="scrollx"><table class="runs" id="rn-causes"></table></div>
586
+ </div>
587
+ <div class="box">
588
+ <header><h2>Runs</h2><span class="note">newest first · open one to see its policy, its denials and its log</span></header>
589
+ <div class="scrollx"><table class="runs" id="rn-list"></table></div>
590
+ <p class="blank quiet" id="rn-more" hidden></p>
591
+ </div>
592
+ </section>
593
+
536
594
  <section data-view="roles">
537
595
 
538
596
  <div class="box">
@@ -587,7 +645,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
587
645
  <section data-view="requests" hidden>
588
646
  <div class="box" id="requests">
589
647
  <header>
590
- <h2>Pending decisions</h2>
648
+ <h2 id="req-h">Pending decisions</h2>
591
649
  <span class="note" id="req-note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
592
650
  <span class="bulk" id="bulk" hidden>
593
651
  <input id="why-all" placeholder="reason, for all of them (required)" aria-label="Reason for declining all">
@@ -656,7 +714,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
656
714
  <div class="foot">
657
715
  <b id="foot-mode">Preview.</b> <span id="foot-text">Opened as a file, this page edits an example policy in memory and
658
716
  shows what would be generated. Nothing is saved; run <b>seisin ui</b> in a repository to see yours.</span><br>
659
- The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.78</b>, whose schema the test suite exercises against the real binary.
717
+ The JSON is <b>@anthropic-ai/sandbox-runtime</b>'s settings schema, which the test suite exercises against the real binary.
660
718
  </div>
661
719
  </main>
662
720
  </div>
@@ -1245,7 +1303,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1245
1303
  aria: "instruction files, for every role",
1246
1304
  click: function(){ propose({ protect: !prot }, "ctl-sw-protect", "instructions", "pw"); }
1247
1305
  }));
1248
- if (!r) { drawPreview(); return; }
1306
+ if (!r) { drawPreview(); if (READONLY) document.querySelectorAll("#pw button").forEach(function(b){ b.disabled = true; }); return; }
1249
1307
  ["ide", "instructions"].forEach(function(f){
1250
1308
  var mine = (r.controlFiles || []).indexOf(f) !== -1;
1251
1309
  var idle = f === "instructions" && !prot;
@@ -1267,6 +1325,9 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1267
1325
  }));
1268
1326
  });
1269
1327
  drawPreview();
1328
+ if (READONLY) document.querySelectorAll("#pw button, #ctl button").forEach(function(b){
1329
+ b.disabled = true; b.title = "read only: this run's policy was lent by its launcher";
1330
+ });
1270
1331
  }
1271
1332
 
1272
1333
  async function callControls(body){
@@ -1687,7 +1748,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1687
1748
  }
1688
1749
 
1689
1750
  set("k-wait", waiting ? "live" : "calm", waiting,
1690
- waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
1751
+ READONLY ? (waiting ? (waiting === 1 ? "request, asked for elsewhere" : "requests, asked for elsewhere") : "nothing asked for")
1752
+ : waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
1691
1753
  set("k-open", off ? "alarm" : "calm", off,
1692
1754
  off ? (off === 1 ? "role running unconfined" : "roles running unconfined") : "every role confined");
1693
1755
  set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
@@ -1759,6 +1821,12 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1759
1821
  return narrowable(r) && f && f.checked ? { scope: "file", grant: r.target } : { scope: "folder", grant: r.grant };
1760
1822
  }
1761
1823
 
1824
+ /** Read only, where a request goes instead: the launcher that lent the run its territory. */
1825
+ function askedWhere(){
1826
+ var who = RUNINFO && RUNINFO.launcher;
1827
+ return who ? "Not granted here — ask for it in <b>" + esc(who) + "</b>:" : "Not granted here — ask for it where the run was launched:";
1828
+ }
1829
+
1762
1830
  function drawReqs(){
1763
1831
  var host = document.getElementById("reqs");
1764
1832
  pill("req-pin", REQS.length, REQS.length === 1 ? "request" : "requests");
@@ -1776,17 +1844,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1776
1844
  return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
1777
1845
  '<div class="who">' +
1778
1846
  "<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) + ' <span class="path">' + esc(r.target) + "</span></div>" +
1779
- scopeOf(r) + handoffOf(r) + scopeChoice(r, i) +
1847
+ scopeOf(r) + handoffOf(r) + (READONLY ? "" : scopeChoice(r, i)) +
1780
1848
  '<div class="sub"><span class="asked">' + times + "</span>" +
1781
1849
  (w.text ? ' · last <span title="' + escA(w.title) + '">' + esc(w.text) + "</span>" : "") + stale + "</div>" +
1782
1850
  '<div class="err" id="err-' + i + '" role="alert" hidden></div>' +
1783
1851
  "</div>" +
1784
- '<div class="act">' +
1852
+ (READONLY
1853
+ ? '<div class="act ro-ask">' + askedWhere() + ' <code>' + esc(r.grant) + "</code></div>"
1854
+ : '<div class="act">' +
1785
1855
  '<input id="why-' + i + '" placeholder="reason (required)" aria-label="Reason for ' +
1786
1856
  escA(r.role) + '’s request" aria-describedby="err-' + i + '">' +
1787
1857
  '<button class="btn primary" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="granted" type="button" data-write>Grant</button>' +
1788
1858
  '<button class="btn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="declined" type="button" data-write>Decline</button>' +
1789
- "</div>" +
1859
+ "</div>") +
1790
1860
  "</div>";
1791
1861
  }).join("");
1792
1862
 
@@ -1829,7 +1899,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1829
1899
  var bulk = document.getElementById("bulk");
1830
1900
  var btn = document.getElementById("decline-all");
1831
1901
  var why = document.getElementById("why-all");
1832
- bulk.hidden = REQS.length < 2;
1902
+ bulk.hidden = REQS.length < 2 || READONLY;
1833
1903
  btn.disabled = false;
1834
1904
  var rest = "Decline all (" + REQS.length + ")";
1835
1905
  disarm(btn, rest);
@@ -1979,15 +2049,29 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1979
2049
  * server, and it is moved into this tab's storage and wiped from the address
1980
2050
  * bar and the history entry at once.
1981
2051
  */
2052
+ /**
2053
+ * Over a directory of runs, the run that is open: `&run=<id>` in the link a
2054
+ * launcher hands out (beside the token, in the fragment), kept in the query
2055
+ * once the fragment is wiped, so a reload stays on it.
2056
+ */
2057
+ var RUN = (function(){
2058
+ var h = /(?:^#|&)run=([A-Za-z0-9][A-Za-z0-9._-]{0,127})(?:&|$)/.exec(location.hash || "");
2059
+ var q = new URLSearchParams(location.search).get("run");
2060
+ var id = h ? h[1] : q;
2061
+ return id && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) ? id : null;
2062
+ })();
1982
2063
  var TOKEN = (function(){
1983
2064
  var m = /(?:^#|&)t=([a-f0-9]+)/.exec(location.hash || "");
1984
2065
  try {
1985
2066
  if (m) { sessionStorage.setItem("seisin-token", m[1]); }
1986
2067
  var t = m ? m[1] : (sessionStorage.getItem("seisin-token") || "");
1987
- if (m) history.replaceState(null, "", location.pathname + location.search);
2068
+ if (m) history.replaceState(null, "", location.pathname + (RUN ? "?run=" + encodeURIComponent(RUN) : location.search));
1988
2069
  return t;
1989
2070
  } catch (_) { return m ? m[1] : ""; }
1990
2071
  })();
2072
+ /** "policy" — one seisin.toml, the console as it always was — or "runs", read only. */
2073
+ var MODE = "policy";
2074
+ var READONLY = false;
1991
2075
 
1992
2076
  /** What to tell a person when a request to this server fails. */
1993
2077
  function whyFailed(e, status){
@@ -2050,6 +2134,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2050
2134
  }
2051
2135
  function stateUrl(){
2052
2136
  var s = sinceIso();
2137
+ if (MODE === "runs" && RUN)
2138
+ return "/api/run?run=" + encodeURIComponent(RUN) + (s ? "&since=" + encodeURIComponent(s) : "");
2053
2139
  return "/api/state" + (s ? "?since=" + encodeURIComponent(s) : "");
2054
2140
  }
2055
2141
  function rangeLabel(){
@@ -2180,7 +2266,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2180
2266
  if (ASKING[name]) return;
2181
2267
  ASKING[name] = true;
2182
2268
  try {
2183
- var r = await fetch("/api/settings?role=" + encodeURIComponent(name),
2269
+ var r = await fetch("/api/settings?role=" + encodeURIComponent(name) + (RUN ? "&run=" + encodeURIComponent(RUN) : ""),
2184
2270
  { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
2185
2271
  var out = await r.json();
2186
2272
  SETTINGS[name] = r.ok ? { settings: out.settings } : { error: out.error || "could not read the settings" };
@@ -2198,6 +2284,12 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2198
2284
  try {
2199
2285
  // Opened as a file there is no server to ask; the fetch only printed a CORS error.
2200
2286
  if (location.protocol === "file:") throw new Error("no server");
2287
+ var info = await fetch("/api/info", { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
2288
+ if (info.ok) {
2289
+ var mode = (await info.json()).mode;
2290
+ if (mode === "runs") { MODE = "runs"; READONLY = true; }
2291
+ }
2292
+ if (MODE === "runs" && !RUN) { await bootRuns(); return; }
2201
2293
  got = await fetchState(true);
2202
2294
  // A live server that refuses this tab's token: `seisin ui` was restarted
2203
2295
  // and minted a new one. Falling back to the example here showed a policy
@@ -2236,6 +2328,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2236
2328
  LIVE = true;
2237
2329
  applyState(s);
2238
2330
  LIVE_LABEL = s.config.replace(/^.*\//, "") + " · live";
2331
+ if (MODE === "runs") {
2332
+ RUNINFO = s.run || null; LIVE_LABEL = "run " + RUN + " · read only"; drawRunBanner();
2333
+ document.getElementById("req-note").textContent = "read only · asked for where the run was launched, not here";
2334
+ }
2239
2335
  label.textContent = LIVE_LABEL;
2240
2336
  label.style.color = "var(--green)";
2241
2337
  // Live, the what-if button goes for good: "run every role unconfined" against a real repo is
@@ -2246,10 +2342,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2246
2342
  document.getElementById("kill").hidden = true;
2247
2343
  // And so does the card that counts what the button would do: live it could only say 0.
2248
2344
  document.getElementById("k-open").style.display = "none";
2249
- document.getElementById("toml-note").textContent = "the file on disk, as it stands";
2250
- document.getElementById("foot-mode").textContent = "Live.";
2251
- document.getElementById("foot-text").innerHTML =
2252
- "This page reads <b>" + esc(s.config) + "</b> and the log on disk. Two actions write, each in place and stamped " +
2345
+ // Read only nothing waits on the person here: what the run asked for is asked for where it was launched.
2346
+ if (READONLY) {
2347
+ var nav = document.getElementById("nav-req");
2348
+ nav.childNodes[1].textContent = "Asked for";
2349
+ document.querySelector("#k-wait h2").textContent = "asked for";
2350
+ document.getElementById("req-h").textContent = "What this run asked for";
2351
+ }
2352
+ document.getElementById("toml-note").textContent = READONLY ? "this run's policy, as its launcher archived it" : "the file on disk, as it stands";
2353
+ document.getElementById("foot-mode").textContent = READONLY ? "Live, read only." : "Live.";
2354
+ document.getElementById("foot-text").innerHTML = READONLY
2355
+ ? "This page reads <b>" + esc(s.config) + "</b>, one archived run's policy, and its log. Nothing is written from here: " +
2356
+ "the run's territory was lent by whoever launched it, and access is asked for there."
2357
+ : "This page reads <b>" + esc(s.config) + "</b> and the log on disk. Two actions write, each in place and stamped " +
2253
2358
  "with its reason: a decision on a request, and saving a control-files change after its preview. Territories " +
2254
2359
  "and keys are changed in seisin.toml itself.";
2255
2360
  draw();
@@ -2257,6 +2362,168 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2257
2362
  poll();
2258
2363
  }
2259
2364
 
2365
+ /* ── a directory of runs ──────────────────────────────────────────────────
2366
+ * `seisin ui --runs`: every run, filtered and summed, read only. One run opens
2367
+ * as the console always looked — its policy, its denials, its log — with no
2368
+ * grant on it. */
2369
+ var RUNINFO = null;
2370
+ var RFILTER = (function(){
2371
+ var q = new URLSearchParams(location.search), out = {};
2372
+ ["team", "role", "work_item", "status", "outcome", "q"].forEach(function(k){ var v = q.get(k); if (v) out[k] = v; });
2373
+ return out;
2374
+ })();
2375
+ var RLAST = { url: null, etag: null, v: null };
2376
+
2377
+ function runsUrl(){
2378
+ var q = new URLSearchParams();
2379
+ Object.keys(RFILTER).forEach(function(k){ if (RFILTER[k]) q.set(k, RFILTER[k]); });
2380
+ var s = q.toString();
2381
+ return "/api/runs" + (s ? "?" + s : "");
2382
+ }
2383
+ async function fetchRuns(fresh){
2384
+ var url = runsUrl();
2385
+ var h = { "x-seisin-token": TOKEN };
2386
+ if (!fresh && RLAST.etag && RLAST.url === url) h["if-none-match"] = RLAST.etag;
2387
+ var r = await fetch(url, { cache: "no-store", headers: h });
2388
+ if (r.status === 304) return { same: true };
2389
+ if (!r.ok) return { status: r.status };
2390
+ var v = await r.json();
2391
+ RLAST = { url: url, etag: r.headers.get("etag"), v: v };
2392
+ return { v: v };
2393
+ }
2394
+
2395
+ function runMeta(r){
2396
+ var bits = [];
2397
+ if (r.team) bits.push("team <b>" + esc(r.team) + "</b>");
2398
+ if (r.role) bits.push("role <b>" + esc(r.role) + "</b>");
2399
+ if (r.work_item) bits.push("<b>" + esc(r.work_item) + "</b>");
2400
+ if (r.repo) bits.push("repo " + esc(r.repo));
2401
+ if (r.status) bits.push("status " + esc(r.status));
2402
+ var w = when(r.started_at || "");
2403
+ if (w.text) bits.push('started <span title="' + escA(w.title) + '">' + esc(w.text) + "</span>");
2404
+ return bits.join(" · ");
2405
+ }
2406
+ function outcomeTag(o){
2407
+ var says = { clean: "clean", denied: "denied", refused: "canary failed", unread: "policy unreadable" };
2408
+ return '<span class="oc ' + escA(o) + '">' + esc(says[o] || o) + "</span>";
2409
+ }
2410
+
2411
+ function drawRunBanner(){
2412
+ var b = document.getElementById("run-banner");
2413
+ var r = RUNINFO || { id: RUN };
2414
+ b.innerHTML = "<b>Run " + esc(r.run_id || r.id) + "</b> " + outcomeTag(r.outcome || "") +
2415
+ ' · <a href="./">all runs</a>' +
2416
+ '<div class="meta">' + (runMeta(r) || "no run.json: only what its policy and log say") +
2417
+ " · read only: nothing is granted from here</div>";
2418
+ b.hidden = false;
2419
+ document.getElementById("nav-runs").hidden = false;
2420
+ }
2421
+
2422
+ function drawRunsView(v){
2423
+ // The filters: every value any run has, with how many runs have it.
2424
+ document.querySelectorAll("#rn-filters select").forEach(function(sel){
2425
+ var f = sel.dataset.f;
2426
+ var opts = (v.facets[f] || []);
2427
+ var cur = RFILTER[f] || "";
2428
+ sel.innerHTML = '<option value="">all</option>' + opts.map(function(o){
2429
+ return '<option value="' + escA(o.value) + '"' + (o.value === cur ? " selected" : "") + ">" + esc(o.value) + " (" + o.runs + ")</option>";
2430
+ }).join("");
2431
+ sel.disabled = !opts.length;
2432
+ });
2433
+ document.getElementById("rn-count").textContent = v.totals.runs === v.total
2434
+ ? plural(v.total, "run") : plural(v.totals.runs, "run") + " of " + v.total;
2435
+ pill("runs-pin", v.total, v.total === 1 ? "run" : "runs");
2436
+
2437
+ var t = v.totals, k = t.byKind || {};
2438
+ function card(cls, title, n, sub){
2439
+ return '<div class="card kpi ' + cls + '"><h2>' + esc(title) + '</h2><div class="n">' + esc(Number(n).toLocaleString()) +
2440
+ '</div><div class="s">' + sub + "</div></div>";
2441
+ }
2442
+ document.getElementById("rn-kpis").innerHTML =
2443
+ card("b", "runs", t.runs, (t.outcome.denied || 0) + " denied something · " + (t.outcome.clean || 0) + " clean") +
2444
+ card(t.outcome.refused ? "a" : "g", "canary failed", t.outcome.refused || 0, "runs that never started") +
2445
+ card(t.denied ? "a" : "g", "denied", t.denied, (k.file || 0).toLocaleString() + " files · " + (k.network || 0).toLocaleString() + " network") +
2446
+ card(t.retried ? "a" : "g", "spent retrying", t.retried, "calls after the first denial of the same thing");
2447
+
2448
+ var causes = v.causes || [];
2449
+ document.getElementById("rn-causes").innerHTML = causes.length
2450
+ ? "<thead><tr><th>denied</th><th>what</th><th class=num>runs</th><th class=num>times</th><th>teams</th><th>roles</th></tr></thead><tbody>" +
2451
+ causes.map(function(c){
2452
+ return '<tr class="' + (c.about === "system" ? "sys" : "") + '"><td>' + esc(c.action) + "</td><td><code>" + esc(c.target) +
2453
+ "</code></td><td class=num>" + c.runs + "</td><td class=num>" + c.times + "</td><td>" + esc((c.teams || []).join(", ")) +
2454
+ "</td><td>" + esc((c.roles || []).join(", ")) + "</td></tr>";
2455
+ }).join("") + "</tbody>"
2456
+ : '<tbody><tr><td class="blank quiet">Nothing denied in these runs.</td></tr></tbody>';
2457
+
2458
+ var SHOWN = 300, runs = v.runs || [];
2459
+ document.getElementById("rn-list").innerHTML = runs.length
2460
+ ? "<thead><tr><th>run</th><th>started</th><th>team</th><th>role</th><th>work item</th><th>status</th><th>outcome</th>" +
2461
+ "<th class=num>denied</th><th class=num>retried</th><th>hosts denied</th></tr></thead><tbody>" +
2462
+ runs.slice(0, SHOWN).map(function(r){
2463
+ var w = when(r.started_at || "");
2464
+ return "<tr><td><a href=\"./?run=" + encodeURIComponent(r.id) + "#requests\"><code>" + esc(r.run_id || r.id) + "</code></a></td>" +
2465
+ '<td title="' + escA(w.title || "") + '">' + esc(w.text || "") + "</td><td>" + esc(r.team || "") + "</td><td>" + esc(r.role || "") +
2466
+ "</td><td>" + esc(r.work_item || "") + "</td><td>" + esc(r.status || "") + "</td><td>" + outcomeTag(r.outcome) +
2467
+ "</td><td class=num>" + r.denied + "</td><td class=num>" + r.retried + "</td><td>" +
2468
+ (r.hosts || []).slice(0, 3).map(function(h){ return "<code>" + esc(h.target) + "</code>"; }).join(" ") + "</td></tr>";
2469
+ }).join("") + "</tbody>"
2470
+ : '<tbody><tr><td class="blank quiet">' + (v.total ? "No run matches these filters." : "No runs in " + esc(v.dir) + " yet.") + "</td></tr></tbody>";
2471
+ var more = document.getElementById("rn-more");
2472
+ more.hidden = runs.length <= SHOWN;
2473
+ more.textContent = "Showing the newest " + SHOWN + " of " + runs.length + ": narrow the filters for the rest.";
2474
+ }
2475
+
2476
+ function setRunFilter(k, val){
2477
+ if (val) RFILTER[k] = val; else delete RFILTER[k];
2478
+ var q = new URLSearchParams();
2479
+ Object.keys(RFILTER).forEach(function(x){ q.set(x, RFILTER[x]); });
2480
+ history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : ""));
2481
+ fetchRuns(true).then(function(g){ if (g.v) drawRunsView(g.v); }).catch(function(){});
2482
+ }
2483
+
2484
+ async function bootRuns(){
2485
+ var label = document.getElementById("source");
2486
+ var got = await fetchRuns(true);
2487
+ if (!got.v) {
2488
+ label.textContent = got.status === 403 ? "link expired — open the new one" : "the console did not answer";
2489
+ label.style.color = "var(--amber)";
2490
+ return;
2491
+ }
2492
+ LIVE = true;
2493
+ LIVE_LABEL = "runs · read only";
2494
+ label.textContent = LIVE_LABEL;
2495
+ label.style.color = "var(--green)";
2496
+ document.getElementById("nav-runs").hidden = false;
2497
+ document.getElementById("nav-all").classList.add("on");
2498
+ document.getElementById("nav-cap").hidden = true;
2499
+ document.querySelectorAll(".side .list > a[href^='#']").forEach(function(a){ a.hidden = true; });
2500
+ // Inline, not `hidden`: the cards' own display rules outrank the attribute.
2501
+ ["kpis", "range", "kill"].forEach(function(id){ var e = document.getElementById(id); if (e) { e.hidden = true; e.style.display = "none"; } });
2502
+ document.getElementById("foot-mode").textContent = "Live, read only.";
2503
+ document.getElementById("foot-text").innerHTML = "This page reads the runs in <b>" + esc(got.v.dir) +
2504
+ "</b>, one folder each, as their launcher archived them. Nothing is written from here.";
2505
+ document.querySelectorAll("#rn-filters select").forEach(function(sel){
2506
+ sel.addEventListener("change", function(){ setRunFilter(sel.dataset.f, sel.value); });
2507
+ });
2508
+ var q = document.getElementById("rn-q");
2509
+ q.value = RFILTER.q || "";
2510
+ var tq = null;
2511
+ q.addEventListener("input", function(){ clearTimeout(tq); tq = setTimeout(function(){ setRunFilter("q", q.value.trim()); }, 250); });
2512
+ drawRunsView(got.v);
2513
+ show("runs");
2514
+ (function again(){
2515
+ setTimeout(async function(){
2516
+ try {
2517
+ var g = await fetchRuns(false);
2518
+ if (g.v) drawRunsView(g.v);
2519
+ if (g.v || g.same) { FAILS = 0; if (OFFLINE) setOffline(null); }
2520
+ else if (++FAILS >= 2) setOffline(g.status === 403 ? "expired" : "down");
2521
+ } catch (_) { if (++FAILS >= 2) setOffline("down"); }
2522
+ again();
2523
+ }, 2000);
2524
+ })();
2525
+ }
2526
+
2260
2527
  /** Every two seconds after the last answer, not every two seconds regardless. */
2261
2528
  function poll(){
2262
2529
  setTimeout(async function(){
@@ -2269,11 +2536,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2269
2536
  * it has to keep working opened straight off the disk with no server at all. */
2270
2537
  function views(){
2271
2538
  return {
2539
+ runs: {view:"runs", title:"Runs",
2540
+ sub:"Every run in the directory, read only: what each was denied and what the denials cost. Open one for its policy and its log."},
2272
2541
  policy: {view:"roles", title:"Roles",
2273
2542
  sub:"Each role's territory — the folders it may write — and the keys it may read. " +
2274
2543
  (LIVE ? "Read-only here: the policy is seisin.toml." : "In this example you can edit them; nothing is saved.")},
2275
- requests: {view:"requests", title:"Waiting on you",
2276
- sub:"Requests: access an agent asked for and cannot have. Granting one adds a line to seisin.toml."},
2544
+ requests: {view:"requests", title: READONLY ? "Asked for" : "Waiting on you",
2545
+ sub: READONLY
2546
+ ? "Requests: access this run asked for and did not have. Read only: they are asked for where the run was launched."
2547
+ : "Requests: access an agent asked for and cannot have. Granting one adds a line to seisin.toml."},
2277
2548
  denied: {view:"denied", title:"Denied", tab:"path",
2278
2549
  sub:"Everything an agent tried in the window and the OS denied, grouped by what it was about."},
2279
2550
  causes: {view:"denied", title:"Denied", tab:"name",
@@ -2320,7 +2591,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2320
2591
  }
2321
2592
 
2322
2593
  /** The landing view is what is waiting on you, not the policy table. */
2323
- function route(){ return (location.hash || "#requests").slice(1); }
2594
+ function route(){ return MODE === "runs" && !RUN ? "runs" : (location.hash || "#requests").slice(1); }
2324
2595
  window.addEventListener("hashchange", function(){ show(route()); });
2325
2596
  show(route());
2326
2597