sessionpipe 0.1.0 → 0.1.1

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/dist/cli.js CHANGED
@@ -1,15 +1,24 @@
1
1
  #!/usr/bin/env node
2
- // sessionpipe 0.1.0 · Apache-2.0 · https://sessionpipe.org
2
+ // sessionpipe 0.1.1 · Apache-2.0 · https://sessionpipe.org
3
3
 
4
4
  // src/cli.ts
5
- import { execFileSync as execFileSync3, spawn } from "node:child_process";
6
- import { readdirSync as readdirSync6, readFileSync as readFileSync9, realpathSync as realpathSync2, statSync as statSync6, unlinkSync as unlinkSync2 } from "node:fs";
5
+ import { execFileSync as execFileSync4, spawn, spawnSync } from "node:child_process";
6
+ import { mkdtempSync, readdirSync as readdirSync7, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync, statSync as statSync6, unlinkSync as unlinkSync5 } from "node:fs";
7
7
  import os3 from "node:os";
8
8
  import path12 from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
11
  // ../core/src/adapters/antigravity.ts
12
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, realpathSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
12
+ import {
13
+ existsSync as existsSync2,
14
+ mkdirSync as mkdirSync2,
15
+ readdirSync as readdirSync2,
16
+ readFileSync as readFileSync2,
17
+ realpathSync,
18
+ statSync as statSync2,
19
+ unlinkSync as unlinkSync2,
20
+ writeFileSync as writeFileSync2
21
+ } from "node:fs";
13
22
  import path4 from "node:path";
14
23
 
15
24
  // ../core/src/paths.ts
@@ -236,16 +245,42 @@ function readAntigravity(lines, from) {
236
245
  }
237
246
 
238
247
  // ../core/src/adapters/claude-shaped.ts
239
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
248
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
240
249
  import path3 from "node:path";
