sessionpipe 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/dist/cli.js +2749 -266
- 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 +116 -21
- 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 +370 -119
- package/dist/index.js.map +4 -4
- package/dist/run.d.ts +13 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/worker.js +396 -119
- package/dist/worker.js.map +4 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
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 { 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
|
|
12
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
existsSync as existsSync2,
|
|
19
|
+
mkdirSync as mkdirSync2,
|
|
20
|
+
readdirSync as readdirSync2,
|
|
21
|
+
readFileSync as readFileSync2,
|
|
22
|
+
realpathSync,
|
|
23
|
+
statSync as statSync2,
|
|
24
|
+
unlinkSync as unlinkSync2,
|
|
25
|
+
writeFileSync as writeFileSync2
|
|
26
|
+
} from "node:fs";
|
|
13
27
|
import path4 from "node:path";
|
|
14
28
|
|
|
15
29
|
// ../core/src/paths.ts
|
|
@@ -236,28 +250,67 @@ function readAntigravity(lines, from) {
|
|
|
236
250
|
}
|
|
237
251
|
|
|
238
252
|
// ../core/src/adapters/claude-shaped.ts
|
|
239
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
253
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
240
254
|
import path3 from "node:path";
|
|
241
255
|
var OURS = /sessionpipe[^"\n]*[\\/]hook\.js"?\s+[a-z][a-z-]*\s+[A-Za-z]+\s*$/;
|
|
242
256
|
var isOurs = (h) => Array.isArray(h?.hooks) && h.hooks.some((x) => typeof x.command === "string" && OURS.test(x.command));
|
|
243
|
-
function
|
|
257
|
+
function readJsonFile(file) {
|
|
258
|
+
let raw;
|
|
244
259
|
try {
|
|
245
|
-
|
|
260
|
+
raw = readFileSync(file, "utf8");
|
|
246
261
|
} catch {
|
|
247
|
-
return {};
|
|
262
|
+
return { ok: true, existed: false, value: {}, raw: "", indent: " ", eol: "\n", finalNewline: true };
|
|
248
263
|
}
|
|
264
|
+
let value;
|
|
265
|
+
try {
|
|
266
|
+
value = JSON.parse(raw);
|
|
267
|
+
} catch {
|
|
268
|
+
const hint = /^\s*\/\/|\/\*/m.test(raw) ? "not plain JSON (it has comments)" : "not plain JSON";
|
|
269
|
+
return { ok: false, existed: true, reason: hint };
|
|
270
|
+
}
|
|
271
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
272
|
+
return { ok: false, existed: true, reason: "not a JSON object" };
|
|
273
|
+
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
274
|
+
const m = /^( +|\t+)\S/m.exec(raw);
|
|
275
|
+
const indent = m?.[1] ?? " ";
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
existed: true,
|
|
279
|
+
value,
|
|
280
|
+
raw,
|
|
281
|
+
indent,
|
|
282
|
+
eol,
|
|
283
|
+
finalNewline: /\r?\n$/.test(raw)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function formatJson(value, f) {
|
|
287
|
+
const text = JSON.stringify(value, null, f.indent).replace(/\n/g, f.eol);
|
|
288
|
+
return f.finalNewline ? text + f.eol : text;
|
|
249
289
|
}
|
|
250
290
|
function entryFor(event, harness, cmd2, o) {
|
|
251
291
|
const matcher = o.matcherFor(event);
|
|
252
292
|
return {
|
|
253
293
|
...matcher !== void 0 ? { matcher } : {},
|
|
254
|
-
hooks: [{ type: "command", command: cmd2([harness, event]).command, timeout: o.timeout }]
|
|
294
|
+
hooks: [{ type: "command", command: cmd2([harness, event]).command, timeout: o.timeoutFor?.(event) ?? o.timeout }]
|
|
255
295
|
};
|
|
256
296
|
}
|
|
257
297
|
function installClaudeShaped(file, harness, events, cmd2, o) {
|
|
258
|
-
const
|
|
298
|
+
const f = readJsonFile(file);
|
|
299
|
+
if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
|
|
300
|
+
const settings2 = f.value;
|
|
259
301
|
const hooks = settings2.hooks && typeof settings2.hooks === "object" ? settings2.hooks : {};
|
|
260
302
|
let changed = false;
|
|
303
|
+
const want = new Set(events);
|
|
304
|
+
for (const ev of Object.keys(hooks)) {
|
|
305
|
+
if (want.has(ev)) continue;
|
|
306
|
+
const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
|
|
307
|
+
const kept = list.filter((h) => !isOurs(h));
|
|
308
|
+
if (kept.length !== list.length) {
|
|
309
|
+
changed = true;
|
|
310
|
+
if (kept.length) hooks[ev] = kept;
|
|
311
|
+
else delete hooks[ev];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
261
314
|
for (const ev of events) {
|
|
262
315
|
const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
|
|
263
316
|
const entry2 = entryFor(ev, harness, cmd2, o);
|
|
@@ -276,19 +329,19 @@ function installClaudeShaped(file, harness, events, cmd2, o) {
|
|
|
276
329
|
if (changed) {
|
|
277
330
|
settings2.hooks = hooks;
|
|
278
331
|
mkdirSync(path3.dirname(file), { recursive: true });
|
|
279
|
-
writeFileSync(file,
|
|
280
|
-
`);
|
|
332
|
+
writeFileSync(file, formatJson(settings2, f));
|
|
281
333
|
}
|
|
282
|
-
return { file, changed };
|
|
334
|
+
return { file, changed, created: !f.existed && changed };
|
|
283
335
|
}
|
|
284
|
-
function uninstallClaudeShaped(file, events) {
|
|
336
|
+
function uninstallClaudeShaped(file, events, opts = {}) {
|
|
285
337
|
if (!existsSync(file)) return { file, changed: false };
|
|
286
|
-
const
|
|
287
|
-
|
|
338
|
+
const f = readJsonFile(file);
|
|
339
|
+
if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
|
|
340
|
+
const settings2 = f.value;
|
|
288
341
|
const hooks = settings2.hooks;
|
|
289
342
|
if (!hooks || typeof hooks !== "object") return { file, changed: false };
|
|
290
343
|
let changed = false;
|
|
291
|
-
for (const ev of events) {
|
|
344
|
+
for (const ev of [...events, ...Object.keys(hooks)]) {
|
|
292
345
|
const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
|
|
293
346
|
const kept = list.filter((h) => !isOurs(h));
|
|
294
347
|
if (kept.length !== list.length) {
|
|
@@ -299,13 +352,17 @@ function uninstallClaudeShaped(file, events) {
|
|
|
299
352
|
}
|
|
300
353
|
if (!changed) return { file, changed: false };
|
|
301
354
|
if (!Object.keys(hooks).length) delete settings2.hooks;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
355
|
+
if (opts.created && !Object.keys(settings2).length) {
|
|
356
|
+
unlinkSync(file);
|
|
357
|
+
return { file, changed: true, note: "removed (sessionpipe created it)" };
|
|
358
|
+
}
|
|
359
|
+
const next = formatJson(settings2, f);
|
|
360
|
+
if (next !== f.raw) writeFileSync(file, next);
|
|
305
361
|
return { file, changed: true };
|
|
306
362
|
}
|
|
307
363
|
function installedClaudeShaped(file, harness, events, cmd2, o) {
|
|
308
|
-
const
|
|
364
|
+
const f = readJsonFile(file);
|
|
365
|
+
const hooks = f.ok ? f.value.hooks ?? {} : {};
|
|
309
366
|
let present = 0;
|
|
310
367
|
let current = 0;
|
|
311
368
|
for (const ev of events) {
|
|
@@ -314,6 +371,9 @@ function installedClaudeShaped(file, harness, events, cmd2, o) {
|
|
|
314
371
|
if (mine.length) present++;
|
|
315
372
|
if (mine.length === 1 && JSON.stringify(mine[0]) === JSON.stringify(entryFor(ev, harness, cmd2, o))) current++;
|
|
316
373
|
}
|
|
374
|
+
for (const ev of Object.keys(hooks))
|
|
375
|
+
if (!events.includes(ev) && (Array.isArray(hooks[ev]) ? hooks[ev] : []).some((h) => isOurs(h)))
|
|
376
|
+
return "stale";
|
|
317
377
|
if (!present) return "missing";
|
|
318
378
|
return current === events.length ? "current" : "stale";
|
|
319
379
|
}
|
|
@@ -328,10 +388,13 @@ var projectsDir = (env) => path4.join(gemini(env), "config", "projects");
|
|
|
328
388
|
var dataDirs = (env) => ["antigravity", "antigravity-cli", "antigravity-ide"].map((d) => path4.join(gemini(env), d));
|
|
329
389
|
var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
330
390
|
var validTool = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
|
|
331
|
-
function entry(cmd2) {
|
|
391
|
+
function entry(cmd2, lean) {
|
|
332
392
|
const handler = (e) => ({ type: "command", command: cmd2([NAME, e]).command, timeout: 5 });
|
|
333
393
|
const out2 = { enabled: true };
|
|
334
|
-
for (const e of AG_EVENTS)
|
|
394
|
+
for (const e of AG_EVENTS) {
|
|
395
|
+
if (lean && e === "PreToolUse") continue;
|
|
396
|
+
out2[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
|
|
397
|
+
}
|
|
335
398
|
return out2;
|
|
336
399
|
}
|
|
337
400
|
var antigravity = {
|
|
@@ -339,34 +402,49 @@ var antigravity = {
|
|
|
339
402
|
events: AG_EVENTS,
|
|
340
403
|
detect: (env = process.env) => existsSync2(path4.join(gemini(env), "config")) || dataDirs(env).some((d) => existsSync2(d)),
|
|
341
404
|
configFiles: (env = process.env) => [hooksFile(env)],
|
|
342
|
-
install(cmd2, env = process.env) {
|
|
405
|
+
install(cmd2, env = process.env, opts = {}) {
|
|
343
406
|
const file = hooksFile(env);
|
|
344
|
-
const
|
|
345
|
-
|
|
407
|
+
const f = readJsonFile(file);
|
|
408
|
+
if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
|
|
409
|
+
const hooks = f.value;
|
|
410
|
+
const e = entry(cmd2, opts.lean);
|
|
346
411
|
if (JSON.stringify(hooks[HOOK_NAME]) === JSON.stringify(e)) return [{ file, changed: false }];
|
|
347
412
|
hooks[HOOK_NAME] = e;
|
|
348
413
|
mkdirSync2(path4.dirname(file), { recursive: true });
|
|
349
|
-
writeFileSync2(file,
|
|
350
|
-
`);
|
|
414
|
+
writeFileSync2(file, formatJson(hooks, f));
|
|
351
415
|
return [
|
|
352
|
-
{
|
|
416
|
+
{
|
|
417
|
+
file,
|
|
418
|
+
changed: true,
|
|
419
|
+
created: !f.existed,
|
|
420
|
+
note: "hooks.json is read when a conversation starts; open ones report after a restart"
|
|
421
|
+
}
|
|
353
422
|
];
|
|
354
423
|
},
|
|
355
|
-
uninstall(env = process.env) {
|
|
424
|
+
uninstall(env = process.env, opts = {}) {
|
|
356
425
|
const file = hooksFile(env);
|
|
357
426
|
if (!existsSync2(file)) return [{ file, changed: false }];
|
|
358
|
-
const
|
|
427
|
+
const f = readJsonFile(file);
|
|
428
|
+
if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
|
|
429
|
+
const hooks = f.value;
|
|
359
430
|
if (!(HOOK_NAME in hooks)) return [{ file, changed: false }];
|
|
360
431
|
delete hooks[HOOK_NAME];
|
|
361
|
-
|
|
362
|
-
|
|
432
|
+
if (opts.created?.includes(file) && !Object.keys(hooks).length) {
|
|
433
|
+
unlinkSync2(file);
|
|
434
|
+
return [{ file, changed: true, note: "removed (sessionpipe created it)" }];
|
|
435
|
+
}
|
|
436
|
+
writeFileSync2(file, formatJson(hooks, f));
|
|
363
437
|
return [{ file, changed: true }];
|
|
364
438
|
},
|
|
365
|
-
installed(cmd2, env = process.env) {
|
|
439
|
+
installed(cmd2, env = process.env, opts = {}) {
|
|
366
440
|
const file = hooksFile(env);
|
|
367
|
-
const
|
|
441
|
+
const f = readJsonFile(file);
|
|
442
|
+
const have = f.ok ? f.value[HOOK_NAME] : void 0;
|
|
368
443
|
return [
|
|
369
|
-
{
|
|
444
|
+
{
|
|
445
|
+
file,
|
|
446
|
+
state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd2, opts.lean)) ? "current" : "stale"
|
|
447
|
+
}
|
|
370
448
|
];
|
|
371
449
|
},
|
|
372
450
|
fromHook(input) {
|
|
@@ -632,9 +710,18 @@ var CLAUDE_EVENTS = [
|
|
|
632
710
|
"SubagentStop",
|
|
633
711
|
"PreCompact",
|
|
634
712
|
"PostCompact",
|
|
635
|
-
"SessionEnd"
|
|
713
|
+
"SessionEnd",
|
|
714
|
+
"StopFailure"
|
|
636
715
|
];
|
|
637
|
-
var
|
|
716
|
+
var LEAN_SKIP = /* @__PURE__ */ new Set(["PreToolUse", "PostToolUseFailure"]);
|
|
717
|
+
var eventsFor = (lean) => lean ? CLAUDE_EVENTS.filter((e) => !LEAN_SKIP.has(e)) : [...CLAUDE_EVENTS];
|
|
718
|
+
var OPTS = {
|
|
719
|
+
matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0,
|
|
720
|
+
timeout: 5,
|
|
721
|
+
// The hook waits up to 125 s for a verified control answer, and only when control
|
|
722
|
+
// is paired on this machine; everywhere else it exits in milliseconds as before.
|
|
723
|
+
timeoutFor: (e) => e === "PermissionRequest" ? 130 : void 0
|
|
724
|
+
};
|
|
638
725
|
var DESKTOP_ID = /^local_[A-Za-z0-9-]{1,64}$/;
|
|
639
726
|
function claudeDirs(env = process.env, home = HOME) {
|
|
640
727
|
const out2 = [];
|
|
@@ -665,11 +752,13 @@ var claudeCode = {
|
|
|
665
752
|
events: CLAUDE_EVENTS,
|
|
666
753
|
detect: (env = process.env) => claudeDirs(env).some((d) => existsSync3(d)),
|
|
667
754
|
configFiles: (env = process.env) => claudeDirs(env).map(settingsIn),
|
|
668
|
-
install: (cmd2, env = process.env) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2,
|
|
669
|
-
uninstall: (env = process.env) => claudeDirs(env).map(
|
|
670
|
-
|
|
755
|
+
install: (cmd2, env = process.env, opts = {}) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd2, OPTS)),
|
|
756
|
+
uninstall: (env = process.env, opts = {}) => claudeDirs(env).map(
|
|
757
|
+
(d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS, { created: opts.created?.includes(settingsIn(d)) })
|
|
758
|
+
),
|
|
759
|
+
installed: (cmd2, env = process.env, opts = {}) => claudeDirs(env).map((d) => ({
|
|
671
760
|
file: settingsIn(d),
|
|
672
|
-
state: installedClaudeShaped(settingsIn(d), NAME2,
|
|
761
|
+
state: installedClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd2, OPTS)
|
|
673
762
|
})),
|
|
674
763
|
fromHook(input) {
|
|
675
764
|
const event = input.argv[1] ?? "";
|
|
@@ -760,6 +849,15 @@ var claudeCode = {
|
|
|
760
849
|
case "Stop":
|
|
761
850
|
events.push(ev("turn.ended", { turn_id, reason: "stop" }));
|
|
762
851
|
break;
|
|
852
|
+
case "StopFailure":
|
|
853
|
+
events.push(
|
|
854
|
+
ev("turn.ended", {
|
|
855
|
+
turn_id,
|
|
856
|
+
reason: "error",
|
|
857
|
+
error: typeof s.error === "string" ? s.error.slice(0, 200) : void 0
|
|
858
|
+
})
|
|
859
|
+
);
|
|
860
|
+
break;
|
|
763
861
|
case "SubagentStart":
|
|
764
862
|
events.push(
|
|
765
863
|
ev("subagent.started", {
|
|
@@ -1067,9 +1165,9 @@ function claudeFacts(file, sessionId, hints, state2) {
|
|
|
1067
1165
|
}
|
|
1068
1166
|
const name = live?.name ? { title: live.name, source: live.name_source } : typeof mem.name === "string" ? { title: mem.name, source: mem.name_source } : null;
|
|
1069
1167
|
const pick = name && name.source === "custom" && name || custom && { title: custom, source: "custom" } || name || ai && { title: ai, source: "harness" };
|
|
1070
|
-
let
|
|
1071
|
-
if (!
|
|
1072
|
-
|
|
1168
|
+
let ask2 = typeof mem.first_ask === "string" ? mem.first_ask : firstAsk(H);
|
|
1169
|
+
if (!ask2 && size > 64 * 1024 && !mem.first_ask_none) {
|
|
1170
|
+
ask2 = scanLines(file, (l) => {
|
|
1073
1171
|
try {
|
|
1074
1172
|
const t = askText(JSON.parse(l));
|
|
1075
1173
|
return t ? appTitle(t) : null;
|
|
@@ -1077,17 +1175,17 @@ function claudeFacts(file, sessionId, hints, state2) {
|
|
|
1077
1175
|
return null;
|
|
1078
1176
|
}
|
|
1079
1177
|
});
|
|
1080
|
-
if (!
|
|
1178
|
+
if (!ask2 && size > 16 * 1024 * 1024) state2.save(NAME2, id, { first_ask_none: true });
|
|
1081
1179
|
}
|
|
1082
|
-
if (
|
|
1083
|
-
out2.first_ask =
|
|
1084
|
-
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 });
|
|
1085
1183
|
}
|
|
1086
1184
|
if (pick) {
|
|
1087
1185
|
out2.title = String(pick.title).slice(0, 120);
|
|
1088
1186
|
out2.title_source = pick.source;
|
|
1089
|
-
} else if (
|
|
1090
|
-
out2.title =
|
|
1187
|
+
} else if (ask2) {
|
|
1188
|
+
out2.title = ask2;
|
|
1091
1189
|
out2.title_source = "first-ask";
|
|
1092
1190
|
}
|
|
1093
1191
|
const acct = claudeAccount(dir);
|
|
@@ -1271,6 +1369,7 @@ var CODEX_EVENTS = [
|
|
|
1271
1369
|
"SessionEnd"
|
|
1272
1370
|
];
|
|
1273
1371
|
var OPTS2 = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
|
|
1372
|
+
var eventsFor2 = (lean) => lean ? CODEX_EVENTS.filter((e) => e !== "PreToolUse") : [...CODEX_EVENTS];
|
|
1274
1373
|
var codexHome = (env) => env.CODEX_HOME || path6.join(HOME, ".codex");
|
|
1275
1374
|
var hooksFile2 = (env) => path6.join(codexHome(env), "hooks.json");
|
|
1276
1375
|
var configFile2 = (env) => path6.join(codexHome(env), "config.toml");
|
|
@@ -1319,7 +1418,8 @@ function installNotify(cmd2, env) {
|
|
|
1319
1418
|
return {
|
|
1320
1419
|
file,
|
|
1321
1420
|
changed: false,
|
|
1322
|
-
|
|
1421
|
+
skipped: true,
|
|
1422
|
+
note: `notify belongs to ${(n.argv?.[0] ?? n.raw).replace(/^.*\//, "").slice(0, 40)}; left alone. Codex will report nothing until you approve the hooks: start \`codex\` and accept the hook review`
|
|
1323
1423
|
};
|
|
1324
1424
|
}
|
|
1325
1425
|
write(withNotify(text, line));
|
|
@@ -1376,14 +1476,21 @@ var codex = {
|
|
|
1376
1476
|
events: CODEX_EVENTS,
|
|
1377
1477
|
detect: (env = process.env) => existsSync4(codexHome(env)),
|
|
1378
1478
|
configFiles: (env = process.env) => [hooksFile2(env), configFile2(env)],
|
|
1379
|
-
install: (cmd2, env = process.env) => [
|
|
1380
|
-
installClaudeShaped(hooksFile2(env), NAME3,
|
|
1479
|
+
install: (cmd2, env = process.env, opts = {}) => [
|
|
1480
|
+
installClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd2, OPTS2),
|
|
1381
1481
|
installNotify(cmd2, env)
|
|
1382
1482
|
],
|
|
1383
|
-
uninstall: (env = process.env) => [
|
|
1384
|
-
|
|
1483
|
+
uninstall: (env = process.env, opts = {}) => [
|
|
1484
|
+
uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS, { created: opts.created?.includes(hooksFile2(env)) }),
|
|
1485
|
+
uninstallNotify(env)
|
|
1486
|
+
],
|
|
1487
|
+
installed: (cmd2, env = process.env, opts = {}) => {
|
|
1385
1488
|
const out2 = [
|
|
1386
|
-
{
|
|
1489
|
+
{
|
|
1490
|
+
file: hooksFile2(env),
|
|
1491
|
+
state: installedClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd2, OPTS2),
|
|
1492
|
+
note: "Codex runs non-managed hooks only after the review in `codex` (/hooks); until then nothing is reported unless the notify fallback is ours"
|
|
1493
|
+
}
|
|
1387
1494
|
];
|
|
1388
1495
|
let text = "";
|
|
1389
1496
|
try {
|
|
@@ -1571,6 +1678,7 @@ function codexFacts(file) {
|
|
|
1571
1678
|
}
|
|
1572
1679
|
|
|
1573
1680
|
// ../core/src/adapters/gemini-cli.ts
|
|
1681
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
1574
1682
|
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
|
|
1575
1683
|
import path7 from "node:path";
|
|
1576
1684
|
var NAME4 = "gemini-cli";
|
|
@@ -1588,17 +1696,61 @@ var OPTS3 = { matcherFor: (e) => /Tool/.test(e) ? ".*" : void 0, timeout: 5e3 };
|
|
|
1588
1696
|
var geminiDir = (env) => env.GEMINI_CLI_HOME || path7.join(HOME, ".gemini");
|
|
1589
1697
|
var settings = (env) => path7.join(geminiDir(env), "settings.json");
|
|
1590
1698
|
var validTool4 = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
|
|
1699
|
+
var eventsFor3 = (lean) => lean ? GEMINI_EVENTS.filter((e) => e !== "BeforeTool") : [...GEMINI_EVENTS];
|
|
1700
|
+
function geminiHooksActive(settingsFile) {
|
|
1701
|
+
let tools;
|
|
1702
|
+
try {
|
|
1703
|
+
tools = JSON.parse(readFileSync5(settingsFile, "utf8")).tools;
|
|
1704
|
+
} catch {
|
|
1705
|
+
}
|
|
1706
|
+
if (tools?.enableHooks === true) return { active: true };
|
|
1707
|
+
if (tools?.enableHooks === false)
|
|
1708
|
+
return { active: false, note: "inactive: tools.enableHooks is false in settings.json" };
|
|
1709
|
+
let version = "";
|
|
1710
|
+
try {
|
|
1711
|
+
version = execFileSync3("gemini", ["--version"], {
|
|
1712
|
+
encoding: "utf8",
|
|
1713
|
+
timeout: 4e3,
|
|
1714
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1715
|
+
}).trim();
|
|
1716
|
+
} catch {
|
|
1717
|
+
return {
|
|
1718
|
+
active: true,
|
|
1719
|
+
note: "could not run `gemini --version`; hooks need tools.enableHooks on versions before 0.46"
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
const m = /(\d+)\.(\d+)/.exec(version);
|
|
1723
|
+
if (m && (Number(m[1]) > 0 || Number(m[2]) >= 46)) return { active: true };
|
|
1724
|
+
return {
|
|
1725
|
+
active: false,
|
|
1726
|
+
note: `inactive: Gemini CLI ${version || "?"} runs hooks only with "tools": {"enableHooks": true} in settings.json`
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1591
1729
|
var geminiCli = {
|
|
1592
1730
|
name: NAME4,
|
|
1593
1731
|
events: GEMINI_EVENTS,
|
|
1594
1732
|
// ~/.gemini also belongs to Antigravity; Gemini CLI is here when its settings or its chat logs exist.
|
|
1595
1733
|
detect: (env = process.env) => existsSync5(settings(env)) || existsSync5(path7.join(geminiDir(env), "tmp")),
|
|
1596
1734
|
configFiles: (env = process.env) => [settings(env)],
|
|
1597
|
-
install: (cmd2, env = process.env
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1735
|
+
install: (cmd2, env = process.env, opts = {}) => {
|
|
1736
|
+
const r = installClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd2, OPTS3);
|
|
1737
|
+
const a = geminiHooksActive(settings(env));
|
|
1738
|
+
return [a.note && !r.skipped ? { ...r, note: [r.note, a.note].filter(Boolean).join("; ") } : r];
|
|
1739
|
+
},
|
|
1740
|
+
uninstall: (env = process.env, opts = {}) => [
|
|
1741
|
+
uninstallClaudeShaped(settings(env), GEMINI_EVENTS, { created: opts.created?.includes(settings(env)) })
|
|
1601
1742
|
],
|
|
1743
|
+
installed: (cmd2, env = process.env, opts = {}) => {
|
|
1744
|
+
const state2 = installedClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd2, OPTS3);
|
|
1745
|
+
const a = geminiHooksActive(settings(env));
|
|
1746
|
+
return [
|
|
1747
|
+
{
|
|
1748
|
+
file: settings(env),
|
|
1749
|
+
state: state2 === "current" && !a.active ? "inactive" : state2,
|
|
1750
|
+
...a.note ? { note: a.note } : {}
|
|
1751
|
+
}
|
|
1752
|
+
];
|
|
1753
|
+
},
|
|
1602
1754
|
fromHook(input) {
|
|
1603
1755
|
const event = input.argv[1] ?? "";
|
|
1604
1756
|
let s = {};
|
|
@@ -1710,17 +1862,366 @@ function prune4(o) {
|
|
|
1710
1862
|
return o;
|
|
1711
1863
|
}
|
|
1712
1864
|
|
|
1865
|
+
// ../core/src/adapters/claude-code-control.ts
|
|
1866
|
+
var claude_code_control_exports = {};
|
|
1867
|
+
__export(claude_code_control_exports, {
|
|
1868
|
+
JOB_TIMEOUT_MS: () => JOB_TIMEOUT_MS,
|
|
1869
|
+
claudeEnv: () => claudeEnv,
|
|
1870
|
+
detectCaps: () => detectCaps,
|
|
1871
|
+
findClaude: () => findClaude,
|
|
1872
|
+
findTranscript: () => findTranscript,
|
|
1873
|
+
isUuid: () => isUuid,
|
|
1874
|
+
liveProcess: () => liveProcess,
|
|
1875
|
+
modeFlags: () => modeFlags,
|
|
1876
|
+
parseCaps: () => parseCaps,
|
|
1877
|
+
parseResult: () => parseResult,
|
|
1878
|
+
recentlyWritten: () => recentlyWritten,
|
|
1879
|
+
runClaude: () => runClaude,
|
|
1880
|
+
sessionFolder: () => sessionFolder
|
|
1881
|
+
});
|
|
1882
|
+
import { execFileSync as execFileSync4, spawn } from "node:child_process";
|
|
1883
|
+
import { accessSync, closeSync as closeSync2, constants, openSync as openSync2, readdirSync as readdirSync5, readFileSync as readFileSync6, readSync as readSync2, statSync as statSync4 } from "node:fs";
|
|
1884
|
+
import os2 from "node:os";
|
|
1885
|
+
import path8 from "node:path";
|
|
1886
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1887
|
+
var JOB_TIMEOUT_MS = 30 * 6e4;
|
|
1888
|
+
var OUT_CAP = 1024 * 1024;
|
|
1889
|
+
var LIVE_WRITE_MS = 9e4;
|
|
1890
|
+
function isExe(p) {
|
|
1891
|
+
try {
|
|
1892
|
+
if (!statSync4(p).isFile()) return false;
|
|
1893
|
+
accessSync(p, constants.X_OK);
|
|
1894
|
+
return true;
|
|
1895
|
+
} catch {
|
|
1896
|
+
return false;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
var cmpVersion = (a, b) => {
|
|
1900
|
+
const pa = a.split(".").map(Number);
|
|
1901
|
+
const pb = b.split(".").map(Number);
|
|
1902
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++)
|
|
1903
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1904
|
+
return 0;
|
|
1905
|
+
};
|
|
1906
|
+
function findClaude(env = process.env, home = os2.homedir()) {
|
|
1907
|
+
if (env.SESSIONPIPE_CLAUDE && isExe(env.SESSIONPIPE_CLAUDE)) return env.SESSIONPIPE_CLAUDE;
|
|
1908
|
+
const names = process.platform === "win32" ? ["claude.exe", "claude.cmd"] : ["claude"];
|
|
1909
|
+
for (const d of String(env.PATH || "").split(path8.delimiter)) {
|
|
1910
|
+
if (!d || !path8.isAbsolute(d)) continue;
|
|
1911
|
+
for (const n of names) if (isExe(path8.join(d, n))) return path8.join(d, n);
|
|
1912
|
+
}
|
|
1913
|
+
for (const p of [
|
|
1914
|
+
path8.join(home, ".claude", "local", "claude"),
|
|
1915
|
+
path8.join(home, ".local", "bin", "claude"),
|
|
1916
|
+
"/opt/homebrew/bin/claude",
|
|
1917
|
+
"/usr/local/bin/claude"
|
|
1918
|
+
])
|
|
1919
|
+
if (isExe(p)) return p;
|
|
1920
|
+
const app = path8.join(home, "Library", "Application Support", "Claude", "claude-code");
|
|
1921
|
+
let versions = [];
|
|
1922
|
+
try {
|
|
1923
|
+
versions = readdirSync5(app).filter((n) => /^\d+(\.\d+)*$/.test(n)).sort(cmpVersion).reverse();
|
|
1924
|
+
} catch {
|
|
1925
|
+
}
|
|
1926
|
+
for (const v of versions) {
|
|
1927
|
+
const p = path8.join(app, v, "claude.app", "Contents", "MacOS", "claude");
|
|
1928
|
+
if (isExe(p)) return p;
|
|
1929
|
+
}
|
|
1930
|
+
return null;
|
|
1931
|
+
}
|
|
1932
|
+
function optionBlock(help2, flag2) {
|
|
1933
|
+
const lines = help2.split("\n");
|
|
1934
|
+
const re = new RegExp(`^\\s{0,4}(?:-\\w, )?${flag2.replace(/-/g, "\\-")}(?![\\w-])`);
|
|
1935
|
+
const start = lines.findIndex((l) => re.test(l));
|
|
1936
|
+
if (start < 0) return null;
|
|
1937
|
+
const out2 = [lines[start]];
|
|
1938
|
+
for (let i = start + 1; i < lines.length && lines[i]?.trim() && !/^\s{0,4}-/.test(lines[i]); i++)
|
|
1939
|
+
out2.push(lines[i]);
|
|
1940
|
+
return out2.join(" ");
|
|
1941
|
+
}
|
|
1942
|
+
function parseCaps(help2) {
|
|
1943
|
+
const pm = optionBlock(help2, "--permission-mode");
|
|
1944
|
+
const pp = optionBlock(help2, "--permission-prompts");
|
|
1945
|
+
return {
|
|
1946
|
+
hasMode: !!pm,
|
|
1947
|
+
modes: pm ? [...pm.matchAll(/"([A-Za-z]+)"/g)].map((m) => m[1]) : [],
|
|
1948
|
+
promptsNone: !!pp && /"none"/.test(pp),
|
|
1949
|
+
fork: !!optionBlock(help2, "--fork-session")
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
function modeFlags(mode, caps) {
|
|
1953
|
+
if (mode === "auto") {
|
|
1954
|
+
if (!caps.modes.includes("auto"))
|
|
1955
|
+
return { error: "this machine is in auto mode, but its Claude Code has no auto permission mode" };
|
|
1956
|
+
return { args: ["--permission-mode", "auto"] };
|
|
1957
|
+
}
|
|
1958
|
+
if (caps.modes.includes("dontAsk")) return { args: ["--permission-mode", "dontAsk"] };
|
|
1959
|
+
const args2 = [];
|
|
1960
|
+
if (caps.hasMode)
|
|
1961
|
+
args2.push(
|
|
1962
|
+
"--permission-mode",
|
|
1963
|
+
caps.modes.includes("manual") && !caps.modes.includes("default") ? "manual" : "default"
|
|
1964
|
+
);
|
|
1965
|
+
if (caps.promptsNone) args2.push("--permission-prompts", "none");
|
|
1966
|
+
return { args: args2 };
|
|
1967
|
+
}
|
|
1968
|
+
function claudeEnv(configDir, env = process.env) {
|
|
1969
|
+
const e = { ...env };
|
|
1970
|
+
for (const k of [
|
|
1971
|
+
"CLAUDECODE",
|
|
1972
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
1973
|
+
"CLAUDE_CODE_HOST_SESSION_ID",
|
|
1974
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
1975
|
+
"CLAUDE_CODE_SESSION_ATTENDED",
|
|
1976
|
+
"CLAUDE_CODE_SSE_PORT"
|
|
1977
|
+
])
|
|
1978
|
+
delete e[k];
|
|
1979
|
+
e.SESSIONPIPE_CONTROL_JOB = "1";
|
|
1980
|
+
if (configDir !== void 0) {
|
|
1981
|
+
if (path8.resolve(configDir) === path8.resolve(os2.homedir(), ".claude")) delete e.CLAUDE_CONFIG_DIR;
|
|
1982
|
+
else e.CLAUDE_CONFIG_DIR = configDir;
|
|
1983
|
+
}
|
|
1984
|
+
return e;
|
|
1985
|
+
}
|
|
1986
|
+
function detectCaps(bin) {
|
|
1987
|
+
let help2 = "";
|
|
1988
|
+
try {
|
|
1989
|
+
help2 = execFileSync4(bin, ["--help"], {
|
|
1990
|
+
encoding: "utf8",
|
|
1991
|
+
timeout: 3e4,
|
|
1992
|
+
env: claudeEnv(),
|
|
1993
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1994
|
+
maxBuffer: 4 * 1024 * 1024
|
|
1995
|
+
});
|
|
1996
|
+
} catch (e) {
|
|
1997
|
+
help2 = String(e?.stdout || "");
|
|
1998
|
+
}
|
|
1999
|
+
return parseCaps(help2);
|
|
2000
|
+
}
|
|
2001
|
+
function findTranscript(session, dirs) {
|
|
2002
|
+
if (!UUID_RE.test(session)) return null;
|
|
2003
|
+
for (const dir of dirs) {
|
|
2004
|
+
let projects = [];
|
|
2005
|
+
try {
|
|
2006
|
+
projects = readdirSync5(path8.join(dir, "projects"), { withFileTypes: true });
|
|
2007
|
+
} catch {
|
|
2008
|
+
continue;
|
|
2009
|
+
}
|
|
2010
|
+
for (const p of projects) {
|
|
2011
|
+
if (!p.isDirectory()) continue;
|
|
2012
|
+
const file = path8.join(dir, "projects", p.name, `${session}.jsonl`);
|
|
2013
|
+
try {
|
|
2014
|
+
if (statSync4(file).isFile()) return { file, configDir: dir, project: p.name };
|
|
2015
|
+
} catch {
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
return null;
|
|
2020
|
+
}
|
|
2021
|
+
var projectName = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
2022
|
+
function sessionFolder(t, cap2 = 8 * 1024 * 1024) {
|
|
2023
|
+
let first = null;
|
|
2024
|
+
let count = 0;
|
|
2025
|
+
let fd;
|
|
2026
|
+
const look = (line) => {
|
|
2027
|
+
if (line.indexOf('"cwd"') < 0) return null;
|
|
2028
|
+
let j;
|
|
2029
|
+
try {
|
|
2030
|
+
j = JSON.parse(line.toString("utf8"));
|
|
2031
|
+
} catch {
|
|
2032
|
+
return null;
|
|
2033
|
+
}
|
|
2034
|
+
if (!j || typeof j.cwd !== "string" || !path8.isAbsolute(j.cwd)) return null;
|
|
2035
|
+
if (projectName(j.cwd) === t.project) return j.cwd;
|
|
2036
|
+
first ??= j.cwd;
|
|
2037
|
+
count++;
|
|
2038
|
+
return null;
|
|
2039
|
+
};
|
|
2040
|
+
try {
|
|
2041
|
+
fd = openSync2(t.file, "r");
|
|
2042
|
+
const buf = Buffer.alloc(256 * 1024);
|
|
2043
|
+
let pos = 0;
|
|
2044
|
+
let rest = Buffer.alloc(0);
|
|
2045
|
+
let skipping = false;
|
|
2046
|
+
while (pos < cap2 && count < 50) {
|
|
2047
|
+
const n = readSync2(fd, buf, 0, buf.length, pos);
|
|
2048
|
+
if (!n) {
|
|
2049
|
+
if (!skipping && rest.length) {
|
|
2050
|
+
const hit = look(rest);
|
|
2051
|
+
if (hit) return hit;
|
|
2052
|
+
}
|
|
2053
|
+
break;
|
|
2054
|
+
}
|
|
2055
|
+
pos += n;
|
|
2056
|
+
let chunk = rest.length ? Buffer.concat([rest, buf.subarray(0, n)]) : buf.subarray(0, n);
|
|
2057
|
+
let nl = chunk.indexOf(10);
|
|
2058
|
+
while (nl >= 0) {
|
|
2059
|
+
const line = chunk.subarray(0, nl);
|
|
2060
|
+
chunk = chunk.subarray(nl + 1);
|
|
2061
|
+
if (skipping) skipping = false;
|
|
2062
|
+
else {
|
|
2063
|
+
const hit = look(line);
|
|
2064
|
+
if (hit) return hit;
|
|
2065
|
+
}
|
|
2066
|
+
nl = chunk.indexOf(10);
|
|
2067
|
+
}
|
|
2068
|
+
if (chunk.length > 2 * 1024 * 1024) {
|
|
2069
|
+
skipping = true;
|
|
2070
|
+
rest = Buffer.alloc(0);
|
|
2071
|
+
} else rest = skipping ? Buffer.alloc(0) : Buffer.from(chunk);
|
|
2072
|
+
}
|
|
2073
|
+
} catch {
|
|
2074
|
+
} finally {
|
|
2075
|
+
if (fd !== void 0)
|
|
2076
|
+
try {
|
|
2077
|
+
closeSync2(fd);
|
|
2078
|
+
} catch {
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
return first;
|
|
2082
|
+
}
|
|
2083
|
+
function pidAlive(pid) {
|
|
2084
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) return false;
|
|
2085
|
+
try {
|
|
2086
|
+
process.kill(pid, 0);
|
|
2087
|
+
return true;
|
|
2088
|
+
} catch (e) {
|
|
2089
|
+
return e.code === "EPERM";
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
function liveProcess(session, configDir) {
|
|
2093
|
+
let names = [];
|
|
2094
|
+
try {
|
|
2095
|
+
names = readdirSync5(path8.join(configDir, "sessions")).filter((n) => /^\d+\.json$/.test(n));
|
|
2096
|
+
} catch {
|
|
2097
|
+
return false;
|
|
2098
|
+
}
|
|
2099
|
+
for (const n of names) {
|
|
2100
|
+
try {
|
|
2101
|
+
const j = JSON.parse(readFileSync6(path8.join(configDir, "sessions", n), "utf8"));
|
|
2102
|
+
if (String(j.sessionId) !== session) continue;
|
|
2103
|
+
if (pidAlive(Number(j.pid ?? n.slice(0, -5)))) return true;
|
|
2104
|
+
} catch {
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
return false;
|
|
2108
|
+
}
|
|
2109
|
+
function recentlyWritten(file, ourLastEnd, now = Date.now()) {
|
|
2110
|
+
let m;
|
|
2111
|
+
try {
|
|
2112
|
+
m = statSync4(file).mtimeMs;
|
|
2113
|
+
} catch {
|
|
2114
|
+
return false;
|
|
2115
|
+
}
|
|
2116
|
+
if (now - m > LIVE_WRITE_MS) return false;
|
|
2117
|
+
return !(ourLastEnd && m <= ourLastEnd + 2e3);
|
|
2118
|
+
}
|
|
2119
|
+
function runClaude(bin, args2, o) {
|
|
2120
|
+
return new Promise((resolve) => {
|
|
2121
|
+
let out2 = [];
|
|
2122
|
+
let outLen = 0;
|
|
2123
|
+
let tooBig = false;
|
|
2124
|
+
let errTail = "";
|
|
2125
|
+
let timedOut = false;
|
|
2126
|
+
let settled = false;
|
|
2127
|
+
let child;
|
|
2128
|
+
try {
|
|
2129
|
+
child = spawn(bin, args2, {
|
|
2130
|
+
cwd: o.cwd,
|
|
2131
|
+
env: o.env,
|
|
2132
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2133
|
+
detached: process.platform !== "win32",
|
|
2134
|
+
windowsHide: true
|
|
2135
|
+
});
|
|
2136
|
+
} catch (e) {
|
|
2137
|
+
resolve({ error: e.message });
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
const kill = (sig) => {
|
|
2141
|
+
try {
|
|
2142
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, sig);
|
|
2143
|
+
else child.kill(sig);
|
|
2144
|
+
} catch {
|
|
2145
|
+
try {
|
|
2146
|
+
child.kill(sig);
|
|
2147
|
+
} catch {
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
};
|
|
2151
|
+
child.stdout?.on("data", (b) => {
|
|
2152
|
+
if (tooBig) return;
|
|
2153
|
+
if (outLen + b.length > OUT_CAP) {
|
|
2154
|
+
tooBig = true;
|
|
2155
|
+
out2 = [];
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
out2.push(b);
|
|
2159
|
+
outLen += b.length;
|
|
2160
|
+
});
|
|
2161
|
+
child.stderr?.on("data", (b) => {
|
|
2162
|
+
errTail = (errTail + b.toString("utf8")).slice(-4e3);
|
|
2163
|
+
});
|
|
2164
|
+
const timer = setTimeout(() => {
|
|
2165
|
+
timedOut = true;
|
|
2166
|
+
kill("SIGTERM");
|
|
2167
|
+
setTimeout(() => kill("SIGKILL"), 1e4).unref();
|
|
2168
|
+
}, o.timeoutMs ?? JOB_TIMEOUT_MS);
|
|
2169
|
+
const result = (code, signal) => ({
|
|
2170
|
+
code,
|
|
2171
|
+
signal,
|
|
2172
|
+
stdout: tooBig ? null : Buffer.concat(out2).toString("utf8"),
|
|
2173
|
+
tooBig,
|
|
2174
|
+
stderr: errTail,
|
|
2175
|
+
timedOut
|
|
2176
|
+
});
|
|
2177
|
+
const finish = (r) => {
|
|
2178
|
+
if (settled) return;
|
|
2179
|
+
settled = true;
|
|
2180
|
+
clearTimeout(timer);
|
|
2181
|
+
resolve(r);
|
|
2182
|
+
};
|
|
2183
|
+
child.on("error", (e) => finish({ error: e.message }));
|
|
2184
|
+
child.on("exit", (code, signal) => setTimeout(() => finish(result(code, signal)), 2e3).unref());
|
|
2185
|
+
child.on("close", (code, signal) => finish(result(code, signal)));
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
function parseResult(stdout) {
|
|
2189
|
+
const s = String(stdout || "").trim();
|
|
2190
|
+
if (!s) return null;
|
|
2191
|
+
const pick = (j) => {
|
|
2192
|
+
if (Array.isArray(j)) {
|
|
2193
|
+
for (let i = j.length - 1; i >= 0; i--) if (j[i]?.type === "result") return j[i];
|
|
2194
|
+
return null;
|
|
2195
|
+
}
|
|
2196
|
+
return j && typeof j === "object" ? j : null;
|
|
2197
|
+
};
|
|
2198
|
+
try {
|
|
2199
|
+
return pick(JSON.parse(s));
|
|
2200
|
+
} catch {
|
|
2201
|
+
}
|
|
2202
|
+
const lines = s.split("\n");
|
|
2203
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2204
|
+
try {
|
|
2205
|
+
const j = pick(JSON.parse(lines[i]));
|
|
2206
|
+
if (j && (j.type === "result" || "result" in j)) return j;
|
|
2207
|
+
} catch {
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
return null;
|
|
2211
|
+
}
|
|
2212
|
+
var isUuid = (s) => typeof s === "string" && UUID_RE.test(s);
|
|
2213
|
+
|
|
1713
2214
|
// ../core/src/adapters/index.ts
|
|
1714
2215
|
var ADAPTERS = [claudeCode, codex, geminiCli, antigravity];
|
|
1715
2216
|
var adapterByName = (name) => ADAPTERS.find((a) => a.name === name);
|
|
1716
2217
|
|
|
1717
2218
|
// ../core/src/config.ts
|
|
1718
|
-
import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as
|
|
1719
|
-
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";
|
|
1720
2221
|
var DEFAULT_CONFIG = { sinks: [], harnesses: {}, update_check: true, keep_days: 30 };
|
|
1721
2222
|
function readConfig(file = configFile()) {
|
|
1722
2223
|
try {
|
|
1723
|
-
const j = JSON.parse(
|
|
2224
|
+
const j = JSON.parse(readFileSync7(file, "utf8"));
|
|
1724
2225
|
return {
|
|
1725
2226
|
...DEFAULT_CONFIG,
|
|
1726
2227
|
...j,
|
|
@@ -1732,7 +2233,7 @@ function readConfig(file = configFile()) {
|
|
|
1732
2233
|
}
|
|
1733
2234
|
}
|
|
1734
2235
|
function writeConfig(c, file = configFile()) {
|
|
1735
|
-
mkdirSync4(
|
|
2236
|
+
mkdirSync4(path9.dirname(file), { recursive: true, mode: 448 });
|
|
1736
2237
|
writeFileSync4(file, `${JSON.stringify(c, null, 2)}
|
|
1737
2238
|
`, { mode: 384 });
|
|
1738
2239
|
if (process.platform !== "win32" && existsSync6(file)) {
|
|
@@ -1800,32 +2301,32 @@ function makeEvent(a, ctx) {
|
|
|
1800
2301
|
// ../core/src/outbox.ts
|
|
1801
2302
|
import {
|
|
1802
2303
|
appendFileSync,
|
|
1803
|
-
closeSync as
|
|
2304
|
+
closeSync as closeSync3,
|
|
1804
2305
|
existsSync as existsSync7,
|
|
1805
2306
|
mkdirSync as mkdirSync5,
|
|
1806
|
-
openSync as
|
|
1807
|
-
readdirSync as
|
|
1808
|
-
readFileSync as
|
|
1809
|
-
readSync as
|
|
2307
|
+
openSync as openSync3,
|
|
2308
|
+
readdirSync as readdirSync6,
|
|
2309
|
+
readFileSync as readFileSync8,
|
|
2310
|
+
readSync as readSync3,
|
|
1810
2311
|
renameSync,
|
|
1811
|
-
statSync as
|
|
1812
|
-
unlinkSync,
|
|
2312
|
+
statSync as statSync5,
|
|
2313
|
+
unlinkSync as unlinkSync3,
|
|
1813
2314
|
writeFileSync as writeFileSync5
|
|
1814
2315
|
} from "node:fs";
|
|
1815
|
-
import
|
|
2316
|
+
import path10 from "node:path";
|
|
1816
2317
|
var Outbox = class {
|
|
1817
2318
|
constructor(root) {
|
|
1818
2319
|
this.root = root;
|
|
1819
2320
|
}
|
|
1820
2321
|
root;
|
|
1821
2322
|
dir(harness) {
|
|
1822
|
-
return
|
|
2323
|
+
return path10.join(this.root, "outbox", safeId(harness));
|
|
1823
2324
|
}
|
|
1824
2325
|
file(ref) {
|
|
1825
|
-
return
|
|
2326
|
+
return path10.join(this.dir(ref.harness), `${safeId(ref.session)}.jsonl`);
|
|
1826
2327
|
}
|
|
1827
2328
|
seqFile(ref) {
|
|
1828
|
-
return
|
|
2329
|
+
return path10.join(this.dir(ref.harness), `${safeId(ref.session)}.seq`);
|
|
1829
2330
|
}
|
|
1830
2331
|
/** The next seq for a session, persisted (survives a hibernating machine and a
|
|
1831
2332
|
* restart; an in-memory counter would restart at 0 and collide). */
|
|
@@ -1833,18 +2334,18 @@ var Outbox = class {
|
|
|
1833
2334
|
const f = this.seqFile(ref);
|
|
1834
2335
|
let n = 0;
|
|
1835
2336
|
try {
|
|
1836
|
-
n = Number(
|
|
2337
|
+
n = Number(readFileSync8(f, "utf8")) || 0;
|
|
1837
2338
|
} catch {
|
|
1838
2339
|
n = this.lastSeqInFile(ref) + 1;
|
|
1839
2340
|
}
|
|
1840
|
-
mkdirSync5(
|
|
1841
|
-
writeFileSync5(f, String(n + 1));
|
|
2341
|
+
mkdirSync5(path10.dirname(f), { recursive: true, mode: 448 });
|
|
2342
|
+
writeFileSync5(f, String(n + 1), { mode: 384 });
|
|
1842
2343
|
return n;
|
|
1843
2344
|
}
|
|
1844
2345
|
lastSeqInFile(ref) {
|
|
1845
2346
|
const f = this.file(ref);
|
|
1846
2347
|
if (!existsSync7(f)) return -1;
|
|
1847
|
-
const lines =
|
|
2348
|
+
const lines = readFileSync8(f, "utf8").trim().split("\n");
|
|
1848
2349
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1849
2350
|
try {
|
|
1850
2351
|
const j = JSON.parse(lines[i] ?? "");
|
|
@@ -1856,26 +2357,26 @@ var Outbox = class {
|
|
|
1856
2357
|
}
|
|
1857
2358
|
append(ref, events) {
|
|
1858
2359
|
if (!events.length) return;
|
|
1859
|
-
mkdirSync5(this.dir(ref.harness), { recursive: true });
|
|
2360
|
+
mkdirSync5(this.dir(ref.harness), { recursive: true, mode: 448 });
|
|
1860
2361
|
appendFileSync(this.file(ref), `${events.map((e) => JSON.stringify(e)).join("\n")}
|
|
1861
|
-
|
|
2362
|
+
`, { mode: 384 });
|
|
1862
2363
|
}
|
|
1863
2364
|
/** Every session file, newest first. */
|
|
1864
2365
|
sessions() {
|
|
1865
2366
|
const out2 = [];
|
|
1866
|
-
const base =
|
|
2367
|
+
const base = path10.join(this.root, "outbox");
|
|
1867
2368
|
if (!existsSync7(base)) return [];
|
|
1868
|
-
for (const h of
|
|
1869
|
-
const d =
|
|
2369
|
+
for (const h of readdirSync6(base)) {
|
|
2370
|
+
const d = path10.join(base, h);
|
|
1870
2371
|
let names = [];
|
|
1871
2372
|
try {
|
|
1872
|
-
names =
|
|
2373
|
+
names = readdirSync6(d).filter((n) => n.endsWith(".jsonl"));
|
|
1873
2374
|
} catch {
|
|
1874
2375
|
continue;
|
|
1875
2376
|
}
|
|
1876
2377
|
for (const n of names) {
|
|
1877
2378
|
try {
|
|
1878
|
-
out2.push({ ref: { harness: h, session: n.slice(0, -6) }, mtime:
|
|
2379
|
+
out2.push({ ref: { harness: h, session: n.slice(0, -6) }, mtime: statSync5(path10.join(d, n)).mtimeMs });
|
|
1879
2380
|
} catch {
|
|
1880
2381
|
}
|
|
1881
2382
|
}
|
|
@@ -1887,12 +2388,12 @@ var Outbox = class {
|
|
|
1887
2388
|
read(ref, from, maxEvents = 500) {
|
|
1888
2389
|
const f = this.file(ref);
|
|
1889
2390
|
if (!existsSync7(f)) return { events: [], offset: from };
|
|
1890
|
-
const size =
|
|
2391
|
+
const size = statSync5(f).size;
|
|
1891
2392
|
if (from >= size) return { events: [], offset: from };
|
|
1892
|
-
const fd =
|
|
2393
|
+
const fd = openSync3(f, "r");
|
|
1893
2394
|
try {
|
|
1894
2395
|
const buf = Buffer.alloc(Math.min(size - from, 8 * 1024 * 1024));
|
|
1895
|
-
const n =
|
|
2396
|
+
const n = readSync3(fd, buf, 0, buf.length, from);
|
|
1896
2397
|
const text = buf.subarray(0, n).toString("utf8");
|
|
1897
2398
|
const events = [];
|
|
1898
2399
|
let offset = from;
|
|
@@ -1911,7 +2412,7 @@ var Outbox = class {
|
|
|
1911
2412
|
}
|
|
1912
2413
|
return { events, offset };
|
|
1913
2414
|
} finally {
|
|
1914
|
-
|
|
2415
|
+
closeSync3(fd);
|
|
1915
2416
|
}
|
|
1916
2417
|
}
|
|
1917
2418
|
/** Files older than `days` are pruned; the cursor files with them. */
|
|
@@ -1921,10 +2422,10 @@ var Outbox = class {
|
|
|
1921
2422
|
for (const ref of this.sessions()) {
|
|
1922
2423
|
const f = this.file(ref);
|
|
1923
2424
|
try {
|
|
1924
|
-
if (
|
|
1925
|
-
|
|
2425
|
+
if (statSync5(f).mtimeMs < cutoff) {
|
|
2426
|
+
unlinkSync3(f);
|
|
1926
2427
|
try {
|
|
1927
|
-
|
|
2428
|
+
unlinkSync3(this.seqFile(ref));
|
|
1928
2429
|
} catch {
|
|
1929
2430
|
}
|
|
1930
2431
|
n++;
|
|
@@ -1937,7 +2438,7 @@ var Outbox = class {
|
|
|
1937
2438
|
delete(ref) {
|
|
1938
2439
|
for (const f of [this.file(ref), this.seqFile(ref)]) {
|
|
1939
2440
|
try {
|
|
1940
|
-
|
|
2441
|
+
unlinkSync3(f);
|
|
1941
2442
|
} catch {
|
|
1942
2443
|
}
|
|
1943
2444
|
}
|
|
@@ -1951,20 +2452,20 @@ var Cursors = class {
|
|
|
1951
2452
|
root;
|
|
1952
2453
|
sink;
|
|
1953
2454
|
file() {
|
|
1954
|
-
return
|
|
2455
|
+
return path10.join(this.root, "cursors", `${safeId(this.sink)}.json`);
|
|
1955
2456
|
}
|
|
1956
2457
|
read() {
|
|
1957
2458
|
try {
|
|
1958
|
-
return JSON.parse(
|
|
2459
|
+
return JSON.parse(readFileSync8(this.file(), "utf8"));
|
|
1959
2460
|
} catch {
|
|
1960
2461
|
return {};
|
|
1961
2462
|
}
|
|
1962
2463
|
}
|
|
1963
2464
|
write(c) {
|
|
1964
2465
|
const f = this.file();
|
|
1965
|
-
mkdirSync5(
|
|
2466
|
+
mkdirSync5(path10.dirname(f), { recursive: true, mode: 448 });
|
|
1966
2467
|
const tmp = `${f}.${process.pid}.tmp`;
|
|
1967
|
-
writeFileSync5(tmp, JSON.stringify(c));
|
|
2468
|
+
writeFileSync5(tmp, JSON.stringify(c), { mode: 384 });
|
|
1968
2469
|
renameSync(tmp, f);
|
|
1969
2470
|
}
|
|
1970
2471
|
key(ref) {
|
|
@@ -1972,11 +2473,11 @@ var Cursors = class {
|
|
|
1972
2473
|
}
|
|
1973
2474
|
};
|
|
1974
2475
|
function takeLock(root, ref, staleMs = 5 * 6e4) {
|
|
1975
|
-
const dir =
|
|
1976
|
-
mkdirSync5(dir, { recursive: true });
|
|
1977
|
-
const f =
|
|
2476
|
+
const dir = path10.join(root, "locks");
|
|
2477
|
+
mkdirSync5(dir, { recursive: true, mode: 448 });
|
|
2478
|
+
const f = path10.join(dir, `${safeId(ref.harness)}-${safeId(ref.session)}.lock`);
|
|
1978
2479
|
try {
|
|
1979
|
-
if (Date.now() -
|
|
2480
|
+
if (Date.now() - statSync5(f).mtimeMs > staleMs) unlinkSync3(f);
|
|
1980
2481
|
else return null;
|
|
1981
2482
|
} catch {
|
|
1982
2483
|
}
|
|
@@ -1987,7 +2488,7 @@ function takeLock(root, ref, staleMs = 5 * 6e4) {
|
|
|
1987
2488
|
}
|
|
1988
2489
|
return () => {
|
|
1989
2490
|
try {
|
|
1990
|
-
|
|
2491
|
+
unlinkSync3(f);
|
|
1991
2492
|
} catch {
|
|
1992
2493
|
}
|
|
1993
2494
|
};
|
|
@@ -2123,7 +2624,7 @@ function redactAssignments(text) {
|
|
|
2123
2624
|
function isSecretName(name) {
|
|
2124
2625
|
if (/^[A-Z0-9_]+_KEY$/.test(name)) return true;
|
|
2125
2626
|
const flat = name.toLowerCase().replace(/[_-]/g, "");
|
|
2126
|
-
return /(?:pass(?:word|wd|phrase|code)
|
|
2627
|
+
return /(?:pass(?:word|wd|phrase|code)|pwd|secret|secretkey|secretaccesskey|clientsecret|apikey|accesskey|accesstoken|authtoken|refreshtoken|privatekey|token|credential|creds)s?$/.test(
|
|
2127
2628
|
flat
|
|
2128
2629
|
);
|
|
2129
2630
|
}
|
|
@@ -2212,7 +2713,7 @@ var DATA_ALLOW = {
|
|
|
2212
2713
|
"session.backfill": { 0: ["started_at", "last_at", "turns"] },
|
|
2213
2714
|
"session.forgotten": { 0: [] },
|
|
2214
2715
|
"turn.started": { 0: ["turn_id"], 1: ["prompt_chars"] },
|
|
2215
|
-
"turn.ended": { 0: ["turn_id", "reason", "ms"] },
|
|
2716
|
+
"turn.ended": { 0: ["turn_id", "reason", "ms"], 1: ["error"] },
|
|
2216
2717
|
"turn.transcript": { 2: ["turn", "user", "assistant", "at"] },
|
|
2217
2718
|
"tool.started": { 1: ["tool", "call_id", "turn_id", "agent_id"], 3: ["input"] },
|
|
2218
2719
|
"tool.ended": { 1: ["tool", "call_id", "turn_id", "agent_id", "ms", "ok", "error"], 3: ["input", "output"] },
|
|
@@ -2290,7 +2791,7 @@ function filterEvent(event, tier, pii, opts = {}) {
|
|
|
2290
2791
|
|
|
2291
2792
|
// ../core/src/sinks/file.ts
|
|
2292
2793
|
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync6 } from "node:fs";
|
|
2293
|
-
import
|
|
2794
|
+
import path11 from "node:path";
|
|
2294
2795
|
var FileSink = class {
|
|
2295
2796
|
constructor(name, file, tier, pii) {
|
|
2296
2797
|
this.name = name;
|
|
@@ -2304,7 +2805,7 @@ var FileSink = class {
|
|
|
2304
2805
|
pii;
|
|
2305
2806
|
async send(events) {
|
|
2306
2807
|
try {
|
|
2307
|
-
mkdirSync6(
|
|
2808
|
+
mkdirSync6(path11.dirname(this.file), { recursive: true });
|
|
2308
2809
|
appendFileSync2(this.file, `${events.map((e) => JSON.stringify(e)).join("\n")}
|
|
2309
2810
|
`);
|
|
2310
2811
|
return { ok: true, acknowledged: events.length, accepted: events.length, duplicates: 0, rejected: [] };
|
|
@@ -2333,51 +2834,1890 @@ var StdoutSink = class {
|
|
|
2333
2834
|
}
|
|
2334
2835
|
};
|
|
2335
2836
|
|
|
2336
|
-
// src/
|
|
2337
|
-
import {
|
|
2338
|
-
import
|
|
2339
|
-
import
|
|
2837
|
+
// src/control/cli.ts
|
|
2838
|
+
import { existsSync as existsSync11, realpathSync as realpathSync3, watchFile } from "node:fs";
|
|
2839
|
+
import os5 from "node:os";
|
|
2840
|
+
import path16 from "node:path";
|
|
2340
2841
|
|
|
2341
|
-
// src/
|
|
2342
|
-
import {
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2842
|
+
// src/control/daemon.ts
|
|
2843
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync8, realpathSync as realpathSync2, statSync as statSync7, unlinkSync as unlinkSync4 } from "node:fs";
|
|
2844
|
+
import net from "node:net";
|
|
2845
|
+
import path13 from "node:path";
|
|
2846
|
+
|
|
2847
|
+
// ../core/src/control/verify.ts
|
|
2848
|
+
var GRANT_MAX_MS = 24 * 36e5;
|
|
2849
|
+
var SKEW_MS = 5 * 6e4;
|
|
2850
|
+
var MACHINE_MAX_AGE_MS = 24 * 36e5;
|
|
2851
|
+
var RECEIVER_MAX_AGE_MS = 10 * 6e4;
|
|
2852
|
+
var NONCE_TTL_MS = 25 * 36e5;
|
|
2853
|
+
var CMD_MAX = 12e3;
|
|
2854
|
+
var TEXT_MAX = 4e3;
|
|
2855
|
+
var NOTE_MAX = 2e3;
|
|
2856
|
+
var GRANT_STR_MAX = 2e3;
|
|
2857
|
+
var COMMAND_TYPE = "sessionpipe.control";
|
|
2858
|
+
var GRANT_TYPE = "sessionpipe.grant";
|
|
2859
|
+
var CONTROL_KINDS = ["prompt", "permission.answer", "cancel", "start"];
|
|
2860
|
+
var ES256 = -7;
|
|
2861
|
+
var RS256 = -257;
|
|
2862
|
+
var NONCE_RE = /^[A-Za-z0-9_-]{22,64}$/;
|
|
2863
|
+
var MACHINE_RE = /^m_[A-Za-z0-9_-]{16,40}$/;
|
|
2864
|
+
var HARNESS_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
2865
|
+
var SESSION_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
2866
|
+
var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2867
|
+
var B64URL_RE = /^[A-Za-z0-9_-]*$/;
|
|
2868
|
+
var UP = 1;
|
|
2869
|
+
var UV = 4;
|
|
2870
|
+
var enc = new TextEncoder();
|
|
2871
|
+
var dec = new TextDecoder();
|
|
2872
|
+
function b64url(bytes) {
|
|
2873
|
+
const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
2874
|
+
let s = "";
|
|
2875
|
+
for (const b of u) s += String.fromCharCode(b);
|
|
2876
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2877
|
+
}
|
|
2878
|
+
function fromB64url(s) {
|
|
2879
|
+
if (typeof s !== "string" || !B64URL_RE.test(s) || s.length % 4 === 1) return null;
|
|
2880
|
+
try {
|
|
2881
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4));
|
|
2882
|
+
const out2 = new Uint8Array(bin.length);
|
|
2883
|
+
for (let i = 0; i < bin.length; i++) out2[i] = bin.charCodeAt(i);
|
|
2884
|
+
return out2;
|
|
2885
|
+
} catch {
|
|
2886
|
+
return null;
|
|
2351
2887
|
}
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2888
|
+
}
|
|
2889
|
+
async function sha256(data) {
|
|
2890
|
+
const bytes = typeof data === "string" ? enc.encode(data) : data;
|
|
2891
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
2892
|
+
}
|
|
2893
|
+
function concat(a, b) {
|
|
2894
|
+
const out2 = new Uint8Array(a.length + b.length);
|
|
2895
|
+
out2.set(a, 0);
|
|
2896
|
+
out2.set(b, a.length);
|
|
2897
|
+
return out2;
|
|
2898
|
+
}
|
|
2899
|
+
function sameBytes(a, b) {
|
|
2900
|
+
if (a.length !== b.length) return false;
|
|
2901
|
+
let d = 0;
|
|
2902
|
+
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
|
|
2903
|
+
return d === 0;
|
|
2904
|
+
}
|
|
2905
|
+
function derToRaw(der) {
|
|
2906
|
+
if (der[0] !== 48 || der.length < 8 || der[1] !== der.length - 2) return null;
|
|
2907
|
+
let i = 2;
|
|
2908
|
+
const part = () => {
|
|
2909
|
+
if (der[i] !== 2) return null;
|
|
2910
|
+
const len = der[i + 1];
|
|
2911
|
+
if (len < 1 || len > 33) return null;
|
|
2912
|
+
let v = der.slice(i + 2, i + 2 + len);
|
|
2913
|
+
if (v.length !== len) return null;
|
|
2914
|
+
if (v[0] & 128) return null;
|
|
2915
|
+
if (len > 1 && v[0] === 0 && !(v[1] & 128)) return null;
|
|
2916
|
+
i += 2 + len;
|
|
2917
|
+
while (v.length > 32 && v[0] === 0) v = v.slice(1);
|
|
2918
|
+
if (v.length > 32) return null;
|
|
2919
|
+
const out2 = new Uint8Array(32);
|
|
2920
|
+
out2.set(v, 32 - v.length);
|
|
2921
|
+
return out2;
|
|
2922
|
+
};
|
|
2923
|
+
const r = part();
|
|
2924
|
+
const s = part();
|
|
2925
|
+
return r && s && i === der.length ? concat(r, s) : null;
|
|
2926
|
+
}
|
|
2927
|
+
var u8 = (b) => b;
|
|
2928
|
+
async function importKey(key) {
|
|
2929
|
+
const spki = fromB64url(key.spki);
|
|
2930
|
+
if (!spki?.length) return null;
|
|
2931
|
+
try {
|
|
2932
|
+
if (key.alg === ES256)
|
|
2933
|
+
return await crypto.subtle.importKey("spki", u8(spki), { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
2934
|
+
if (key.alg === RS256) {
|
|
2935
|
+
const k = await crypto.subtle.importKey("spki", u8(spki), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, [
|
|
2936
|
+
"verify"
|
|
2937
|
+
]);
|
|
2938
|
+
const bits = k.algorithm.modulusLength ?? 0;
|
|
2939
|
+
return bits >= 2048 ? k : null;
|
|
2940
|
+
}
|
|
2941
|
+
} catch {
|
|
2360
2942
|
}
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2943
|
+
return null;
|
|
2944
|
+
}
|
|
2945
|
+
async function keyUsable(key) {
|
|
2946
|
+
return !!key && typeof key.id === "string" && /^[A-Za-z0-9_-]{16,1400}$/.test(key.id) && (key.alg === ES256 || key.alg === RS256) && !!await importKey(key);
|
|
2947
|
+
}
|
|
2948
|
+
function originAllowed(origin, rpId) {
|
|
2949
|
+
if (typeof origin !== "string" || !rpId) return false;
|
|
2950
|
+
let u;
|
|
2951
|
+
try {
|
|
2952
|
+
u = new URL(origin);
|
|
2953
|
+
} catch {
|
|
2954
|
+
return false;
|
|
2955
|
+
}
|
|
2956
|
+
if (u.origin !== origin) return false;
|
|
2957
|
+
if (u.hostname !== rpId && !u.hostname.endsWith(`.${rpId}`)) return false;
|
|
2958
|
+
if (u.protocol === "https:") return true;
|
|
2959
|
+
return u.protocol === "http:" && rpId === "localhost";
|
|
2960
|
+
}
|
|
2961
|
+
var no = (why) => ({ ok: false, why });
|
|
2962
|
+
async function verifyAssertion(challenge, a, key, rpId) {
|
|
2963
|
+
if (!a || typeof a !== "object") return no("no passkey signature");
|
|
2964
|
+
if (a.cred !== key.id) return no("signed with a key this machine doesn't trust");
|
|
2965
|
+
const cdBytes = fromB64url(a.cd);
|
|
2966
|
+
const ad = fromB64url(a.ad);
|
|
2967
|
+
const sig = fromB64url(a.sig);
|
|
2968
|
+
if (!cdBytes || !ad || !sig?.length) return no("the passkey signature is malformed");
|
|
2969
|
+
let cd;
|
|
2970
|
+
try {
|
|
2971
|
+
cd = JSON.parse(dec.decode(cdBytes));
|
|
2972
|
+
} catch {
|
|
2973
|
+
return no("the passkey signature is malformed");
|
|
2974
|
+
}
|
|
2975
|
+
if (!cd || typeof cd !== "object") return no("the passkey signature is malformed");
|
|
2976
|
+
if (cd.type !== "webauthn.get") return no("the passkey answered a different question");
|
|
2977
|
+
if (cd.challenge !== b64url(challenge)) return no("the passkey signed something else");
|
|
2978
|
+
if (!originAllowed(cd.origin, rpId)) return no(`the passkey was used on another site, not ${rpId}`);
|
|
2979
|
+
if (cd.crossOrigin === true) return no("the passkey was used inside another site's frame");
|
|
2980
|
+
if (ad.length < 37) return no("the passkey signature is malformed");
|
|
2981
|
+
if (!sameBytes(ad.slice(0, 32), await sha256(rpId))) return no(`the passkey belongs to another site, not ${rpId}`);
|
|
2982
|
+
const flags = ad[32];
|
|
2983
|
+
if (!(flags & UP) || !(flags & UV)) return no("the passkey signed without verifying the person");
|
|
2984
|
+
const pub = await importKey(key);
|
|
2985
|
+
if (!pub) return no("a trusted key is unusable");
|
|
2986
|
+
const signed = concat(ad, await sha256(cdBytes));
|
|
2987
|
+
let good = false;
|
|
2988
|
+
try {
|
|
2989
|
+
if (key.alg === ES256) {
|
|
2990
|
+
const raw = derToRaw(sig);
|
|
2991
|
+
good = !!raw && await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, pub, u8(raw), u8(signed));
|
|
2992
|
+
} else {
|
|
2993
|
+
good = await crypto.subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, pub, u8(sig), u8(signed));
|
|
2373
2994
|
}
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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 {
|
|
2381
4721
|
j = text ? JSON.parse(text) : {};
|
|
2382
4722
|
} catch {
|
|
2383
4723
|
}
|
|
@@ -2424,26 +4764,48 @@ var HttpSink = class {
|
|
|
2424
4764
|
};
|
|
2425
4765
|
|
|
2426
4766
|
// src/run.ts
|
|
2427
|
-
|
|
4767
|
+
async function tellDaemon(state2, job, session, transcript) {
|
|
4768
|
+
const sock = socketPath(state2);
|
|
4769
|
+
if (process.platform !== "win32" && !existsSync12(sock)) return;
|
|
4770
|
+
await ask(
|
|
4771
|
+
sock,
|
|
4772
|
+
{
|
|
4773
|
+
op: "event",
|
|
4774
|
+
session: `claude-code:${session}`,
|
|
4775
|
+
event: job.event,
|
|
4776
|
+
cwd: job.cwd,
|
|
4777
|
+
...transcript ? { transcript } : {}
|
|
4778
|
+
},
|
|
4779
|
+
300
|
|
4780
|
+
);
|
|
4781
|
+
}
|
|
4782
|
+
var VERSION3 = true ? "0.2.0" : "0.0.0";
|
|
4783
|
+
function enqueueJob(state2, job) {
|
|
4784
|
+
const jobs = jobsDir(state2);
|
|
4785
|
+
mkdirSync10(jobs, { recursive: true, mode: 448 });
|
|
4786
|
+
const f = path17.join(jobs, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.json`);
|
|
4787
|
+
writeFileSync8(f, JSON.stringify(job), { mode: 384 });
|
|
4788
|
+
return f;
|
|
4789
|
+
}
|
|
2428
4790
|
function log(state2, line) {
|
|
2429
4791
|
try {
|
|
2430
|
-
|
|
2431
|
-
const f =
|
|
4792
|
+
mkdirSync10(state2, { recursive: true, mode: 448 });
|
|
4793
|
+
const f = path17.join(state2, "log");
|
|
2432
4794
|
try {
|
|
2433
|
-
if (
|
|
4795
|
+
if (statSync8(f).size > 1024 * 1024) renameSync3(f, `${f}.1`);
|
|
2434
4796
|
} catch {
|
|
2435
4797
|
}
|
|
2436
|
-
|
|
4798
|
+
appendFileSync4(f, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
2437
4799
|
`);
|
|
2438
4800
|
} catch {
|
|
2439
4801
|
}
|
|
2440
4802
|
}
|
|
2441
4803
|
function factsState(state2) {
|
|
2442
|
-
const file = (h, s) =>
|
|
4804
|
+
const file = (h, s) => path17.join(state2, "facts", safeId(h), `${safeId(s)}.json`);
|
|
2443
4805
|
return {
|
|
2444
4806
|
get(h, s) {
|
|
2445
4807
|
try {
|
|
2446
|
-
return JSON.parse(
|
|
4808
|
+
return JSON.parse(readFileSync10(file(h, s), "utf8"));
|
|
2447
4809
|
} catch {
|
|
2448
4810
|
return {};
|
|
2449
4811
|
}
|
|
@@ -2452,8 +4814,8 @@ function factsState(state2) {
|
|
|
2452
4814
|
const cur = this.get(h, s);
|
|
2453
4815
|
const next = { ...cur, ...patch };
|
|
2454
4816
|
if (JSON.stringify(next) === JSON.stringify(cur)) return;
|
|
2455
|
-
|
|
2456
|
-
|
|
4817
|
+
mkdirSync10(path17.dirname(file(h, s)), { recursive: true, mode: 448 });
|
|
4818
|
+
writeFileSync8(file(h, s), JSON.stringify(next), { mode: 384 });
|
|
2457
4819
|
}
|
|
2458
4820
|
};
|
|
2459
4821
|
}
|
|
@@ -2464,7 +4826,7 @@ function buildSinks(sinks) {
|
|
|
2464
4826
|
const tier = Math.min(s.tier, s.max_tier ?? 3);
|
|
2465
4827
|
if (s.url === "stdout") out2.push(new StdoutSink(s.name, tier, !!s.pii));
|
|
2466
4828
|
else if (s.url.startsWith("file:"))
|
|
2467
|
-
out2.push(new FileSink(s.name, s.url.slice(5).replace(/^~(?=\/|$)/,
|
|
4829
|
+
out2.push(new FileSink(s.name, s.url.slice(5).replace(/^~(?=\/|$)/, os6.homedir()), tier, !!s.pii));
|
|
2468
4830
|
else if (/^https?:\/\//.test(s.url)) out2.push(new HttpSink(s, tier));
|
|
2469
4831
|
}
|
|
2470
4832
|
return out2;
|
|
@@ -2476,14 +4838,17 @@ async function runJob(job, opts = {}) {
|
|
|
2476
4838
|
if (!adapter) return { events: [], delivered: {} };
|
|
2477
4839
|
const r = adapter.fromHook({ argv: job.argv, stdin: job.stdin, env: { ...process.env, ...job.env }, cwd: job.cwd });
|
|
2478
4840
|
if (!r || !r.session.id) return { events: [], delivered: {} };
|
|
4841
|
+
if (job.harness === "claude-code") await tellDaemon(state2, job, r.session.id, r.transcript);
|
|
2479
4842
|
const outbox = new Outbox(state2);
|
|
2480
4843
|
const ref = { harness: job.harness, session: r.session.id };
|
|
2481
|
-
|
|
4844
|
+
let release = takeLock(state2, ref);
|
|
4845
|
+
for (let wait = 50; !release && wait <= 3200; wait *= 2) {
|
|
4846
|
+
await new Promise((res) => setTimeout(res, wait));
|
|
4847
|
+
release = takeLock(state2, ref);
|
|
4848
|
+
}
|
|
2482
4849
|
if (!release) {
|
|
2483
|
-
log(state2, `${job.harness} ${job.event}: session ${r.session.id} locked, re-queued`);
|
|
2484
|
-
|
|
2485
|
-
mkdirSync7(jobs, { recursive: true });
|
|
2486
|
-
writeFileSync6(path11.join(jobs, `${Date.now().toString(36)}-requeue.json`), JSON.stringify(job));
|
|
4850
|
+
log(state2, `${job.harness} ${job.event}: session ${r.session.id} locked for 6 s, re-queued`);
|
|
4851
|
+
enqueueJob(state2, job);
|
|
2487
4852
|
return { events: [], delivered: {} };
|
|
2488
4853
|
}
|
|
2489
4854
|
try {
|
|
@@ -2508,7 +4873,7 @@ async function runJob(job, opts = {}) {
|
|
|
2508
4873
|
...learned.branch ? { branch: learned.branch } : {},
|
|
2509
4874
|
id: r.session.id,
|
|
2510
4875
|
seq: 0,
|
|
2511
|
-
machine: machineName(cfg,
|
|
4876
|
+
machine: machineName(cfg, os6.hostname()),
|
|
2512
4877
|
...cwdRaw ? { cwd: tilde(cwdRaw) } : {},
|
|
2513
4878
|
...r.session.model ? { model: r.session.model } : {}
|
|
2514
4879
|
};
|
|
@@ -2547,7 +4912,10 @@ async function runJob(job, opts = {}) {
|
|
|
2547
4912
|
if (read.turns.length)
|
|
2548
4913
|
facts.save(job.harness, r.session.id, { transcript_line: read.line, transcript_turns: turnNo });
|
|
2549
4914
|
}
|
|
2550
|
-
outbox.append(
|
|
4915
|
+
outbox.append(
|
|
4916
|
+
ref,
|
|
4917
|
+
events.map((e) => ({ ...e, session: redactDeep(e.session), data: redactDeep(e.data) }))
|
|
4918
|
+
);
|
|
2551
4919
|
log(
|
|
2552
4920
|
state2,
|
|
2553
4921
|
`${job.harness} ${job.event}: ${events.map((e) => e.type).join(",") || "nothing"} (${r.session.id.slice(0, 8)})`
|
|
@@ -2573,34 +4941,57 @@ async function flush(state2, sinkConfigs, outbox, first) {
|
|
|
2573
4941
|
const cursors = new Cursors(state2, sink2.name);
|
|
2574
4942
|
const c = cursors.read();
|
|
2575
4943
|
let sent = 0;
|
|
4944
|
+
let stalled = false;
|
|
4945
|
+
let batch = [];
|
|
4946
|
+
let pending = {};
|
|
4947
|
+
const send = async () => {
|
|
4948
|
+
if (!batch.length) {
|
|
4949
|
+
Object.assign(c, pending);
|
|
4950
|
+
pending = {};
|
|
4951
|
+
cursors.write(c);
|
|
4952
|
+
return true;
|
|
4953
|
+
}
|
|
4954
|
+
const res = await sink2.send(batch);
|
|
4955
|
+
if (!res.ok) {
|
|
4956
|
+
log(state2, `sink ${sink2.name}: ${res.status ?? ""} ${res.error ?? ""} \u2192 ${res.action ?? "retry"}`);
|
|
4957
|
+
if (res.action === "pause" || res.action === "lower-tier") applySinkVerdict(sink2.name, res);
|
|
4958
|
+
if (res.action !== "drop") {
|
|
4959
|
+
batch = [];
|
|
4960
|
+
pending = {};
|
|
4961
|
+
return false;
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
4964
|
+
sent += batch.length;
|
|
4965
|
+
Object.assign(c, pending);
|
|
4966
|
+
cursors.write(c);
|
|
4967
|
+
batch = [];
|
|
4968
|
+
pending = {};
|
|
4969
|
+
return true;
|
|
4970
|
+
};
|
|
2576
4971
|
for (const ref of refs) {
|
|
2577
|
-
if (sent >= MAX_PER_RUN) break;
|
|
2578
|
-
let offset = c[cursors.key(ref)] ?? 0;
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
const { events, offset: next } = outbox.read(ref, offset,
|
|
4972
|
+
if (stalled || sent >= MAX_PER_RUN) break;
|
|
4973
|
+
let offset = pending[cursors.key(ref)] ?? c[cursors.key(ref)] ?? 0;
|
|
4974
|
+
for (; ; ) {
|
|
4975
|
+
const room = BATCH - batch.length;
|
|
4976
|
+
const { events, offset: next } = outbox.read(ref, offset, room);
|
|
2582
4977
|
if (!events.length) break;
|
|
2583
|
-
const
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
const res = await sink2.send(filtered);
|
|
2587
|
-
ok = res.ok;
|
|
2588
|
-
if (!ok) {
|
|
2589
|
-
log(state2, `sink ${sink2.name}: ${res.status ?? ""} ${res.error ?? ""} \u2192 ${res.action ?? "retry"}`);
|
|
2590
|
-
if (res.action === "pause" || res.action === "lower-tier") applySinkVerdict(sink2.name, res);
|
|
2591
|
-
if (res.action === "drop") ok = true;
|
|
2592
|
-
}
|
|
2593
|
-
if (ok) sent += filtered.length;
|
|
2594
|
-
}
|
|
2595
|
-
if (!ok) {
|
|
2596
|
-
stalled = true;
|
|
2597
|
-
break;
|
|
4978
|
+
for (const e of events) {
|
|
4979
|
+
const f = filterEvent(e, sink2.tier, sink2.pii, { home: os6.homedir() });
|
|
4980
|
+
if (f) batch.push(f);
|
|
2598
4981
|
}
|
|
2599
4982
|
offset = next;
|
|
2600
|
-
|
|
2601
|
-
|
|
4983
|
+
pending[cursors.key(ref)] = offset;
|
|
4984
|
+
if (batch.length >= BATCH) {
|
|
4985
|
+
if (!await send()) {
|
|
4986
|
+
stalled = true;
|
|
4987
|
+
break;
|
|
4988
|
+
}
|
|
4989
|
+
if (sent >= MAX_PER_RUN) break;
|
|
4990
|
+
}
|
|
2602
4991
|
}
|
|
2603
|
-
|
|
4992
|
+
}
|
|
4993
|
+
if (!stalled && (batch.length || Object.keys(pending).length)) {
|
|
4994
|
+
if (!await send()) stalled = true;
|
|
2604
4995
|
}
|
|
2605
4996
|
delivered[sink2.name] = sent;
|
|
2606
4997
|
}
|
|
@@ -2617,7 +5008,7 @@ function applySinkVerdict(name, res) {
|
|
|
2617
5008
|
} catch {
|
|
2618
5009
|
}
|
|
2619
5010
|
}
|
|
2620
|
-
var jobsDir = (state2) =>
|
|
5011
|
+
var jobsDir = (state2) => path17.join(state2, "jobs");
|
|
2621
5012
|
|
|
2622
5013
|
// src/cli.ts
|
|
2623
5014
|
process.stdout.on("error", (e) => {
|
|
@@ -2635,12 +5026,12 @@ var out = (s = "") => {
|
|
|
2635
5026
|
`);
|
|
2636
5027
|
};
|
|
2637
5028
|
var state = stateDir();
|
|
2638
|
-
var distDir =
|
|
5029
|
+
var distDir = path18.dirname(fileURLToPath(import.meta.url));
|
|
2639
5030
|
function hookCommand() {
|
|
2640
5031
|
const node = stableNode(process.execPath);
|
|
2641
|
-
let script =
|
|
5032
|
+
let script = path18.join(distDir, "hook.js");
|
|
2642
5033
|
try {
|
|
2643
|
-
script =
|
|
5034
|
+
script = realpathSync4(script);
|
|
2644
5035
|
} catch {
|
|
2645
5036
|
}
|
|
2646
5037
|
return (a) => ({
|
|
@@ -2653,13 +5044,13 @@ function stableNode(exe) {
|
|
|
2653
5044
|
if (!m) return exe;
|
|
2654
5045
|
let real;
|
|
2655
5046
|
try {
|
|
2656
|
-
real =
|
|
5047
|
+
real = realpathSync4(exe);
|
|
2657
5048
|
} catch {
|
|
2658
5049
|
return exe;
|
|
2659
5050
|
}
|
|
2660
5051
|
for (const c of [`${m[1]}/opt/${m[2]}/bin/node`, `${m[1]}/bin/node`]) {
|
|
2661
5052
|
try {
|
|
2662
|
-
if (
|
|
5053
|
+
if (realpathSync4(c) === real) return c;
|
|
2663
5054
|
} catch {
|
|
2664
5055
|
}
|
|
2665
5056
|
}
|
|
@@ -2695,39 +5086,58 @@ async function main() {
|
|
|
2695
5086
|
return update();
|
|
2696
5087
|
case "worker":
|
|
2697
5088
|
return workerFromArgv();
|
|
5089
|
+
case "control":
|
|
5090
|
+
return controlMain(args, out, distDir);
|
|
5091
|
+
case "wait":
|
|
5092
|
+
return waitMain(args, out);
|
|
2698
5093
|
case "hook":
|
|
2699
5094
|
out("sessionpipe hook is dist/hook.js; harnesses run it directly");
|
|
2700
5095
|
return;
|
|
2701
5096
|
case "--version":
|
|
2702
5097
|
case "-v":
|
|
2703
5098
|
case "version":
|
|
2704
|
-
out(
|
|
5099
|
+
out(VERSION3);
|
|
2705
5100
|
return;
|
|
2706
5101
|
default:
|
|
2707
5102
|
return help();
|
|
2708
5103
|
}
|
|
2709
5104
|
}
|
|
2710
5105
|
function help() {
|
|
2711
|
-
out(`sessionpipe ${
|
|
5106
|
+
out(`sessionpipe ${VERSION3} \u2014 an open protocol for what your coding agents are doing
|
|
2712
5107
|
|
|
2713
5108
|
sessionpipe install [--claude-code --codex --gemini-cli --antigravity] [--machine NAME] [--backfill DAYS] [--sink URL --tier N]
|
|
2714
5109
|
sessionpipe uninstall [--keep-state]
|
|
2715
|
-
sessionpipe sink add <url|file:PATH|stdout> [--tier 0-3] [--token T] [--pii] [--
|
|
5110
|
+
sessionpipe sink add <url|file:PATH|stdout> [--tier 0-3] [--token T] [--pii] [--name N]
|
|
2716
5111
|
sessionpipe sink list | remove <name> | test <name>
|
|
2717
5112
|
sessionpipe status | doctor [--json]
|
|
2718
5113
|
sessionpipe tail [--session ID] [--tier N] [--harness NAME]
|
|
2719
5114
|
sessionpipe backfill [--days 30] | forget <harness> <session> | replay --sink NAME
|
|
2720
5115
|
sessionpipe update
|
|
5116
|
+
${CONTROL_HELP}
|
|
2721
5117
|
|
|
2722
5118
|
Docs: https://sessionpipe.org \xB7 nothing leaves this machine until you add a sink.`);
|
|
2723
5119
|
}
|
|
2724
5120
|
async function install() {
|
|
2725
5121
|
if (viaNpx) {
|
|
2726
|
-
out(` Installing sessionpipe@${
|
|
5122
|
+
out(` Installing sessionpipe@${VERSION3} globally, so the hooks start in milliseconds\u2026`);
|
|
2727
5123
|
try {
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
5124
|
+
let prefix = "";
|
|
5125
|
+
try {
|
|
5126
|
+
prefix = execFileSync6("npm", ["prefix", "-g"], {
|
|
5127
|
+
encoding: "utf8",
|
|
5128
|
+
timeout: 15e3,
|
|
5129
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
5130
|
+
}).trim();
|
|
5131
|
+
} catch {
|
|
5132
|
+
}
|
|
5133
|
+
const target = /\/Cellar\/node(?:@\d+)?\/[^/]+/.test(prefix) ? path18.join(os7.homedir(), ".local") : prefix;
|
|
5134
|
+
execFileSync6("npm", ["install", "-g", `sessionpipe@${VERSION3}`, ...target ? ["--prefix", target] : []], {
|
|
5135
|
+
stdio: ["ignore", "ignore", "inherit"]
|
|
5136
|
+
});
|
|
5137
|
+
const bin = process.platform === "win32" ? path18.join(target, "sessionpipe.cmd") : target ? path18.join(target, "bin", "sessionpipe") : "sessionpipe";
|
|
5138
|
+
if (target && target !== prefix)
|
|
5139
|
+
out(` Installed into ${target}; make sure ${path18.join(target, "bin")} is on your PATH.`);
|
|
5140
|
+
execFileSync6(bin, args, { stdio: "inherit", shell: process.platform === "win32" });
|
|
2731
5141
|
return;
|
|
2732
5142
|
} catch {
|
|
2733
5143
|
out(
|
|
@@ -2739,6 +5149,8 @@ async function install() {
|
|
|
2739
5149
|
const cfg = readConfig();
|
|
2740
5150
|
const machine = flag("--machine");
|
|
2741
5151
|
if (machine) cfg.machine = machine.slice(0, 80);
|
|
5152
|
+
const sinkUrl = flag("--sink");
|
|
5153
|
+
if (sinkUrl) await sinkAdd(sinkUrl, cfg);
|
|
2742
5154
|
const adapters = selectedAdapters();
|
|
2743
5155
|
if (!adapters.length) {
|
|
2744
5156
|
out(
|
|
@@ -2746,25 +5158,14 @@ async function install() {
|
|
|
2746
5158
|
);
|
|
2747
5159
|
return;
|
|
2748
5160
|
}
|
|
2749
|
-
out(` Machine: ${machineName(cfg,
|
|
2750
|
-
const
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
` ${r.changed ? "\u2713" : "="} ${a.name}: ${tilde(r.file)}${r.note ? ` \u2014 ${r.note}` : r.changed ? " written" : " already current"}`
|
|
2757
|
-
);
|
|
2758
|
-
if (a.name === "gemini-cli")
|
|
2759
|
-
out(" note: Gemini CLI's own telemetry defaults logPrompts on; that is Google's setting, not sessionpipe's.");
|
|
2760
|
-
if (a.name === "codex")
|
|
2761
|
-
out(
|
|
2762
|
-
" note: Codex runs non-managed hooks only after you trust them: open Codex and run /hooks once. The notify fallback reports turn ends meanwhile."
|
|
2763
|
-
);
|
|
2764
|
-
}
|
|
5161
|
+
out(` Machine: ${machineName(cfg, os7.hostname())}`);
|
|
5162
|
+
const lean = isLean(cfg);
|
|
5163
|
+
if (lean)
|
|
5164
|
+
out(
|
|
5165
|
+
" No sink above tier 0: hooking one tool event per harness (the heartbeat's). `sink add \u2026 --tier 1` or higher hooks the rest."
|
|
5166
|
+
);
|
|
5167
|
+
installHarnesses(adapters, cfg, lean);
|
|
2765
5168
|
writeConfig(cfg);
|
|
2766
|
-
const sinkUrl = flag("--sink");
|
|
2767
|
-
if (sinkUrl) await sinkAdd(sinkUrl, cfg);
|
|
2768
5169
|
const days = Number(flag("--backfill") ?? 30);
|
|
2769
5170
|
if (days > 0) backfillRows(adapters, days, cfg);
|
|
2770
5171
|
out("");
|
|
@@ -2776,10 +5177,41 @@ async function install() {
|
|
|
2776
5177
|
);
|
|
2777
5178
|
out(" Sessions already running pick up hooks on their next start (Claude Code \u2265 2.1.280 picks them up live).");
|
|
2778
5179
|
}
|
|
5180
|
+
function isLean(cfg) {
|
|
5181
|
+
return !cfg.sinks.some((s) => !s.paused && Math.min(s.tier, s.max_tier ?? 3) >= 1);
|
|
5182
|
+
}
|
|
5183
|
+
function installHarnesses(adapters, cfg, lean) {
|
|
5184
|
+
const hc = hookCommand();
|
|
5185
|
+
for (const a of adapters) {
|
|
5186
|
+
const prev = cfg.harnesses[a.name] ?? {};
|
|
5187
|
+
const created = new Set(prev.created ?? []);
|
|
5188
|
+
const reports = a.install(hc, process.env, { lean, created: [...created] });
|
|
5189
|
+
for (const r of reports) {
|
|
5190
|
+
if (r.created) created.add(r.file);
|
|
5191
|
+
const mark = r.skipped ? "!" : r.changed ? "\u2713" : "=";
|
|
5192
|
+
out(
|
|
5193
|
+
` ${mark} ${a.name}: ${tilde(r.file)}${r.note ? ` \u2014 ${r.note}` : r.changed ? " written" : " already current"}`
|
|
5194
|
+
);
|
|
5195
|
+
}
|
|
5196
|
+
cfg.harnesses[a.name] = { enabled: true, ...created.size ? { created: [...created] } : {} };
|
|
5197
|
+
if (a.name === "gemini-cli")
|
|
5198
|
+
out(" note: Gemini CLI's own telemetry defaults logPrompts on; that is Google's setting, not sessionpipe's.");
|
|
5199
|
+
if (a.name === "codex" && !reports.some((r) => r.skipped))
|
|
5200
|
+
out(
|
|
5201
|
+
" note: Codex runs non-managed hooks only after you approve them: start `codex` and accept the hook review. The notify fallback reports turn ends meanwhile."
|
|
5202
|
+
);
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
2779
5205
|
function uninstall() {
|
|
5206
|
+
const cfg = readConfig();
|
|
2780
5207
|
for (const a of ADAPTERS) {
|
|
2781
|
-
|
|
5208
|
+
const created = cfg.harnesses[a.name]?.created ?? [];
|
|
5209
|
+
for (const r of a.uninstall(process.env, { created }))
|
|
5210
|
+
if (r.changed) out(` \u2713 ${a.name}: ${r.note ?? `hooks removed from ${tilde(r.file)}`}`);
|
|
5211
|
+
else if (r.skipped) out(` ! ${a.name}: ${tilde(r.file)} \u2014 ${r.note}`);
|
|
5212
|
+
delete cfg.harnesses[a.name];
|
|
2782
5213
|
}
|
|
5214
|
+
writeConfig(cfg);
|
|
2783
5215
|
if (!has("--keep-state"))
|
|
2784
5216
|
out(
|
|
2785
5217
|
` Outbox and state kept under ${tilde(state)} (delete it yourself, or pass nothing: it prunes after ${readConfig().keep_days ?? 30} days).`
|
|
@@ -2810,27 +5242,30 @@ async function sink() {
|
|
|
2810
5242
|
if (!s) return out(` no sink named ${args[2]}`);
|
|
2811
5243
|
const [sk] = buildSinks([s]);
|
|
2812
5244
|
if (!sk) return out(" sink type not supported yet");
|
|
2813
|
-
const
|
|
2814
|
-
|
|
2815
|
-
{
|
|
2816
|
-
|
|
2817
|
-
session: { id: `test-${Date.now()}`, seq: 0, machine: machineName(cfg, os3.hostname()) }
|
|
2818
|
-
}
|
|
5245
|
+
const session = { id: `test-${Date.now()}`, machine: machineName(cfg, os7.hostname()) };
|
|
5246
|
+
const mk = (type, seq) => makeEvent(
|
|
5247
|
+
{ type, harnessEvent: "test", data: {} },
|
|
5248
|
+
{ harness: { name: "sessionpipe" }, session: { ...session, seq } }
|
|
2819
5249
|
);
|
|
2820
|
-
const r = await sk.send([
|
|
5250
|
+
const r = await sk.send([
|
|
5251
|
+
filterEvent(mk("session.heartbeat", 0), sk.tier, sk.pii),
|
|
5252
|
+
filterEvent(mk("session.forgotten", 1), sk.tier, sk.pii)
|
|
5253
|
+
]);
|
|
2821
5254
|
out(
|
|
2822
|
-
r.ok ? ` \u2713 ${s.name}: ${r.status ?? "ok"} accepted ${r.accepted ??
|
|
5255
|
+
r.ok ? ` \u2713 ${s.name}: ${r.status ?? "ok"} accepted ${r.accepted ?? 2} (a heartbeat and its forget)` : ` \u2717 ${s.name}: ${r.status ?? ""} ${r.error ?? ""} (${r.action})`
|
|
2823
5256
|
);
|
|
2824
5257
|
return;
|
|
2825
5258
|
}
|
|
2826
5259
|
out(
|
|
2827
|
-
"usage: sessionpipe sink add <url|file:PATH|stdout> [--tier N] [--token T] [--pii] [--
|
|
5260
|
+
"usage: sessionpipe sink add <url|file:PATH|stdout> [--tier N] [--token T] [--pii] [--name N] | list | remove <name> | test <name>"
|
|
2828
5261
|
);
|
|
2829
5262
|
}
|
|
2830
5263
|
async function sinkAdd(url, cfg) {
|
|
2831
5264
|
const tier = Math.max(0, Math.min(3, Number(flag("--tier") ?? 0)));
|
|
2832
5265
|
const name = flag("--name") ?? (url === "stdout" ? "stdout" : url.startsWith("file:") ? "file" : new URL(url).host);
|
|
2833
|
-
|
|
5266
|
+
if (has("--control"))
|
|
5267
|
+
out(" --control is ignored: control is set up per machine (`sessionpipe control pair <url>`, spec/CONTROL.md)");
|
|
5268
|
+
const s = { name, url, tier, pii: has("--pii") };
|
|
2834
5269
|
const token = flag("--token");
|
|
2835
5270
|
if (token) s.token = token;
|
|
2836
5271
|
const secret = flag("--secret");
|
|
@@ -2847,10 +5282,6 @@ async function sinkAdd(url, cfg) {
|
|
|
2847
5282
|
}
|
|
2848
5283
|
if (wk.endpoints) s.endpoints = wk.endpoints;
|
|
2849
5284
|
s.well_known_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2850
|
-
if (s.control && !(wk.capabilities ?? []).includes("control")) {
|
|
2851
|
-
out(" receiver does not list `control`; --control ignored");
|
|
2852
|
-
s.control = false;
|
|
2853
|
-
}
|
|
2854
5285
|
} catch (e) {
|
|
2855
5286
|
out(
|
|
2856
5287
|
` ! ${url} has no readable /.well-known/sessionpipe (${e.message}); added anyway, at tier ${tier}`
|
|
@@ -2859,9 +5290,7 @@ async function sinkAdd(url, cfg) {
|
|
|
2859
5290
|
}
|
|
2860
5291
|
cfg.sinks = cfg.sinks.filter((x) => x.name !== name).concat([s]);
|
|
2861
5292
|
writeConfig(cfg);
|
|
2862
|
-
out(
|
|
2863
|
-
` \u2713 sink ${name}: ${url} at tier ${Math.min(tier, s.max_tier ?? 3)}${s.pii ? ", pii" : ""}${s.control ? ", control" : ""}`
|
|
2864
|
-
);
|
|
5293
|
+
out(` \u2713 sink ${name}: ${url} at tier ${Math.min(tier, s.max_tier ?? 3)}${s.pii ? ", pii" : ""}`);
|
|
2865
5294
|
out(` tier ${tier} sends: ${TIER_TEXT[Math.min(tier, s.max_tier ?? 3)]}`);
|
|
2866
5295
|
}
|
|
2867
5296
|
var TIER_TEXT = {
|
|
@@ -2874,7 +5303,7 @@ function backfillRows(adapters, days, cfg) {
|
|
|
2874
5303
|
const since = Date.now() - days * 864e5;
|
|
2875
5304
|
const outbox = new Outbox(state);
|
|
2876
5305
|
let n = 0;
|
|
2877
|
-
const machine = machineName(cfg,
|
|
5306
|
+
const machine = machineName(cfg, os7.hostname());
|
|
2878
5307
|
for (const a of adapters) {
|
|
2879
5308
|
for (const row of a.backfill(since)) {
|
|
2880
5309
|
const ref = { harness: a.name, session: row.session.id };
|
|
@@ -2918,7 +5347,7 @@ async function forget() {
|
|
|
2918
5347
|
{ type: "session.forgotten", harnessEvent: "forget", data: {} },
|
|
2919
5348
|
{
|
|
2920
5349
|
harness: { name: harness },
|
|
2921
|
-
session: { id: session, seq: outbox.nextSeq(ref), machine: machineName(cfg,
|
|
5350
|
+
session: { id: session, seq: outbox.nextSeq(ref), machine: machineName(cfg, os7.hostname()) }
|
|
2922
5351
|
}
|
|
2923
5352
|
);
|
|
2924
5353
|
const sinks = buildSinks(cfg.sinks);
|
|
@@ -2943,10 +5372,13 @@ async function replay() {
|
|
|
2943
5372
|
function status() {
|
|
2944
5373
|
const cfg = readConfig();
|
|
2945
5374
|
const hc = hookCommand();
|
|
2946
|
-
out(` machine: ${machineName(cfg,
|
|
5375
|
+
out(` machine: ${machineName(cfg, os7.hostname())} sessionpipe ${VERSION3} state ${tilde(state)}`);
|
|
2947
5376
|
for (const a of ADAPTERS) {
|
|
2948
5377
|
if (!a.detect() && !cfg.harnesses[a.name]) continue;
|
|
2949
|
-
for (const r of a.installed(hc
|
|
5378
|
+
for (const r of a.installed(hc, process.env, { lean: isLean(cfg) }))
|
|
5379
|
+
out(
|
|
5380
|
+
` ${a.name.padEnd(12)} ${tilde(r.file).padEnd(44)} ${r.state}${r.state !== "current" && r.note ? ` \u2014 ${r.note}` : ""}`
|
|
5381
|
+
);
|
|
2950
5382
|
}
|
|
2951
5383
|
if (!cfg.sinks.length) out(" sinks: none (events stay in the local outbox)");
|
|
2952
5384
|
for (const s of cfg.sinks) {
|
|
@@ -2956,7 +5388,7 @@ function status() {
|
|
|
2956
5388
|
for (const ref of outbox.sessions()) {
|
|
2957
5389
|
const f = outbox.file(ref);
|
|
2958
5390
|
try {
|
|
2959
|
-
pending += Math.max(0,
|
|
5391
|
+
pending += Math.max(0, statSync9(f).size - (c[`${ref.harness}/${ref.session}`] ?? 0)) > 0 ? 1 : 0;
|
|
2960
5392
|
} catch {
|
|
2961
5393
|
}
|
|
2962
5394
|
}
|
|
@@ -2965,17 +5397,52 @@ function status() {
|
|
|
2965
5397
|
);
|
|
2966
5398
|
}
|
|
2967
5399
|
const t = timing();
|
|
2968
|
-
if (t)
|
|
5400
|
+
if (t)
|
|
5401
|
+
out(
|
|
5402
|
+
` hook: in-process p50 ${t.p50} ms over ${t.n} runs (\`sessionpipe doctor\` measures the wall clock the harness waits)`
|
|
5403
|
+
);
|
|
2969
5404
|
const jobs = jobsDir(state);
|
|
2970
5405
|
try {
|
|
2971
|
-
const n =
|
|
5406
|
+
const n = readdirSync8(jobs).length;
|
|
2972
5407
|
if (n) out(` jobs: ${n} waiting`);
|
|
2973
5408
|
} catch {
|
|
2974
5409
|
}
|
|
2975
5410
|
}
|
|
5411
|
+
function hookWall(runs = 8) {
|
|
5412
|
+
const hook = path18.join(distDir, "hook.js");
|
|
5413
|
+
const tmp = mkdtempSync(path18.join(os7.tmpdir(), "sessionpipe-doctor-"));
|
|
5414
|
+
const env = {
|
|
5415
|
+
...process.env,
|
|
5416
|
+
SESSIONPIPE_STATE: tmp,
|
|
5417
|
+
SESSIONPIPE_CONFIG: path18.join(tmp, "config.json"),
|
|
5418
|
+
SESSIONPIPE_NO_WORKER: "1"
|
|
5419
|
+
};
|
|
5420
|
+
const input = JSON.stringify({
|
|
5421
|
+
session_id: "doctor",
|
|
5422
|
+
hook_event_name: "PostToolUse",
|
|
5423
|
+
tool_name: "Bash",
|
|
5424
|
+
cwd: process.cwd()
|
|
5425
|
+
});
|
|
5426
|
+
const times = [];
|
|
5427
|
+
try {
|
|
5428
|
+
for (let i = 0; i < runs; i++) {
|
|
5429
|
+
const t0 = process.hrtime.bigint();
|
|
5430
|
+
const r = spawnSync(process.execPath, [hook, "claude-code", "PostToolUse"], { input, env, encoding: "utf8" });
|
|
5431
|
+
if (r.status !== 0) return null;
|
|
5432
|
+
times.push(Number(process.hrtime.bigint() - t0) / 1e6);
|
|
5433
|
+
}
|
|
5434
|
+
} catch {
|
|
5435
|
+
return null;
|
|
5436
|
+
} finally {
|
|
5437
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
5438
|
+
}
|
|
5439
|
+
times.sort((a, b) => a - b);
|
|
5440
|
+
const q = (p) => Math.round(times[Math.min(times.length - 1, Math.floor(p * times.length))] ?? 0);
|
|
5441
|
+
return { n: times.length, p50: q(0.5), p95: q(0.95) };
|
|
5442
|
+
}
|
|
2976
5443
|
function timing() {
|
|
2977
5444
|
try {
|
|
2978
|
-
const lines =
|
|
5445
|
+
const lines = readFileSync11(path18.join(state, "timing.jsonl"), "utf8").trim().split("\n").slice(-100);
|
|
2979
5446
|
const ms = lines.map((l) => JSON.parse(l).ms).filter((x) => x >= 0).sort((a, b) => a - b);
|
|
2980
5447
|
if (!ms.length) return null;
|
|
2981
5448
|
const q = (p) => Math.round(ms[Math.min(ms.length - 1, Math.floor(p * ms.length))] ?? 0);
|
|
@@ -2988,15 +5455,16 @@ function doctor() {
|
|
|
2988
5455
|
const cfg = readConfig();
|
|
2989
5456
|
const hc = hookCommand();
|
|
2990
5457
|
const report = {
|
|
2991
|
-
version:
|
|
5458
|
+
version: VERSION3,
|
|
2992
5459
|
node: process.version,
|
|
2993
5460
|
node_path: stableNode(process.execPath),
|
|
2994
|
-
machine: machineName(cfg,
|
|
5461
|
+
machine: machineName(cfg, os7.hostname()),
|
|
2995
5462
|
state,
|
|
2996
5463
|
config: configFile(),
|
|
2997
5464
|
harnesses: {},
|
|
2998
5465
|
sinks: [],
|
|
2999
5466
|
timing: timing(),
|
|
5467
|
+
hook_wall_ms: hookWall(),
|
|
3000
5468
|
warnings: []
|
|
3001
5469
|
};
|
|
3002
5470
|
const warnings = report.warnings;
|
|
@@ -3006,8 +5474,15 @@ function doctor() {
|
|
|
3006
5474
|
);
|
|
3007
5475
|
for (const a of ADAPTERS) {
|
|
3008
5476
|
if (!a.detect()) continue;
|
|
3009
|
-
const st = a.installed(hc);
|
|
3010
|
-
report.harnesses[a.name] = st.map((r) => ({
|
|
5477
|
+
const st = a.installed(hc, process.env, { lean: isLean(cfg) });
|
|
5478
|
+
report.harnesses[a.name] = st.map((r) => ({
|
|
5479
|
+
file: tilde(r.file),
|
|
5480
|
+
state: r.state,
|
|
5481
|
+
...r.note ? { note: r.note } : {}
|
|
5482
|
+
}));
|
|
5483
|
+
for (const r of st)
|
|
5484
|
+
if (r.state === "inactive")
|
|
5485
|
+
warnings.push(`${a.name}: hooks are written but the harness will not run them \u2014 ${r.note ?? ""}`);
|
|
3011
5486
|
for (const r of st)
|
|
3012
5487
|
if (r.state === "stale")
|
|
3013
5488
|
warnings.push(
|
|
@@ -3030,14 +5505,16 @@ function doctor() {
|
|
|
3030
5505
|
paused: s.paused ?? null,
|
|
3031
5506
|
token: s.token ? `${s.token.slice(0, 4)}\u2026` : null
|
|
3032
5507
|
});
|
|
3033
|
-
const
|
|
3034
|
-
if (
|
|
3035
|
-
warnings.push(
|
|
5508
|
+
const w = report.hook_wall_ms;
|
|
5509
|
+
if (w && w.p50 > 150)
|
|
5510
|
+
warnings.push(
|
|
5511
|
+
`the harness waits ${w.p50} ms per hook (p50, target < 150): that is Node's own startup on this machine. A lean install (no sink above tier 0) hooks fewer events.`
|
|
5512
|
+
);
|
|
3036
5513
|
if (has("--json")) {
|
|
3037
5514
|
out(JSON.stringify(report, null, 2));
|
|
3038
5515
|
return;
|
|
3039
5516
|
}
|
|
3040
|
-
out(` sessionpipe ${
|
|
5517
|
+
out(` sessionpipe ${VERSION3} \xB7 node ${process.version} at ${report.node_path}`);
|
|
3041
5518
|
out(` config ${tilde(configFile())} \xB7 state ${tilde(state)}`);
|
|
3042
5519
|
for (const [h, st] of Object.entries(report.harnesses))
|
|
3043
5520
|
for (const r of st) out(` ${h.padEnd(12)} ${r.file.padEnd(44)} ${r.state}`);
|
|
@@ -3045,8 +5522,14 @@ function doctor() {
|
|
|
3045
5522
|
out(
|
|
3046
5523
|
` sink ${s.name.padEnd(16)} tier ${s.tier}${s.max_tier !== null ? ` (receiver max ${s.max_tier})` : ""}${s.paused ? ` PAUSED ${s.paused}` : ""}`
|
|
3047
5524
|
);
|
|
3048
|
-
|
|
3049
|
-
|
|
5525
|
+
const t = report.timing;
|
|
5526
|
+
out(
|
|
5527
|
+
w ? ` hook wall clock (what the harness waits): p50 ${w.p50} ms \xB7 p95 ${w.p95} ms over ${w.n} spawns` : " hook wall clock: could not spawn dist/hook.js"
|
|
5528
|
+
);
|
|
5529
|
+
out(
|
|
5530
|
+
t ? ` hook in-process: p50 ${t.p50} ms \xB7 p95 ${t.p95} ms over the last ${t.n} runs` : " hook in-process: no runs yet"
|
|
5531
|
+
);
|
|
5532
|
+
for (const w2 of warnings) out(` ! ${w2}`);
|
|
3050
5533
|
if (!warnings.length) out(" no warnings");
|
|
3051
5534
|
}
|
|
3052
5535
|
async function tail() {
|
|
@@ -3095,7 +5578,7 @@ async function tail() {
|
|
|
3095
5578
|
out(`sessionpipe tail \xB7 tier ${tier} \xB7 ${tilde(state)}/outbox (Ctrl-C to stop)`);
|
|
3096
5579
|
for (const ref of outbox.sessions()) {
|
|
3097
5580
|
try {
|
|
3098
|
-
offsets.set(`${ref.harness}/${ref.session}`, has("--all") ? 0 :
|
|
5581
|
+
offsets.set(`${ref.harness}/${ref.session}`, has("--all") ? 0 : statSync9(outbox.file(ref)).size);
|
|
3099
5582
|
} catch {
|
|
3100
5583
|
}
|
|
3101
5584
|
}
|
|
@@ -3118,9 +5601,9 @@ async function tail() {
|
|
|
3118
5601
|
async function workerFromArgv() {
|
|
3119
5602
|
const f = args[1];
|
|
3120
5603
|
if (!f) return;
|
|
3121
|
-
const job = JSON.parse(
|
|
5604
|
+
const job = JSON.parse(readFileSync11(f, "utf8"));
|
|
3122
5605
|
try {
|
|
3123
|
-
|
|
5606
|
+
unlinkSync7(f);
|
|
3124
5607
|
} catch {
|
|
3125
5608
|
}
|
|
3126
5609
|
await runJob(job);
|
|
@@ -3131,15 +5614,15 @@ async function update() {
|
|
|
3131
5614
|
const r = await fetch("https://registry.npmjs.org/sessionpipe/latest", { signal: AbortSignal.timeout(8e3) });
|
|
3132
5615
|
const j = await r.json();
|
|
3133
5616
|
if (!j.version) throw new Error("no version");
|
|
3134
|
-
if (j.version ===
|
|
3135
|
-
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`);
|
|
3136
5619
|
const prefix = prefixOf(distDir);
|
|
3137
|
-
|
|
5620
|
+
execFileSync6("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
|
|
3138
5621
|
stdio: "inherit"
|
|
3139
5622
|
});
|
|
3140
5623
|
out(" re-arming hooks\u2026");
|
|
3141
|
-
const bin = prefix ?
|
|
3142
|
-
const p =
|
|
5624
|
+
const bin = prefix ? path18.join(prefix, "bin", "sessionpipe") : "sessionpipe";
|
|
5625
|
+
const p = spawn2(bin, ["install", "--backfill", "0"], { stdio: "inherit" });
|
|
3143
5626
|
await new Promise((r2) => p.on("exit", r2));
|
|
3144
5627
|
} catch (e) {
|
|
3145
5628
|
out(` couldn't check npm: ${e.message}`);
|