sessionpipe 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/dist/cli.js +2459 -219
- package/dist/cli.js.map +4 -4
- package/dist/control/cli.d.ts +4 -0
- package/dist/control/cli.d.ts.map +1 -0
- package/dist/control/daemon.d.ts +172 -0
- package/dist/control/daemon.d.ts.map +1 -0
- package/dist/control/local.d.ts +63 -0
- package/dist/control/local.d.ts.map +1 -0
- package/dist/control/pair.d.ts +56 -0
- package/dist/control/pair.d.ts.map +1 -0
- package/dist/control/reply.d.ts +19 -0
- package/dist/control/reply.d.ts.map +1 -0
- package/dist/control/route.d.ts +41 -0
- package/dist/control/route.d.ts.map +1 -0
- package/dist/control/sdk.d.ts +7 -0
- package/dist/control/sdk.d.ts.map +1 -0
- package/dist/control/store.d.ts +76 -0
- package/dist/control/store.d.ts.map +1 -0
- package/dist/control/wait.d.ts +8 -0
- package/dist/control/wait.d.ts.map +1 -0
- package/dist/hook.js +114 -19
- package/dist/hook.js.map +4 -4
- package/dist/http-sink.d.ts +4 -0
- package/dist/http-sink.d.ts.map +1 -1
- package/dist/index.js +92 -28
- package/dist/index.js.map +4 -4
- package/dist/run.d.ts.map +1 -1
- package/dist/worker.js +87 -23
- package/dist/worker.js.map +4 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// sessionpipe 0.
|
|
2
|
+
// sessionpipe 0.3.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
|
|
6
|
-
import { mkdtempSync, readdirSync as
|
|
7
|
-
import
|
|
8
|
-
import
|
|
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 = {
|
|
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
|
|
1158
|
-
if (!
|
|
1159
|
-
|
|
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 (!
|
|
1178
|
+
if (!ask2 && size > 16 * 1024 * 1024) state2.save(NAME2, id, { first_ask_none: true });
|
|
1168
1179
|
}
|
|
1169
|
-
if (
|
|
1170
|
-
out2.first_ask =
|
|
1171
|
-
if (!mem.first_ask) state2.save(NAME2, id, { first_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 (
|
|
1177
|
-
out2.title =
|
|
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
|
|
1860
|
-
import
|
|
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(
|
|
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(
|
|
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
|
|
2304
|
+
closeSync as closeSync3,
|
|
1945
2305
|
existsSync as existsSync7,
|
|
1946
2306
|
mkdirSync as mkdirSync5,
|
|
1947
|
-
openSync as
|
|
1948
|
-
readdirSync as
|
|
1949
|
-
readFileSync as
|
|
1950
|
-
readSync as
|
|
2307
|
+
openSync as openSync3,
|
|
2308
|
+
readdirSync as readdirSync6,
|
|
2309
|
+
readFileSync as readFileSync8,
|
|
2310
|
+
readSync as readSync3,
|
|
1951
2311
|
renameSync,
|
|
1952
|
-
statSync as
|
|
2312
|
+
statSync as statSync5,
|
|
1953
2313
|
unlinkSync as unlinkSync3,
|
|
1954
2314
|
writeFileSync as writeFileSync5
|
|
1955
2315
|
} from "node:fs";
|
|
1956
|
-
import
|
|
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
|
|
2323
|
+
return path10.join(this.root, "outbox", safeId(harness));
|
|
1964
2324
|
}
|
|
1965
2325
|
file(ref) {
|
|
1966
|
-
return
|
|
2326
|
+
return path10.join(this.dir(ref.harness), `${safeId(ref.session)}.jsonl`);
|
|
1967
2327
|
}
|
|
1968
2328
|
seqFile(ref) {
|
|
1969
|
-
return
|
|
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(
|
|
2337
|
+
n = Number(readFileSync8(f, "utf8")) || 0;
|
|
1978
2338
|
} catch {
|
|
1979
2339
|
n = this.lastSeqInFile(ref) + 1;
|
|
1980
2340
|
}
|
|
1981
|
-
mkdirSync5(
|
|
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 =
|
|
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 =
|
|
2367
|
+
const base = path10.join(this.root, "outbox");
|
|
2008
2368
|
if (!existsSync7(base)) return [];
|
|
2009
|
-
for (const h of
|
|
2010
|
-
const d =
|
|
2369
|
+
for (const h of readdirSync6(base)) {
|
|
2370
|
+
const d = path10.join(base, h);
|
|
2011
2371
|
let names = [];
|
|
2012
2372
|
try {
|
|
2013
|
-
names =
|
|
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:
|
|
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 =
|
|
2391
|
+
const size = statSync5(f).size;
|
|
2032
2392
|
if (from >= size) return { events: [], offset: from };
|
|
2033
|
-
const fd =
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
|
2455
|
+
return path10.join(this.root, "cursors", `${safeId(this.sink)}.json`);
|
|
2096
2456
|
}
|
|
2097
2457
|
read() {
|
|
2098
2458
|
try {
|
|
2099
|
-
return JSON.parse(
|
|
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(
|
|
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 =
|
|
2476
|
+
const dir = path10.join(root, "locks");
|
|
2117
2477
|
mkdirSync5(dir, { recursive: true, mode: 448 });
|
|
2118
|
-
const f =
|
|
2478
|
+
const f = path10.join(dir, `${safeId(ref.harness)}-${safeId(ref.session)}.lock`);
|
|
2119
2479
|
try {
|
|
2120
|
-
if (Date.now() -
|
|
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
|
|
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(
|
|
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,2012 @@ var StdoutSink = class {
|
|
|
2474
2834
|
}
|
|
2475
2835
|
};
|
|
2476
2836
|
|
|
2477
|
-
// src/
|
|
2478
|
-
import {
|
|
2479
|
-
|
|
2480
|
-
|
|
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/
|
|
2493
|
-
import {
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
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
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2600
|
-
|
|
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.3.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 { randomBytes as randomBytes2 } from "node:crypto";
|
|
4226
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync9, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7 } from "node:fs";
|
|
4227
|
+
import os4 from "node:os";
|
|
4228
|
+
import path15 from "node:path";
|
|
4229
|
+
var VERSION2 = true ? "0.3.0" : "0.0.0";
|
|
4230
|
+
async function json(r) {
|
|
4231
|
+
try {
|
|
4232
|
+
return await r.json();
|
|
4233
|
+
} catch {
|
|
4234
|
+
return {};
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
async function discover(url, f) {
|
|
4238
|
+
const base = url.replace(/\/$/, "");
|
|
4239
|
+
const r = await f(`${base}/.well-known/sessionpipe`);
|
|
4240
|
+
if (!r.ok) throw new Error(`${base} has no sessionpipe well-known file (HTTP ${r.status})`);
|
|
4241
|
+
const wk = await r.json();
|
|
4242
|
+
if (!wk.capabilities?.includes("control") || !wk.endpoints?.control || !wk.control?.signing?.rp_id)
|
|
4243
|
+
throw new Error(`${base} doesn't serve control yet (its well-known file lists no control.signing)`);
|
|
4244
|
+
const abs = (ep) => /^https?:\/\//.test(ep) ? ep : base + ep;
|
|
4245
|
+
return {
|
|
4246
|
+
control: abs(wk.endpoints.control),
|
|
4247
|
+
rpId: wk.control.signing.rp_id,
|
|
4248
|
+
open: wk.control.open_pairing === true,
|
|
4249
|
+
events: wk.endpoints.events ? abs(wk.endpoints.events) : null
|
|
4250
|
+
};
|
|
4251
|
+
}
|
|
4252
|
+
async function pair(o) {
|
|
4253
|
+
const f = o.fetch ?? fetch;
|
|
4254
|
+
const sleep2 = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
4255
|
+
const { control, rpId, open } = await discover(o.url, f);
|
|
4256
|
+
if (!o.token && !open)
|
|
4257
|
+
throw new Error(
|
|
4258
|
+
`${o.url} needs a key to pair: add it as a sink first (\`sessionpipe sink add ${o.url} --token \u2026\`) or pass --token.`
|
|
4259
|
+
);
|
|
4260
|
+
const pollKey = o.token ? null : randomBytes2(32).toString("base64url");
|
|
4261
|
+
const cfg = readControl(o.env) ?? { name: o.name, folders: [], mode: o.mode, receivers: [] };
|
|
4262
|
+
cfg.name = o.name || cfg.name;
|
|
4263
|
+
cfg.mode = o.mode;
|
|
4264
|
+
cfg.folders = [.../* @__PURE__ */ new Set([...cfg.folders, ...o.folders.map((d) => path15.resolve(d))])];
|
|
4265
|
+
const existing = cfg.receivers.find((r) => r.control === control);
|
|
4266
|
+
const auth = { authorization: `Bearer ${o.token ?? pollKey}`, "content-type": "application/json" };
|
|
4267
|
+
const started = await f(`${control}/pair`, {
|
|
4268
|
+
method: "POST",
|
|
4269
|
+
headers: o.token ? auth : { "content-type": "application/json" },
|
|
4270
|
+
body: JSON.stringify({
|
|
4271
|
+
...pollKey ? { poll_key: pollKey } : {},
|
|
4272
|
+
...existing ? { machine: existing.machine } : {},
|
|
4273
|
+
name: cfg.name,
|
|
4274
|
+
harnesses: ["claude-code"],
|
|
4275
|
+
modes: ["waiter", "turn", "resume", "fork"],
|
|
4276
|
+
version: VERSION2,
|
|
4277
|
+
folders: cfg.folders,
|
|
4278
|
+
mode: cfg.mode
|
|
4279
|
+
})
|
|
4280
|
+
});
|
|
4281
|
+
const s = await json(started);
|
|
4282
|
+
if (started.status !== 201 && started.status !== 200)
|
|
4283
|
+
throw new Error(
|
|
4284
|
+
`the receiver refused to start pairing (HTTP ${started.status}${s.reason ? `: ${String(s.reason)}` : ""})`
|
|
4285
|
+
);
|
|
4286
|
+
const machine = String(s.machine);
|
|
4287
|
+
const code = String(s.code);
|
|
4288
|
+
o.out("");
|
|
4289
|
+
const digits = String(s.check);
|
|
4290
|
+
const spaced = /^\d{6}$/.test(digits) ? `${digits.slice(0, 3)} ${digits.slice(3)}` : digits;
|
|
4291
|
+
if (pollKey) {
|
|
4292
|
+
o.out(` Open ${String(s.url)} and enter: ${spaced}`);
|
|
4293
|
+
o.out(" then approve with your passkey (Touch ID, Face ID, a security key).");
|
|
4294
|
+
} else {
|
|
4295
|
+
o.out(" Open this link and confirm with your passkey (Touch ID, Face ID, a security key):");
|
|
4296
|
+
o.out(` ${String(s.url)}`);
|
|
4297
|
+
o.out(` The page shows these digits too: ${digits}`);
|
|
4298
|
+
}
|
|
4299
|
+
o.out("");
|
|
4300
|
+
const until = Date.parse(String(s.expires_at)) || Date.now() + 10 * 6e4;
|
|
4301
|
+
for (; ; ) {
|
|
4302
|
+
if (Date.now() > until) throw new Error("the pairing link expired; run `sessionpipe control pair` again");
|
|
4303
|
+
await sleep2(2e3);
|
|
4304
|
+
const r = await f(`${control}/pair?code=${encodeURIComponent(code)}`, { headers: auth }).catch(() => null);
|
|
4305
|
+
if (!r) continue;
|
|
4306
|
+
if (r.status === 404) throw new Error("the receiver lost the pairing; run `sessionpipe control pair` again");
|
|
4307
|
+
const p = await json(r);
|
|
4308
|
+
if (p.status === "expired") throw new Error("the pairing link expired; run `sessionpipe control pair` again");
|
|
4309
|
+
if (p.status !== "paired") continue;
|
|
4310
|
+
const key = p.key;
|
|
4311
|
+
const proof = p.proof;
|
|
4312
|
+
const v = await verifyEnrollment({ machine, code, key, proof, rpId });
|
|
4313
|
+
if (!v.ok) throw new Error(`the receiver's key didn't prove itself (${v.why}); nothing was trusted`);
|
|
4314
|
+
const token = existing?.token ?? (typeof p.token === "string" ? p.token : "");
|
|
4315
|
+
if (!token) throw new Error("the receiver didn't hand this machine its token");
|
|
4316
|
+
const rec = existing ?? {
|
|
4317
|
+
url: o.url.replace(/\/$/, ""),
|
|
4318
|
+
control,
|
|
4319
|
+
rpId,
|
|
4320
|
+
machine,
|
|
4321
|
+
token,
|
|
4322
|
+
keys: [],
|
|
4323
|
+
paired_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
4324
|
+
};
|
|
4325
|
+
if (!rec.keys.some((k) => k.id === key.id)) rec.keys.push({ ...key, added_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4326
|
+
if (!existing) cfg.receivers.push(rec);
|
|
4327
|
+
writeControl(cfg, o.env);
|
|
4328
|
+
o.out(` \u2713 Paired with ${rec.url} as ${machine} \xB7 ${rec.keys.length} key(s) trusted on this machine`);
|
|
4329
|
+
if (typeof p.account === "string")
|
|
4330
|
+
o.out(` Approved by ${p.account}. If that isn't you, run \`sessionpipe control off\` now.`);
|
|
4331
|
+
if (typeof p.sink_token === "string" && o.addSink) o.addSink(p.sink_token);
|
|
4332
|
+
return cfg;
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
async function off(o) {
|
|
4336
|
+
const f = o.fetch ?? fetch;
|
|
4337
|
+
const cfg = readControl(o.env);
|
|
4338
|
+
if (!cfg) return o.out(" control isn't set up on this machine");
|
|
4339
|
+
const gone = cfg.receivers.filter((r) => !o.url || r.url === o.url.replace(/\/$/, ""));
|
|
4340
|
+
for (const r of gone) {
|
|
4341
|
+
await f(`${r.control}/off`, { method: "POST", headers: { authorization: `Bearer ${r.token}` } }).catch(() => null);
|
|
4342
|
+
o.out(` removed ${r.url} (${r.machine}, ${r.keys.length} key(s))`);
|
|
4343
|
+
}
|
|
4344
|
+
cfg.receivers = cfg.receivers.filter((r) => !gone.includes(r));
|
|
4345
|
+
writeControl(cfg, o.env);
|
|
4346
|
+
return cfg;
|
|
4347
|
+
}
|
|
4348
|
+
function removeKey(id, env) {
|
|
4349
|
+
const cfg = readControl(env);
|
|
4350
|
+
if (!cfg) return 0;
|
|
4351
|
+
let n = 0;
|
|
4352
|
+
for (const r of cfg.receivers) {
|
|
4353
|
+
const before = r.keys.length;
|
|
4354
|
+
r.keys = r.keys.filter((k) => k.id !== id && !k.id.startsWith(id));
|
|
4355
|
+
n += before - r.keys.length;
|
|
4356
|
+
}
|
|
4357
|
+
writeControl(cfg, env);
|
|
4358
|
+
return n;
|
|
4359
|
+
}
|
|
4360
|
+
var LABEL = "org.sessionpipe.control";
|
|
4361
|
+
var UNIT = "sessionpipe-control.service";
|
|
4362
|
+
function serviceFiles(o) {
|
|
4363
|
+
const home = o.home ?? os4.homedir();
|
|
4364
|
+
const xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
4365
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4366
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4367
|
+
<plist version="1.0">
|
|
4368
|
+
<dict>
|
|
4369
|
+
<key>Label</key><string>${LABEL}</string>
|
|
4370
|
+
<key>ProgramArguments</key>
|
|
4371
|
+
<array><string>${xml(o.node)}</string><string>${xml(o.cli)}</string><string>control</string><string>run</string></array>
|
|
4372
|
+
<key>RunAtLoad</key><true/>
|
|
4373
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
4374
|
+
<key>ProcessType</key><string>Background</string>
|
|
4375
|
+
<key>StandardOutPath</key><string>${xml(path15.join(o.logDir, "daemon.log"))}</string>
|
|
4376
|
+
<key>StandardErrorPath</key><string>${xml(path15.join(o.logDir, "daemon.log"))}</string>
|
|
4377
|
+
</dict>
|
|
4378
|
+
</plist>
|
|
4379
|
+
`;
|
|
4380
|
+
const q = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
4381
|
+
const unit = `[Unit]
|
|
4382
|
+
Description=sessionpipe control daemon (signed messages to your coding sessions)
|
|
4383
|
+
After=network-online.target
|
|
4384
|
+
|
|
4385
|
+
[Service]
|
|
4386
|
+
ExecStart=${q(o.node)} ${q(o.cli)} control run
|
|
4387
|
+
Restart=on-failure
|
|
4388
|
+
RestartSec=5
|
|
4389
|
+
|
|
4390
|
+
[Install]
|
|
4391
|
+
WantedBy=default.target
|
|
4392
|
+
`;
|
|
4393
|
+
return {
|
|
4394
|
+
plist: { file: path15.join(home, "Library", "LaunchAgents", `${LABEL}.plist`), body: plist },
|
|
4395
|
+
unit: { file: path15.join(home, ".config", "systemd", "user", UNIT), body: unit }
|
|
4396
|
+
};
|
|
4397
|
+
}
|
|
4398
|
+
function installService(o) {
|
|
4399
|
+
const logDir = controlState(o.env);
|
|
4400
|
+
mkdirSync9(logDir, { recursive: true, mode: 448 });
|
|
4401
|
+
const f = serviceFiles({ node: o.node, cli: o.cli, logDir });
|
|
4402
|
+
const run = (cmd2, args2) => execFileSync5(cmd2, args2, { stdio: "ignore", timeout: 15e3 });
|
|
4403
|
+
if (process.platform === "darwin") {
|
|
4404
|
+
mkdirSync9(path15.dirname(f.plist.file), { recursive: true });
|
|
4405
|
+
writeFileSync7(f.plist.file, f.plist.body);
|
|
4406
|
+
const uid = String(process.getuid?.() ?? "");
|
|
4407
|
+
try {
|
|
4408
|
+
run("launchctl", ["bootout", `gui/${uid}/${LABEL}`]);
|
|
4409
|
+
} catch {
|
|
4410
|
+
}
|
|
4411
|
+
run("launchctl", ["bootstrap", `gui/${uid}`, f.plist.file]);
|
|
4412
|
+
return `launchd agent ${LABEL} (log: ${path15.join(logDir, "daemon.log")})`;
|
|
4413
|
+
}
|
|
4414
|
+
if (process.platform === "linux") {
|
|
4415
|
+
mkdirSync9(path15.dirname(f.unit.file), { recursive: true });
|
|
4416
|
+
writeFileSync7(f.unit.file, f.unit.body);
|
|
4417
|
+
try {
|
|
4418
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
4419
|
+
run("systemctl", ["--user", "enable", "--now", UNIT]);
|
|
4420
|
+
return `systemd user unit ${UNIT} (journalctl --user -u ${UNIT})`;
|
|
4421
|
+
} catch {
|
|
4422
|
+
return `wrote ${f.unit.file}, but systemd --user isn't reachable here; run \`sessionpipe control run\` yourself (tmux, screen)`;
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
return "no service manager for this platform yet; run `sessionpipe control run` yourself";
|
|
4426
|
+
}
|
|
4427
|
+
function uninstallService(env) {
|
|
4428
|
+
const f = serviceFiles({ node: "", cli: "", logDir: controlState(env) });
|
|
4429
|
+
const run = (cmd2, args2) => {
|
|
4430
|
+
try {
|
|
4431
|
+
execFileSync5(cmd2, args2, { stdio: "ignore", timeout: 15e3 });
|
|
4432
|
+
} catch {
|
|
4433
|
+
}
|
|
4434
|
+
};
|
|
4435
|
+
if (process.platform === "darwin" && existsSync10(f.plist.file)) {
|
|
4436
|
+
run("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${LABEL}`]);
|
|
4437
|
+
unlinkSync5(f.plist.file);
|
|
4438
|
+
}
|
|
4439
|
+
if (process.platform === "linux" && existsSync10(f.unit.file)) {
|
|
4440
|
+
run("systemctl", ["--user", "disable", "--now", UNIT]);
|
|
4441
|
+
unlinkSync5(f.unit.file);
|
|
4442
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4446
|
+
// src/control/sdk.ts
|
|
4447
|
+
async function loadSdk(o) {
|
|
4448
|
+
const spec = o.env?.SESSIONPIPE_AGENT_SDK || "@anthropic-ai/claude-agent-sdk";
|
|
4449
|
+
let query;
|
|
4450
|
+
try {
|
|
4451
|
+
const mod = await import(
|
|
4452
|
+
/* @vite-ignore */
|
|
4453
|
+
spec
|
|
4454
|
+
);
|
|
4455
|
+
if (typeof mod.query !== "function") return null;
|
|
4456
|
+
query = mod.query;
|
|
4457
|
+
} catch {
|
|
4458
|
+
return null;
|
|
4459
|
+
}
|
|
4460
|
+
o.log(`Agent SDK found (${spec}): new sessions start in place`);
|
|
4461
|
+
return {
|
|
4462
|
+
start({
|
|
4463
|
+
session,
|
|
4464
|
+
cwd,
|
|
4465
|
+
mode
|
|
4466
|
+
}) {
|
|
4467
|
+
const queue = [];
|
|
4468
|
+
let wake = null;
|
|
4469
|
+
let closed = false;
|
|
4470
|
+
async function* input() {
|
|
4471
|
+
while (!closed) {
|
|
4472
|
+
while (queue.length) yield queue.shift();
|
|
4473
|
+
await new Promise((r) => {
|
|
4474
|
+
wake = r;
|
|
4475
|
+
});
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4478
|
+
const q = query({
|
|
4479
|
+
prompt: input(),
|
|
4480
|
+
options: {
|
|
4481
|
+
sessionId: session,
|
|
4482
|
+
cwd,
|
|
4483
|
+
permissionMode: mode === "auto" ? "auto" : "dontAsk",
|
|
4484
|
+
...o.claudeBin ? { pathToClaudeCodeExecutable: o.claudeBin } : {},
|
|
4485
|
+
env: { ...o.env ?? process.env, SESSIONPIPE_CONTROL_JOB: "1" }
|
|
4486
|
+
}
|
|
4487
|
+
});
|
|
4488
|
+
const waiting = [];
|
|
4489
|
+
let busy = false;
|
|
4490
|
+
let lastUsed = Date.now();
|
|
4491
|
+
void (async () => {
|
|
4492
|
+
try {
|
|
4493
|
+
for await (const m of q) {
|
|
4494
|
+
if (m.type !== "result") continue;
|
|
4495
|
+
busy = waiting.length > 1;
|
|
4496
|
+
lastUsed = Date.now();
|
|
4497
|
+
const w = waiting.shift();
|
|
4498
|
+
const text = typeof m.result === "string" && m.result.trim() ? m.result : "(finished without a written reply)";
|
|
4499
|
+
w?.resolve(m.is_error ? { reply: "", error: text } : { reply: text });
|
|
4500
|
+
}
|
|
4501
|
+
} catch (e) {
|
|
4502
|
+
for (const w of waiting.splice(0)) w.resolve({ reply: "", error: String(e?.message || e) });
|
|
4503
|
+
}
|
|
4504
|
+
})();
|
|
4505
|
+
return {
|
|
4506
|
+
send(text) {
|
|
4507
|
+
busy = true;
|
|
4508
|
+
lastUsed = Date.now();
|
|
4509
|
+
return new Promise((resolve) => {
|
|
4510
|
+
waiting.push({ resolve });
|
|
4511
|
+
queue.push({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null });
|
|
4512
|
+
wake?.();
|
|
4513
|
+
});
|
|
4514
|
+
},
|
|
4515
|
+
interrupt: () => q.interrupt(),
|
|
4516
|
+
close() {
|
|
4517
|
+
closed = true;
|
|
4518
|
+
wake?.();
|
|
4519
|
+
try {
|
|
4520
|
+
q.close();
|
|
4521
|
+
} catch {
|
|
4522
|
+
}
|
|
4523
|
+
},
|
|
4524
|
+
get busy() {
|
|
4525
|
+
return busy;
|
|
4526
|
+
},
|
|
4527
|
+
get lastUsed() {
|
|
4528
|
+
return lastUsed;
|
|
4529
|
+
}
|
|
4530
|
+
};
|
|
4531
|
+
}
|
|
4532
|
+
};
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
// src/control/wait.ts
|
|
4536
|
+
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.)";
|
|
4537
|
+
function waitSession(flag2, env = process.env) {
|
|
4538
|
+
if (flag2) return parseSessionRef(flag2) ? flag2 : null;
|
|
4539
|
+
if (env.CLAUDE_CODE_SESSION_ID) return `claude-code:${env.CLAUDE_CODE_SESSION_ID}`;
|
|
4540
|
+
if (env.CODEX_SESSION_ID) return `codex:${env.CODEX_SESSION_ID}`;
|
|
4541
|
+
return null;
|
|
4542
|
+
}
|
|
4543
|
+
async function waitForMessage(session, o = {}) {
|
|
4544
|
+
const sock = socketPath(stateDir(o.env), o.env);
|
|
4545
|
+
for (; ; ) {
|
|
4546
|
+
const r = await ask(sock, { op: "wait", session, pid: process.pid }, 2 ** 31 - 1);
|
|
4547
|
+
if (r?.op === "message") return `${r.text}
|
|
4548
|
+
|
|
4549
|
+
${RE_ARM}`;
|
|
4550
|
+
if (r?.op === "replaced")
|
|
4551
|
+
return "(Another `sessionpipe wait` for this session took over; this one stopped. Nothing to do.)";
|
|
4552
|
+
await new Promise((res) => setTimeout(res, o.retryMs ?? 1e3));
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
// src/control/cli.ts
|
|
4557
|
+
var { detectCaps: detectCaps2, findClaude: findClaude2, runClaude: runClaude2 } = claude_code_control_exports;
|
|
4558
|
+
var CONTROL_HELP = ` sessionpipe control pair <receiver> [--folder DIR]\u2026 [--mode safe|auto] [--token T] [--name N] [--no-service]
|
|
4559
|
+
sessionpipe control status | keys [remove <id>] | off [<receiver>] | run
|
|
4560
|
+
sessionpipe wait [--session <harness>:<id>] (a session runs this in the background)`;
|
|
4561
|
+
async function controlMain(argv, out2, distDir2) {
|
|
4562
|
+
const flag2 = (n) => {
|
|
4563
|
+
const i = argv.indexOf(n);
|
|
4564
|
+
return i >= 0 ? argv[i + 1] : void 0;
|
|
4565
|
+
};
|
|
4566
|
+
const flags = (n) => argv.flatMap((a, i) => a === n && argv[i + 1] ? [argv[i + 1]] : []);
|
|
4567
|
+
const sub = argv[1];
|
|
4568
|
+
switch (sub) {
|
|
4569
|
+
case "pair": {
|
|
4570
|
+
const raw = argv[2];
|
|
4571
|
+
if (!raw || raw.startsWith("-")) return out2(`usage:
|
|
4572
|
+
${CONTROL_HELP}`);
|
|
4573
|
+
const url = (/^https?:\/\//.test(raw) ? raw : `https://${raw}`).replace(/\/$/, "");
|
|
4574
|
+
const cfg = readConfig();
|
|
4575
|
+
const sink2 = cfg.sinks.find((s) => s.url.replace(/\/$/, "") === url);
|
|
4576
|
+
const token = flag2("--token") ?? sink2?.token ?? null;
|
|
4577
|
+
if (token !== null && !/^[\x21-\x7e]+$/.test(token))
|
|
4578
|
+
return out2(
|
|
4579
|
+
" That token has a character a key never has (a placeholder like ss_\u2026 pasted as is?). Leave --token out to pair with a link instead."
|
|
4580
|
+
);
|
|
4581
|
+
const folders = flags("--folder");
|
|
4582
|
+
const existing = readControl();
|
|
4583
|
+
if (!folders.length && !existing?.folders.length) folders.push(process.cwd());
|
|
4584
|
+
await pair({
|
|
4585
|
+
url,
|
|
4586
|
+
token,
|
|
4587
|
+
// The key the receiver handed over at pairing becomes this machine's sink for it,
|
|
4588
|
+
// state only (tier 0), unless one is configured already.
|
|
4589
|
+
addSink: (t) => {
|
|
4590
|
+
const now = readConfig();
|
|
4591
|
+
if (now.sinks.some((x) => x.url.replace(/\/$/, "") === url)) return;
|
|
4592
|
+
now.sinks.push({ name: new URL(url).host, url, tier: 0, pii: false, control: false, token: t });
|
|
4593
|
+
writeConfig(now);
|
|
4594
|
+
out2(
|
|
4595
|
+
` \u2713 Sink ${new URL(url).host} added (tier 0: session state, no conversation text). \`sessionpipe sink add ${url} --tier 2\` sends transcripts too.`
|
|
4596
|
+
);
|
|
4597
|
+
},
|
|
4598
|
+
folders,
|
|
4599
|
+
mode: flag2("--mode") === "auto" ? "auto" : existing?.mode ?? "safe",
|
|
4600
|
+
name: flag2("--name") ?? existing?.name ?? cfg.machine ?? os5.hostname(),
|
|
4601
|
+
out: out2
|
|
4602
|
+
});
|
|
4603
|
+
const c = readControl();
|
|
4604
|
+
out2(` Folders sessions may run in: ${c.folders.join(", ")}`);
|
|
4605
|
+
out2(
|
|
4606
|
+
` Mode: ${c.mode} (${c.mode === "safe" ? "nothing that needs approval runs unattended" : "Claude Code's auto mode"})`
|
|
4607
|
+
);
|
|
4608
|
+
let cli = path16.join(distDir2, "cli.js");
|
|
4609
|
+
try {
|
|
4610
|
+
cli = realpathSync3(cli);
|
|
4611
|
+
} catch {
|
|
4612
|
+
}
|
|
4613
|
+
if (argv.includes("--no-service"))
|
|
4614
|
+
out2(" Daemon: not installed as a service (--no-service); run `sessionpipe control run` yourself");
|
|
4615
|
+
else out2(` Daemon: ${installService({ node: process.execPath, cli })}`);
|
|
4616
|
+
return;
|
|
4617
|
+
}
|
|
4618
|
+
case "keys": {
|
|
4619
|
+
const c = readControl();
|
|
4620
|
+
if (argv[2] === "remove" && argv[3]) {
|
|
4621
|
+
const n = removeKey(argv[3]);
|
|
4622
|
+
return out2(n ? ` removed ${n} key(s)` : " no such key");
|
|
4623
|
+
}
|
|
4624
|
+
if (!c?.receivers.length)
|
|
4625
|
+
return out2(" control isn't paired on this machine (`sessionpipe control pair <receiver>`)");
|
|
4626
|
+
for (const r of c.receivers) {
|
|
4627
|
+
out2(` ${r.url} \xB7 machine ${r.machine}`);
|
|
4628
|
+
for (const k of r.keys)
|
|
4629
|
+
out2(` ${k.id.slice(0, 16)}\u2026 ${k.alg === -7 ? "ES256" : "RS256"} added ${k.added_at.slice(0, 10)}`);
|
|
4630
|
+
}
|
|
4631
|
+
return;
|
|
4632
|
+
}
|
|
4633
|
+
case "off": {
|
|
4634
|
+
const c = await off({ out: out2, ...argv[2] ? { url: argv[2] } : {} });
|
|
4635
|
+
if (c && !c.receivers.length) uninstallService();
|
|
4636
|
+
return;
|
|
4637
|
+
}
|
|
4638
|
+
case "status": {
|
|
4639
|
+
const r = await ask(socketPath(stateDir()), { op: "status" }, 2e3);
|
|
4640
|
+
if (r?.op !== "status") return out2(" the control daemon isn't running");
|
|
4641
|
+
return out2(JSON.stringify(r.status, null, 2));
|
|
4642
|
+
}
|
|
4643
|
+
case "run":
|
|
4644
|
+
return runDaemon(out2);
|
|
4645
|
+
default:
|
|
4646
|
+
return out2(CONTROL_HELP);
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
async function runDaemon(out2) {
|
|
4650
|
+
const cfg = readControl();
|
|
4651
|
+
if (!cfg?.receivers.length) {
|
|
4652
|
+
out2("control isn't paired on this machine; nothing to do (`sessionpipe control pair <receiver>`)");
|
|
4653
|
+
return;
|
|
4654
|
+
}
|
|
4655
|
+
const log2 = (s) => out2(`${(/* @__PURE__ */ new Date()).toISOString()} ${s}`);
|
|
4656
|
+
let claude = null;
|
|
4657
|
+
const claudeNow = () => {
|
|
4658
|
+
const bin = findClaude2();
|
|
4659
|
+
if (!bin) return null;
|
|
4660
|
+
const sig = `${bin}:${existsSync11(bin) ? realpathSync3(bin) : ""}`;
|
|
4661
|
+
if (claude?.sig !== sig) claude = { bin, caps: detectCaps2(bin), sig };
|
|
4662
|
+
return claude;
|
|
4663
|
+
};
|
|
4664
|
+
const sdk = await loadSdk({ claudeBin: findClaude2(), env: process.env, log: log2 });
|
|
4665
|
+
const d = new ControlDaemon(cfg, controlState(), socketPath(stateDir()), {
|
|
4666
|
+
now: Date.now,
|
|
4667
|
+
fetch,
|
|
4668
|
+
log: log2,
|
|
4669
|
+
claudeDirs: () => claudeDirs(),
|
|
4670
|
+
claude: claudeNow,
|
|
4671
|
+
run: runClaude2,
|
|
4672
|
+
sdk
|
|
4673
|
+
});
|
|
4674
|
+
await d.start();
|
|
4675
|
+
watchFile(controlFile(), { interval: 5e3 }, () => {
|
|
4676
|
+
const next = readControl();
|
|
4677
|
+
if (next) d.reload(next);
|
|
4678
|
+
});
|
|
4679
|
+
const bye = async () => {
|
|
4680
|
+
await d.stop();
|
|
4681
|
+
process.exit(0);
|
|
4682
|
+
};
|
|
4683
|
+
process.on("SIGTERM", bye);
|
|
4684
|
+
process.on("SIGINT", bye);
|
|
4685
|
+
}
|
|
4686
|
+
async function waitMain(argv, out2) {
|
|
4687
|
+
const i = argv.indexOf("--session");
|
|
4688
|
+
const session = waitSession(i >= 0 ? argv[i + 1] : void 0);
|
|
4689
|
+
if (!session) {
|
|
4690
|
+
out2("sessionpipe wait: which session? Pass --session <harness>:<id> (Claude Code sets CLAUDE_CODE_SESSION_ID).");
|
|
4691
|
+
process.exitCode = 2;
|
|
4692
|
+
return;
|
|
4693
|
+
}
|
|
4694
|
+
out2(await waitForMessage(session));
|
|
4695
|
+
}
|
|
4696
|
+
|
|
4697
|
+
// src/run.ts
|
|
4698
|
+
import {
|
|
4699
|
+
appendFileSync as appendFileSync4,
|
|
4700
|
+
existsSync as existsSync12,
|
|
4701
|
+
mkdirSync as mkdirSync10,
|
|
4702
|
+
readdirSync as readdirSync7,
|
|
4703
|
+
readFileSync as readFileSync10,
|
|
4704
|
+
renameSync as renameSync3,
|
|
4705
|
+
statSync as statSync8,
|
|
4706
|
+
unlinkSync as unlinkSync6,
|
|
4707
|
+
writeFileSync as writeFileSync8
|
|
4708
|
+
} from "node:fs";
|
|
4709
|
+
import os6 from "node:os";
|
|
4710
|
+
import path17 from "node:path";
|
|
4711
|
+
|
|
4712
|
+
// src/http-sink.ts
|
|
4713
|
+
import { createHash as createHash3, createHmac } from "node:crypto";
|
|
4714
|
+
var TIMEOUT_MS = 15e3;
|
|
4715
|
+
function webhookId(events) {
|
|
4716
|
+
return `msg_${createHash3("sha256").update(events.map((e) => e.id).join(",")).digest("base64url").slice(0, 32)}`;
|
|
4717
|
+
}
|
|
4718
|
+
var HttpSink = class {
|
|
4719
|
+
constructor(cfg, tier) {
|
|
4720
|
+
this.cfg = cfg;
|
|
4721
|
+
this.tier = tier;
|
|
4722
|
+
this.name = cfg.name;
|
|
4723
|
+
this.pii = !!cfg.pii;
|
|
4724
|
+
this.url = cfg.url.replace(/\/$/, "");
|
|
4725
|
+
}
|
|
4726
|
+
cfg;
|
|
4727
|
+
tier;
|
|
4728
|
+
name;
|
|
4729
|
+
pii;
|
|
4730
|
+
url;
|
|
4731
|
+
eventsUrl() {
|
|
4732
|
+
const ep = this.cfg.endpoints?.events ?? "/api/sessionpipe/v1/events";
|
|
4733
|
+
return /^https?:\/\//.test(ep) ? ep : this.url + ep;
|
|
4734
|
+
}
|
|
4735
|
+
async send(events) {
|
|
4736
|
+
const body = JSON.stringify({ events });
|
|
4737
|
+
const headers = { "content-type": "application/json", "sessionpipe-protocol": "1" };
|
|
4738
|
+
if (this.cfg.token) headers.authorization = `Bearer ${this.cfg.token}`;
|
|
4739
|
+
if (this.cfg.secret?.startsWith("whsec_")) {
|
|
4740
|
+
const id = webhookId(events);
|
|
4741
|
+
const ts = Math.floor(Date.now() / 1e3);
|
|
4742
|
+
const key = Buffer.from(this.cfg.secret.slice(6), "base64");
|
|
4743
|
+
const sig = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
|
|
4744
|
+
headers["webhook-id"] = id;
|
|
4745
|
+
headers["webhook-timestamp"] = String(ts);
|
|
4746
|
+
headers["webhook-signature"] = `v1,${sig}`;
|
|
4747
|
+
}
|
|
4748
|
+
const ctl = new AbortController();
|
|
4749
|
+
const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
|
4750
|
+
try {
|
|
4751
|
+
const r = await fetch(this.eventsUrl(), { method: "POST", headers, body, signal: ctl.signal });
|
|
4752
|
+
const text = await r.text();
|
|
4753
|
+
let j = {};
|
|
4754
|
+
try {
|
|
4755
|
+
j = text ? JSON.parse(text) : {};
|
|
4756
|
+
} catch {
|
|
4757
|
+
}
|
|
4758
|
+
if (r.status === 202 || r.status === 200) {
|
|
4759
|
+
return {
|
|
4760
|
+
ok: true,
|
|
4761
|
+
acknowledged: events.length,
|
|
4762
|
+
accepted: Number(j.accepted ?? events.length),
|
|
4763
|
+
duplicates: Number(j.duplicates ?? 0),
|
|
4764
|
+
rejected: Array.isArray(j.rejected) ? j.rejected : [],
|
|
4765
|
+
...typeof j.max_tier === "number" ? { max_tier: j.max_tier } : {},
|
|
4766
|
+
status: r.status
|
|
4767
|
+
};
|
|
4768
|
+
}
|
|
4769
|
+
const err = typeof j.reason === "string" ? j.reason : text.slice(0, 200);
|
|
4770
|
+
if (r.status === 400) return { ok: false, acknowledged: 0, status: 400, error: err, action: "drop" };
|
|
4771
|
+
if (r.status === 401) return { ok: false, acknowledged: 0, status: 401, error: err, action: "pause" };
|
|
4772
|
+
if (r.status === 403)
|
|
4773
|
+
return {
|
|
4774
|
+
ok: false,
|
|
4775
|
+
acknowledged: 0,
|
|
4776
|
+
status: 403,
|
|
4777
|
+
error: err,
|
|
4778
|
+
action: "lower-tier",
|
|
4779
|
+
...typeof j.max_tier === "number" ? { max_tier: j.max_tier } : {}
|
|
4780
|
+
};
|
|
4781
|
+
if (r.status === 413) return { ok: false, acknowledged: 0, status: 413, error: err, action: "split" };
|
|
4782
|
+
if (r.status === 429)
|
|
4783
|
+
return {
|
|
4784
|
+
ok: false,
|
|
4785
|
+
acknowledged: 0,
|
|
4786
|
+
status: 429,
|
|
4787
|
+
error: err,
|
|
4788
|
+
action: "retry",
|
|
4789
|
+
retryAfterMs: Number(r.headers.get("retry-after") ?? 60) * 1e3
|
|
4790
|
+
};
|
|
4791
|
+
return { ok: false, acknowledged: 0, status: r.status, error: err, action: "retry" };
|
|
4792
|
+
} catch (e) {
|
|
4793
|
+
return { ok: false, acknowledged: 0, error: String(e.message ?? e), action: "retry" };
|
|
4794
|
+
} finally {
|
|
4795
|
+
clearTimeout(t);
|
|
4796
|
+
}
|
|
4797
|
+
}
|
|
4798
|
+
};
|
|
4799
|
+
|
|
4800
|
+
// src/run.ts
|
|
4801
|
+
async function tellDaemon(state2, job, session, transcript) {
|
|
4802
|
+
const sock = socketPath(state2);
|
|
4803
|
+
if (process.platform !== "win32" && !existsSync12(sock)) return;
|
|
4804
|
+
await ask(
|
|
4805
|
+
sock,
|
|
4806
|
+
{
|
|
4807
|
+
op: "event",
|
|
4808
|
+
session: `claude-code:${session}`,
|
|
4809
|
+
event: job.event,
|
|
4810
|
+
cwd: job.cwd,
|
|
4811
|
+
...transcript ? { transcript } : {}
|
|
4812
|
+
},
|
|
4813
|
+
300
|
|
4814
|
+
);
|
|
4815
|
+
}
|
|
4816
|
+
var VERSION3 = true ? "0.3.0" : "0.0.0";
|
|
4817
|
+
function enqueueJob(state2, job) {
|
|
4818
|
+
const jobs = jobsDir(state2);
|
|
4819
|
+
mkdirSync10(jobs, { recursive: true, mode: 448 });
|
|
4820
|
+
const f = path17.join(jobs, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.json`);
|
|
4821
|
+
writeFileSync8(f, JSON.stringify(job), { mode: 384 });
|
|
4822
|
+
return f;
|
|
4823
|
+
}
|
|
4824
|
+
function log(state2, line) {
|
|
4825
|
+
try {
|
|
4826
|
+
mkdirSync10(state2, { recursive: true, mode: 448 });
|
|
4827
|
+
const f = path17.join(state2, "log");
|
|
4828
|
+
try {
|
|
4829
|
+
if (statSync8(f).size > 1024 * 1024) renameSync3(f, `${f}.1`);
|
|
4830
|
+
} catch {
|
|
4831
|
+
}
|
|
4832
|
+
appendFileSync4(f, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
4833
|
+
`);
|
|
4834
|
+
} catch {
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
function factsState(state2) {
|
|
4838
|
+
const file = (h, s) => path17.join(state2, "facts", safeId(h), `${safeId(s)}.json`);
|
|
2601
4839
|
return {
|
|
2602
4840
|
get(h, s) {
|
|
2603
4841
|
try {
|
|
2604
|
-
return JSON.parse(
|
|
4842
|
+
return JSON.parse(readFileSync10(file(h, s), "utf8"));
|
|
2605
4843
|
} catch {
|
|
2606
4844
|
return {};
|
|
2607
4845
|
}
|
|
@@ -2610,8 +4848,8 @@ function factsState(state2) {
|
|
|
2610
4848
|
const cur = this.get(h, s);
|
|
2611
4849
|
const next = { ...cur, ...patch };
|
|
2612
4850
|
if (JSON.stringify(next) === JSON.stringify(cur)) return;
|
|
2613
|
-
|
|
2614
|
-
|
|
4851
|
+
mkdirSync10(path17.dirname(file(h, s)), { recursive: true, mode: 448 });
|
|
4852
|
+
writeFileSync8(file(h, s), JSON.stringify(next), { mode: 384 });
|
|
2615
4853
|
}
|
|
2616
4854
|
};
|
|
2617
4855
|
}
|
|
@@ -2622,7 +4860,7 @@ function buildSinks(sinks) {
|
|
|
2622
4860
|
const tier = Math.min(s.tier, s.max_tier ?? 3);
|
|
2623
4861
|
if (s.url === "stdout") out2.push(new StdoutSink(s.name, tier, !!s.pii));
|
|
2624
4862
|
else if (s.url.startsWith("file:"))
|
|
2625
|
-
out2.push(new FileSink(s.name, s.url.slice(5).replace(/^~(?=\/|$)/,
|
|
4863
|
+
out2.push(new FileSink(s.name, s.url.slice(5).replace(/^~(?=\/|$)/, os6.homedir()), tier, !!s.pii));
|
|
2626
4864
|
else if (/^https?:\/\//.test(s.url)) out2.push(new HttpSink(s, tier));
|
|
2627
4865
|
}
|
|
2628
4866
|
return out2;
|
|
@@ -2634,6 +4872,7 @@ async function runJob(job, opts = {}) {
|
|
|
2634
4872
|
if (!adapter) return { events: [], delivered: {} };
|
|
2635
4873
|
const r = adapter.fromHook({ argv: job.argv, stdin: job.stdin, env: { ...process.env, ...job.env }, cwd: job.cwd });
|
|
2636
4874
|
if (!r || !r.session.id) return { events: [], delivered: {} };
|
|
4875
|
+
if (job.harness === "claude-code") await tellDaemon(state2, job, r.session.id, r.transcript);
|
|
2637
4876
|
const outbox = new Outbox(state2);
|
|
2638
4877
|
const ref = { harness: job.harness, session: r.session.id };
|
|
2639
4878
|
let release = takeLock(state2, ref);
|
|
@@ -2668,7 +4907,7 @@ async function runJob(job, opts = {}) {
|
|
|
2668
4907
|
...learned.branch ? { branch: learned.branch } : {},
|
|
2669
4908
|
id: r.session.id,
|
|
2670
4909
|
seq: 0,
|
|
2671
|
-
machine: machineName(cfg,
|
|
4910
|
+
machine: machineName(cfg, os6.hostname()),
|
|
2672
4911
|
...cwdRaw ? { cwd: tilde(cwdRaw) } : {},
|
|
2673
4912
|
...r.session.model ? { model: r.session.model } : {}
|
|
2674
4913
|
};
|
|
@@ -2771,7 +5010,7 @@ async function flush(state2, sinkConfigs, outbox, first) {
|
|
|
2771
5010
|
const { events, offset: next } = outbox.read(ref, offset, room);
|
|
2772
5011
|
if (!events.length) break;
|
|
2773
5012
|
for (const e of events) {
|
|
2774
|
-
const f = filterEvent(e, sink2.tier, sink2.pii, { home:
|
|
5013
|
+
const f = filterEvent(e, sink2.tier, sink2.pii, { home: os6.homedir() });
|
|
2775
5014
|
if (f) batch.push(f);
|
|
2776
5015
|
}
|
|
2777
5016
|
offset = next;
|
|
@@ -2803,7 +5042,7 @@ function applySinkVerdict(name, res) {
|
|
|
2803
5042
|
} catch {
|
|
2804
5043
|
}
|
|
2805
5044
|
}
|
|
2806
|
-
var jobsDir = (state2) =>
|
|
5045
|
+
var jobsDir = (state2) => path17.join(state2, "jobs");
|
|
2807
5046
|
|
|
2808
5047
|
// src/cli.ts
|
|
2809
5048
|
process.stdout.on("error", (e) => {
|
|
@@ -2821,12 +5060,12 @@ var out = (s = "") => {
|
|
|
2821
5060
|
`);
|
|
2822
5061
|
};
|
|
2823
5062
|
var state = stateDir();
|
|
2824
|
-
var distDir =
|
|
5063
|
+
var distDir = path18.dirname(fileURLToPath(import.meta.url));
|
|
2825
5064
|
function hookCommand() {
|
|
2826
5065
|
const node = stableNode(process.execPath);
|
|
2827
|
-
let script =
|
|
5066
|
+
let script = path18.join(distDir, "hook.js");
|
|
2828
5067
|
try {
|
|
2829
|
-
script =
|
|
5068
|
+
script = realpathSync4(script);
|
|
2830
5069
|
} catch {
|
|
2831
5070
|
}
|
|
2832
5071
|
return (a) => ({
|
|
@@ -2839,13 +5078,13 @@ function stableNode(exe) {
|
|
|
2839
5078
|
if (!m) return exe;
|
|
2840
5079
|
let real;
|
|
2841
5080
|
try {
|
|
2842
|
-
real =
|
|
5081
|
+
real = realpathSync4(exe);
|
|
2843
5082
|
} catch {
|
|
2844
5083
|
return exe;
|
|
2845
5084
|
}
|
|
2846
5085
|
for (const c of [`${m[1]}/opt/${m[2]}/bin/node`, `${m[1]}/bin/node`]) {
|
|
2847
5086
|
try {
|
|
2848
|
-
if (
|
|
5087
|
+
if (realpathSync4(c) === real) return c;
|
|
2849
5088
|
} catch {
|
|
2850
5089
|
}
|
|
2851
5090
|
}
|
|
@@ -2881,53 +5120,58 @@ async function main() {
|
|
|
2881
5120
|
return update();
|
|
2882
5121
|
case "worker":
|
|
2883
5122
|
return workerFromArgv();
|
|
5123
|
+
case "control":
|
|
5124
|
+
return controlMain(args, out, distDir);
|
|
5125
|
+
case "wait":
|
|
5126
|
+
return waitMain(args, out);
|
|
2884
5127
|
case "hook":
|
|
2885
5128
|
out("sessionpipe hook is dist/hook.js; harnesses run it directly");
|
|
2886
5129
|
return;
|
|
2887
5130
|
case "--version":
|
|
2888
5131
|
case "-v":
|
|
2889
5132
|
case "version":
|
|
2890
|
-
out(
|
|
5133
|
+
out(VERSION3);
|
|
2891
5134
|
return;
|
|
2892
5135
|
default:
|
|
2893
5136
|
return help();
|
|
2894
5137
|
}
|
|
2895
5138
|
}
|
|
2896
5139
|
function help() {
|
|
2897
|
-
out(`sessionpipe ${
|
|
5140
|
+
out(`sessionpipe ${VERSION3} \u2014 an open protocol for what your coding agents are doing
|
|
2898
5141
|
|
|
2899
5142
|
sessionpipe install [--claude-code --codex --gemini-cli --antigravity] [--machine NAME] [--backfill DAYS] [--sink URL --tier N]
|
|
2900
5143
|
sessionpipe uninstall [--keep-state]
|
|
2901
|
-
sessionpipe sink add <url|file:PATH|stdout> [--tier 0-3] [--token T] [--pii] [--
|
|
5144
|
+
sessionpipe sink add <url|file:PATH|stdout> [--tier 0-3] [--token T] [--pii] [--name N]
|
|
2902
5145
|
sessionpipe sink list | remove <name> | test <name>
|
|
2903
5146
|
sessionpipe status | doctor [--json]
|
|
2904
5147
|
sessionpipe tail [--session ID] [--tier N] [--harness NAME]
|
|
2905
5148
|
sessionpipe backfill [--days 30] | forget <harness> <session> | replay --sink NAME
|
|
2906
5149
|
sessionpipe update
|
|
5150
|
+
${CONTROL_HELP}
|
|
2907
5151
|
|
|
2908
5152
|
Docs: https://sessionpipe.org \xB7 nothing leaves this machine until you add a sink.`);
|
|
2909
5153
|
}
|
|
2910
5154
|
async function install() {
|
|
2911
5155
|
if (viaNpx) {
|
|
2912
|
-
out(` Installing sessionpipe@${
|
|
5156
|
+
out(` Installing sessionpipe@${VERSION3} globally, so the hooks start in milliseconds\u2026`);
|
|
2913
5157
|
try {
|
|
2914
5158
|
let prefix = "";
|
|
2915
5159
|
try {
|
|
2916
|
-
prefix =
|
|
5160
|
+
prefix = execFileSync6("npm", ["prefix", "-g"], {
|
|
2917
5161
|
encoding: "utf8",
|
|
2918
5162
|
timeout: 15e3,
|
|
2919
5163
|
stdio: ["ignore", "pipe", "ignore"]
|
|
2920
5164
|
}).trim();
|
|
2921
5165
|
} catch {
|
|
2922
5166
|
}
|
|
2923
|
-
const target = /\/Cellar\/node(?:@\d+)?\/[^/]+/.test(prefix) ?
|
|
2924
|
-
|
|
5167
|
+
const target = /\/Cellar\/node(?:@\d+)?\/[^/]+/.test(prefix) ? path18.join(os7.homedir(), ".local") : prefix;
|
|
5168
|
+
execFileSync6("npm", ["install", "-g", `sessionpipe@${VERSION3}`, ...target ? ["--prefix", target] : []], {
|
|
2925
5169
|
stdio: ["ignore", "ignore", "inherit"]
|
|
2926
5170
|
});
|
|
2927
|
-
const bin = process.platform === "win32" ?
|
|
5171
|
+
const bin = process.platform === "win32" ? path18.join(target, "sessionpipe.cmd") : target ? path18.join(target, "bin", "sessionpipe") : "sessionpipe";
|
|
2928
5172
|
if (target && target !== prefix)
|
|
2929
|
-
out(` Installed into ${target}; make sure ${
|
|
2930
|
-
|
|
5173
|
+
out(` Installed into ${target}; make sure ${path18.join(target, "bin")} is on your PATH.`);
|
|
5174
|
+
execFileSync6(bin, args, { stdio: "inherit", shell: process.platform === "win32" });
|
|
2931
5175
|
return;
|
|
2932
5176
|
} catch {
|
|
2933
5177
|
out(
|
|
@@ -2948,7 +5192,7 @@ async function install() {
|
|
|
2948
5192
|
);
|
|
2949
5193
|
return;
|
|
2950
5194
|
}
|
|
2951
|
-
out(` Machine: ${machineName(cfg,
|
|
5195
|
+
out(` Machine: ${machineName(cfg, os7.hostname())}`);
|
|
2952
5196
|
const lean = isLean(cfg);
|
|
2953
5197
|
if (lean)
|
|
2954
5198
|
out(
|
|
@@ -3032,7 +5276,7 @@ async function sink() {
|
|
|
3032
5276
|
if (!s) return out(` no sink named ${args[2]}`);
|
|
3033
5277
|
const [sk] = buildSinks([s]);
|
|
3034
5278
|
if (!sk) return out(" sink type not supported yet");
|
|
3035
|
-
const session = { id: `test-${Date.now()}`, machine: machineName(cfg,
|
|
5279
|
+
const session = { id: `test-${Date.now()}`, machine: machineName(cfg, os7.hostname()) };
|
|
3036
5280
|
const mk = (type, seq) => makeEvent(
|
|
3037
5281
|
{ type, harnessEvent: "test", data: {} },
|
|
3038
5282
|
{ harness: { name: "sessionpipe" }, session: { ...session, seq } }
|
|
@@ -3047,13 +5291,15 @@ async function sink() {
|
|
|
3047
5291
|
return;
|
|
3048
5292
|
}
|
|
3049
5293
|
out(
|
|
3050
|
-
"usage: sessionpipe sink add <url|file:PATH|stdout> [--tier N] [--token T] [--pii] [--
|
|
5294
|
+
"usage: sessionpipe sink add <url|file:PATH|stdout> [--tier N] [--token T] [--pii] [--name N] | list | remove <name> | test <name>"
|
|
3051
5295
|
);
|
|
3052
5296
|
}
|
|
3053
5297
|
async function sinkAdd(url, cfg) {
|
|
3054
5298
|
const tier = Math.max(0, Math.min(3, Number(flag("--tier") ?? 0)));
|
|
3055
5299
|
const name = flag("--name") ?? (url === "stdout" ? "stdout" : url.startsWith("file:") ? "file" : new URL(url).host);
|
|
3056
|
-
|
|
5300
|
+
if (has("--control"))
|
|
5301
|
+
out(" --control is ignored: control is set up per machine (`sessionpipe control pair <url>`, spec/CONTROL.md)");
|
|
5302
|
+
const s = { name, url, tier, pii: has("--pii") };
|
|
3057
5303
|
const token = flag("--token");
|
|
3058
5304
|
if (token) s.token = token;
|
|
3059
5305
|
const secret = flag("--secret");
|
|
@@ -3070,10 +5316,6 @@ async function sinkAdd(url, cfg) {
|
|
|
3070
5316
|
}
|
|
3071
5317
|
if (wk.endpoints) s.endpoints = wk.endpoints;
|
|
3072
5318
|
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
5319
|
} catch (e) {
|
|
3078
5320
|
out(
|
|
3079
5321
|
` ! ${url} has no readable /.well-known/sessionpipe (${e.message}); added anyway, at tier ${tier}`
|
|
@@ -3082,9 +5324,7 @@ async function sinkAdd(url, cfg) {
|
|
|
3082
5324
|
}
|
|
3083
5325
|
cfg.sinks = cfg.sinks.filter((x) => x.name !== name).concat([s]);
|
|
3084
5326
|
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
|
-
);
|
|
5327
|
+
out(` \u2713 sink ${name}: ${url} at tier ${Math.min(tier, s.max_tier ?? 3)}${s.pii ? ", pii" : ""}`);
|
|
3088
5328
|
out(` tier ${tier} sends: ${TIER_TEXT[Math.min(tier, s.max_tier ?? 3)]}`);
|
|
3089
5329
|
}
|
|
3090
5330
|
var TIER_TEXT = {
|
|
@@ -3097,7 +5337,7 @@ function backfillRows(adapters, days, cfg) {
|
|
|
3097
5337
|
const since = Date.now() - days * 864e5;
|
|
3098
5338
|
const outbox = new Outbox(state);
|
|
3099
5339
|
let n = 0;
|
|
3100
|
-
const machine = machineName(cfg,
|
|
5340
|
+
const machine = machineName(cfg, os7.hostname());
|
|
3101
5341
|
for (const a of adapters) {
|
|
3102
5342
|
for (const row of a.backfill(since)) {
|
|
3103
5343
|
const ref = { harness: a.name, session: row.session.id };
|
|
@@ -3141,7 +5381,7 @@ async function forget() {
|
|
|
3141
5381
|
{ type: "session.forgotten", harnessEvent: "forget", data: {} },
|
|
3142
5382
|
{
|
|
3143
5383
|
harness: { name: harness },
|
|
3144
|
-
session: { id: session, seq: outbox.nextSeq(ref), machine: machineName(cfg,
|
|
5384
|
+
session: { id: session, seq: outbox.nextSeq(ref), machine: machineName(cfg, os7.hostname()) }
|
|
3145
5385
|
}
|
|
3146
5386
|
);
|
|
3147
5387
|
const sinks = buildSinks(cfg.sinks);
|
|
@@ -3166,7 +5406,7 @@ async function replay() {
|
|
|
3166
5406
|
function status() {
|
|
3167
5407
|
const cfg = readConfig();
|
|
3168
5408
|
const hc = hookCommand();
|
|
3169
|
-
out(` machine: ${machineName(cfg,
|
|
5409
|
+
out(` machine: ${machineName(cfg, os7.hostname())} sessionpipe ${VERSION3} state ${tilde(state)}`);
|
|
3170
5410
|
for (const a of ADAPTERS) {
|
|
3171
5411
|
if (!a.detect() && !cfg.harnesses[a.name]) continue;
|
|
3172
5412
|
for (const r of a.installed(hc, process.env, { lean: isLean(cfg) }))
|
|
@@ -3182,7 +5422,7 @@ function status() {
|
|
|
3182
5422
|
for (const ref of outbox.sessions()) {
|
|
3183
5423
|
const f = outbox.file(ref);
|
|
3184
5424
|
try {
|
|
3185
|
-
pending += Math.max(0,
|
|
5425
|
+
pending += Math.max(0, statSync9(f).size - (c[`${ref.harness}/${ref.session}`] ?? 0)) > 0 ? 1 : 0;
|
|
3186
5426
|
} catch {
|
|
3187
5427
|
}
|
|
3188
5428
|
}
|
|
@@ -3197,18 +5437,18 @@ function status() {
|
|
|
3197
5437
|
);
|
|
3198
5438
|
const jobs = jobsDir(state);
|
|
3199
5439
|
try {
|
|
3200
|
-
const n =
|
|
5440
|
+
const n = readdirSync8(jobs).length;
|
|
3201
5441
|
if (n) out(` jobs: ${n} waiting`);
|
|
3202
5442
|
} catch {
|
|
3203
5443
|
}
|
|
3204
5444
|
}
|
|
3205
5445
|
function hookWall(runs = 8) {
|
|
3206
|
-
const hook =
|
|
3207
|
-
const tmp = mkdtempSync(
|
|
5446
|
+
const hook = path18.join(distDir, "hook.js");
|
|
5447
|
+
const tmp = mkdtempSync(path18.join(os7.tmpdir(), "sessionpipe-doctor-"));
|
|
3208
5448
|
const env = {
|
|
3209
5449
|
...process.env,
|
|
3210
5450
|
SESSIONPIPE_STATE: tmp,
|
|
3211
|
-
SESSIONPIPE_CONFIG:
|
|
5451
|
+
SESSIONPIPE_CONFIG: path18.join(tmp, "config.json"),
|
|
3212
5452
|
SESSIONPIPE_NO_WORKER: "1"
|
|
3213
5453
|
};
|
|
3214
5454
|
const input = JSON.stringify({
|
|
@@ -3236,7 +5476,7 @@ function hookWall(runs = 8) {
|
|
|
3236
5476
|
}
|
|
3237
5477
|
function timing() {
|
|
3238
5478
|
try {
|
|
3239
|
-
const lines =
|
|
5479
|
+
const lines = readFileSync11(path18.join(state, "timing.jsonl"), "utf8").trim().split("\n").slice(-100);
|
|
3240
5480
|
const ms = lines.map((l) => JSON.parse(l).ms).filter((x) => x >= 0).sort((a, b) => a - b);
|
|
3241
5481
|
if (!ms.length) return null;
|
|
3242
5482
|
const q = (p) => Math.round(ms[Math.min(ms.length - 1, Math.floor(p * ms.length))] ?? 0);
|
|
@@ -3249,10 +5489,10 @@ function doctor() {
|
|
|
3249
5489
|
const cfg = readConfig();
|
|
3250
5490
|
const hc = hookCommand();
|
|
3251
5491
|
const report = {
|
|
3252
|
-
version:
|
|
5492
|
+
version: VERSION3,
|
|
3253
5493
|
node: process.version,
|
|
3254
5494
|
node_path: stableNode(process.execPath),
|
|
3255
|
-
machine: machineName(cfg,
|
|
5495
|
+
machine: machineName(cfg, os7.hostname()),
|
|
3256
5496
|
state,
|
|
3257
5497
|
config: configFile(),
|
|
3258
5498
|
harnesses: {},
|
|
@@ -3308,7 +5548,7 @@ function doctor() {
|
|
|
3308
5548
|
out(JSON.stringify(report, null, 2));
|
|
3309
5549
|
return;
|
|
3310
5550
|
}
|
|
3311
|
-
out(` sessionpipe ${
|
|
5551
|
+
out(` sessionpipe ${VERSION3} \xB7 node ${process.version} at ${report.node_path}`);
|
|
3312
5552
|
out(` config ${tilde(configFile())} \xB7 state ${tilde(state)}`);
|
|
3313
5553
|
for (const [h, st] of Object.entries(report.harnesses))
|
|
3314
5554
|
for (const r of st) out(` ${h.padEnd(12)} ${r.file.padEnd(44)} ${r.state}`);
|
|
@@ -3372,7 +5612,7 @@ async function tail() {
|
|
|
3372
5612
|
out(`sessionpipe tail \xB7 tier ${tier} \xB7 ${tilde(state)}/outbox (Ctrl-C to stop)`);
|
|
3373
5613
|
for (const ref of outbox.sessions()) {
|
|
3374
5614
|
try {
|
|
3375
|
-
offsets.set(`${ref.harness}/${ref.session}`, has("--all") ? 0 :
|
|
5615
|
+
offsets.set(`${ref.harness}/${ref.session}`, has("--all") ? 0 : statSync9(outbox.file(ref)).size);
|
|
3376
5616
|
} catch {
|
|
3377
5617
|
}
|
|
3378
5618
|
}
|
|
@@ -3395,9 +5635,9 @@ async function tail() {
|
|
|
3395
5635
|
async function workerFromArgv() {
|
|
3396
5636
|
const f = args[1];
|
|
3397
5637
|
if (!f) return;
|
|
3398
|
-
const job = JSON.parse(
|
|
5638
|
+
const job = JSON.parse(readFileSync11(f, "utf8"));
|
|
3399
5639
|
try {
|
|
3400
|
-
|
|
5640
|
+
unlinkSync7(f);
|
|
3401
5641
|
} catch {
|
|
3402
5642
|
}
|
|
3403
5643
|
await runJob(job);
|
|
@@ -3408,15 +5648,15 @@ async function update() {
|
|
|
3408
5648
|
const r = await fetch("https://registry.npmjs.org/sessionpipe/latest", { signal: AbortSignal.timeout(8e3) });
|
|
3409
5649
|
const j = await r.json();
|
|
3410
5650
|
if (!j.version) throw new Error("no version");
|
|
3411
|
-
if (j.version ===
|
|
3412
|
-
out(` ${j.version} is available (you run ${
|
|
5651
|
+
if (j.version === VERSION3) return out(` sessionpipe ${VERSION3} is current`);
|
|
5652
|
+
out(` ${j.version} is available (you run ${VERSION3}); installing into this copy's prefix\u2026`);
|
|
3413
5653
|
const prefix = prefixOf(distDir);
|
|
3414
|
-
|
|
5654
|
+
execFileSync6("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
|
|
3415
5655
|
stdio: "inherit"
|
|
3416
5656
|
});
|
|
3417
5657
|
out(" re-arming hooks\u2026");
|
|
3418
|
-
const bin = prefix ?
|
|
3419
|
-
const p =
|
|
5658
|
+
const bin = prefix ? path18.join(prefix, "bin", "sessionpipe") : "sessionpipe";
|
|
5659
|
+
const p = spawn2(bin, ["install", "--backfill", "0"], { stdio: "inherit" });
|
|
3420
5660
|
await new Promise((r2) => p.on("exit", r2));
|
|
3421
5661
|
} catch (e) {
|
|
3422
5662
|
out(` couldn't check npm: ${e.message}`);
|