241
250
  var OURS = /sessionpipe[^"\n]*[\\/]hook\.js"?\s+[a-z][a-z-]*\s+[A-Za-z]+\s*$/;
242
251
  var isOurs = (h) => Array.isArray(h?.hooks) && h.hooks.some((x) => typeof x.command === "string" && OURS.test(x.command));
243
- function readJson(file) {
252
+ function readJsonFile(file) {
253
+ let raw;
244
254
  try {
245
- return JSON.parse(readFileSync(file, "utf8"));
255
+ raw = readFileSync(file, "utf8");
246
256
  } catch {
247
- return {};
257
+ return { ok: true, existed: false, value: {}, raw: "", indent: " ", eol: "\n", finalNewline: true };
248
258
  }
259
+ let value;
260
+ try {
261
+ value = JSON.parse(raw);
262
+ } catch {
263
+ const hint = /^\s*\/\/|\/\*/m.test(raw) ? "not plain JSON (it has comments)" : "not plain JSON";
264
+ return { ok: false, existed: true, reason: hint };
265
+ }
266
+ if (!value || typeof value !== "object" || Array.isArray(value))
267
+ return { ok: false, existed: true, reason: "not a JSON object" };
268
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
269
+ const m = /^( +|\t+)\S/m.exec(raw);
270
+ const indent = m?.[1] ?? " ";
271
+ return {
272
+ ok: true,
273
+ existed: true,
274
+ value,
275
+ raw,
276
+ indent,
277
+ eol,
278
+ finalNewline: /\r?\n$/.test(raw)
279
+ };
280
+ }
281
+ function formatJson(value, f) {
282
+ const text = JSON.stringify(value, null, f.indent).replace(/\n/g, f.eol);
283
+ return f.finalNewline ? text + f.eol : text;
249
284
  }
250
285
  function entryFor(event, harness, cmd2, o) {
251
286
  const matcher = o.matcherFor(event);
@@ -255,9 +290,22 @@ function entryFor(event, harness, cmd2, o) {
255
290
  };
256
291
  }
257
292
  function installClaudeShaped(file, harness, events, cmd2, o) {
258
- const settings2 = readJson(file);
293
+ const f = readJsonFile(file);
294
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
295
+ const settings2 = f.value;
259
296
  const hooks = settings2.hooks && typeof settings2.hooks === "object" ? settings2.hooks : {};
260
297
  let changed = false;
298
+ const want = new Set(events);
299
+ for (const ev of Object.keys(hooks)) {
300
+ if (want.has(ev)) continue;
301
+ const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
302
+ const kept = list.filter((h) => !isOurs(h));
303
+ if (kept.length !== list.length) {
304
+ changed = true;
305
+ if (kept.length) hooks[ev] = kept;
306
+ else delete hooks[ev];
307
+ }
308
+ }
261
309
  for (const ev of events) {
262
310
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
263
311
  const entry2 = entryFor(ev, harness, cmd2, o);
@@ -276,19 +324,19 @@ function installClaudeShaped(file, harness, events, cmd2, o) {
276
324
  if (changed) {
277
325
  settings2.hooks = hooks;
278
326
  mkdirSync(path3.dirname(file), { recursive: true });
279
- writeFileSync(file, `${JSON.stringify(settings2, null, 2)}
280
- `);
327
+ writeFileSync(file, formatJson(settings2, f));
281
328
  }
282
- return { file, changed };
329
+ return { file, changed, created: !f.existed && changed };
283
330
  }
284
- function uninstallClaudeShaped(file, events) {
331
+ function uninstallClaudeShaped(file, events, opts = {}) {
285
332
  if (!existsSync(file)) return { file, changed: false };
286
- const raw = readFileSync(file, "utf8");
287
- const settings2 = readJson(file);
333
+ const f = readJsonFile(file);
334
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
335
+ const settings2 = f.value;
288
336
  const hooks = settings2.hooks;
289
337
  if (!hooks || typeof hooks !== "object") return { file, changed: false };
290
338
  let changed = false;
291
- for (const ev of events) {
339
+ for (const ev of [...events, ...Object.keys(hooks)]) {
292
340
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
293
341
  const kept = list.filter((h) => !isOurs(h));
294
342
  if (kept.length !== list.length) {
@@ -299,13 +347,17 @@ function uninstallClaudeShaped(file, events) {
299
347
  }
300
348
  if (!changed) return { file, changed: false };
301
349
  if (!Object.keys(hooks).length) delete settings2.hooks;
302
- const next = `${JSON.stringify(settings2, null, 2)}
303
- `;
304
- if (next !== raw) writeFileSync(file, next);
350
+ if (opts.created && !Object.keys(settings2).length) {
351
+ unlinkSync(file);
352
+ return { file, changed: true, note: "removed (sessionpipe created it)" };
353
+ }
354
+ const next = formatJson(settings2, f);
355
+ if (next !== f.raw) writeFileSync(file, next);
305
356
  return { file, changed: true };
306
357
  }
307
358
  function installedClaudeShaped(file, harness, events, cmd2, o) {
308
- const hooks = readJson(file).hooks ?? {};
359
+ const f = readJsonFile(file);
360
+ const hooks = f.ok ? f.value.hooks ?? {} : {};
309
361
  let present = 0;
310
362
  let current = 0;
311
363
  for (const ev of events) {
@@ -314,6 +366,9 @@ function installedClaudeShaped(file, harness, events, cmd2, o) {
314
366
  if (mine.length) present++;
315
367
  if (mine.length === 1 && JSON.stringify(mine[0]) === JSON.stringify(entryFor(ev, harness, cmd2, o))) current++;
316
368
  }
369
+ for (const ev of Object.keys(hooks))
370
+ if (!events.includes(ev) && (Array.isArray(hooks[ev]) ? hooks[ev] : []).some((h) => isOurs(h)))
371
+ return "stale";
317
372
  if (!present) return "missing";
318
373
  return current === events.length ? "current" : "stale";
319
374
  }
@@ -328,10 +383,13 @@ var projectsDir = (env) => path4.join(gemini(env), "config", "projects");
328
383
  var dataDirs = (env) => ["antigravity", "antigravity-cli", "antigravity-ide"].map((d) => path4.join(gemini(env), d));
329
384
  var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
330
385
  var validTool = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
331
- function entry(cmd2) {
386
+ function entry(cmd2, lean) {
332
387
  const handler = (e) => ({ type: "command", command: cmd2([NAME, e]).command, timeout: 5 });
333
388
  const out2 = { enabled: true };
334
- for (const e of AG_EVENTS) out2[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
389
+ for (const e of AG_EVENTS) {
390
+ if (lean && e === "PreToolUse") continue;
391
+ out2[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
392
+ }
335
393
  return out2;
336
394
  }
337
395
  var antigravity = {
@@ -339,34 +397,49 @@ var antigravity = {
339
397
  events: AG_EVENTS,
340
398
  detect: (env = process.env) => existsSync2(path4.join(gemini(env), "config")) || dataDirs(env).some((d) => existsSync2(d)),
341
399
  configFiles: (env = process.env) => [hooksFile(env)],
342
- install(cmd2, env = process.env) {
400
+ install(cmd2, env = process.env, opts = {}) {
343
401
  const file = hooksFile(env);
344
- const hooks = readJson(file);
345
- const e = entry(cmd2);
402
+ const f = readJsonFile(file);
403
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
404
+ const hooks = f.value;
405
+ const e = entry(cmd2, opts.lean);
346
406
  if (JSON.stringify(hooks[HOOK_NAME]) === JSON.stringify(e)) return [{ file, changed: false }];
347
407
  hooks[HOOK_NAME] = e;
348
408
  mkdirSync2(path4.dirname(file), { recursive: true });
349
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
350
- `);
409
+ writeFileSync2(file, formatJson(hooks, f));
351
410
  return [
352
- { file, changed: true, note: "hooks.json is read when a conversation starts; open ones report after a restart" }
411
+ {
412
+ file,
413
+ changed: true,
414
+ created: !f.existed,
415
+ note: "hooks.json is read when a conversation starts; open ones report after a restart"
416
+ }
353
417
  ];
354
418
  },
355
- uninstall(env = process.env) {
419
+ uninstall(env = process.env, opts = {}) {
356
420
  const file = hooksFile(env);
357
421
  if (!existsSync2(file)) return [{ file, changed: false }];
358
- const hooks = readJson(file);
422
+ const f = readJsonFile(file);
423
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
424
+ const hooks = f.value;
359
425
  if (!(HOOK_NAME in hooks)) return [{ file, changed: false }];
360
426
  delete hooks[HOOK_NAME];
361
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
362
- `);
427
+ if (opts.created?.includes(file) && !Object.keys(hooks).length) {
428
+ unlinkSync2(file);
429
+ return [{ file, changed: true, note: "removed (sessionpipe created it)" }];
430
+ }
431
+ writeFileSync2(file, formatJson(hooks, f));
363
432
  return [{ file, changed: true }];
364
433
  },
365
- installed(cmd2, env = process.env) {
434
+ installed(cmd2, env = process.env, opts = {}) {
366
435
  const file = hooksFile(env);
367
- const have = readJson(file)[HOOK_NAME];
436
+ const f = readJsonFile(file);
437
+ const have = f.ok ? f.value[HOOK_NAME] : void 0;
368
438
  return [
369
- { file, state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd2)) ? "current" : "stale" }
439
+ {
440
+ file,
441
+ state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd2, opts.lean)) ? "current" : "stale"
442
+ }
370
443
  ];
371
444
  },
372
445
  fromHook(input) {
@@ -632,8 +705,11 @@ var CLAUDE_EVENTS = [
632
705
  "SubagentStop",
633
706
  "PreCompact",
634
707
  "PostCompact",
635
- "SessionEnd"
708
+ "SessionEnd",
709
+ "StopFailure"
636
710
  ];
711
+ var LEAN_SKIP = /* @__PURE__ */ new Set(["PreToolUse", "PostToolUseFailure"]);
712
+ var eventsFor = (lean) => lean ? CLAUDE_EVENTS.filter((e) => !LEAN_SKIP.has(e)) : [...CLAUDE_EVENTS];
637
713
  var OPTS = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
638
714
  var DESKTOP_ID = /^local_[A-Za-z0-9-]{1,64}$/;
639
715
  function claudeDirs(env = process.env, home = HOME) {
@@ -665,11 +741,13 @@ var claudeCode = {
665
741
  events: CLAUDE_EVENTS,
666
742
  detect: (env = process.env) => claudeDirs(env).some((d) => existsSync3(d)),
667
743
  configFiles: (env = process.env) => claudeDirs(env).map(settingsIn),
668
- install: (cmd2, env = process.env) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, CLAUDE_EVENTS, cmd2, OPTS)),
669
- uninstall: (env = process.env) => claudeDirs(env).map((d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS)),
670
- installed: (cmd2, env = process.env) => claudeDirs(env).map((d) => ({
744
+ install: (cmd2, env = process.env, opts = {}) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd2, OPTS)),
745
+ uninstall: (env = process.env, opts = {}) => claudeDirs(env).map(
746
+ (d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS, { created: opts.created?.includes(settingsIn(d)) })
747
+ ),
748
+ installed: (cmd2, env = process.env, opts = {}) => claudeDirs(env).map((d) => ({
671
749
  file: settingsIn(d),
672
- state: installedClaudeShaped(settingsIn(d), NAME2, CLAUDE_EVENTS, cmd2, OPTS)
750
+ state: installedClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd2, OPTS)
673
751
  })),
674
752
  fromHook(input) {
675
753
  const event = input.argv[1] ?? "";
@@ -760,6 +838,15 @@ var claudeCode = {
760
838
  case "Stop":
761
839
  events.push(ev("turn.ended", { turn_id, reason: "stop" }));
762
840
  break;
841
+ case "StopFailure":
842
+ events.push(
843
+ ev("turn.ended", {
844
+ turn_id,
845
+ reason: "error",
846
+ error: typeof s.error === "string" ? s.error.slice(0, 200) : void 0
847
+ })
848
+ );
849
+ break;
763
850
  case "SubagentStart":
764
851
  events.push(
765
852
  ev("subagent.started", {
@@ -1271,6 +1358,7 @@ var CODEX_EVENTS = [
1271
1358
  "SessionEnd"
1272
1359
  ];
1273
1360
  var OPTS2 = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
1361
+ var eventsFor2 = (lean) => lean ? CODEX_EVENTS.filter((e) => e !== "PreToolUse") : [...CODEX_EVENTS];
1274
1362
  var codexHome = (env) => env.CODEX_HOME || path6.join(HOME, ".codex");
1275
1363
  var hooksFile2 = (env) => path6.join(codexHome(env), "hooks.json");
1276
1364
  var configFile2 = (env) => path6.join(codexHome(env), "config.toml");
@@ -1319,7 +1407,8 @@ function installNotify(cmd2, env) {
1319
1407
  return {
1320
1408
  file,
1321
1409
  changed: false,
1322
- note: `left alone: notify runs ${n.raw.slice(0, 80)} (hooks.json carries the events; the notify fallback is off)`
1410
+ skipped: true,
1411
+ note: `notify belongs to ${(n.argv?.[0] ?? n.raw).replace(/^.*\//, "").slice(0, 40)}; left alone. Codex will report nothing until you approve the hooks: start \`codex\` and accept the hook review`
1323
1412
  };
1324
1413
  }
1325
1414
  write(withNotify(text, line));
@@ -1376,14 +1465,21 @@ var codex = {
1376
1465
  events: CODEX_EVENTS,
1377
1466
  detect: (env = process.env) => existsSync4(codexHome(env)),
1378
1467
  configFiles: (env = process.env) => [hooksFile2(env), configFile2(env)],
1379
- install: (cmd2, env = process.env) => [
1380
- installClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd2, OPTS2),
1468
+ install: (cmd2, env = process.env, opts = {}) => [
1469
+ installClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd2, OPTS2),
1381
1470
  installNotify(cmd2, env)
1382
1471
  ],
1383
- uninstall: (env = process.env) => [uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS), uninstallNotify(env)],
1384
- installed: (cmd2, env = process.env) => {
1472
+ uninstall: (env = process.env, opts = {}) => [
1473
+ uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS, { created: opts.created?.includes(hooksFile2(env)) }),
1474
+ uninstallNotify(env)
1475
+ ],
1476
+ installed: (cmd2, env = process.env, opts = {}) => {
1385
1477
  const out2 = [
1386
- { file: hooksFile2(env), state: installedClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd2, OPTS2) }
1478
+ {
1479
+ file: hooksFile2(env),
1480
+ state: installedClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd2, OPTS2),
1481
+ note: "Codex runs non-managed hooks only after the review in `codex` (/hooks); until then nothing is reported unless the notify fallback is ours"
1482
+ }
1387
1483
  ];
1388
1484
  let text = "";
1389
1485
  try {
@@ -1571,6 +1667,7 @@ function codexFacts(file) {
1571
1667
  }
1572
1668
 
1573
1669
  // ../core/src/adapters/gemini-cli.ts
1670
+ import { execFileSync as execFileSync3 } from "node:child_process";
1574
1671
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
1575
1672
  import path7 from "node:path";
1576
1673
  var NAME4 = "gemini-cli";
@@ -1588,17 +1685,61 @@ var OPTS3 = { matcherFor: (e) => /Tool/.test(e) ? ".*" : void 0, timeout: 5e3 };
1588
1685
  var geminiDir = (env) => env.GEMINI_CLI_HOME || path7.join(HOME, ".gemini");
1589
1686
  var settings = (env) => path7.join(geminiDir(env), "settings.json");
1590
1687
  var validTool4 = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
1688
+ var eventsFor3 = (lean) => lean ? GEMINI_EVENTS.filter((e) => e !== "BeforeTool") : [...GEMINI_EVENTS];
1689
+ function geminiHooksActive(settingsFile) {
1690
+ let tools;
1691
+ try {
1692
+ tools = JSON.parse(readFileSync5(settingsFile, "utf8")).tools;
1693
+ } catch {
1694
+ }
1695
+ if (tools?.enableHooks === true) return { active: true };
1696
+ if (tools?.enableHooks === false)
1697
+ return { active: false, note: "inactive: tools.enableHooks is false in settings.json" };
1698
+ let version = "";
1699
+ try {
1700
+ version = execFileSync3("gemini", ["--version"], {
1701
+ encoding: "utf8",
1702
+ timeout: 4e3,
1703
+ stdio: ["ignore", "pipe", "ignore"]
1704
+ }).trim();
1705
+ } catch {
1706
+ return {
1707
+ active: true,
1708
+ note: "could not run `gemini --version`; hooks need tools.enableHooks on versions before 0.46"
1709
+ };
1710
+ }
1711
+ const m = /(\d+)\.(\d+)/.exec(version);
1712
+ if (m && (Number(m[1]) > 0 || Number(m[2]) >= 46)) return { active: true };
1713
+ return {
1714
+ active: false,
1715
+ note: `inactive: Gemini CLI ${version || "?"} runs hooks only with "tools": {"enableHooks": true} in settings.json`
1716
+ };
1717
+ }
1591
1718
  var geminiCli = {
1592
1719
  name: NAME4,
1593
1720
  events: GEMINI_EVENTS,
1594
1721
  // ~/.gemini also belongs to Antigravity; Gemini CLI is here when its settings or its chat logs exist.
1595
1722
  detect: (env = process.env) => existsSync5(settings(env)) || existsSync5(path7.join(geminiDir(env), "tmp")),
1596
1723
  configFiles: (env = process.env) => [settings(env)],
1597
- install: (cmd2, env = process.env) => [installClaudeShaped(settings(env), NAME4, GEMINI_EVENTS, cmd2, OPTS3)],
1598
- uninstall: (env = process.env) => [uninstallClaudeShaped(settings(env), GEMINI_EVENTS)],
1599
- installed: (cmd2, env = process.env) => [
1600
- { file: settings(env), state: installedClaudeShaped(settings(env), NAME4, GEMINI_EVENTS, cmd2, OPTS3) }
1724
+ install: (cmd2, env = process.env, opts = {}) => {
1725
+ const r = installClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd2, OPTS3);
1726
+ const a = geminiHooksActive(settings(env));
1727
+ return [a.note && !r.skipped ? { ...r, note: [r.note, a.note].filter(Boolean).join("; ") } : r];
1728
+ },
1729
+ uninstall: (env = process.env, opts = {}) => [
1730
+ uninstallClaudeShaped(settings(env), GEMINI_EVENTS, { created: opts.created?.includes(settings(env)) })
1601
1731
  ],
1732
+ installed: (cmd2, env = process.env, opts = {}) => {
1733
+ const state2 = installedClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd2, OPTS3);
1734
+ const a = geminiHooksActive(settings(env));
1735
+ return [
1736
+ {
1737
+ file: settings(env),
1738
+ state: state2 === "current" && !a.active ? "inactive" : state2,
1739
+ ...a.note ? { note: a.note } : {}
1740
+ }
1741
+ ];
1742
+ },
1602
1743
  fromHook(input) {
1603
1744
  const event = input.argv[1] ?? "";
1604
1745
  let s = {};
@@ -1809,7 +1950,7 @@ import {
1809
1950
  readSync as readSync2,
1810
1951
  renameSync,
1811
1952
  statSync as statSync4,
1812
- unlinkSync,
1953
+ unlinkSync as unlinkSync3,
1813
1954
  writeFileSync as writeFileSync5
1814
1955
  } from "node:fs";
1815
1956
  import path9 from "node:path";
@@ -1837,8 +1978,8 @@ var Outbox = class {
1837
1978
  } catch {
1838
1979
  n = this.lastSeqInFile(ref) + 1;
1839
1980
  }
1840
- mkdirSync5(path9.dirname(f), { recursive: true });
1841
- writeFileSync5(f, String(n + 1));
1981
+ mkdirSync5(path9.dirname(f), { recursive: true, mode: 448 });
1982
+ writeFileSync5(f, String(n + 1), { mode: 384 });
1842
1983
  return n;
1843
1984
  }
1844
1985
  lastSeqInFile(ref) {
@@ -1856,9 +1997,9 @@ var Outbox = class {
1856
1997
  }
1857
1998
  append(ref, events) {
1858
1999
  if (!events.length) return;
1859
- mkdirSync5(this.dir(ref.harness), { recursive: true });
2000
+ mkdirSync5(this.dir(ref.harness), { recursive: true, mode: 448 });
1860
2001
  appendFileSync(this.file(ref), `${events.map((e) => JSON.stringify(e)).join("\n")}
1861
- `);
2002
+ `, { mode: 384 });
1862
2003
  }
1863
2004
  /** Every session file, newest first. */
1864
2005
  sessions() {
@@ -1922,9 +2063,9 @@ var Outbox = class {
1922
2063
  const f = this.file(ref);
1923
2064
  try {
1924
2065
  if (statSync4(f).mtimeMs < cutoff) {
1925
- unlinkSync(f);
2066
+ unlinkSync3(f);
1926
2067
  try {
1927
- unlinkSync(this.seqFile(ref));
2068
+ unlinkSync3(this.seqFile(ref));
1928
2069
  } catch {
1929
2070
  }
1930
2071
  n++;
@@ -1937,7 +2078,7 @@ var Outbox = class {
1937
2078
  delete(ref) {
1938
2079
  for (const f of [this.file(ref), this.seqFile(ref)]) {
1939
2080
  try {
1940
- unlinkSync(f);
2081
+ unlinkSync3(f);
1941
2082
  } catch {
1942
2083
  }
1943
2084
  }
@@ -1962,9 +2103,9 @@ var Cursors = class {
1962
2103
  }
1963
2104
  write(c) {
1964
2105
  const f = this.file();
1965
- mkdirSync5(path9.dirname(f), { recursive: true });
2106
+ mkdirSync5(path9.dirname(f), { recursive: true, mode: 448 });
1966
2107
  const tmp = `${f}.${process.pid}.tmp`;
1967
- writeFileSync5(tmp, JSON.stringify(c));
2108
+ writeFileSync5(tmp, JSON.stringify(c), { mode: 384 });
1968
2109
  renameSync(tmp, f);
1969
2110
  }
1970
2111
  key(ref) {
@@ -1973,10 +2114,10 @@ var Cursors = class {
1973
2114
  };
1974
2115
  function takeLock(root, ref, staleMs = 5 * 6e4) {
1975
2116
  const dir = path9.join(root, "locks");
1976
- mkdirSync5(dir, { recursive: true });
2117
+ mkdirSync5(dir, { recursive: true, mode: 448 });
1977
2118
  const f = path9.join(dir, `${safeId(ref.harness)}-${safeId(ref.session)}.lock`);
1978
2119
  try {
1979
- if (Date.now() - statSync4(f).mtimeMs > staleMs) unlinkSync(f);
2120
+ if (Date.now() - statSync4(f).mtimeMs > staleMs) unlinkSync3(f);
1980
2121
  else return null;
1981
2122
  } catch {
1982
2123
  }
@@ -1987,7 +2128,7 @@ function takeLock(root, ref, staleMs = 5 * 6e4) {
1987
2128
  }
1988
2129
  return () => {
1989
2130
  try {
1990
- unlinkSync(f);
2131
+ unlinkSync3(f);
1991
2132
  } catch {
1992
2133
  }
1993
2134
  };
@@ -2123,7 +2264,7 @@ function redactAssignments(text) {
2123
2264
  function isSecretName(name) {
2124
2265
  if (/^[A-Z0-9_]+_KEY$/.test(name)) return true;
2125
2266
  const flat = name.toLowerCase().replace(/[_-]/g, "");
2126
- return /(?:pass(?:word|wd|phrase|code)?|pwd|secret|secretkey|secretaccesskey|clientsecret|apikey|accesskey|accesstoken|authtoken|refreshtoken|privatekey|token|credential|creds)s?$/.test(
2267
+ return /(?:pass(?:word|wd|phrase|code)|pwd|secret|secretkey|secretaccesskey|clientsecret|apikey|accesskey|accesstoken|authtoken|refreshtoken|privatekey|token|credential|creds)s?$/.test(
2127
2268
  flat
2128
2269
  );
2129
2270
  }
@@ -2212,7 +2353,7 @@ var DATA_ALLOW = {
2212
2353
  "session.backfill": { 0: ["started_at", "last_at", "turns"] },
2213
2354
  "session.forgotten": { 0: [] },
2214
2355
  "turn.started": { 0: ["turn_id"], 1: ["prompt_chars"] },
2215
- "turn.ended": { 0: ["turn_id", "reason", "ms"] },
2356
+ "turn.ended": { 0: ["turn_id", "reason", "ms"], 1: ["error"] },
2216
2357
  "turn.transcript": { 2: ["turn", "user", "assistant", "at"] },
2217
2358
  "tool.started": { 1: ["tool", "call_id", "turn_id", "agent_id"], 3: ["input"] },
2218
2359
  "tool.ended": { 1: ["tool", "call_id", "turn_id", "agent_id", "ms", "ok", "error"], 3: ["input", "output"] },
@@ -2334,7 +2475,17 @@ var StdoutSink = class {
2334
2475
  };
2335
2476
 
2336
2477
  // src/run.ts
2337
- import { appendFileSync as appendFileSync3, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync8, renameSync as renameSync2, statSync as statSync5, writeFileSync as writeFileSync6 } from "node:fs";
2478
+ import {
2479
+ appendFileSync as appendFileSync3,
2480
+ existsSync as existsSync8,
2481
+ mkdirSync as mkdirSync7,
2482
+ readdirSync as readdirSync6,
2483
+ readFileSync as readFileSync8,
2484
+ renameSync as renameSync2,
2485
+ statSync as statSync5,
2486
+ unlinkSync as unlinkSync4,
2487
+ writeFileSync as writeFileSync6
2488
+ } from "node:fs";
2338
2489
  import os2 from "node:os";
2339
2490
  import path11 from "node:path";
2340
2491
 
@@ -2424,10 +2575,17 @@ var HttpSink = class {
2424
2575
  };
2425
2576
 
2426
2577
  // src/run.ts
2427
- var VERSION = true ? "0.1.0" : "0.0.0";
2578
+ var VERSION = true ? "0.1.1" : "0.0.0";
2579
+ function enqueueJob(state2, job) {
2580
+ const jobs = jobsDir(state2);
2581
+ mkdirSync7(jobs, { recursive: true, mode: 448 });
2582
+ const f = path11.join(jobs, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.json`);
2583
+ writeFileSync6(f, JSON.stringify(job), { mode: 384 });
2584
+ return f;
2585
+ }
2428
2586
  function log(state2, line) {
2429
2587
  try {
2430
- mkdirSync7(state2, { recursive: true });
2588
+ mkdirSync7(state2, { recursive: true, mode: 448 });
2431
2589
  const f = path11.join(state2, "log");
2432
2590
  try {
2433
2591
  if (statSync5(f).size > 1024 * 1024) renameSync2(f, `${f}.1`);
@@ -2452,8 +2610,8 @@ function factsState(state2) {
2452
2610
  const cur = this.get(h, s);
2453
2611
  const next = { ...cur, ...patch };
2454
2612
  if (JSON.stringify(next) === JSON.stringify(cur)) return;
2455
- mkdirSync7(path11.dirname(file(h, s)), { recursive: true });
2456
- writeFileSync6(file(h, s), JSON.stringify(next));
2613
+ mkdirSync7(path11.dirname(file(h, s)), { recursive: true, mode: 448 });
2614
+ writeFileSync6(file(h, s), JSON.stringify(next), { mode: 384 });
2457
2615
  }
2458
2616
  };
2459
2617
  }
@@ -2478,12 +2636,14 @@ async function runJob(job, opts = {}) {
2478
2636
  if (!r || !r.session.id) return { events: [], delivered: {} };
2479
2637
  const outbox = new Outbox(state2);
2480
2638
  const ref = { harness: job.harness, session: r.session.id };
2481
- const release = takeLock(state2, ref);
2639
+ let release = takeLock(state2, ref);
2640
+ for (let wait = 50; !release && wait <= 3200; wait *= 2) {
2641
+ await new Promise((res) => setTimeout(res, wait));
2642
+ release = takeLock(state2, ref);
2643
+ }
2482
2644
  if (!release) {
2483
- log(state2, `${job.harness} ${job.event}: session ${r.session.id} locked, re-queued`);
2484
- const jobs = path11.join(state2, "jobs");
2485
- mkdirSync7(jobs, { recursive: true });
2486
- writeFileSync6(path11.join(jobs, `${Date.now().toString(36)}-requeue.json`), JSON.stringify(job));
2645
+ log(state2, `${job.harness} ${job.event}: session ${r.session.id} locked for 6 s, re-queued`);
2646
+ enqueueJob(state2, job);
2487
2647
  return { events: [], delivered: {} };
2488
2648
  }
2489
2649
  try {
@@ -2547,7 +2707,10 @@ async function runJob(job, opts = {}) {
2547
2707
  if (read.turns.length)
2548
2708
  facts.save(job.harness, r.session.id, { transcript_line: read.line, transcript_turns: turnNo });
2549
2709
  }
2550
- outbox.append(ref, events);
2710
+ outbox.append(
2711
+ ref,
2712
+ events.map((e) => ({ ...e, session: redactDeep(e.session), data: redactDeep(e.data) }))
2713
+ );
2551
2714
  log(
2552
2715
  state2,
2553
2716
  `${job.harness} ${job.event}: ${events.map((e) => e.type).join(",") || "nothing"} (${r.session.id.slice(0, 8)})`
@@ -2573,34 +2736,57 @@ async function flush(state2, sinkConfigs, outbox, first) {
2573
2736
  const cursors = new Cursors(state2, sink2.name);
2574
2737
  const c = cursors.read();
2575
2738
  let sent = 0;
2739
+ let stalled = false;
2740
+ let batch = [];
2741
+ let pending = {};
2742
+ const send = async () => {
2743
+ if (!batch.length) {
2744
+ Object.assign(c, pending);
2745
+ pending = {};
2746
+ cursors.write(c);
2747
+ return true;
2748
+ }
2749
+ const res = await sink2.send(batch);
2750
+ if (!res.ok) {
2751
+ log(state2, `sink ${sink2.name}: ${res.status ?? ""} ${res.error ?? ""} \u2192 ${res.action ?? "retry"}`);
2752
+ if (res.action === "pause" || res.action === "lower-tier") applySinkVerdict(sink2.name, res);
2753
+ if (res.action !== "drop") {
2754
+ batch = [];
2755
+ pending = {};
2756
+ return false;
2757
+ }
2758
+ }
2759
+ sent += batch.length;
2760
+ Object.assign(c, pending);
2761
+ cursors.write(c);
2762
+ batch = [];
2763
+ pending = {};
2764
+ return true;
2765
+ };
2576
2766
  for (const ref of refs) {
2577
- if (sent >= MAX_PER_RUN) break;
2578
- let offset = c[cursors.key(ref)] ?? 0;
2579
- let stalled = false;
2580
- while (sent < MAX_PER_RUN) {
2581
- const { events, offset: next } = outbox.read(ref, offset, BATCH);
2767
+ if (stalled || sent >= MAX_PER_RUN) break;
2768
+ let offset = pending[cursors.key(ref)] ?? c[cursors.key(ref)] ?? 0;
2769
+ for (; ; ) {
2770
+ const room = BATCH - batch.length;
2771
+ const { events, offset: next } = outbox.read(ref, offset, room);
2582
2772
  if (!events.length) break;
2583
- const filtered = events.map((e) => filterEvent(e, sink2.tier, sink2.pii, { home: os2.homedir() })).filter((e) => !!e);
2584
- let ok = true;
2585
- if (filtered.length) {
2586
- const res = await sink2.send(filtered);
2587
- ok = res.ok;
2588
- if (!ok) {
2589
- log(state2, `sink ${sink2.name}: ${res.status ?? ""} ${res.error ?? ""} \u2192 ${res.action ?? "retry"}`);
2590
- if (res.action === "pause" || res.action === "lower-tier") applySinkVerdict(sink2.name, res);
2591
- if (res.action === "drop") ok = true;
2592
- }
2593
- if (ok) sent += filtered.length;
2594
- }
2595
- if (!ok) {
2596
- stalled = true;
2597
- break;
2773
+ for (const e of events) {
2774
+ const f = filterEvent(e, sink2.tier, sink2.pii, { home: os2.homedir() });
2775
+ if (f) batch.push(f);
2598
2776
  }
2599
2777
  offset = next;
2600
- c[cursors.key(ref)] = offset;
2601
- cursors.write(c);
2778
+ pending[cursors.key(ref)] = offset;
2779
+ if (batch.length >= BATCH) {
2780
+ if (!await send()) {
2781
+ stalled = true;
2782
+ break;
2783
+ }
2784
+ if (sent >= MAX_PER_RUN) break;
2785
+ }
2602
2786
  }
2603
- if (stalled) break;
2787
+ }
2788
+ if (!stalled && (batch.length || Object.keys(pending).length)) {
2789
+ if (!await send()) stalled = true;
2604
2790
  }
2605
2791
  delivered[sink2.name] = sent;
2606
2792
  }
@@ -2725,9 +2911,23 @@ async function install() {
2725
2911
  if (viaNpx) {
2726
2912
  out(` Installing sessionpipe@${VERSION} globally, so the hooks start in milliseconds\u2026`);
2727
2913
  try {
2728
- execFileSync3("npm", ["install", "-g", `sessionpipe@${VERSION}`], { stdio: ["ignore", "ignore", "inherit"] });
2729
- const bin = process.platform === "win32" ? "sessionpipe.cmd" : "sessionpipe";
2730
- execFileSync3(bin, args, { stdio: "inherit", shell: process.platform === "win32" });
2914
+ let prefix = "";
2915
+ try {
2916
+ prefix = execFileSync4("npm", ["prefix", "-g"], {
2917
+ encoding: "utf8",
2918
+ timeout: 15e3,
2919
+ stdio: ["ignore", "pipe", "ignore"]
2920
+ }).trim();
2921
+ } catch {
2922
+ }
2923
+ const target = /\/Cellar\/node(?:@\d+)?\/[^/]+/.test(prefix) ? path12.join(os3.homedir(), ".local") : prefix;
2924
+ execFileSync4("npm", ["install", "-g", `sessionpipe@${VERSION}`, ...target ? ["--prefix", target] : []], {
2925
+ stdio: ["ignore", "ignore", "inherit"]
2926
+ });
2927
+ const bin = process.platform === "win32" ? path12.join(target, "sessionpipe.cmd") : target ? path12.join(target, "bin", "sessionpipe") : "sessionpipe";
2928
+ if (target && target !== prefix)
2929
+ out(` Installed into ${target}; make sure ${path12.join(target, "bin")} is on your PATH.`);
2930
+ execFileSync4(bin, args, { stdio: "inherit", shell: process.platform === "win32" });
2731
2931
  return;
2732
2932
  } catch {
2733
2933
  out(
@@ -2739,6 +2939,8 @@ async function install() {
2739
2939
  const cfg = readConfig();
2740
2940
  const machine = flag("--machine");
2741
2941
  if (machine) cfg.machine = machine.slice(0, 80);
2942
+ const sinkUrl = flag("--sink");
2943
+ if (sinkUrl) await sinkAdd(sinkUrl, cfg);
2742
2944
  const adapters = selectedAdapters();
2743
2945
  if (!adapters.length) {
2744
2946
  out(
@@ -2747,24 +2949,13 @@ async function install() {
2747
2949
  return;
2748
2950
  }
2749
2951
  out(` Machine: ${machineName(cfg, os3.hostname())}`);
2750
- const hc = hookCommand();
2751
- for (const a of adapters) {
2752
- const reports = a.install(hc);
2753
- cfg.harnesses[a.name] = { enabled: true };
2754
- for (const r of reports)
2755
- out(
2756
- ` ${r.changed ? "\u2713" : "="} ${a.name}: ${tilde(r.file)}${r.note ? ` \u2014 ${r.note}` : r.changed ? " written" : " already current"}`
2757
- );
2758
- if (a.name === "gemini-cli")
2759
- out(" note: Gemini CLI's own telemetry defaults logPrompts on; that is Google's setting, not sessionpipe's.");
2760
- if (a.name === "codex")
2761
- out(
2762
- " note: Codex runs non-managed hooks only after you trust them: open Codex and run /hooks once. The notify fallback reports turn ends meanwhile."
2763
- );
2764
- }
2952
+ const lean = isLean(cfg);
2953
+ if (lean)
2954
+ out(
2955
+ " No sink above tier 0: hooking one tool event per harness (the heartbeat's). `sink add \u2026 --tier 1` or higher hooks the rest."
2956
+ );
2957
+ installHarnesses(adapters, cfg, lean);
2765
2958
  writeConfig(cfg);
2766
- const sinkUrl = flag("--sink");
2767
- if (sinkUrl) await sinkAdd(sinkUrl, cfg);
2768
2959
  const days = Number(flag("--backfill") ?? 30);
2769
2960
  if (days > 0) backfillRows(adapters, days, cfg);
2770
2961
  out("");
@@ -2776,10 +2967,41 @@ async function install() {
2776
2967
  );
2777
2968
  out(" Sessions already running pick up hooks on their next start (Claude Code \u2265 2.1.280 picks them up live).");
2778
2969
  }
2970
+ function isLean(cfg) {
2971
+ return !cfg.sinks.some((s) => !s.paused && Math.min(s.tier, s.max_tier ?? 3) >= 1);
2972
+ }
2973
+ function installHarnesses(adapters, cfg, lean) {
2974
+ const hc = hookCommand();
2975
+ for (const a of adapters) {
2976
+ const prev = cfg.harnesses[a.name] ?? {};
2977
+ const created = new Set(prev.created ?? []);
2978
+ const reports = a.install(hc, process.env, { lean, created: [...created] });
2979
+ for (const r of reports) {
2980
+ if (r.created) created.add(r.file);
2981
+ const mark = r.skipped ? "!" : r.changed ? "\u2713" : "=";
2982
+ out(
2983
+ ` ${mark} ${a.name}: ${tilde(r.file)}${r.note ? ` \u2014 ${r.note}` : r.changed ? " written" : " already current"}`
2984
+ );
2985
+ }
2986
+ cfg.harnesses[a.name] = { enabled: true, ...created.size ? { created: [...created] } : {} };
2987
+ if (a.name === "gemini-cli")
2988
+ out(" note: Gemini CLI's own telemetry defaults logPrompts on; that is Google's setting, not sessionpipe's.");
2989
+ if (a.name === "codex" && !reports.some((r) => r.skipped))
2990
+ out(
2991
+ " note: Codex runs non-managed hooks only after you approve them: start `codex` and accept the hook review. The notify fallback reports turn ends meanwhile."
2992
+ );
2993
+ }
2994
+ }
2779
2995
  function uninstall() {
2996
+ const cfg = readConfig();
2780
2997
  for (const a of ADAPTERS) {
2781
- for (const r of a.uninstall()) if (r.changed) out(` \u2713 ${a.name}: hooks removed from ${tilde(r.file)}`);
2998
+ const created = cfg.harnesses[a.name]?.created ?? [];
2999
+ for (const r of a.uninstall(process.env, { created }))
3000
+ if (r.changed) out(` \u2713 ${a.name}: ${r.note ?? `hooks removed from ${tilde(r.file)}`}`);
3001
+ else if (r.skipped) out(` ! ${a.name}: ${tilde(r.file)} \u2014 ${r.note}`);
3002
+ delete cfg.harnesses[a.name];
2782
3003
  }
3004
+ writeConfig(cfg);
2783
3005
  if (!has("--keep-state"))
2784
3006
  out(
2785
3007
  ` Outbox and state kept under ${tilde(state)} (delete it yourself, or pass nothing: it prunes after ${readConfig().keep_days ?? 30} days).`
@@ -2810,16 +3032,17 @@ async function sink() {
2810
3032
  if (!s) return out(` no sink named ${args[2]}`);
2811
3033
  const [sk] = buildSinks([s]);
2812
3034
  if (!sk) return out(" sink type not supported yet");
2813
- const e = makeEvent(
2814
- { type: "session.heartbeat", harnessEvent: "test", data: {} },
2815
- {
2816
- harness: { name: "sessionpipe" },
2817
- session: { id: `test-${Date.now()}`, seq: 0, machine: machineName(cfg, os3.hostname()) }
2818
- }
3035
+ const session = { id: `test-${Date.now()}`, machine: machineName(cfg, os3.hostname()) };
3036
+ const mk = (type, seq) => makeEvent(
3037
+ { type, harnessEvent: "test", data: {} },
3038
+ { harness: { name: "sessionpipe" }, session: { ...session, seq } }
2819
3039
  );
2820
- const r = await sk.send([filterEvent(e, sk.tier, sk.pii)]);
3040
+ const r = await sk.send([
3041
+ filterEvent(mk("session.heartbeat", 0), sk.tier, sk.pii),
3042
+ filterEvent(mk("session.forgotten", 1), sk.tier, sk.pii)
3043
+ ]);
2821
3044
  out(
2822
- r.ok ? ` \u2713 ${s.name}: ${r.status ?? "ok"} accepted ${r.accepted ?? 1}` : ` \u2717 ${s.name}: ${r.status ?? ""} ${r.error ?? ""} (${r.action})`
3045
+ r.ok ? ` \u2713 ${s.name}: ${r.status ?? "ok"} accepted ${r.accepted ?? 2} (a heartbeat and its forget)` : ` \u2717 ${s.name}: ${r.status ?? ""} ${r.error ?? ""} (${r.action})`
2823
3046
  );
2824
3047
  return;
2825
3048
  }
@@ -2946,7 +3169,10 @@ function status() {
2946
3169
  out(` machine: ${machineName(cfg, os3.hostname())} sessionpipe ${VERSION} state ${tilde(state)}`);
2947
3170
  for (const a of ADAPTERS) {
2948
3171
  if (!a.detect() && !cfg.harnesses[a.name]) continue;
2949
- for (const r of a.installed(hc)) out(` ${a.name.padEnd(12)} ${tilde(r.file).padEnd(44)} ${r.state}`);
3172
+ for (const r of a.installed(hc, process.env, { lean: isLean(cfg) }))
3173
+ out(
3174
+ ` ${a.name.padEnd(12)} ${tilde(r.file).padEnd(44)} ${r.state}${r.state !== "current" && r.note ? ` \u2014 ${r.note}` : ""}`
3175
+ );
2950
3176
  }
2951
3177
  if (!cfg.sinks.length) out(" sinks: none (events stay in the local outbox)");
2952
3178
  for (const s of cfg.sinks) {
@@ -2965,14 +3191,49 @@ function status() {
2965
3191
  );
2966
3192
  }
2967
3193
  const t = timing();
2968
- if (t) out(` hook: p50 ${t.p50} ms \xB7 p95 ${t.p95} ms over ${t.n} runs`);
3194
+ if (t)
3195
+ out(
3196
+ ` hook: in-process p50 ${t.p50} ms over ${t.n} runs (\`sessionpipe doctor\` measures the wall clock the harness waits)`
3197
+ );
2969
3198
  const jobs = jobsDir(state);
2970
3199
  try {
2971
- const n = readdirSync6(jobs).length;
3200
+ const n = readdirSync7(jobs).length;
2972
3201
  if (n) out(` jobs: ${n} waiting`);
2973
3202
  } catch {
2974
3203
  }
2975
3204
  }
3205
+ function hookWall(runs = 8) {
3206
+ const hook = path12.join(distDir, "hook.js");
3207
+ const tmp = mkdtempSync(path12.join(os3.tmpdir(), "sessionpipe-doctor-"));
3208
+ const env = {
3209
+ ...process.env,
3210
+ SESSIONPIPE_STATE: tmp,
3211
+ SESSIONPIPE_CONFIG: path12.join(tmp, "config.json"),
3212
+ SESSIONPIPE_NO_WORKER: "1"
3213
+ };
3214
+ const input = JSON.stringify({
3215
+ session_id: "doctor",
3216
+ hook_event_name: "PostToolUse",
3217
+ tool_name: "Bash",
3218
+ cwd: process.cwd()
3219
+ });
3220
+ const times = [];
3221
+ try {
3222
+ for (let i = 0; i < runs; i++) {
3223
+ const t0 = process.hrtime.bigint();
3224
+ const r = spawnSync(process.execPath, [hook, "claude-code", "PostToolUse"], { input, env, encoding: "utf8" });
3225
+ if (r.status !== 0) return null;
3226
+ times.push(Number(process.hrtime.bigint() - t0) / 1e6);
3227
+ }
3228
+ } catch {
3229
+ return null;
3230
+ } finally {
3231
+ rmSync(tmp, { recursive: true, force: true });
3232
+ }
3233
+ times.sort((a, b) => a - b);
3234
+ const q = (p) => Math.round(times[Math.min(times.length - 1, Math.floor(p * times.length))] ?? 0);
3235
+ return { n: times.length, p50: q(0.5), p95: q(0.95) };
3236
+ }
2976
3237
  function timing() {
2977
3238
  try {
2978
3239
  const lines = readFileSync9(path12.join(state, "timing.jsonl"), "utf8").trim().split("\n").slice(-100);
@@ -2997,6 +3258,7 @@ function doctor() {
2997
3258
  harnesses: {},
2998
3259
  sinks: [],
2999
3260
  timing: timing(),
3261
+ hook_wall_ms: hookWall(),
3000
3262
  warnings: []
3001
3263
  };
3002
3264
  const warnings = report.warnings;
@@ -3006,8 +3268,15 @@ function doctor() {
3006
3268
  );
3007
3269
  for (const a of ADAPTERS) {
3008
3270
  if (!a.detect()) continue;
3009
- const st = a.installed(hc);
3010
- report.harnesses[a.name] = st.map((r) => ({ file: tilde(r.file), state: r.state }));
3271
+ const st = a.installed(hc, process.env, { lean: isLean(cfg) });
3272
+ report.harnesses[a.name] = st.map((r) => ({
3273
+ file: tilde(r.file),
3274
+ state: r.state,
3275
+ ...r.note ? { note: r.note } : {}
3276
+ }));
3277
+ for (const r of st)
3278
+ if (r.state === "inactive")
3279
+ warnings.push(`${a.name}: hooks are written but the harness will not run them \u2014 ${r.note ?? ""}`);
3011
3280
  for (const r of st)
3012
3281
  if (r.state === "stale")
3013
3282
  warnings.push(
@@ -3030,9 +3299,11 @@ function doctor() {
3030
3299
  paused: s.paused ?? null,
3031
3300
  token: s.token ? `${s.token.slice(0, 4)}\u2026` : null
3032
3301
  });
3033
- const t = timing();
3034
- if (t && t.p50 > 150)
3035
- warnings.push(`hook p50 is ${t.p50} ms (target < 150). Is node on a slow disk or a network share?`);
3302
+ const w = report.hook_wall_ms;
3303
+ if (w && w.p50 > 150)
3304
+ warnings.push(
3305
+ `the harness waits ${w.p50} ms per hook (p50, target < 150): that is Node's own startup on this machine. A lean install (no sink above tier 0) hooks fewer events.`
3306
+ );
3036
3307
  if (has("--json")) {
3037
3308
  out(JSON.stringify(report, null, 2));
3038
3309
  return;
@@ -3045,8 +3316,14 @@ function doctor() {
3045
3316
  out(
3046
3317
  ` sink ${s.name.padEnd(16)} tier ${s.tier}${s.max_tier !== null ? ` (receiver max ${s.max_tier})` : ""}${s.paused ? ` PAUSED ${s.paused}` : ""}`
3047
3318
  );
3048
- out(t ? ` hook p50 ${t.p50} ms \xB7 p95 ${t.p95} ms over ${t.n} runs` : " hook timing: no runs yet");
3049
- for (const w of warnings) out(` ! ${w}`);
3319
+ const t = report.timing;
3320
+ out(
3321
+ w ? ` hook wall clock (what the harness waits): p50 ${w.p50} ms \xB7 p95 ${w.p95} ms over ${w.n} spawns` : " hook wall clock: could not spawn dist/hook.js"
3322
+ );
3323
+ out(
3324
+ t ? ` hook in-process: p50 ${t.p50} ms \xB7 p95 ${t.p95} ms over the last ${t.n} runs` : " hook in-process: no runs yet"
3325
+ );
3326
+ for (const w2 of warnings) out(` ! ${w2}`);
3050
3327
  if (!warnings.length) out(" no warnings");
3051
3328
  }
3052
3329
  async function tail() {
@@ -3120,7 +3397,7 @@ async function workerFromArgv() {
3120
3397
  if (!f) return;
3121
3398
  const job = JSON.parse(readFileSync9(f, "utf8"));
3122
3399
  try {
3123
- unlinkSync2(f);
3400
+ unlinkSync5(f);
3124
3401
  } catch {
3125
3402
  }
3126
3403
  await runJob(job);
@@ -3134,7 +3411,7 @@ async function update() {
3134
3411
  if (j.version === VERSION) return out(` sessionpipe ${VERSION} is current`);
3135
3412
  out(` ${j.version} is available (you run ${VERSION}); installing into this copy's prefix\u2026`);
3136
3413
  const prefix = prefixOf(distDir);
3137
- execFileSync3("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
3414
+ execFileSync4("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
3138
3415
  stdio: "inherit"
3139
3416
  });
3140
3417
  out(" re-arming hooks\u2026");