sessionpipe 0.1.0 → 0.2.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/dist/cli.js CHANGED
@@ -1,15 +1,29 @@
1
1
  #!/usr/bin/env node
2
- // sessionpipe 0.1.0 · Apache-2.0 · https://sessionpipe.org
2
+ // sessionpipe 0.2.0 · Apache-2.0 · https://sessionpipe.org
3
+ var __defProp = Object.defineProperty;
4
+ var __export = (target, all) => {
5
+ for (var name in all)
6
+ __defProp(target, name, { get: all[name], enumerable: true });
7
+ };
3
8
 
4
9
  // 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";
7
- import os3 from "node:os";
8
- import path12 from "node:path";
10
+ import { execFileSync as execFileSync6, spawn as spawn2, spawnSync } from "node:child_process";
11
+ import { mkdtempSync, readdirSync as readdirSync8, readFileSync as readFileSync11, realpathSync as realpathSync4, rmSync, statSync as statSync9, unlinkSync as unlinkSync7 } from "node:fs";
12
+ import os7 from "node:os";
13
+ import path18 from "node:path";
9
14
  import { fileURLToPath } from "node:url";
10
15
 
11
16
  // ../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";
17
+ import {
18
+ existsSync as existsSync2,
19
+ mkdirSync as mkdirSync2,
20
+ readdirSync as readdirSync2,
21
+ readFileSync as readFileSync2,
22
+ realpathSync,
23
+ statSync as statSync2,
24
+ unlinkSync as unlinkSync2,
25
+ writeFileSync as writeFileSync2
26
+ } from "node:fs";
13
27
  import path4 from "node:path";
14
28
 
15
29
  // ../core/src/paths.ts
@@ -236,28 +250,67 @@ function readAntigravity(lines, from) {
236
250
  }
237
251
 
238
252
  // ../core/src/adapters/claude-shaped.ts
239
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
253
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
240
254
  import path3 from "node:path";
