seisin 0.3.0 → 0.4.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/src/log.js CHANGED
@@ -13,9 +13,9 @@
13
13
  * tries the parent's socket first for exactly that reason; writing the file
14
14
  * directly is what happens when there is no parent, outside the box.
15
15
  */
16
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
16
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
17
17
  import { createHash } from "node:crypto";
18
- import { dirname, join } from "node:path";
18
+ import { basename, dirname, join } from "node:path";
19
19
  import { STATE_DIR, LOG_NAME } from "./layout.js";
20
20
  import { send } from "./spool.js";
21
21
 
@@ -39,17 +39,97 @@ export function append(file, entry) {
39
39
  try {
40
40
  mkdirSync(dirname(file), { recursive: true });
41
41
  withLock(file, () => {
42
+ // Rotate first, under the same lock: a full segment is renamed away and a
43
+ // fresh one seeded so the chain continues, then this line lands in the new
44
+ // segment. Done here so the size check and the append never race.
45
+ maybeRotate(file);
42
46
  // `prev` goes last and is computed from the previous line exactly as it
43
47
  // sits on disk, so verifying needs nothing but the file.
44
48
  const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
45
49
  appendFileSync(file, line + "\n");
46
- });
50
+ }, { waitMs: envInt("SEISIN_LOG_LOCK_WAIT_MS", 6000) });
47
51
  return true;
48
- } catch {
52
+ } catch (e) {
53
+ // A line that could not get the lock is gone, and a missing line does not
54
+ // break the chain. Say so beside the log, so `verify` can.
55
+ if (e?.code === "ELOCKED") {
56
+ try { appendFileSync(droppedPath(file), JSON.stringify({ at: new Date().toISOString(), role: entry?.role, event: entry?.event }) + "\n"); } catch {}
57
+ }
49
58
  return false;
50
59
  }
51
60
  }
52
61
 
62
+ /** Where entries that could not be written are counted. */
63
+ export function droppedPath(file) {
64
+ return file + ".dropped";
65
+ }
66
+
67
+ function countLines(file) {
68
+ try { return readFileSync(file, "utf8").split("\n").filter((l) => l.trim()).length; } catch { return 0; }
69
+ }
70
+
71
+ /**
72
+ * When the current segment fills, retire it and start a fresh one that still
73
+ * chains.
74
+ *
75
+ * An append-only file that only grows is the right default and the wrong
76
+ * forever: a busy team writes megabytes of it, and `verify` walks all of them
77
+ * on every run. Rotation caps the size of the file three tools tail while
78
+ * keeping the property that made the chain worth having — that a deletion shows.
79
+ *
80
+ * The retired segment keeps its own chain intact; the new segment's first line
81
+ * is a `rotated` marker whose `prev` is the retired segment's last hash, so the
82
+ * two verify as one chain across the cut (`verifyLog`). The comment on GENESIS
83
+ * asked for exactly this. Segments are numbered upward, `.1` the oldest, and
84
+ * the oldest beyond the keep count are dropped — the only place seisin removes
85
+ * a record, and it removes whole retired segments, never a line.
86
+ */
87
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MiB per segment
88
+ const DEFAULT_KEEP = 5; // retired segments kept beside the current one
89
+
90
+ function envInt(name, fallback) {
91
+ const n = Number(process.env[name]);
92
+ return Number.isFinite(n) && n > 0 ? n : fallback;
93
+ }
94
+
95
+ /** Every segment of the log, oldest → newest, current last. */
96
+ export function logSegments(file) {
97
+ const dir = dirname(file);
98
+ const base = basename(file);
99
+ let names = [];
100
+ try { names = readdirSync(dir); } catch { return existsSync(file) ? [file] : []; }
101
+ const archives = names
102
+ .map((n) => {
103
+ if (!n.startsWith(base + ".")) return null;
104
+ const seq = Number(n.slice(base.length + 1));
105
+ return Number.isInteger(seq) && seq > 0 ? { seq, path: join(dir, n) } : null;
106
+ })
107
+ .filter(Boolean)
108
+ .sort((a, b) => a.seq - b.seq)
109
+ .map((a) => a.path);
110
+ if (existsSync(file)) archives.push(file);
111
+ return archives;
112
+ }
113
+
114
+ function maybeRotate(file) {
115
+ let sz = 0;
116
+ try { sz = statSync(file).size; } catch { return; }
117
+ if (sz < envInt("SEISIN_LOG_MAX_BYTES", DEFAULT_MAX_BYTES)) return;
118
+
119
+ const tail = hashOf(lastLine(file));
120
+ const archives = logSegments(file).filter((p) => p !== file);
121
+ const seqs = archives.map((p) => Number(p.slice((file + ".").length))).filter(Number.isInteger);
122
+ const next = (seqs.length ? Math.max(...seqs) : 0) + 1;
123
+ renameSync(file, `${file}.${next}`);
124
+ // Seed the new current so the first real append chains from the retired tail.
125
+ const marker = JSON.stringify({ at: new Date().toISOString(), event: "rotated", from: `${basename(file)}.${next}`, prev: tail });
126
+ appendFileSync(file, marker + "\n");
127
+
128
+ const keep = envInt("SEISIN_LOG_KEEP", DEFAULT_KEEP);
129
+ const retired = logSegments(file).filter((p) => p !== file); // oldest → newest, includes the new archive
130
+ for (let i = 0; i < retired.length - keep; i++) { try { unlinkSync(retired[i]); } catch {} }
131
+ }
132
+
53
133
  /**
54
134
  * The log is chained: every line carries `prev`, the hash of the line before it.
55
135
  *
@@ -90,23 +170,73 @@ function lastLine(file) {
90
170
  /**
91
171
  * One writer at a time. Several roles of a round run at once and write this
92
172
  * same file; two of them reading the same last line would fork the chain.
93
- * A lock left by a killed process is taken over after a few seconds.
173
+ *
174
+ * The lock names its holder (pid and a nonce). A lock whose holder is gone is
175
+ * taken over at once; one whose holder cannot be read is taken over once it is
176
+ * older than `staleMs`. The wait outlasts that age on purpose: a writer that
177
+ * died holding the lock can never make the next one give up, so the only entry
178
+ * ever dropped is one that waited on a writer still alive — and that drop is
179
+ * counted beside the log (see `append`), because a missing line does not break
180
+ * the chain and would otherwise not show.
94
181
  */
