seisin 0.1.1 → 0.3.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/src/serve.js CHANGED
@@ -18,7 +18,9 @@ import { fileURLToPath } from "node:url";
18
18
  import { loadConfig } from "./config.js";
19
19
  import { read, logPath } from "./log.js";
20
20
  import { settingsFor } from "./srt.js";
21
- import { pending, requestsPath, settle, applyGrant } from "./requests.js";
21
+ import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale } from "./requests.js";
22
+ import { walls } from "./walls.js";
23
+ import { explain } from "./owners.js";
22
24
 
23
25
  const HERE = dirname(fileURLToPath(import.meta.url));
24
26
 
@@ -29,23 +31,165 @@ const HERE = dirname(fileURLToPath(import.meta.url));
29
31
  * page is a view of it — someone editing seisin.toml in an editor should see
30
32
  * the console change, not wonder why it disagrees.
31
33
  */
32
- function state(configPath) {
34
+ /**
35
+ * Today's denials, grouped by what was denied rather than by who asked.
36
+ *
37
+ * Called `causes` and not `friction`: `seisin review` already has a `friction`
38
+ * that counts something narrower — repeated denials that are not about keys —
39
+ * and two screens of one product reporting different numbers under one label
40
+ * is the kind of thing nobody notices until they are compared.
41
+ *
42
+ * The console used to show them per role, which is the same list a hundred
43
+ * times over: measured on one deployment, 1,080 of 1,422 refusals were a
44
+ * single lock file across six repositories, and per-role that reads as six
45
+ * hundred identical rows instead of one sentence. Grouped, the shape of the
46
+ * day is visible at a glance and it is almost never a territory dispute.
47
+ *
48
+ * `stillRefused` is recomputed against the policy as it stands, not read out
49
+ * of the log — a cause that has been granted since is history, not friction,
50
+ * and leaving it on the page sends somebody to fix what is already fixed.
51
+ */
52
+ export function causesOf(cfg, entries) {
53
+ const by = new Map();
54
+ for (const e of entries) {
55
+ if (e.verdict !== "denied" || !e.target || !e.action) continue;
56
+ const k = `${e.action}\u0000${e.target}`;
57
+ const g = by.get(k) ?? { action: e.action, target: e.target, times: 0, roles: new Set(), owners: e.owners ?? [] };
58
+ g.times++;
59
+ g.roles.add(e.role);
60
+ by.set(k, g);
61
+ }
62
+ const total = [...by.values()].reduce((n, g) => n + g.times, 0);
63
+
64
+ /**
65
+ * The same grouping again, one level coarser: by the NAME at the end of the
66
+ * path rather than the path.
67
+ *
68
+ * Grouping by path alone gets the most important reading backwards. Measured
69
+ * here: six of the top seven causes were `.git/index.lock` in six different
70
+ * repositories, no single one above 17% — so "no cause dominates" is what the
71
+ * arithmetic says and the opposite of what is true. Three quarters of the
72
+ * window was one *kind* of thing, and that is a tooling problem with a
73
+ * mechanical fix, not a territory question anybody needs to rule on.
74
+ *
75
+ * By last segment, and nothing cleverer. A regex that recognised lock files,
76
+ * caches and build outputs would be a list of guesses about other people's
77
+ * toolchains that quietly goes stale; a repeated filename is a fact about
78
+ * this log.
79
+ */
80
+ const fams = new Map();
81
+ for (const g of by.values()) {
82
+ const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
83
+ const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
84
+ f.times += g.times;
85
+ f.paths++;
86
+ // The places, so the console can show what a name is made of instead of
87
+ // asserting a percentage the reader has to take on faith.
88
+ f.where.push({ target: g.target, times: g.times });
89
+ for (const r of g.roles) f.roles.add(r);
90
+ fams.set(name, f);
91
+ }
92
+ const families = [...fams.values()]
93
+ .sort((a, b) => b.times - a.times)
94
+ .map((f) => ({
95
+ name: f.name, times: f.times, paths: f.paths,
96
+ share: total ? f.times / total : 0,
97
+ roles: [...f.roles].sort(),
98
+ where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
99
+ }));
100
+
101
+ /**
102
+ * What a `grep` over the log cannot tell you.
103
+ *
104
+ * The headline used to be "74% of it is one name", and a field review
105
+ * applied this project's own test to it: *did the number tell you something
106
+ * you did not know?* For somebody who reads the raw log, no — they had
107
+ * already counted that. The console was repeating the log back.
108
+ *
109
+ * These two are different, because they need the **policy** and the log does
110
+ * not contain it. `unowned` is the count of causes on paths no role claims:
111
+ * no grant resolves those until somebody decides who owns them, which is a
112
+ * decision rather than a number. `settled` is friction that has since been
113
+ * granted — real yesterday, noise today.
114
+ */
115
+ const live = [...by.values()];
116
+ const unowned = live.filter((g) => !(g.owners ?? []).length).length;
117
+
118
+ return {
119
+ total,
120
+ unowned,
121
+ // The real number of distinct causes, not the length of the list below.
122
+ // The page says "over N distinct paths" and the list is capped at twelve,
123
+ // so taking N from the list reported the cap as if it were the count —
124
+ // a wrong number stated confidently, which is worse than no number.
125
+ distinct: by.size,
126
+ families: families.slice(0, 8),
127
+ causes: [...by.values()]
128
+ .sort((a, b) => b.times - a.times)
129
+ .slice(0, 12)
130
+ .map((g) => ({
131
+ action: g.action,
132
+ target: g.target,
133
+ times: g.times,
134
+ share: total ? g.times / total : 0,
135
+ roles: [...g.roles].sort(),
136
+ owners: g.owners,
137
+ // How many of the roles that hit this would still hit it. `some` and
138
+ // not `every`: two roles out of three still blocked is still friction,
139
+ // and requiring all of them would quietly retire a live cause the day
140
+ // one role got a grant.
141
+ stillRefused: [...g.roles].filter((r) => cfg.roles[r] && !explain(cfg, r, g.action, g.target).allowed).length,
142
+ })),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * `?since=<ISO date>`, or null for no filter. Anything that does not parse is
148
+ * ignored rather than refused: a bad filter should show everything, not an error.
149
+ */
150
+ export function sinceOf(url) {
151
+ const raw = new URLSearchParams((url ?? "").split("?")[1] ?? "").get("since");
152
+ const t = raw ? Date.parse(raw) : NaN;
153
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
154
+ }
155
+
156
+ export function state(configPath, { since = null } = {}) {
33
157
  const cfg = loadConfig(configPath);
34
- const entries = read(logPath(cfg.root), { limit: 200 });
158
+ // The date filter narrows everything read from the log: activity, causes,
159
+ // walls and the per-role counts. Not the request queue, which is what is
160
+ // waiting now whatever the window.
161
+ const entries = read(logPath(cfg.root), { limit: 200, since });
162
+ // The whole window, not a tail. "All" used to mean the last 4000 lines, so a
163
+ // 30-day window could show more denials than "all" did (6k against 4k on a
164
+ // real log). Every count on the page comes from this list.
165
+ const forShape = read(logPath(cfg.root), since ? { since } : {});
35
166
 
36
167
  const roles = Object.values(cfg.roles).map((r) => ({
37
168
  name: r.name,
38
169
  writes: r.writes,
39
170
  keys: r.keys,
40
171
  env: r.env,
41
- blocks: entries.filter((e) => e.role === r.name && e.verdict === "denied").length,
172
+ neverWrites: r.neverWritesDeclared ?? r.neverWrites ?? [],
173
+ localBinding: r.localBinding === true,
174
+ localPorts: r.localPorts ?? [],
175
+ mcp: r.mcp ?? null,
176
+ trustd: r.trustd === true,
177
+ // Counted over the same window as the causes, so the KPI and the screen it
178
+ // links to agree.
179
+ blocks: forShape.filter((e) => e.role === r.name && e.verdict === "denied").length,
42
180
  settings: settingsFor(cfg, r.name),
43
181
  }));
44
182
 
183
+ // Read once for every role's walls, rather than once per role.
184
+ const whole = read(logPath(cfg.root), { verdict: "denied", since });
185
+ // Staleness is about the role's whole history, never the window: a request
186
+ // is not old just because the filter hides the runs that made it old.
187
+ const history = since ? read(logPath(cfg.root), { verdict: "denied" }) : whole;
45
188
  return {
46
189
  root: cfg.root,
47
190
  config: cfg.path,
48
- requests: pending(requestsPath(cfg.root)),
191
+ requests: markStale(pending(requestsPath(cfg.root)), history),
192
+ since,
49
193
  // The file itself, not a regeneration of it. The page can render a policy
50
194
  // from its own model, and that model has no comments — so showing it under
51
195
  // the heading "seisin.toml" next to "copy this back" invites someone to
@@ -54,6 +198,13 @@ function state(configPath) {
54
198
  keyDirs: cfg.keyDirs,
55
199
  allowedDomains: cfg.allowedDomains,
56
200
  roles,
201
+ causes: causesOf(cfg, forShape),
202
+ // What each role keeps being refused AND would still be refused today.
203
+ // Empty for a role that has hit nothing twice, which is most of them.
204
+ walls: Object.fromEntries(
205
+ Object.keys(cfg.roles)
206
+ .map((r) => [r, walls(cfg, r, { entries: whole })])
207
+ .filter(([, w]) => w.length)),
57
208
  log: entries.slice(-60).reverse(),
58
209
  live: true,
59
210
  };
@@ -81,6 +232,7 @@ function decide(configPath, { number, decision, reason }) {
81
232
  // The config is edited as text, so comments and order survive. Written
82
233
  // before the queue is settled: if this throws, the request is still open
83
234
  // rather than marked done against a file that never changed.
235
+ refuseIfBarred(cfg, req);
84
236
  const { toml, changed } = applyGrant(readFileSync(cfg.path, "utf8"), req, reason);
85
237
  if (changed) writeFileSync(cfg.path, toml);
86
238
  }
@@ -89,17 +241,49 @@ function decide(configPath, { number, decision, reason }) {
89
241
  }
90
242
 
91
243
  /** The body of a POST, capped: this endpoint takes three short fields. */
92
- function readBody(req) {
244
+ /**
245
+ * A request body, capped. The cap is per route: one decision is a few hundred
246
+ * bytes, but "decline all" carries every key on screen — 109 of them were
247
+ * 9.3 KB, over the old flat 4 KB, and the connection was cut with nothing but
248
+ * "Failed to fetch" in the browser. Found by Carlos on the first real use.
249
+ */
250
+ function readBody(req, max = 4096) {
93
251
  return new Promise((ok, fail) => {
94
252
  let s = "";
95
253
  req.on("data", (c) => {
96
254
  s += c;
97
- if (s.length > 4096) { fail(new Error("body too large")); req.destroy(); }
255
+ if (s.length > max) { fail(new Error("body too large")); req.destroy(); }
98
256
  });
99
257
  req.on("end", () => ok(s));
100
258
  });
101
259
  }
102
260
 
261
+ /**
262
+ * Refuse every request the page was showing, in one go.
263
+ *
264
+ * Only refusing. There is no "approve all" and there will not be: a grant is a
265
+ * decision about one path and one owner, and doing forty of them without
266
+ * reading them is the permissive default this tool exists to stand against.
267
+ * Refusing in bulk is safe — it grants nothing and changes no policy — and it
268
+ * is what a queue full of noise from a bug already fixed needs.
269
+ *
270
+ * By key, not "whatever is pending now": a request that arrived after the page
271
+ * drew is one the person has not seen, and it stays.
272
+ */
273
+ function declineAll(configPath, { keys, reason }) {
274
+ if (!Array.isArray(keys) || !keys.length) throw new Error("keys must list the requests on screen");
275
+ const cfg = loadConfig(configPath);
276
+ const file = requestsPath(cfg.root);
277
+ const wanted = new Set(keys.map(String));
278
+ let declined = 0;
279
+ for (const req of pending(file)) {
280
+ if (!wanted.has(req.key)) continue;
281
+ settle(file, req.key, "denied", reason ?? "");
282
+ declined++;
283
+ }
284
+ return { ok: true, declined };
285
+ }
286
+
103
287
  export function serve(configPath, port = 4178) {
104
288
  const page = join(HERE, "..", "ui", "index.html");
105
289
  if (!existsSync(page)) throw new Error("the console is missing from this install");
@@ -114,12 +298,69 @@ export function serve(configPath, port = 4178) {
114
298
  */
115
299
  const token = randomBytes(24).toString("hex");
116
300
 
301
+ /**
302
+ * Every route under /api/ asks for the token, reads included.
303
+ *
304
+ * The page used to be the only thing that carried the token and /api/state
305
+ * answered anyone. Both rested on "an agent cannot reach loopback", and
306
+ * `local_binding = true` on macOS makes that false: the runtime lets that
307
+ * role connect to every localhost port, and loopback bypasses the egress
308
+ * proxy. Reproduced end to end by a read-only review on 2026-09-22 — a role
309
+ * read the token off `GET /`, posted to /api/decide, and approved its own
310
+ * request into another role's territory. /api/state handed it the policy, the
311
+ * log and where every key lives, with no token at all.
312
+ *
313
+ * So the token no longer travels in any response. `seisin ui` puts it in the
314
+ * URL fragment, which a browser never sends to a server; the page takes it
315
+ * from there, keeps it for the tab, and removes it from the address bar and
316
+ * the history entry. Anything that fetches `/` gets a page with no token in it.
317
+ */
318
+ const authorized = (req) => req.headers["x-seisin-token"] === token;
319
+
320
+ /**
321
+ * The Host header has to name this server.
322
+ *
323
+ * A page on the operator's browser can point a hostname it controls at
324
+ * 127.0.0.1 (DNS rebinding) and then read responses as same-origin. It still
325
+ * could not produce the token, but the check is one line and makes the
326
+ * question moot: the only names this server answers to are its own.
327
+ */
328
+ const hostOk = (req, port) => {
329
+ const h = String(req.headers.host ?? "");
330
+ return h === `127.0.0.1:${port}` || h === `localhost:${port}`;
331
+ };
332
+
333
+ /**
334
+ * The path, without whatever came after `?`.
335
+ *
336
+ * `req.url` is the raw request target, so a query string made every route
337
+ * miss: `http://localhost:4178/?anything` answered 404 on the one page this
338
+ * server has. Harmless until a launcher appends a parameter or a link is
339
+ * shared with a suffix on it, and then the console just looks broken.
340
+ */
341
+ const pathOf = (u) => (u ?? "/").split("?")[0];
342
+
117
343
  const server = createServer(async (req, res) => {
118
- if (req.method === "POST" && req.url === "/api/decide") {
119
- if (req.headers["x-seisin-token"] !== token) {
120
- res.writeHead(403, { "content-type": "application/json" });
121
- return res.end(JSON.stringify({ error: "bad or missing token" }));
344
+ if (!hostOk(req, server.address()?.port)) {
345
+ res.writeHead(403, { "content-type": "text/plain" });
346
+ return res.end("wrong host");
347
+ }
348
+ if (pathOf(req.url).startsWith("/api/") && !authorized(req)) {
349
+ res.writeHead(403, { "content-type": "application/json" });
350
+ return res.end(JSON.stringify({ error: "bad or missing token — open the URL `seisin ui` printed" }));
351
+ }
352
+ if (req.method === "POST" && pathOf(req.url) === "/api/decline-all") {
353
+ try {
354
+ // ~85 bytes per key: 1 MB is ten thousand requests on one screen.
355
+ const out = declineAll(configPath, JSON.parse((await readBody(req, 1024 * 1024)) || "{}"));
356
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
357
+ return res.end(JSON.stringify(out));
358
+ } catch (e) {
359
+ res.writeHead(400, { "content-type": "application/json" });
360
+ return res.end(JSON.stringify({ error: e.message }));
122
361
  }
362
+ }
363
+ if (req.method === "POST" && pathOf(req.url) === "/api/decide") {
123
364
  try {
124
365
  const out = decide(configPath, JSON.parse((await readBody(req)) || "{}"));
125
366
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
@@ -133,10 +374,10 @@ export function serve(configPath, port = 4178) {
133
374
  res.writeHead(405).end("method not allowed");
134
375
  return;
135
376
  }
136
- if (req.url === "/api/state") {
377
+ if (pathOf(req.url) === "/api/state") {
137
378
  let body;
138
379
  try {
139
- body = JSON.stringify(state(configPath));
380
+ body = JSON.stringify(state(configPath, { since: sinceOf(req.url) }));
140
381
  } catch (e) {
141
382
  res.writeHead(500, { "content-type": "application/json" });
142
383
  return res.end(JSON.stringify({ error: e.message }));
@@ -145,11 +386,10 @@ export function serve(configPath, port = 4178) {
145
386
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
146
387
  return res.end(body);
147
388
  }
148
- if (req.url === "/" || req.url === "/index.html") {
149
- // Inlined rather than put in the URL: a fragment survives in history, in
150
- // a screenshot, and in whatever the operator pastes into a chat.
151
- const html = readFileSync(page, "utf8")
152
- .replace("</head>", `<script>window.SEISIN_TOKEN=${JSON.stringify(token)}</script></head>`);
389
+ if (pathOf(req.url) === "/" || pathOf(req.url) === "/index.html") {
390
+ // No token in here. It used to be inlined, which handed it to anything
391
+ // that could GET this page — see `authorized` above.
392
+ const html = readFileSync(page, "utf8");
153
393
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
154
394
  return res.end(html);
155
395
  }
@@ -159,7 +399,11 @@ export function serve(configPath, port = 4178) {
159
399
  return new Promise((ok, fail) => {
160
400
  // Loopback only. The page maps where every credential lives; it has no
161
401
  // business being reachable from the network the laptop happens to be on.
162
- server.listen(port, "127.0.0.1", () => ok(server));
402
+ server.listen(port, "127.0.0.1", () => {
403
+ // For `seisin ui`, which builds the URL, and for tests. Never sent.
404
+ Object.defineProperty(server, "seisinToken", { value: token });
405
+ ok(server);
406
+ });
163
407
  server.on("error", (e) =>
164
408
  fail(e.code === "EADDRINUSE"
165
409
  ? new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`)
package/src/spool.js CHANGED
@@ -26,8 +26,9 @@
26
26
  * solved here; the log is evidence of what happened, not proof that nothing
27
27
  * else did.
28
28
  */
29
- import { createServer, createConnection } from "node:net";
30
- import { unlinkSync, existsSync, mkdtempSync, chmodSync } from "node:fs";
29
+ import { createServer, createConnection, Socket } from "node:net";
30
+ import { unlinkSync, existsSync, mkdtempSync, chmodSync, openSync, writeSync, closeSync, constants } from "node:fs";
31
+ import { execFileSync } from "node:child_process";
31
32
  import { tmpdir } from "node:os";
32
33
  import { join, dirname } from "node:path";
33
34
 
@@ -59,29 +60,78 @@ export function spoolPath() {
59
60
  * in. The parent decides what that means; the sender does not get to pick a
60
61
  * path, which is the other half of why this is not just a slower file write.
61
62
  */
63
+ /**
64
+ * Which channel this platform can use: a unix socket, or a FIFO.
65
+ *
66
+ * On Linux the runtime blocks creating ANY unix socket inside the box with a
67
+ * seccomp filter — it cannot filter by path, so `allowUnixSockets` is ignored
68
+ * there (its README says so). The socket this file was built on therefore
69
+ * never carried a line on Linux: measured in Docker on 2026-09-23, with the
70
+ * code as published in 0.2.0 and after, the hook "sent" and the log stayed
71
+ * empty. A FIFO is a file, not a socket — seccomp does not see it, bubblewrap
72
+ * grants it by path, and a write of up to PIPE_BUF bytes is atomic, so lines
73
+ * from concurrent tool calls do not interleave. Measured the same day: the
74
+ * role wrote a line through it, could not create anything beside it, and
75
+ * could not remove it.
76
+ */
77
+ export const FIFO_SUFFIX = ".fifo";
78
+ export const channelName = (platform = process.platform) => (platform === "linux" ? "s.fifo" : "s.sock");
79
+
80
+ /** The most a FIFO write carries atomically on Linux (PIPE_BUF). */
81
+ const PIPE_BUF = 4096;
82
+
83
+ /** Parses newline-separated JSON messages and hands each to `sink`. */
84
+ function lineReader(sink, onTooLong) {
85
+ let buffer = "";
86
+ return (chunk) => {
87
+ buffer += chunk;
88
+ // A 1 MB line is not a log entry, it is something trying to fill a disk.
89
+ if (buffer.length > 1_000_000) { buffer = ""; return onTooLong?.(); }
90
+ let nl;
91
+ while ((nl = buffer.indexOf("\n")) !== -1) {
92
+ const line = buffer.slice(0, nl);
93
+ buffer = buffer.slice(nl + 1);
94
+ if (!line.trim()) continue;
95
+ try {
96
+ const msg = JSON.parse(line);
97
+ if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
98
+ } catch {
99
+ // Garbage from inside the box is not the parent's emergency.
100
+ }
101
+ }
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The FIFO end of the spool. Opened read-write so it never sees end-of-file
107
+ * when a writer closes — every hook call opens, writes one line and closes —
108
+ * and read through a net.Socket, which takes a pipe descriptor and reads it
109
+ * without holding a thread.
110
+ */
111
+ function fifoSpool(sink, path) {
112
+ if (existsSync(path)) unlinkSync(path);
113
+ execFileSync("mkfifo", ["-m", "600", path]);
114
+ const fd = openSync(path, constants.O_RDWR | constants.O_NONBLOCK);
115
+ const stream = new Socket({ fd, readable: true, writable: false });
116
+ stream.setEncoding("utf8");
117
+ stream.on("data", lineReader(sink));
118
+ stream.on("error", () => {});
119
+ return Promise.resolve({
120
+ path,
121
+ close() {
122
+ stream.destroy();
123
+ try { unlinkSync(path); } catch {}
124
+ },
125
+ });
126
+ }
127
+
62
128
  export function spool(sink, path = spoolPath()) {
129
+ if (path.endsWith(FIFO_SUFFIX)) return fifoSpool(sink, path);
63
130
  if (existsSync(path)) unlinkSync(path);
64
131
 
65
132
  const server = createServer((conn) => {
66
- let buffer = "";
67
133
  conn.setEncoding("utf8");
68
- conn.on("data", (chunk) => {
69
- buffer += chunk;
70
- // A 1 MB line is not a log entry, it is something trying to fill a disk.
71
- if (buffer.length > 1_000_000) return conn.destroy();
72
- let nl;
73
- while ((nl = buffer.indexOf("\n")) !== -1) {
74
- const line = buffer.slice(0, nl);
75
- buffer = buffer.slice(nl + 1);
76
- if (!line.trim()) continue;
77
- try {
78
- const msg = JSON.parse(line);
79
- if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
80
- } catch {
81
- // Garbage from inside the box is not the parent's emergency.
82
- }
83
- }
84
- });
134
+ conn.on("data", lineReader(sink, () => conn.destroy()));
85
135
  conn.on("error", () => {});
86
136
  });
87
137
 
@@ -136,6 +186,7 @@ const inFlight = new Set();
136
186
 
137
187
  export function send(to, entry, path = process.env[SOCK_ENV]) {
138
188
  if (!path) return false;
189
+ if (path.endsWith(FIFO_SUFFIX)) return sendFifo(to, entry, path);
139
190
  const p = new Promise((done) => {
140
191
  let settled = false;
141
192
  const finish = () => { if (!settled) { settled = true; done(true); } };
@@ -162,3 +213,30 @@ export function send(to, entry, path = process.env[SOCK_ENV]) {
162
213
  export async function flush() {
163
214
  await Promise.all([...inFlight]);
164
215
  }
216
+
217
+ /**
218
+ * One line into the FIFO, synchronously: nothing to flush, nothing to lose to
219
+ * `process.exit`. Non-blocking, so a parent that is gone (no reader) costs an
220
+ * ENXIO and not a hung tool call. Longer than PIPE_BUF, the free-text fields
221
+ * are shortened first — past that size a write is no longer atomic and two
222
+ * tool calls could interleave their lines.
223
+ */
224
+ function sendFifo(to, entry, path) {
225
+ const full = { at: new Date().toISOString(), ...entry };
226
+ let line = JSON.stringify({ to, entry: full }) + "\n";
227
+ if (Buffer.byteLength(line) > PIPE_BUF) {
228
+ const short = { ...full, reason: String(full.reason ?? "").slice(0, 300), target: String(full.target ?? "").slice(0, 1000) };
229
+ line = JSON.stringify({ to, entry: short }) + "\n";
230
+ if (Buffer.byteLength(line) > PIPE_BUF) return false;
231
+ }
232
+ let fd;
233
+ try {
234
+ fd = openSync(path, constants.O_WRONLY | constants.O_NONBLOCK);
235
+ writeSync(fd, line);
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ } finally {
240
+ if (fd !== undefined) try { closeSync(fd); } catch {}
241
+ }
242
+ }