241
255
  var OURS = /sessionpipe[^"\n]*[\\/]hook\.js"?\s+[a-z][a-z-]*\s+[A-Za-z]+\s*$/;
242
256
  var isOurs = (h) => Array.isArray(h?.hooks) && h.hooks.some((x) => typeof x.command === "string" && OURS.test(x.command));
243
- function readJson(file) {
257
+ function readJsonFile(file) {
258
+ let raw;
244
259
  try {
245
- return JSON.parse(readFileSync(file, "utf8"));
260
+ raw = readFileSync(file, "utf8");
246
261
  } catch {
247
- return {};
262
+ return { ok: true, existed: false, value: {}, raw: "", indent: " ", eol: "\n", finalNewline: true };
248
263
  }
264
+ let value;
265
+ try {
266
+ value = JSON.parse(raw);
267
+ } catch {
268
+ const hint = /^\s*\/\/|\/\*/m.test(raw) ? "not plain JSON (it has comments)" : "not plain JSON";
269
+ return { ok: false, existed: true, reason: hint };
270
+ }
271
+ if (!value || typeof value !== "object" || Array.isArray(value))
272
+ return { ok: false, existed: true, reason: "not a JSON object" };
273
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
274
+ const m = /^( +|\t+)\S/m.exec(raw);
275
+ const indent = m?.[1] ?? " ";
276
+ return {
277
+ ok: true,
278
+ existed: true,
279
+ value,
280
+ raw,
281
+ indent,
282
+ eol,
283
+ finalNewline: /\r?\n$/.test(raw)
284
+ };
285
+ }
286
+ function formatJson(value, f) {
287
+ const text = JSON.stringify(value, null, f.indent).replace(/\n/g, f.eol);
288
+ return f.finalNewline ? text + f.eol : text;
249
289
  }
250
290
  function entryFor(event, harness, cmd2, o) {
251
291
  const matcher = o.matcherFor(event);
252
292
  return {
253
293
  ...matcher !== void 0 ? { matcher } : {},
254
- hooks: [{ type: "command", command: cmd2([harness, event]).command, timeout: o.timeout }]
294
+ hooks: [{ type: "command", command: cmd2([harness, event]).command, timeout: o.timeoutFor?.(event) ?? o.timeout }]
255
295
  };
256
296
  }
257
297
  function installClaudeShaped(file, harness, events, cmd2, o) {
258
- const settings2 = readJson(file);
298
+ const f = readJsonFile(file);
299
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
300
+ const settings2 = f.value;
259
301
  const hooks = settings2.hooks && typeof settings2.hooks === "object" ? settings2.hooks : {};
260
302
  let changed = false;
303
+ const want = new Set(events);
304
+ for (const ev of Object.keys(hooks)) {
305
+ if (want.has(ev)) continue;
306
+ const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
307
+ const kept = list.filter((h) => !isOurs(h));
308
+ if (kept.length !== list.length) {
309
+ changed = true;
310
+ if (kept.length) hooks[ev] = kept;
311
+ else delete hooks[ev];
312
+ }
313
+ }
261
314
  for (const ev of events) {
262
315
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
263
316
  const entry2 = entryFor(ev, harness, cmd2, o);
@@ -276,19 +329,19 @@ function installClaudeShaped(file, harness, events, cmd2, o) {
276
329
  if (changed) {
277
330
  settings2.hooks = hooks;
278
331
  mkdirSync(path3.dirname(file), { recursive: true });
279
- writeFileSync(file, `${JSON.stringify(settings2, null, 2)}
280
- `);
332
+ writeFileSync(file, formatJson(settings2, f));
281
333
  }
282
- return { file, changed };
334
+ return { file, changed, created: !f.existed && changed };
283
335
  }
284
- function uninstallClaudeShaped(file, events) {
336
+ function uninstallClaudeShaped(file, events, opts = {}) {
285
337
  if (!existsSync(file)) return { file, changed: false };
286
- const raw = readFileSync(file, "utf8");
287
- const settings2 = readJson(file);
338
+ const f = readJsonFile(file);
339
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
340
+ const settings2 = f.value;
288
341
  const hooks = settings2.hooks;
289
342
  if (!hooks || typeof hooks !== "object") return { file, changed: false };
290
343
  let changed = false;
291
- for (const ev of events) {
344
+ for (const ev of [...events, ...Object.keys(hooks)]) {
292
345
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
293
346
  const kept = list.filter((h) => !isOurs(h));
294
347
  if (kept.length !== list.length) {
@@ -299,13 +352,17 @@ function uninstallClaudeShaped(file, events) {
299
352
  }
300
353
  if (!changed) return { file, changed: false };
301
354
  if (!Object.keys(hooks).length) delete settings2.hooks;
302
- const next = `${JSON.stringify(settings2, null, 2)}
303
- `;
304
- if (next !== raw) writeFileSync(file, next);
355
+ if (opts.created && !Object.keys(settings2).length) {
356
+ unlinkSync(file);
357
+ return { file, changed: true, note: "removed (sessionpipe created it)" };
358
+ }
359
+ const next = formatJson(settings2, f);
360
+ if (next !== f.raw) writeFileSync(file, next);
305
361
  return { file, changed: true };
306
362
  }
307
363
  function installedClaudeShaped(file, harness, events, cmd2, o) {
308
- const hooks = readJson(file).hooks ?? {};
364
+ const f = readJsonFile(file);
365
+ const hooks = f.ok ? f.value.hooks ?? {} : {};
309
366
  let present = 0;
310
367
  let current = 0;
311
368
  for (const ev of events) {
@@ -314,6 +371,9 @@ function installedClaudeShaped(file, harness, events, cmd2, o) {
314
371
  if (mine.length) present++;
315
372
  if (mine.length === 1 && JSON.stringify(mine[0]) === JSON.stringify(entryFor(ev, harness, cmd2, o))) current++;
316
373
  }
374
+ for (const ev of Object.keys(hooks))
375
+ if (!events.includes(ev) && (Array.isArray(hooks[ev]) ? hooks[ev] : []).some((h) => isOurs(h)))
376
+ return "stale";
317
377
  if (!present) return "missing";
318
378
  return current === events.length ? "current" : "stale";
319
379
  }
@@ -328,10 +388,13 @@ var projectsDir = (env) => path4.join(gemini(env), "config", "projects");
328
388
  var dataDirs = (env) => ["antigravity", "antigravity-cli", "antigravity-ide"].map((d) => path4.join(gemini(env), d));
329
389
  var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
330
390
  var validTool = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
331
- function entry(cmd2) {
391
+ function entry(cmd2, lean) {
332
392
  const handler = (e) => ({ type: "command", command: cmd2([NAME, e]).command, timeout: 5 });
333
393
  const out2 = { enabled: true };
334
- for (const e of AG_EVENTS) out2[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
394
+ for (const e of AG_EVENTS) {
395
+ if (lean && e === "PreToolUse") continue;
396
+ out2[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
397
+ }
335
398
  return out2;
336
399
  }
337
400
  var antigravity = {
@@ -339,34 +402,49 @@ var antigravity = {
339
402
  events: AG_EVENTS,
340
403
  detect: (env = process.env) => existsSync2(path4.join(gemini(env), "config")) || dataDirs(env).some((d) => existsSync2(d)),
341
404
  configFiles: (env = process.env) => [hooksFile(env)],
342
- install(cmd2, env = process.env) {
405
+ install(cmd2, env = process.env, opts = {}) {
343
406
  const file = hooksFile(env);
344
- const hooks = readJson(file);
345
- const e = entry(cmd2);
407
+ const f = readJsonFile(file);
408
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
409
+ const hooks = f.value;
410
+ const e = entry(cmd2, opts.lean);
346
411
  if (JSON.stringify(hooks[HOOK_NAME]) === JSON.stringify(e)) return [{ file, changed: false }];
347
412
  hooks[HOOK_NAME] = e;
348
413
  mkdirSync2(path4.dirname(file), { recursive: true });
349
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
350
- `);
414
+ writeFileSync2(file, formatJson(hooks, f));
351
415
  return [
352
- { file, changed: true, note: "hooks.json is read when a conversation starts; open ones report after a restart" }
416
+ {
417
+ file,
418
+ changed: true,
419
+ created: !f.existed,
420
+ note: "hooks.json is read when a conversation starts; open ones report after a restart"
421
+ }
353
422
  ];
354
423
  },
355
- uninstall(env = process.env) {
424
+ uninstall(env = process.env, opts = {}) {
356
425
  const file = hooksFile(env);
357
426
  if (!existsSync2(file)) return [{ file, changed: false }];
358
- const hooks = readJson(file);
427
+ const f = readJsonFile(file);
428
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
429
+ const hooks = f.value;
359
430
  if (!(HOOK_NAME in hooks)) return [{ file, changed: false }];
360
431
  delete hooks[HOOK_NAME];
361
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
362
- `);
432
+ if (opts.created?.includes(file) && !Object.keys(hooks).length) {
433
+ unlinkSync2(file);
434
+ return [{ file, changed: true, note: "removed (sessionpipe created it)" }];
435
+ }
436
+ writeFileSync2(file, formatJson(hooks, f));
363
437
  return [{ file, changed: true }];
364
438
  },
365
- installed(cmd2, env = process.env) {
439
+ installed(cmd2, env = process.env, opts = {}) {
366
440
  const file = hooksFile(env);
367
- const have = readJson(file)[HOOK_NAME];
441
+ const f = readJsonFile(file);
442
+ const have = f.ok ? f.value[HOOK_NAME] : void 0;
368
443
  return [
369
- { file, state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd2)) ? "current" : "stale" }
444
+ {
445
+ file,
446
+ state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd2, opts.lean)) ? "current" : "stale"
447
+ }
370
448
  ];
371
449
  },
372
450
  fromHook(input) {
@@ -632,9 +710,18 @@ var CLAUDE_EVENTS = [
632
710
  "SubagentStop",
633
711
  "PreCompact",
634
712
  "PostCompact",
635
- "SessionEnd"
713
+ "SessionEnd",
714
+ "StopFailure"
636
715
  ];
637
- var OPTS = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
716
+ var LEAN_SKIP = /* @__PURE__ */ new Set(["PreToolUse", "PostToolUseFailure"]);
717
+ var eventsFor = (lean) => lean ? CLAUDE_EVENTS.filter((e) => !LEAN_SKIP.has(e)) : [...CLAUDE_EVENTS];
718
+ var OPTS = {
719
+ matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0,
720
+ timeout: 5,
721
+ // The hook waits up to 125 s for a verified control answer, and only when control
722
+ // is paired on this machine; everywhere else it exits in milliseconds as before.
723
+ timeoutFor: (e) => e === "PermissionRequest" ? 130 : void 0
724
+ };
638
725
  var DESKTOP_ID = /^local_[A-Za-z0-9-]{1,64}$/;
639
726
  function claudeDirs(env = process.env, home = HOME) {
640
727
  const out2 = [];
@@ -665,11 +752,13 @@ var claudeCode = {
665
752
  events: CLAUDE_EVENTS,
666
753
  detect: (env = process.env) => claudeDirs(env).some((d) => existsSync3(d)),
667
754
  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) => ({
755
+ install: (cmd2, env = process.env, opts = {}) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd2, OPTS)),
756
+ uninstall: (env = process.env, opts = {}) => claudeDirs(env).map(
757
+ (d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS, { created: opts.created?.includes(settingsIn(d)) })
758
+ ),
759
+ installed: (cmd2, env = process.env, opts = {}) => claudeDirs(env).map((d) => ({
671
760
  file: settingsIn(d),
672
- state: installedClaudeShaped(settingsIn(d), NAME2, CLAUDE_EVENTS, cmd2, OPTS)
761
+ state: installedClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd2, OPTS)
673
762
  })),
674
763
  fromHook(input) {
675
764
  const event = input.argv[1] ?? "";
@@ -760,6 +849,15 @@ var claudeCode = {
760
849
  case "Stop":
761
850
  events.push(ev("turn.ended", { turn_id, reason: "stop" }));
762
851
  break;
852
+ case "StopFailure":
853
+ events.push(
854
+ ev("turn.ended", {
855
+ turn_id,
856
+ reason: "error",
857
+ error: typeof s.error === "string" ? s.error.slice(0, 200) : void 0
858
+ })
859
+ );
860
+ break;
763
861
  case "SubagentStart":
764
862
  events.push(
765
863
  ev("subagent.started", {
@@ -1067,9 +1165,9 @@ function claudeFacts(file, sessionId, hints, state2) {
1067
1165
  }
1068
1166
  const name = live?.name ? { title: live.name, source: live.name_source } : typeof mem.name === "string" ? { title: mem.name, source: mem.name_source } : null;
1069
1167
  const pick = name && name.source === "custom" && name || custom && { title: custom, source: "custom" } || name || ai && { title: ai, source: "harness" };
1070
- let ask = typeof mem.first_ask === "string" ? mem.first_ask : firstAsk(H);
1071
- if (!ask && size > 64 * 1024 && !mem.first_ask_none) {
1072
- ask = scanLines(file, (l) => {
1168
+ let ask2 = typeof mem.first_ask === "string" ? mem.first_ask : firstAsk(H);
1169
+ if (!ask2 && size > 64 * 1024 && !mem.first_ask_none) {
1170
+ ask2 = scanLines(file, (l) => {
1073
1171
  try {
1074
1172
  const t = askText(JSON.parse(l));
1075
1173
  return t ? appTitle(t) : null;
@@ -1077,17 +1175,17 @@ function claudeFacts(file, sessionId, hints, state2) {
1077
1175
  return null;
1078
1176
  }
1079
1177
  });
1080
- if (!ask && size > 16 * 1024 * 1024) state2.save(NAME2, id, { first_ask_none: true });
1178
+ if (!ask2 && size > 16 * 1024 * 1024) state2.save(NAME2, id, { first_ask_none: true });
1081
1179
  }
1082
- if (ask) {
1083
- out2.first_ask = ask;
1084
- if (!mem.first_ask) state2.save(NAME2, id, { first_ask: ask });
1180
+ if (ask2) {
1181
+ out2.first_ask = ask2;
1182
+ if (!mem.first_ask) state2.save(NAME2, id, { first_ask: ask2 });
1085
1183
  }
1086
1184
  if (pick) {
1087
1185
  out2.title = String(pick.title).slice(0, 120);
1088
1186
  out2.title_source = pick.source;
1089
- } else if (ask) {
1090
- out2.title = ask;
1187
+ } else if (ask2) {
1188
+ out2.title = ask2;
1091
1189
  out2.title_source = "first-ask";
1092
1190
  }
1093
1191
  const acct = claudeAccount(dir);
@@ -1271,6 +1369,7 @@ var CODEX_EVENTS = [
1271
1369
  "SessionEnd"
1272
1370
  ];
1273
1371
  var OPTS2 = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
1372
+ var eventsFor2 = (lean) => lean ? CODEX_EVENTS.filter((e) => e !== "PreToolUse") : [...CODEX_EVENTS];
1274
1373
  var codexHome = (env) => env.CODEX_HOME || path6.join(HOME, ".codex");
1275
1374
  var hooksFile2 = (env) => path6.join(codexHome(env), "hooks.json");
1276
1375
  var configFile2 = (env) => path6.join(codexHome(env), "config.toml");
@@ -1319,7 +1418,8 @@ function installNotify(cmd2, env) {
1319
1418
  return {
1320
1419
  file,
1321
1420
  changed: false,
1322
- note: `left alone: notify runs ${n.raw.slice(0, 80)} (hooks.json carries the events; the notify fallback is off)`
1421
+ skipped: true,
1422
+ 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
1423
  };
1324
1424
  }
1325
1425
  write(withNotify(text, line));
@@ -1376,14 +1476,21 @@ var codex = {
1376
1476
  events: CODEX_EVENTS,
1377
1477
  detect: (env = process.env) => existsSync4(codexHome(env)),
1378
1478
  configFiles: (env = process.env) => [hooksFile2(env), configFile2(env)],
1379
- install: (cmd2, env = process.env) => [
1380
- installClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd2, OPTS2),
1479
+ install: (cmd2, env = process.env, opts = {}) => [
1480
+ installClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd2, OPTS2),
1381
1481
  installNotify(cmd2, env)
1382
1482
  ],
1383
- uninstall: (env = process.env) => [uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS), uninstallNotify(env)],
1384
- installed: (cmd2, env = process.env) => {
1483
+ uninstall: (env = process.env, opts = {}) => [
1484
+ uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS, { created: opts.created?.includes(hooksFile2(env)) }),
1485
+ uninstallNotify(env)
1486
+ ],
1487
+ installed: (cmd2, env = process.env, opts = {}) => {
1385
1488
  const out2 = [
1386
- { file: hooksFile2(env), state: installedClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd2, OPTS2) }
1489
+ {
1490
+ file: hooksFile2(env),
1491
+ state: installedClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd2, OPTS2),
1492
+ note: "Codex runs non-managed hooks only after the review in `codex` (/hooks); until then nothing is reported unless the notify fallback is ours"
1493
+ }
1387
1494
  ];
1388
1495
  let text = "";
1389
1496
  try {
@@ -1571,6 +1678,7 @@ function codexFacts(file) {
1571
1678
  }
1572
1679
 
1573
1680
  // ../core/src/adapters/gemini-cli.ts
1681
+ import { execFileSync as execFileSync3 } from "node:child_process";
1574
1682
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
1575
1683
  import path7 from "node:path";
1576
1684
  var NAME4 = "gemini-cli";
@@ -1588,17 +1696,61 @@ var OPTS3 = { matcherFor: (e) => /Tool/.test(e) ? ".*" : void 0, timeout: 5e3 };
1588
1696
  var geminiDir = (env) => env.GEMINI_CLI_HOME || path7.join(HOME, ".gemini");
1589
1697
  var settings = (env) => path7.join(geminiDir(env), "settings.json");
1590
1698
  var validTool4 = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
1699
+ var eventsFor3 = (lean) => lean ? GEMINI_EVENTS.filter((e) => e !== "BeforeTool") : [...GEMINI_EVENTS];
1700
+ function geminiHooksActive(settingsFile) {
1701
+ let tools;
1702
+ try {
1703
+ tools = JSON.parse(readFileSync5(settingsFile, "utf8")).tools;
1704
+ } catch {
1705
+ }
1706
+ if (tools?.enableHooks === true) return { active: true };
1707
+ if (tools?.enableHooks === false)
1708
+ return { active: false, note: "inactive: tools.enableHooks is false in settings.json" };
1709
+ let version = "";
1710
+ try {
1711
+ version = execFileSync3("gemini", ["--version"], {
1712
+ encoding: "utf8",
1713
+ timeout: 4e3,
1714
+ stdio: ["ignore", "pipe", "ignore"]
1715
+ }).trim();
1716
+ } catch {
1717
+ return {
1718
+ active: true,
1719
+ note: "could not run `gemini --version`; hooks need tools.enableHooks on versions before 0.46"
1720
+ };
1721
+ }
1722
+ const m = /(\d+)\.(\d+)/.exec(version);
1723
+ if (m && (Number(m[1]) > 0 || Number(m[2]) >= 46)) return { active: true };
1724
+ return {
1725
+ active: false,
1726
+ note: `inactive: Gemini CLI ${version || "?"} runs hooks only with "tools": {"enableHooks": true} in settings.json`
1727
+ };
1728
+ }
1591
1729
  var geminiCli = {
1592
1730
  name: NAME4,
1593
1731
  events: GEMINI_EVENTS,
1594
1732
  // ~/.gemini also belongs to Antigravity; Gemini CLI is here when its settings or its chat logs exist.
1595
1733
  detect: (env = process.env) => existsSync5(settings(env)) || existsSync5(path7.join(geminiDir(env), "tmp")),
1596
1734
  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) }
1735
+ install: (cmd2, env = process.env, opts = {}) => {
1736
+ const r = installClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd2, OPTS3);
1737
+ const a = geminiHooksActive(settings(env));
1738
+ return [a.note && !r.skipped ? { ...r, note: [r.note, a.note].filter(Boolean).join("; ") } : r];
1739
+ },
1740
+ uninstall: (env = process.env, opts = {}) => [
1741
+ uninstallClaudeShaped(settings(env), GEMINI_EVENTS, { created: opts.created?.includes(settings(env)) })
1601
1742
  ],
1743
+ installed: (cmd2, env = process.env, opts = {}) => {
1744
+ const state2 = installedClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd2, OPTS3);
1745
+ const a = geminiHooksActive(settings(env));
1746
+ return [
1747
+ {
1748
+ file: settings(env),
1749
+ state: state2 === "current" && !a.active ? "inactive" : state2,
1750
+ ...a.note ? { note: a.note } : {}
1751
+ }
1752
+ ];
1753
+ },
1602
1754
  fromHook(input) {
1603
1755
  const event = input.argv[1] ?? "";
1604
1756
  let s = {};
@@ -1710,17 +1862,366 @@ function prune4(o) {
1710
1862
  return o;
1711
1863
  }
1712
1864
 
1865
+ // ../core/src/adapters/claude-code-control.ts
1866
+ var claude_code_control_exports = {};
1867
+ __export(claude_code_control_exports, {
1868
+ JOB_TIMEOUT_MS: () => JOB_TIMEOUT_MS,
1869
+ claudeEnv: () => claudeEnv,
1870
+ detectCaps: () => detectCaps,
1871
+ findClaude: () => findClaude,
1872
+ findTranscript: () => findTranscript,
1873
+ isUuid: () => isUuid,
1874
+ liveProcess: () => liveProcess,
1875
+ modeFlags: () => modeFlags,
1876
+ parseCaps: () => parseCaps,
1877
+ parseResult: () => parseResult,
1878
+ recentlyWritten: () => recentlyWritten,
1879
+ runClaude: () => runClaude,
1880
+ sessionFolder: () => sessionFolder
1881
+ });
1882
+ import { execFileSync as execFileSync4, spawn } from "node:child_process";
1883
+ import { accessSync, closeSync as closeSync2, constants, openSync as openSync2, readdirSync as readdirSync5, readFileSync as readFileSync6, readSync as readSync2, statSync as statSync4 } from "node:fs";
1884
+ import os2 from "node:os";
1885
+ import path8 from "node:path";
1886
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1887
+ var JOB_TIMEOUT_MS = 30 * 6e4;
1888
+ var OUT_CAP = 1024 * 1024;
1889
+ var LIVE_WRITE_MS = 9e4;
1890
+ function isExe(p) {
1891
+ try {
1892
+ if (!statSync4(p).isFile()) return false;
1893
+ accessSync(p, constants.X_OK);
1894
+ return true;
1895
+ } catch {
1896
+ return false;
1897
+ }
1898
+ }
1899
+ var cmpVersion = (a, b) => {
1900
+ const pa = a.split(".").map(Number);
1901
+ const pb = b.split(".").map(Number);
1902
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++)
1903
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
1904
+ return 0;
1905
+ };
1906
+ function findClaude(env = process.env, home = os2.homedir()) {
1907
+ if (env.SESSIONPIPE_CLAUDE && isExe(env.SESSIONPIPE_CLAUDE)) return env.SESSIONPIPE_CLAUDE;
1908
+ const names = process.platform === "win32" ? ["claude.exe", "claude.cmd"] : ["claude"];
1909
+ for (const d of String(env.PATH || "").split(path8.delimiter)) {
1910
+ if (!d || !path8.isAbsolute(d)) continue;
1911
+ for (const n of names) if (isExe(path8.join(d, n))) return path8.join(d, n);
1912
+ }
1913
+ for (const p of [
1914
+ path8.join(home, ".claude", "local", "claude"),
1915
+ path8.join(home, ".local", "bin", "claude"),
1916
+ "/opt/homebrew/bin/claude",
1917
+ "/usr/local/bin/claude"
1918
+ ])
1919
+ if (isExe(p)) return p;
1920
+ const app = path8.join(home, "Library", "Application Support", "Claude", "claude-code");
1921
+ let versions = [];
1922
+ try {
1923
+ versions = readdirSync5(app).filter((n) => /^\d+(\.\d+)*$/.test(n)).sort(cmpVersion).reverse();
1924
+ } catch {
1925
+ }
1926
+ for (const v of versions) {
1927
+ const p = path8.join(app, v, "claude.app", "Contents", "MacOS", "claude");
1928
+ if (isExe(p)) return p;
1929
+ }
1930
+ return null;
1931
+ }
1932
+ function optionBlock(help2, flag2) {
1933
+ const lines = help2.split("\n");
1934
+ const re = new RegExp(`^\\s{0,4}(?:-\\w, )?${flag2.replace(/-/g, "\\-")}(?![\\w-])`);
1935
+ const start = lines.findIndex((l) => re.test(l));
1936
+ if (start < 0) return null;
1937
+ const out2 = [lines[start]];
1938
+ for (let i = start + 1; i < lines.length && lines[i]?.trim() && !/^\s{0,4}-/.test(lines[i]); i++)
1939
+ out2.push(lines[i]);
1940
+ return out2.join(" ");
1941
+ }
1942
+ function parseCaps(help2) {
1943
+ const pm = optionBlock(help2, "--permission-mode");
1944
+ const pp = optionBlock(help2, "--permission-prompts");
1945
+ return {
1946
+ hasMode: !!pm,
1947
+ modes: pm ? [...pm.matchAll(/"([A-Za-z]+)"/g)].map((m) => m[1]) : [],
1948
+ promptsNone: !!pp && /"none"/.test(pp),
1949
+ fork: !!optionBlock(help2, "--fork-session")
1950
+ };
1951
+ }
1952
+ function modeFlags(mode, caps) {
1953
+ if (mode === "auto") {
1954
+ if (!caps.modes.includes("auto"))
1955
+ return { error: "this machine is in auto mode, but its Claude Code has no auto permission mode" };
1956
+ return { args: ["--permission-mode", "auto"] };
1957
+ }
1958
+ if (caps.modes.includes("dontAsk")) return { args: ["--permission-mode", "dontAsk"] };
1959
+ const args2 = [];
1960
+ if (caps.hasMode)
1961
+ args2.push(
1962
+ "--permission-mode",
1963
+ caps.modes.includes("manual") && !caps.modes.includes("default") ? "manual" : "default"
1964
+ );
1965
+ if (caps.promptsNone) args2.push("--permission-prompts", "none");
1966
+ return { args: args2 };
1967
+ }
1968
+ function claudeEnv(configDir, env = process.env) {
1969
+ const e = { ...env };
1970
+ for (const k of [
1971
+ "CLAUDECODE",
1972
+ "CLAUDE_CODE_SESSION_ID",
1973
+ "CLAUDE_CODE_HOST_SESSION_ID",
1974
+ "CLAUDE_CODE_ENTRYPOINT",
1975
+ "CLAUDE_CODE_SESSION_ATTENDED",
1976
+ "CLAUDE_CODE_SSE_PORT"
1977
+ ])
1978
+ delete e[k];
1979
+ e.SESSIONPIPE_CONTROL_JOB = "1";
1980
+ if (configDir !== void 0) {
1981
+ if (path8.resolve(configDir) === path8.resolve(os2.homedir(), ".claude")) delete e.CLAUDE_CONFIG_DIR;
1982
+ else e.CLAUDE_CONFIG_DIR = configDir;
1983
+ }
1984
+ return e;
1985
+ }
1986
+ function detectCaps(bin) {
1987
+ let help2 = "";
1988
+ try {
1989
+ help2 = execFileSync4(bin, ["--help"], {
1990
+ encoding: "utf8",
1991
+ timeout: 3e4,
1992
+ env: claudeEnv(),
1993
+ stdio: ["ignore", "pipe", "pipe"],
1994
+ maxBuffer: 4 * 1024 * 1024
1995
+ });
1996
+ } catch (e) {
1997
+ help2 = String(e?.stdout || "");
1998
+ }
1999
+ return parseCaps(help2);
2000
+ }
2001
+ function findTranscript(session, dirs) {
2002
+ if (!UUID_RE.test(session)) return null;
2003
+ for (const dir of dirs) {
2004
+ let projects = [];
2005
+ try {
2006
+ projects = readdirSync5(path8.join(dir, "projects"), { withFileTypes: true });
2007
+ } catch {
2008
+ continue;
2009
+ }
2010
+ for (const p of projects) {
2011
+ if (!p.isDirectory()) continue;
2012
+ const file = path8.join(dir, "projects", p.name, `${session}.jsonl`);
2013
+ try {
2014
+ if (statSync4(file).isFile()) return { file, configDir: dir, project: p.name };
2015
+ } catch {
2016
+ }
2017
+ }
2018
+ }
2019
+ return null;
2020
+ }
2021
+ var projectName = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
2022
+ function sessionFolder(t, cap2 = 8 * 1024 * 1024) {
2023
+ let first = null;
2024
+ let count = 0;
2025
+ let fd;
2026
+ const look = (line) => {
2027
+ if (line.indexOf('"cwd"') < 0) return null;
2028
+ let j;
2029
+ try {
2030
+ j = JSON.parse(line.toString("utf8"));
2031
+ } catch {
2032
+ return null;
2033
+ }
2034
+ if (!j || typeof j.cwd !== "string" || !path8.isAbsolute(j.cwd)) return null;
2035
+ if (projectName(j.cwd) === t.project) return j.cwd;
2036
+ first ??= j.cwd;
2037
+ count++;
2038
+ return null;
2039
+ };
2040
+ try {
2041
+ fd = openSync2(t.file, "r");
2042
+ const buf = Buffer.alloc(256 * 1024);
2043
+ let pos = 0;
2044
+ let rest = Buffer.alloc(0);
2045
+ let skipping = false;
2046
+ while (pos < cap2 && count < 50) {
2047
+ const n = readSync2(fd, buf, 0, buf.length, pos);
2048
+ if (!n) {
2049
+ if (!skipping && rest.length) {
2050
+ const hit = look(rest);
2051
+ if (hit) return hit;
2052
+ }
2053
+ break;
2054
+ }
2055
+ pos += n;
2056
+ let chunk = rest.length ? Buffer.concat([rest, buf.subarray(0, n)]) : buf.subarray(0, n);
2057
+ let nl = chunk.indexOf(10);
2058
+ while (nl >= 0) {
2059
+ const line = chunk.subarray(0, nl);
2060
+ chunk = chunk.subarray(nl + 1);
2061
+ if (skipping) skipping = false;
2062
+ else {
2063
+ const hit = look(line);
2064
+ if (hit) return hit;
2065
+ }
2066
+ nl = chunk.indexOf(10);
2067
+ }
2068
+ if (chunk.length > 2 * 1024 * 1024) {
2069
+ skipping = true;
2070
+ rest = Buffer.alloc(0);
2071
+ } else rest = skipping ? Buffer.alloc(0) : Buffer.from(chunk);
2072
+ }
2073
+ } catch {
2074
+ } finally {
2075
+ if (fd !== void 0)
2076
+ try {
2077
+ closeSync2(fd);
2078
+ } catch {
2079
+ }
2080
+ }
2081
+ return first;
2082
+ }
2083
+ function pidAlive(pid) {
2084
+ if (!Number.isSafeInteger(pid) || pid <= 1) return false;
2085
+ try {
2086
+ process.kill(pid, 0);
2087
+ return true;
2088
+ } catch (e) {
2089
+ return e.code === "EPERM";
2090
+ }
2091
+ }
2092
+ function liveProcess(session, configDir) {
2093
+ let names = [];
2094
+ try {
2095
+ names = readdirSync5(path8.join(configDir, "sessions")).filter((n) => /^\d+\.json$/.test(n));
2096
+ } catch {
2097
+ return false;
2098
+ }
2099
+ for (const n of names) {
2100
+ try {
2101
+ const j = JSON.parse(readFileSync6(path8.join(configDir, "sessions", n), "utf8"));
2102
+ if (String(j.sessionId) !== session) continue;
2103
+ if (pidAlive(Number(j.pid ?? n.slice(0, -5)))) return true;
2104
+ } catch {
2105
+ }
2106
+ }
2107
+ return false;
2108
+ }
2109
+ function recentlyWritten(file, ourLastEnd, now = Date.now()) {
2110
+ let m;
2111
+ try {
2112
+ m = statSync4(file).mtimeMs;
2113
+ } catch {
2114
+ return false;
2115
+ }
2116
+ if (now - m > LIVE_WRITE_MS) return false;
2117
+ return !(ourLastEnd && m <= ourLastEnd + 2e3);
2118
+ }
2119
+ function runClaude(bin, args2, o) {
2120
+ return new Promise((resolve) => {
2121
+ let out2 = [];
2122
+ let outLen = 0;
2123
+ let tooBig = false;
2124
+ let errTail = "";
2125
+ let timedOut = false;
2126
+ let settled = false;
2127
+ let child;
2128
+ try {
2129
+ child = spawn(bin, args2, {
2130
+ cwd: o.cwd,
2131
+ env: o.env,
2132
+ stdio: ["ignore", "pipe", "pipe"],
2133
+ detached: process.platform !== "win32",
2134
+ windowsHide: true
2135
+ });
2136
+ } catch (e) {
2137
+ resolve({ error: e.message });
2138
+ return;
2139
+ }
2140
+ const kill = (sig) => {
2141
+ try {
2142
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, sig);
2143
+ else child.kill(sig);
2144
+ } catch {
2145
+ try {
2146
+ child.kill(sig);
2147
+ } catch {
2148
+ }
2149
+ }
2150
+ };
2151
+ child.stdout?.on("data", (b) => {
2152
+ if (tooBig) return;
2153
+ if (outLen + b.length > OUT_CAP) {
2154
+ tooBig = true;
2155
+ out2 = [];
2156
+ return;
2157
+ }
2158
+ out2.push(b);
2159
+ outLen += b.length;
2160
+ });
2161
+ child.stderr?.on("data", (b) => {
2162
+ errTail = (errTail + b.toString("utf8")).slice(-4e3);
2163
+ });
2164
+ const timer = setTimeout(() => {
2165
+ timedOut = true;
2166
+ kill("SIGTERM");
2167
+ setTimeout(() => kill("SIGKILL"), 1e4).unref();
2168
+ }, o.timeoutMs ?? JOB_TIMEOUT_MS);
2169
+ const result = (code, signal) => ({
2170
+ code,
2171
+ signal,
2172
+ stdout: tooBig ? null : Buffer.concat(out2).toString("utf8"),
2173
+ tooBig,
2174
+ stderr: errTail,
2175
+ timedOut
2176
+ });
2177
+ const finish = (r) => {
2178
+ if (settled) return;
2179
+ settled = true;
2180
+ clearTimeout(timer);
2181
+ resolve(r);
2182
+ };
2183
+ child.on("error", (e) => finish({ error: e.message }));
2184
+ child.on("exit", (code, signal) => setTimeout(() => finish(result(code, signal)), 2e3).unref());
2185
+ child.on("close", (code, signal) => finish(result(code, signal)));
2186
+ });
2187
+ }
2188
+ function parseResult(stdout) {
2189
+ const s = String(stdout || "").trim();
2190
+ if (!s) return null;
2191
+ const pick = (j) => {
2192
+ if (Array.isArray(j)) {
2193
+ for (let i = j.length - 1; i >= 0; i--) if (j[i]?.type === "result") return j[i];
2194
+ return null;
2195
+ }
2196
+ return j && typeof j === "object" ? j : null;
2197
+ };
2198
+ try {
2199
+ return pick(JSON.parse(s));
2200
+ } catch {
2201
+ }
2202
+ const lines = s.split("\n");
2203
+ for (let i = lines.length - 1; i >= 0; i--) {
2204
+ try {
2205
+ const j = pick(JSON.parse(lines[i]));
2206
+ if (j && (j.type === "result" || "result" in j)) return j;
2207
+ } catch {
2208
+ }
2209
+ }
2210
+ return null;
2211
+ }
2212
+ var isUuid = (s) => typeof s === "string" && UUID_RE.test(s);
2213
+
1713
2214
  // ../core/src/adapters/index.ts
1714
2215
  var ADAPTERS = [claudeCode, codex, geminiCli, antigravity];
1715
2216
  var adapterByName = (name) => ADAPTERS.find((a) => a.name === name);
1716
2217
 
1717
2218
  // ../core/src/config.ts
1718
- import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
1719
- import path8 from "node:path";
2219
+ import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
2220
+ import path9 from "node:path";
1720
2221
  var DEFAULT_CONFIG = { sinks: [], harnesses: {}, update_check: true, keep_days: 30 };
1721
2222
  function readConfig(file = configFile()) {
1722
2223
  try {
1723
- const j = JSON.parse(readFileSync6(file, "utf8"));
2224
+ const j = JSON.parse(readFileSync7(file, "utf8"));
1724
2225
  return {
1725
2226
  ...DEFAULT_CONFIG,
1726
2227
  ...j,
@@ -1732,7 +2233,7 @@ function readConfig(file = configFile()) {
1732
2233
  }
1733
2234
  }
1734
2235
  function writeConfig(c, file = configFile()) {
1735
- mkdirSync4(path8.dirname(file), { recursive: true, mode: 448 });
2236
+ mkdirSync4(path9.dirname(file), { recursive: true, mode: 448 });
1736
2237
  writeFileSync4(file, `${JSON.stringify(c, null, 2)}
1737
2238
  `, { mode: 384 });
1738
2239
  if (process.platform !== "win32" && existsSync6(file)) {
@@ -1800,32 +2301,32 @@ function makeEvent(a, ctx) {
1800
2301
  // ../core/src/outbox.ts
1801
2302
  import {
1802
2303
  appendFileSync,
1803
- closeSync as closeSync2,
2304
+ closeSync as closeSync3,
1804
2305
  existsSync as existsSync7,
1805
2306
  mkdirSync as mkdirSync5,
1806
- openSync as openSync2,
1807
- readdirSync as readdirSync5,
1808
- readFileSync as readFileSync7,
1809
- readSync as readSync2,
2307
+ openSync as openSync3,
2308
+ readdirSync as readdirSync6,
2309
+ readFileSync as readFileSync8,
2310
+ readSync as readSync3,
1810
2311
  renameSync,
1811
- statSync as statSync4,
1812
- unlinkSync,
2312
+ statSync as statSync5,
2313
+ unlinkSync as unlinkSync3,
1813
2314
  writeFileSync as writeFileSync5
1814
2315
  } from "node:fs";
1815
- import path9 from "node:path";
2316
+ import path10 from "node:path";
1816
2317
  var Outbox = class {
1817
2318
  constructor(root) {
1818
2319
  this.root = root;
1819
2320
  }
1820
2321
  root;
1821
2322
  dir(harness) {
1822
- return path9.join(this.root, "outbox", safeId(harness));
2323
+ return path10.join(this.root, "outbox", safeId(harness));
1823
2324
  }
1824
2325
  file(ref) {
1825
- return path9.join(this.dir(ref.harness), `${safeId(ref.session)}.jsonl`);
2326
+ return path10.join(this.dir(ref.harness), `${safeId(ref.session)}.jsonl`);
1826
2327
  }
1827
2328
  seqFile(ref) {
1828
- return path9.join(this.dir(ref.harness), `${safeId(ref.session)}.seq`);
2329
+ return path10.join(this.dir(ref.harness), `${safeId(ref.session)}.seq`);
1829
2330
  }
1830
2331
  /** The next seq for a session, persisted (survives a hibernating machine and a
1831
2332
  * restart; an in-memory counter would restart at 0 and collide). */
@@ -1833,18 +2334,18 @@ var Outbox = class {
1833
2334
  const f = this.seqFile(ref);
1834
2335
  let n = 0;
1835
2336
  try {
1836
- n = Number(readFileSync7(f, "utf8")) || 0;
2337
+ n = Number(readFileSync8(f, "utf8")) || 0;
1837
2338
  } catch {
1838
2339
  n = this.lastSeqInFile(ref) + 1;
1839
2340
  }
1840
- mkdirSync5(path9.dirname(f), { recursive: true });
1841
- writeFileSync5(f, String(n + 1));
2341
+ mkdirSync5(path10.dirname(f), { recursive: true, mode: 448 });
2342
+ writeFileSync5(f, String(n + 1), { mode: 384 });
1842
2343
  return n;
1843
2344
  }
1844
2345
  lastSeqInFile(ref) {
1845
2346
  const f = this.file(ref);
1846
2347
  if (!existsSync7(f)) return -1;
1847
- const lines = readFileSync7(f, "utf8").trim().split("\n");
2348
+ const lines = readFileSync8(f, "utf8").trim().split("\n");
1848
2349
  for (let i = lines.length - 1; i >= 0; i--) {
1849
2350
  try {
1850
2351
  const j = JSON.parse(lines[i] ?? "");
@@ -1856,26 +2357,26 @@ var Outbox = class {
1856
2357
  }
1857
2358
  append(ref, events) {
1858
2359
  if (!events.length) return;
1859
- mkdirSync5(this.dir(ref.harness), { recursive: true });
2360
+ mkdirSync5(this.dir(ref.harness), { recursive: true, mode: 448 });
1860
2361
  appendFileSync(this.file(ref), `${events.map((e) => JSON.stringify(e)).join("\n")}
1861
- `);
2362
+ `, { mode: 384 });
1862
2363
  }
1863
2364
  /** Every session file, newest first. */
1864
2365
  sessions() {
1865
2366
  const out2 = [];
1866
- const base = path9.join(this.root, "outbox");
2367
+ const base = path10.join(this.root, "outbox");
1867
2368
  if (!existsSync7(base)) return [];
1868
- for (const h of readdirSync5(base)) {
1869
- const d = path9.join(base, h);
2369
+ for (const h of readdirSync6(base)) {
2370
+ const d = path10.join(base, h);
1870
2371
  let names = [];
1871
2372
  try {
1872
- names = readdirSync5(d).filter((n) => n.endsWith(".jsonl"));
2373
+ names = readdirSync6(d).filter((n) => n.endsWith(".jsonl"));
1873
2374
  } catch {
1874
2375
  continue;
1875
2376
  }
1876
2377
  for (const n of names) {
1877
2378
  try {
1878
- out2.push({ ref: { harness: h, session: n.slice(0, -6) }, mtime: statSync4(path9.join(d, n)).mtimeMs });
2379
+ out2.push({ ref: { harness: h, session: n.slice(0, -6) }, mtime: statSync5(path10.join(d, n)).mtimeMs });
1879
2380
  } catch {
1880
2381
  }
1881
2382
  }
@@ -1887,12 +2388,12 @@ var Outbox = class {
1887
2388
  read(ref, from, maxEvents = 500) {
1888
2389
  const f = this.file(ref);
1889
2390
  if (!existsSync7(f)) return { events: [], offset: from };
1890
- const size = statSync4(f).size;
2391
+ const size = statSync5(f).size;
1891
2392
  if (from >= size) return { events: [], offset: from };
1892
- const fd = openSync2(f, "r");
2393
+ const fd = openSync3(f, "r");
1893
2394
  try {
1894
2395
  const buf = Buffer.alloc(Math.min(size - from, 8 * 1024 * 1024));
1895
- const n = readSync2(fd, buf, 0, buf.length, from);
2396
+ const n = readSync3(fd, buf, 0, buf.length, from);
1896
2397
  const text = buf.subarray(0, n).toString("utf8");
1897
2398
  const events = [];
1898
2399
  let offset = from;
@@ -1911,7 +2412,7 @@ var Outbox = class {
1911
2412
  }
1912
2413
  return { events, offset };
1913
2414
  } finally {
1914
- closeSync2(fd);
2415
+ closeSync3(fd);
1915
2416
  }
1916
2417
  }
1917
2418
  /** Files older than `days` are pruned; the cursor files with them. */
@@ -1921,10 +2422,10 @@ var Outbox = class {
1921
2422
  for (const ref of this.sessions()) {
1922
2423
  const f = this.file(ref);
1923
2424
  try {
1924
- if (statSync4(f).mtimeMs < cutoff) {
1925
- unlinkSync(f);
2425
+ if (statSync5(f).mtimeMs < cutoff) {
2426
+ unlinkSync3(f);
1926
2427
  try {
1927
- unlinkSync(this.seqFile(ref));
2428
+ unlinkSync3(this.seqFile(ref));
1928
2429
  } catch {
1929
2430
  }
1930
2431
  n++;
@@ -1937,7 +2438,7 @@ var Outbox = class {
1937
2438
  delete(ref) {
1938
2439
  for (const f of [this.file(ref), this.seqFile(ref)]) {
1939
2440
  try {
1940
- unlinkSync(f);
2441
+ unlinkSync3(f);
1941
2442
  } catch {
1942
2443
  }
1943
2444
  }
@@ -1951,20 +2452,20 @@ var Cursors = class {
1951
2452
  root;
1952
2453
  sink;
1953
2454
  file() {
1954
- return path9.join(this.root, "cursors", `${safeId(this.sink)}.json`);
2455
+ return path10.join(this.root, "cursors", `${safeId(this.sink)}.json`);
1955
2456
  }
1956
2457
  read() {
1957
2458
  try {
1958
- return JSON.parse(readFileSync7(this.file(), "utf8"));
2459
+ return JSON.parse(readFileSync8(this.file(), "utf8"));
1959
2460
  } catch {
1960
2461
  return {};
1961
2462
  }
1962
2463
  }
1963
2464
  write(c) {
1964
2465
  const f = this.file();
1965
- mkdirSync5(path9.dirname(f), { recursive: true });
2466
+ mkdirSync5(path10.dirname(f), { recursive: true, mode: 448 });
1966
2467
  const tmp = `${f}.${process.pid}.tmp`;
1967
- writeFileSync5(tmp, JSON.stringify(c));
2468
+ writeFileSync5(tmp, JSON.stringify(c), { mode: 384 });
1968
2469
  renameSync(tmp, f);
1969
2470
  }
1970
2471
  key(ref) {
@@ -1972,11 +2473,11 @@ var Cursors = class {
1972
2473
  }
1973
2474
  };
1974
2475
  function takeLock(root, ref, staleMs = 5 * 6e4) {
1975
- const dir = path9.join(root, "locks");
1976
- mkdirSync5(dir, { recursive: true });
1977
- const f = path9.join(dir, `${safeId(ref.harness)}-${safeId(ref.session)}.lock`);
2476
+ const dir = path10.join(root, "locks");
2477
+ mkdirSync5(dir, { recursive: true, mode: 448 });
2478
+ const f = path10.join(dir, `${safeId(ref.harness)}-${safeId(ref.session)}.lock`);
1978
2479
  try {
1979
- if (Date.now() - statSync4(f).mtimeMs > staleMs) unlinkSync(f);
2480
+ if (Date.now() - statSync5(f).mtimeMs > staleMs) unlinkSync3(f);
1980
2481
  else return null;
1981
2482
  } catch {
1982
2483
  }
@@ -1987,7 +2488,7 @@ function takeLock(root, ref, staleMs = 5 * 6e4) {
1987
2488
  }
1988
2489
  return () => {
1989
2490
  try {
1990
- unlinkSync(f);
2491
+ unlinkSync3(f);
1991
2492
  } catch {
1992
2493
  }
1993
2494
  };
@@ -2123,7 +2624,7 @@ function redactAssignments(text) {
2123
2624
  function isSecretName(name) {
2124
2625
  if (/^[A-Z0-9_]+_KEY$/.test(name)) return true;
2125
2626
  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(
2627
+ return /(?:pass(?:word|wd|phrase|code)|pwd|secret|secretkey|secretaccesskey|clientsecret|apikey|accesskey|accesstoken|authtoken|refreshtoken|privatekey|token|credential|creds)s?$/.test(
2127
2628
  flat
2128
2629
  );
2129
2630
  }
@@ -2212,7 +2713,7 @@ var DATA_ALLOW = {
2212
2713
  "session.backfill": { 0: ["started_at", "last_at", "turns"] },
2213
2714
  "session.forgotten": { 0: [] },
2214
2715
  "turn.started": { 0: ["turn_id"], 1: ["prompt_chars"] },
2215
- "turn.ended": { 0: ["turn_id", "reason", "ms"] },
2716
+ "turn.ended": { 0: ["turn_id", "reason", "ms"], 1: ["error"] },
2216
2717
  "turn.transcript": { 2: ["turn", "user", "assistant", "at"] },
2217
2718
  "tool.started": { 1: ["tool", "call_id", "turn_id", "agent_id"], 3: ["input"] },
2218
2719
  "tool.ended": { 1: ["tool", "call_id", "turn_id", "agent_id", "ms", "ok", "error"], 3: ["input", "output"] },
@@ -2290,7 +2791,7 @@ function filterEvent(event, tier, pii, opts = {}) {
2290
2791
 
2291
2792
  // ../core/src/sinks/file.ts
2292
2793
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync6 } from "node:fs";
2293
- import path10 from "node:path";
2794
+ import path11 from "node:path";
2294
2795
  var FileSink = class {
2295
2796
  constructor(name, file, tier, pii) {
2296
2797
  this.name = name;
@@ -2304,7 +2805,7 @@ var FileSink = class {
2304
2805
  pii;
2305
2806
  async send(events) {
2306
2807
  try {
2307
- mkdirSync6(path10.dirname(this.file), { recursive: true });
2808
+ mkdirSync6(path11.dirname(this.file), { recursive: true });
2308
2809
  appendFileSync2(this.file, `${events.map((e) => JSON.stringify(e)).join("\n")}
2309
2810
  `);
2310
2811
  return { ok: true, acknowledged: events.length, accepted: events.length, duplicates: 0, rejected: [] };
@@ -2333,51 +2834,1890 @@ var StdoutSink = class {
2333
2834
  }
2334
2835
  };
2335
2836
 
2336
- // 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";
2338
- import os2 from "node:os";
2339
- import path11 from "node:path";
2837
+ // src/control/cli.ts
2838
+ import { existsSync as existsSync11, realpathSync as realpathSync3, watchFile } from "node:fs";
2839
+ import os5 from "node:os";
2840
+ import path16 from "node:path";
2340
2841
 
2341
- // src/http-sink.ts
2342
- import { createHmac, randomUUID } from "node:crypto";
2343
- var TIMEOUT_MS = 15e3;
2344
- var HttpSink = class {
2345
- constructor(cfg, tier) {
2346
- this.cfg = cfg;
2347
- this.tier = tier;
2348
- this.name = cfg.name;
2349
- this.pii = !!cfg.pii;
2350
- this.url = cfg.url.replace(/\/$/, "");
2842
+ // src/control/daemon.ts
2843
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, realpathSync as realpathSync2, statSync as statSync7, unlinkSync as unlinkSync4 } from "node:fs";
2844
+ import net from "node:net";
2845
+ import path13 from "node:path";
2846
+
2847
+ // ../core/src/control/verify.ts
2848
+ var GRANT_MAX_MS = 24 * 36e5;
2849
+ var SKEW_MS = 5 * 6e4;
2850
+ var MACHINE_MAX_AGE_MS = 24 * 36e5;
2851
+ var RECEIVER_MAX_AGE_MS = 10 * 6e4;
2852
+ var NONCE_TTL_MS = 25 * 36e5;
2853
+ var CMD_MAX = 12e3;
2854
+ var TEXT_MAX = 4e3;
2855
+ var NOTE_MAX = 2e3;
2856
+ var GRANT_STR_MAX = 2e3;
2857
+ var COMMAND_TYPE = "sessionpipe.control";
2858
+ var GRANT_TYPE = "sessionpipe.grant";
2859
+ var CONTROL_KINDS = ["prompt", "permission.answer", "cancel", "start"];
2860
+ var ES256 = -7;
2861
+ var RS256 = -257;
2862
+ var NONCE_RE = /^[A-Za-z0-9_-]{22,64}$/;
2863
+ var MACHINE_RE = /^m_[A-Za-z0-9_-]{16,40}$/;
2864
+ var HARNESS_RE = /^[a-z][a-z0-9-]{0,39}$/;
2865
+ var SESSION_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
2866
+ var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2867
+ var B64URL_RE = /^[A-Za-z0-9_-]*$/;
2868
+ var UP = 1;
2869
+ var UV = 4;
2870
+ var enc = new TextEncoder();
2871
+ var dec = new TextDecoder();
2872
+ function b64url(bytes) {
2873
+ const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
2874
+ let s = "";
2875
+ for (const b of u) s += String.fromCharCode(b);
2876
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2877
+ }
2878
+ function fromB64url(s) {
2879
+ if (typeof s !== "string" || !B64URL_RE.test(s) || s.length % 4 === 1) return null;
2880
+ try {
2881
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4));
2882
+ const out2 = new Uint8Array(bin.length);
2883
+ for (let i = 0; i < bin.length; i++) out2[i] = bin.charCodeAt(i);
2884
+ return out2;
2885
+ } catch {
2886
+ return null;
2351
2887
  }
2352
- cfg;
2353
- tier;
2354
- name;
2355
- pii;
2356
- url;
2357
- eventsUrl() {
2358
- const ep = this.cfg.endpoints?.events ?? "/api/sessionpipe/v1/events";
2359
- return /^https?:\/\//.test(ep) ? ep : this.url + ep;
2888
+ }
2889
+ async function sha256(data) {
2890
+ const bytes = typeof data === "string" ? enc.encode(data) : data;
2891
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
2892
+ }
2893
+ function concat(a, b) {
2894
+ const out2 = new Uint8Array(a.length + b.length);
2895
+ out2.set(a, 0);
2896
+ out2.set(b, a.length);
2897
+ return out2;
2898
+ }
2899
+ function sameBytes(a, b) {
2900
+ if (a.length !== b.length) return false;
2901
+ let d = 0;
2902
+ for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
2903
+ return d === 0;
2904
+ }
2905
+ function derToRaw(der) {
2906
+ if (der[0] !== 48 || der.length < 8 || der[1] !== der.length - 2) return null;
2907
+ let i = 2;
2908
+ const part = () => {
2909
+ if (der[i] !== 2) return null;
2910
+ const len = der[i + 1];
2911
+ if (len < 1 || len > 33) return null;
2912
+ let v = der.slice(i + 2, i + 2 + len);
2913
+ if (v.length !== len) return null;
2914
+ if (v[0] & 128) return null;
2915
+ if (len > 1 && v[0] === 0 && !(v[1] & 128)) return null;
2916
+ i += 2 + len;
2917
+ while (v.length > 32 && v[0] === 0) v = v.slice(1);
2918
+ if (v.length > 32) return null;
2919
+ const out2 = new Uint8Array(32);
2920
+ out2.set(v, 32 - v.length);
2921
+ return out2;
2922
+ };
2923
+ const r = part();
2924
+ const s = part();
2925
+ return r && s && i === der.length ? concat(r, s) : null;
2926
+ }
2927
+ var u8 = (b) => b;
2928
+ async function importKey(key) {
2929
+ const spki = fromB64url(key.spki);
2930
+ if (!spki?.length) return null;
2931
+ try {
2932
+ if (key.alg === ES256)
2933
+ return await crypto.subtle.importKey("spki", u8(spki), { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
2934
+ if (key.alg === RS256) {
2935
+ const k = await crypto.subtle.importKey("spki", u8(spki), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, [
2936
+ "verify"
2937
+ ]);
2938
+ const bits = k.algorithm.modulusLength ?? 0;
2939
+ return bits >= 2048 ? k : null;
2940
+ }
2941
+ } catch {
2360
2942
  }
2361
- async send(events) {
2362
- const body = JSON.stringify({ events });
2363
- const headers = { "content-type": "application/json", "sessionpipe-protocol": "1" };
2364
- if (this.cfg.token) headers.authorization = `Bearer ${this.cfg.token}`;
2365
- if (this.cfg.secret?.startsWith("whsec_")) {
2366
- const id = `msg_${randomUUID()}`;
2367
- const ts = Math.floor(Date.now() / 1e3);
2368
- const key = Buffer.from(this.cfg.secret.slice(6), "base64");
2369
- const sig = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
2370
- headers["webhook-id"] = id;
2371
- headers["webhook-timestamp"] = String(ts);
2372
- headers["webhook-signature"] = `v1,${sig}`;
2943
+ return null;
2944
+ }
2945
+ async function keyUsable(key) {
2946
+ return !!key && typeof key.id === "string" && /^[A-Za-z0-9_-]{16,1400}$/.test(key.id) && (key.alg === ES256 || key.alg === RS256) && !!await importKey(key);
2947
+ }
2948
+ function originAllowed(origin, rpId) {
2949
+ if (typeof origin !== "string" || !rpId) return false;
2950
+ let u;
2951
+ try {
2952
+ u = new URL(origin);
2953
+ } catch {
2954
+ return false;
2955
+ }
2956
+ if (u.origin !== origin) return false;
2957
+ if (u.hostname !== rpId && !u.hostname.endsWith(`.${rpId}`)) return false;
2958
+ if (u.protocol === "https:") return true;
2959
+ return u.protocol === "http:" && rpId === "localhost";
2960
+ }
2961
+ var no = (why) => ({ ok: false, why });
2962
+ async function verifyAssertion(challenge, a, key, rpId) {
2963
+ if (!a || typeof a !== "object") return no("no passkey signature");
2964
+ if (a.cred !== key.id) return no("signed with a key this machine doesn't trust");
2965
+ const cdBytes = fromB64url(a.cd);
2966
+ const ad = fromB64url(a.ad);
2967
+ const sig = fromB64url(a.sig);
2968
+ if (!cdBytes || !ad || !sig?.length) return no("the passkey signature is malformed");
2969
+ let cd;
2970
+ try {
2971
+ cd = JSON.parse(dec.decode(cdBytes));
2972
+ } catch {
2973
+ return no("the passkey signature is malformed");
2974
+ }
2975
+ if (!cd || typeof cd !== "object") return no("the passkey signature is malformed");
2976
+ if (cd.type !== "webauthn.get") return no("the passkey answered a different question");
2977
+ if (cd.challenge !== b64url(challenge)) return no("the passkey signed something else");
2978
+ if (!originAllowed(cd.origin, rpId)) return no(`the passkey was used on another site, not ${rpId}`);
2979
+ if (cd.crossOrigin === true) return no("the passkey was used inside another site's frame");
2980
+ if (ad.length < 37) return no("the passkey signature is malformed");
2981
+ if (!sameBytes(ad.slice(0, 32), await sha256(rpId))) return no(`the passkey belongs to another site, not ${rpId}`);
2982
+ const flags = ad[32];
2983
+ if (!(flags & UP) || !(flags & UV)) return no("the passkey signed without verifying the person");
2984
+ const pub = await importKey(key);
2985
+ if (!pub) return no("a trusted key is unusable");
2986
+ const signed = concat(ad, await sha256(cdBytes));
2987
+ let good = false;
2988
+ try {
2989
+ if (key.alg === ES256) {
2990
+ const raw = derToRaw(sig);
2991
+ good = !!raw && await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, pub, u8(raw), u8(signed));
2992
+ } else {
2993
+ good = await crypto.subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, pub, u8(sig), u8(signed));
2373
2994
  }
2374
- const ctl = new AbortController();
2375
- const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);
2376
- try {
2377
- const r = await fetch(this.eventsUrl(), { method: "POST", headers, body, signal: ctl.signal });
2378
- const text = await r.text();
2379
- let j = {};
2380
- try {
2995
+ } catch {
2996
+ good = false;
2997
+ }
2998
+ return good ? { ok: true } : no("the passkey signature doesn't verify");
2999
+ }
3000
+ function pairChallenge(machine, code) {
3001
+ return sha256(`sessionpipe.pair:${machine}:${code}`);
3002
+ }
3003
+ async function verifyEnrollment(o) {
3004
+ if (!await keyUsable(o.key)) return no("the key is in a shape this machine can't use");
3005
+ return verifyAssertion(await pairChallenge(o.machine, o.code), o.proof, o.key, o.rpId);
3006
+ }
3007
+ var bad = (code, why) => ({
3008
+ ok: false,
3009
+ code,
3010
+ why
3011
+ });
3012
+ var isNum = (n) => typeof n === "number" && Number.isFinite(n);
3013
+ function sessionIdOk(harness, id) {
3014
+ if (!SESSION_ID_RE.test(id)) return false;
3015
+ if (harness === "claude-code") return UUID_RE2.test(id);
3016
+ return true;
3017
+ }
3018
+ function parseSessionRef(ref) {
3019
+ if (typeof ref !== "string") return null;
3020
+ const i = ref.indexOf(":");
3021
+ if (i <= 0) return null;
3022
+ const harness = ref.slice(0, i);
3023
+ const id = ref.slice(i + 1);
3024
+ return HARNESS_RE.test(harness) && sessionIdOk(harness, id) ? { harness, id } : null;
3025
+ }
3026
+ function parseCommand(str) {
3027
+ if (typeof str !== "string" || !str || str.length > CMD_MAX)
3028
+ return bad("malformed", "the command is missing or too long");
3029
+ let c;
3030
+ try {
3031
+ c = JSON.parse(str);
3032
+ } catch {
3033
+ return bad("malformed", "the command is not JSON");
3034
+ }
3035
+ if (!c || typeof c !== "object" || Array.isArray(c)) return bad("malformed", "the command is not an object");
3036
+ if (c.v !== 1 || c.t !== COMMAND_TYPE) return bad("not_a_command", "not a sessionpipe control command");
3037
+ if (typeof c.machine !== "string" || !MACHINE_RE.test(c.machine)) return bad("malformed", "which machine?");
3038
+ if (!CONTROL_KINDS.includes(c.kind))
3039
+ return bad("unknown_kind", "the command asks for something unknown");
3040
+ const ref = parseSessionRef(c.session);
3041
+ if (!ref) return bad("bad_session", "which session?");
3042
+ if (typeof c.nonce !== "string" || !NONCE_RE.test(c.nonce))
3043
+ return bad("bad_nonce", "the nonce is missing or malformed");
3044
+ if (!isNum(c.iat) || !Number.isInteger(c.iat) || c.iat <= 0)
3045
+ return bad("malformed", "the command has no signing time");
3046
+ const has2 = (k) => c[k] !== void 0;
3047
+ const text = () => typeof c.text === "string" && c.text.trim().length > 0 && c.text.length <= TEXT_MAX;
3048
+ switch (c.kind) {
3049
+ case "prompt":
3050
+ if (!text()) return bad("bad_fields", `a prompt needs text, at most ${TEXT_MAX} characters`);
3051
+ if (has2("for") || has2("decision") || has2("cwd")) return bad("bad_fields", "a prompt carries only text");
3052
+ break;
3053
+ case "start":
3054
+ if (!text()) return bad("bad_fields", `a new session needs text, at most ${TEXT_MAX} characters`);
3055
+ if (ref.harness !== "claude-code") return bad("bad_fields", "only Claude Code sessions can be started");
3056
+ if (typeof c.cwd !== "string" || !isAbsolute(c.cwd) || c.cwd.length > 1024)
3057
+ return bad("bad_fields", "a new session needs an absolute folder");
3058
+ if (has2("for") || has2("decision")) return bad("bad_fields", "a start carries text and cwd");
3059
+ break;
3060
+ case "permission.answer":
3061
+ if (typeof c.for !== "string" || !c.for || c.for.length > 128) return bad("bad_fields", "which attention?");
3062
+ if (c.decision !== "allow" && c.decision !== "deny") return bad("bad_fields", "allow or deny?");
3063
+ if (has2("text") || has2("cwd")) return bad("bad_fields", "a permission answer carries for, decision and note");
3064
+ break;
3065
+ case "cancel":
3066
+ if (has2("text") || has2("for") || has2("decision") || has2("cwd"))
3067
+ return bad("bad_fields", "a cancel carries nothing");
3068
+ break;
3069
+ }
3070
+ if (has2("note") && (typeof c.note !== "string" || c.note.length > NOTE_MAX))
3071
+ return bad("bad_fields", `a note is text, at most ${NOTE_MAX} characters`);
3072
+ return { ok: true, cmd: c };
3073
+ }
3074
+ function isAbsolute(p) {
3075
+ return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
3076
+ }
3077
+ function parseGrant(str, rpId) {
3078
+ if (typeof str !== "string" || !str || str.length > GRANT_STR_MAX)
3079
+ return { ok: false, why: "the grant is missing or too long" };
3080
+ let g;
3081
+ try {
3082
+ g = JSON.parse(str);
3083
+ } catch {
3084
+ return { ok: false, why: "the grant is not JSON" };
3085
+ }
3086
+ if (!g || typeof g !== "object" || Array.isArray(g)) return { ok: false, why: "the grant is not an object" };
3087
+ if (g.v !== 1 || g.t !== GRANT_TYPE) return { ok: false, why: "not a sessionpipe grant" };
3088
+ if (g.rp !== rpId) return { ok: false, why: `the grant is for another site, not ${rpId}` };
3089
+ if (typeof g.pub !== "string" || !fromB64url(g.pub)?.length || !isNum(g.iat) || !isNum(g.exp))
3090
+ return { ok: false, why: "the grant is missing parts" };
3091
+ if (g.exp <= g.iat) return { ok: false, why: "the grant ends before it starts" };
3092
+ if (g.exp - g.iat > GRANT_MAX_MS + SKEW_MS) return { ok: false, why: "the grant lasts longer than a day" };
3093
+ return { ok: true, grant: g };
3094
+ }
3095
+ async function verifyCsig(cmd2, csig, pub) {
3096
+ const sig = fromB64url(csig);
3097
+ const spki = fromB64url(pub);
3098
+ if (sig?.length !== 64 || !spki) return false;
3099
+ try {
3100
+ const key = await crypto.subtle.importKey("spki", u8(spki), { name: "ECDSA", namedCurve: "P-256" }, false, [
3101
+ "verify"
3102
+ ]);
3103
+ return await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, u8(sig), u8(enc.encode(cmd2)));
3104
+ } catch {
3105
+ return false;
3106
+ }
3107
+ }
3108
+ async function verifyCommand(s, o) {
3109
+ if (!s || typeof s !== "object") return bad("malformed", "nothing to verify");
3110
+ const p = parseCommand(s.cmd);
3111
+ if (!p.ok) return p;
3112
+ const c = p.cmd;
3113
+ if (c.machine !== o.machine) return bad("wrong_machine", "the command is for another machine");
3114
+ let via = null;
3115
+ let keyId = "";
3116
+ let refusal = null;
3117
+ if (s.grant && s.csig) {
3118
+ const r = await checkGrantPath(s, c, o);
3119
+ if (r.ok) {
3120
+ via = "grant";
3121
+ keyId = r.key;
3122
+ } else refusal = r;
3123
+ }
3124
+ if (!via && s.confirm) {
3125
+ const key = o.trusted.find((k) => k && k.id === s.confirm?.cred);
3126
+ if (!key) refusal = { code: "untrusted_key", why: "confirmed with a key this machine doesn't trust" };
3127
+ else {
3128
+ const a = await verifyAssertion(await sha256(s.cmd), s.confirm, key, o.rpId);
3129
+ if (a.ok) {
3130
+ via = "confirm";
3131
+ keyId = key.id;
3132
+ } else refusal = { code: "bad_assertion", why: a.why };
3133
+ }
3134
+ }
3135
+ if (!via) {
3136
+ if (refusal) return bad(refusal.code, refusal.why);
3137
+ return bad("unsigned", "the command isn't signed");
3138
+ }
3139
+ if (c.iat > o.now + SKEW_MS) return bad("future", "the command is dated in the future (is this clock right?)");
3140
+ if (o.now - c.iat > (o.maxAgeMs ?? MACHINE_MAX_AGE_MS)) return bad("too_old", "the command was signed too long ago");
3141
+ if (o.seenNonce) {
3142
+ let seen;
3143
+ try {
3144
+ seen = await o.seenNonce(c.nonce);
3145
+ } catch {
3146
+ return bad("nonce_store", "the nonce couldn't be recorded, so the command didn't run");
3147
+ }
3148
+ if (seen) return bad("replay", "this command was already used once");
3149
+ }
3150
+ return { ok: true, cmd: c, via, key: keyId };
3151
+ }
3152
+ async function checkGrantPath(s, c, o) {
3153
+ const bundle = s.grant;
3154
+ const g = parseGrant(bundle.str, o.rpId);
3155
+ if (!g.ok) return bad("bad_grant", g.why);
3156
+ const key = o.trusted.find((k) => k && k.id === bundle.cred);
3157
+ if (!key) return bad("untrusted_key", "the grant was signed with a key this machine doesn't trust");
3158
+ const a = await verifyAssertion(await sha256(bundle.str), bundle, key, o.rpId);
3159
+ if (!a.ok) return bad("bad_assertion", a.why);
3160
+ if (!await verifyCsig(s.cmd, s.csig, g.grant.pub))
3161
+ return bad("bad_csig", "the command's signature doesn't verify \u2014 it was changed after it was signed");
3162
+ if (c.iat < g.grant.iat - SKEW_MS || c.iat > g.grant.exp)
3163
+ return bad("outside_grant", "the command was signed outside its grant's window");
3164
+ return { ok: true, key: key.id };
3165
+ }
3166
+
3167
+ // src/control/reply.ts
3168
+ import { closeSync as closeSync4, openSync as openSync4, readSync as readSync4, statSync as statSync6 } from "node:fs";
3169
+ function transcriptEnd(file) {
3170
+ try {
3171
+ return statSync6(file).size;
3172
+ } catch {
3173
+ return 0;
3174
+ }
3175
+ }
3176
+ function answerIn(lines) {
3177
+ const parts = [];
3178
+ for (const line of lines) {
3179
+ let j;
3180
+ try {
3181
+ j = JSON.parse(line);
3182
+ } catch {
3183
+ continue;
3184
+ }
3185
+ if (j.type !== "assistant" || j.isSidechain) continue;
3186
+ const content = j.message?.content;
3187
+ if (Array.isArray(content)) {
3188
+ for (const c of content)
3189
+ if (c?.type === "text" && typeof c.text === "string" && c.text.trim()) parts.push(c.text.trim());
3190
+ }
3191
+ if (j.message?.stop_reason === "end_turn") return { done: true, text: parts.join("\n\n") };
3192
+ }
3193
+ return { done: false, text: parts.join("\n\n") };
3194
+ }
3195
+ async function awaitAnswer(file, from, o) {
3196
+ const now = o.now ?? Date.now;
3197
+ const sleep2 = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref?.()));
3198
+ const until = now() + o.timeoutMs;
3199
+ let offset = from;
3200
+ let partial = "";
3201
+ const lines = [];
3202
+ while (now() < until) {
3203
+ const size = transcriptEnd(file);
3204
+ if (size < offset) return null;
3205
+ if (size > offset) {
3206
+ const fd = openSync4(file, "r");
3207
+ try {
3208
+ const buf = Buffer.alloc(Math.min(size - offset, 4 * 1024 * 1024));
3209
+ const n = readSync4(fd, buf, 0, buf.length, offset);
3210
+ offset += n;
3211
+ const chunk = partial + buf.subarray(0, n).toString("utf8");
3212
+ const cut = chunk.lastIndexOf("\n");
3213
+ partial = cut < 0 ? chunk : chunk.slice(cut + 1);
3214
+ if (cut >= 0) lines.push(...chunk.slice(0, cut).split("\n").filter(Boolean));
3215
+ } finally {
3216
+ closeSync4(fd);
3217
+ }
3218
+ const a = answerIn(lines);
3219
+ if (a.done) return a.text || null;
3220
+ }
3221
+ await sleep2(o.pollMs ?? 500);
3222
+ }
3223
+ return null;
3224
+ }
3225
+
3226
+ // src/control/route.ts
3227
+ function routePrompt(s) {
3228
+ if (s.sdk) return { mode: "sdk" };
3229
+ if (s.waiter) return { mode: "waiter" };
3230
+ if (s.midTurn) return { mode: "turn" };
3231
+ if (s.api) return { mode: "api" };
3232
+ if (s.harness !== "claude-code") return { unsupported: `no idle delivery for ${s.harness} yet` };
3233
+ if (!s.known) return { refused: "no_session" };
3234
+ if (!s.live) return { mode: "resume" };
3235
+ if (s.canFork) return { mode: "fork" };
3236
+ return { unsupported: "the session is open and this Claude Code can't copy it (no --fork-session)" };
3237
+ }
3238
+ function kindPath(kind, harness) {
3239
+ switch (kind) {
3240
+ case "prompt":
3241
+ return "prompt";
3242
+ case "permission.answer":
3243
+ return harness === "gemini-cli" ? { unsupported: "Gemini CLI has no permission answer channel" } : "permission";
3244
+ case "cancel":
3245
+ return "cancel";
3246
+ case "start":
3247
+ return harness === "claude-code" ? "start" : { unsupported: `can't start a ${harness} session` };
3248
+ default:
3249
+ return { unsupported: `unknown kind ${String(kind)}` };
3250
+ }
3251
+ }
3252
+ var IN_PLACE_ANSWER = "Answer it right here, as you would answer the person at the keyboard: your reply is sent back to them automatically. Don't run a command or call a tool just to send it.";
3253
+ function frame(text, receiver) {
3254
+ let host = receiver;
3255
+ try {
3256
+ host = new URL(receiver).host;
3257
+ } catch {
3258
+ }
3259
+ return `Message via ${host} (sessionpipe control, signed on the sender's device):
3260
+
3261
+ ${text}`;
3262
+ }
3263
+
3264
+ // src/control/store.ts
3265
+ import {
3266
+ appendFileSync as appendFileSync3,
3267
+ chmodSync as chmodSync2,
3268
+ closeSync as closeSync5,
3269
+ existsSync as existsSync8,
3270
+ fsyncSync,
3271
+ mkdirSync as mkdirSync7,
3272
+ openSync as openSync5,
3273
+ readFileSync as readFileSync9,
3274
+ renameSync as renameSync2,
3275
+ writeFileSync as writeFileSync6
3276
+ } from "node:fs";
3277
+ import path12 from "node:path";
3278
+ var controlFile = (env = process.env) => path12.join(path12.dirname(configFile(env)), "control.json");
3279
+ var controlState = (env = process.env) => path12.join(stateDir(env), "control");
3280
+ function writePrivate(file, value) {
3281
+ mkdirSync7(path12.dirname(file), { recursive: true, mode: 448 });
3282
+ const tmp = `${file}.${process.pid}.tmp`;
3283
+ writeFileSync6(tmp, `${JSON.stringify(value, null, 2)}
3284
+ `, { mode: 384 });
3285
+ renameSync2(tmp, file);
3286
+ try {
3287
+ chmodSync2(file, 384);
3288
+ } catch {
3289
+ }
3290
+ }
3291
+ function readControl(env = process.env) {
3292
+ try {
3293
+ const j = JSON.parse(readFileSync9(controlFile(env), "utf8"));
3294
+ if (!j || !Array.isArray(j.receivers)) return null;
3295
+ return {
3296
+ name: j.name || "machine",
3297
+ folders: j.folders ?? [],
3298
+ mode: j.mode === "auto" ? "auto" : "safe",
3299
+ receivers: j.receivers
3300
+ };
3301
+ } catch {
3302
+ return null;
3303
+ }
3304
+ }
3305
+ var writeControl = (c, env = process.env) => writePrivate(controlFile(env), c);
3306
+ var NonceStore = class {
3307
+ constructor(file, now = Date.now) {
3308
+ this.file = file;
3309
+ this.now = now;
3310
+ let raw = "";
3311
+ try {
3312
+ raw = readFileSync9(file, "utf8");
3313
+ } catch {
3314
+ }
3315
+ const cut = this.now() - NONCE_TTL_MS;
3316
+ for (const line of raw.split("\n")) {
3317
+ if (!line) continue;
3318
+ try {
3319
+ const { n, t } = JSON.parse(line);
3320
+ if (typeof n === "string" && t > cut) this.seenAt.set(n, t);
3321
+ } catch {
3322
+ }
3323
+ }
3324
+ this.compact();
3325
+ }
3326
+ file;
3327
+ now;
3328
+ seenAt = /* @__PURE__ */ new Map();
3329
+ seen = (nonce) => {
3330
+ const t = this.seenAt.get(nonce);
3331
+ if (t !== void 0 && t > this.now() - NONCE_TTL_MS) return true;
3332
+ const at = this.now();
3333
+ mkdirSync7(path12.dirname(this.file), { recursive: true, mode: 448 });
3334
+ const fd = openSync5(this.file, "a", 384);
3335
+ try {
3336
+ appendFileSync3(fd, `${JSON.stringify({ n: nonce, t: at })}
3337
+ `);
3338
+ fsyncSync(fd);
3339
+ } finally {
3340
+ closeSync5(fd);
3341
+ }
3342
+ this.seenAt.set(nonce, at);
3343
+ return false;
3344
+ };
3345
+ /** Rewrite the file with only the live window (on start, and daily). */
3346
+ compact() {
3347
+ const cut = this.now() - NONCE_TTL_MS;
3348
+ for (const [n, t] of this.seenAt) if (t <= cut) this.seenAt.delete(n);
3349
+ if (!existsSync8(this.file) && !this.seenAt.size) return;
3350
+ const body = [...this.seenAt].map(([n, t]) => JSON.stringify({ n, t })).join("\n");
3351
+ mkdirSync7(path12.dirname(this.file), { recursive: true, mode: 448 });
3352
+ writeFileSync6(`${this.file}.tmp`, body ? `${body}
3353
+ ` : "", { mode: 384 });
3354
+ renameSync2(`${this.file}.tmp`, this.file);
3355
+ }
3356
+ get size() {
3357
+ return this.seenAt.size;
3358
+ }
3359
+ };
3360
+ var AckOutbox = class {
3361
+ constructor(file) {
3362
+ this.file = file;
3363
+ }
3364
+ file;
3365
+ all() {
3366
+ try {
3367
+ return readFileSync9(this.file, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
3368
+ } catch {
3369
+ return [];
3370
+ }
3371
+ }
3372
+ add(a) {
3373
+ mkdirSync7(path12.dirname(this.file), { recursive: true, mode: 448 });
3374
+ appendFileSync3(this.file, `${JSON.stringify(a)}
3375
+ `, { mode: 384 });
3376
+ }
3377
+ keep(left) {
3378
+ mkdirSync7(path12.dirname(this.file), { recursive: true, mode: 448 });
3379
+ writeFileSync6(`${this.file}.tmp`, left.map((a) => JSON.stringify(a)).join("\n") + (left.length ? "\n" : ""), {
3380
+ mode: 384
3381
+ });
3382
+ renameSync2(`${this.file}.tmp`, this.file);
3383
+ }
3384
+ };
3385
+ function readState(file) {
3386
+ try {
3387
+ const j = JSON.parse(readFileSync9(file, "utf8"));
3388
+ return { copies: j.copies && typeof j.copies === "object" ? j.copies : {} };
3389
+ } catch {
3390
+ return { copies: {} };
3391
+ }
3392
+ }
3393
+ var writeState = (file, s) => writePrivate(file, s);
3394
+
3395
+ // src/control/daemon.ts
3396
+ var {
3397
+ claudeEnv: claudeEnv2,
3398
+ findTranscript: findTranscript2,
3399
+ isUuid: isUuid2,
3400
+ JOB_TIMEOUT_MS: JOB_TIMEOUT_MS2,
3401
+ liveProcess: liveProcess2,
3402
+ modeFlags: modeFlags2,
3403
+ parseResult: parseResult2,
3404
+ recentlyWritten: recentlyWritten2,
3405
+ sessionFolder: sessionFolder2
3406
+ } = claude_code_control_exports;
3407
+ var VERSION = true ? "0.2.0" : "0.0.0";
3408
+ var MAX_REPLY = 2e4;
3409
+ var HELLO_EVERY_MS = 10 * 6e4;
3410
+ var TURN_FALLBACK_MS = 30 * 6e4;
3411
+ var ANSWER_WAIT_MS = 30 * 6e4;
3412
+ var RECENT_ACKS_MS = 25 * 36e5;
3413
+ var CLOSES_ATTENTION = /* @__PURE__ */ new Set([
3414
+ "PostToolUse",
3415
+ "PostToolUseFailure",
3416
+ "Stop",
3417
+ "StopFailure",
3418
+ "UserPromptSubmit",
3419
+ "SessionEnd"
3420
+ ]);
3421
+ var LIMITS = { startConcurrent: 2, startPerHour: 10, headless: 3 };
3422
+ var ControlDaemon = class {
3423
+ constructor(cfg, dir, sock, deps) {
3424
+ this.cfg = cfg;
3425
+ this.dir = dir;
3426
+ this.sock = sock;
3427
+ this.deps = deps;
3428
+ this.nonces = new NonceStore(path13.join(dir, "nonces.jsonl"), deps.now);
3429
+ this.acks = new AckOutbox(path13.join(dir, "acks.jsonl"));
3430
+ this.state = readState(path13.join(dir, "state.json"));
3431
+ }
3432
+ cfg;
3433
+ dir;
3434
+ sock;
3435
+ deps;
3436
+ server = null;
3437
+ live = /* @__PURE__ */ new Map();
3438
+ sdkSessions = /* @__PURE__ */ new Map();
3439
+ nonces;
3440
+ acks;
3441
+ recentAcks = /* @__PURE__ */ new Map();
3442
+ inflight = /* @__PURE__ */ new Set();
3443
+ queues = /* @__PURE__ */ new Map();
3444
+ lastEnd = /* @__PURE__ */ new Map();
3445
+ state;
3446
+ running = false;
3447
+ polling = /* @__PURE__ */ new Map();
3448
+ delivered = {};
3449
+ helloTimer = null;
3450
+ helloSoon = null;
3451
+ aborts = /* @__PURE__ */ new Set();
3452
+ startsRunning = 0;
3453
+ startTimes = [];
3454
+ procs = 0;
3455
+ procWaiters = [];
3456
+ // --- lifecycle ------------------------------------------------------------------
3457
+ async start() {
3458
+ this.running = true;
3459
+ mkdirSync8(this.dir, { recursive: true, mode: 448 });
3460
+ await this.listen();
3461
+ for (const r of this.cfg.receivers) void this.pollLoop(r);
3462
+ this.helloTimer = setInterval(() => void this.helloAll(), HELLO_EVERY_MS);
3463
+ this.helloTimer.unref();
3464
+ void this.helloAll();
3465
+ void this.flushAcks();
3466
+ const idle = setInterval(() => this.closeIdleSdk(), 6e4);
3467
+ idle.unref();
3468
+ this.deps.log(`control daemon ${VERSION} up \xB7 ${this.cfg.receivers.length} receiver(s) \xB7 socket ${this.sock}`);
3469
+ }
3470
+ async stop() {
3471
+ this.running = false;
3472
+ for (const a of this.aborts) a.abort();
3473
+ if (this.helloTimer) clearInterval(this.helloTimer);
3474
+ if (this.helloSoon) clearTimeout(this.helloSoon);
3475
+ for (const s of this.sdkSessions.values()) s.close();
3476
+ for (const l of this.live.values()) {
3477
+ l.waiter?.destroy();
3478
+ for (const a of l.attentions.values()) a.destroy();
3479
+ }
3480
+ await new Promise((r) => this.server ? this.server.close(() => r()) : r());
3481
+ if (process.platform !== "win32")
3482
+ try {
3483
+ unlinkSync4(this.sock);
3484
+ } catch {
3485
+ }
3486
+ }
3487
+ reload(cfg) {
3488
+ const known = new Set(this.cfg.receivers.map((r) => r.control + r.machine));
3489
+ this.cfg = cfg;
3490
+ for (const r of cfg.receivers) if (!known.has(r.control + r.machine)) void this.pollLoop(r);
3491
+ }
3492
+ status() {
3493
+ return {
3494
+ version: VERSION,
3495
+ name: this.cfg.name,
3496
+ receivers: this.cfg.receivers.map((r) => {
3497
+ const p = this.polling.get(r.control + r.machine);
3498
+ return {
3499
+ url: r.url,
3500
+ machine: r.machine,
3501
+ keys: r.keys.length,
3502
+ polling: !!p?.on,
3503
+ last_poll: p?.last ?? null,
3504
+ last_error: p?.error ?? null
3505
+ };
3506
+ }),
3507
+ waiting: [...this.live].filter(([, l]) => l.waiter).map(([k]) => k),
3508
+ mid_turn: [...this.live].filter(([, l]) => l.midTurn).map(([k]) => k),
3509
+ attentions: [...this.live].flatMap(([k, l]) => [...l.attentions.keys()].map((id) => ({ session: k, id }))),
3510
+ sdk: [...this.sdkSessions.keys()],
3511
+ nonces: this.nonces.size,
3512
+ acks_pending: this.acks.all().length,
3513
+ delivered: { ...this.delivered }
3514
+ };
3515
+ }
3516
+ // --- the local socket -----------------------------------------------------------
3517
+ listen() {
3518
+ if (process.platform !== "win32") {
3519
+ try {
3520
+ if (existsSync9(this.sock)) unlinkSync4(this.sock);
3521
+ } catch {
3522
+ }
3523
+ }
3524
+ this.server = net.createServer((s) => this.onConn(s));
3525
+ return new Promise((resolve, reject) => {
3526
+ this.server.once("error", reject);
3527
+ this.server.listen(this.sock, () => resolve());
3528
+ });
3529
+ }
3530
+ onConn(s) {
3531
+ let buf = "";
3532
+ let handled = false;
3533
+ s.on("error", () => {
3534
+ });
3535
+ s.on("data", (d) => {
3536
+ if (handled) return;
3537
+ buf += d.toString("utf8");
3538
+ if (buf.length > 64 * 1024) return s.destroy();
3539
+ const i = buf.indexOf("\n");
3540
+ if (i < 0) return;
3541
+ handled = true;
3542
+ let req;
3543
+ try {
3544
+ req = JSON.parse(buf.slice(0, i));
3545
+ } catch {
3546
+ return s.destroy();
3547
+ }
3548
+ this.onRequest(req, s);
3549
+ });
3550
+ }
3551
+ reply(s, r) {
3552
+ try {
3553
+ s.end(`${JSON.stringify(r)}
3554
+ `);
3555
+ } catch {
3556
+ }
3557
+ }
3558
+ liveOf(session) {
3559
+ let l = this.live.get(session);
3560
+ if (!l) {
3561
+ l = {
3562
+ midTurn: false,
3563
+ continued: false,
3564
+ waiter: null,
3565
+ turnQueue: [],
3566
+ attentions: /* @__PURE__ */ new Map(),
3567
+ lastEvent: this.deps.now()
3568
+ };
3569
+ this.live.set(session, l);
3570
+ }
3571
+ return l;
3572
+ }
3573
+ onRequest(req, s) {
3574
+ switch (req.op) {
3575
+ case "status":
3576
+ this.reply(s, { op: "status", status: this.status() });
3577
+ return;
3578
+ case "wait": {
3579
+ if (!parseSessionRef(req.session)) {
3580
+ this.reply(s, { op: "none" });
3581
+ return;
3582
+ }
3583
+ const l = this.liveOf(req.session);
3584
+ if (l.waiter && l.waiter !== s) this.reply(l.waiter, { op: "replaced" });
3585
+ l.waiter = s;
3586
+ s.on("close", () => {
3587
+ if (l.waiter === s) {
3588
+ l.waiter = null;
3589
+ this.scheduleHello();
3590
+ }
3591
+ });
3592
+ this.scheduleHello();
3593
+ return;
3594
+ }
3595
+ case "stop": {
3596
+ const l = this.liveOf(req.session);
3597
+ l.lastEvent = this.deps.now();
3598
+ const p = this.takeTurn(l);
3599
+ if (!p) {
3600
+ this.reply(s, { op: "none" });
3601
+ return;
3602
+ }
3603
+ l.continued = true;
3604
+ const from = this.answerFrom(req.session);
3605
+ this.reply(s, { op: "block", reason: `${frame(p.text, p.receiver.url)}
3606
+
3607
+ ${IN_PLACE_ANSWER}`, id: p.msgId });
3608
+ this.followAnswer(p.receiver, p.msgId, "turn", req.session, from, p.queuedAt);
3609
+ return;
3610
+ }
3611
+ case "permission": {
3612
+ const l = this.liveOf(req.session);
3613
+ const prev = l.attentions.get(req.attention);
3614
+ if (prev && prev !== s) this.reply(prev, { op: "none" });
3615
+ l.attentions.set(req.attention, s);
3616
+ s.on("close", () => {
3617
+ if (l.attentions.get(req.attention) === s) l.attentions.delete(req.attention);
3618
+ });
3619
+ return;
3620
+ }
3621
+ case "event":
3622
+ this.onEvent(req);
3623
+ this.reply(s, { op: "ok" });
3624
+ return;
3625
+ }
3626
+ }
3627
+ /** A queued turn message that hasn't expired. */
3628
+ takeTurn(l) {
3629
+ while (l.turnQueue.length) {
3630
+ const p = l.turnQueue.shift();
3631
+ if (!this.inflight.has(p.msgId)) continue;
3632
+ return p;
3633
+ }
3634
+ return null;
3635
+ }
3636
+ onEvent(e) {
3637
+ const l = this.liveOf(e.session);
3638
+ l.lastEvent = this.deps.now();
3639
+ if (e.cwd) l.cwd = e.cwd;
3640
+ if (e.transcript) l.transcript = e.transcript;
3641
+ if (CLOSES_ATTENTION.has(e.event) && l.attentions.size) {
3642
+ for (const s of l.attentions.values()) this.reply(s, { op: "none" });
3643
+ l.attentions.clear();
3644
+ }
3645
+ if (e.event === "UserPromptSubmit" || e.event === "SessionStart") {
3646
+ if (e.event === "UserPromptSubmit") l.midTurn = true;
3647
+ return;
3648
+ }
3649
+ if (e.event === "Stop" && l.continued) {
3650
+ l.continued = false;
3651
+ return;
3652
+ }
3653
+ if (e.event === "Stop" || e.event === "StopFailure" || e.event === "SessionEnd") {
3654
+ l.midTurn = false;
3655
+ l.continued = false;
3656
+ const left = l.turnQueue.splice(0);
3657
+ for (const p of left) if (this.inflight.has(p.msgId)) void this.reroute(p);
3658
+ }
3659
+ }
3660
+ // --- receivers ------------------------------------------------------------------
3661
+ async pollLoop(first) {
3662
+ const key = first.control + first.machine;
3663
+ const st = { last: null, error: null, on: true };
3664
+ this.polling.set(key, st);
3665
+ let backoff = 0;
3666
+ let downSince = 0;
3667
+ const steps = [1e3, 2e3, 5e3, 1e4, 15e3];
3668
+ const pause = (n) => steps[Math.min(n, steps.length - 1)] * (0.75 + Math.random() * 0.5);
3669
+ for (; ; ) {
3670
+ const r = this.cfg.receivers.find((x) => x.control + x.machine === key);
3671
+ if (!this.running || !r) break;
3672
+ const ctl = new AbortController();
3673
+ this.aborts.add(ctl);
3674
+ const t = setTimeout(() => ctl.abort(), 35e3);
3675
+ try {
3676
+ const u = new URL(r.control);
3677
+ u.searchParams.set("machine", r.machine);
3678
+ u.searchParams.set("wait", "25");
3679
+ const asked = this.deps.now();
3680
+ const res = await this.deps.fetch(u, { headers: { authorization: `Bearer ${r.token}` }, signal: ctl.signal });
3681
+ st.last = this.deps.now();
3682
+ if (downSince && res.status < 500) {
3683
+ this.deps.log(`${r.url}: back after ${Math.round((asked - downSince) / 1e3)} s (${backoff} failed tries)`);
3684
+ downSince = 0;
3685
+ }
3686
+ if (res.status === 401) {
3687
+ st.error = "the receiver no longer knows this machine (401); run `sessionpipe control pair` again";
3688
+ this.deps.log(`${r.url}: ${st.error}`);
3689
+ break;
3690
+ }
3691
+ if (res.status === 200) {
3692
+ const body = await res.json();
3693
+ const cur = this.cfg.receivers.find((x) => x.control + x.machine === key) ?? r;
3694
+ let fresh = 0;
3695
+ for (const m of body.messages ?? []) {
3696
+ if (m && !this.inflight.has(m.id)) fresh++;
3697
+ void this.handle(cur, m).catch(
3698
+ (e) => this.deps.log(`handle ${String(m?.id).slice(-8)}: ${String(e?.message || e)}`)
3699
+ );
3700
+ }
3701
+ if (!fresh) await sleep(2e3);
3702
+ st.error = null;
3703
+ backoff = 0;
3704
+ } else if (res.status === 204) {
3705
+ st.error = null;
3706
+ backoff = 0;
3707
+ } else {
3708
+ st.error = `HTTP ${res.status}`;
3709
+ downSince ||= this.deps.now();
3710
+ await sleep(pause(backoff++));
3711
+ }
3712
+ } catch (e) {
3713
+ if (!this.running) break;
3714
+ st.error = String(e?.message || e).slice(0, 120);
3715
+ downSince ||= this.deps.now();
3716
+ await sleep(pause(backoff++));
3717
+ } finally {
3718
+ clearTimeout(t);
3719
+ this.aborts.delete(ctl);
3720
+ }
3721
+ }
3722
+ st.on = false;
3723
+ }
3724
+ scheduleHello() {
3725
+ if (this.helloSoon) return;
3726
+ this.helloSoon = setTimeout(() => {
3727
+ this.helloSoon = null;
3728
+ void this.helloAll();
3729
+ }, 1500);
3730
+ this.helloSoon.unref();
3731
+ }
3732
+ async helloAll() {
3733
+ const waiting = [...this.live].filter(([, l]) => l.waiter).map(([k]) => k);
3734
+ const modes = ["waiter", "turn", "resume", "fork", ...this.deps.sdk ? ["sdk"] : []];
3735
+ for (const r of this.cfg.receivers) {
3736
+ const body = {
3737
+ name: this.cfg.name,
3738
+ harnesses: ["claude-code"],
3739
+ modes,
3740
+ keys: r.keys.map((k) => k.id),
3741
+ version: VERSION,
3742
+ waiting,
3743
+ folders: this.cfg.folders,
3744
+ mode: this.cfg.mode
3745
+ };
3746
+ await this.post(r, "/hello", body).catch(() => {
3747
+ });
3748
+ }
3749
+ }
3750
+ async post(r, sub, body) {
3751
+ const ctl = new AbortController();
3752
+ const t = setTimeout(() => ctl.abort(), 15e3);
3753
+ try {
3754
+ return await this.deps.fetch(`${r.control.replace(/\/$/, "")}${sub}`, {
3755
+ method: "POST",
3756
+ headers: { authorization: `Bearer ${r.token}`, "content-type": "application/json" },
3757
+ body: JSON.stringify(body),
3758
+ signal: ctl.signal
3759
+ });
3760
+ } finally {
3761
+ clearTimeout(t);
3762
+ }
3763
+ }
3764
+ // --- acks -----------------------------------------------------------------------
3765
+ finish(r, id, a, since) {
3766
+ const ack = { receiver: r.control + r.machine, id, at: new Date(this.deps.now()).toISOString(), ...a };
3767
+ if (ack.reply) ack.reply = redactSecrets(ack.reply).slice(0, MAX_REPLY);
3768
+ if (ack.detail) ack.detail = redactSecrets(ack.detail).slice(0, 500);
3769
+ if (a.outcome !== "taken") {
3770
+ this.inflight.delete(id);
3771
+ this.recentAcks.set(id, { ack, at: this.deps.now() });
3772
+ this.delivered[a.mode ?? a.outcome] = (this.delivered[a.mode ?? a.outcome] ?? 0) + 1;
3773
+ this.deps.log(
3774
+ `ack ${id.slice(-8)} ${a.outcome}${a.mode ? ` via ${a.mode}` : ""}${a.code ? ` (${a.code})` : ""}${since ? ` send\u2192${a.outcome} ${this.deps.now() - since} ms` : ""}`
3775
+ );
3776
+ }
3777
+ this.acks.add(ack);
3778
+ void this.flushAcks();
3779
+ }
3780
+ flushing = false;
3781
+ async flushAcks() {
3782
+ if (this.flushing) return;
3783
+ this.flushing = true;
3784
+ try {
3785
+ for (let round = 0; round < 5; round++) {
3786
+ const all = this.acks.all();
3787
+ if (!all.length) return;
3788
+ const left = [];
3789
+ for (const r of this.cfg.receivers) {
3790
+ const mine = all.filter((a) => a.receiver === r.control + r.machine);
3791
+ if (!mine.length) continue;
3792
+ const acks = mine.map(({ receiver: _r, ...a }) => a);
3793
+ const ok = await this.post(r, "/ack", { acks }).then((res) => res.status >= 200 && res.status < 300).catch(() => false);
3794
+ if (!ok) left.push(...mine);
3795
+ }
3796
+ const now = this.acks.all();
3797
+ const added = now.slice(all.length);
3798
+ this.acks.keep([...left, ...added]);
3799
+ if (!left.length && !added.length) return;
3800
+ if (left.length) await sleep(2e3 * (round + 1));
3801
+ }
3802
+ } finally {
3803
+ this.flushing = false;
3804
+ }
3805
+ }
3806
+ // --- one message ----------------------------------------------------------------
3807
+ async handle(r, m) {
3808
+ if (!m || typeof m.id !== "string") return;
3809
+ if (this.inflight.has(m.id)) return;
3810
+ const prior = this.recentAcks.get(m.id);
3811
+ if (prior && this.deps.now() - prior.at < RECENT_ACKS_MS) {
3812
+ this.acks.add(prior.ack);
3813
+ void this.flushAcks();
3814
+ return;
3815
+ }
3816
+ this.inflight.add(m.id);
3817
+ const sent = Date.parse(m.at) || this.deps.now();
3818
+ if (Date.parse(m.expires_at) <= this.deps.now()) return this.finish(r, m.id, { outcome: "expired" });
3819
+ const v = await verifyCommand(
3820
+ { cmd: m.cmd, csig: m.csig ?? null, grant: m.grant ?? null, confirm: m.confirm ?? null },
3821
+ { machine: r.machine, rpId: r.rpId, trusted: r.keys, now: this.deps.now(), seenNonce: this.nonces.seen }
3822
+ );
3823
+ if (!v.ok) {
3824
+ this.deps.log(`refused ${m.id.slice(-8)}: ${v.code} \u2014 ${v.why}`);
3825
+ return this.finish(r, m.id, { outcome: "refused", code: v.code, detail: v.why });
3826
+ }
3827
+ const cmd2 = v.cmd;
3828
+ const ref = parseSessionRef(cmd2.session);
3829
+ const kp = kindPath(cmd2.kind, ref.harness);
3830
+ if (typeof kp === "object") return this.finish(r, m.id, { outcome: "unsupported", detail: kp.unsupported });
3831
+ try {
3832
+ if (kp === "start") return await this.startSession(r, m.id, cmd2, sent);
3833
+ const place = this.place(cmd2.session);
3834
+ if ("refused" in place)
3835
+ return this.finish(r, m.id, { outcome: "refused", code: place.refused, detail: place.why });
3836
+ if (kp === "permission") return this.permission(r, m.id, cmd2, sent);
3837
+ if (kp === "cancel") return await this.cancel(r, m.id, cmd2, sent);
3838
+ return await this.serial(`deliver:${cmd2.session}`, async () => {
3839
+ const now = this.place(cmd2.session);
3840
+ if ("refused" in now) return this.finish(r, m.id, { outcome: "refused", code: now.refused, detail: now.why });
3841
+ return this.prompt(r, m.id, cmd2, now, sent);
3842
+ });
3843
+ } catch (e) {
3844
+ this.finish(r, m.id, { outcome: "failed", detail: String(e?.message || e) });
3845
+ }
3846
+ }
3847
+ /** Where the session lives, and whether its folder is allowed — read from the
3848
+ * session's own transcript (Claude Code) or the hooks' cwd, never the command. */
3849
+ place(session) {
3850
+ const ref = parseSessionRef(session);
3851
+ if (ref.harness === "claude-code") {
3852
+ const target = this.state.copies[ref.id] ?? ref.id;
3853
+ const t = findTranscript2(target, this.deps.claudeDirs()) ?? findTranscript2(ref.id, this.deps.claudeDirs());
3854
+ if (!t) return { refused: "no_session", why: `no Claude Code session ${ref.id.slice(0, 8)} on this machine` };
3855
+ const folder = sessionFolder2(t);
3856
+ const ok2 = folder && this.allowed(folder);
3857
+ if (!ok2) return { refused: "folder", why: "that session's folder isn't one this machine allows" };
3858
+ return { folder: ok2, transcript: t };
3859
+ }
3860
+ const cwd = this.live.get(session)?.cwd;
3861
+ const ok = cwd && this.allowed(cwd);
3862
+ if (!ok) return { refused: cwd ? "folder" : "no_session", why: "no allowed folder known for that session" };
3863
+ return { folder: ok, transcript: null };
3864
+ }
3865
+ allowed(dir) {
3866
+ if (!path13.isAbsolute(dir)) return null;
3867
+ let r;
3868
+ try {
3869
+ r = realpathSync2.native(dir);
3870
+ if (!statSync7(r).isDirectory()) return null;
3871
+ } catch {
3872
+ return null;
3873
+ }
3874
+ for (const f of this.cfg.folders) {
3875
+ let rf = f;
3876
+ try {
3877
+ rf = realpathSync2.native(f);
3878
+ } catch {
3879
+ }
3880
+ if (r === rf || r.startsWith(rf.endsWith(path13.sep) ? rf : rf + path13.sep)) return r;
3881
+ }
3882
+ return null;
3883
+ }
3884
+ permission(r, id, cmd2, sent) {
3885
+ const l = this.live.get(cmd2.session);
3886
+ const hook = l?.attentions.get(cmd2.for);
3887
+ if (!hook) {
3888
+ if (cmd2.decision === "allow")
3889
+ return this.finish(r, id, {
3890
+ outcome: "refused",
3891
+ code: "no_attention",
3892
+ detail: "no permission prompt open for that id"
3893
+ });
3894
+ this.finish(r, id, { outcome: "expired", detail: "the prompt was already answered or closed" });
3895
+ return;
3896
+ }
3897
+ l.attentions.delete(cmd2.for);
3898
+ this.reply(hook, {
3899
+ op: "decision",
3900
+ behavior: cmd2.decision,
3901
+ ...cmd2.note ? { message: cmd2.note } : {},
3902
+ id
3903
+ });
3904
+ this.finish(r, id, { outcome: "delivered", mode: "turn" }, sent);
3905
+ }
3906
+ async cancel(r, id, cmd2, sent) {
3907
+ const ref = parseSessionRef(cmd2.session);
3908
+ const held = this.sdkSessions.get(ref.id);
3909
+ if (!held)
3910
+ return this.finish(r, id, {
3911
+ outcome: "unsupported",
3912
+ detail: "no cancel path for a session the daemon doesn't hold"
3913
+ });
3914
+ await held.interrupt();
3915
+ this.finish(r, id, { outcome: "delivered", mode: "sdk" }, sent);
3916
+ }
3917
+ view(session, transcript) {
3918
+ const ref = parseSessionRef(session);
3919
+ const l = this.live.get(session);
3920
+ const claude = ref.harness === "claude-code" ? this.deps.claude() : null;
3921
+ const target = this.state.copies[ref.id] ?? ref.id;
3922
+ const t = transcript;
3923
+ const live = !!t && (liveProcess2(target, t.configDir) || recentlyWritten2(t.file, this.lastEnd.get(target), this.deps.now()));
3924
+ return {
3925
+ harness: ref.harness,
3926
+ sdk: this.sdkSessions.has(ref.id),
3927
+ waiter: !!l?.waiter && !l.waiter.destroyed,
3928
+ midTurn: !!l?.midTurn,
3929
+ live,
3930
+ known: !!t,
3931
+ canFork: !!claude?.caps.fork,
3932
+ api: false
3933
+ };
3934
+ }
3935
+ async prompt(r, id, cmd2, place, sent) {
3936
+ const route = routePrompt(this.view(cmd2.session, place.transcript));
3937
+ if ("unsupported" in route) return this.finish(r, id, { outcome: "unsupported", detail: route.unsupported });
3938
+ if ("refused" in route) return this.finish(r, id, { outcome: "refused", code: route.refused });
3939
+ const ref = parseSessionRef(cmd2.session);
3940
+ const text = cmd2.text;
3941
+ switch (route.mode) {
3942
+ case "waiter": {
3943
+ const l = this.live.get(cmd2.session);
3944
+ const w = l.waiter;
3945
+ l.waiter = null;
3946
+ const from = this.answerFrom(cmd2.session);
3947
+ this.reply(w, { op: "message", text: frame(text, r.url), id });
3948
+ this.scheduleHello();
3949
+ return this.followAnswer(r, id, "waiter", cmd2.session, from, sent);
3950
+ }
3951
+ case "turn": {
3952
+ const l = this.liveOf(cmd2.session);
3953
+ const p = { receiver: r, msgId: id, text, session: cmd2.session, queuedAt: sent };
3954
+ l.turnQueue.push(p);
3955
+ this.finish(r, id, { outcome: "taken" });
3956
+ const t = setTimeout(() => {
3957
+ if (this.inflight.has(id) && l.turnQueue.includes(p)) {
3958
+ l.turnQueue.splice(l.turnQueue.indexOf(p), 1);
3959
+ void this.reroute(p);
3960
+ }
3961
+ }, TURN_FALLBACK_MS);
3962
+ t.unref();
3963
+ return;
3964
+ }
3965
+ case "sdk": {
3966
+ const s = this.sdkSessions.get(ref.id);
3967
+ this.finish(r, id, { outcome: "taken" });
3968
+ const out2 = await s.send(frame(text, r.url));
3969
+ return this.finish(
3970
+ r,
3971
+ id,
3972
+ out2.error ? { outcome: "failed", detail: out2.error } : { outcome: "delivered", mode: "sdk", reply: out2.reply },
3973
+ sent
3974
+ );
3975
+ }
3976
+ case "resume":
3977
+ case "fork":
3978
+ return this.headless(r, id, cmd2, place, route.mode, sent);
3979
+ case "api":
3980
+ return this.finish(r, id, { outcome: "unsupported", detail: "no input API wired for this harness" });
3981
+ }
3982
+ }
3983
+ /** A turn message whose Stop never took it: route again, now that it isn't mid-turn. */
3984
+ async reroute(p) {
3985
+ const l = this.liveOf(p.session);
3986
+ l.midTurn = false;
3987
+ const place = this.place(p.session);
3988
+ if ("refused" in place) return this.finish(p.receiver, p.msgId, { outcome: "refused", code: place.refused });
3989
+ const cmd2 = { kind: "prompt", session: p.session, text: p.text };
3990
+ this.inflight.delete(p.msgId);
3991
+ this.inflight.add(p.msgId);
3992
+ await this.prompt(p.receiver, p.msgId, cmd2, place, p.queuedAt).catch(
3993
+ (e) => this.finish(p.receiver, p.msgId, { outcome: "failed", detail: String(e?.message || e) })
3994
+ );
3995
+ }
3996
+ /** Jobs for one session (and its copy) run one at a time, in order. */
3997
+ serial(key, task) {
3998
+ const prev = this.queues.get(key) ?? Promise.resolve();
3999
+ const next = prev.then(task, task);
4000
+ this.queues.set(
4001
+ key,
4002
+ next.catch(() => {
4003
+ })
4004
+ );
4005
+ return next;
4006
+ }
4007
+ async headless(r, id, cmd2, place, mode, sent) {
4008
+ const claude = this.deps.claude();
4009
+ if (!claude)
4010
+ return this.finish(r, id, { outcome: "failed", detail: "Claude Code isn't installed on this machine" });
4011
+ const mf = modeFlags2(this.cfg.mode, claude.caps);
4012
+ if ("error" in mf) return this.finish(r, id, { outcome: "refused", code: "mode", detail: mf.error });
4013
+ const ref = parseSessionRef(cmd2.session);
4014
+ this.finish(r, id, { outcome: "taken" });
4015
+ await this.serial(ref.id, async () => {
4016
+ const target = this.state.copies[ref.id] ?? ref.id;
4017
+ const resume = mode === "fork" ? ["--resume", target, "--fork-session"] : ["--resume", target];
4018
+ const args2 = ["-p", "--output-format", "json", ...mf.args, ...resume, frame(cmd2.text, r.url)];
4019
+ this.deps.log(`${id.slice(-8)}: claude -p ${[...mf.args, ...resume].join(" ")} in ${place.folder}`);
4020
+ const res = await this.slot(
4021
+ () => this.deps.run(claude.bin, args2, {
4022
+ cwd: place.folder,
4023
+ env: claudeEnv2(place.transcript?.configDir, this.deps.env),
4024
+ timeoutMs: JOB_TIMEOUT_MS2
4025
+ })
4026
+ );
4027
+ this.lastEnd.set(target, this.deps.now());
4028
+ const out2 = this.outcome(res);
4029
+ if ("failed" in out2) return this.finish(r, id, { outcome: "failed", detail: out2.failed });
4030
+ const ran = isUuid2(out2.session) ? out2.session : target;
4031
+ if (mode === "fork" && ran !== target) {
4032
+ this.state.copies[ref.id] = ran;
4033
+ writeState(path13.join(this.dir, "state.json"), this.state);
4034
+ this.lastEnd.set(ran, this.deps.now());
4035
+ }
4036
+ this.finish(
4037
+ r,
4038
+ id,
4039
+ { outcome: "delivered", mode, reply: out2.reply, ...ran !== ref.id ? { session: `claude-code:${ran}` } : {} },
4040
+ sent
4041
+ );
4042
+ });
4043
+ }
4044
+ outcome(res) {
4045
+ if (res.error) return { failed: `couldn't start Claude Code: ${res.error}` };
4046
+ if (res.timedOut) return { failed: "Claude Code was still working after 30 minutes, so it was stopped" };
4047
+ if (res.tooBig) return { failed: "Claude Code's answer was too large to read" };
4048
+ const j = parseResult2(res.stdout);
4049
+ if (!j) {
4050
+ const why = String(res.stderr || "").trim().split("\n").slice(-3).join(" ");
4051
+ return {
4052
+ failed: `Claude Code exited (${res.signal || `code ${res.code}`}) without an answer${why ? `: ${why}` : ""}`
4053
+ };
4054
+ }
4055
+ if (j.is_error) return { failed: String(j.result || j.subtype || "Claude Code reported an error") };
4056
+ const denials = Array.isArray(j.permission_denials) ? j.permission_denials : [];
4057
+ const names = [...new Set(denials.map((d) => d?.tool_name).filter((n) => typeof n === "string"))];
4058
+ const tail2 = denials.length ? `
4059
+
4060
+ (Not allowed on this machine, since nobody was there to approve: ${names.join(", ") || `${denials.length} action(s)`}.)` : "";
4061
+ const text = typeof j.result === "string" && j.result.trim() ? j.result : "(Claude Code finished without a written reply.)";
4062
+ return { reply: text + tail2, ...typeof j.session_id === "string" ? { session: j.session_id } : {} };
4063
+ }
4064
+ /** Where the session's transcript ends right now: its answer starts after this. */
4065
+ answerFrom(session) {
4066
+ const ref = parseSessionRef(session);
4067
+ if (!ref || ref.harness !== "claude-code") return null;
4068
+ const t = findTranscript2(this.state.copies[ref.id] ?? ref.id, this.deps.claudeDirs());
4069
+ return t ? { file: t.file, offset: transcriptEnd(t.file) } : null;
4070
+ }
4071
+ /** A message handed to a live session (a waiter, a Stop block) is `taken` now; the
4072
+ * session answers in its own turn, which the daemon reads from the transcript and
4073
+ * sends back as the final ack's `reply` — so the person sees the answer where they
4074
+ * asked. A turn that doesn't end within ANSWER_WAIT_MS is acked delivered without. */
4075
+ followAnswer(r, id, mode, session, from, sent) {
4076
+ this.deps.log(`${id.slice(-8)}: in ${session.slice(0, 24)} via ${mode} ${this.deps.now() - sent} ms after send`);
4077
+ this.finish(r, id, { outcome: "taken" });
4078
+ void (async () => {
4079
+ const answer = from ? await awaitAnswer(from.file, from.offset, {
4080
+ timeoutMs: this.deps.answerWaitMs ?? ANSWER_WAIT_MS,
4081
+ now: this.deps.now
4082
+ }) : null;
4083
+ this.finish(r, id, { outcome: "delivered", mode, ...answer ? { reply: answer } : {} }, sent);
4084
+ })().catch((e) => this.finish(r, id, { outcome: "delivered", mode, detail: String(e?.message || e) }));
4085
+ }
4086
+ limits() {
4087
+ const l = this.cfg.limits ?? {};
4088
+ const n = (v, d) => typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : d;
4089
+ return {
4090
+ startConcurrent: n(l.start_concurrent, LIMITS.startConcurrent),
4091
+ startPerHour: n(l.start_per_hour, LIMITS.startPerHour),
4092
+ headless: Math.max(1, n(l.headless, LIMITS.headless))
4093
+ };
4094
+ }
4095
+ /** One headless Claude Code process at a time per slot; the rest wait in order. */
4096
+ async slot(fn) {
4097
+ while (this.procs >= this.limits().headless) await new Promise((r) => this.procWaiters.push(r));
4098
+ this.procs++;
4099
+ try {
4100
+ return await fn();
4101
+ } finally {
4102
+ this.procs--;
4103
+ this.procWaiters.shift()?.();
4104
+ }
4105
+ }
4106
+ async startSession(r, id, cmd2, sent) {
4107
+ const lim = this.limits();
4108
+ const now = this.deps.now();
4109
+ this.startTimes = this.startTimes.filter((t) => now - t < 36e5);
4110
+ if (this.startsRunning >= lim.startConcurrent || this.startTimes.length >= lim.startPerHour) {
4111
+ const detail = this.startsRunning >= lim.startConcurrent ? `this machine is already starting ${this.startsRunning} new session(s); it starts at most ${lim.startConcurrent} at once` : `this machine started ${this.startTimes.length} new sessions in the last hour, its limit`;
4112
+ this.deps.log(`refused ${id.slice(-8)}: start_limit \u2014 ${detail}`);
4113
+ return this.finish(r, id, { outcome: "refused", code: "start_limit", detail });
4114
+ }
4115
+ this.startsRunning++;
4116
+ this.startTimes.push(now);
4117
+ try {
4118
+ await this.startOne(r, id, cmd2, sent);
4119
+ } finally {
4120
+ this.startsRunning--;
4121
+ }
4122
+ }
4123
+ async startOne(r, id, cmd2, sent) {
4124
+ const ref = parseSessionRef(cmd2.session);
4125
+ const cwd = this.allowed(cmd2.cwd);
4126
+ if (!cwd)
4127
+ return this.finish(r, id, {
4128
+ outcome: "refused",
4129
+ code: "folder",
4130
+ detail: "that folder isn't one this machine allows"
4131
+ });
4132
+ if (findTranscript2(ref.id, this.deps.claudeDirs()) || this.sdkSessions.has(ref.id))
4133
+ return this.finish(r, id, {
4134
+ outcome: "refused",
4135
+ code: "bad_session",
4136
+ detail: "that new session's id is already taken"
4137
+ });
4138
+ this.finish(r, id, { outcome: "taken" });
4139
+ if (this.deps.sdk) {
4140
+ const sdk = this.deps.sdk;
4141
+ const out3 = await this.slot(async () => {
4142
+ const s = sdk.start({ session: ref.id, cwd, mode: this.cfg.mode });
4143
+ this.sdkSessions.set(ref.id, s);
4144
+ return s.send(frame(cmd2.text, r.url));
4145
+ });
4146
+ return this.finish(
4147
+ r,
4148
+ id,
4149
+ out3.error ? { outcome: "failed", detail: out3.error } : { outcome: "delivered", mode: "sdk", reply: out3.reply },
4150
+ sent
4151
+ );
4152
+ }
4153
+ const claude = this.deps.claude();
4154
+ if (!claude)
4155
+ return this.finish(r, id, { outcome: "failed", detail: "Claude Code isn't installed on this machine" });
4156
+ const mf = modeFlags2(this.cfg.mode, claude.caps);
4157
+ if ("error" in mf) return this.finish(r, id, { outcome: "refused", code: "mode", detail: mf.error });
4158
+ const res = await this.slot(
4159
+ () => this.deps.run(
4160
+ claude.bin,
4161
+ ["-p", "--output-format", "json", ...mf.args, "--session-id", ref.id, frame(cmd2.text, r.url)],
4162
+ { cwd, env: claudeEnv2(void 0, this.deps.env), timeoutMs: JOB_TIMEOUT_MS2 }
4163
+ )
4164
+ );
4165
+ this.lastEnd.set(ref.id, this.deps.now());
4166
+ const out2 = this.outcome(res);
4167
+ if ("failed" in out2) return this.finish(r, id, { outcome: "failed", detail: out2.failed });
4168
+ this.finish(r, id, { outcome: "delivered", mode: "resume", reply: out2.reply }, sent);
4169
+ }
4170
+ closeIdleSdk() {
4171
+ const idle = this.deps.sdkIdleMs ?? 10 * 6e4;
4172
+ for (const [id, s] of this.sdkSessions)
4173
+ if (!s.busy && this.deps.now() - s.lastUsed > idle) {
4174
+ s.close();
4175
+ this.sdkSessions.delete(id);
4176
+ this.deps.log(
4177
+ `sdk session ${id.slice(0, 8)} closed after ${Math.round(idle / 6e4)} min idle (it resumes on the next message)`
4178
+ );
4179
+ }
4180
+ }
4181
+ };
4182
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
4183
+
4184
+ // src/control/local.ts
4185
+ import net2 from "node:net";
4186
+ import os3 from "node:os";
4187
+ import path14 from "node:path";
4188
+ function socketPath(state2, env = process.env) {
4189
+ if (env.SESSIONPIPE_SOCKET) return env.SESSIONPIPE_SOCKET;
4190
+ if (process.platform === "win32") return `\\\\.\\pipe\\sessionpipe-control-${os3.userInfo().username}`;
4191
+ return path14.join(state2, "control.sock");
4192
+ }
4193
+ function ask(sock, req, timeoutMs) {
4194
+ return new Promise((resolve) => {
4195
+ let buf = "";
4196
+ let done = false;
4197
+ const s = net2.connect(sock);
4198
+ const finish = (r) => {
4199
+ if (done) return;
4200
+ done = true;
4201
+ clearTimeout(t);
4202
+ s.destroy();
4203
+ resolve(r);
4204
+ };
4205
+ const t = setTimeout(() => finish(null), timeoutMs);
4206
+ s.on("connect", () => s.write(`${JSON.stringify(req)}
4207
+ `));
4208
+ s.on("data", (d) => {
4209
+ buf += d.toString("utf8");
4210
+ const i = buf.indexOf("\n");
4211
+ if (i < 0) return;
4212
+ try {
4213
+ finish(JSON.parse(buf.slice(0, i)));
4214
+ } catch {
4215
+ finish(null);
4216
+ }
4217
+ });
4218
+ s.on("error", () => finish(null));
4219
+ s.on("close", () => finish(null));
4220
+ });
4221
+ }
4222
+
4223
+ // src/control/pair.ts
4224
+ import { execFileSync as execFileSync5 } from "node:child_process";
4225
+ import { existsSync as existsSync10, mkdirSync as mkdirSync9, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7 } from "node:fs";
4226
+ import os4 from "node:os";
4227
+ import path15 from "node:path";
4228
+ var VERSION2 = true ? "0.2.0" : "0.0.0";
4229
+ async function json(r) {
4230
+ try {
4231
+ return await r.json();
4232
+ } catch {
4233
+ return {};
4234
+ }
4235
+ }
4236
+ async function discover(url, f) {
4237
+ const base = url.replace(/\/$/, "");
4238
+ const r = await f(`${base}/.well-known/sessionpipe`);
4239
+ if (!r.ok) throw new Error(`${base} has no sessionpipe well-known file (HTTP ${r.status})`);
4240
+ const wk = await r.json();
4241
+ if (!wk.capabilities?.includes("control") || !wk.endpoints?.control || !wk.control?.signing?.rp_id)
4242
+ throw new Error(`${base} doesn't serve control yet (its well-known file lists no control.signing)`);
4243
+ const ep = wk.endpoints.control;
4244
+ return { control: /^https?:\/\//.test(ep) ? ep : base + ep, rpId: wk.control.signing.rp_id };
4245
+ }
4246
+ async function pair(o) {
4247
+ const f = o.fetch ?? fetch;
4248
+ const sleep2 = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
4249
+ const { control, rpId } = await discover(o.url, f);
4250
+ const cfg = readControl(o.env) ?? { name: o.name, folders: [], mode: o.mode, receivers: [] };
4251
+ cfg.name = o.name || cfg.name;
4252
+ cfg.mode = o.mode;
4253
+ cfg.folders = [.../* @__PURE__ */ new Set([...cfg.folders, ...o.folders.map((d) => path15.resolve(d))])];
4254
+ const existing = cfg.receivers.find((r) => r.control === control);
4255
+ const auth = { authorization: `Bearer ${o.token}`, "content-type": "application/json" };
4256
+ const started = await f(`${control}/pair`, {
4257
+ method: "POST",
4258
+ headers: auth,
4259
+ body: JSON.stringify({
4260
+ ...existing ? { machine: existing.machine } : {},
4261
+ name: cfg.name,
4262
+ harnesses: ["claude-code"],
4263
+ modes: ["waiter", "turn", "resume", "fork"],
4264
+ version: VERSION2,
4265
+ folders: cfg.folders,
4266
+ mode: cfg.mode
4267
+ })
4268
+ });
4269
+ const s = await json(started);
4270
+ if (started.status !== 201 && started.status !== 200)
4271
+ throw new Error(
4272
+ `the receiver refused to start pairing (HTTP ${started.status}${s.reason ? `: ${String(s.reason)}` : ""})`
4273
+ );
4274
+ const machine = String(s.machine);
4275
+ const code = String(s.code);
4276
+ o.out("");
4277
+ o.out(` Open this link and confirm with your passkey (Touch ID, Face ID, a security key):`);
4278
+ o.out(` ${String(s.url)}`);
4279
+ o.out(` The page shows these digits too: ${String(s.check)}`);
4280
+ o.out("");
4281
+ const until = Date.parse(String(s.expires_at)) || Date.now() + 10 * 6e4;
4282
+ for (; ; ) {
4283
+ if (Date.now() > until) throw new Error("the pairing link expired; run `sessionpipe control pair` again");
4284
+ await sleep2(2e3);
4285
+ const r = await f(`${control}/pair?code=${encodeURIComponent(code)}`, { headers: auth }).catch(() => null);
4286
+ if (!r) continue;
4287
+ if (r.status === 404) throw new Error("the receiver lost the pairing; run `sessionpipe control pair` again");
4288
+ const p = await json(r);
4289
+ if (p.status === "expired") throw new Error("the pairing link expired; run `sessionpipe control pair` again");
4290
+ if (p.status !== "paired") continue;
4291
+ const key = p.key;
4292
+ const proof = p.proof;
4293
+ const v = await verifyEnrollment({ machine, code, key, proof, rpId });
4294
+ if (!v.ok) throw new Error(`the receiver's key didn't prove itself (${v.why}); nothing was trusted`);
4295
+ const token = existing?.token ?? (typeof p.token === "string" ? p.token : "");
4296
+ if (!token) throw new Error("the receiver didn't hand this machine its token");
4297
+ const rec = existing ?? {
4298
+ url: o.url.replace(/\/$/, ""),
4299
+ control,
4300
+ rpId,
4301
+ machine,
4302
+ token,
4303
+ keys: [],
4304
+ paired_at: (/* @__PURE__ */ new Date()).toISOString()
4305
+ };
4306
+ if (!rec.keys.some((k) => k.id === key.id)) rec.keys.push({ ...key, added_at: (/* @__PURE__ */ new Date()).toISOString() });
4307
+ if (!existing) cfg.receivers.push(rec);
4308
+ writeControl(cfg, o.env);
4309
+ o.out(` \u2713 Paired with ${rec.url} as ${machine} \xB7 ${rec.keys.length} key(s) trusted on this machine`);
4310
+ return cfg;
4311
+ }
4312
+ }
4313
+ async function off(o) {
4314
+ const f = o.fetch ?? fetch;
4315
+ const cfg = readControl(o.env);
4316
+ if (!cfg) return o.out(" control isn't set up on this machine");
4317
+ const gone = cfg.receivers.filter((r) => !o.url || r.url === o.url.replace(/\/$/, ""));
4318
+ for (const r of gone) {
4319
+ await f(`${r.control}/off`, { method: "POST", headers: { authorization: `Bearer ${r.token}` } }).catch(() => null);
4320
+ o.out(` removed ${r.url} (${r.machine}, ${r.keys.length} key(s))`);
4321
+ }
4322
+ cfg.receivers = cfg.receivers.filter((r) => !gone.includes(r));
4323
+ writeControl(cfg, o.env);
4324
+ return cfg;
4325
+ }
4326
+ function removeKey(id, env) {
4327
+ const cfg = readControl(env);
4328
+ if (!cfg) return 0;
4329
+ let n = 0;
4330
+ for (const r of cfg.receivers) {
4331
+ const before = r.keys.length;
4332
+ r.keys = r.keys.filter((k) => k.id !== id && !k.id.startsWith(id));
4333
+ n += before - r.keys.length;
4334
+ }
4335
+ writeControl(cfg, env);
4336
+ return n;
4337
+ }
4338
+ var LABEL = "org.sessionpipe.control";
4339
+ var UNIT = "sessionpipe-control.service";
4340
+ function serviceFiles(o) {
4341
+ const home = o.home ?? os4.homedir();
4342
+ const xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
4343
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
4344
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4345
+ <plist version="1.0">
4346
+ <dict>
4347
+ <key>Label</key><string>${LABEL}</string>
4348
+ <key>ProgramArguments</key>
4349
+ <array><string>${xml(o.node)}</string><string>${xml(o.cli)}</string><string>control</string><string>run</string></array>
4350
+ <key>RunAtLoad</key><true/>
4351
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
4352
+ <key>ProcessType</key><string>Background</string>
4353
+ <key>StandardOutPath</key><string>${xml(path15.join(o.logDir, "daemon.log"))}</string>
4354
+ <key>StandardErrorPath</key><string>${xml(path15.join(o.logDir, "daemon.log"))}</string>
4355
+ </dict>
4356
+ </plist>
4357
+ `;
4358
+ const q = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
4359
+ const unit = `[Unit]
4360
+ Description=sessionpipe control daemon (signed messages to your coding sessions)
4361
+ After=network-online.target
4362
+
4363
+ [Service]
4364
+ ExecStart=${q(o.node)} ${q(o.cli)} control run
4365
+ Restart=on-failure
4366
+ RestartSec=5
4367
+
4368
+ [Install]
4369
+ WantedBy=default.target
4370
+ `;
4371
+ return {
4372
+ plist: { file: path15.join(home, "Library", "LaunchAgents", `${LABEL}.plist`), body: plist },
4373
+ unit: { file: path15.join(home, ".config", "systemd", "user", UNIT), body: unit }
4374
+ };
4375
+ }
4376
+ function installService(o) {
4377
+ const logDir = controlState(o.env);
4378
+ mkdirSync9(logDir, { recursive: true, mode: 448 });
4379
+ const f = serviceFiles({ node: o.node, cli: o.cli, logDir });
4380
+ const run = (cmd2, args2) => execFileSync5(cmd2, args2, { stdio: "ignore", timeout: 15e3 });
4381
+ if (process.platform === "darwin") {
4382
+ mkdirSync9(path15.dirname(f.plist.file), { recursive: true });
4383
+ writeFileSync7(f.plist.file, f.plist.body);
4384
+ const uid = String(process.getuid?.() ?? "");
4385
+ try {
4386
+ run("launchctl", ["bootout", `gui/${uid}/${LABEL}`]);
4387
+ } catch {
4388
+ }
4389
+ run("launchctl", ["bootstrap", `gui/${uid}`, f.plist.file]);
4390
+ return `launchd agent ${LABEL} (log: ${path15.join(logDir, "daemon.log")})`;
4391
+ }
4392
+ if (process.platform === "linux") {
4393
+ mkdirSync9(path15.dirname(f.unit.file), { recursive: true });
4394
+ writeFileSync7(f.unit.file, f.unit.body);
4395
+ try {
4396
+ run("systemctl", ["--user", "daemon-reload"]);
4397
+ run("systemctl", ["--user", "enable", "--now", UNIT]);
4398
+ return `systemd user unit ${UNIT} (journalctl --user -u ${UNIT})`;
4399
+ } catch {
4400
+ return `wrote ${f.unit.file}, but systemd --user isn't reachable here; run \`sessionpipe control run\` yourself (tmux, screen)`;
4401
+ }
4402
+ }
4403
+ return "no service manager for this platform yet; run `sessionpipe control run` yourself";
4404
+ }
4405
+ function uninstallService(env) {
4406
+ const f = serviceFiles({ node: "", cli: "", logDir: controlState(env) });
4407
+ const run = (cmd2, args2) => {
4408
+ try {
4409
+ execFileSync5(cmd2, args2, { stdio: "ignore", timeout: 15e3 });
4410
+ } catch {
4411
+ }
4412
+ };
4413
+ if (process.platform === "darwin" && existsSync10(f.plist.file)) {
4414
+ run("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${LABEL}`]);
4415
+ unlinkSync5(f.plist.file);
4416
+ }
4417
+ if (process.platform === "linux" && existsSync10(f.unit.file)) {
4418
+ run("systemctl", ["--user", "disable", "--now", UNIT]);
4419
+ unlinkSync5(f.unit.file);
4420
+ run("systemctl", ["--user", "daemon-reload"]);
4421
+ }
4422
+ }
4423
+
4424
+ // src/control/sdk.ts
4425
+ async function loadSdk(o) {
4426
+ const spec = o.env?.SESSIONPIPE_AGENT_SDK || "@anthropic-ai/claude-agent-sdk";
4427
+ let query;
4428
+ try {
4429
+ const mod = await import(
4430
+ /* @vite-ignore */
4431
+ spec
4432
+ );
4433
+ if (typeof mod.query !== "function") return null;
4434
+ query = mod.query;
4435
+ } catch {
4436
+ return null;
4437
+ }
4438
+ o.log(`Agent SDK found (${spec}): new sessions start in place`);
4439
+ return {
4440
+ start({
4441
+ session,
4442
+ cwd,
4443
+ mode
4444
+ }) {
4445
+ const queue = [];
4446
+ let wake = null;
4447
+ let closed = false;
4448
+ async function* input() {
4449
+ while (!closed) {
4450
+ while (queue.length) yield queue.shift();
4451
+ await new Promise((r) => {
4452
+ wake = r;
4453
+ });
4454
+ }
4455
+ }
4456
+ const q = query({
4457
+ prompt: input(),
4458
+ options: {
4459
+ sessionId: session,
4460
+ cwd,
4461
+ permissionMode: mode === "auto" ? "auto" : "dontAsk",
4462
+ ...o.claudeBin ? { pathToClaudeCodeExecutable: o.claudeBin } : {},
4463
+ env: { ...o.env ?? process.env, SESSIONPIPE_CONTROL_JOB: "1" }
4464
+ }
4465
+ });
4466
+ const waiting = [];
4467
+ let busy = false;
4468
+ let lastUsed = Date.now();
4469
+ void (async () => {
4470
+ try {
4471
+ for await (const m of q) {
4472
+ if (m.type !== "result") continue;
4473
+ busy = waiting.length > 1;
4474
+ lastUsed = Date.now();
4475
+ const w = waiting.shift();
4476
+ const text = typeof m.result === "string" && m.result.trim() ? m.result : "(finished without a written reply)";
4477
+ w?.resolve(m.is_error ? { reply: "", error: text } : { reply: text });
4478
+ }
4479
+ } catch (e) {
4480
+ for (const w of waiting.splice(0)) w.resolve({ reply: "", error: String(e?.message || e) });
4481
+ }
4482
+ })();
4483
+ return {
4484
+ send(text) {
4485
+ busy = true;
4486
+ lastUsed = Date.now();
4487
+ return new Promise((resolve) => {
4488
+ waiting.push({ resolve });
4489
+ queue.push({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null });
4490
+ wake?.();
4491
+ });
4492
+ },
4493
+ interrupt: () => q.interrupt(),
4494
+ close() {
4495
+ closed = true;
4496
+ wake?.();
4497
+ try {
4498
+ q.close();
4499
+ } catch {
4500
+ }
4501
+ },
4502
+ get busy() {
4503
+ return busy;
4504
+ },
4505
+ get lastUsed() {
4506
+ return lastUsed;
4507
+ }
4508
+ };
4509
+ }
4510
+ };
4511
+ }
4512
+
4513
+ // src/control/wait.ts
4514
+ var RE_ARM = "(Answer it right here, as you would answer the person at the keyboard: your reply is sent back to them automatically, so don't run a command or call a tool just to send it. Then, without mentioning it, start `sessionpipe wait` again as a background command, with the longest timeout your harness allows, to keep receiving messages.)";
4515
+ function waitSession(flag2, env = process.env) {
4516
+ if (flag2) return parseSessionRef(flag2) ? flag2 : null;
4517
+ if (env.CLAUDE_CODE_SESSION_ID) return `claude-code:${env.CLAUDE_CODE_SESSION_ID}`;
4518
+ if (env.CODEX_SESSION_ID) return `codex:${env.CODEX_SESSION_ID}`;
4519
+ return null;
4520
+ }
4521
+ async function waitForMessage(session, o = {}) {
4522
+ const sock = socketPath(stateDir(o.env), o.env);
4523
+ for (; ; ) {
4524
+ const r = await ask(sock, { op: "wait", session, pid: process.pid }, 2 ** 31 - 1);
4525
+ if (r?.op === "message") return `${r.text}
4526
+
4527
+ ${RE_ARM}`;
4528
+ if (r?.op === "replaced")
4529
+ return "(Another `sessionpipe wait` for this session took over; this one stopped. Nothing to do.)";
4530
+ await new Promise((res) => setTimeout(res, o.retryMs ?? 1e3));
4531
+ }
4532
+ }
4533
+
4534
+ // src/control/cli.ts
4535
+ var { detectCaps: detectCaps2, findClaude: findClaude2, runClaude: runClaude2 } = claude_code_control_exports;
4536
+ var CONTROL_HELP = ` sessionpipe control pair <receiver> [--folder DIR]\u2026 [--mode safe|auto] [--token T] [--name N] [--no-service]
4537
+ sessionpipe control status | keys [remove <id>] | off [<receiver>] | run
4538
+ sessionpipe wait [--session <harness>:<id>] (a session runs this in the background)`;
4539
+ async function controlMain(argv, out2, distDir2) {
4540
+ const flag2 = (n) => {
4541
+ const i = argv.indexOf(n);
4542
+ return i >= 0 ? argv[i + 1] : void 0;
4543
+ };
4544
+ const flags = (n) => argv.flatMap((a, i) => a === n && argv[i + 1] ? [argv[i + 1]] : []);
4545
+ const sub = argv[1];
4546
+ switch (sub) {
4547
+ case "pair": {
4548
+ const url = argv[2];
4549
+ if (!url || !/^https?:\/\//.test(url)) return out2(`usage:
4550
+ ${CONTROL_HELP}`);
4551
+ const cfg = readConfig();
4552
+ const sink2 = cfg.sinks.find((s) => s.url.replace(/\/$/, "") === url.replace(/\/$/, ""));
4553
+ const token = flag2("--token") ?? sink2?.token;
4554
+ if (!token)
4555
+ return out2(
4556
+ ` No token for ${url}: add it as a sink first (\`sessionpipe sink add ${url} --token \u2026\`) or pass --token.`
4557
+ );
4558
+ const folders = flags("--folder");
4559
+ const existing = readControl();
4560
+ if (!folders.length && !existing?.folders.length) folders.push(process.cwd());
4561
+ await pair({
4562
+ url,
4563
+ token,
4564
+ folders,
4565
+ mode: flag2("--mode") === "auto" ? "auto" : existing?.mode ?? "safe",
4566
+ name: flag2("--name") ?? existing?.name ?? cfg.machine ?? os5.hostname(),
4567
+ out: out2
4568
+ });
4569
+ const c = readControl();
4570
+ out2(` Folders sessions may run in: ${c.folders.join(", ")}`);
4571
+ out2(
4572
+ ` Mode: ${c.mode} (${c.mode === "safe" ? "nothing that needs approval runs unattended" : "Claude Code's auto mode"})`
4573
+ );
4574
+ let cli = path16.join(distDir2, "cli.js");
4575
+ try {
4576
+ cli = realpathSync3(cli);
4577
+ } catch {
4578
+ }
4579
+ if (argv.includes("--no-service"))
4580
+ out2(" Daemon: not installed as a service (--no-service); run `sessionpipe control run` yourself");
4581
+ else out2(` Daemon: ${installService({ node: process.execPath, cli })}`);
4582
+ return;
4583
+ }
4584
+ case "keys": {
4585
+ const c = readControl();
4586
+ if (argv[2] === "remove" && argv[3]) {
4587
+ const n = removeKey(argv[3]);
4588
+ return out2(n ? ` removed ${n} key(s)` : " no such key");
4589
+ }
4590
+ if (!c?.receivers.length)
4591
+ return out2(" control isn't paired on this machine (`sessionpipe control pair <receiver>`)");
4592
+ for (const r of c.receivers) {
4593
+ out2(` ${r.url} \xB7 machine ${r.machine}`);
4594
+ for (const k of r.keys)
4595
+ out2(` ${k.id.slice(0, 16)}\u2026 ${k.alg === -7 ? "ES256" : "RS256"} added ${k.added_at.slice(0, 10)}`);
4596
+ }
4597
+ return;
4598
+ }
4599
+ case "off": {
4600
+ const c = await off({ out: out2, ...argv[2] ? { url: argv[2] } : {} });
4601
+ if (c && !c.receivers.length) uninstallService();
4602
+ return;
4603
+ }
4604
+ case "status": {
4605
+ const r = await ask(socketPath(stateDir()), { op: "status" }, 2e3);
4606
+ if (r?.op !== "status") return out2(" the control daemon isn't running");
4607
+ return out2(JSON.stringify(r.status, null, 2));
4608
+ }
4609
+ case "run":
4610
+ return runDaemon(out2);
4611
+ default:
4612
+ return out2(CONTROL_HELP);
4613
+ }
4614
+ }
4615
+ async function runDaemon(out2) {
4616
+ const cfg = readControl();
4617
+ if (!cfg?.receivers.length) {
4618
+ out2("control isn't paired on this machine; nothing to do (`sessionpipe control pair <receiver>`)");
4619
+ return;
4620
+ }
4621
+ const log2 = (s) => out2(`${(/* @__PURE__ */ new Date()).toISOString()} ${s}`);
4622
+ let claude = null;
4623
+ const claudeNow = () => {
4624
+ const bin = findClaude2();
4625
+ if (!bin) return null;
4626
+ const sig = `${bin}:${existsSync11(bin) ? realpathSync3(bin) : ""}`;
4627
+ if (claude?.sig !== sig) claude = { bin, caps: detectCaps2(bin), sig };
4628
+ return claude;
4629
+ };
4630
+ const sdk = await loadSdk({ claudeBin: findClaude2(), env: process.env, log: log2 });
4631
+ const d = new ControlDaemon(cfg, controlState(), socketPath(stateDir()), {
4632
+ now: Date.now,
4633
+ fetch,
4634
+ log: log2,
4635
+ claudeDirs: () => claudeDirs(),
4636
+ claude: claudeNow,
4637
+ run: runClaude2,
4638
+ sdk
4639
+ });
4640
+ await d.start();
4641
+ watchFile(controlFile(), { interval: 5e3 }, () => {
4642
+ const next = readControl();
4643
+ if (next) d.reload(next);
4644
+ });
4645
+ const bye = async () => {
4646
+ await d.stop();
4647
+ process.exit(0);
4648
+ };
4649
+ process.on("SIGTERM", bye);
4650
+ process.on("SIGINT", bye);
4651
+ }
4652
+ async function waitMain(argv, out2) {
4653
+ const i = argv.indexOf("--session");
4654
+ const session = waitSession(i >= 0 ? argv[i + 1] : void 0);
4655
+ if (!session) {
4656
+ out2("sessionpipe wait: which session? Pass --session <harness>:<id> (Claude Code sets CLAUDE_CODE_SESSION_ID).");
4657
+ process.exitCode = 2;
4658
+ return;
4659
+ }
4660
+ out2(await waitForMessage(session));
4661
+ }
4662
+
4663
+ // src/run.ts
4664
+ import {
4665
+ appendFileSync as appendFileSync4,
4666
+ existsSync as existsSync12,
4667
+ mkdirSync as mkdirSync10,
4668
+ readdirSync as readdirSync7,
4669
+ readFileSync as readFileSync10,
4670
+ renameSync as renameSync3,
4671
+ statSync as statSync8,
4672
+ unlinkSync as unlinkSync6,
4673
+ writeFileSync as writeFileSync8
4674
+ } from "node:fs";
4675
+ import os6 from "node:os";
4676
+ import path17 from "node:path";
4677
+
4678
+ // src/http-sink.ts
4679
+ import { createHash as createHash3, createHmac } from "node:crypto";
4680
+ var TIMEOUT_MS = 15e3;
4681
+ function webhookId(events) {
4682
+ return `msg_${createHash3("sha256").update(events.map((e) => e.id).join(",")).digest("base64url").slice(0, 32)}`;
4683
+ }
4684
+ var HttpSink = class {
4685
+ constructor(cfg, tier) {
4686
+ this.cfg = cfg;
4687
+ this.tier = tier;
4688
+ this.name = cfg.name;
4689
+ this.pii = !!cfg.pii;
4690
+ this.url = cfg.url.replace(/\/$/, "");
4691
+ }
4692
+ cfg;
4693
+ tier;
4694
+ name;
4695
+ pii;
4696
+ url;
4697
+ eventsUrl() {
4698
+ const ep = this.cfg.endpoints?.events ?? "/api/sessionpipe/v1/events";
4699
+ return /^https?:\/\//.test(ep) ? ep : this.url + ep;
4700
+ }
4701
+ async send(events) {
4702
+ const body = JSON.stringify({ events });
4703
+ const headers = { "content-type": "application/json", "sessionpipe-protocol": "1" };
4704
+ if (this.cfg.token) headers.authorization = `Bearer ${this.cfg.token}`;
4705
+ if (this.cfg.secret?.startsWith("whsec_")) {
4706
+ const id = webhookId(events);
4707
+ const ts = Math.floor(Date.now() / 1e3);
4708
+ const key = Buffer.from(this.cfg.secret.slice(6), "base64");
4709
+ const sig = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
4710
+ headers["webhook-id"] = id;
4711
+ headers["webhook-timestamp"] = String(ts);
4712
+ headers["webhook-signature"] = `v1,${sig}`;
4713
+ }
4714
+ const ctl = new AbortController();
4715
+ const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);
4716
+ try {
4717
+ const r = await fetch(this.eventsUrl(), { method: "POST", headers, body, signal: ctl.signal });
4718
+ const text = await r.text();
4719
+ let j = {};
4720
+ try {
2381
4721
  j = text ? JSON.parse(text) : {};
2382
4722
  } catch {
2383
4723
  }
@@ -2424,26 +4764,48 @@ var HttpSink = class {
2424
4764
  };
2425
4765
 
2426
4766
  // src/run.ts
2427
- var VERSION = true ? "0.1.0" : "0.0.0";
4767
+ async function tellDaemon(state2, job, session, transcript) {
4768
+ const sock = socketPath(state2);
4769
+ if (process.platform !== "win32" && !existsSync12(sock)) return;
4770
+ await ask(
4771
+ sock,
4772
+ {
4773
+ op: "event",
4774
+ session: `claude-code:${session}`,
4775
+ event: job.event,
4776
+ cwd: job.cwd,
4777
+ ...transcript ? { transcript } : {}
4778
+ },
4779
+ 300
4780
+ );
4781
+ }
4782
+ var VERSION3 = true ? "0.2.0" : "0.0.0";
4783
+ function enqueueJob(state2, job) {
4784
+ const jobs = jobsDir(state2);
4785
+ mkdirSync10(jobs, { recursive: true, mode: 448 });
4786
+ const f = path17.join(jobs, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.json`);
4787
+ writeFileSync8(f, JSON.stringify(job), { mode: 384 });
4788
+ return f;
4789
+ }
2428
4790
  function log(state2, line) {
2429
4791
  try {
2430
- mkdirSync7(state2, { recursive: true });
2431
- const f = path11.join(state2, "log");
4792
+ mkdirSync10(state2, { recursive: true, mode: 448 });
4793
+ const f = path17.join(state2, "log");
2432
4794
  try {
2433
- if (statSync5(f).size > 1024 * 1024) renameSync2(f, `${f}.1`);
4795
+ if (statSync8(f).size > 1024 * 1024) renameSync3(f, `${f}.1`);
2434
4796
  } catch {
2435
4797
  }
2436
- appendFileSync3(f, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
4798
+ appendFileSync4(f, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
2437
4799
  `);
2438
4800
  } catch {
2439
4801
  }
2440
4802
  }
2441
4803
  function factsState(state2) {
2442
- const file = (h, s) => path11.join(state2, "facts", safeId(h), `${safeId(s)}.json`);
4804
+ const file = (h, s) => path17.join(state2, "facts", safeId(h), `${safeId(s)}.json`);
2443
4805
  return {
2444
4806
  get(h, s) {
2445
4807
  try {
2446
- return JSON.parse(readFileSync8(file(h, s), "utf8"));
4808
+ return JSON.parse(readFileSync10(file(h, s), "utf8"));
2447
4809
  } catch {
2448
4810
  return {};
2449
4811
  }
@@ -2452,8 +4814,8 @@ function factsState(state2) {
2452
4814
  const cur = this.get(h, s);
2453
4815
  const next = { ...cur, ...patch };
2454
4816
  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));
4817
+ mkdirSync10(path17.dirname(file(h, s)), { recursive: true, mode: 448 });
4818
+ writeFileSync8(file(h, s), JSON.stringify(next), { mode: 384 });
2457
4819
  }
2458
4820
  };
2459
4821
  }
@@ -2464,7 +4826,7 @@ function buildSinks(sinks) {
2464
4826
  const tier = Math.min(s.tier, s.max_tier ?? 3);
2465
4827
  if (s.url === "stdout") out2.push(new StdoutSink(s.name, tier, !!s.pii));
2466
4828
  else if (s.url.startsWith("file:"))
2467
- out2.push(new FileSink(s.name, s.url.slice(5).replace(/^~(?=\/|$)/, os2.homedir()), tier, !!s.pii));
4829
+ out2.push(new FileSink(s.name, s.url.slice(5).replace(/^~(?=\/|$)/, os6.homedir()), tier, !!s.pii));
2468
4830
  else if (/^https?:\/\//.test(s.url)) out2.push(new HttpSink(s, tier));
2469
4831
  }
2470
4832
  return out2;
@@ -2476,14 +4838,17 @@ async function runJob(job, opts = {}) {
2476
4838
  if (!adapter) return { events: [], delivered: {} };
2477
4839
  const r = adapter.fromHook({ argv: job.argv, stdin: job.stdin, env: { ...process.env, ...job.env }, cwd: job.cwd });
2478
4840
  if (!r || !r.session.id) return { events: [], delivered: {} };
4841
+ if (job.harness === "claude-code") await tellDaemon(state2, job, r.session.id, r.transcript);
2479
4842
  const outbox = new Outbox(state2);
2480
4843
  const ref = { harness: job.harness, session: r.session.id };
2481
- const release = takeLock(state2, ref);
4844
+ let release = takeLock(state2, ref);
4845
+ for (let wait = 50; !release && wait <= 3200; wait *= 2) {
4846
+ await new Promise((res) => setTimeout(res, wait));
4847
+ release = takeLock(state2, ref);
4848
+ }
2482
4849
  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));
4850
+ log(state2, `${job.harness} ${job.event}: session ${r.session.id} locked for 6 s, re-queued`);
4851
+ enqueueJob(state2, job);
2487
4852
  return { events: [], delivered: {} };
2488
4853
  }
2489
4854
  try {
@@ -2508,7 +4873,7 @@ async function runJob(job, opts = {}) {
2508
4873
  ...learned.branch ? { branch: learned.branch } : {},
2509
4874
  id: r.session.id,
2510
4875
  seq: 0,
2511
- machine: machineName(cfg, os2.hostname()),
4876
+ machine: machineName(cfg, os6.hostname()),
2512
4877
  ...cwdRaw ? { cwd: tilde(cwdRaw) } : {},
2513
4878
  ...r.session.model ? { model: r.session.model } : {}
2514
4879
  };
@@ -2547,7 +4912,10 @@ async function runJob(job, opts = {}) {
2547
4912
  if (read.turns.length)
2548
4913
  facts.save(job.harness, r.session.id, { transcript_line: read.line, transcript_turns: turnNo });
2549
4914
  }
2550
- outbox.append(ref, events);
4915
+ outbox.append(
4916
+ ref,
4917
+ events.map((e) => ({ ...e, session: redactDeep(e.session), data: redactDeep(e.data) }))
4918
+ );
2551
4919
  log(
2552
4920
  state2,
2553
4921
  `${job.harness} ${job.event}: ${events.map((e) => e.type).join(",") || "nothing"} (${r.session.id.slice(0, 8)})`
@@ -2573,34 +4941,57 @@ async function flush(state2, sinkConfigs, outbox, first) {
2573
4941
  const cursors = new Cursors(state2, sink2.name);
2574
4942
  const c = cursors.read();
2575
4943
  let sent = 0;
4944
+ let stalled = false;
4945
+ let batch = [];
4946
+ let pending = {};
4947
+ const send = async () => {
4948
+ if (!batch.length) {
4949
+ Object.assign(c, pending);
4950
+ pending = {};
4951
+ cursors.write(c);
4952
+ return true;
4953
+ }
4954
+ const res = await sink2.send(batch);
4955
+ if (!res.ok) {
4956
+ log(state2, `sink ${sink2.name}: ${res.status ?? ""} ${res.error ?? ""} \u2192 ${res.action ?? "retry"}`);
4957
+ if (res.action === "pause" || res.action === "lower-tier") applySinkVerdict(sink2.name, res);
4958
+ if (res.action !== "drop") {
4959
+ batch = [];
4960
+ pending = {};
4961
+ return false;
4962
+ }
4963
+ }
4964
+ sent += batch.length;
4965
+ Object.assign(c, pending);
4966
+ cursors.write(c);
4967
+ batch = [];
4968
+ pending = {};
4969
+ return true;
4970
+ };
2576
4971
  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);
4972
+ if (stalled || sent >= MAX_PER_RUN) break;
4973
+ let offset = pending[cursors.key(ref)] ?? c[cursors.key(ref)] ?? 0;
4974
+ for (; ; ) {
4975
+ const room = BATCH - batch.length;
4976
+ const { events, offset: next } = outbox.read(ref, offset, room);
2582
4977
  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;
4978
+ for (const e of events) {
4979
+ const f = filterEvent(e, sink2.tier, sink2.pii, { home: os6.homedir() });
4980
+ if (f) batch.push(f);
2598
4981
  }
2599
4982
  offset = next;
2600
- c[cursors.key(ref)] = offset;
2601
- cursors.write(c);
4983
+ pending[cursors.key(ref)] = offset;
4984
+ if (batch.length >= BATCH) {
4985
+ if (!await send()) {
4986
+ stalled = true;
4987
+ break;
4988
+ }
4989
+ if (sent >= MAX_PER_RUN) break;
4990
+ }
2602
4991
  }
2603
- if (stalled) break;
4992
+ }
4993
+ if (!stalled && (batch.length || Object.keys(pending).length)) {
4994
+ if (!await send()) stalled = true;
2604
4995
  }
2605
4996
  delivered[sink2.name] = sent;
2606
4997
  }
@@ -2617,7 +5008,7 @@ function applySinkVerdict(name, res) {
2617
5008
  } catch {
2618
5009
  }
2619
5010
  }
2620
- var jobsDir = (state2) => path11.join(state2, "jobs");
5011
+ var jobsDir = (state2) => path17.join(state2, "jobs");
2621
5012
 
2622
5013
  // src/cli.ts
2623
5014
  process.stdout.on("error", (e) => {
@@ -2635,12 +5026,12 @@ var out = (s = "") => {
2635
5026
  `);
2636
5027
  };
2637
5028
  var state = stateDir();
2638
- var distDir = path12.dirname(fileURLToPath(import.meta.url));
5029
+ var distDir = path18.dirname(fileURLToPath(import.meta.url));
2639
5030
  function hookCommand() {
2640
5031
  const node = stableNode(process.execPath);
2641
- let script = path12.join(distDir, "hook.js");
5032
+ let script = path18.join(distDir, "hook.js");
2642
5033
  try {
2643
- script = realpathSync2(script);
5034
+ script = realpathSync4(script);
2644
5035
  } catch {
2645
5036
  }
2646
5037
  return (a) => ({
@@ -2653,13 +5044,13 @@ function stableNode(exe) {
2653
5044
  if (!m) return exe;
2654
5045
  let real;
2655
5046
  try {
2656
- real = realpathSync2(exe);
5047
+ real = realpathSync4(exe);
2657
5048
  } catch {
2658
5049
  return exe;
2659
5050
  }
2660
5051
  for (const c of [`${m[1]}/opt/${m[2]}/bin/node`, `${m[1]}/bin/node`]) {
2661
5052
  try {
2662
- if (realpathSync2(c) === real) return c;
5053
+ if (realpathSync4(c) === real) return c;
2663
5054
  } catch {
2664
5055
  }
2665
5056
  }
@@ -2695,39 +5086,58 @@ async function main() {
2695
5086
  return update();
2696
5087
  case "worker":
2697
5088
  return workerFromArgv();
5089
+ case "control":
5090
+ return controlMain(args, out, distDir);
5091
+ case "wait":
5092
+ return waitMain(args, out);
2698
5093
  case "hook":
2699
5094
  out("sessionpipe hook is dist/hook.js; harnesses run it directly");
2700
5095
  return;
2701
5096
  case "--version":
2702
5097
  case "-v":
2703
5098
  case "version":
2704
- out(VERSION);
5099
+ out(VERSION3);
2705
5100
  return;
2706
5101
  default:
2707
5102
  return help();
2708
5103
  }
2709
5104
  }
2710
5105
  function help() {
2711
- out(`sessionpipe ${VERSION} \u2014 an open protocol for what your coding agents are doing
5106
+ out(`sessionpipe ${VERSION3} \u2014 an open protocol for what your coding agents are doing
2712
5107
 
2713
5108
  sessionpipe install [--claude-code --codex --gemini-cli --antigravity] [--machine NAME] [--backfill DAYS] [--sink URL --tier N]
2714
5109
  sessionpipe uninstall [--keep-state]
2715
- sessionpipe sink add <url|file:PATH|stdout> [--tier 0-3] [--token T] [--pii] [--control] [--name N]
5110
+ sessionpipe sink add <url|file:PATH|stdout> [--tier 0-3] [--token T] [--pii] [--name N]
2716
5111
  sessionpipe sink list | remove <name> | test <name>
2717
5112
  sessionpipe status | doctor [--json]
2718
5113
  sessionpipe tail [--session ID] [--tier N] [--harness NAME]
2719
5114
  sessionpipe backfill [--days 30] | forget <harness> <session> | replay --sink NAME
2720
5115
  sessionpipe update
5116
+ ${CONTROL_HELP}
2721
5117
 
2722
5118
  Docs: https://sessionpipe.org \xB7 nothing leaves this machine until you add a sink.`);
2723
5119
  }
2724
5120
  async function install() {
2725
5121
  if (viaNpx) {
2726
- out(` Installing sessionpipe@${VERSION} globally, so the hooks start in milliseconds\u2026`);
5122
+ out(` Installing sessionpipe@${VERSION3} globally, so the hooks start in milliseconds\u2026`);
2727
5123
  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" });
5124
+ let prefix = "";
5125
+ try {
5126
+ prefix = execFileSync6("npm", ["prefix", "-g"], {
5127
+ encoding: "utf8",
5128
+ timeout: 15e3,
5129
+ stdio: ["ignore", "pipe", "ignore"]
5130
+ }).trim();
5131
+ } catch {
5132
+ }
5133
+ const target = /\/Cellar\/node(?:@\d+)?\/[^/]+/.test(prefix) ? path18.join(os7.homedir(), ".local") : prefix;
5134
+ execFileSync6("npm", ["install", "-g", `sessionpipe@${VERSION3}`, ...target ? ["--prefix", target] : []], {
5135
+ stdio: ["ignore", "ignore", "inherit"]
5136
+ });
5137
+ const bin = process.platform === "win32" ? path18.join(target, "sessionpipe.cmd") : target ? path18.join(target, "bin", "sessionpipe") : "sessionpipe";
5138
+ if (target && target !== prefix)
5139
+ out(` Installed into ${target}; make sure ${path18.join(target, "bin")} is on your PATH.`);
5140
+ execFileSync6(bin, args, { stdio: "inherit", shell: process.platform === "win32" });
2731
5141
  return;
2732
5142
  } catch {
2733
5143
  out(
@@ -2739,6 +5149,8 @@ async function install() {
2739
5149
  const cfg = readConfig();
2740
5150
  const machine = flag("--machine");
2741
5151
  if (machine) cfg.machine = machine.slice(0, 80);
5152
+ const sinkUrl = flag("--sink");
5153
+ if (sinkUrl) await sinkAdd(sinkUrl, cfg);
2742
5154
  const adapters = selectedAdapters();
2743
5155
  if (!adapters.length) {
2744
5156
  out(
@@ -2746,25 +5158,14 @@ async function install() {
2746
5158
  );
2747
5159
  return;
2748
5160
  }
2749
- 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
- }
5161
+ out(` Machine: ${machineName(cfg, os7.hostname())}`);
5162
+ const lean = isLean(cfg);
5163
+ if (lean)
5164
+ out(
5165
+ " No sink above tier 0: hooking one tool event per harness (the heartbeat's). `sink add \u2026 --tier 1` or higher hooks the rest."
5166
+ );
5167
+ installHarnesses(adapters, cfg, lean);
2765
5168
  writeConfig(cfg);
2766
- const sinkUrl = flag("--sink");
2767
- if (sinkUrl) await sinkAdd(sinkUrl, cfg);
2768
5169
  const days = Number(flag("--backfill") ?? 30);
2769
5170
  if (days > 0) backfillRows(adapters, days, cfg);
2770
5171
  out("");
@@ -2776,10 +5177,41 @@ async function install() {
2776
5177
  );
2777
5178
  out(" Sessions already running pick up hooks on their next start (Claude Code \u2265 2.1.280 picks them up live).");
2778
5179
  }
5180
+ function isLean(cfg) {
5181
+ return !cfg.sinks.some((s) => !s.paused && Math.min(s.tier, s.max_tier ?? 3) >= 1);
5182
+ }
5183
+ function installHarnesses(adapters, cfg, lean) {
5184
+ const hc = hookCommand();
5185
+ for (const a of adapters) {
5186
+ const prev = cfg.harnesses[a.name] ?? {};
5187
+ const created = new Set(prev.created ?? []);
5188
+ const reports = a.install(hc, process.env, { lean, created: [...created] });
5189
+ for (const r of reports) {
5190
+ if (r.created) created.add(r.file);
5191
+ const mark = r.skipped ? "!" : r.changed ? "\u2713" : "=";
5192
+ out(
5193
+ ` ${mark} ${a.name}: ${tilde(r.file)}${r.note ? ` \u2014 ${r.note}` : r.changed ? " written" : " already current"}`
5194
+ );
5195
+ }
5196
+ cfg.harnesses[a.name] = { enabled: true, ...created.size ? { created: [...created] } : {} };
5197
+ if (a.name === "gemini-cli")
5198
+ out(" note: Gemini CLI's own telemetry defaults logPrompts on; that is Google's setting, not sessionpipe's.");
5199
+ if (a.name === "codex" && !reports.some((r) => r.skipped))
5200
+ out(
5201
+ " 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."
5202
+ );
5203
+ }
5204
+ }
2779
5205
  function uninstall() {
5206
+ const cfg = readConfig();
2780
5207
  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)}`);
5208
+ const created = cfg.harnesses[a.name]?.created ?? [];
5209
+ for (const r of a.uninstall(process.env, { created }))
5210
+ if (r.changed) out(` \u2713 ${a.name}: ${r.note ?? `hooks removed from ${tilde(r.file)}`}`);
5211
+ else if (r.skipped) out(` ! ${a.name}: ${tilde(r.file)} \u2014 ${r.note}`);
5212
+ delete cfg.harnesses[a.name];
2782
5213
  }
5214
+ writeConfig(cfg);
2783
5215
  if (!has("--keep-state"))
2784
5216
  out(
2785
5217
  ` Outbox and state kept under ${tilde(state)} (delete it yourself, or pass nothing: it prunes after ${readConfig().keep_days ?? 30} days).`
@@ -2810,27 +5242,30 @@ async function sink() {
2810
5242
  if (!s) return out(` no sink named ${args[2]}`);
2811
5243
  const [sk] = buildSinks([s]);
2812
5244
  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
- }
5245
+ const session = { id: `test-${Date.now()}`, machine: machineName(cfg, os7.hostname()) };
5246
+ const mk = (type, seq) => makeEvent(
5247
+ { type, harnessEvent: "test", data: {} },
5248
+ { harness: { name: "sessionpipe" }, session: { ...session, seq } }
2819
5249
  );
2820
- const r = await sk.send([filterEvent(e, sk.tier, sk.pii)]);
5250
+ const r = await sk.send([
5251
+ filterEvent(mk("session.heartbeat", 0), sk.tier, sk.pii),
5252
+ filterEvent(mk("session.forgotten", 1), sk.tier, sk.pii)
5253
+ ]);
2821
5254
  out(
2822
- r.ok ? ` \u2713 ${s.name}: ${r.status ?? "ok"} accepted ${r.accepted ?? 1}` : ` \u2717 ${s.name}: ${r.status ?? ""} ${r.error ?? ""} (${r.action})`
5255
+ 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
5256
  );
2824
5257
  return;
2825
5258
  }
2826
5259
  out(
2827
- "usage: sessionpipe sink add <url|file:PATH|stdout> [--tier N] [--token T] [--pii] [--control] [--name N] | list | remove <name> | test <name>"
5260
+ "usage: sessionpipe sink add <url|file:PATH|stdout> [--tier N] [--token T] [--pii] [--name N] | list | remove <name> | test <name>"
2828
5261
  );
2829
5262
  }
2830
5263
  async function sinkAdd(url, cfg) {
2831
5264
  const tier = Math.max(0, Math.min(3, Number(flag("--tier") ?? 0)));
2832
5265
  const name = flag("--name") ?? (url === "stdout" ? "stdout" : url.startsWith("file:") ? "file" : new URL(url).host);
2833
- const s = { name, url, tier, pii: has("--pii"), control: has("--control") };
5266
+ if (has("--control"))
5267
+ out(" --control is ignored: control is set up per machine (`sessionpipe control pair <url>`, spec/CONTROL.md)");
5268
+ const s = { name, url, tier, pii: has("--pii") };
2834
5269
  const token = flag("--token");
2835
5270
  if (token) s.token = token;
2836
5271
  const secret = flag("--secret");
@@ -2847,10 +5282,6 @@ async function sinkAdd(url, cfg) {
2847
5282
  }
2848
5283
  if (wk.endpoints) s.endpoints = wk.endpoints;
2849
5284
  s.well_known_at = (/* @__PURE__ */ new Date()).toISOString();
2850
- if (s.control && !(wk.capabilities ?? []).includes("control")) {
2851
- out(" receiver does not list `control`; --control ignored");
2852
- s.control = false;
2853
- }
2854
5285
  } catch (e) {
2855
5286
  out(
2856
5287
  ` ! ${url} has no readable /.well-known/sessionpipe (${e.message}); added anyway, at tier ${tier}`
@@ -2859,9 +5290,7 @@ async function sinkAdd(url, cfg) {
2859
5290
  }
2860
5291
  cfg.sinks = cfg.sinks.filter((x) => x.name !== name).concat([s]);
2861
5292
  writeConfig(cfg);
2862
- out(
2863
- ` \u2713 sink ${name}: ${url} at tier ${Math.min(tier, s.max_tier ?? 3)}${s.pii ? ", pii" : ""}${s.control ? ", control" : ""}`
2864
- );
5293
+ out(` \u2713 sink ${name}: ${url} at tier ${Math.min(tier, s.max_tier ?? 3)}${s.pii ? ", pii" : ""}`);
2865
5294
  out(` tier ${tier} sends: ${TIER_TEXT[Math.min(tier, s.max_tier ?? 3)]}`);
2866
5295
  }
2867
5296
  var TIER_TEXT = {
@@ -2874,7 +5303,7 @@ function backfillRows(adapters, days, cfg) {
2874
5303
  const since = Date.now() - days * 864e5;
2875
5304
  const outbox = new Outbox(state);
2876
5305
  let n = 0;
2877
- const machine = machineName(cfg, os3.hostname());
5306
+ const machine = machineName(cfg, os7.hostname());
2878
5307
  for (const a of adapters) {
2879
5308
  for (const row of a.backfill(since)) {
2880
5309
  const ref = { harness: a.name, session: row.session.id };
@@ -2918,7 +5347,7 @@ async function forget() {
2918
5347
  { type: "session.forgotten", harnessEvent: "forget", data: {} },
2919
5348
  {
2920
5349
  harness: { name: harness },
2921
- session: { id: session, seq: outbox.nextSeq(ref), machine: machineName(cfg, os3.hostname()) }
5350
+ session: { id: session, seq: outbox.nextSeq(ref), machine: machineName(cfg, os7.hostname()) }
2922
5351
  }
2923
5352
  );
2924
5353
  const sinks = buildSinks(cfg.sinks);
@@ -2943,10 +5372,13 @@ async function replay() {
2943
5372
  function status() {
2944
5373
  const cfg = readConfig();
2945
5374
  const hc = hookCommand();
2946
- out(` machine: ${machineName(cfg, os3.hostname())} sessionpipe ${VERSION} state ${tilde(state)}`);
5375
+ out(` machine: ${machineName(cfg, os7.hostname())} sessionpipe ${VERSION3} state ${tilde(state)}`);
2947
5376
  for (const a of ADAPTERS) {
2948
5377
  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}`);
5378
+ for (const r of a.installed(hc, process.env, { lean: isLean(cfg) }))
5379
+ out(
5380
+ ` ${a.name.padEnd(12)} ${tilde(r.file).padEnd(44)} ${r.state}${r.state !== "current" && r.note ? ` \u2014 ${r.note}` : ""}`
5381
+ );
2950
5382
  }
2951
5383
  if (!cfg.sinks.length) out(" sinks: none (events stay in the local outbox)");
2952
5384
  for (const s of cfg.sinks) {
@@ -2956,7 +5388,7 @@ function status() {
2956
5388
  for (const ref of outbox.sessions()) {
2957
5389
  const f = outbox.file(ref);
2958
5390
  try {
2959
- pending += Math.max(0, statSync6(f).size - (c[`${ref.harness}/${ref.session}`] ?? 0)) > 0 ? 1 : 0;
5391
+ pending += Math.max(0, statSync9(f).size - (c[`${ref.harness}/${ref.session}`] ?? 0)) > 0 ? 1 : 0;
2960
5392
  } catch {
2961
5393
  }
2962
5394
  }
@@ -2965,17 +5397,52 @@ function status() {
2965
5397
  );
2966
5398
  }
2967
5399
  const t = timing();
2968
- if (t) out(` hook: p50 ${t.p50} ms \xB7 p95 ${t.p95} ms over ${t.n} runs`);
5400
+ if (t)
5401
+ out(
5402
+ ` hook: in-process p50 ${t.p50} ms over ${t.n} runs (\`sessionpipe doctor\` measures the wall clock the harness waits)`
5403
+ );
2969
5404
  const jobs = jobsDir(state);
2970
5405
  try {
2971
- const n = readdirSync6(jobs).length;
5406
+ const n = readdirSync8(jobs).length;
2972
5407
  if (n) out(` jobs: ${n} waiting`);
2973
5408
  } catch {
2974
5409
  }
2975
5410
  }
5411
+ function hookWall(runs = 8) {
5412
+ const hook = path18.join(distDir, "hook.js");
5413
+ const tmp = mkdtempSync(path18.join(os7.tmpdir(), "sessionpipe-doctor-"));
5414
+ const env = {
5415
+ ...process.env,
5416
+ SESSIONPIPE_STATE: tmp,
5417
+ SESSIONPIPE_CONFIG: path18.join(tmp, "config.json"),
5418
+ SESSIONPIPE_NO_WORKER: "1"
5419
+ };
5420
+ const input = JSON.stringify({
5421
+ session_id: "doctor",
5422
+ hook_event_name: "PostToolUse",
5423
+ tool_name: "Bash",
5424
+ cwd: process.cwd()
5425
+ });
5426
+ const times = [];
5427
+ try {
5428
+ for (let i = 0; i < runs; i++) {
5429
+ const t0 = process.hrtime.bigint();
5430
+ const r = spawnSync(process.execPath, [hook, "claude-code", "PostToolUse"], { input, env, encoding: "utf8" });
5431
+ if (r.status !== 0) return null;
5432
+ times.push(Number(process.hrtime.bigint() - t0) / 1e6);
5433
+ }
5434
+ } catch {
5435
+ return null;
5436
+ } finally {
5437
+ rmSync(tmp, { recursive: true, force: true });
5438
+ }
5439
+ times.sort((a, b) => a - b);
5440
+ const q = (p) => Math.round(times[Math.min(times.length - 1, Math.floor(p * times.length))] ?? 0);
5441
+ return { n: times.length, p50: q(0.5), p95: q(0.95) };
5442
+ }
2976
5443
  function timing() {
2977
5444
  try {
2978
- const lines = readFileSync9(path12.join(state, "timing.jsonl"), "utf8").trim().split("\n").slice(-100);
5445
+ const lines = readFileSync11(path18.join(state, "timing.jsonl"), "utf8").trim().split("\n").slice(-100);
2979
5446
  const ms = lines.map((l) => JSON.parse(l).ms).filter((x) => x >= 0).sort((a, b) => a - b);
2980
5447
  if (!ms.length) return null;
2981
5448
  const q = (p) => Math.round(ms[Math.min(ms.length - 1, Math.floor(p * ms.length))] ?? 0);
@@ -2988,15 +5455,16 @@ function doctor() {
2988
5455
  const cfg = readConfig();
2989
5456
  const hc = hookCommand();
2990
5457
  const report = {
2991
- version: VERSION,
5458
+ version: VERSION3,
2992
5459
  node: process.version,
2993
5460
  node_path: stableNode(process.execPath),
2994
- machine: machineName(cfg, os3.hostname()),
5461
+ machine: machineName(cfg, os7.hostname()),
2995
5462
  state,
2996
5463
  config: configFile(),
2997
5464
  harnesses: {},
2998
5465
  sinks: [],
2999
5466
  timing: timing(),
5467
+ hook_wall_ms: hookWall(),
3000
5468
  warnings: []
3001
5469
  };
3002
5470
  const warnings = report.warnings;
@@ -3006,8 +5474,15 @@ function doctor() {
3006
5474
  );
3007
5475
  for (const a of ADAPTERS) {
3008
5476
  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 }));
5477
+ const st = a.installed(hc, process.env, { lean: isLean(cfg) });
5478
+ report.harnesses[a.name] = st.map((r) => ({
5479
+ file: tilde(r.file),
5480
+ state: r.state,
5481
+ ...r.note ? { note: r.note } : {}
5482
+ }));
5483
+ for (const r of st)
5484
+ if (r.state === "inactive")
5485
+ warnings.push(`${a.name}: hooks are written but the harness will not run them \u2014 ${r.note ?? ""}`);
3011
5486
  for (const r of st)
3012
5487
  if (r.state === "stale")
3013
5488
  warnings.push(
@@ -3030,14 +5505,16 @@ function doctor() {
3030
5505
  paused: s.paused ?? null,
3031
5506
  token: s.token ? `${s.token.slice(0, 4)}\u2026` : null
3032
5507
  });
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?`);
5508
+ const w = report.hook_wall_ms;
5509
+ if (w && w.p50 > 150)
5510
+ warnings.push(
5511
+ `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.`
5512
+ );
3036
5513
  if (has("--json")) {
3037
5514
  out(JSON.stringify(report, null, 2));
3038
5515
  return;
3039
5516
  }
3040
- out(` sessionpipe ${VERSION} \xB7 node ${process.version} at ${report.node_path}`);
5517
+ out(` sessionpipe ${VERSION3} \xB7 node ${process.version} at ${report.node_path}`);
3041
5518
  out(` config ${tilde(configFile())} \xB7 state ${tilde(state)}`);
3042
5519
  for (const [h, st] of Object.entries(report.harnesses))
3043
5520
  for (const r of st) out(` ${h.padEnd(12)} ${r.file.padEnd(44)} ${r.state}`);
@@ -3045,8 +5522,14 @@ function doctor() {
3045
5522
  out(
3046
5523
  ` sink ${s.name.padEnd(16)} tier ${s.tier}${s.max_tier !== null ? ` (receiver max ${s.max_tier})` : ""}${s.paused ? ` PAUSED ${s.paused}` : ""}`
3047
5524
  );
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}`);
5525
+ const t = report.timing;
5526
+ out(
5527
+ 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"
5528
+ );
5529
+ out(
5530
+ 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"
5531
+ );
5532
+ for (const w2 of warnings) out(` ! ${w2}`);
3050
5533
  if (!warnings.length) out(" no warnings");
3051
5534
  }
3052
5535
  async function tail() {
@@ -3095,7 +5578,7 @@ async function tail() {
3095
5578
  out(`sessionpipe tail \xB7 tier ${tier} \xB7 ${tilde(state)}/outbox (Ctrl-C to stop)`);
3096
5579
  for (const ref of outbox.sessions()) {
3097
5580
  try {
3098
- offsets.set(`${ref.harness}/${ref.session}`, has("--all") ? 0 : statSync6(outbox.file(ref)).size);
5581
+ offsets.set(`${ref.harness}/${ref.session}`, has("--all") ? 0 : statSync9(outbox.file(ref)).size);
3099
5582
  } catch {
3100
5583
  }
3101
5584
  }
@@ -3118,9 +5601,9 @@ async function tail() {
3118
5601
  async function workerFromArgv() {
3119
5602
  const f = args[1];
3120
5603
  if (!f) return;
3121
- const job = JSON.parse(readFileSync9(f, "utf8"));
5604
+ const job = JSON.parse(readFileSync11(f, "utf8"));
3122
5605
  try {
3123
- unlinkSync2(f);
5606
+ unlinkSync7(f);
3124
5607
  } catch {
3125
5608
  }
3126
5609
  await runJob(job);
@@ -3131,15 +5614,15 @@ async function update() {
3131
5614
  const r = await fetch("https://registry.npmjs.org/sessionpipe/latest", { signal: AbortSignal.timeout(8e3) });
3132
5615
  const j = await r.json();
3133
5616
  if (!j.version) throw new Error("no version");
3134
- if (j.version === VERSION) return out(` sessionpipe ${VERSION} is current`);
3135
- out(` ${j.version} is available (you run ${VERSION}); installing into this copy's prefix\u2026`);
5617
+ if (j.version === VERSION3) return out(` sessionpipe ${VERSION3} is current`);
5618
+ out(` ${j.version} is available (you run ${VERSION3}); installing into this copy's prefix\u2026`);
3136
5619
  const prefix = prefixOf(distDir);
3137
- execFileSync3("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
5620
+ execFileSync6("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
3138
5621
  stdio: "inherit"
3139
5622
  });
3140
5623
  out(" re-arming hooks\u2026");
3141
- const bin = prefix ? path12.join(prefix, "bin", "sessionpipe") : "sessionpipe";
3142
- const p = spawn(bin, ["install", "--backfill", "0"], { stdio: "inherit" });
5624
+ const bin = prefix ? path18.join(prefix, "bin", "sessionpipe") : "sessionpipe";
5625
+ const p = spawn2(bin, ["install", "--backfill", "0"], { stdio: "inherit" });
3143
5626
  await new Promise((r2) => p.on("exit", r2));
3144
5627
  } catch (e) {
3145
5628
  out(` couldn't check npm: ${e.message}`);