95
- function withLock(file, fn) {
182
+ export function withLock(file, fn, { waitMs = 6000, staleMs = 5000 } = {}) {
96
183
  const lock = file + ".lock";
97
- const deadline = Date.now() + 2000;
184
+ const deadline = Date.now() + waitMs;
185
+ const mine = `${process.pid} ${createHash("sha256").update(String(Math.random()) + Date.now()).digest("hex").slice(0, 12)}\n`;
186
+ let acquired = false;
98
187
  for (;;) {
99
188
  try {
100
- closeSync(openSync(lock, "wx"));
189
+ const fd = openSync(lock, "wx");
190
+ try { writeSync(fd, mine); } finally { closeSync(fd); }
191
+ acquired = true;
101
192
  break;
102
193
  } catch (e) {
103
194
  if (e.code !== "EEXIST") throw e;
104
- try { if (Date.now() - statSync(lock).mtimeMs > 5000) { unlinkSync(lock); continue; } } catch {}
105
- if (Date.now() > deadline) break; // never block the run over bookkeeping
195
+ if (reapable(lock, staleMs)) continue;
196
+ // Never write without the lock. Doing so forks the hash chain; deleting
197
+ // the lock in `finally` then admits still more writers while its owner is
198
+ // active. The log is bookkeeping and append() may fail without taking the
199
+ // agent down, so dropping this entry is safer than fabricating evidence.
200
+ if (Date.now() > deadline) {
201
+ const error = new Error(`could not acquire log lock within ${waitMs} ms`);
202
+ error.code = "ELOCKED";
203
+ throw error;
204
+ }
106
205
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
107
206
  }
108
207
  }
109
- try { return fn(); } finally { try { unlinkSync(lock); } catch {} }
208
+ try { return fn(); }
209
+ finally {
210
+ // Remove it only while it is still ours: if it was taken over, it now
211
+ // belongs to someone else.
212
+ if (acquired) try { if (readFileSync(lock, "utf8") === mine) unlinkSync(lock); } catch {}
213
+ }
214
+ }
215
+
216
+ /** True if the process is still there. EPERM means it is, and not ours to signal. */
217
+ function alive(pid) {
218
+ try { process.kill(pid, 0); return true; }
219
+ catch (e) { return e.code !== "ESRCH"; }
220
+ }
221
+
222
+ /**
223
+ * Removes a lock whose holder is gone, and says whether it did.
224
+ *
225
+ * The content is read again just before removing it, so a waiter that decided
226
+ * on one lock does not remove a newer one another waiter has just taken.
227
+ */
228
+ function reapable(lock, staleMs) {
229
+ let seen, mtime;
230
+ try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; } catch { return true; }
231
+ const pid = Number(seen.split(" ")[0]);
232
+ const named = Number.isInteger(pid) && pid > 0;
233
+ // A named holder that is alive is waited on, however long it takes: its age
234
+ // says nothing about whether it is done. An unnamed one (an older seisin, or
235
+ // killed between creating the file and writing its name) goes by age.
236
+ const gone = named ? !alive(pid) : Date.now() - mtime > staleMs;
237
+ if (!gone) return false;
238
+ try { if (readFileSync(lock, "utf8") !== seen) return true; unlinkSync(lock); } catch {}
239
+ return true;
110
240
  }
