sessionpipe 0.1.1 → 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,11 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // sessionpipe 0.1.1 · 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 execFileSync4, spawn, spawnSync } from "node:child_process";
6
- import { mkdtempSync, readdirSync as readdirSync7, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync, statSync as statSync6, unlinkSync as unlinkSync5 } from "node:fs";
7
- 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
@@ -286,7 +291,7 @@ function entryFor(event, harness, cmd2, o) {
286
291
  const matcher = o.matcherFor(event);
287
292
  return {
288
293
  ...matcher !== void 0 ? { matcher } : {},
289
- 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 }]
290
295
  };
291
296
  }
292
297
  function installClaudeShaped(file, harness, events, cmd2, o) {
@@ -710,7 +715,13 @@ var CLAUDE_EVENTS = [
710
715
  ];
711
716
  var LEAN_SKIP = /* @__PURE__ */ new Set(["PreToolUse", "PostToolUseFailure"]);
712
717
  var eventsFor = (lean) => lean ? CLAUDE_EVENTS.filter((e) => !LEAN_SKIP.has(e)) : [...CLAUDE_EVENTS];
713
- var OPTS = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
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
+ };
714
725
  var DESKTOP_ID = /^local_[A-Za-z0-9-]{1,64}$/;
715
726
  function claudeDirs(env = process.env, home = HOME) {
716
727
  const out2 = [];
@@ -1154,9 +1165,9 @@ function claudeFacts(file, sessionId, hints, state2) {
1154
1165
  }
1155
1166
  const name = live?.name ? { title: live.name, source: live.name_source } : typeof mem.name === "string" ? { title: mem.name, source: mem.name_source } : null;
1156
1167
  const pick = name && name.source === "custom" && name || custom && { title: custom, source: "custom" } || name || ai && { title: ai, source: "harness" };
1157
- let ask = typeof mem.first_ask === "string" ? mem.first_ask : firstAsk(H);
1158
- if (!ask && size > 64 * 1024 && !mem.first_ask_none) {
1159
- 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) => {
1160
1171
  try {
1161
1172
  const t = askText(JSON.parse(l));
1162
1173
  return t ? appTitle(t) : null;
@@ -1164,17 +1175,17 @@ function claudeFacts(file, sessionId, hints, state2) {
1164
1175
  return null;
1165
1176
  }
1166
1177
  });
1167
- 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 });
1168
1179
  }
1169
- if (ask) {
1170
- out2.first_ask = ask;
1171
- 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 });
1172
1183
  }
1173
1184
  if (pick) {
1174
1185
  out2.title = String(pick.title).slice(0, 120);
1175
1186
  out2.title_source = pick.source;
1176
- } else if (ask) {
1177
- out2.title = ask;
1187
+ } else if (ask2) {
1188
+ out2.title = ask2;
1178
1189
  out2.title_source = "first-ask";
1179
1190
  }
1180
1191
  const acct = claudeAccount(dir);
@@ -1851,17 +1862,366 @@ function prune4(o) {
1851
1862
  return o;
1852
1863
  }
1853
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
+
1854
2214
  // ../core/src/adapters/index.ts
1855
2215
  var ADAPTERS = [claudeCode, codex, geminiCli, antigravity];
1856
2216
  var adapterByName = (name) => ADAPTERS.find((a) => a.name === name);
1857
2217
 
1858
2218
  // ../core/src/config.ts
1859
- import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
1860
- 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";
1861
2221
  var DEFAULT_CONFIG = { sinks: [], harnesses: {}, update_check: true, keep_days: 30 };
