sessionpipe 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/dist/cli.js +2425 -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 +50 -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.2.0 · Apache-2.0 · https://sessionpipe.org
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __export = (target, all) => {
|
|
5
|
+
for (var name in all)
|
|
6
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
7
|
+
};
|
|
3
8
|
|
|
4
9
|
// src/cli.ts
|
|
5
|
-
import { execFileSync as
|
|
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,1978 @@ 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.2.0" : "0.0.0";
|
|
3408
|
+
var MAX_REPLY = 2e4;
|
|
3409
|
+
var HELLO_EVERY_MS = 10 * 6e4;
|
|
3410
|
+
var TURN_FALLBACK_MS = 30 * 6e4;
|
|
3411
|
+
var ANSWER_WAIT_MS = 30 * 6e4;
|
|
3412
|
+
var RECENT_ACKS_MS = 25 * 36e5;
|
|
3413
|
+
var CLOSES_ATTENTION = /* @__PURE__ */ new Set([
|
|
3414
|
+
"PostToolUse",
|
|
3415
|
+
"PostToolUseFailure",
|
|
3416
|
+
"Stop",
|
|
3417
|
+
"StopFailure",
|
|
3418
|
+
"UserPromptSubmit",
|
|
3419
|
+
"SessionEnd"
|
|
3420
|
+
]);
|
|
3421
|
+
var LIMITS = { startConcurrent: 2, startPerHour: 10, headless: 3 };
|
|
3422
|
+
var ControlDaemon = class {
|
|
3423
|
+
constructor(cfg, dir, sock, deps) {
|
|
3424
|
+
this.cfg = cfg;
|
|
3425
|
+
this.dir = dir;
|
|
3426
|
+
this.sock = sock;
|
|
3427
|
+
this.deps = deps;
|
|
3428
|
+
this.nonces = new NonceStore(path13.join(dir, "nonces.jsonl"), deps.now);
|
|
3429
|
+
this.acks = new AckOutbox(path13.join(dir, "acks.jsonl"));
|
|
3430
|
+
this.state = readState(path13.join(dir, "state.json"));
|
|
3431
|
+
}
|
|
3432
|
+
cfg;
|
|
3433
|
+
dir;
|
|
3434
|
+
sock;
|
|
3435
|
+
deps;
|
|
3436
|
+
server = null;
|
|
3437
|
+
live = /* @__PURE__ */ new Map();
|
|
3438
|
+
sdkSessions = /* @__PURE__ */ new Map();
|
|
3439
|
+
nonces;
|
|
3440
|
+
acks;
|
|
3441
|
+
recentAcks = /* @__PURE__ */ new Map();
|
|
3442
|
+
inflight = /* @__PURE__ */ new Set();
|
|
3443
|
+
queues = /* @__PURE__ */ new Map();
|
|
3444
|
+
lastEnd = /* @__PURE__ */ new Map();
|
|
3445
|
+
state;
|
|
3446
|
+
running = false;
|
|
3447
|
+
polling = /* @__PURE__ */ new Map();
|
|
3448
|
+
delivered = {};
|
|
3449
|
+
helloTimer = null;
|
|
3450
|
+
helloSoon = null;
|
|
3451
|
+
aborts = /* @__PURE__ */ new Set();
|
|
3452
|
+
startsRunning = 0;
|
|
3453
|
+
startTimes = [];
|
|
3454
|
+
procs = 0;
|
|
3455
|
+
procWaiters = [];
|
|
3456
|
+
// --- lifecycle ------------------------------------------------------------------
|
|
3457
|
+
async start() {
|
|
3458
|
+
this.running = true;
|
|
3459
|
+
mkdirSync8(this.dir, { recursive: true, mode: 448 });
|
|
3460
|
+
await this.listen();
|
|
3461
|
+
for (const r of this.cfg.receivers) void this.pollLoop(r);
|
|
3462
|
+
this.helloTimer = setInterval(() => void this.helloAll(), HELLO_EVERY_MS);
|
|
3463
|
+
this.helloTimer.unref();
|
|
3464
|
+
void this.helloAll();
|
|
3465
|
+
void this.flushAcks();
|
|
3466
|
+
const idle = setInterval(() => this.closeIdleSdk(), 6e4);
|
|
3467
|
+
idle.unref();
|
|
3468
|
+
this.deps.log(`control daemon ${VERSION} up \xB7 ${this.cfg.receivers.length} receiver(s) \xB7 socket ${this.sock}`);
|
|
3469
|
+
}
|
|
3470
|
+
async stop() {
|
|
3471
|
+
this.running = false;
|
|
3472
|
+
for (const a of this.aborts) a.abort();
|
|
3473
|
+
if (this.helloTimer) clearInterval(this.helloTimer);
|
|
3474
|
+
if (this.helloSoon) clearTimeout(this.helloSoon);
|
|
3475
|
+
for (const s of this.sdkSessions.values()) s.close();
|
|
3476
|
+
for (const l of this.live.values()) {
|
|
3477
|
+
l.waiter?.destroy();
|
|
3478
|
+
for (const a of l.attentions.values()) a.destroy();
|
|
3479
|
+
}
|
|
3480
|
+
await new Promise((r) => this.server ? this.server.close(() => r()) : r());
|
|
3481
|
+
if (process.platform !== "win32")
|
|
3482
|
+
try {
|
|
3483
|
+
unlinkSync4(this.sock);
|
|
3484
|
+
} catch {
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
reload(cfg) {
|
|
3488
|
+
const known = new Set(this.cfg.receivers.map((r) => r.control + r.machine));
|
|
3489
|
+
this.cfg = cfg;
|
|
3490
|
+
for (const r of cfg.receivers) if (!known.has(r.control + r.machine)) void this.pollLoop(r);
|
|
3491
|
+
}
|
|
3492
|
+
status() {
|
|
3493
|
+
return {
|
|
3494
|
+
version: VERSION,
|
|
3495
|
+
name: this.cfg.name,
|
|
3496
|
+
receivers: this.cfg.receivers.map((r) => {
|
|
3497
|
+
const p = this.polling.get(r.control + r.machine);
|
|
3498
|
+
return {
|
|
3499
|
+
url: r.url,
|
|
3500
|
+
machine: r.machine,
|
|
3501
|
+
keys: r.keys.length,
|
|
3502
|
+
polling: !!p?.on,
|
|
3503
|
+
last_poll: p?.last ?? null,
|
|
3504
|
+
last_error: p?.error ?? null
|
|
3505
|
+
};
|
|
3506
|
+
}),
|
|
3507
|
+
waiting: [...this.live].filter(([, l]) => l.waiter).map(([k]) => k),
|
|
3508
|
+
mid_turn: [...this.live].filter(([, l]) => l.midTurn).map(([k]) => k),
|
|
3509
|
+
attentions: [...this.live].flatMap(([k, l]) => [...l.attentions.keys()].map((id) => ({ session: k, id }))),
|
|
3510
|
+
sdk: [...this.sdkSessions.keys()],
|
|
3511
|
+
nonces: this.nonces.size,
|
|
3512
|
+
acks_pending: this.acks.all().length,
|
|
3513
|
+
delivered: { ...this.delivered }
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
// --- the local socket -----------------------------------------------------------
|
|
3517
|
+
listen() {
|
|
3518
|
+
if (process.platform !== "win32") {
|
|
3519
|
+
try {
|
|
3520
|
+
if (existsSync9(this.sock)) unlinkSync4(this.sock);
|
|
3521
|
+
} catch {
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
this.server = net.createServer((s) => this.onConn(s));
|
|
3525
|
+
return new Promise((resolve, reject) => {
|
|
3526
|
+
this.server.once("error", reject);
|
|
3527
|
+
this.server.listen(this.sock, () => resolve());
|
|
3528
|
+
});
|
|
3529
|
+
}
|
|
3530
|
+
onConn(s) {
|
|
3531
|
+
let buf = "";
|
|
3532
|
+
let handled = false;
|
|
3533
|
+
s.on("error", () => {
|
|
3534
|
+
});
|
|
3535
|
+
s.on("data", (d) => {
|
|
3536
|
+
if (handled) return;
|
|
3537
|
+
buf += d.toString("utf8");
|
|
3538
|
+
if (buf.length > 64 * 1024) return s.destroy();
|
|
3539
|
+
const i = buf.indexOf("\n");
|
|
3540
|
+
if (i < 0) return;
|
|
3541
|
+
handled = true;
|
|
3542
|
+
let req;
|
|
3543
|
+
try {
|
|
3544
|
+
req = JSON.parse(buf.slice(0, i));
|
|
3545
|
+
} catch {
|
|
3546
|
+
return s.destroy();
|
|
3547
|
+
}
|
|
3548
|
+
this.onRequest(req, s);
|
|
3549
|
+
});
|
|
3550
|
+
}
|
|
3551
|
+
reply(s, r) {
|
|
3552
|
+
try {
|
|
3553
|
+
s.end(`${JSON.stringify(r)}
|
|
3554
|
+
`);
|
|
3555
|
+
} catch {
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
liveOf(session) {
|
|
3559
|
+
let l = this.live.get(session);
|
|
3560
|
+
if (!l) {
|
|
3561
|
+
l = {
|
|
3562
|
+
midTurn: false,
|
|
3563
|
+
continued: false,
|
|
3564
|
+
waiter: null,
|
|
3565
|
+
turnQueue: [],
|
|
3566
|
+
attentions: /* @__PURE__ */ new Map(),
|
|
3567
|
+
lastEvent: this.deps.now()
|
|
3568
|
+
};
|
|
3569
|
+
this.live.set(session, l);
|
|
3570
|
+
}
|
|
3571
|
+
return l;
|
|
3572
|
+
}
|
|
3573
|
+
onRequest(req, s) {
|
|
3574
|
+
switch (req.op) {
|
|
3575
|
+
case "status":
|
|
3576
|
+
this.reply(s, { op: "status", status: this.status() });
|
|
3577
|
+
return;
|
|
3578
|
+
case "wait": {
|
|
3579
|
+
if (!parseSessionRef(req.session)) {
|
|
3580
|
+
this.reply(s, { op: "none" });
|
|
3581
|
+
return;
|
|
3582
|
+
}
|
|
3583
|
+
const l = this.liveOf(req.session);
|
|
3584
|
+
if (l.waiter && l.waiter !== s) this.reply(l.waiter, { op: "replaced" });
|
|
3585
|
+
l.waiter = s;
|
|
3586
|
+
s.on("close", () => {
|
|
3587
|
+
if (l.waiter === s) {
|
|
3588
|
+
l.waiter = null;
|
|
3589
|
+
this.scheduleHello();
|
|
3590
|
+
}
|
|
3591
|
+
});
|
|
3592
|
+
this.scheduleHello();
|
|
3593
|
+
return;
|
|
3594
|
+
}
|
|
3595
|
+
case "stop": {
|
|
3596
|
+
const l = this.liveOf(req.session);
|
|
3597
|
+
l.lastEvent = this.deps.now();
|
|
3598
|
+
const p = this.takeTurn(l);
|
|
3599
|
+
if (!p) {
|
|
3600
|
+
this.reply(s, { op: "none" });
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
l.continued = true;
|
|
3604
|
+
const from = this.answerFrom(req.session);
|
|
3605
|
+
this.reply(s, { op: "block", reason: `${frame(p.text, p.receiver.url)}
|
|
3606
|
+
|
|
3607
|
+
${IN_PLACE_ANSWER}`, id: p.msgId });
|
|
3608
|
+
this.followAnswer(p.receiver, p.msgId, "turn", req.session, from, p.queuedAt);
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3611
|
+
case "permission": {
|
|
3612
|
+
const l = this.liveOf(req.session);
|
|
3613
|
+
const prev = l.attentions.get(req.attention);
|
|
3614
|
+
if (prev && prev !== s) this.reply(prev, { op: "none" });
|
|
3615
|
+
l.attentions.set(req.attention, s);
|
|
3616
|
+
s.on("close", () => {
|
|
3617
|
+
if (l.attentions.get(req.attention) === s) l.attentions.delete(req.attention);
|
|
3618
|
+
});
|
|
3619
|
+
return;
|
|
3620
|
+
}
|
|
3621
|
+
case "event":
|
|
3622
|
+
this.onEvent(req);
|
|
3623
|
+
this.reply(s, { op: "ok" });
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
/** A queued turn message that hasn't expired. */
|
|
3628
|
+
takeTurn(l) {
|
|
3629
|
+
while (l.turnQueue.length) {
|
|
3630
|
+
const p = l.turnQueue.shift();
|
|
3631
|
+
if (!this.inflight.has(p.msgId)) continue;
|
|
3632
|
+
return p;
|
|
3633
|
+
}
|
|
3634
|
+
return null;
|
|
3635
|
+
}
|
|
3636
|
+
onEvent(e) {
|
|
3637
|
+
const l = this.liveOf(e.session);
|
|
3638
|
+
l.lastEvent = this.deps.now();
|
|
3639
|
+
if (e.cwd) l.cwd = e.cwd;
|
|
3640
|
+
if (e.transcript) l.transcript = e.transcript;
|
|
3641
|
+
if (CLOSES_ATTENTION.has(e.event) && l.attentions.size) {
|
|
3642
|
+
for (const s of l.attentions.values()) this.reply(s, { op: "none" });
|
|
3643
|
+
l.attentions.clear();
|
|
3644
|
+
}
|
|
3645
|
+
if (e.event === "UserPromptSubmit" || e.event === "SessionStart") {
|
|
3646
|
+
if (e.event === "UserPromptSubmit") l.midTurn = true;
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
if (e.event === "Stop" && l.continued) {
|
|
3650
|
+
l.continued = false;
|
|
3651
|
+
return;
|
|
3652
|
+
}
|
|
3653
|
+
if (e.event === "Stop" || e.event === "StopFailure" || e.event === "SessionEnd") {
|
|
3654
|
+
l.midTurn = false;
|
|
3655
|
+
l.continued = false;
|
|
3656
|
+
const left = l.turnQueue.splice(0);
|
|
3657
|
+
for (const p of left) if (this.inflight.has(p.msgId)) void this.reroute(p);
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
// --- receivers ------------------------------------------------------------------
|
|
3661
|
+
async pollLoop(first) {
|
|
3662
|
+
const key = first.control + first.machine;
|
|
3663
|
+
const st = { last: null, error: null, on: true };
|
|
3664
|
+
this.polling.set(key, st);
|
|
3665
|
+
let backoff = 0;
|
|
3666
|
+
let downSince = 0;
|
|
3667
|
+
const steps = [1e3, 2e3, 5e3, 1e4, 15e3];
|
|
3668
|
+
const pause = (n) => steps[Math.min(n, steps.length - 1)] * (0.75 + Math.random() * 0.5);
|
|
3669
|
+
for (; ; ) {
|
|
3670
|
+
const r = this.cfg.receivers.find((x) => x.control + x.machine === key);
|
|
3671
|
+
if (!this.running || !r) break;
|
|
3672
|
+
const ctl = new AbortController();
|
|
3673
|
+
this.aborts.add(ctl);
|
|
3674
|
+
const t = setTimeout(() => ctl.abort(), 35e3);
|
|
3675
|
+
try {
|
|
3676
|
+
const u = new URL(r.control);
|
|
3677
|
+
u.searchParams.set("machine", r.machine);
|
|
3678
|
+
u.searchParams.set("wait", "25");
|
|
3679
|
+
const asked = this.deps.now();
|
|
3680
|
+
const res = await this.deps.fetch(u, { headers: { authorization: `Bearer ${r.token}` }, signal: ctl.signal });
|
|
3681
|
+
st.last = this.deps.now();
|
|
3682
|
+
if (downSince && res.status < 500) {
|
|
3683
|
+
this.deps.log(`${r.url}: back after ${Math.round((asked - downSince) / 1e3)} s (${backoff} failed tries)`);
|
|
3684
|
+
downSince = 0;
|
|
3685
|
+
}
|
|
3686
|
+
if (res.status === 401) {
|
|
3687
|
+
st.error = "the receiver no longer knows this machine (401); run `sessionpipe control pair` again";
|
|
3688
|
+
this.deps.log(`${r.url}: ${st.error}`);
|
|
3689
|
+
break;
|
|
3690
|
+
}
|
|
3691
|
+
if (res.status === 200) {
|
|
3692
|
+
const body = await res.json();
|
|
3693
|
+
const cur = this.cfg.receivers.find((x) => x.control + x.machine === key) ?? r;
|
|
3694
|
+
let fresh = 0;
|
|
3695
|
+
for (const m of body.messages ?? []) {
|
|
3696
|
+
if (m && !this.inflight.has(m.id)) fresh++;
|
|
3697
|
+
void this.handle(cur, m).catch(
|
|
3698
|
+
(e) => this.deps.log(`handle ${String(m?.id).slice(-8)}: ${String(e?.message || e)}`)
|
|
3699
|
+
);
|
|
3700
|
+
}
|
|
3701
|
+
if (!fresh) await sleep(2e3);
|
|
3702
|
+
st.error = null;
|
|
3703
|
+
backoff = 0;
|
|
3704
|
+
} else if (res.status === 204) {
|
|
3705
|
+
st.error = null;
|
|
3706
|
+
backoff = 0;
|
|
3707
|
+
} else {
|
|
3708
|
+
st.error = `HTTP ${res.status}`;
|
|
3709
|
+
downSince ||= this.deps.now();
|
|
3710
|
+
await sleep(pause(backoff++));
|
|
3711
|
+
}
|
|
3712
|
+
} catch (e) {
|
|
3713
|
+
if (!this.running) break;
|
|
3714
|
+
st.error = String(e?.message || e).slice(0, 120);
|
|
3715
|
+
downSince ||= this.deps.now();
|
|
3716
|
+
await sleep(pause(backoff++));
|
|
3717
|
+
} finally {
|
|
3718
|
+
clearTimeout(t);
|
|
3719
|
+
this.aborts.delete(ctl);
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
st.on = false;
|
|
3723
|
+
}
|
|
3724
|
+
scheduleHello() {
|
|
3725
|
+
if (this.helloSoon) return;
|
|
3726
|
+
this.helloSoon = setTimeout(() => {
|
|
3727
|
+
this.helloSoon = null;
|
|
3728
|
+
void this.helloAll();
|
|
3729
|
+
}, 1500);
|
|
3730
|
+
this.helloSoon.unref();
|
|
3731
|
+
}
|
|
3732
|
+
async helloAll() {
|
|
3733
|
+
const waiting = [...this.live].filter(([, l]) => l.waiter).map(([k]) => k);
|
|
3734
|
+
const modes = ["waiter", "turn", "resume", "fork", ...this.deps.sdk ? ["sdk"] : []];
|
|
3735
|
+
for (const r of this.cfg.receivers) {
|
|
3736
|
+
const body = {
|
|
3737
|
+
name: this.cfg.name,
|
|
3738
|
+
harnesses: ["claude-code"],
|
|
3739
|
+
modes,
|
|
3740
|
+
keys: r.keys.map((k) => k.id),
|
|
3741
|
+
version: VERSION,
|
|
3742
|
+
waiting,
|
|
3743
|
+
folders: this.cfg.folders,
|
|
3744
|
+
mode: this.cfg.mode
|
|
3745
|
+
};
|
|
3746
|
+
await this.post(r, "/hello", body).catch(() => {
|
|
3747
|
+
});
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
async post(r, sub, body) {
|
|
3751
|
+
const ctl = new AbortController();
|
|
3752
|
+
const t = setTimeout(() => ctl.abort(), 15e3);
|
|
3753
|
+
try {
|
|
3754
|
+
return await this.deps.fetch(`${r.control.replace(/\/$/, "")}${sub}`, {
|
|
3755
|
+
method: "POST",
|
|
3756
|
+
headers: { authorization: `Bearer ${r.token}`, "content-type": "application/json" },
|
|
3757
|
+
body: JSON.stringify(body),
|
|
3758
|
+
signal: ctl.signal
|
|
3759
|
+
});
|
|
3760
|
+
} finally {
|
|
3761
|
+
clearTimeout(t);
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
// --- acks -----------------------------------------------------------------------
|
|
3765
|
+
finish(r, id, a, since) {
|
|
3766
|
+
const ack = { receiver: r.control + r.machine, id, at: new Date(this.deps.now()).toISOString(), ...a };
|
|
3767
|
+
if (ack.reply) ack.reply = redactSecrets(ack.reply).slice(0, MAX_REPLY);
|
|
3768
|
+
if (ack.detail) ack.detail = redactSecrets(ack.detail).slice(0, 500);
|
|
3769
|
+
if (a.outcome !== "taken") {
|
|
3770
|
+
this.inflight.delete(id);
|
|
3771
|
+
this.recentAcks.set(id, { ack, at: this.deps.now() });
|
|
3772
|
+
this.delivered[a.mode ?? a.outcome] = (this.delivered[a.mode ?? a.outcome] ?? 0) + 1;
|
|
3773
|
+
this.deps.log(
|
|
3774
|
+
`ack ${id.slice(-8)} ${a.outcome}${a.mode ? ` via ${a.mode}` : ""}${a.code ? ` (${a.code})` : ""}${since ? ` send\u2192${a.outcome} ${this.deps.now() - since} ms` : ""}`
|
|
3775
|
+
);
|
|
3776
|
+
}
|
|
3777
|
+
this.acks.add(ack);
|
|
3778
|
+
void this.flushAcks();
|
|
3779
|
+
}
|
|
3780
|
+
flushing = false;
|
|
3781
|
+
async flushAcks() {
|
|
3782
|
+
if (this.flushing) return;
|
|
3783
|
+
this.flushing = true;
|
|
3784
|
+
try {
|
|
3785
|
+
for (let round = 0; round < 5; round++) {
|
|
3786
|
+
const all = this.acks.all();
|
|
3787
|
+
if (!all.length) return;
|
|
3788
|
+
const left = [];
|
|
3789
|
+
for (const r of this.cfg.receivers) {
|
|
3790
|
+
const mine = all.filter((a) => a.receiver === r.control + r.machine);
|
|
3791
|
+
if (!mine.length) continue;
|
|
3792
|
+
const acks = mine.map(({ receiver: _r, ...a }) => a);
|
|
3793
|
+
const ok = await this.post(r, "/ack", { acks }).then((res) => res.status >= 200 && res.status < 300).catch(() => false);
|
|
3794
|
+
if (!ok) left.push(...mine);
|
|
3795
|
+
}
|
|
3796
|
+
const now = this.acks.all();
|
|
3797
|
+
const added = now.slice(all.length);
|
|
3798
|
+
this.acks.keep([...left, ...added]);
|
|
3799
|
+
if (!left.length && !added.length) return;
|
|
3800
|
+
if (left.length) await sleep(2e3 * (round + 1));
|
|
3801
|
+
}
|
|
3802
|
+
} finally {
|
|
3803
|
+
this.flushing = false;
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
// --- one message ----------------------------------------------------------------
|
|
3807
|
+
async handle(r, m) {
|
|
3808
|
+
if (!m || typeof m.id !== "string") return;
|
|
3809
|
+
if (this.inflight.has(m.id)) return;
|
|
3810
|
+
const prior = this.recentAcks.get(m.id);
|
|
3811
|
+
if (prior && this.deps.now() - prior.at < RECENT_ACKS_MS) {
|
|
3812
|
+
this.acks.add(prior.ack);
|
|
3813
|
+
void this.flushAcks();
|
|
3814
|
+
return;
|
|
3815
|
+
}
|
|
3816
|
+
this.inflight.add(m.id);
|
|
3817
|
+
const sent = Date.parse(m.at) || this.deps.now();
|
|
3818
|
+
if (Date.parse(m.expires_at) <= this.deps.now()) return this.finish(r, m.id, { outcome: "expired" });
|
|
3819
|
+
const v = await verifyCommand(
|
|
3820
|
+
{ cmd: m.cmd, csig: m.csig ?? null, grant: m.grant ?? null, confirm: m.confirm ?? null },
|
|
3821
|
+
{ machine: r.machine, rpId: r.rpId, trusted: r.keys, now: this.deps.now(), seenNonce: this.nonces.seen }
|
|
3822
|
+
);
|
|
3823
|
+
if (!v.ok) {
|
|
3824
|
+
this.deps.log(`refused ${m.id.slice(-8)}: ${v.code} \u2014 ${v.why}`);
|
|
3825
|
+
return this.finish(r, m.id, { outcome: "refused", code: v.code, detail: v.why });
|
|
3826
|
+
}
|
|
3827
|
+
const cmd2 = v.cmd;
|
|
3828
|
+
const ref = parseSessionRef(cmd2.session);
|
|
3829
|
+
const kp = kindPath(cmd2.kind, ref.harness);
|
|
3830
|
+
if (typeof kp === "object") return this.finish(r, m.id, { outcome: "unsupported", detail: kp.unsupported });
|
|
3831
|
+
try {
|
|
3832
|
+
if (kp === "start") return await this.startSession(r, m.id, cmd2, sent);
|
|
3833
|
+
const place = this.place(cmd2.session);
|
|
3834
|
+
if ("refused" in place)
|
|
3835
|
+
return this.finish(r, m.id, { outcome: "refused", code: place.refused, detail: place.why });
|
|
3836
|
+
if (kp === "permission") return this.permission(r, m.id, cmd2, sent);
|
|
3837
|
+
if (kp === "cancel") return await this.cancel(r, m.id, cmd2, sent);
|
|
3838
|
+
return await this.serial(`deliver:${cmd2.session}`, async () => {
|
|
3839
|
+
const now = this.place(cmd2.session);
|
|
3840
|
+
if ("refused" in now) return this.finish(r, m.id, { outcome: "refused", code: now.refused, detail: now.why });
|
|
3841
|
+
return this.prompt(r, m.id, cmd2, now, sent);
|
|
3842
|
+
});
|
|
3843
|
+
} catch (e) {
|
|
3844
|
+
this.finish(r, m.id, { outcome: "failed", detail: String(e?.message || e) });
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
/** Where the session lives, and whether its folder is allowed — read from the
|
|
3848
|
+
* session's own transcript (Claude Code) or the hooks' cwd, never the command. */
|
|
3849
|
+
place(session) {
|
|
3850
|
+
const ref = parseSessionRef(session);
|
|
3851
|
+
if (ref.harness === "claude-code") {
|
|
3852
|
+
const target = this.state.copies[ref.id] ?? ref.id;
|
|
3853
|
+
const t = findTranscript2(target, this.deps.claudeDirs()) ?? findTranscript2(ref.id, this.deps.claudeDirs());
|
|
3854
|
+
if (!t) return { refused: "no_session", why: `no Claude Code session ${ref.id.slice(0, 8)} on this machine` };
|
|
3855
|
+
const folder = sessionFolder2(t);
|
|
3856
|
+
const ok2 = folder && this.allowed(folder);
|
|
3857
|
+
if (!ok2) return { refused: "folder", why: "that session's folder isn't one this machine allows" };
|
|
3858
|
+
return { folder: ok2, transcript: t };
|
|
3859
|
+
}
|
|
3860
|
+
const cwd = this.live.get(session)?.cwd;
|
|
3861
|
+
const ok = cwd && this.allowed(cwd);
|
|
3862
|
+
if (!ok) return { refused: cwd ? "folder" : "no_session", why: "no allowed folder known for that session" };
|
|
3863
|
+
return { folder: ok, transcript: null };
|
|
3864
|
+
}
|
|
3865
|
+
allowed(dir) {
|
|
3866
|
+
if (!path13.isAbsolute(dir)) return null;
|
|
3867
|
+
let r;
|
|
3868
|
+
try {
|
|
3869
|
+
r = realpathSync2.native(dir);
|
|
3870
|
+
if (!statSync7(r).isDirectory()) return null;
|
|
3871
|
+
} catch {
|
|
3872
|
+
return null;
|
|
3873
|
+
}
|
|
3874
|
+
for (const f of this.cfg.folders) {
|
|
3875
|
+
let rf = f;
|
|
3876
|
+
try {
|
|
3877
|
+
rf = realpathSync2.native(f);
|
|
3878
|
+
} catch {
|
|
3879
|
+
}
|
|
3880
|
+
if (r === rf || r.startsWith(rf.endsWith(path13.sep) ? rf : rf + path13.sep)) return r;
|
|
3881
|
+
}
|
|
3882
|
+
return null;
|
|
3883
|
+
}
|
|
3884
|
+
permission(r, id, cmd2, sent) {
|
|
3885
|
+
const l = this.live.get(cmd2.session);
|
|
3886
|
+
const hook = l?.attentions.get(cmd2.for);
|
|
3887
|
+
if (!hook) {
|
|
3888
|
+
if (cmd2.decision === "allow")
|
|
3889
|
+
return this.finish(r, id, {
|
|
3890
|
+
outcome: "refused",
|
|
3891
|
+
code: "no_attention",
|
|
3892
|
+
detail: "no permission prompt open for that id"
|
|
3893
|
+
});
|
|
3894
|
+
this.finish(r, id, { outcome: "expired", detail: "the prompt was already answered or closed" });
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
l.attentions.delete(cmd2.for);
|
|
3898
|
+
this.reply(hook, {
|
|
3899
|
+
op: "decision",
|
|
3900
|
+
behavior: cmd2.decision,
|
|
3901
|
+
...cmd2.note ? { message: cmd2.note } : {},
|
|
3902
|
+
id
|
|
3903
|
+
});
|
|
3904
|
+
this.finish(r, id, { outcome: "delivered", mode: "turn" }, sent);
|
|
3905
|
+
}
|
|
3906
|
+
async cancel(r, id, cmd2, sent) {
|
|
3907
|
+
const ref = parseSessionRef(cmd2.session);
|
|
3908
|
+
const held = this.sdkSessions.get(ref.id);
|
|
3909
|
+
if (!held)
|
|
3910
|
+
return this.finish(r, id, {
|
|
3911
|
+
outcome: "unsupported",
|
|
3912
|
+
detail: "no cancel path for a session the daemon doesn't hold"
|
|
3913
|
+
});
|
|
3914
|
+
await held.interrupt();
|
|
3915
|
+
this.finish(r, id, { outcome: "delivered", mode: "sdk" }, sent);
|
|
3916
|
+
}
|
|
3917
|
+
view(session, transcript) {
|
|
3918
|
+
const ref = parseSessionRef(session);
|
|
3919
|
+
const l = this.live.get(session);
|
|
3920
|
+
const claude = ref.harness === "claude-code" ? this.deps.claude() : null;
|
|
3921
|
+
const target = this.state.copies[ref.id] ?? ref.id;
|
|
3922
|
+
const t = transcript;
|
|
3923
|
+
const live = !!t && (liveProcess2(target, t.configDir) || recentlyWritten2(t.file, this.lastEnd.get(target), this.deps.now()));
|
|
3924
|
+
return {
|
|
3925
|
+
harness: ref.harness,
|
|
3926
|
+
sdk: this.sdkSessions.has(ref.id),
|
|
3927
|
+
waiter: !!l?.waiter && !l.waiter.destroyed,
|
|
3928
|
+
midTurn: !!l?.midTurn,
|
|
3929
|
+
live,
|
|
3930
|
+
known: !!t,
|
|
3931
|
+
canFork: !!claude?.caps.fork,
|
|
3932
|
+
api: false
|
|
3933
|
+
};
|
|
3934
|
+
}
|
|
3935
|
+
async prompt(r, id, cmd2, place, sent) {
|
|
3936
|
+
const route = routePrompt(this.view(cmd2.session, place.transcript));
|
|
3937
|
+
if ("unsupported" in route) return this.finish(r, id, { outcome: "unsupported", detail: route.unsupported });
|
|
3938
|
+
if ("refused" in route) return this.finish(r, id, { outcome: "refused", code: route.refused });
|
|
3939
|
+
const ref = parseSessionRef(cmd2.session);
|
|
3940
|
+
const text = cmd2.text;
|
|
3941
|
+
switch (route.mode) {
|
|
3942
|
+
case "waiter": {
|
|
3943
|
+
const l = this.live.get(cmd2.session);
|
|
3944
|
+
const w = l.waiter;
|
|
3945
|
+
l.waiter = null;
|
|
3946
|
+
const from = this.answerFrom(cmd2.session);
|
|
3947
|
+
this.reply(w, { op: "message", text: frame(text, r.url), id });
|
|
3948
|
+
this.scheduleHello();
|
|
3949
|
+
return this.followAnswer(r, id, "waiter", cmd2.session, from, sent);
|
|
3950
|
+
}
|
|
3951
|
+
case "turn": {
|
|
3952
|
+
const l = this.liveOf(cmd2.session);
|
|
3953
|
+
const p = { receiver: r, msgId: id, text, session: cmd2.session, queuedAt: sent };
|
|
3954
|
+
l.turnQueue.push(p);
|
|
3955
|
+
this.finish(r, id, { outcome: "taken" });
|
|
3956
|
+
const t = setTimeout(() => {
|
|
3957
|
+
if (this.inflight.has(id) && l.turnQueue.includes(p)) {
|
|
3958
|
+
l.turnQueue.splice(l.turnQueue.indexOf(p), 1);
|
|
3959
|
+
void this.reroute(p);
|
|
3960
|
+
}
|
|
3961
|
+
}, TURN_FALLBACK_MS);
|
|
3962
|
+
t.unref();
|
|
3963
|
+
return;
|
|
3964
|
+
}
|
|
3965
|
+
case "sdk": {
|
|
3966
|
+
const s = this.sdkSessions.get(ref.id);
|
|
3967
|
+
this.finish(r, id, { outcome: "taken" });
|
|
3968
|
+
const out2 = await s.send(frame(text, r.url));
|
|
3969
|
+
return this.finish(
|
|
3970
|
+
r,
|
|
3971
|
+
id,
|
|
3972
|
+
out2.error ? { outcome: "failed", detail: out2.error } : { outcome: "delivered", mode: "sdk", reply: out2.reply },
|
|
3973
|
+
sent
|
|
3974
|
+
);
|
|
3975
|
+
}
|
|
3976
|
+
case "resume":
|
|
3977
|
+
case "fork":
|
|
3978
|
+
return this.headless(r, id, cmd2, place, route.mode, sent);
|
|
3979
|
+
case "api":
|
|
3980
|
+
return this.finish(r, id, { outcome: "unsupported", detail: "no input API wired for this harness" });
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
/** A turn message whose Stop never took it: route again, now that it isn't mid-turn. */
|
|
3984
|
+
async reroute(p) {
|
|
3985
|
+
const l = this.liveOf(p.session);
|
|
3986
|
+
l.midTurn = false;
|
|
3987
|
+
const place = this.place(p.session);
|
|
3988
|
+
if ("refused" in place) return this.finish(p.receiver, p.msgId, { outcome: "refused", code: place.refused });
|
|
3989
|
+
const cmd2 = { kind: "prompt", session: p.session, text: p.text };
|
|
3990
|
+
this.inflight.delete(p.msgId);
|
|
3991
|
+
this.inflight.add(p.msgId);
|
|
3992
|
+
await this.prompt(p.receiver, p.msgId, cmd2, place, p.queuedAt).catch(
|
|
3993
|
+
(e) => this.finish(p.receiver, p.msgId, { outcome: "failed", detail: String(e?.message || e) })
|
|
3994
|
+
);
|
|
3995
|
+
}
|
|
3996
|
+
/** Jobs for one session (and its copy) run one at a time, in order. */
|
|
3997
|
+
serial(key, task) {
|
|
3998
|
+
const prev = this.queues.get(key) ?? Promise.resolve();
|
|
3999
|
+
const next = prev.then(task, task);
|
|
4000
|
+
this.queues.set(
|
|
4001
|
+
key,
|
|
4002
|
+
next.catch(() => {
|
|
4003
|
+
})
|
|
4004
|
+
);
|
|
4005
|
+
return next;
|
|
4006
|
+
}
|
|
4007
|
+
async headless(r, id, cmd2, place, mode, sent) {
|
|
4008
|
+
const claude = this.deps.claude();
|
|
4009
|
+
if (!claude)
|
|
4010
|
+
return this.finish(r, id, { outcome: "failed", detail: "Claude Code isn't installed on this machine" });
|
|
4011
|
+
const mf = modeFlags2(this.cfg.mode, claude.caps);
|
|
4012
|
+
if ("error" in mf) return this.finish(r, id, { outcome: "refused", code: "mode", detail: mf.error });
|
|
4013
|
+
const ref = parseSessionRef(cmd2.session);
|
|
4014
|
+
this.finish(r, id, { outcome: "taken" });
|
|
4015
|
+
await this.serial(ref.id, async () => {
|
|
4016
|
+
const target = this.state.copies[ref.id] ?? ref.id;
|
|
4017
|
+
const resume = mode === "fork" ? ["--resume", target, "--fork-session"] : ["--resume", target];
|
|
4018
|
+
const args2 = ["-p", "--output-format", "json", ...mf.args, ...resume, frame(cmd2.text, r.url)];
|
|
4019
|
+
this.deps.log(`${id.slice(-8)}: claude -p ${[...mf.args, ...resume].join(" ")} in ${place.folder}`);
|
|
4020
|
+
const res = await this.slot(
|
|
4021
|
+
() => this.deps.run(claude.bin, args2, {
|
|
4022
|
+
cwd: place.folder,
|
|
4023
|
+
env: claudeEnv2(place.transcript?.configDir, this.deps.env),
|
|
4024
|
+
timeoutMs: JOB_TIMEOUT_MS2
|
|
4025
|
+
})
|
|
4026
|
+
);
|
|
4027
|
+
this.lastEnd.set(target, this.deps.now());
|
|
4028
|
+
const out2 = this.outcome(res);
|
|
4029
|
+
if ("failed" in out2) return this.finish(r, id, { outcome: "failed", detail: out2.failed });
|
|
4030
|
+
const ran = isUuid2(out2.session) ? out2.session : target;
|
|
4031
|
+
if (mode === "fork" && ran !== target) {
|
|
4032
|
+
this.state.copies[ref.id] = ran;
|
|
4033
|
+
writeState(path13.join(this.dir, "state.json"), this.state);
|
|
4034
|
+
this.lastEnd.set(ran, this.deps.now());
|
|
4035
|
+
}
|
|
4036
|
+
this.finish(
|
|
4037
|
+
r,
|
|
4038
|
+
id,
|
|
4039
|
+
{ outcome: "delivered", mode, reply: out2.reply, ...ran !== ref.id ? { session: `claude-code:${ran}` } : {} },
|
|
4040
|
+
sent
|
|
4041
|
+
);
|
|
4042
|
+
});
|
|
4043
|
+
}
|
|
4044
|
+
outcome(res) {
|
|
4045
|
+
if (res.error) return { failed: `couldn't start Claude Code: ${res.error}` };
|
|
4046
|
+
if (res.timedOut) return { failed: "Claude Code was still working after 30 minutes, so it was stopped" };
|
|
4047
|
+
if (res.tooBig) return { failed: "Claude Code's answer was too large to read" };
|
|
4048
|
+
const j = parseResult2(res.stdout);
|
|
4049
|
+
if (!j) {
|
|
4050
|
+
const why = String(res.stderr || "").trim().split("\n").slice(-3).join(" ");
|
|
4051
|
+
return {
|
|
4052
|
+
failed: `Claude Code exited (${res.signal || `code ${res.code}`}) without an answer${why ? `: ${why}` : ""}`
|
|
4053
|
+
};
|
|
4054
|
+
}
|
|
4055
|
+
if (j.is_error) return { failed: String(j.result || j.subtype || "Claude Code reported an error") };
|
|
4056
|
+
const denials = Array.isArray(j.permission_denials) ? j.permission_denials : [];
|
|
4057
|
+
const names = [...new Set(denials.map((d) => d?.tool_name).filter((n) => typeof n === "string"))];
|
|
4058
|
+
const tail2 = denials.length ? `
|
|
4059
|
+
|
|
4060
|
+
(Not allowed on this machine, since nobody was there to approve: ${names.join(", ") || `${denials.length} action(s)`}.)` : "";
|
|
4061
|
+
const text = typeof j.result === "string" && j.result.trim() ? j.result : "(Claude Code finished without a written reply.)";
|
|
4062
|
+
return { reply: text + tail2, ...typeof j.session_id === "string" ? { session: j.session_id } : {} };
|
|
4063
|
+
}
|
|
4064
|
+
/** Where the session's transcript ends right now: its answer starts after this. */
|
|
4065
|
+
answerFrom(session) {
|
|
4066
|
+
const ref = parseSessionRef(session);
|
|
4067
|
+
if (!ref || ref.harness !== "claude-code") return null;
|
|
4068
|
+
const t = findTranscript2(this.state.copies[ref.id] ?? ref.id, this.deps.claudeDirs());
|
|
4069
|
+
return t ? { file: t.file, offset: transcriptEnd(t.file) } : null;
|
|
4070
|
+
}
|
|
4071
|
+
/** A message handed to a live session (a waiter, a Stop block) is `taken` now; the
|
|
4072
|
+
* session answers in its own turn, which the daemon reads from the transcript and
|
|
4073
|
+
* sends back as the final ack's `reply` — so the person sees the answer where they
|
|
4074
|
+
* asked. A turn that doesn't end within ANSWER_WAIT_MS is acked delivered without. */
|
|
4075
|
+
followAnswer(r, id, mode, session, from, sent) {
|
|
4076
|
+
this.deps.log(`${id.slice(-8)}: in ${session.slice(0, 24)} via ${mode} ${this.deps.now() - sent} ms after send`);
|
|
4077
|
+
this.finish(r, id, { outcome: "taken" });
|
|
4078
|
+
void (async () => {
|
|
4079
|
+
const answer = from ? await awaitAnswer(from.file, from.offset, {
|
|
4080
|
+
timeoutMs: this.deps.answerWaitMs ?? ANSWER_WAIT_MS,
|
|
4081
|
+
now: this.deps.now
|
|
4082
|
+
}) : null;
|
|
4083
|
+
this.finish(r, id, { outcome: "delivered", mode, ...answer ? { reply: answer } : {} }, sent);
|
|
4084
|
+
})().catch((e) => this.finish(r, id, { outcome: "delivered", mode, detail: String(e?.message || e) }));
|
|
4085
|
+
}
|
|
4086
|
+
limits() {
|
|
4087
|
+
const l = this.cfg.limits ?? {};
|
|
4088
|
+
const n = (v, d) => typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : d;
|
|
4089
|
+
return {
|
|
4090
|
+
startConcurrent: n(l.start_concurrent, LIMITS.startConcurrent),
|
|
4091
|
+
startPerHour: n(l.start_per_hour, LIMITS.startPerHour),
|
|
4092
|
+
headless: Math.max(1, n(l.headless, LIMITS.headless))
|
|
4093
|
+
};
|
|
4094
|
+
}
|
|
4095
|
+
/** One headless Claude Code process at a time per slot; the rest wait in order. */
|
|
4096
|
+
async slot(fn) {
|
|
4097
|
+
while (this.procs >= this.limits().headless) await new Promise((r) => this.procWaiters.push(r));
|
|
4098
|
+
this.procs++;
|
|
4099
|
+
try {
|
|
4100
|
+
return await fn();
|
|
4101
|
+
} finally {
|
|
4102
|
+
this.procs--;
|
|
4103
|
+
this.procWaiters.shift()?.();
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
async startSession(r, id, cmd2, sent) {
|
|
4107
|
+
const lim = this.limits();
|
|
4108
|
+
const now = this.deps.now();
|
|
4109
|
+
this.startTimes = this.startTimes.filter((t) => now - t < 36e5);
|
|
4110
|
+
if (this.startsRunning >= lim.startConcurrent || this.startTimes.length >= lim.startPerHour) {
|
|
4111
|
+
const detail = this.startsRunning >= lim.startConcurrent ? `this machine is already starting ${this.startsRunning} new session(s); it starts at most ${lim.startConcurrent} at once` : `this machine started ${this.startTimes.length} new sessions in the last hour, its limit`;
|
|
4112
|
+
this.deps.log(`refused ${id.slice(-8)}: start_limit \u2014 ${detail}`);
|
|
4113
|
+
return this.finish(r, id, { outcome: "refused", code: "start_limit", detail });
|
|
4114
|
+
}
|
|
4115
|
+
this.startsRunning++;
|
|
4116
|
+
this.startTimes.push(now);
|
|
4117
|
+
try {
|
|
4118
|
+
await this.startOne(r, id, cmd2, sent);
|
|
4119
|
+
} finally {
|
|
4120
|
+
this.startsRunning--;
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
async startOne(r, id, cmd2, sent) {
|
|
4124
|
+
const ref = parseSessionRef(cmd2.session);
|
|
4125
|
+
const cwd = this.allowed(cmd2.cwd);
|
|
4126
|
+
if (!cwd)
|
|
4127
|
+
return this.finish(r, id, {
|
|
4128
|
+
outcome: "refused",
|
|
4129
|
+
code: "folder",
|
|
4130
|
+
detail: "that folder isn't one this machine allows"
|
|
4131
|
+
});
|
|
4132
|
+
if (findTranscript2(ref.id, this.deps.claudeDirs()) || this.sdkSessions.has(ref.id))
|
|
4133
|
+
return this.finish(r, id, {
|
|
4134
|
+
outcome: "refused",
|
|
4135
|
+
code: "bad_session",
|
|
4136
|
+
detail: "that new session's id is already taken"
|
|
4137
|
+
});
|
|
4138
|
+
this.finish(r, id, { outcome: "taken" });
|
|
4139
|
+
if (this.deps.sdk) {
|
|
4140
|
+
const sdk = this.deps.sdk;
|
|
4141
|
+
const out3 = await this.slot(async () => {
|
|
4142
|
+
const s = sdk.start({ session: ref.id, cwd, mode: this.cfg.mode });
|
|
4143
|
+
this.sdkSessions.set(ref.id, s);
|
|
4144
|
+
return s.send(frame(cmd2.text, r.url));
|
|
4145
|
+
});
|
|
4146
|
+
return this.finish(
|
|
4147
|
+
r,
|
|
4148
|
+
id,
|
|
4149
|
+
out3.error ? { outcome: "failed", detail: out3.error } : { outcome: "delivered", mode: "sdk", reply: out3.reply },
|
|
4150
|
+
sent
|
|
4151
|
+
);
|
|
4152
|
+
}
|
|
4153
|
+
const claude = this.deps.claude();
|
|
4154
|
+
if (!claude)
|
|
4155
|
+
return this.finish(r, id, { outcome: "failed", detail: "Claude Code isn't installed on this machine" });
|
|
4156
|
+
const mf = modeFlags2(this.cfg.mode, claude.caps);
|
|
4157
|
+
if ("error" in mf) return this.finish(r, id, { outcome: "refused", code: "mode", detail: mf.error });
|
|
4158
|
+
const res = await this.slot(
|
|
4159
|
+
() => this.deps.run(
|
|
4160
|
+
claude.bin,
|
|
4161
|
+
["-p", "--output-format", "json", ...mf.args, "--session-id", ref.id, frame(cmd2.text, r.url)],
|
|
4162
|
+
{ cwd, env: claudeEnv2(void 0, this.deps.env), timeoutMs: JOB_TIMEOUT_MS2 }
|
|
4163
|
+
)
|
|
4164
|
+
);
|
|
4165
|
+
this.lastEnd.set(ref.id, this.deps.now());
|
|
4166
|
+
const out2 = this.outcome(res);
|
|
4167
|
+
if ("failed" in out2) return this.finish(r, id, { outcome: "failed", detail: out2.failed });
|
|
4168
|
+
this.finish(r, id, { outcome: "delivered", mode: "resume", reply: out2.reply }, sent);
|
|
4169
|
+
}
|
|
4170
|
+
closeIdleSdk() {
|
|
4171
|
+
const idle = this.deps.sdkIdleMs ?? 10 * 6e4;
|
|
4172
|
+
for (const [id, s] of this.sdkSessions)
|
|
4173
|
+
if (!s.busy && this.deps.now() - s.lastUsed > idle) {
|
|
4174
|
+
s.close();
|
|
4175
|
+
this.sdkSessions.delete(id);
|
|
4176
|
+
this.deps.log(
|
|
4177
|
+
`sdk session ${id.slice(0, 8)} closed after ${Math.round(idle / 6e4)} min idle (it resumes on the next message)`
|
|
4178
|
+
);
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
};
|
|
4182
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
|
|
4183
|
+
|
|
4184
|
+
// src/control/local.ts
|
|
4185
|
+
import net2 from "node:net";
|
|
4186
|
+
import os3 from "node:os";
|
|
4187
|
+
import path14 from "node:path";
|
|
4188
|
+
function socketPath(state2, env = process.env) {
|
|
4189
|
+
if (env.SESSIONPIPE_SOCKET) return env.SESSIONPIPE_SOCKET;
|
|
4190
|
+
if (process.platform === "win32") return `\\\\.\\pipe\\sessionpipe-control-${os3.userInfo().username}`;
|
|
4191
|
+
return path14.join(state2, "control.sock");
|
|
4192
|
+
}
|
|
4193
|
+
function ask(sock, req, timeoutMs) {
|
|
4194
|
+
return new Promise((resolve) => {
|
|
4195
|
+
let buf = "";
|
|
4196
|
+
let done = false;
|
|
4197
|
+
const s = net2.connect(sock);
|
|
4198
|
+
const finish = (r) => {
|
|
4199
|
+
if (done) return;
|
|
4200
|
+
done = true;
|
|
4201
|
+
clearTimeout(t);
|
|
4202
|
+
s.destroy();
|
|
4203
|
+
resolve(r);
|
|
4204
|
+
};
|
|
4205
|
+
const t = setTimeout(() => finish(null), timeoutMs);
|
|
4206
|
+
s.on("connect", () => s.write(`${JSON.stringify(req)}
|
|
4207
|
+
`));
|
|
4208
|
+
s.on("data", (d) => {
|
|
4209
|
+
buf += d.toString("utf8");
|
|
4210
|
+
const i = buf.indexOf("\n");
|
|
4211
|
+
if (i < 0) return;
|
|
4212
|
+
try {
|
|
4213
|
+
finish(JSON.parse(buf.slice(0, i)));
|
|
4214
|
+
} catch {
|
|
4215
|
+
finish(null);
|
|
4216
|
+
}
|
|
4217
|
+
});
|
|
4218
|
+
s.on("error", () => finish(null));
|
|
4219
|
+
s.on("close", () => finish(null));
|
|
4220
|
+
});
|
|
4221
|
+
}
|
|
4222
|
+
|
|
4223
|
+
// src/control/pair.ts
|
|
4224
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
4225
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync9, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7 } from "node:fs";
|
|
4226
|
+
import os4 from "node:os";
|
|
4227
|
+
import path15 from "node:path";
|
|
4228
|
+
var VERSION2 = true ? "0.2.0" : "0.0.0";
|
|
4229
|
+
async function json(r) {
|
|
4230
|
+
try {
|
|
4231
|
+
return await r.json();
|
|
4232
|
+
} catch {
|
|
4233
|
+
return {};
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
async function discover(url, f) {
|
|
4237
|
+
const base = url.replace(/\/$/, "");
|
|
4238
|
+
const r = await f(`${base}/.well-known/sessionpipe`);
|
|
4239
|
+
if (!r.ok) throw new Error(`${base} has no sessionpipe well-known file (HTTP ${r.status})`);
|
|
4240
|
+
const wk = await r.json();
|
|
4241
|
+
if (!wk.capabilities?.includes("control") || !wk.endpoints?.control || !wk.control?.signing?.rp_id)
|
|
4242
|
+
throw new Error(`${base} doesn't serve control yet (its well-known file lists no control.signing)`);
|
|
4243
|
+
const ep = wk.endpoints.control;
|
|
4244
|
+
return { control: /^https?:\/\//.test(ep) ? ep : base + ep, rpId: wk.control.signing.rp_id };
|
|
4245
|
+
}
|
|
4246
|
+
async function pair(o) {
|
|
4247
|
+
const f = o.fetch ?? fetch;
|
|
4248
|
+
const sleep2 = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
4249
|
+
const { control, rpId } = await discover(o.url, f);
|
|
4250
|
+
const cfg = readControl(o.env) ?? { name: o.name, folders: [], mode: o.mode, receivers: [] };
|
|
4251
|
+
cfg.name = o.name || cfg.name;
|
|
4252
|
+
cfg.mode = o.mode;
|
|
4253
|
+
cfg.folders = [.../* @__PURE__ */ new Set([...cfg.folders, ...o.folders.map((d) => path15.resolve(d))])];
|
|
4254
|
+
const existing = cfg.receivers.find((r) => r.control === control);
|
|
4255
|
+
const auth = { authorization: `Bearer ${o.token}`, "content-type": "application/json" };
|
|
4256
|
+
const started = await f(`${control}/pair`, {
|
|
4257
|
+
method: "POST",
|
|
4258
|
+
headers: auth,
|
|
4259
|
+
body: JSON.stringify({
|
|
4260
|
+
...existing ? { machine: existing.machine } : {},
|
|
4261
|
+
name: cfg.name,
|
|
4262
|
+
harnesses: ["claude-code"],
|
|
4263
|
+
modes: ["waiter", "turn", "resume", "fork"],
|
|
4264
|
+
version: VERSION2,
|
|
4265
|
+
folders: cfg.folders,
|
|
4266
|
+
mode: cfg.mode
|
|
4267
|
+
})
|
|
4268
|
+
});
|
|
4269
|
+
const s = await json(started);
|
|
4270
|
+
if (started.status !== 201 && started.status !== 200)
|
|
4271
|
+
throw new Error(
|
|
4272
|
+
`the receiver refused to start pairing (HTTP ${started.status}${s.reason ? `: ${String(s.reason)}` : ""})`
|
|
4273
|
+
);
|
|
4274
|
+
const machine = String(s.machine);
|
|
4275
|
+
const code = String(s.code);
|
|
4276
|
+
o.out("");
|
|
4277
|
+
o.out(` Open this link and confirm with your passkey (Touch ID, Face ID, a security key):`);
|
|
4278
|
+
o.out(` ${String(s.url)}`);
|
|
4279
|
+
o.out(` The page shows these digits too: ${String(s.check)}`);
|
|
4280
|
+
o.out("");
|
|
4281
|
+
const until = Date.parse(String(s.expires_at)) || Date.now() + 10 * 6e4;
|
|
4282
|
+
for (; ; ) {
|
|
4283
|
+
if (Date.now() > until) throw new Error("the pairing link expired; run `sessionpipe control pair` again");
|
|
4284
|
+
await sleep2(2e3);
|
|
4285
|
+
const r = await f(`${control}/pair?code=${encodeURIComponent(code)}`, { headers: auth }).catch(() => null);
|
|
4286
|
+
if (!r) continue;
|
|
4287
|
+
if (r.status === 404) throw new Error("the receiver lost the pairing; run `sessionpipe control pair` again");
|
|
4288
|
+
const p = await json(r);
|
|
4289
|
+
if (p.status === "expired") throw new Error("the pairing link expired; run `sessionpipe control pair` again");
|
|
4290
|
+
if (p.status !== "paired") continue;
|
|
4291
|
+
const key = p.key;
|
|
4292
|
+
const proof = p.proof;
|
|
4293
|
+
const v = await verifyEnrollment({ machine, code, key, proof, rpId });
|
|
4294
|
+
if (!v.ok) throw new Error(`the receiver's key didn't prove itself (${v.why}); nothing was trusted`);
|
|
4295
|
+
const token = existing?.token ?? (typeof p.token === "string" ? p.token : "");
|
|
4296
|
+
if (!token) throw new Error("the receiver didn't hand this machine its token");
|
|
4297
|
+
const rec = existing ?? {
|
|
4298
|
+
url: o.url.replace(/\/$/, ""),
|
|
4299
|
+
control,
|
|
4300
|
+
rpId,
|
|
4301
|
+
machine,
|
|
4302
|
+
token,
|
|
4303
|
+
keys: [],
|
|
4304
|
+
paired_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
4305
|
+
};
|
|
4306
|
+
if (!rec.keys.some((k) => k.id === key.id)) rec.keys.push({ ...key, added_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4307
|
+
if (!existing) cfg.receivers.push(rec);
|
|
4308
|
+
writeControl(cfg, o.env);
|
|
4309
|
+
o.out(` \u2713 Paired with ${rec.url} as ${machine} \xB7 ${rec.keys.length} key(s) trusted on this machine`);
|
|
4310
|
+
return cfg;
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
async function off(o) {
|
|
4314
|
+
const f = o.fetch ?? fetch;
|
|
4315
|
+
const cfg = readControl(o.env);
|
|
4316
|
+
if (!cfg) return o.out(" control isn't set up on this machine");
|
|
4317
|
+
const gone = cfg.receivers.filter((r) => !o.url || r.url === o.url.replace(/\/$/, ""));
|
|
4318
|
+
for (const r of gone) {
|
|
4319
|
+
await f(`${r.control}/off`, { method: "POST", headers: { authorization: `Bearer ${r.token}` } }).catch(() => null);
|
|
4320
|
+
o.out(` removed ${r.url} (${r.machine}, ${r.keys.length} key(s))`);
|
|
4321
|
+
}
|
|
4322
|
+
cfg.receivers = cfg.receivers.filter((r) => !gone.includes(r));
|
|
4323
|
+
writeControl(cfg, o.env);
|
|
4324
|
+
return cfg;
|
|
4325
|
+
}
|
|
4326
|
+
function removeKey(id, env) {
|
|
4327
|
+
const cfg = readControl(env);
|
|
4328
|
+
if (!cfg) return 0;
|
|
4329
|
+
let n = 0;
|
|
4330
|
+
for (const r of cfg.receivers) {
|
|
4331
|
+
const before = r.keys.length;
|
|
4332
|
+
r.keys = r.keys.filter((k) => k.id !== id && !k.id.startsWith(id));
|
|
4333
|
+
n += before - r.keys.length;
|
|
4334
|
+
}
|
|
4335
|
+
writeControl(cfg, env);
|
|
4336
|
+
return n;
|
|
4337
|
+
}
|
|
4338
|
+
var LABEL = "org.sessionpipe.control";
|
|
4339
|
+
var UNIT = "sessionpipe-control.service";
|
|
4340
|
+
function serviceFiles(o) {
|
|
4341
|
+
const home = o.home ?? os4.homedir();
|
|
4342
|
+
const xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
4343
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4344
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4345
|
+
<plist version="1.0">
|
|
4346
|
+
<dict>
|
|
4347
|
+
<key>Label</key><string>${LABEL}</string>
|
|
4348
|
+
<key>ProgramArguments</key>
|
|
4349
|
+
<array><string>${xml(o.node)}</string><string>${xml(o.cli)}</string><string>control</string><string>run</string></array>
|
|
4350
|
+
<key>RunAtLoad</key><true/>
|
|
4351
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
4352
|
+
<key>ProcessType</key><string>Background</string>
|
|
4353
|
+
<key>StandardOutPath</key><string>${xml(path15.join(o.logDir, "daemon.log"))}</string>
|
|
4354
|
+
<key>StandardErrorPath</key><string>${xml(path15.join(o.logDir, "daemon.log"))}</string>
|
|
4355
|
+
</dict>
|
|
4356
|
+
</plist>
|
|
4357
|
+
`;
|
|
4358
|
+
const q = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
4359
|
+
const unit = `[Unit]
|
|
4360
|
+
Description=sessionpipe control daemon (signed messages to your coding sessions)
|
|
4361
|
+
After=network-online.target
|
|
4362
|
+
|
|
4363
|
+
[Service]
|
|
4364
|
+
ExecStart=${q(o.node)} ${q(o.cli)} control run
|
|
4365
|
+
Restart=on-failure
|
|
4366
|
+
RestartSec=5
|
|
4367
|
+
|
|
4368
|
+
[Install]
|
|
4369
|
+
WantedBy=default.target
|
|
4370
|
+
`;
|
|
4371
|
+
return {
|
|
4372
|
+
plist: { file: path15.join(home, "Library", "LaunchAgents", `${LABEL}.plist`), body: plist },
|
|
4373
|
+
unit: { file: path15.join(home, ".config", "systemd", "user", UNIT), body: unit }
|
|
4374
|
+
};
|
|
4375
|
+
}
|
|
4376
|
+
function installService(o) {
|
|
4377
|
+
const logDir = controlState(o.env);
|
|
4378
|
+
mkdirSync9(logDir, { recursive: true, mode: 448 });
|
|
4379
|
+
const f = serviceFiles({ node: o.node, cli: o.cli, logDir });
|
|
4380
|
+
const run = (cmd2, args2) => execFileSync5(cmd2, args2, { stdio: "ignore", timeout: 15e3 });
|
|
4381
|
+
if (process.platform === "darwin") {
|
|
4382
|
+
mkdirSync9(path15.dirname(f.plist.file), { recursive: true });
|
|
4383
|
+
writeFileSync7(f.plist.file, f.plist.body);
|
|
4384
|
+
const uid = String(process.getuid?.() ?? "");
|
|
4385
|
+
try {
|
|
4386
|
+
run("launchctl", ["bootout", `gui/${uid}/${LABEL}`]);
|
|
4387
|
+
} catch {
|
|
4388
|
+
}
|
|
4389
|
+
run("launchctl", ["bootstrap", `gui/${uid}`, f.plist.file]);
|
|
4390
|
+
return `launchd agent ${LABEL} (log: ${path15.join(logDir, "daemon.log")})`;
|
|
4391
|
+
}
|
|
4392
|
+
if (process.platform === "linux") {
|
|
4393
|
+
mkdirSync9(path15.dirname(f.unit.file), { recursive: true });
|
|
4394
|
+
writeFileSync7(f.unit.file, f.unit.body);
|
|
4395
|
+
try {
|
|
4396
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
4397
|
+
run("systemctl", ["--user", "enable", "--now", UNIT]);
|
|
4398
|
+
return `systemd user unit ${UNIT} (journalctl --user -u ${UNIT})`;
|
|
4399
|
+
} catch {
|
|
4400
|
+
return `wrote ${f.unit.file}, but systemd --user isn't reachable here; run \`sessionpipe control run\` yourself (tmux, screen)`;
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
return "no service manager for this platform yet; run `sessionpipe control run` yourself";
|
|
4404
|
+
}
|
|
4405
|
+
function uninstallService(env) {
|
|
4406
|
+
const f = serviceFiles({ node: "", cli: "", logDir: controlState(env) });
|
|
4407
|
+
const run = (cmd2, args2) => {
|
|
4408
|
+
try {
|
|
4409
|
+
execFileSync5(cmd2, args2, { stdio: "ignore", timeout: 15e3 });
|
|
4410
|
+
} catch {
|
|
4411
|
+
}
|
|
4412
|
+
};
|
|
4413
|
+
if (process.platform === "darwin" && existsSync10(f.plist.file)) {
|
|
4414
|
+
run("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${LABEL}`]);
|
|
4415
|
+
unlinkSync5(f.plist.file);
|
|
4416
|
+
}
|
|
4417
|
+
if (process.platform === "linux" && existsSync10(f.unit.file)) {
|
|
4418
|
+
run("systemctl", ["--user", "disable", "--now", UNIT]);
|
|
4419
|
+
unlinkSync5(f.unit.file);
|
|
4420
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
// src/control/sdk.ts
|
|
4425
|
+
async function loadSdk(o) {
|
|
4426
|
+
const spec = o.env?.SESSIONPIPE_AGENT_SDK || "@anthropic-ai/claude-agent-sdk";
|
|
4427
|
+
let query;
|
|
4428
|
+
try {
|
|
4429
|
+
const mod = await import(
|
|
4430
|
+
/* @vite-ignore */
|
|
4431
|
+
spec
|
|
4432
|
+
);
|
|
4433
|
+
if (typeof mod.query !== "function") return null;
|
|
4434
|
+
query = mod.query;
|
|
4435
|
+
} catch {
|
|
4436
|
+
return null;
|
|
4437
|
+
}
|
|
4438
|
+
o.log(`Agent SDK found (${spec}): new sessions start in place`);
|
|
4439
|
+
return {
|
|
4440
|
+
start({
|
|
4441
|
+
session,
|
|
4442
|
+
cwd,
|
|
4443
|
+
mode
|
|
4444
|
+
}) {
|
|
4445
|
+
const queue = [];
|
|
4446
|
+
let wake = null;
|
|
4447
|
+
let closed = false;
|
|
4448
|
+
async function* input() {
|
|
4449
|
+
while (!closed) {
|
|
4450
|
+
while (queue.length) yield queue.shift();
|
|
4451
|
+
await new Promise((r) => {
|
|
4452
|
+
wake = r;
|
|
4453
|
+
});
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
const q = query({
|
|
4457
|
+
prompt: input(),
|
|
4458
|
+
options: {
|
|
4459
|
+
sessionId: session,
|
|
4460
|
+
cwd,
|
|
4461
|
+
permissionMode: mode === "auto" ? "auto" : "dontAsk",
|
|
4462
|
+
...o.claudeBin ? { pathToClaudeCodeExecutable: o.claudeBin } : {},
|
|
4463
|
+
env: { ...o.env ?? process.env, SESSIONPIPE_CONTROL_JOB: "1" }
|
|
4464
|
+
}
|
|
4465
|
+
});
|
|
4466
|
+
const waiting = [];
|
|
4467
|
+
let busy = false;
|
|
4468
|
+
let lastUsed = Date.now();
|
|
4469
|
+
void (async () => {
|
|
4470
|
+
try {
|
|
4471
|
+
for await (const m of q) {
|
|
4472
|
+
if (m.type !== "result") continue;
|
|
4473
|
+
busy = waiting.length > 1;
|
|
4474
|
+
lastUsed = Date.now();
|
|
4475
|
+
const w = waiting.shift();
|
|
4476
|
+
const text = typeof m.result === "string" && m.result.trim() ? m.result : "(finished without a written reply)";
|
|
4477
|
+
w?.resolve(m.is_error ? { reply: "", error: text } : { reply: text });
|
|
4478
|
+
}
|
|
4479
|
+
} catch (e) {
|
|
4480
|
+
for (const w of waiting.splice(0)) w.resolve({ reply: "", error: String(e?.message || e) });
|
|
4481
|
+
}
|
|
4482
|
+
})();
|
|
4483
|
+
return {
|
|
4484
|
+
send(text) {
|
|
4485
|
+
busy = true;
|
|
4486
|
+
lastUsed = Date.now();
|
|
4487
|
+
return new Promise((resolve) => {
|
|
4488
|
+
waiting.push({ resolve });
|
|
4489
|
+
queue.push({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null });
|
|
4490
|
+
wake?.();
|
|
4491
|
+
});
|
|
4492
|
+
},
|
|
4493
|
+
interrupt: () => q.interrupt(),
|
|
4494
|
+
close() {
|
|
4495
|
+
closed = true;
|
|
4496
|
+
wake?.();
|
|
4497
|
+
try {
|
|
4498
|
+
q.close();
|
|
4499
|
+
} catch {
|
|
4500
|
+
}
|
|
4501
|
+
},
|
|
4502
|
+
get busy() {
|
|
4503
|
+
return busy;
|
|
4504
|
+
},
|
|
4505
|
+
get lastUsed() {
|
|
4506
|
+
return lastUsed;
|
|
4507
|
+
}
|
|
4508
|
+
};
|
|
4509
|
+
}
|
|
4510
|
+
};
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
// src/control/wait.ts
|
|
4514
|
+
var RE_ARM = "(Answer it right here, as you would answer the person at the keyboard: your reply is sent back to them automatically, so don't run a command or call a tool just to send it. Then, without mentioning it, start `sessionpipe wait` again as a background command, with the longest timeout your harness allows, to keep receiving messages.)";
|
|
4515
|
+
function waitSession(flag2, env = process.env) {
|
|
4516
|
+
if (flag2) return parseSessionRef(flag2) ? flag2 : null;
|
|
4517
|
+
if (env.CLAUDE_CODE_SESSION_ID) return `claude-code:${env.CLAUDE_CODE_SESSION_ID}`;
|
|
4518
|
+
if (env.CODEX_SESSION_ID) return `codex:${env.CODEX_SESSION_ID}`;
|
|
4519
|
+
return null;
|
|
4520
|
+
}
|
|
4521
|
+
async function waitForMessage(session, o = {}) {
|
|
4522
|
+
const sock = socketPath(stateDir(o.env), o.env);
|
|
4523
|
+
for (; ; ) {
|
|
4524
|
+
const r = await ask(sock, { op: "wait", session, pid: process.pid }, 2 ** 31 - 1);
|
|
4525
|
+
if (r?.op === "message") return `${r.text}
|
|
4526
|
+
|
|
4527
|
+
${RE_ARM}`;
|
|
4528
|
+
if (r?.op === "replaced")
|
|
4529
|
+
return "(Another `sessionpipe wait` for this session took over; this one stopped. Nothing to do.)";
|
|
4530
|
+
await new Promise((res) => setTimeout(res, o.retryMs ?? 1e3));
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4534
|
+
// src/control/cli.ts
|
|
4535
|
+
var { detectCaps: detectCaps2, findClaude: findClaude2, runClaude: runClaude2 } = claude_code_control_exports;
|
|
4536
|
+
var CONTROL_HELP = ` sessionpipe control pair <receiver> [--folder DIR]\u2026 [--mode safe|auto] [--token T] [--name N] [--no-service]
|
|
4537
|
+
sessionpipe control status | keys [remove <id>] | off [<receiver>] | run
|
|
4538
|
+
sessionpipe wait [--session <harness>:<id>] (a session runs this in the background)`;
|
|
4539
|
+
async function controlMain(argv, out2, distDir2) {
|
|
4540
|
+
const flag2 = (n) => {
|
|
4541
|
+
const i = argv.indexOf(n);
|
|
4542
|
+
return i >= 0 ? argv[i + 1] : void 0;
|
|
4543
|
+
};
|
|
4544
|
+
const flags = (n) => argv.flatMap((a, i) => a === n && argv[i + 1] ? [argv[i + 1]] : []);
|
|
4545
|
+
const sub = argv[1];
|
|
4546
|
+
switch (sub) {
|
|
4547
|
+
case "pair": {
|
|
4548
|
+
const url = argv[2];
|
|
4549
|
+
if (!url || !/^https?:\/\//.test(url)) return out2(`usage:
|
|
4550
|
+
${CONTROL_HELP}`);
|
|
4551
|
+
const cfg = readConfig();
|
|
4552
|
+
const sink2 = cfg.sinks.find((s) => s.url.replace(/\/$/, "") === url.replace(/\/$/, ""));
|
|
4553
|
+
const token = flag2("--token") ?? sink2?.token;
|
|
4554
|
+
if (!token)
|
|
4555
|
+
return out2(
|
|
4556
|
+
` No token for ${url}: add it as a sink first (\`sessionpipe sink add ${url} --token \u2026\`) or pass --token.`
|
|
4557
|
+
);
|
|
4558
|
+
const folders = flags("--folder");
|
|
4559
|
+
const existing = readControl();
|
|
4560
|
+
if (!folders.length && !existing?.folders.length) folders.push(process.cwd());
|
|
4561
|
+
await pair({
|
|
4562
|
+
url,
|
|
4563
|
+
token,
|
|
4564
|
+
folders,
|
|
4565
|
+
mode: flag2("--mode") === "auto" ? "auto" : existing?.mode ?? "safe",
|
|
4566
|
+
name: flag2("--name") ?? existing?.name ?? cfg.machine ?? os5.hostname(),
|
|
4567
|
+
out: out2
|
|
4568
|
+
});
|
|
4569
|
+
const c = readControl();
|
|
4570
|
+
out2(` Folders sessions may run in: ${c.folders.join(", ")}`);
|
|
4571
|
+
out2(
|
|
4572
|
+
` Mode: ${c.mode} (${c.mode === "safe" ? "nothing that needs approval runs unattended" : "Claude Code's auto mode"})`
|
|
4573
|
+
);
|
|
4574
|
+
let cli = path16.join(distDir2, "cli.js");
|
|
4575
|
+
try {
|
|
4576
|
+
cli = realpathSync3(cli);
|
|
4577
|
+
} catch {
|
|
4578
|
+
}
|
|
4579
|
+
if (argv.includes("--no-service"))
|
|
4580
|
+
out2(" Daemon: not installed as a service (--no-service); run `sessionpipe control run` yourself");
|
|
4581
|
+
else out2(` Daemon: ${installService({ node: process.execPath, cli })}`);
|
|
4582
|
+
return;
|
|
4583
|
+
}
|
|
4584
|
+
case "keys": {
|
|
4585
|
+
const c = readControl();
|
|
4586
|
+
if (argv[2] === "remove" && argv[3]) {
|
|
4587
|
+
const n = removeKey(argv[3]);
|
|
4588
|
+
return out2(n ? ` removed ${n} key(s)` : " no such key");
|
|
4589
|
+
}
|
|
4590
|
+
if (!c?.receivers.length)
|
|
4591
|
+
return out2(" control isn't paired on this machine (`sessionpipe control pair <receiver>`)");
|
|
4592
|
+
for (const r of c.receivers) {
|
|
4593
|
+
out2(` ${r.url} \xB7 machine ${r.machine}`);
|
|
4594
|
+
for (const k of r.keys)
|
|
4595
|
+
out2(` ${k.id.slice(0, 16)}\u2026 ${k.alg === -7 ? "ES256" : "RS256"} added ${k.added_at.slice(0, 10)}`);
|
|
4596
|
+
}
|
|
4597
|
+
return;
|
|
4598
|
+
}
|
|
4599
|
+
case "off": {
|
|
4600
|
+
const c = await off({ out: out2, ...argv[2] ? { url: argv[2] } : {} });
|
|
4601
|
+
if (c && !c.receivers.length) uninstallService();
|
|
4602
|
+
return;
|
|
4603
|
+
}
|
|
4604
|
+
case "status": {
|
|
4605
|
+
const r = await ask(socketPath(stateDir()), { op: "status" }, 2e3);
|
|
4606
|
+
if (r?.op !== "status") return out2(" the control daemon isn't running");
|
|
4607
|
+
return out2(JSON.stringify(r.status, null, 2));
|
|
4608
|
+
}
|
|
4609
|
+
case "run":
|
|
4610
|
+
return runDaemon(out2);
|
|
4611
|
+
default:
|
|
4612
|
+
return out2(CONTROL_HELP);
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
async function runDaemon(out2) {
|
|
4616
|
+
const cfg = readControl();
|
|
4617
|
+
if (!cfg?.receivers.length) {
|
|
4618
|
+
out2("control isn't paired on this machine; nothing to do (`sessionpipe control pair <receiver>`)");
|
|
4619
|
+
return;
|
|
4620
|
+
}
|
|
4621
|
+
const log2 = (s) => out2(`${(/* @__PURE__ */ new Date()).toISOString()} ${s}`);
|
|
4622
|
+
let claude = null;
|
|
4623
|
+
const claudeNow = () => {
|
|
4624
|
+
const bin = findClaude2();
|
|
4625
|
+
if (!bin) return null;
|
|
4626
|
+
const sig = `${bin}:${existsSync11(bin) ? realpathSync3(bin) : ""}`;
|
|
4627
|
+
if (claude?.sig !== sig) claude = { bin, caps: detectCaps2(bin), sig };
|
|
4628
|
+
return claude;
|
|
4629
|
+
};
|
|
4630
|
+
const sdk = await loadSdk({ claudeBin: findClaude2(), env: process.env, log: log2 });
|
|
4631
|
+
const d = new ControlDaemon(cfg, controlState(), socketPath(stateDir()), {
|
|
4632
|
+
now: Date.now,
|
|
4633
|
+
fetch,
|
|
4634
|
+
log: log2,
|
|
4635
|
+
claudeDirs: () => claudeDirs(),
|
|
4636
|
+
claude: claudeNow,
|
|
4637
|
+
run: runClaude2,
|
|
4638
|
+
sdk
|
|
4639
|
+
});
|
|
4640
|
+
await d.start();
|
|
4641
|
+
watchFile(controlFile(), { interval: 5e3 }, () => {
|
|
4642
|
+
const next = readControl();
|
|
4643
|
+
if (next) d.reload(next);
|
|
4644
|
+
});
|
|
4645
|
+
const bye = async () => {
|
|
4646
|
+
await d.stop();
|
|
4647
|
+
process.exit(0);
|
|
4648
|
+
};
|
|
4649
|
+
process.on("SIGTERM", bye);
|
|
4650
|
+
process.on("SIGINT", bye);
|
|
4651
|
+
}
|
|
4652
|
+
async function waitMain(argv, out2) {
|
|
4653
|
+
const i = argv.indexOf("--session");
|
|
4654
|
+
const session = waitSession(i >= 0 ? argv[i + 1] : void 0);
|
|
4655
|
+
if (!session) {
|
|
4656
|
+
out2("sessionpipe wait: which session? Pass --session <harness>:<id> (Claude Code sets CLAUDE_CODE_SESSION_ID).");
|
|
4657
|
+
process.exitCode = 2;
|
|
4658
|
+
return;
|
|
4659
|
+
}
|
|
4660
|
+
out2(await waitForMessage(session));
|
|
4661
|
+
}
|
|
4662
|
+
|
|
4663
|
+
// src/run.ts
|
|
4664
|
+
import {
|
|
4665
|
+
appendFileSync as appendFileSync4,
|
|
4666
|
+
existsSync as existsSync12,
|
|
4667
|
+
mkdirSync as mkdirSync10,
|
|
4668
|
+
readdirSync as readdirSync7,
|
|
4669
|
+
readFileSync as readFileSync10,
|
|
4670
|
+
renameSync as renameSync3,
|
|
4671
|
+
statSync as statSync8,
|
|
4672
|
+
unlinkSync as unlinkSync6,
|
|
4673
|
+
writeFileSync as writeFileSync8
|
|
4674
|
+
} from "node:fs";
|
|
4675
|
+
import os6 from "node:os";
|
|
4676
|
+
import path17 from "node:path";
|
|
4677
|
+
|
|
4678
|
+
// src/http-sink.ts
|
|
4679
|
+
import { createHash as createHash3, createHmac } from "node:crypto";
|
|
4680
|
+
var TIMEOUT_MS = 15e3;
|
|
4681
|
+
function webhookId(events) {
|
|
4682
|
+
return `msg_${createHash3("sha256").update(events.map((e) => e.id).join(",")).digest("base64url").slice(0, 32)}`;
|
|
4683
|
+
}
|
|
4684
|
+
var HttpSink = class {
|
|
4685
|
+
constructor(cfg, tier) {
|
|
4686
|
+
this.cfg = cfg;
|
|
4687
|
+
this.tier = tier;
|
|
4688
|
+
this.name = cfg.name;
|
|
4689
|
+
this.pii = !!cfg.pii;
|
|
4690
|
+
this.url = cfg.url.replace(/\/$/, "");
|
|
4691
|
+
}
|
|
4692
|
+
cfg;
|
|
4693
|
+
tier;
|
|
4694
|
+
name;
|
|
4695
|
+
pii;
|
|
4696
|
+
url;
|
|
4697
|
+
eventsUrl() {
|
|
4698
|
+
const ep = this.cfg.endpoints?.events ?? "/api/sessionpipe/v1/events";
|
|
4699
|
+
return /^https?:\/\//.test(ep) ? ep : this.url + ep;
|
|
4700
|
+
}
|
|
4701
|
+
async send(events) {
|
|
4702
|
+
const body = JSON.stringify({ events });
|
|
4703
|
+
const headers = { "content-type": "application/json", "sessionpipe-protocol": "1" };
|
|
4704
|
+
if (this.cfg.token) headers.authorization = `Bearer ${this.cfg.token}`;
|
|
4705
|
+
if (this.cfg.secret?.startsWith("whsec_")) {
|
|
4706
|
+
const id = webhookId(events);
|
|
4707
|
+
const ts = Math.floor(Date.now() / 1e3);
|
|
4708
|
+
const key = Buffer.from(this.cfg.secret.slice(6), "base64");
|
|
4709
|
+
const sig = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
|
|
4710
|
+
headers["webhook-id"] = id;
|
|
4711
|
+
headers["webhook-timestamp"] = String(ts);
|
|
4712
|
+
headers["webhook-signature"] = `v1,${sig}`;
|
|
4713
|
+
}
|
|
4714
|
+
const ctl = new AbortController();
|
|
4715
|
+
const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
|
4716
|
+
try {
|
|
4717
|
+
const r = await fetch(this.eventsUrl(), { method: "POST", headers, body, signal: ctl.signal });
|
|
4718
|
+
const text = await r.text();
|
|
4719
|
+
let j = {};
|
|
4720
|
+
try {
|
|
4721
|
+
j = text ? JSON.parse(text) : {};
|
|
4722
|
+
} catch {
|
|
4723
|
+
}
|
|
4724
|
+
if (r.status === 202 || r.status === 200) {
|
|
4725
|
+
return {
|
|
4726
|
+
ok: true,
|
|
4727
|
+
acknowledged: events.length,
|
|
4728
|
+
accepted: Number(j.accepted ?? events.length),
|
|
4729
|
+
duplicates: Number(j.duplicates ?? 0),
|
|
4730
|
+
rejected: Array.isArray(j.rejected) ? j.rejected : [],
|
|
4731
|
+
...typeof j.max_tier === "number" ? { max_tier: j.max_tier } : {},
|
|
4732
|
+
status: r.status
|
|
4733
|
+
};
|
|
4734
|
+
}
|
|
4735
|
+
const err = typeof j.reason === "string" ? j.reason : text.slice(0, 200);
|
|
4736
|
+
if (r.status === 400) return { ok: false, acknowledged: 0, status: 400, error: err, action: "drop" };
|
|
4737
|
+
if (r.status === 401) return { ok: false, acknowledged: 0, status: 401, error: err, action: "pause" };
|
|
4738
|
+
if (r.status === 403)
|
|
4739
|
+
return {
|
|
4740
|
+
ok: false,
|
|
4741
|
+
acknowledged: 0,
|
|
4742
|
+
status: 403,
|
|
4743
|
+
error: err,
|
|
4744
|
+
action: "lower-tier",
|
|
4745
|
+
...typeof j.max_tier === "number" ? { max_tier: j.max_tier } : {}
|
|
4746
|
+
};
|
|
4747
|
+
if (r.status === 413) return { ok: false, acknowledged: 0, status: 413, error: err, action: "split" };
|
|
4748
|
+
if (r.status === 429)
|
|
4749
|
+
return {
|
|
4750
|
+
ok: false,
|
|
4751
|
+
acknowledged: 0,
|
|
4752
|
+
status: 429,
|
|
4753
|
+
error: err,
|
|
4754
|
+
action: "retry",
|
|
4755
|
+
retryAfterMs: Number(r.headers.get("retry-after") ?? 60) * 1e3
|
|
4756
|
+
};
|
|
4757
|
+
return { ok: false, acknowledged: 0, status: r.status, error: err, action: "retry" };
|
|
4758
|
+
} catch (e) {
|
|
4759
|
+
return { ok: false, acknowledged: 0, error: String(e.message ?? e), action: "retry" };
|
|
4760
|
+
} finally {
|
|
4761
|
+
clearTimeout(t);
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4764
|
+
};
|
|
4765
|
+
|
|
4766
|
+
// src/run.ts
|
|
4767
|
+
async function tellDaemon(state2, job, session, transcript) {
|
|
4768
|
+
const sock = socketPath(state2);
|
|
4769
|
+
if (process.platform !== "win32" && !existsSync12(sock)) return;
|
|
4770
|
+
await ask(
|
|
4771
|
+
sock,
|
|
4772
|
+
{
|
|
4773
|
+
op: "event",
|
|
4774
|
+
session: `claude-code:${session}`,
|
|
4775
|
+
event: job.event,
|
|
4776
|
+
cwd: job.cwd,
|
|
4777
|
+
...transcript ? { transcript } : {}
|
|
4778
|
+
},
|
|
4779
|
+
300
|
|
4780
|
+
);
|
|
4781
|
+
}
|
|
4782
|
+
var VERSION3 = true ? "0.2.0" : "0.0.0";
|
|
4783
|
+
function enqueueJob(state2, job) {
|
|
4784
|
+
const jobs = jobsDir(state2);
|
|
4785
|
+
mkdirSync10(jobs, { recursive: true, mode: 448 });
|
|
4786
|
+
const f = path17.join(jobs, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.json`);
|
|
4787
|
+
writeFileSync8(f, JSON.stringify(job), { mode: 384 });
|
|
4788
|
+
return f;
|
|
4789
|
+
}
|
|
4790
|
+
function log(state2, line) {
|
|
4791
|
+
try {
|
|
4792
|
+
mkdirSync10(state2, { recursive: true, mode: 448 });
|
|
4793
|
+
const f = path17.join(state2, "log");
|
|
4794
|
+
try {
|
|
4795
|
+
if (statSync8(f).size > 1024 * 1024) renameSync3(f, `${f}.1`);
|
|
4796
|
+
} catch {
|
|
4797
|
+
}
|
|
4798
|
+
appendFileSync4(f, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
4799
|
+
`);
|
|
4800
|
+
} catch {
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
function factsState(state2) {
|
|
4804
|
+
const file = (h, s) => path17.join(state2, "facts", safeId(h), `${safeId(s)}.json`);
|
|
2601
4805
|
return {
|
|
2602
4806
|
get(h, s) {
|
|
2603
4807
|
try {
|
|
2604
|
-
return JSON.parse(
|
|
4808
|
+
return JSON.parse(readFileSync10(file(h, s), "utf8"));
|
|
2605
4809
|
} catch {
|
|
2606
4810
|
return {};
|
|
2607
4811
|
}
|
|
@@ -2610,8 +4814,8 @@ function factsState(state2) {
|
|
|
2610
4814
|
const cur = this.get(h, s);
|
|
2611
4815
|
const next = { ...cur, ...patch };
|
|
2612
4816
|
if (JSON.stringify(next) === JSON.stringify(cur)) return;
|
|
2613
|
-
|
|
2614
|
-
|
|
4817
|
+
mkdirSync10(path17.dirname(file(h, s)), { recursive: true, mode: 448 });
|
|
4818
|
+
writeFileSync8(file(h, s), JSON.stringify(next), { mode: 384 });
|
|
2615
4819
|
}
|
|
2616
4820
|
};
|
|
2617
4821
|
}
|
|
@@ -2622,7 +4826,7 @@ function buildSinks(sinks) {
|
|
|
2622
4826
|
const tier = Math.min(s.tier, s.max_tier ?? 3);
|
|
2623
4827
|
if (s.url === "stdout") out2.push(new StdoutSink(s.name, tier, !!s.pii));
|
|
2624
4828
|
else if (s.url.startsWith("file:"))
|
|
2625
|
-
out2.push(new FileSink(s.name, s.url.slice(5).replace(/^~(?=\/|$)/,
|
|
4829
|
+
out2.push(new FileSink(s.name, s.url.slice(5).replace(/^~(?=\/|$)/, os6.homedir()), tier, !!s.pii));
|
|
2626
4830
|
else if (/^https?:\/\//.test(s.url)) out2.push(new HttpSink(s, tier));
|
|
2627
4831
|
}
|
|
2628
4832
|
return out2;
|
|
@@ -2634,6 +4838,7 @@ async function runJob(job, opts = {}) {
|
|
|
2634
4838
|
if (!adapter) return { events: [], delivered: {} };
|
|
2635
4839
|
const r = adapter.fromHook({ argv: job.argv, stdin: job.stdin, env: { ...process.env, ...job.env }, cwd: job.cwd });
|
|
2636
4840
|
if (!r || !r.session.id) return { events: [], delivered: {} };
|
|
4841
|
+
if (job.harness === "claude-code") await tellDaemon(state2, job, r.session.id, r.transcript);
|
|
2637
4842
|
const outbox = new Outbox(state2);
|
|
2638
4843
|
const ref = { harness: job.harness, session: r.session.id };
|
|
2639
4844
|
let release = takeLock(state2, ref);
|
|
@@ -2668,7 +4873,7 @@ async function runJob(job, opts = {}) {
|
|
|
2668
4873
|
...learned.branch ? { branch: learned.branch } : {},
|
|
2669
4874
|
id: r.session.id,
|
|
2670
4875
|
seq: 0,
|
|
2671
|
-
machine: machineName(cfg,
|
|
4876
|
+
machine: machineName(cfg, os6.hostname()),
|
|
2672
4877
|
...cwdRaw ? { cwd: tilde(cwdRaw) } : {},
|
|
2673
4878
|
...r.session.model ? { model: r.session.model } : {}
|
|
2674
4879
|
};
|
|
@@ -2771,7 +4976,7 @@ async function flush(state2, sinkConfigs, outbox, first) {
|
|
|
2771
4976
|
const { events, offset: next } = outbox.read(ref, offset, room);
|
|
2772
4977
|
if (!events.length) break;
|
|
2773
4978
|
for (const e of events) {
|
|
2774
|
-
const f = filterEvent(e, sink2.tier, sink2.pii, { home:
|
|
4979
|
+
const f = filterEvent(e, sink2.tier, sink2.pii, { home: os6.homedir() });
|
|
2775
4980
|
if (f) batch.push(f);
|
|
2776
4981
|
}
|
|
2777
4982
|
offset = next;
|
|
@@ -2803,7 +5008,7 @@ function applySinkVerdict(name, res) {
|
|
|
2803
5008
|
} catch {
|
|
2804
5009
|
}
|
|
2805
5010
|
}
|
|
2806
|
-
var jobsDir = (state2) =>
|
|
5011
|
+
var jobsDir = (state2) => path17.join(state2, "jobs");
|
|
2807
5012
|
|
|
2808
5013
|
// src/cli.ts
|
|
2809
5014
|
process.stdout.on("error", (e) => {
|
|
@@ -2821,12 +5026,12 @@ var out = (s = "") => {
|
|
|
2821
5026
|
`);
|
|
2822
5027
|
};
|
|
2823
5028
|
var state = stateDir();
|
|
2824
|
-
var distDir =
|
|
5029
|
+
var distDir = path18.dirname(fileURLToPath(import.meta.url));
|
|
2825
5030
|
function hookCommand() {
|
|
2826
5031
|
const node = stableNode(process.execPath);
|
|
2827
|
-
let script =
|
|
5032
|
+
let script = path18.join(distDir, "hook.js");
|
|
2828
5033
|
try {
|
|
2829
|
-
script =
|
|
5034
|
+
script = realpathSync4(script);
|
|
2830
5035
|
} catch {
|
|
2831
5036
|
}
|
|
2832
5037
|
return (a) => ({
|
|
@@ -2839,13 +5044,13 @@ function stableNode(exe) {
|
|
|
2839
5044
|
if (!m) return exe;
|
|
2840
5045
|
let real;
|
|
2841
5046
|
try {
|
|
2842
|
-
real =
|
|
5047
|
+
real = realpathSync4(exe);
|
|
2843
5048
|
} catch {
|
|
2844
5049
|
return exe;
|
|
2845
5050
|
}
|
|
2846
5051
|
for (const c of [`${m[1]}/opt/${m[2]}/bin/node`, `${m[1]}/bin/node`]) {
|
|
2847
5052
|
try {
|
|
2848
|
-
if (
|
|
5053
|
+
if (realpathSync4(c) === real) return c;
|
|
2849
5054
|
} catch {
|
|
2850
5055
|
}
|
|
2851
5056
|
}
|
|
@@ -2881,53 +5086,58 @@ async function main() {
|
|
|
2881
5086
|
return update();
|
|
2882
5087
|
case "worker":
|
|
2883
5088
|
return workerFromArgv();
|
|
5089
|
+
case "control":
|
|
5090
|
+
return controlMain(args, out, distDir);
|
|
5091
|
+
case "wait":
|
|
5092
|
+
return waitMain(args, out);
|
|
2884
5093
|
case "hook":
|
|
2885
5094
|
out("sessionpipe hook is dist/hook.js; harnesses run it directly");
|
|
2886
5095
|
return;
|
|
2887
5096
|
case "--version":
|
|
2888
5097
|
case "-v":
|
|
2889
5098
|
case "version":
|
|
2890
|
-
out(
|
|
5099
|
+
out(VERSION3);
|
|
2891
5100
|
return;
|
|
2892
5101
|
default:
|
|
2893
5102
|
return help();
|
|
2894
5103
|
}
|
|
2895
5104
|
}
|
|
2896
5105
|
function help() {
|
|
2897
|
-
out(`sessionpipe ${
|
|
5106
|
+
out(`sessionpipe ${VERSION3} \u2014 an open protocol for what your coding agents are doing
|
|
2898
5107
|
|
|
2899
5108
|
sessionpipe install [--claude-code --codex --gemini-cli --antigravity] [--machine NAME] [--backfill DAYS] [--sink URL --tier N]
|
|
2900
5109
|
sessionpipe uninstall [--keep-state]
|
|
2901
|
-
sessionpipe sink add <url|file:PATH|stdout> [--tier 0-3] [--token T] [--pii] [--
|
|
5110
|
+
sessionpipe sink add <url|file:PATH|stdout> [--tier 0-3] [--token T] [--pii] [--name N]
|
|
2902
5111
|
sessionpipe sink list | remove <name> | test <name>
|
|
2903
5112
|
sessionpipe status | doctor [--json]
|
|
2904
5113
|
sessionpipe tail [--session ID] [--tier N] [--harness NAME]
|
|
2905
5114
|
sessionpipe backfill [--days 30] | forget <harness> <session> | replay --sink NAME
|
|
2906
5115
|
sessionpipe update
|
|
5116
|
+
${CONTROL_HELP}
|
|
2907
5117
|
|
|
2908
5118
|
Docs: https://sessionpipe.org \xB7 nothing leaves this machine until you add a sink.`);
|
|
2909
5119
|
}
|
|
2910
5120
|
async function install() {
|
|
2911
5121
|
if (viaNpx) {
|
|
2912
|
-
out(` Installing sessionpipe@${
|
|
5122
|
+
out(` Installing sessionpipe@${VERSION3} globally, so the hooks start in milliseconds\u2026`);
|
|
2913
5123
|
try {
|
|
2914
5124
|
let prefix = "";
|
|
2915
5125
|
try {
|
|
2916
|
-
prefix =
|
|
5126
|
+
prefix = execFileSync6("npm", ["prefix", "-g"], {
|
|
2917
5127
|
encoding: "utf8",
|
|
2918
5128
|
timeout: 15e3,
|
|
2919
5129
|
stdio: ["ignore", "pipe", "ignore"]
|
|
2920
5130
|
}).trim();
|
|
2921
5131
|
} catch {
|
|
2922
5132
|
}
|
|
2923
|
-
const target = /\/Cellar\/node(?:@\d+)?\/[^/]+/.test(prefix) ?
|
|
2924
|
-
|
|
5133
|
+
const target = /\/Cellar\/node(?:@\d+)?\/[^/]+/.test(prefix) ? path18.join(os7.homedir(), ".local") : prefix;
|
|
5134
|
+
execFileSync6("npm", ["install", "-g", `sessionpipe@${VERSION3}`, ...target ? ["--prefix", target] : []], {
|
|
2925
5135
|
stdio: ["ignore", "ignore", "inherit"]
|
|
2926
5136
|
});
|
|
2927
|
-
const bin = process.platform === "win32" ?
|
|
5137
|
+
const bin = process.platform === "win32" ? path18.join(target, "sessionpipe.cmd") : target ? path18.join(target, "bin", "sessionpipe") : "sessionpipe";
|
|
2928
5138
|
if (target && target !== prefix)
|
|
2929
|
-
out(` Installed into ${target}; make sure ${
|
|
2930
|
-
|
|
5139
|
+
out(` Installed into ${target}; make sure ${path18.join(target, "bin")} is on your PATH.`);
|
|
5140
|
+
execFileSync6(bin, args, { stdio: "inherit", shell: process.platform === "win32" });
|
|
2931
5141
|
return;
|
|
2932
5142
|
} catch {
|
|
2933
5143
|
out(
|
|
@@ -2948,7 +5158,7 @@ async function install() {
|
|
|
2948
5158
|
);
|
|
2949
5159
|
return;
|
|
2950
5160
|
}
|
|
2951
|
-
out(` Machine: ${machineName(cfg,
|
|
5161
|
+
out(` Machine: ${machineName(cfg, os7.hostname())}`);
|
|
2952
5162
|
const lean = isLean(cfg);
|
|
2953
5163
|
if (lean)
|
|
2954
5164
|
out(
|
|
@@ -3032,7 +5242,7 @@ async function sink() {
|
|
|
3032
5242
|
if (!s) return out(` no sink named ${args[2]}`);
|
|
3033
5243
|
const [sk] = buildSinks([s]);
|
|
3034
5244
|
if (!sk) return out(" sink type not supported yet");
|
|
3035
|
-
const session = { id: `test-${Date.now()}`, machine: machineName(cfg,
|
|
5245
|
+
const session = { id: `test-${Date.now()}`, machine: machineName(cfg, os7.hostname()) };
|
|
3036
5246
|
const mk = (type, seq) => makeEvent(
|
|
3037
5247
|
{ type, harnessEvent: "test", data: {} },
|
|
3038
5248
|
{ harness: { name: "sessionpipe" }, session: { ...session, seq } }
|
|
@@ -3047,13 +5257,15 @@ async function sink() {
|
|
|
3047
5257
|
return;
|
|
3048
5258
|
}
|
|
3049
5259
|
out(
|
|
3050
|
-
"usage: sessionpipe sink add <url|file:PATH|stdout> [--tier N] [--token T] [--pii] [--
|
|
5260
|
+
"usage: sessionpipe sink add <url|file:PATH|stdout> [--tier N] [--token T] [--pii] [--name N] | list | remove <name> | test <name>"
|
|
3051
5261
|
);
|
|
3052
5262
|
}
|
|
3053
5263
|
async function sinkAdd(url, cfg) {
|
|
3054
5264
|
const tier = Math.max(0, Math.min(3, Number(flag("--tier") ?? 0)));
|
|
3055
5265
|
const name = flag("--name") ?? (url === "stdout" ? "stdout" : url.startsWith("file:") ? "file" : new URL(url).host);
|
|
3056
|
-
|
|
5266
|
+
if (has("--control"))
|
|
5267
|
+
out(" --control is ignored: control is set up per machine (`sessionpipe control pair <url>`, spec/CONTROL.md)");
|
|
5268
|
+
const s = { name, url, tier, pii: has("--pii") };
|
|
3057
5269
|
const token = flag("--token");
|
|
3058
5270
|
if (token) s.token = token;
|
|
3059
5271
|
const secret = flag("--secret");
|
|
@@ -3070,10 +5282,6 @@ async function sinkAdd(url, cfg) {
|
|
|
3070
5282
|
}
|
|
3071
5283
|
if (wk.endpoints) s.endpoints = wk.endpoints;
|
|
3072
5284
|
s.well_known_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
3073
|
-
if (s.control && !(wk.capabilities ?? []).includes("control")) {
|
|
3074
|
-
out(" receiver does not list `control`; --control ignored");
|
|
3075
|
-
s.control = false;
|
|
3076
|
-
}
|
|
3077
5285
|
} catch (e) {
|
|
3078
5286
|
out(
|
|
3079
5287
|
` ! ${url} has no readable /.well-known/sessionpipe (${e.message}); added anyway, at tier ${tier}`
|
|
@@ -3082,9 +5290,7 @@ async function sinkAdd(url, cfg) {
|
|
|
3082
5290
|
}
|
|
3083
5291
|
cfg.sinks = cfg.sinks.filter((x) => x.name !== name).concat([s]);
|
|
3084
5292
|
writeConfig(cfg);
|
|
3085
|
-
out(
|
|
3086
|
-
` \u2713 sink ${name}: ${url} at tier ${Math.min(tier, s.max_tier ?? 3)}${s.pii ? ", pii" : ""}${s.control ? ", control" : ""}`
|
|
3087
|
-
);
|
|
5293
|
+
out(` \u2713 sink ${name}: ${url} at tier ${Math.min(tier, s.max_tier ?? 3)}${s.pii ? ", pii" : ""}`);
|
|
3088
5294
|
out(` tier ${tier} sends: ${TIER_TEXT[Math.min(tier, s.max_tier ?? 3)]}`);
|
|
3089
5295
|
}
|
|
3090
5296
|
var TIER_TEXT = {
|
|
@@ -3097,7 +5303,7 @@ function backfillRows(adapters, days, cfg) {
|
|
|
3097
5303
|
const since = Date.now() - days * 864e5;
|
|
3098
5304
|
const outbox = new Outbox(state);
|
|
3099
5305
|
let n = 0;
|
|
3100
|
-
const machine = machineName(cfg,
|
|
5306
|
+
const machine = machineName(cfg, os7.hostname());
|
|
3101
5307
|
for (const a of adapters) {
|
|
3102
5308
|
for (const row of a.backfill(since)) {
|
|
3103
5309
|
const ref = { harness: a.name, session: row.session.id };
|
|
@@ -3141,7 +5347,7 @@ async function forget() {
|
|
|
3141
5347
|
{ type: "session.forgotten", harnessEvent: "forget", data: {} },
|
|
3142
5348
|
{
|
|
3143
5349
|
harness: { name: harness },
|
|
3144
|
-
session: { id: session, seq: outbox.nextSeq(ref), machine: machineName(cfg,
|
|
5350
|
+
session: { id: session, seq: outbox.nextSeq(ref), machine: machineName(cfg, os7.hostname()) }
|
|
3145
5351
|
}
|
|
3146
5352
|
);
|
|
3147
5353
|
const sinks = buildSinks(cfg.sinks);
|
|
@@ -3166,7 +5372,7 @@ async function replay() {
|
|
|
3166
5372
|
function status() {
|
|
3167
5373
|
const cfg = readConfig();
|
|
3168
5374
|
const hc = hookCommand();
|
|
3169
|
-
out(` machine: ${machineName(cfg,
|
|
5375
|
+
out(` machine: ${machineName(cfg, os7.hostname())} sessionpipe ${VERSION3} state ${tilde(state)}`);
|
|
3170
5376
|
for (const a of ADAPTERS) {
|
|
3171
5377
|
if (!a.detect() && !cfg.harnesses[a.name]) continue;
|
|
3172
5378
|
for (const r of a.installed(hc, process.env, { lean: isLean(cfg) }))
|
|
@@ -3182,7 +5388,7 @@ function status() {
|
|
|
3182
5388
|
for (const ref of outbox.sessions()) {
|
|
3183
5389
|
const f = outbox.file(ref);
|
|
3184
5390
|
try {
|
|
3185
|
-
pending += Math.max(0,
|
|
5391
|
+
pending += Math.max(0, statSync9(f).size - (c[`${ref.harness}/${ref.session}`] ?? 0)) > 0 ? 1 : 0;
|
|
3186
5392
|
} catch {
|
|
3187
5393
|
}
|
|
3188
5394
|
}
|
|
@@ -3197,18 +5403,18 @@ function status() {
|
|
|
3197
5403
|
);
|
|
3198
5404
|
const jobs = jobsDir(state);
|
|
3199
5405
|
try {
|
|
3200
|
-
const n =
|
|
5406
|
+
const n = readdirSync8(jobs).length;
|
|
3201
5407
|
if (n) out(` jobs: ${n} waiting`);
|
|
3202
5408
|
} catch {
|
|
3203
5409
|
}
|
|
3204
5410
|
}
|
|
3205
5411
|
function hookWall(runs = 8) {
|
|
3206
|
-
const hook =
|
|
3207
|
-
const tmp = mkdtempSync(
|
|
5412
|
+
const hook = path18.join(distDir, "hook.js");
|
|
5413
|
+
const tmp = mkdtempSync(path18.join(os7.tmpdir(), "sessionpipe-doctor-"));
|
|
3208
5414
|
const env = {
|
|
3209
5415
|
...process.env,
|
|
3210
5416
|
SESSIONPIPE_STATE: tmp,
|
|
3211
|
-
SESSIONPIPE_CONFIG:
|
|
5417
|
+
SESSIONPIPE_CONFIG: path18.join(tmp, "config.json"),
|
|
3212
5418
|
SESSIONPIPE_NO_WORKER: "1"
|
|
3213
5419
|
};
|
|
3214
5420
|
const input = JSON.stringify({
|
|
@@ -3236,7 +5442,7 @@ function hookWall(runs = 8) {
|
|
|
3236
5442
|
}
|
|
3237
5443
|
function timing() {
|
|
3238
5444
|
try {
|
|
3239
|
-
const lines =
|
|
5445
|
+
const lines = readFileSync11(path18.join(state, "timing.jsonl"), "utf8").trim().split("\n").slice(-100);
|
|
3240
5446
|
const ms = lines.map((l) => JSON.parse(l).ms).filter((x) => x >= 0).sort((a, b) => a - b);
|
|
3241
5447
|
if (!ms.length) return null;
|
|
3242
5448
|
const q = (p) => Math.round(ms[Math.min(ms.length - 1, Math.floor(p * ms.length))] ?? 0);
|
|
@@ -3249,10 +5455,10 @@ function doctor() {
|
|
|
3249
5455
|
const cfg = readConfig();
|
|
3250
5456
|
const hc = hookCommand();
|
|
3251
5457
|
const report = {
|
|
3252
|
-
version:
|
|
5458
|
+
version: VERSION3,
|
|
3253
5459
|
node: process.version,
|
|
3254
5460
|
node_path: stableNode(process.execPath),
|
|
3255
|
-
machine: machineName(cfg,
|
|
5461
|
+
machine: machineName(cfg, os7.hostname()),
|
|
3256
5462
|
state,
|
|
3257
5463
|
config: configFile(),
|
|
3258
5464
|
harnesses: {},
|
|
@@ -3308,7 +5514,7 @@ function doctor() {
|
|
|
3308
5514
|
out(JSON.stringify(report, null, 2));
|
|
3309
5515
|
return;
|
|
3310
5516
|
}
|
|
3311
|
-
out(` sessionpipe ${
|
|
5517
|
+
out(` sessionpipe ${VERSION3} \xB7 node ${process.version} at ${report.node_path}`);
|
|
3312
5518
|
out(` config ${tilde(configFile())} \xB7 state ${tilde(state)}`);
|
|
3313
5519
|
for (const [h, st] of Object.entries(report.harnesses))
|
|
3314
5520
|
for (const r of st) out(` ${h.padEnd(12)} ${r.file.padEnd(44)} ${r.state}`);
|
|
@@ -3372,7 +5578,7 @@ async function tail() {
|
|
|
3372
5578
|
out(`sessionpipe tail \xB7 tier ${tier} \xB7 ${tilde(state)}/outbox (Ctrl-C to stop)`);
|
|
3373
5579
|
for (const ref of outbox.sessions()) {
|
|
3374
5580
|
try {
|
|
3375
|
-
offsets.set(`${ref.harness}/${ref.session}`, has("--all") ? 0 :
|
|
5581
|
+
offsets.set(`${ref.harness}/${ref.session}`, has("--all") ? 0 : statSync9(outbox.file(ref)).size);
|
|
3376
5582
|
} catch {
|
|
3377
5583
|
}
|
|
3378
5584
|
}
|
|
@@ -3395,9 +5601,9 @@ async function tail() {
|
|
|
3395
5601
|
async function workerFromArgv() {
|
|
3396
5602
|
const f = args[1];
|
|
3397
5603
|
if (!f) return;
|
|
3398
|
-
const job = JSON.parse(
|
|
5604
|
+
const job = JSON.parse(readFileSync11(f, "utf8"));
|
|
3399
5605
|
try {
|
|
3400
|
-
|
|
5606
|
+
unlinkSync7(f);
|
|
3401
5607
|
} catch {
|
|
3402
5608
|
}
|
|
3403
5609
|
await runJob(job);
|
|
@@ -3408,15 +5614,15 @@ async function update() {
|
|
|
3408
5614
|
const r = await fetch("https://registry.npmjs.org/sessionpipe/latest", { signal: AbortSignal.timeout(8e3) });
|
|
3409
5615
|
const j = await r.json();
|
|
3410
5616
|
if (!j.version) throw new Error("no version");
|
|
3411
|
-
if (j.version ===
|
|
3412
|
-
out(` ${j.version} is available (you run ${
|
|
5617
|
+
if (j.version === VERSION3) return out(` sessionpipe ${VERSION3} is current`);
|
|
5618
|
+
out(` ${j.version} is available (you run ${VERSION3}); installing into this copy's prefix\u2026`);
|
|
3413
5619
|
const prefix = prefixOf(distDir);
|
|
3414
|
-
|
|
5620
|
+
execFileSync6("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
|
|
3415
5621
|
stdio: "inherit"
|
|
3416
5622
|
});
|
|
3417
5623
|
out(" re-arming hooks\u2026");
|
|
3418
|
-
const bin = prefix ?
|
|
3419
|
-
const p =
|
|
5624
|
+
const bin = prefix ? path18.join(prefix, "bin", "sessionpipe") : "sessionpipe";
|
|
5625
|
+
const p = spawn2(bin, ["install", "--backfill", "0"], { stdio: "inherit" });
|
|
3420
5626
|
await new Promise((r2) => p.on("exit", r2));
|
|
3421
5627
|
} catch (e) {
|
|
3422
5628
|
out(` couldn't check npm: ${e.message}`);
|