111
241
 
112
242
  /**
@@ -116,8 +246,8 @@ function withLock(file, fn) {
116
246
  * unchained prefix, not reported as tampering. After the first chained line,
117
247
  * a line without `prev` is itself a break.
118
248
  */
119
- export function verifyChain(file) {
120
- const out = { lines: 0, unchained: 0, chained: 0, breaks: [] };
249
+ function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
250
+ const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null };
121
251
  if (!existsSync(file)) return out;
122
252
  let previous = null;
123
253
  let started = false;
@@ -129,15 +259,54 @@ export function verifyChain(file) {
129
259
  const prev = e?.prev;
130
260
  if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
131
261
  started = true;
132
- const expected = out.chained === 0 && out.unchained === 0 ? GENESIS : hashOf(previous);
133
- const ok = prev === expected || (out.chained === 0 && prev === hashOf(previous));
134
- if (!ok) out.breaks.push({ line: out.lines, expected, found: prev ?? null });
262
+ const first = out.chained === 0 && out.unchained === 0;
263
+ // A rotation marker leading a segment we have nothing to check it against —
264
+ // a file verified on its own, or the oldest segment left after the prefix
265
+ // was pruned for retention (seed still GENESIS) — is accepted rather than
266
+ // called a break. For any later segment the seed is the real previous tail,
267
+ // so the marker's `prev` IS checked, and a whole segment deleted or
268
+ // reordered in the middle shows.
269
+ const rotationStart = first && e?.event === "rotated" && seed === GENESIS;
270
+ const expected = first ? seed : hashOf(previous);
271
+ const ok = rotationStart || prev === expected || (first && prev === hashOf(previous));
272
+ if (!ok) out.breaks.push({ line: startLine + out.lines, expected, found: prev ?? null });
135
273
  out.chained++;
136
274
  previous = line;
137
275
  }
276
+ out.tail = previous;
138
277
  return out;
139
278
  }
140
279
 
280
+ export function verifyChain(file, seed = GENESIS) {
281
+ const r = walk(file, { seed });
282
+ return { lines: r.lines, unchained: r.unchained, chained: r.chained, breaks: r.breaks };
283
+ }
284
+
285
+ /**
286
+ * Verify the whole log, across every rotated segment, as one chain.
287
+ *
288
+ * Each retired segment is walked in order, and its last hash becomes the seed
289
+ * the next segment's `rotated` marker must match — so deleting or reordering a
290
+ * whole segment shows, not only an edit inside one. With no rotation there is a
291
+ * single segment and this is exactly `verifyChain`. What it cannot see is a
292
+ * segment dropped off the *oldest* end by the keep limit: that is retention, not
293
+ * tampering, and the count of segments is reported so the drop is not silent.
294
+ */
295
+ export function verifyLog(file) {
296
+ const segs = logSegments(file);
297
+ const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length, dropped: countLines(droppedPath(file)) };
298
+ let seed = GENESIS;
299
+ for (const seg of segs) {
300
+ const r = walk(seg, { seed, startLine: agg.lines });
301
+ agg.lines += r.lines;
302
+ agg.unchained += r.unchained;
303
+ agg.chained += r.chained;
304
+ agg.breaks.push(...r.breaks);
305
+ seed = hashOf(r.tail);
306
+ }
307
+ return agg;
308
+ }
309
+
141
310
  /** Reads entries, newest last. A malformed line is skipped, never fatal. */