1862
2222
  function readConfig(file = configFile()) {
1863
2223
  try {
1864
- const j = JSON.parse(readFileSync6(file, "utf8"));
2224
+ const j = JSON.parse(readFileSync7(file, "utf8"));
1865
2225
  return {
1866
2226
  ...DEFAULT_CONFIG,
1867
2227
  ...j,
@@ -1873,7 +2233,7 @@ function readConfig(file = configFile()) {
1873
2233
  }
1874
2234
  }
1875
2235
  function writeConfig(c, file = configFile()) {
1876
- mkdirSync4(path8.dirname(file), { recursive: true, mode: 448 });
2236
+ mkdirSync4(path9.dirname(file), { recursive: true, mode: 448 });
1877
2237
  writeFileSync4(file, `${JSON.stringify(c, null, 2)}
1878
2238
  `, { mode: 384 });
1879
2239
  if (process.platform !== "win32" && existsSync6(file)) {
@@ -1941,32 +2301,32 @@ function makeEvent(a, ctx) {
1941
2301
  // ../core/src/outbox.ts
1942
2302
  import {
1943
2303
  appendFileSync,
1944
- closeSync as closeSync2,
2304
+ closeSync as closeSync3,
1945
2305
  existsSync as existsSync7,
1946
2306
  mkdirSync as mkdirSync5,
1947
- openSync as openSync2,
1948
- readdirSync as readdirSync5,
1949
- readFileSync as readFileSync7,
1950
- readSync as readSync2,
2307
+ openSync as openSync3,
2308
+ readdirSync as readdirSync6,
2309
+ readFileSync as readFileSync8,
2310
+ readSync as readSync3,
1951
2311
  renameSync,
1952
- statSync as statSync4,
2312
+ statSync as statSync5,
1953
2313
  unlinkSync as unlinkSync3,
1954
2314
  writeFileSync as writeFileSync5
1955
2315
  } from "node:fs";
1956
- import path9 from "node:path";
2316
+ import path10 from "node:path";
1957
2317
  var Outbox = class {
1958
2318
  constructor(root) {
1959
2319
  this.root = root;
1960
2320
  }
1961
2321
  root;
1962
2322
  dir(harness) {
1963
- return path9.join(this.root, "outbox", safeId(harness));
2323
+ return path10.join(this.root, "outbox", safeId(harness));
1964
2324
  }
1965
2325
  file(ref) {
1966
- return path9.join(this.dir(ref.harness), `${safeId(ref.session)}.jsonl`);
2326
+ return path10.join(this.dir(ref.harness), `${safeId(ref.session)}.jsonl`);
1967
2327
  }
1968
2328
  seqFile(ref) {
1969
- return path9.join(this.dir(ref.harness), `${safeId(ref.session)}.seq`);
2329
+ return path10.join(this.dir(ref.harness), `${safeId(ref.session)}.seq`);
1970
2330
  }
1971
2331
  /** The next seq for a session, persisted (survives a hibernating machine and a
1972
2332
  * restart; an in-memory counter would restart at 0 and collide). */
@@ -1974,18 +2334,18 @@ var Outbox = class {
1974
2334
  const f = this.seqFile(ref);
1975
2335
  let n = 0;
1976
2336
  try {
1977
- n = Number(readFileSync7(f, "utf8")) || 0;
2337
+ n = Number(readFileSync8(f, "utf8")) || 0;
1978
2338
  } catch {
1979
2339
  n = this.lastSeqInFile(ref) + 1;
1980
2340
  }
1981
- mkdirSync5(path9.dirname(f), { recursive: true, mode: 448 });
2341
+ mkdirSync5(path10.dirname(f), { recursive: true, mode: 448 });
1982
2342
  writeFileSync5(f, String(n + 1), { mode: 384 });
1983
2343
  return n;
1984
2344
  }
1985
2345
  lastSeqInFile(ref) {
1986
2346
  const f = this.file(ref);
1987
2347
  if (!existsSync7(f)) return -1;
1988
- const lines = readFileSync7(f, "utf8").trim().split("\n");
2348
+ const lines = readFileSync8(f, "utf8").trim().split("\n");
1989
2349
  for (let i = lines.length - 1; i >= 0; i--) {
1990
2350
  try {
1991
2351
  const j = JSON.parse(lines[i] ?? "");
@@ -2004,19 +2364,19 @@ var Outbox = class {
2004
2364
  /** Every session file, newest first. */
2005
2365
  sessions() {
2006
2366
  const out2 = [];
2007
- const base = path9.join(this.root, "outbox");
2367
+ const base = path10.join(this.root, "outbox");
2008
2368
  if (!existsSync7(base)) return [];
2009
- for (const h of readdirSync5(base)) {
2010
- const d = path9.join(base, h);
2369
+ for (const h of readdirSync6(base)) {
2370
+ const d = path10.join(base, h);
2011
2371
  let names = [];
2012
2372
  try {
2013
- names = readdirSync5(d).filter((n) => n.endsWith(".jsonl"));
2373
+ names = readdirSync6(d).filter((n) => n.endsWith(".jsonl"));
2014
2374
  } catch {
2015
2375
  continue;
2016
2376
  }
2017
2377
  for (const n of names) {
2018
2378
  try {
2019
- 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 });
2020
2380
  } catch {
2021
2381
  }
2022
2382
  }
@@ -2028,12 +2388,12 @@ var Outbox = class {
2028
2388
  read(ref, from, maxEvents = 500) {
2029
2389
  const f = this.file(ref);
2030
2390
  if (!existsSync7(f)) return { events: [], offset: from };
2031
- const size = statSync4(f).size;
2391
+ const size = statSync5(f).size;
2032
2392
  if (from >= size) return { events: [], offset: from };
2033
- const fd = openSync2(f, "r");
2393
+ const fd = openSync3(f, "r");
2034
2394
  try {
2035
2395
  const buf = Buffer.alloc(Math.min(size - from, 8 * 1024 * 1024));
2036
- const n = readSync2(fd, buf, 0, buf.length, from);
2396
+ const n = readSync3(fd, buf, 0, buf.length, from);
2037
2397
  const text = buf.subarray(0, n).toString("utf8");
2038
2398
  const events = [];
2039
2399
  let offset = from;
@@ -2052,7 +2412,7 @@ var Outbox = class {
2052
2412
  }
2053
2413
  return { events, offset };
2054
2414
  } finally {
2055
- closeSync2(fd);
2415
+ closeSync3(fd);
2056
2416
  }
2057
2417
  }
2058
2418
  /** Files older than `days` are pruned; the cursor files with them. */
@@ -2062,7 +2422,7 @@ var Outbox = class {
2062
2422
  for (const ref of this.sessions()) {
2063
2423
  const f = this.file(ref);
2064
2424
  try {
2065
- if (statSync4(f).mtimeMs < cutoff) {
2425
+ if (statSync5(f).mtimeMs < cutoff) {
2066
2426
  unlinkSync3(f);
2067
2427
  try {
2068
2428
  unlinkSync3(this.seqFile(ref));
@@ -2092,18 +2452,18 @@ var Cursors = class {
2092
2452
  root;
2093
2453
  sink;
2094
2454
  file() {
2095
- return path9.join(this.root, "cursors", `${safeId(this.sink)}.json`);
2455
+ return path10.join(this.root, "cursors", `${safeId(this.sink)}.json`);
2096
2456
  }
2097
2457
  read() {
2098
2458
  try {
2099
- return JSON.parse(readFileSync7(this.file(), "utf8"));
2459
+ return JSON.parse(readFileSync8(this.file(), "utf8"));
2100
2460
  } catch {
2101
2461
  return {};
2102
2462
  }
2103
2463
  }
2104
2464
  write(c) {
2105
2465
  const f = this.file();
2106
- mkdirSync5(path9.dirname(f), { recursive: true, mode: 448 });
2466
+ mkdirSync5(path10.dirname(f), { recursive: true, mode: 448 });
2107
2467
  const tmp = `${f}.${process.pid}.tmp`;
2108
2468
  writeFileSync5(tmp, JSON.stringify(c), { mode: 384 });
2109
2469
  renameSync(tmp, f);
@@ -2113,11 +2473,11 @@ var Cursors = class {
2113
2473
  }
2114
2474
  };
2115
2475
  function takeLock(root, ref, staleMs = 5 * 6e4) {
2116
- const dir = path9.join(root, "locks");
2476
+ const dir = path10.join(root, "locks");
2117
2477
  mkdirSync5(dir, { recursive: true, mode: 448 });
2118
- const f = path9.join(dir, `${safeId(ref.harness)}-${safeId(ref.session)}.lock`);
2478
+ const f = path10.join(dir, `${safeId(ref.harness)}-${safeId(ref.session)}.lock`);
2119
2479
  try {
2120
- if (Date.now() - statSync4(f).mtimeMs > staleMs) unlinkSync3(f);
2480
+ if (Date.now() - statSync5(f).mtimeMs > staleMs) unlinkSync3(f);
2121
2481
  else return null;
2122
2482
  } catch {
2123
2483
  }
@@ -2431,7 +2791,7 @@ function filterEvent(event, tier, pii, opts = {}) {
2431
2791
 
2432
2792
  // ../core/src/sinks/file.ts
2433
2793
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync6 } from "node:fs";
2434
- import path10 from "node:path";
2794
+ import path11 from "node:path";
2435
2795
  var FileSink = class {
2436
2796
  constructor(name, file, tier, pii) {
2437
2797
  this.name = name;
@@ -2445,7 +2805,7 @@ var FileSink = class {
2445
2805
  pii;
2446
2806
  async send(events) {
2447
2807
  try {
2448
- mkdirSync6(path10.dirname(this.file), { recursive: true });
2808
+ mkdirSync6(path11.dirname(this.file), { recursive: true });
2449
2809
  appendFileSync2(this.file, `${events.map((e) => JSON.stringify(e)).join("\n")}
2450
2810
  `);
2451
2811
  return { ok: true, acknowledged: events.length, accepted: events.length, duplicates: 0, rejected: [] };
@@ -2474,134 +2834,1978 @@ var StdoutSink = class {
2474
2834
  }
2475
2835
  };
2476
2836
 
2477
- // src/run.ts
2478
- import {
2479
- appendFileSync as appendFileSync3,
2480
- existsSync as existsSync8,
2481
- mkdirSync as mkdirSync7,
2482
- readdirSync as readdirSync6,
2483
- readFileSync as readFileSync8,
2484
- renameSync as renameSync2,
2485
- statSync as statSync5,
2486
- unlinkSync as unlinkSync4,
2487
- writeFileSync as writeFileSync6
2488
- } from "node:fs";
2489
- import os2 from "node:os";
2490
- 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";
2491
2841
 
2492
- // src/http-sink.ts
2493
- import { createHmac, randomUUID } from "node:crypto";
2494
- var TIMEOUT_MS = 15e3;
2495
- var HttpSink = class {
2496
- constructor(cfg, tier) {
2497
- this.cfg = cfg;
2498
- this.tier = tier;
2499
- this.name = cfg.name;
2500
- this.pii = !!cfg.pii;
2501
- this.url = cfg.url.replace(/\/$/, "");
2502
- }
2503
- cfg;
2504
- tier;
2505
- name;
2506
- pii;
2507
- url;
2508
- eventsUrl() {
2509
- const ep = this.cfg.endpoints?.events ?? "/api/sessionpipe/v1/events";
2510
- return /^https?:\/\//.test(ep) ? ep : this.url + ep;
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;
2511
2887
  }
2512
- async send(events) {
2513
- const body = JSON.stringify({ events });
2514
- const headers = { "content-type": "application/json", "sessionpipe-protocol": "1" };
2515
- if (this.cfg.token) headers.authorization = `Bearer ${this.cfg.token}`;
2516
- if (this.cfg.secret?.startsWith("whsec_")) {
2517
- const id = `msg_${randomUUID()}`;
2518
- const ts = Math.floor(Date.now() / 1e3);
2519
- const key = Buffer.from(this.cfg.secret.slice(6), "base64");
2520
- const sig = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
2521
- headers["webhook-id"] = id;
2522
- headers["webhook-timestamp"] = String(ts);
2523
- headers["webhook-signature"] = `v1,${sig}`;
2524
- }
2525
- const ctl = new AbortController();
2526
- const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);
2527
- try {
2528
- const r = await fetch(this.eventsUrl(), { method: "POST", headers, body, signal: ctl.signal });
2529
- const text = await r.text();
2530
- let j = {};
2531
- try {
2532
- j = text ? JSON.parse(text) : {};
2533
- } catch {
2534
- }
2535
- if (r.status === 202 || r.status === 200) {
2536
- return {
2537
- ok: true,
2538
- acknowledged: events.length,
2539
- accepted: Number(j.accepted ?? events.length),
2540
- duplicates: Number(j.duplicates ?? 0),
2541
- rejected: Array.isArray(j.rejected) ? j.rejected : [],
2542
- ...typeof j.max_tier === "number" ? { max_tier: j.max_tier } : {},
2543
- status: r.status
2544
- };
2545
- }
2546
- const err = typeof j.reason === "string" ? j.reason : text.slice(0, 200);
2547
- if (r.status === 400) return { ok: false, acknowledged: 0, status: 400, error: err, action: "drop" };
2548
- if (r.status === 401) return { ok: false, acknowledged: 0, status: 401, error: err, action: "pause" };
2549
- if (r.status === 403)
2550
- return {
2551
- ok: false,
2552
- acknowledged: 0,
2553
- status: 403,
2554
- error: err,
2555
- action: "lower-tier",
2556
- ...typeof j.max_tier === "number" ? { max_tier: j.max_tier } : {}
2557
- };
2558
- if (r.status === 413) return { ok: false, acknowledged: 0, status: 413, error: err, action: "split" };
2559
- if (r.status === 429)
2560
- return {
2561
- ok: false,
2562
- acknowledged: 0,
2563
- status: 429,
2564
- error: err,
2565
- action: "retry",
2566
- retryAfterMs: Number(r.headers.get("retry-after") ?? 60) * 1e3
2567
- };
2568
- return { ok: false, acknowledged: 0, status: r.status, error: err, action: "retry" };
2569
- } catch (e) {
2570
- return { ok: false, acknowledged: 0, error: String(e.message ?? e), action: "retry" };
2571
- } finally {
2572
- clearTimeout(t);
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;
2573
2940
  }
2941
+ } catch {
2574
2942
  }
2575
- };
2576
-
2577
- // src/run.ts
2578
- var VERSION = true ? "0.1.1" : "0.0.0";
2579
- function enqueueJob(state2, job) {
2580
- const jobs = jobsDir(state2);
2581
- mkdirSync7(jobs, { recursive: true, mode: 448 });
2582
- const f = path11.join(jobs, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.json`);
2583
- writeFileSync6(f, JSON.stringify(job), { mode: 384 });
2584
- return f;
2943
+ return null;
2585
2944
  }
2586
- function log(state2, line) {
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;
2587
2951
  try {
2588
- mkdirSync7(state2, { recursive: true, mode: 448 });
2589
- const f = path11.join(state2, "log");
2590
- try {
2591
- if (statSync5(f).size > 1024 * 1024) renameSync2(f, `${f}.1`);
2592
- } catch {
2593
- }
2594
- appendFileSync3(f, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
2595
- `);
2952
+ u = new URL(origin);
2596
2953
  } catch {
2954
+ return false;
2597
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";
2598
2960
  }
2599
- function factsState(state2) {
2600
- const file = (h, s) => path11.join(state2, "facts", safeId(h), `${safeId(s)}.json`);
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));
2994
+ }
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 {
4721
+ j = text ? JSON.parse(text) : {};
4722
+ } catch {
4723
+ }
4724
+ if (r.status === 202 || r.status === 200) {
4725
+ return {
4726
+ ok: true,
4727
+ acknowledged: events.length,
4728
+ accepted: Number(j.accepted ?? events.length),
4729
+ duplicates: Number(j.duplicates ?? 0),
4730
+ rejected: Array.isArray(j.rejected) ? j.rejected : [],
4731
+ ...typeof j.max_tier === "number" ? { max_tier: j.max_tier } : {},
4732
+ status: r.status
4733
+ };
4734
+ }
4735
+ const err = typeof j.reason === "string" ? j.reason : text.slice(0, 200);
4736
+ if (r.status === 400) return { ok: false, acknowledged: 0, status: 400, error: err, action: "drop" };
4737
+ if (r.status === 401) return { ok: false, acknowledged: 0, status: 401, error: err, action: "pause" };
4738
+ if (r.status === 403)
4739
+ return {
4740
+ ok: false,
4741
+ acknowledged: 0,
4742
+ status: 403,
4743
+ error: err,
4744
+ action: "lower-tier",
4745
+ ...typeof j.max_tier === "number" ? { max_tier: j.max_tier } : {}
4746
+ };
4747
+ if (r.status === 413) return { ok: false, acknowledged: 0, status: 413, error: err, action: "split" };
4748
+ if (r.status === 429)
4749
+ return {
4750
+ ok: false,
4751
+ acknowledged: 0,
4752
+ status: 429,
4753
+ error: err,
4754
+ action: "retry",
4755
+ retryAfterMs: Number(r.headers.get("retry-after") ?? 60) * 1e3
4756
+ };
4757
+ return { ok: false, acknowledged: 0, status: r.status, error: err, action: "retry" };
4758
+ } catch (e) {
4759
+ return { ok: false, acknowledged: 0, error: String(e.message ?? e), action: "retry" };
4760
+ } finally {
4761
+ clearTimeout(t);
4762
+ }
4763
+ }
4764
+ };
4765
+
4766
+ // src/run.ts
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
+ }
4790
+ function log(state2, line) {
4791
+ try {
4792
+ mkdirSync10(state2, { recursive: true, mode: 448 });
4793
+ const f = path17.join(state2, "log");
4794
+ try {
4795
+ if (statSync8(f).size > 1024 * 1024) renameSync3(f, `${f}.1`);
4796
+ } catch {
4797
+ }
4798
+ appendFileSync4(f, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
4799
+ `);
4800
+ } catch {
4801
+ }
4802
+ }
4803
+ function factsState(state2) {
4804
+ const file = (h, s) => path17.join(state2, "facts", safeId(h), `${safeId(s)}.json`);
2601
4805
  return {
2602
4806
  get(h, s) {
2603
4807
  try {
2604
- return JSON.parse(readFileSync8(file(h, s), "utf8"));
4808
+ return JSON.parse(readFileSync10(file(h, s), "utf8"));
2605
4809
  } catch {
2606
4810
  return {};
2607
4811
  }
@@ -2610,8 +4814,8 @@ function factsState(state2) {
2610
4814
  const cur = this.get(h, s);
2611
4815
  const next = { ...cur, ...patch };
2612
4816
  if (JSON.stringify(next) === JSON.stringify(cur)) return;
2613
- mkdirSync7(path11.dirname(file(h, s)), { recursive: true, mode: 448 });
2614
- writeFileSync6(file(h, s), JSON.stringify(next), { mode: 384 });
4817
+ mkdirSync10(path17.dirname(file(h, s)), { recursive: true, mode: 448 });
4818
+ writeFileSync8(file(h, s), JSON.stringify(next), { mode: 384 });
2615
4819
  }
2616
4820
  };
2617
4821
  }
@@ -2622,7 +4826,7 @@ function buildSinks(sinks) {
2622
4826
  const tier = Math.min(s.tier, s.max_tier ?? 3);
2623
4827
  if (s.url === "stdout") out2.push(new StdoutSink(s.name, tier, !!s.pii));
2624
4828
  else if (s.url.startsWith("file:"))
2625
- 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));
2626
4830
  else if (/^https?:\/\//.test(s.url)) out2.push(new HttpSink(s, tier));
2627
4831
  }
2628
4832
  return out2;
@@ -2634,6 +4838,7 @@ async function runJob(job, opts = {}) {
2634
4838
  if (!adapter) return { events: [], delivered: {} };
2635
4839
  const r = adapter.fromHook({ argv: job.argv, stdin: job.stdin, env: { ...process.env, ...job.env }, cwd: job.cwd });
2636
4840
  if (!r || !r.session.id) return { events: [], delivered: {} };
4841
+ if (job.harness === "claude-code") await tellDaemon(state2, job, r.session.id, r.transcript);
2637
4842
  const outbox = new Outbox(state2);
2638
4843
  const ref = { harness: job.harness, session: r.session.id };
2639
4844
  let release = takeLock(state2, ref);
@@ -2668,7 +4873,7 @@ async function runJob(job, opts = {}) {
2668
4873
  ...learned.branch ? { branch: learned.branch } : {},
2669
4874
  id: r.session.id,
2670
4875
  seq: 0,
2671
- machine: machineName(cfg, os2.hostname()),
4876
+ machine: machineName(cfg, os6.hostname()),
2672
4877
  ...cwdRaw ? { cwd: tilde(cwdRaw) } : {},
2673
4878
  ...r.session.model ? { model: r.session.model } : {}
2674
4879
  };
@@ -2771,7 +4976,7 @@ async function flush(state2, sinkConfigs, outbox, first) {
2771
4976
  const { events, offset: next } = outbox.read(ref, offset, room);
2772
4977
  if (!events.length) break;
2773
4978
  for (const e of events) {
2774
- const f = filterEvent(e, sink2.tier, sink2.pii, { home: os2.homedir() });
4979
+ const f = filterEvent(e, sink2.tier, sink2.pii, { home: os6.homedir() });
2775
4980
  if (f) batch.push(f);
2776
4981
  }
2777
4982
  offset = next;
@@ -2803,7 +5008,7 @@ function applySinkVerdict(name, res) {
2803
5008
  } catch {
2804
5009
  }
2805
5010
  }
2806
- var jobsDir = (state2) => path11.join(state2, "jobs");
5011
+ var jobsDir = (state2) => path17.join(state2, "jobs");
2807
5012
 
2808
5013
  // src/cli.ts
2809
5014
  process.stdout.on("error", (e) => {
@@ -2821,12 +5026,12 @@ var out = (s = "") => {
2821
5026
  `);
2822
5027
  };
2823
5028
  var state = stateDir();
2824
- var distDir = path12.dirname(fileURLToPath(import.meta.url));
5029
+ var distDir = path18.dirname(fileURLToPath(import.meta.url));
2825
5030
  function hookCommand() {
2826
5031
  const node = stableNode(process.execPath);
2827
- let script = path12.join(distDir, "hook.js");
5032
+ let script = path18.join(distDir, "hook.js");
2828
5033
  try {
2829
- script = realpathSync2(script);
5034
+ script = realpathSync4(script);
2830
5035
  } catch {
2831
5036
  }
2832
5037
  return (a) => ({
@@ -2839,13 +5044,13 @@ function stableNode(exe) {
2839
5044
  if (!m) return exe;
2840
5045
  let real;
2841
5046
  try {
2842
- real = realpathSync2(exe);
5047
+ real = realpathSync4(exe);
2843
5048
  } catch {
2844
5049
  return exe;
2845
5050
  }
2846
5051
  for (const c of [`${m[1]}/opt/${m[2]}/bin/node`, `${m[1]}/bin/node`]) {
2847
5052
  try {
2848
- if (realpathSync2(c) === real) return c;
5053
+ if (realpathSync4(c) === real) return c;
2849
5054
  } catch {
2850
5055
  }
2851
5056
  }
@@ -2881,53 +5086,58 @@ async function main() {
2881
5086
  return update();
2882
5087
  case "worker":
2883
5088
  return workerFromArgv();
5089
+ case "control":
5090
+ return controlMain(args, out, distDir);
5091
+ case "wait":
5092
+ return waitMain(args, out);
2884
5093
  case "hook":
2885
5094
  out("sessionpipe hook is dist/hook.js; harnesses run it directly");
2886
5095
  return;
2887
5096
  case "--version":
2888
5097
  case "-v":
2889
5098
  case "version":
2890
- out(VERSION);
5099
+ out(VERSION3);
2891
5100
  return;
2892
5101
  default:
2893
5102
  return help();
2894
5103
  }
2895
5104
  }
2896
5105
  function help() {
2897
- 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
2898
5107
 
2899
5108
  sessionpipe install [--claude-code --codex --gemini-cli --antigravity] [--machine NAME] [--backfill DAYS] [--sink URL --tier N]
2900
5109
  sessionpipe uninstall [--keep-state]
2901
- 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]
2902
5111
  sessionpipe sink list | remove <name> | test <name>
2903
5112
  sessionpipe status | doctor [--json]
2904
5113
  sessionpipe tail [--session ID] [--tier N] [--harness NAME]
2905
5114
  sessionpipe backfill [--days 30] | forget <harness> <session> | replay --sink NAME
2906
5115
  sessionpipe update
5116
+ ${CONTROL_HELP}
2907
5117
 
2908
5118
  Docs: https://sessionpipe.org \xB7 nothing leaves this machine until you add a sink.`);
2909
5119
  }
2910
5120
  async function install() {
2911
5121
  if (viaNpx) {
2912
- 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`);
2913
5123
  try {
2914
5124
  let prefix = "";
2915
5125
  try {
2916
- prefix = execFileSync4("npm", ["prefix", "-g"], {
5126
+ prefix = execFileSync6("npm", ["prefix", "-g"], {
2917
5127
  encoding: "utf8",
2918
5128
  timeout: 15e3,
2919
5129
  stdio: ["ignore", "pipe", "ignore"]
2920
5130
  }).trim();
2921
5131
  } catch {
2922
5132
  }
2923
- const target = /\/Cellar\/node(?:@\d+)?\/[^/]+/.test(prefix) ? path12.join(os3.homedir(), ".local") : prefix;
2924
- execFileSync4("npm", ["install", "-g", `sessionpipe@${VERSION}`, ...target ? ["--prefix", target] : []], {
5133
+ const target = /\/Cellar\/node(?:@\d+)?\/[^/]+/.test(prefix) ? path18.join(os7.homedir(), ".local") : prefix;
5134
+ execFileSync6("npm", ["install", "-g", `sessionpipe@${VERSION3}`, ...target ? ["--prefix", target] : []], {
2925
5135
  stdio: ["ignore", "ignore", "inherit"]
2926
5136
  });
2927
- const bin = process.platform === "win32" ? path12.join(target, "sessionpipe.cmd") : target ? path12.join(target, "bin", "sessionpipe") : "sessionpipe";
5137
+ const bin = process.platform === "win32" ? path18.join(target, "sessionpipe.cmd") : target ? path18.join(target, "bin", "sessionpipe") : "sessionpipe";
2928
5138
  if (target && target !== prefix)
2929
- out(` Installed into ${target}; make sure ${path12.join(target, "bin")} is on your PATH.`);
2930
- execFileSync4(bin, args, { stdio: "inherit", shell: process.platform === "win32" });
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" });
2931
5141
  return;
2932
5142
  } catch {
2933
5143
  out(
@@ -2948,7 +5158,7 @@ async function install() {
2948
5158
  );
2949
5159
  return;
2950
5160
  }
2951
- out(` Machine: ${machineName(cfg, os3.hostname())}`);
5161
+ out(` Machine: ${machineName(cfg, os7.hostname())}`);
2952
5162
  const lean = isLean(cfg);
2953
5163
  if (lean)
2954
5164
  out(
@@ -3032,7 +5242,7 @@ async function sink() {
3032
5242
  if (!s) return out(` no sink named ${args[2]}`);
3033
5243
  const [sk] = buildSinks([s]);
3034
5244
  if (!sk) return out(" sink type not supported yet");
3035
- const session = { id: `test-${Date.now()}`, machine: machineName(cfg, os3.hostname()) };
5245
+ const session = { id: `test-${Date.now()}`, machine: machineName(cfg, os7.hostname()) };
3036
5246
  const mk = (type, seq) => makeEvent(
3037
5247
  { type, harnessEvent: "test", data: {} },
3038
5248
  { harness: { name: "sessionpipe" }, session: { ...session, seq } }
@@ -3047,13 +5257,15 @@ async function sink() {
3047
5257
  return;
3048
5258
  }
3049
5259
  out(
3050
- "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>"
3051
5261
  );
3052
5262
  }
3053
5263
  async function sinkAdd(url, cfg) {
3054
5264
  const tier = Math.max(0, Math.min(3, Number(flag("--tier") ?? 0)));
3055
5265
  const name = flag("--name") ?? (url === "stdout" ? "stdout" : url.startsWith("file:") ? "file" : new URL(url).host);
3056
- 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") };
3057
5269
  const token = flag("--token");
3058
5270
  if (token) s.token = token;
3059
5271
  const secret = flag("--secret");
@@ -3070,10 +5282,6 @@ async function sinkAdd(url, cfg) {
3070
5282
  }
3071
5283
  if (wk.endpoints) s.endpoints = wk.endpoints;
3072
5284
  s.well_known_at = (/* @__PURE__ */ new Date()).toISOString();
3073
- if (s.control && !(wk.capabilities ?? []).includes("control")) {
3074
- out(" receiver does not list `control`; --control ignored");
3075
- s.control = false;
3076
- }
3077
5285
  } catch (e) {
3078
5286
  out(
3079
5287
  ` ! ${url} has no readable /.well-known/sessionpipe (${e.message}); added anyway, at tier ${tier}`
@@ -3082,9 +5290,7 @@ async function sinkAdd(url, cfg) {
3082
5290
  }
3083
5291
  cfg.sinks = cfg.sinks.filter((x) => x.name !== name).concat([s]);
3084
5292
  writeConfig(cfg);
3085
- out(
3086
- ` \u2713 sink ${name}: ${url} at tier ${Math.min(tier, s.max_tier ?? 3)}${s.pii ? ", pii" : ""}${s.control ? ", control" : ""}`
3087
- );
5293
+ out(` \u2713 sink ${name}: ${url} at tier ${Math.min(tier, s.max_tier ?? 3)}${s.pii ? ", pii" : ""}`);
3088
5294
  out(` tier ${tier} sends: ${TIER_TEXT[Math.min(tier, s.max_tier ?? 3)]}`);
3089
5295
  }
3090
5296
  var TIER_TEXT = {
@@ -3097,7 +5303,7 @@ function backfillRows(adapters, days, cfg) {
3097
5303
  const since = Date.now() - days * 864e5;
3098
5304
  const outbox = new Outbox(state);
3099
5305
  let n = 0;
3100
- const machine = machineName(cfg, os3.hostname());
5306
+ const machine = machineName(cfg, os7.hostname());
3101
5307
  for (const a of adapters) {
3102
5308
  for (const row of a.backfill(since)) {
3103
5309
  const ref = { harness: a.name, session: row.session.id };
@@ -3141,7 +5347,7 @@ async function forget() {
3141
5347
  { type: "session.forgotten", harnessEvent: "forget", data: {} },
3142
5348
  {
3143
5349
  harness: { name: harness },
3144
- 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()) }
3145
5351
  }
3146
5352
  );
3147
5353
  const sinks = buildSinks(cfg.sinks);
@@ -3166,7 +5372,7 @@ async function replay() {
3166
5372
  function status() {
3167
5373
  const cfg = readConfig();
3168
5374
  const hc = hookCommand();
3169
- out(` machine: ${machineName(cfg, os3.hostname())} sessionpipe ${VERSION} state ${tilde(state)}`);
5375
+ out(` machine: ${machineName(cfg, os7.hostname())} sessionpipe ${VERSION3} state ${tilde(state)}`);
3170
5376
  for (const a of ADAPTERS) {
3171
5377
  if (!a.detect() && !cfg.harnesses[a.name]) continue;
3172
5378
  for (const r of a.installed(hc, process.env, { lean: isLean(cfg) }))
@@ -3182,7 +5388,7 @@ function status() {
3182
5388
  for (const ref of outbox.sessions()) {
3183
5389
  const f = outbox.file(ref);
3184
5390
  try {
3185
- 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;
3186
5392
  } catch {
3187
5393
  }
3188
5394
  }
@@ -3197,18 +5403,18 @@ function status() {
3197
5403
  );
3198
5404
  const jobs = jobsDir(state);
3199
5405
  try {
3200
- const n = readdirSync7(jobs).length;
5406
+ const n = readdirSync8(jobs).length;
3201
5407
  if (n) out(` jobs: ${n} waiting`);
3202
5408
  } catch {
3203
5409
  }
3204
5410
  }
3205
5411
  function hookWall(runs = 8) {
3206
- const hook = path12.join(distDir, "hook.js");
3207
- const tmp = mkdtempSync(path12.join(os3.tmpdir(), "sessionpipe-doctor-"));
5412
+ const hook = path18.join(distDir, "hook.js");
5413
+ const tmp = mkdtempSync(path18.join(os7.tmpdir(), "sessionpipe-doctor-"));
3208
5414
  const env = {
3209
5415
  ...process.env,
3210
5416
  SESSIONPIPE_STATE: tmp,
3211
- SESSIONPIPE_CONFIG: path12.join(tmp, "config.json"),
5417
+ SESSIONPIPE_CONFIG: path18.join(tmp, "config.json"),
3212
5418
  SESSIONPIPE_NO_WORKER: "1"
3213
5419
  };
3214
5420
  const input = JSON.stringify({
@@ -3236,7 +5442,7 @@ function hookWall(runs = 8) {
3236
5442
  }
3237
5443
  function timing() {
3238
5444
  try {
3239
- 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);
3240
5446
  const ms = lines.map((l) => JSON.parse(l).ms).filter((x) => x >= 0).sort((a, b) => a - b);
3241
5447
  if (!ms.length) return null;
3242
5448
  const q = (p) => Math.round(ms[Math.min(ms.length - 1, Math.floor(p * ms.length))] ?? 0);
@@ -3249,10 +5455,10 @@ function doctor() {
3249
5455
  const cfg = readConfig();
3250
5456
  const hc = hookCommand();
3251
5457
  const report = {
3252
- version: VERSION,
5458
+ version: VERSION3,
3253
5459
  node: process.version,
3254
5460
  node_path: stableNode(process.execPath),
3255
- machine: machineName(cfg, os3.hostname()),
5461
+ machine: machineName(cfg, os7.hostname()),
3256
5462
  state,
3257
5463
  config: configFile(),
3258
5464
  harnesses: {},
@@ -3308,7 +5514,7 @@ function doctor() {
3308
5514
  out(JSON.stringify(report, null, 2));
3309
5515
  return;
3310
5516
  }
3311
- out(` sessionpipe ${VERSION} \xB7 node ${process.version} at ${report.node_path}`);
5517
+ out(` sessionpipe ${VERSION3} \xB7 node ${process.version} at ${report.node_path}`);
3312
5518
  out(` config ${tilde(configFile())} \xB7 state ${tilde(state)}`);
3313
5519
  for (const [h, st] of Object.entries(report.harnesses))
3314
5520
  for (const r of st) out(` ${h.padEnd(12)} ${r.file.padEnd(44)} ${r.state}`);
@@ -3372,7 +5578,7 @@ async function tail() {
3372
5578
  out(`sessionpipe tail \xB7 tier ${tier} \xB7 ${tilde(state)}/outbox (Ctrl-C to stop)`);
3373
5579
  for (const ref of outbox.sessions()) {
3374
5580
  try {
3375
- 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);
3376
5582
  } catch {
3377
5583
  }
3378
5584
  }
@@ -3395,9 +5601,9 @@ async function tail() {
3395
5601
  async function workerFromArgv() {
3396
5602
  const f = args[1];
3397
5603
  if (!f) return;
3398
- const job = JSON.parse(readFileSync9(f, "utf8"));
5604
+ const job = JSON.parse(readFileSync11(f, "utf8"));
3399
5605
  try {
3400
- unlinkSync5(f);
5606
+ unlinkSync7(f);
3401
5607
  } catch {
3402
5608
  }
3403
5609
  await runJob(job);
@@ -3408,15 +5614,15 @@ async function update() {
3408
5614
  const r = await fetch("https://registry.npmjs.org/sessionpipe/latest", { signal: AbortSignal.timeout(8e3) });
3409
5615
  const j = await r.json();
3410
5616
  if (!j.version) throw new Error("no version");
3411
- if (j.version === VERSION) return out(` sessionpipe ${VERSION} is current`);
3412
- 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`);
3413
5619
  const prefix = prefixOf(distDir);
3414
- execFileSync4("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
5620
+ execFileSync6("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
3415
5621
  stdio: "inherit"
3416
5622
  });
3417
5623
  out(" re-arming hooks\u2026");
3418
- const bin = prefix ? path12.join(prefix, "bin", "sessionpipe") : "sessionpipe";
3419
- 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" });
3420
5626
  await new Promise((r2) => p.on("exit", r2));
3421
5627
  } catch (e) {
3422
5628
  out(` couldn't check npm: ${e.message}`);