seisin 0.1.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/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "seisin",
3
+ "version": "0.1.0",
4
+ "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it blocks it tells you whose file it was.",
5
+ "keywords": [
6
+ "ai-agents",
7
+ "claude-code",
8
+ "sandbox",
9
+ "permissions",
10
+ "multi-agent",
11
+ "seatbelt",
12
+ "bubblewrap",
13
+ "codeowners",
14
+ "rbac",
15
+ "agent-permissions",
16
+ "code-ownership"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "Carlos Tapia",
20
+ "homepage": "https://github.com/carlostapiaolguin3-stack/seisin#readme",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/carlostapiaolguin3-stack/seisin.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/carlostapiaolguin3-stack/seisin/issues"
27
+ },
28
+ "type": "module",
29
+ "bin": {
30
+ "seisin": "./src/cli.js"
31
+ },
32
+ "files": [
33
+ "src",
34
+ "ui",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "dependencies": {
42
+ "@anthropic-ai/sandbox-runtime": "^0.0.76"
43
+ },
44
+ "scripts": {
45
+ "test": "node --test test/*.test.js"
46
+ },
47
+ "main": "./src/index.js",
48
+ "exports": {
49
+ ".": "./src/index.js",
50
+ "./package.json": "./package.json"
51
+ }
52
+ }
package/src/cli.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * seisin — give each agent its own folders and its own keys.
4
+ *
5
+ * This file is dispatch and nothing else: parse argv, load the config, call one
6
+ * command, turn a thrown error into an exit code. Every command lives in
7
+ * `commands/` and every decision lives in a module beside it, so that a change
8
+ * to how something is printed cannot change what is allowed.
9
+ *
10
+ * Enforcement is not ours. @anthropic-ai/sandbox-runtime asks the operating
11
+ * system — Seatbelt on macOS, bubblewrap on Linux — and the kernel does not
12
+ * care how a command was spelled. What seisin adds is the part a kernel can
13
+ * never know: which role a path belongs to, and therefore who to ask next.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { findConfig, loadConfig } from "./config.js";
20
+ import { CONFIG_NAME } from "./layout.js";
21
+ import { C, out, err } from "./render.js";
22
+
23
+ import { run } from "./commands/run.js";
24
+ import { check } from "./commands/check.js";
25
+ import { explainCommand } from "./commands/explain.js";
26
+ import { scanCommand } from "./commands/scan.js";
27
+ import { init, initFromObservations } from "./commands/init.js";
28
+ import { log, watch } from "./commands/log.js";
29
+ import { hook } from "./commands/hook.js";
30
+ import { ui } from "./commands/ui.js";
31
+ import { requests, grant, deny } from "./commands/requests.js";
32
+ import { whose } from "./commands/whose.js";
33
+ import { reviewCommand } from "./commands/review.js";
34
+ import { wire } from "./commands/wire.js";
35
+ import { serveMcp } from "./mcp.js";
36
+
37
+ const HERE = dirname(fileURLToPath(import.meta.url));
38
+ const [, , command, ...argv] = process.argv;
39
+
40
+ const USAGE = `
41
+ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
42
+
43
+ seisin run <role> -- <command...> run a command as that role
44
+ seisin run <role> --observe -- <cmd> open the repo and watch — the network stays shut
45
+ seisin check [role] print the map, run nothing
46
+ seisin explain <role> read|write <path>
47
+ seisin whose <path> who owns it — safe to call from inside the box
48
+ seisin scan find secrets outside the declared key dirs
49
+ seisin review what the log says about the policy
50
+ seisin wire let the agent record what it does
51
+ seisin requests what the agents asked for and could not do
52
+ seisin grant <n> [--reason "…"] approve one, with its provenance
53
+ seisin deny <n> [--reason "…"] refuse one, and record why
54
+ seisin log [--role r] [--verdict denied]
55
+ seisin watch follow the log live
56
+ seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
57
+ seisin ui [--port n] open the console, live
58
+ seisin mcp an MCP server on stdio — read-only
59
+
60
+ Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
61
+ seisin decides what to ask for, and says whose file it was when the answer is no.
62
+ `;
63
+
64
+ /** Loads the policy, or explains where to get one. */
65
+ function config() {
66
+ const path = findConfig();
67
+ if (!path) throw new Error(`no ${CONFIG_NAME} found here or above. Run "seisin init" to write one.`);
68
+ return loadConfig(path);
69
+ }
70
+
71
+ /**
72
+ * Commands that need a config get it; commands that make one do not.
73
+ *
74
+ * Each returns the exit code it wants, or nothing for zero. `run` and `ui` are
75
+ * the exceptions — they hand the process over and never come back here.
76
+ */
77
+ const COMMANDS = {
78
+ run: async () => await run(config(), argv),
79
+ // Non-zero when the policy cannot be enforced as written, so `seisin check`
80
+ // composes in a pre-commit hook or CI the way `explain` and `scan` already do.
81
+ // Warnings about a config that WILL work still exit 0 — failing on those
82
+ // would make the command unusable within a week.
83
+ check: () => (check(config(), argv).warnings.some((w) => w.kind === "cannot-be-enforced") ? 1 : 0),
84
+ explain: () => (explainCommand(config(), argv).allowed ? 0 : 1),
85
+ scan: () => (scanCommand(config()).certain.length ? 1 : 0),
86
+ log: () => void log(config(), argv),
87
+ watch: () => void watch(config()),
88
+ init: () => void (argv.includes("--from-observations") ? initFromObservations(config()) : init()),
89
+ ui: () => ui(config(), argv),
90
+ whose: () => void whose(config(), argv),
91
+ wire: () => void wire(config()),
92
+ review: () => (reviewCommand(config(), argv).friction.length ? 1 : 0),
93
+ requests: () => (requests(config()).length ? 1 : 0),
94
+ grant: () => void grant(config(), argv),
95
+ deny: () => void deny(config(), argv),
96
+ mcp: async () => {
97
+ await serveMcp(version());
98
+ return 0;
99
+ },
100
+ hook: async () => {
101
+ const decision = await hook();
102
+ if (decision?.hookSpecificOutput) out(JSON.stringify(decision) + "\n");
103
+ return 0;
104
+ },
105
+ "--version": () => void out(version() + "\n"),
106
+ "-v": () => void out(version() + "\n"),
107
+ };
108
+
109
+ function version() {
110
+ return JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version;
111
+ }
112
+
113
+ const chosen = COMMANDS[command];
114
+ if (!chosen) {
115
+ out(USAGE);
116
+ process.exit(command ? 2 : 0);
117
+ }
118
+
119
+ try {
120
+ const code = await chosen();
121
+ if (typeof code === "number") process.exit(code);
122
+ } catch (e) {
123
+ err(`${C.red}seisin:${C.off} ${e.message}\n`);
124
+ process.exit(2);
125
+ }
@@ -0,0 +1,9 @@
1
+ /** `seisin check` — print the map and every way it does not hold. Runs nothing. */
2
+ import { inspect } from "../inspect.js";
3
+ import { renderReport, out } from "../render.js";
4
+
5
+ export function check(config, argv = []) {
6
+ const report = inspect(config, argv[0] ?? null, config.path);
7
+ out(renderReport(report));
8
+ return report;
9
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `seisin explain` — ask one question, get the owner back.
3
+ *
4
+ * Exits 1 on a denial so it composes in a script: `seisin explain … || echo no`.
5
+ * That is the only reason the exit code is not always 0 — a denial is an answer,
6
+ * not a failure, but a shell has one channel for both.
7
+ */
8
+ import { isAbsolute } from "node:path";
9
+ import { explain } from "../owners.js";
10
+ import { twinsOf, whereIs } from "../worktree.js";
11
+ import { renderVerdict, C, out } from "../render.js";
12
+
13
+ export function explainCommand(config, argv = []) {
14
+ const [role, action, target] = argv;
15
+ if (!role || !action || !target)
16
+ throw new Error("usage: seisin explain <role> <read|write> <path-or-key>");
17
+ if (!config.roles[role]) throw new Error(`unknown role "${role}"`);
18
+
19
+ const verb = action.startsWith("r") ? "read" : "write";
20
+
21
+ /**
22
+ * An absolute path inside the repo is the same question as the relative one.
23
+ *
24
+ * It was not being read that way: the policy is written in repo-relative
25
+ * paths, so an absolute target matched nothing and came back **denied — has
26
+ * no owner**, for a file its role could write perfectly well. `whose` has
27
+ * always stripped the root; the hook does too. Only this command did not, so
28
+ * the two answered differently about the same file, and the one people run to
29
+ * check a boundary was the one that was wrong.
30
+ *
31
+ * Wrong in the direction that matters, too: it reported a path as unowned and
32
+ * refused while the kernel allowed it — see "Refusing beats widening" in
33
+ * docs/decisions.md. A path outside the root is left exactly as written,
34
+ * because that is a question about somewhere else and it still deserves its
35
+ * honest "no owner".
36
+ */
37
+ const asked = target.startsWith(config.root + "/") ? target.slice(config.root.length + 1) : target;
38
+
39
+ const verdict = explain(config, role, verb, asked);
40
+ out(renderVerdict(role, verb, target, verdict));
41
+
42
+ const worktree = verb === "write" ? elsewhere(config, role, target, verdict) : [];
43
+ for (const w of worktree) out(renderElsewhere(w));
44
+ return { ...verdict, worktree };
45
+ }
46
+
47
+ /**
48
+ * The same question, asked about the same file in the other checkouts.
49
+ *
50
+ * Only a twin that gets a DIFFERENT answer is worth a line. A worktree where
51
+ * the role is equally allowed, or equally refused, changes nothing about what
52
+ * was just printed, and a diagnosis that mentions it anyway is the noise this
53
+ * tool keeps having to remove.
54
+ *
55
+ * The twin is asked about in the spelling the question used — root-relative
56
+ * for a root-relative question — so the two answers come from the same rule
57
+ * and differ only in where the file is.
58
+ */
59
+ function elsewhere(config, role, target, verdict) {
60
+ const { here, twins } = twinsOf(config, target);
61
+ return twins
62
+ .map((t) => ({ ...t, verdict: explain(config, role, "write", isAbsolute(target) ? t.path : t.rel) }))
63
+ .filter((t) => t.verdict.allowed !== verdict.allowed)
64
+ .map((t) => ({ ...t, here, where: whereIs(config, here, t), asked: verdict }));
65
+ }
66
+
67
+ /**
68
+ * Three lines: where the other checkout is, what the answer is there, and
69
+ * which of the two the policy names — the sentence the refusal never carried.
70
+ */
71
+ function renderElsewhere(t) {
72
+ const there = t.verdict.allowed
73
+ ? `is inside ${t.verdict.owners.join(", ")}'s territory`
74
+ : t.verdict.owners.length ? `belongs to ${t.verdict.owners.join(", ")}` : "has no owner";
75
+ const named = t.asked.allowed ? t.here : t;
76
+ const other = t.asked.allowed ? t : t.here;
77
+ // "allowed" is the answer that misleads, so it is the one that gets told
78
+ // where the refusal is. After "denied" that clause would only repeat it.
79
+ const refused = t.asked.allowed ? ` — a write in ${kind(other)} is refused` : "";
80
+ return (
81
+ ` ${C.yellow}worktree${C.off} ${t.where}\n` +
82
+ ` ${C.dim} the same file there is ${C.off}${C.b}${t.rel}${C.off}${C.dim}, and it ${there}.\n` +
83
+ ` The policy names ${kind(named)}, not ${kind(other)}${refused}.${C.off}\n\n`
84
+ );
85
+ }
86
+
87
+ const kind = (c) => (c.worktree ? "the worktree" : "the canonical checkout");
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `seisin hook` — the PreToolUse entry point.
3
+ *
4
+ * Claude Code runs this once per tool call, so every path through it is short
5
+ * and every failure is silent-and-permissive. That asymmetry is deliberate: the
6
+ * hook explains, the kernel enforces. A hook that crashes should cost you an
7
+ * explanation, never a blocked turn — and it cannot cost you a boundary,
8
+ * because it was never the boundary.
9
+ */
10
+ import { findConfig, loadConfig } from "../config.js";
11
+ import { decide } from "../hook.js";
12
+ import { flush } from "../spool.js";
13
+
14
+ export async function hook(stdin = process.stdin, env = process.env) {
15
+ const role = env.SEISIN_ROLE;
16
+ if (!role) return null; // not launched by seisin: say nothing, block nothing
17
+
18
+ let raw = "";
19
+ for await (const chunk of stdin) raw += chunk;
20
+
21
+ let event;
22
+ try {
23
+ event = JSON.parse(raw || "{}");
24
+ } catch {
25
+ return null; // an unreadable event is not grounds to block work
26
+ }
27
+
28
+ let config;
29
+ try {
30
+ config = loadConfig(env.SEISIN_CONFIG ?? findConfig());
31
+ } catch {
32
+ return null;
33
+ }
34
+ if (!config.roles[role]) return null;
35
+
36
+ const decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1" });
37
+ // The entries went to a socket, and cli.js exits as soon as we return. An
38
+ // exit does not drain a socket, so the record would be lost precisely on the
39
+ // turns that produced one.
40
+ await flush();
41
+ return decision;
42
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `seisin init` — where the first policy comes from.
3
+ *
4
+ * Every permission tool dodges this question. Written by hand the first policy
5
+ * is a guess, and the first unjustified denial is when the tool gets
6
+ * uninstalled. So there are two ways in, and both of them **propose**:
7
+ *
8
+ * init read what the repo already says about ownership
9
+ * init --from-observations read what the agents actually did
10
+ *
11
+ * The second is better and needs a run first. Neither writes a policy you did
12
+ * not read: `--from-observations` lands as `.observed`, not as the config.
13
+ */
14
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join, relative } from "node:path";
16
+ import { CONFIG_NAME, tomlString, tomlName } from "../layout.js";
17
+ import { read, logPath, observed, generalise } from "../log.js";
18
+ import { C, out } from "../render.js";
19
+
20
+ /**
21
+ * Three places a repo already says who does what, in order of how much it
22
+ * actually means. None is a policy, so all of them are proposals.
23
+ */
24
+ export function discover(root) {
25
+ const agents = join(root, ".claude", "agents");
26
+ if (existsSync(agents)) {
27
+ const roles = readdirSync(agents)
28
+ .filter((f) => f.endsWith(".md"))
29
+ .map((f) => ({ name: f.replace(/\.md$/, ""), writes: [], keys: [] }));
30
+ if (roles.length) return { source: ".claude/agents/", roles };
31
+ }
32
+
33
+ for (const p of ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]) {
34
+ const file = join(root, p);
35
+ if (!existsSync(file)) continue;
36
+ const paths = readFileSync(file, "utf8")
37
+ .split(/\r?\n/)
38
+ .map((l) => l.replace(/#.*$/, "").trim())
39
+ .filter(Boolean)
40
+ .map((l) => l.split(/\s+/)[0])
41
+ .filter((g) => g && g !== "*");
42
+ if (paths.length)
43
+ return {
44
+ source: p,
45
+ roles: [...new Set(paths)].slice(0, 8).map((g, i) => ({
46
+ name: g.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "") || `role-${i + 1}`,
47
+ writes: [g.endsWith("/") ? g + "**" : g],
48
+ keys: [],
49
+ })),
50
+ };
51
+ }
52
+
53
+ return {
54
+ source: "nothing to read — this is a blank start",
55
+ roles: [
56
+ { name: "frontend", writes: ["src/web/**"], keys: [] },
57
+ { name: "backend", writes: ["src/api/**"], keys: [] },
58
+ ],
59
+ };
60
+ }
61
+
62
+ /** The proposed config, as TOML. Pure: takes what `discover` found. */
63
+ export function renderConfig(found) {
64
+ const lines = [
65
+ "# seisin — which folders each agent writes, and which keys it may read.",
66
+ "#",
67
+ `# Proposed from: ${found.source}`,
68
+ "# Nothing here is enforced until you run the agent through `seisin run`.",
69
+ "# Anything not listed is denied. There is no permissive default.",
70
+ "",
71
+ "# [keys]",
72
+ '# dir = ".secrets" # every key lives here; roles name the files they may read',
73
+ "",
74
+ "[network]",
75
+ "# The agent's own API comes first. Leave it out and the agent cannot even",
76
+ "# authenticate — it fails with a 403 from the egress proxy before it does",
77
+ "# any work, which reads as a broken install rather than a strict policy.",
78
+ "#",
79
+ "# THE LIST BELOW IS CLAUDE'S. If a role runs a different agent, add that",
80
+ "# agent's host or the role will sit inside a perfect territory unable to",
81
+ "# reach its own model. Per role: [roles.NAME] network = [ ... ]",
82
+ "allow = [",
83
+ ' "api.anthropic.com", "*.anthropic.com",',
84
+ ' "github.com", "*.github.com",',
85
+ ' "registry.npmjs.org", "pypi.org", "files.pythonhosted.org"',
86
+ "]",
87
+ "",
88
+ ];
89
+ for (const r of found.roles) {
90
+ lines.push(`[roles.${tomlName(r.name)}]`);
91
+ lines.push(`writes = [${r.writes.map(tomlString).join(", ")}]`);
92
+ lines.push(`keys = [${r.keys.map(tomlString).join(", ")}]`);
93
+ lines.push("");
94
+ }
95
+ return lines.join("\n");
96
+ }
97
+
98
+ /** The observed config, as TOML. Pure: takes the log entries. */
99
+ export function renderObserved(config, entries) {
100
+ const roles = observed(entries);
101
+ const lines = [
102
+ `# ${CONFIG_NAME} — written from ${entries.length} observed action(s).`,
103
+ "#",
104
+ "# This is what your agents actually did, generalised to directories. Read it",
105
+ "# before you trust it: an agent that touched a file once by mistake asked for",
106
+ "# that directory here, and observation cannot tell intent from accident.",
107
+ "",
108
+ ];
109
+ if (config.keyDirs.length)
110
+ lines.push("[keys]", `dir = [${config.keyDirs.map(tomlString).join(", ")}]`, "");
111
+ lines.push("[network]", `allow = [${config.allowedDomains.map(tomlString).join(", ")}]`, "");
112
+
113
+ /**
114
+ * Every declared role appears, whether it acted or not.
115
+ *
116
+ * This used to emit only the roles the log had seen, and the file says "diff
117
+ * it, then move it" — so moving it deleted the territory of every role that
118
+ * happened to be idle during the observation window, silently. A role that
119
+ * did nothing is not a role that needs nothing; it is a role nobody watched.
120
+ *
121
+ * So observation *adds*. What was declared stays, what was seen is appended,
122
+ * and the comment beside each role says which part came from where — because
123
+ * the whole reason to read this file is to tell the two apart.
124
+ */
125
+ for (const name of new Set([...Object.keys(config.roles), ...roles.keys()])) {
126
+ const declared = config.roles[name];
127
+ const seen = roles.get(name);
128
+
129
+ const from = declared ? declared.writes : [];
130
+ const found = seen ? generalise([...seen.writes]).filter((w) => !from.includes(w)) : [];
131
+ const keys = [
132
+ ...(declared ? declared.keys : []),
133
+ ...(seen ? [...new Set([...seen.keys].map((k) => k.replace(/^.*\//, "")))] : []),
134
+ ];
135
+
136
+ // Every value below came out of the log, and the log records paths the
137
+ // agent chose. tomlString refuses what the format cannot hold rather than
138
+ // emitting a file that parses into something else.
139
+ lines.push(`[roles.${tomlName(name)}]`);
140
+ lines.push(`writes = [${[...from, ...found].map(tomlString).join(", ")}]`);
141
+ lines.push(`keys = [${[...new Set(keys)].map(tomlString).join(", ")}]`);
142
+ lines.push(
143
+ seen
144
+ ? `# declared ${from.length}; observation added ${found.length} ` +
145
+ `(${seen.writes.size} path(s) written, ${seen.keys.size} key(s) read)`
146
+ : `# declared ${from.length}; this role did nothing while observing — kept as written`
147
+ );
148
+ lines.push("");
149
+ }
150
+ return { toml: lines.join("\n"), roles: roles.size };
151
+ }
152
+
153
+ export function init(cwd = process.cwd()) {
154
+ const target = join(cwd, CONFIG_NAME);
155
+ if (existsSync(target))
156
+ throw new Error(`${CONFIG_NAME} already exists here. Delete it first if you meant to start over.`);
157
+
158
+ const found = discover(cwd);
159
+ writeFileSync(target, renderConfig(found));
160
+ out(
161
+ `\n wrote ${C.b}${CONFIG_NAME}${C.off} with ${found.roles.length} role(s) from ${found.source}\n` +
162
+ ` ${C.dim}These are a proposal, not a policy. Read them before you run anything.${C.off}\n\n` +
163
+ ` next: seisin check\n\n`
164
+ );
165
+ return found;
166
+ }
167
+
168
+ export function initFromObservations(config) {
169
+ const entries = read(logPath(config.root), { verdict: "observed" });
170
+ if (entries.length === 0)
171
+ throw new Error("nothing observed yet. Run: seisin run <role> --observe -- <command>");
172
+
173
+ const { toml, roles } = renderObserved(config, entries);
174
+ const file = join(config.root, CONFIG_NAME + ".observed");
175
+ writeFileSync(file, toml);
176
+ out(
177
+ `\n wrote ${C.b}${relative(process.cwd(), file)}${C.off} from ${entries.length} observation(s), ${roles} role(s)\n` +
178
+ ` ${C.dim}Not ${CONFIG_NAME} — a policy generated behind your back is not a policy. Diff it, then move it.${C.off}\n\n`
179
+ );
180
+ return { file, entries: entries.length, roles };
181
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `seisin log` and `seisin watch` — reading the record.
3
+ *
4
+ * Both are views of one append-only file and nothing else. `watch` is a tail:
5
+ * there is no daemon, because the file already is the shared state, so anything
6
+ * that can read it can follow it.
7
+ *
8
+ * Reading is the easy half. Writing goes through spool.js when the writer is
9
+ * inside a sandbox, so that the record is not editable by what it records.
10
+ */
11
+ import { createReadStream, watch as watchDir } from "node:fs";
12
+ import { dirname, relative } from "node:path";
13
+ import { read, logPath, size } from "../log.js";
14
+ import { renderEntry, C, out } from "../render.js";
15
+
16
+ const flag = (argv, name) => {
17
+ const i = argv.indexOf(name);
18
+ return i === -1 ? undefined : argv[i + 1];
19
+ };
20
+
21
+ export function log(config, argv = []) {
22
+ const entries = read(logPath(config.root), {
23
+ role: flag(argv, "--role"),
24
+ verdict: flag(argv, "--verdict"),
25
+ limit: Number(flag(argv, "--limit") ?? 40),
26
+ });
27
+
28
+ if (entries.length === 0) {
29
+ out(`\n ${C.dim}nothing recorded yet — run an agent through "seisin run"${C.off}\n\n`);
30
+ return entries;
31
+ }
32
+ out("\n" + entries.map(renderEntry).join(""));
33
+ const denied = entries.filter((e) => e.verdict === "denied").length;
34
+ out(`\n ${C.dim}${entries.length} entries · ${denied} denied${C.off}\n\n`);
35
+ return entries;
36
+ }
37
+
38
+ /**
39
+ * Follows the log from wherever it is now.
40
+ *
41
+ * Watches the *directory*, not the file: the log may not exist yet when watch
42
+ * starts, and a watcher on a missing path is a watcher on nothing. The interval
43
+ * is the floor, because filesystem events are advisory on every platform that
44
+ * has them and absent on the ones that do not.
45
+ */
46
+ export function watch(config, { interval = 500 } = {}) {
47
+ const file = logPath(config.root);
48
+ let at = size(file);
49
+ out(`\n ${C.dim}watching ${relative(process.cwd(), file)} — ctrl-c to stop${C.off}\n\n`);
50
+
51
+ const drain = () => {
52
+ const end = size(file);
53
+ if (end <= at) { at = end; return; } // truncated, or nothing new
54
+ const stream = createReadStream(file, { start: at, end: end - 1, encoding: "utf8" });
55
+ at = end;
56
+ let buf = "";
57
+ stream.on("data", (d) => (buf += d));
58
+ stream.on("end", () => {
59
+ for (const line of buf.split("\n")) {
60
+ if (!line.trim()) continue;
61
+ try { out(renderEntry(JSON.parse(line))); } catch { /* half-written line */ }
62
+ }
63
+ });
64
+ };
65
+
66
+ try { watchDir(dirname(file), () => drain()); } catch { /* platform without events */ }
67
+ return setInterval(drain, interval);
68
+ }