142
311
  export function read(file, { role, verdict, since, limit = 0 } = {}) {
143
312
  if (!existsSync(file)) return [];
@@ -150,6 +319,7 @@ export function read(file, { role, verdict, since, limit = 0 } = {}) {
150
319
  } catch {
151
320
  continue; // a half-written line from a killed process is not an error
152
321
  }
322
+ if (e.event === "rotated") continue; // a structural marker, not a decision
153
323
  if (role && e.role !== role) continue;
154
324
  if (verdict && e.verdict !== verdict) continue;
155
325
  if (since && e.at < since) continue;
@@ -179,6 +349,14 @@ export function observed(entries) {
179
349
  const roles = new Map();
180
350
  for (const e of entries) {
181
351
  if (!e.role || !e.target) continue;
352
+ // A line the parent flagged as `disputed` is one whose verdict did not
353
+ // match the policy the run started with — either the config changed under
354
+ // it, or the line was not written by the hook at all. Either way it is not
355
+ // evidence of what the role legitimately reached for, so it must not become
356
+ // policy: `init --from-observations` builds `writes`/`keys` from here, and a
357
+ // process inside the box could otherwise send `verdict:"observed"` lines for
358
+ // paths outside its territory and have them proposed as its own.
359
+ if (e.disputed) continue;
182
360
  const r = roles.get(e.role) ?? { writes: new Set(), keys: new Set() };
183
361
  if (e.action === "read" && e.kind === "key") r.keys.add(e.target);
184
362
  else if (e.action === "write") r.writes.add(e.target);
package/src/mcp.js CHANGED
@@ -31,7 +31,7 @@ import { loadConfig, findConfig } from "./config.js";
31
31
  import { inspect } from "./inspect.js";
32
32
  import { explain, ownersOf } from "./owners.js";
33
33
  import { settingsFor } from "./srt.js";
34
- import { pending, requestsPath, grantFor, refuseIfBarred, markStale } from "./requests.js";
34
+ import { pending, requestsPath, grantFor, refuseIfBarred, markStale, shellId } from "./requests.js";
35
35
  import { read, logPath } from "./log.js";
36
36
  import { causesOf } from "./serve.js";
37
37
  import { walls, wasted } from "./walls.js";
@@ -100,13 +100,22 @@ const TOOLS = [
100
100
  name: "seisin_causes",
101
101
  description:
102
102
  "Denials grouped by what was denied — by path, and one level coarser by the name at " +
103
- "the end of it — with how many are on paths no role owns. This is the question " +
104
- "`seisin_activity` cannot answer: the raw log has the events, this has them counted " +
105
- "against the policy as it stands, so a cause that has been granted since stops " +
106
- "counting. Reach for it before proposing a change: a day that is three quarters one " +
107
- "filename is a tooling problem, and the same volume spread across unrelated paths is " +
108
- "a question about who owns what. Read-only.",
109
- inputSchema: { type: "object", properties: { limit: { type: "number" } } },
103
+ "the end of it — over the whole log, or since an ISO date. The counts are history: " +
104
+ "what was refused. What is measured against the policy as it stands is where each " +
105
+ "path is now — `standing`: unowned (a decision nobody has made), owned, protected " +
106
+ "(closed to every role on purpose, nothing to grant) or outside (a port, a key, or " +
107
+ "outside the repository) — and, per cause, `stillRefused`, how many of the roles " +
108
+ "that hit it would hit it today. `seisin_activity` has the raw events; this has " +
109
+ "them read against the policy. Reach for it before proposing a change: a day that " +
110
+ "is three quarters one filename is a tooling problem, and the same volume spread " +
111
+ "across unrelated unowned paths is a question about who owns what. Read-only.",
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: {
115
+ limit: { type: "number" },
116
+ since: { type: "string", description: "ISO date; only refusals at or after it" },
117
+ },
118
+ },
110
119
  },
111
120
  {
112
121
  name: "seisin_walls",
@@ -127,8 +136,10 @@ const TOOLS = [
127
136
  "does not grant. The approval is a human action in another channel.",
128
137
  inputSchema: {
129
138
  type: "object",
130
- properties: { number: { type: "number", description: "position in seisin_requests" } },
131
- required: ["number"],
139
+ properties: {
140
+ id: { type: "string", description: "the request's id from seisin_requests — preferred: it does not move when the queue does" },
141
+ number: { type: "number", description: "position in seisin_requests (legacy; the draft still names the id)" },
142
+ },
132
143
  },
133
144
  },
134
145
  ];
@@ -166,6 +177,8 @@ const HANDLERS = {
166
177
  // not enforced is documentation.
167
178
  if (action !== "read" && action !== "write" && action !== "mcp")
168
179
  throw new Error(`action must be "read", "write" or "mcp", got ${JSON.stringify(action)}`);
180
+ if (typeof role !== "string" || !role)
181
+ throw new Error("role must be a non-empty string");
169
182
  if (typeof target !== "string" || !target)
170
183
  throw new Error("target must be a non-empty path");
171
184
  const cfg = config();
@@ -182,14 +195,14 @@ const HANDLERS = {
182
195
  const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
183
196
  return {
184
197
  pending: queue.map((r, i) => ({
185
- number: i + 1, role: r.role, action: r.action,
198
+ number: i + 1, id: r.key, role: r.role, action: r.action,
186
199
  wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
187
200
  ...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
188
201
  })),
189
202
  // Said in the payload and not only in the tool description, because a
190
203
  // model reading this is deciding what to do next.
191
204
  note: queue.length
192
- ? "Approving is not available through MCP. Tell the operator to run: seisin grant <number>"
205
+ ? "Approving is not available through MCP. Tell the operator to run: seisin grant '<id>'"
193
206
  : "nothing waiting",
194
207
  };
195
208
  },
@@ -203,13 +216,21 @@ const HANDLERS = {
203
216
  * and asks it to re-derive the grouping — without the policy, which is the
204
217
  * half that makes the grouping mean anything.
205
218
  */
206
- seisin_causes({ limit }) {
219
+ seisin_causes({ limit, since }) {
207
220
  const cfg = config();
208
- const c = causesOf(cfg, read(logPath(cfg.root), { limit: 4000 }));
221
+ // The whole log, as the console reads it. This read the last 4000 lines
222
+ // after the console had stopped doing so, and the two then disagreed about
223
+ // the same log — 3,999 refusals here against 6,630 there, measured — with
224
+ // nothing telling the agent its window was half of the person's.
225
+ const t = since ? Date.parse(since) : NaN;
226
+ const from = Number.isNaN(t) ? null : new Date(t).toISOString();
227
+ const c = causesOf(cfg, read(logPath(cfg.root), from ? { since: from } : {}));
209
228
  return {
210
229
  total: c.total,
211
230
  distinct: c.distinct,
231
+ ...(from && { since: from }),
212
232
  unowned: c.unowned,
233
+ standing: c.standing,
213
234
  families: c.families,
214
235
  causes: typeof limit === "number" ? c.causes.slice(0, limit) : c.causes,
215
236
  };
@@ -237,12 +258,23 @@ const HANDLERS = {
237
258
  return { entries: read(logPath(cfg.root), { role, verdict, limit: n }) };
238
259
  },
239
260
 
240
- seisin_draft_grant({ number }) {
241
- if (!Number.isInteger(Number(number)) || Number(number) < 1)
242
- throw new Error("number must be a positive integer from seisin_requests");
261
+ seisin_draft_grant({ id, number } = {}) {
243
262
  const cfg = config();
244
- const req = pending(requestsPath(cfg.root))[Number(number) - 1];
245
- if (!req) throw new Error(`no pending request #${number}`);
263
+ const queue = pending(requestsPath(cfg.root));
264
+ let req;
265
+ if (typeof id === "string" && id) {
266
+ req = queue.find((r) => r.key === id);
267
+ if (!req) throw new Error(`request ${id} is not pending`);
268
+ } else {
269
+ if (!Number.isInteger(Number(number)) || Number(number) < 1)
270
+ throw new Error("id (preferred) or number from seisin_requests is required");
271
+ req = queue[Number(number) - 1];
272
+ if (!req) throw new Error(`no pending request #${number}`);
273
+ }
274
+ // The command is written against the id, whatever was asked with: a
275
+ // number is a position in a queue agents keep writing to, and the person
276
+ // runs this later, against the queue as it is then.
277
+ const ref = shellId(req.key);
246
278
 
247
279
  // The same refusal `seisin grant` and the console apply, asked here first.
248
280
  // Without it this drafted, as approvable, a request that `never_writes`
@@ -255,7 +287,7 @@ const HANDLERS = {
255
287
  ownedBy: req.owners,
256
288
  applyWith: null,
257
289
  note: "Not approvable as it stands: never_writes wins over writes. Decline it with " +
258
- `\`seisin decline ${number}\`, or remove the never_writes entry if the subtraction is wrong.`,
290
+ `\`seisin decline ${ref}\`, or remove the never_writes entry if the subtraction is wrong.`,
259
291
  };
260
292
  }
261
293
  const field = req.action === "read" ? "keys" : "writes";
@@ -263,7 +295,8 @@ const HANDLERS = {
263
295
  wouldAdd: { role: req.role, field, value: req.grant },
264
296
  preview: `[roles.${req.role}]\n${field} = [ …, "${req.grant}" ] # asked ${req.times}×`,
265
297
  ownedBy: req.owners,
266
- applyWith: `seisin grant ${number} --reason "<why>"`,
298
+ id: req.key,
299
+ applyWith: `seisin grant ${ref} --reason "<why>"`,
267
300
  note: "Not applied. seisin's MCP server writes nothing — a person runs the command above.",
268
301
  };
269
302
  },
@@ -284,7 +317,10 @@ function handle(message, { write, version }) {
284
317
  const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
285
318
  const fail = (id, code, msg) => send({ jsonrpc: "2.0", id, error: { code, message: msg } });
286
319
 
287
- const { id, method, params = {} } = message;
320
+ const { id, method } = message;
321
+ // `?? {}` so an explicit null params (not just missing) does not crash the
322
+ // dispatcher and leave the request unanswered.
323
+ const params = message.params ?? {};
288
324
 
289
325
  // A notification has no id and takes no answer. Replying to one is a protocol
290
326
  // error that some clients tolerate and others hang on.
@@ -310,8 +346,11 @@ function handle(message, { write, version }) {
310
346
  case "tools/list":
311
347
  return reply(id, { tools: TOOLS });
312
348
  case "tools/call": {
313
- const fn = HANDLERS[params.name];
314
- if (!fn) return fail(id, -32602, `unknown tool "${params.name}"`);
349
+ // hasOwn, not a bare lookup: `params.name` of "constructor" or
350
+ // "hasOwnProperty" would otherwise resolve to a function on Object's
351
+ // prototype and be invoked as a tool.
352
+ const fn = Object.hasOwn(HANDLERS, params.name) ? HANDLERS[params.name] : null;
353
+ if (typeof fn !== "function") return fail(id, -32602, `unknown tool ${JSON.stringify(params.name)}`);
315
354
  try {
316
355
  const result = fn(params.arguments ?? {});
317
356
  // Content, not a bare object: every client renders `content`, and only
package/src/notify.js CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { readFileSync } from "node:fs";
26
26
  import { basename, join } from "node:path";
27
- import { keyOf, pending, requestsPath, grantFor } from "./requests.js";
27
+ import { keyOf, pending, requestsPath, grantFor, shellId } from "./requests.js";
28
28
 
29
29
  export const FORMATS = ["text", "json", "slack"];
30
30
  export const ENV_URL = "SEISIN_NOTIFY_URL";
@@ -46,13 +46,13 @@ export function message(config, req, number, format = "text") {
46
46
  const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
47
47
  const text =
48
48
  `seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
49
- `Approve: seisin grant ${number} · decline: seisin decline ${number}`;
49
+ `Approve: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
50
50
  if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
51
51
  if (format === "json")
52
52
  return {
53
53
  type: "application/json",
54
54
  body: JSON.stringify({
55
- text, repo: basename(config.root), number, role: req.role, action: req.action,
55
+ text, repo: basename(config.root), number, id: keyOf(req), role: req.role, action: req.action,
56
56
  target: req.target, owners: req.owners ?? [], grant: grantFor(req),
57
57
  }),
58
58
  };