sessionpipe 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // sessionpipe 0.5.0 · Apache-2.0 · https://sessionpipe.org
2
+ // sessionpipe 0.6.0 · Apache-2.0 · https://sessionpipe.org
3
3
  var __defProp = Object.defineProperty;
4
4
  var __export = (target, all) => {
5
5
  for (var name in all)
@@ -7,10 +7,10 @@ var __export = (target, all) => {
7
7
  };
8
8
 
9
9
  // src/cli.ts
10
- import { execFileSync as execFileSync7, spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
11
- import { mkdtempSync, readdirSync as readdirSync8, readFileSync as readFileSync12, realpathSync as realpathSync5, rmSync as rmSync2, statSync as statSync9, unlinkSync as unlinkSync7 } from "node:fs";
10
+ import { execFileSync as execFileSync7, spawnSync as spawnSync2 } from "node:child_process";
11
+ import { mkdtempSync, readdirSync as readdirSync8, readFileSync as readFileSync13, realpathSync as realpathSync6, rmSync as rmSync2, statSync as statSync9, unlinkSync as unlinkSync7 } from "node:fs";
12
12
  import os9 from "node:os";
13
- import path21 from "node:path";
13
+ import path23 from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
 
16
16
  // ../core/src/adapters/antigravity.ts
@@ -2942,10 +2942,21 @@ function connectTier(flag2, current) {
2942
2942
  return Math.max(2, Math.min(3, current ?? 0));
2943
2943
  }
2944
2944
 
2945
- // src/control/cli.ts
2946
- import { existsSync as existsSync12, realpathSync as realpathSync4, watchFile } from "node:fs";
2947
- import os7 from "node:os";
2948
- import path19 from "node:path";
2945
+ // src/control/autoupdate.ts
2946
+ import { spawn as spawn3 } from "node:child_process";
2947
+ import { readFileSync as readFileSync11 } from "node:fs";
2948
+ import path18 from "node:path";
2949
+
2950
+ // src/update.ts
2951
+ import { spawn as spawn2 } from "node:child_process";
2952
+ import { accessSync as accessSync2, constants as constants2, existsSync as existsSync9, realpathSync as realpathSync3 } from "node:fs";
2953
+ import path16 from "node:path";
2954
+
2955
+ // src/runtime.ts
2956
+ import { execFileSync as execFileSync5 } from "node:child_process";
2957
+ import { realpathSync as realpathSync2 } from "node:fs";
2958
+ import os5 from "node:os";
2959
+ import path15 from "node:path";
2949
2960
 
2950
2961
  // src/run.ts
2951
2962
  import {
@@ -3218,7 +3229,7 @@ async function tellDaemon(state2, job, session, transcript) {
3218
3229
  300
3219
3230
  );
3220
3231
  }
3221
- var VERSION = true ? "0.5.0" : "0.0.0";
3232
+ var VERSION = true ? "0.6.0" : "0.0.0";
3222
3233
  function enqueueJob(state2, job) {
3223
3234
  const jobs = jobsDir(state2);
3224
3235
  mkdirSync7(jobs, { recursive: true, mode: 448 });
@@ -3457,10 +3468,6 @@ function applySinkVerdict(name, res) {
3457
3468
  var jobsDir = (state2) => path14.join(state2, "jobs");
3458
3469
 
3459
3470
  // src/runtime.ts
3460
- import { execFileSync as execFileSync5 } from "node:child_process";
3461
- import { realpathSync as realpathSync2 } from "node:fs";
3462
- import os5 from "node:os";
3463
- import path15 from "node:path";
3464
3471
  function stableNode(exe) {
3465
3472
  const m = /^(.*)\/Cellar\/(node(?:@\d+)?)\/[^/]+\/bin\/node$/.exec(exe);
3466
3473
  if (!m) return exe;
@@ -3514,9 +3521,171 @@ function rerunGlobally(argv, out2) {
3514
3521
  }
3515
3522
  }
3516
3523
 
3517
- // src/control/daemon.ts
3518
- import { existsSync as existsSync10, mkdirSync as mkdirSync9, realpathSync as realpathSync3, statSync as statSync8, unlinkSync as unlinkSync5 } from "node:fs";
3519
- import net2 from "node:net";
3524
+ // src/update.ts
3525
+ var REGISTRY_LATEST = "https://registry.npmjs.org/sessionpipe/latest";
3526
+ var VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
3527
+ function parse2(v) {
3528
+ const m = VERSION_RE.exec(String(v).trim());
3529
+ if (!m) return null;
3530
+ return { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
3531
+ }
3532
+ function compareVersions(a, b) {
3533
+ const x = parse2(a);
3534
+ const y = parse2(b);
3535
+ if (!x || !y) return null;
3536
+ for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] - y.n[i];
3537
+ if (x.pre === y.pre) return 0;
3538
+ if (x.pre === null) return 1;
3539
+ if (y.pre === null) return -1;
3540
+ return x.pre.localeCompare(y.pre, "en", { numeric: true });
3541
+ }
3542
+ function isNewer(a, b) {
3543
+ const x = parse2(a);
3544
+ if (!x || x.pre !== null) return false;
3545
+ const c = compareVersions(a, b);
3546
+ return c !== null && c > 0;
3547
+ }
3548
+ async function latestVersion(f = fetch, timeoutMs = 8e3) {
3549
+ const r = await f(REGISTRY_LATEST, {
3550
+ signal: AbortSignal.timeout(timeoutMs),
3551
+ headers: { accept: "application/json" }
3552
+ });
3553
+ if (!r.ok) throw new Error(`the npm registry answered HTTP ${r.status}`);
3554
+ const j = await r.json();
3555
+ if (typeof j.version !== "string" || !parse2(j.version)) throw new Error("the npm registry named no version");
3556
+ return j.version;
3557
+ }
3558
+ function prefixOf(distDir2, platform = process.platform) {
3559
+ const re = platform === "win32" ? /^(.*?)[\\/]node_modules[\\/]sessionpipe[\\/]/ : /^(.*)\/lib\/node_modules\/sessionpipe\//;
3560
+ return re.exec(`${distDir2}/`)?.[1] ?? null;
3561
+ }
3562
+ function globalPrefix(distDir2, platform = process.platform) {
3563
+ if (viaNpx(distDir2)) return null;
3564
+ const prefix = prefixOf(distDir2, platform);
3565
+ if (!prefix) return null;
3566
+ const bin = platform === "win32" ? path16.join(prefix, "sessionpipe.cmd") : path16.join(prefix, "bin", "sessionpipe");
3567
+ return existsSync9(bin) ? prefix : null;
3568
+ }
3569
+ function unwritable(prefix, platform = process.platform) {
3570
+ const dir = platform === "win32" ? path16.join(prefix, "node_modules") : path16.join(prefix, "lib", "node_modules");
3571
+ try {
3572
+ accessSync2(dir, constants2.W_OK);
3573
+ return null;
3574
+ } catch {
3575
+ return `${dir} isn't writable by this user (installed with sudo?)`;
3576
+ }
3577
+ }
3578
+ function npmArgv(node, platform = process.platform) {
3579
+ const dir = path16.dirname(node);
3580
+ const candidates = platform === "win32" ? [path16.join(dir, "node_modules", "npm", "bin", "npm-cli.js")] : [path16.join(dir, "npm"), path16.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js")];
3581
+ for (const c of candidates) {
3582
+ try {
3583
+ const real = realpathSync3(c);
3584
+ if (real.endsWith(".js")) return [node, real];
3585
+ } catch {
3586
+ }
3587
+ }
3588
+ return [platform === "win32" ? "npm.cmd" : "npm"];
3589
+ }
3590
+ var execCapture = (argv, o) => new Promise((resolve) => {
3591
+ const [cmd2, ...rest] = argv;
3592
+ let stdout = "";
3593
+ let stderr = "";
3594
+ const keep = (s, b) => (s + b.toString("utf8")).slice(-16e3);
3595
+ let child;
3596
+ try {
3597
+ child = spawn2(cmd2, rest, {
3598
+ env: o.env,
3599
+ stdio: ["ignore", "pipe", "pipe"],
3600
+ windowsHide: true,
3601
+ shell: process.platform === "win32" && cmd2.endsWith(".cmd")
3602
+ });
3603
+ } catch (e) {
3604
+ resolve({ code: null, stdout, stderr, error: e.message });
3605
+ return;
3606
+ }
3607
+ const t = setTimeout(() => child.kill("SIGTERM"), o.timeoutMs);
3608
+ child.stdout?.on("data", (b) => {
3609
+ stdout = keep(stdout, b);
3610
+ });
3611
+ child.stderr?.on("data", (b) => {
3612
+ stderr = keep(stderr, b);
3613
+ });
3614
+ child.on("error", (e) => {
3615
+ clearTimeout(t);
3616
+ resolve({ code: null, stdout, stderr, error: e.message });
3617
+ });
3618
+ child.on("close", (code) => {
3619
+ clearTimeout(t);
3620
+ resolve({ code, stdout, stderr });
3621
+ });
3622
+ });
3623
+ var lastLine = (r) => (r.error || r.stderr.trim().split("\n").filter(Boolean).slice(-2).join(" ") || `exit ${r.code}`).slice(0, 300);
3624
+ async function installedVersion(o) {
3625
+ const r = await (o.exec ?? execCapture)([o.node, path16.join(o.distDir, "cli.js"), "--version"], {
3626
+ env: o.env ?? process.env,
3627
+ timeoutMs: 3e4
3628
+ });
3629
+ const v = r.stdout.trim();
3630
+ return r.code === 0 && parse2(v) ? v : null;
3631
+ }
3632
+ async function installVersion(version, o) {
3633
+ const t0 = Date.now();
3634
+ const platform = o.platform ?? process.platform;
3635
+ const exec = o.exec ?? execCapture;
3636
+ const base = o.env ?? process.env;
3637
+ const env = { ...base, PATH: [path16.dirname(o.node), base.PATH].filter(Boolean).join(path16.delimiter) };
3638
+ const prefix = globalPrefix(o.distDir, platform);
3639
+ if (!prefix)
3640
+ return { ok: false, ms: Date.now() - t0, stage: "prefix", error: "this copy isn't a global npm install" };
3641
+ const npm = npmArgv(o.node, platform);
3642
+ const r = await exec(
3643
+ [
3644
+ ...npm,
3645
+ "install",
3646
+ "--global",
3647
+ "--prefix",
3648
+ prefix,
3649
+ "--ignore-scripts",
3650
+ "--no-audit",
3651
+ "--no-fund",
3652
+ `sessionpipe@${version}`
3653
+ ],
3654
+ { env, timeoutMs: 5 * 6e4 }
3655
+ );
3656
+ if (r.code !== 0) return { ok: false, ms: Date.now() - t0, stage: "npm", error: `npm install: ${lastLine(r)}` };
3657
+ const now = await installedVersion({ node: o.node, distDir: o.distDir, env, exec });
3658
+ if (now !== version)
3659
+ return {
3660
+ ok: false,
3661
+ ms: Date.now() - t0,
3662
+ stage: "verify",
3663
+ error: `npm finished, but the copy in ${prefix} says ${now ?? "nothing"}, not ${version}`
3664
+ };
3665
+ let hooks = "no harness set up here; nothing to re-arm";
3666
+ if (o.harnesses.length) {
3667
+ const h = await exec(
3668
+ [o.node, path16.join(o.distDir, "cli.js"), "install", "--backfill", "0", ...o.harnesses.map((n) => `--${n}`)],
3669
+ { env, timeoutMs: 2 * 6e4 }
3670
+ );
3671
+ hooks = h.code === 0 ? `hooks re-armed for ${o.harnesses.join(", ")}` : `re-arming the hooks failed (${lastLine(h)}); \`sessionpipe install\` does it`;
3672
+ }
3673
+ return { ok: true, ms: Date.now() - t0, hooks };
3674
+ }
3675
+
3676
+ // src/control/store.ts
3677
+ import {
3678
+ appendFileSync as appendFileSync4,
3679
+ chmodSync as chmodSync2,
3680
+ closeSync as closeSync4,
3681
+ existsSync as existsSync10,
3682
+ fsyncSync,
3683
+ mkdirSync as mkdirSync8,
3684
+ openSync as openSync4,
3685
+ readFileSync as readFileSync10,
3686
+ renameSync as renameSync3,
3687
+ writeFileSync as writeFileSync7
3688
+ } from "node:fs";
3520
3689
  import path17 from "node:path";
3521
3690
 
3522
3691
  // ../core/src/control/verify.ts
@@ -3853,122 +4022,11 @@ async function checkGrantPath(s, c, o) {
3853
4022
  return { ok: true, key: key.id };
3854
4023
  }
3855
4024
 
3856
- // src/control/reply.ts
3857
- import { closeSync as closeSync4, openSync as openSync4, readSync as readSync4, statSync as statSync7 } from "node:fs";
3858
- function transcriptEnd(file) {
3859
- try {
3860
- return statSync7(file).size;
3861
- } catch {
3862
- return 0;
3863
- }
3864
- }
3865
- function answerIn(lines) {
3866
- const parts = [];
3867
- for (const line of lines) {
3868
- let j;
3869
- try {
3870
- j = JSON.parse(line);
3871
- } catch {
3872
- continue;
3873
- }
3874
- if (j.type !== "assistant" || j.isSidechain) continue;
3875
- const content = j.message?.content;
3876
- if (Array.isArray(content)) {
3877
- for (const c of content)
3878
- if (c?.type === "text" && typeof c.text === "string" && c.text.trim()) parts.push(c.text.trim());
3879
- }
3880
- if (j.message?.stop_reason === "end_turn") return { done: true, text: parts.join("\n\n") };
3881
- }
3882
- return { done: false, text: parts.join("\n\n") };
3883
- }
3884
- async function awaitAnswer(file, from, o) {
3885
- const now = o.now ?? Date.now;
3886
- const sleep2 = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref?.()));
3887
- const until = now() + o.timeoutMs;
3888
- let offset = from;
3889
- let partial = "";
3890
- const lines = [];
3891
- while (now() < until) {
3892
- const size = transcriptEnd(file);
3893
- if (size < offset) return null;
3894
- if (size > offset) {
3895
- const fd = openSync4(file, "r");
3896
- try {
3897
- const buf = Buffer.alloc(Math.min(size - offset, 4 * 1024 * 1024));
3898
- const n = readSync4(fd, buf, 0, buf.length, offset);
3899
- offset += n;
3900
- const chunk = partial + buf.subarray(0, n).toString("utf8");
3901
- const cut = chunk.lastIndexOf("\n");
3902
- partial = cut < 0 ? chunk : chunk.slice(cut + 1);
3903
- if (cut >= 0) lines.push(...chunk.slice(0, cut).split("\n").filter(Boolean));
3904
- } finally {
3905
- closeSync4(fd);
3906
- }
3907
- const a = answerIn(lines);
3908
- if (a.done) return a.text || null;
3909
- if (a.text) o.onText?.(a.text);
3910
- }
3911
- await sleep2(o.pollMs ?? 500);
3912
- }
3913
- return null;
3914
- }
3915
-
3916
- // src/control/route.ts
3917
- function routePrompt(s) {
3918
- if (s.sdk) return { mode: "sdk" };
3919
- if (s.waiter) return { mode: "waiter" };
3920
- if (s.midTurn) return { mode: "turn" };
3921
- if (s.api) return { mode: "api" };
3922
- if (s.harness !== "claude-code") return { unsupported: `no idle delivery for ${s.harness} yet` };
3923
- if (!s.known) return { refused: "no_session" };
3924
- if (!s.live) return { mode: "resume" };
3925
- if (s.canFork) return { mode: "fork" };
3926
- return { unsupported: "the session is open and this Claude Code can't copy it (no --fork-session)" };
3927
- }
3928
- function kindPath(kind, harness) {
3929
- switch (kind) {
3930
- case "prompt":
3931
- return "prompt";
3932
- case "permission.answer":
3933
- return harness === "gemini-cli" ? { unsupported: "Gemini CLI has no permission answer channel" } : "permission";
3934
- case "cancel":
3935
- return "cancel";
3936
- case "start":
3937
- return harness === "claude-code" ? "start" : { unsupported: `can't start a ${harness} session` };
3938
- default:
3939
- return { unsupported: `unknown kind ${String(kind)}` };
3940
- }
3941
- }
3942
- 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.";
3943
- function frame(text, receiver) {
3944
- let host = receiver;
3945
- try {
3946
- host = new URL(receiver).host;
3947
- } catch {
3948
- }
3949
- return `Message via ${host} (sessionpipe control, signed on the sender's device):
3950
-
3951
- ${text}`;
3952
- }
3953
-
3954
4025
  // src/control/store.ts
3955
- import {
3956
- appendFileSync as appendFileSync4,
3957
- chmodSync as chmodSync2,
3958
- closeSync as closeSync5,
3959
- existsSync as existsSync9,
3960
- fsyncSync,
3961
- mkdirSync as mkdirSync8,
3962
- openSync as openSync5,
3963
- readFileSync as readFileSync10,
3964
- renameSync as renameSync3,
3965
- writeFileSync as writeFileSync7
3966
- } from "node:fs";
3967
- import path16 from "node:path";
3968
- var controlFile = (env = process.env) => path16.join(path16.dirname(configFile(env)), "control.json");
3969
- var controlState = (env = process.env) => path16.join(stateDir(env), "control");
4026
+ var controlFile = (env = process.env) => path17.join(path17.dirname(configFile(env)), "control.json");
4027
+ var controlState = (env = process.env) => path17.join(stateDir(env), "control");
3970
4028
  function writePrivate(file, value) {
3971
- mkdirSync8(path16.dirname(file), { recursive: true, mode: 448 });
4029
+ mkdirSync8(path17.dirname(file), { recursive: true, mode: 448 });
3972
4030
  const tmp = `${file}.${process.pid}.tmp`;
3973
4031
  writeFileSync7(tmp, `${JSON.stringify(value, null, 2)}
3974
4032
  `, { mode: 384 });
@@ -3995,6 +4053,14 @@ function readControl(env = process.env, run = defaultRunner) {
3995
4053
  return null;
3996
4054
  }
3997
4055
  }
4056
+ function pairedCount(env = process.env) {
4057
+ try {
4058
+ const j = JSON.parse(readFileSync10(controlFile(env), "utf8"));
4059
+ return Array.isArray(j.receivers) ? j.receivers.length : 0;
4060
+ } catch {
4061
+ return 0;
4062
+ }
4063
+ }
3998
4064
  var writeControl = (c, env = process.env) => writePrivate(controlFile(env), {
3999
4065
  ...c,
4000
4066
  receivers: c.receivers.map((r) => {
@@ -4060,14 +4126,14 @@ var NonceStore = class {
4060
4126
  const t = this.seenAt.get(nonce);
4061
4127
  if (t !== void 0 && t > this.now() - NONCE_TTL_MS) return true;
4062
4128
  const at = this.now();
4063
- mkdirSync8(path16.dirname(this.file), { recursive: true, mode: 448 });
4064
- const fd = openSync5(this.file, "a", 384);
4129
+ mkdirSync8(path17.dirname(this.file), { recursive: true, mode: 448 });
4130
+ const fd = openSync4(this.file, "a", 384);
4065
4131
  try {
4066
4132
  appendFileSync4(fd, `${JSON.stringify({ n: nonce, t: at })}
4067
4133
  `);
4068
4134
  fsyncSync(fd);
4069
4135
  } finally {
4070
- closeSync5(fd);
4136
+ closeSync4(fd);
4071
4137
  }
4072
4138
  this.seenAt.set(nonce, at);
4073
4139
  return false;
@@ -4076,9 +4142,9 @@ var NonceStore = class {
4076
4142
  compact() {
4077
4143
  const cut = this.now() - NONCE_TTL_MS;
4078
4144
  for (const [n, t] of this.seenAt) if (t <= cut) this.seenAt.delete(n);
4079
- if (!existsSync9(this.file) && !this.seenAt.size) return;
4145
+ if (!existsSync10(this.file) && !this.seenAt.size) return;
4080
4146
  const body = [...this.seenAt].map(([n, t]) => JSON.stringify({ n, t })).join("\n");
4081
- mkdirSync8(path16.dirname(this.file), { recursive: true, mode: 448 });
4147
+ mkdirSync8(path17.dirname(this.file), { recursive: true, mode: 448 });
4082
4148
  writeFileSync7(`${this.file}.tmp`, body ? `${body}
4083
4149
  ` : "", { mode: 384 });
4084
4150
  renameSync3(`${this.file}.tmp`, this.file);
@@ -4100,12 +4166,12 @@ var AckOutbox = class {
4100
4166
  }
4101
4167
  }
4102
4168
  add(a) {
4103
- mkdirSync8(path16.dirname(this.file), { recursive: true, mode: 448 });
4169
+ mkdirSync8(path17.dirname(this.file), { recursive: true, mode: 448 });
4104
4170
  appendFileSync4(this.file, `${JSON.stringify(a)}
4105
4171
  `, { mode: 384 });
4106
4172
  }
4107
4173
  keep(left) {
4108
- mkdirSync8(path16.dirname(this.file), { recursive: true, mode: 448 });
4174
+ mkdirSync8(path17.dirname(this.file), { recursive: true, mode: 448 });
4109
4175
  writeFileSync7(`${this.file}.tmp`, left.map((a) => JSON.stringify(a)).join("\n") + (left.length ? "\n" : ""), {
4110
4176
  mode: 384
4111
4177
  });
@@ -4122,6 +4188,424 @@ function readState(file) {
4122
4188
  }
4123
4189
  var writeState = (file, s) => writePrivate(file, s);
4124
4190
 
4191
+ // src/control/autoupdate.ts
4192
+ var UPDATE_EXIT = 75;
4193
+ var DAY_MS = 24 * 36e5;
4194
+ var LABEL = "org.sessionpipe.control";
4195
+ var UNIT = "sessionpipe-control.service";
4196
+ var updateFile = (env = process.env) => path18.join(controlState(env), "update.json");
4197
+ function readUpdate(file) {
4198
+ try {
4199
+ const j = JSON.parse(readFileSync11(file, "utf8"));
4200
+ return j && typeof j === "object" ? j : {};
4201
+ } catch {
4202
+ return {};
4203
+ }
4204
+ }
4205
+ function isDue(rec, now) {
4206
+ const t = Date.parse(rec.checked_at ?? "");
4207
+ if (!Number.isFinite(t)) return true;
4208
+ if (t > now + 5 * 6e4) return true;
4209
+ return now - t >= DAY_MS;
4210
+ }
4211
+ function autoUpdateOff(o) {
4212
+ if (o.env.SESSIONPIPE_NO_UPDATE_CHECK) return "SESSIONPIPE_NO_UPDATE_CHECK is set";
4213
+ if (o.config.update_check === false) return "update_check is false in config.json (`sessionpipe update on`)";
4214
+ const prefix = globalPrefix(o.distDir, o.platform);
4215
+ if (!prefix) return "this copy isn't a global npm install (npx, a checkout or a dev build)";
4216
+ return unwritable(prefix, o.platform);
4217
+ }
4218
+ function updateSummary(o) {
4219
+ const daemonEnv = o.rec.off?.startsWith("SESSIONPIPE_NO_UPDATE_CHECK") ? "SESSIONPIPE_NO_UPDATE_CHECK is set in the daemon's environment" : null;
4220
+ const off2 = o.off ?? (o.paired ? daemonEnv : "it runs in the control daemon, which isn't set up here (`sessionpipe connect`)");
4221
+ return {
4222
+ auto: !off2,
4223
+ off: off2,
4224
+ last_check: o.rec.checked_at ?? null,
4225
+ latest: o.rec.latest ?? null,
4226
+ last_error: o.rec.error ?? null,
4227
+ pending: o.rec.pending ?? null,
4228
+ updated: o.rec.updated ?? null,
4229
+ failed: o.rec.failed ?? null
4230
+ };
4231
+ }
4232
+ function ago(iso, now) {
4233
+ const ms = now - Date.parse(iso);
4234
+ if (!Number.isFinite(ms)) return "at an unreadable time";
4235
+ if (ms < 9e4) return "just now";
4236
+ if (ms < 90 * 6e4) return `${Math.round(ms / 6e4)} min ago`;
4237
+ if (ms < 36 * 36e5) return `${Math.round(ms / 36e5)} h ago`;
4238
+ return `${Math.round(ms / DAY_MS)} days ago`;
4239
+ }
4240
+ function updateLine(s, now) {
4241
+ const parts = [
4242
+ s.auto ? "automatic (the control daemon checks npm once a day and installs when idle; `sessionpipe update off` stops it)" : `not automatic: ${s.off}`
4243
+ ];
4244
+ if (s.last_check)
4245
+ parts.push(
4246
+ s.last_error ? `last check ${ago(s.last_check, now)} failed: ${s.last_error}` : `last check ${ago(s.last_check, now)}: latest ${s.latest ?? "?"}`
4247
+ );
4248
+ else if (s.auto) parts.push("not checked yet (a minute after the daemon starts)");
4249
+ if (s.pending) parts.push(`${s.pending} waits for the daemon to be idle`);
4250
+ if (s.failed) parts.push(`installing ${s.failed.version} failed ${ago(s.failed.at, now)}: ${s.failed.error}`);
4251
+ else if (s.updated) parts.push(`updated ${s.updated.from} \u2192 ${s.updated.to} ${ago(s.updated.at, now)}`);
4252
+ return parts.join(" \xB7 ");
4253
+ }
4254
+ function restartVia(o) {
4255
+ const s = o.env.SESSIONPIPE_SERVICE ?? "";
4256
+ if (s.startsWith("supervisor:") && s === `supervisor:${o.ppid}`) return "supervisor";
4257
+ if (o.platform === "darwin" && o.ppid === 1 && (s === "launchd" || o.env.XPC_SERVICE_NAME === LABEL))
4258
+ return "launchd";
4259
+ if (o.platform === "linux") {
4260
+ let cg = "";
4261
+ try {
4262
+ cg = (o.cgroup ?? (() => readFileSync11("/proc/self/cgroup", "utf8")))();
4263
+ } catch {
4264
+ }
4265
+ const inUnit = cg.split("\n").some((l) => l.endsWith(`/${UNIT}`) || l.includes(`/${UNIT}/`));
4266
+ const mainPid = !o.env.SYSTEMD_EXEC_PID || o.env.SYSTEMD_EXEC_PID === String(o.pid);
4267
+ if (inUnit && mainPid) return "systemd";
4268
+ }
4269
+ return null;
4270
+ }
4271
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
4272
+ var AutoUpdater = class {
4273
+ constructor(d) {
4274
+ this.d = d;
4275
+ }
4276
+ d;
4277
+ first = null;
4278
+ every = null;
4279
+ stopped = false;
4280
+ busyWith = null;
4281
+ lastOff = void 0;
4282
+ start() {
4283
+ if (this.record().pending) this.save({ pending: null });
4284
+ this.first = setTimeout(() => void this.tick(), this.d.firstCheckMs ?? 6e4);
4285
+ this.first.unref?.();
4286
+ this.every = setInterval(() => void this.tick(), this.d.tickMs ?? 10 * 6e4);
4287
+ this.every.unref?.();
4288
+ }
4289
+ stop() {
4290
+ this.stopped = true;
4291
+ if (this.first) clearTimeout(this.first);
4292
+ if (this.every) clearInterval(this.every);
4293
+ }
4294
+ record() {
4295
+ return readUpdate(this.d.file);
4296
+ }
4297
+ save(patch) {
4298
+ try {
4299
+ writePrivate(this.d.file, { ...readUpdate(this.d.file), ...patch });
4300
+ } catch {
4301
+ }
4302
+ }
4303
+ /** One look at the clock: check when a check is due and nothing is under way. */
4304
+ async tick() {
4305
+ if (this.stopped || this.busyWith) return;
4306
+ const off2 = this.d.off();
4307
+ if (off2 !== this.lastOff) {
4308
+ if (off2) this.d.log(`auto-update off: ${off2}`);
4309
+ else if (this.lastOff) this.d.log("auto-update on");
4310
+ this.lastOff = off2;
4311
+ this.save({ off: off2 });
4312
+ }
4313
+ if (off2) return;
4314
+ const rec = this.record();
4315
+ if (isDue(rec, this.d.now())) return this.check();
4316
+ const known = rec.latest;
4317
+ if (known && isNewer(known, this.d.version) && rec.failed?.version !== known)
4318
+ return this.guard(() => this.apply(known));
4319
+ }
4320
+ /** One update job at a time; whatever it throws is logged, never left unhandled. */
4321
+ guard(job) {
4322
+ if (this.busyWith) return this.busyWith;
4323
+ this.busyWith = job().catch((e) => {
4324
+ if (!this.stopped) this.d.log(`update: ${String(e?.message || e)}`);
4325
+ }).finally(() => {
4326
+ this.busyWith = null;
4327
+ });
4328
+ return this.busyWith;
4329
+ }
4330
+ /** Ask npm now; when it has a newer release, install it once idle and restart. */
4331
+ check() {
4332
+ return this.guard(async () => {
4333
+ const t0 = this.d.now();
4334
+ let latest;
4335
+ try {
4336
+ latest = await this.d.latest();
4337
+ } catch (e) {
4338
+ const error = String(e?.message || e).slice(0, 200);
4339
+ this.save({ checked_at: new Date(t0).toISOString(), latest: null, error, running: this.d.version });
4340
+ this.d.log(`update check: failed after ${this.d.now() - t0} ms: ${error}`);
4341
+ return;
4342
+ }
4343
+ const ms = this.d.now() - t0;
4344
+ this.save({ checked_at: new Date(t0).toISOString(), latest, error: null, running: this.d.version });
4345
+ if (!isNewer(latest, this.d.version)) {
4346
+ this.save({ pending: null });
4347
+ this.d.log(`update check: ${this.d.version} is current (npm latest ${latest}, ${ms} ms)`);
4348
+ return;
4349
+ }
4350
+ this.d.log(`update check: ${latest} is out (running ${this.d.version}, ${ms} ms); updating once idle`);
4351
+ await this.apply(latest);
4352
+ });
4353
+ }
4354
+ /** Restart onto the copy on disk once idle (`sessionpipe update` installed it). */
4355
+ restartWhenIdle() {
4356
+ if (this.stopped) return false;
4357
+ if (this.busyWith) return true;
4358
+ void this.guard(async () => {
4359
+ const onDisk = await this.d.installed();
4360
+ if (!onDisk || onDisk === this.d.version) {
4361
+ this.d.log(`restart asked: the copy on disk is ${onDisk ?? "unreadable"}, the one running; staying`);
4362
+ return;
4363
+ }
4364
+ await this.quiesce(onDisk, false);
4365
+ await this.resumeOnError(async () => {
4366
+ this.d.log(`restart: ${this.d.version} \u2192 ${onDisk} (installed by hand)`);
4367
+ this.save({ updated: { from: this.d.version, to: onDisk, at: new Date(this.d.now()).toISOString(), ms: 0 } });
4368
+ await this.d.restart(this.d.version, onDisk);
4369
+ });
4370
+ });
4371
+ return true;
4372
+ }
4373
+ /** Wait until the daemon has nothing in hand, then stop its intake. A message that
4374
+ * slipped in between is let through and waited for. An automatic update turned off
4375
+ * meanwhile (`sessionpipe update off`) stops waiting. */
4376
+ async quiesce(target, auto = true) {
4377
+ let said = false;
4378
+ for (; ; ) {
4379
+ let why = this.d.daemon.busy();
4380
+ while (why) {
4381
+ if (this.stopped) throw new Error("stopped");
4382
+ const off2 = auto ? this.d.off() : null;
4383
+ if (off2) {
4384
+ this.save({ pending: null });
4385
+ throw new Error(`${target} not installed: auto-update was turned off (${off2})`);
4386
+ }
4387
+ if (!said) {
4388
+ this.d.log(`update: ${target} waits until the daemon is idle (${why})`);
4389
+ said = true;
4390
+ }
4391
+ await sleep(this.d.idlePollMs ?? 15e3);
4392
+ why = this.d.daemon.busy();
4393
+ }
4394
+ this.d.daemon.pauseIntake();
4395
+ if (!this.d.daemon.busy()) return;
4396
+ this.d.daemon.resumeIntake();
4397
+ }
4398
+ }
4399
+ /** Intake is paused from quiesce() on: whatever goes wrong after it lets it go. */
4400
+ async resumeOnError(fn) {
4401
+ try {
4402
+ await fn();
4403
+ } catch (e) {
4404
+ this.d.daemon.resumeIntake();
4405
+ throw e;
4406
+ }
4407
+ }
4408
+ async apply(target) {
4409
+ this.save({ pending: target });
4410
+ await this.quiesce(target);
4411
+ await this.resumeOnError(() => this.installAndRestart(target));
4412
+ }
4413
+ async installAndRestart(target) {
4414
+ const from = this.d.version;
4415
+ const t0 = this.d.now();
4416
+ const onDisk = await this.d.installed();
4417
+ let note = "";
4418
+ const c = onDisk ? compareVersions(onDisk, target) : null;
4419
+ if (onDisk && c !== null && c >= 0) {
4420
+ target = onDisk;
4421
+ note = " (already on disk)";
4422
+ } else {
4423
+ const r = await this.d.install(target);
4424
+ if (!r.ok) {
4425
+ this.d.daemon.resumeIntake();
4426
+ const ms2 = this.d.now() - t0;
4427
+ this.save({
4428
+ pending: null,
4429
+ failed: { version: target, at: new Date(this.d.now()).toISOString(), error: r.error }
4430
+ });
4431
+ this.d.log(`update: ${from} \u2192 ${target} failed after ${ms2} ms: ${r.error}; still running ${from}`);
4432
+ return;
4433
+ }
4434
+ if (r.hooks) note = `; ${r.hooks}`;
4435
+ }
4436
+ const ms = this.d.now() - t0;
4437
+ if (this.d.daemon.busy()) {
4438
+ this.d.daemon.resumeIntake();
4439
+ await this.quiesce(target, false);
4440
+ }
4441
+ this.save({
4442
+ pending: null,
4443
+ failed: null,
4444
+ updated: { from, to: target, at: new Date(this.d.now()).toISOString(), ms }
4445
+ });
4446
+ this.d.log(`update: ${from} \u2192 ${target} in ${ms} ms${note}; restarting`);
4447
+ await this.d.restart(from, target);
4448
+ }
4449
+ };
4450
+ function supervise(o) {
4451
+ const run = o.spawn ?? spawn3;
4452
+ const exit = o.exit ?? ((c) => process.exit(c));
4453
+ const env = { ...o.env, SESSIONPIPE_SERVICE: `supervisor:${process.pid}` };
4454
+ let child = null;
4455
+ let stopping = false;
4456
+ let later = null;
4457
+ const start = () => {
4458
+ later = null;
4459
+ const c = run(o.node, o.args, { env, stdio: "inherit", windowsHide: true });
4460
+ child = c;
4461
+ let done = false;
4462
+ const ended = (code, signal) => {
4463
+ if (done) return;
4464
+ done = true;
4465
+ child = null;
4466
+ if (stopping) return exit(code ?? 0);
4467
+ if (code === UPDATE_EXIT) {
4468
+ o.log("the daemon is restarting onto its new version");
4469
+ return start();
4470
+ }
4471
+ if (code === 0) return exit(0);
4472
+ o.log(`the daemon exited (${signal ?? `code ${code}`}); starting it again in 5 s`);
4473
+ later = setTimeout(start, o.failDelayMs ?? 5e3);
4474
+ };
4475
+ c.on("exit", ended);
4476
+ c.on("error", (e) => {
4477
+ o.log(`couldn't start the daemon: ${e.message}`);
4478
+ ended(1, null);
4479
+ });
4480
+ };
4481
+ const stop = (sig = "SIGTERM") => {
4482
+ stopping = true;
4483
+ if (later) clearTimeout(later);
4484
+ if (child) child.kill(sig);
4485
+ else exit(0);
4486
+ };
4487
+ start();
4488
+ return { stop };
4489
+ }
4490
+
4491
+ // src/control/cli.ts
4492
+ import { existsSync as existsSync13, realpathSync as realpathSync5, unwatchFile, watchFile } from "node:fs";
4493
+ import os7 from "node:os";
4494
+ import path21 from "node:path";
4495
+
4496
+ // src/control/daemon.ts
4497
+ import { existsSync as existsSync11, mkdirSync as mkdirSync9, realpathSync as realpathSync4, statSync as statSync8, unlinkSync as unlinkSync5 } from "node:fs";
4498
+ import net2 from "node:net";
4499
+ import path19 from "node:path";
4500
+
4501
+ // src/control/reply.ts
4502
+ import { closeSync as closeSync5, openSync as openSync5, readSync as readSync4, statSync as statSync7 } from "node:fs";
4503
+ function transcriptEnd(file) {
4504
+ try {
4505
+ return statSync7(file).size;
4506
+ } catch {
4507
+ return 0;
4508
+ }
4509
+ }
4510
+ function answerIn(lines) {
4511
+ const parts = [];
4512
+ for (const line of lines) {
4513
+ let j;
4514
+ try {
4515
+ j = JSON.parse(line);
4516
+ } catch {
4517
+ continue;
4518
+ }
4519
+ if (j.type !== "assistant" || j.isSidechain) continue;
4520
+ const content = j.message?.content;
4521
+ if (Array.isArray(content)) {
4522
+ for (const c of content)
4523
+ if (c?.type === "text" && typeof c.text === "string" && c.text.trim()) parts.push(c.text.trim());
4524
+ }
4525
+ if (j.message?.stop_reason === "end_turn") return { done: true, text: parts.join("\n\n") };
4526
+ }
4527
+ return { done: false, text: parts.join("\n\n") };
4528
+ }
4529
+ async function awaitAnswer(file, from, o) {
4530
+ const now = o.now ?? Date.now;
4531
+ const sleep3 = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref?.()));
4532
+ const until = now() + o.timeoutMs;
4533
+ let offset = from;
4534
+ let partial = "";
4535
+ const lines = [];
4536
+ while (now() < until) {
4537
+ const size = transcriptEnd(file);
4538
+ if (size < offset) return null;
4539
+ if (size > offset) {
4540
+ const fd = openSync5(file, "r");
4541
+ try {
4542
+ const buf = Buffer.alloc(Math.min(size - offset, 4 * 1024 * 1024));
4543
+ const n = readSync4(fd, buf, 0, buf.length, offset);
4544
+ offset += n;
4545
+ const chunk = partial + buf.subarray(0, n).toString("utf8");
4546
+ const cut = chunk.lastIndexOf("\n");
4547
+ partial = cut < 0 ? chunk : chunk.slice(cut + 1);
4548
+ if (cut >= 0) lines.push(...chunk.slice(0, cut).split("\n").filter(Boolean));
4549
+ } finally {
4550
+ closeSync5(fd);
4551
+ }
4552
+ const a = answerIn(lines);
4553
+ if (a.done) return a.text || null;
4554
+ if (a.text) o.onText?.(a.text);
4555
+ }
4556
+ await sleep3(o.pollMs ?? 500);
4557
+ }
4558
+ return null;
4559
+ }
4560
+
4561
+ // src/control/route.ts
4562
+ function routePrompt(s) {
4563
+ if (s.sdk) return { mode: "sdk" };
4564
+ if (s.waiter) return { mode: "waiter" };
4565
+ if (s.midTurn) return { mode: "turn" };
4566
+ if (s.api) return { mode: "api" };
4567
+ if (s.harness !== "claude-code") return { unsupported: `no idle delivery for ${s.harness} yet` };
4568
+ if (!s.known) return { refused: "no_session" };
4569
+ if (!s.live) return { mode: "resume" };
4570
+ if (s.canFork) return { mode: "fork" };
4571
+ return { unsupported: "the session is open and this Claude Code can't copy it (no --fork-session)" };
4572
+ }
4573
+ function kindPath(kind, harness) {
4574
+ switch (kind) {
4575
+ case "prompt":
4576
+ return "prompt";
4577
+ case "permission.answer":
4578
+ return harness === "gemini-cli" ? { unsupported: "Gemini CLI has no permission answer channel" } : "permission";
4579
+ case "cancel":
4580
+ return "cancel";
4581
+ case "start":
4582
+ return harness === "claude-code" ? "start" : { unsupported: `can't start a ${harness} session` };
4583
+ default:
4584
+ return { unsupported: `unknown kind ${String(kind)}` };
4585
+ }
4586
+ }
4587
+ 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.";
4588
+ function frame(text, receiver) {
4589
+ let host = receiver;
4590
+ try {
4591
+ host = new URL(receiver).host;
4592
+ } catch {
4593
+ }
4594
+ return `Message via ${host} (sessionpipe control, signed on the sender's device):
4595
+
4596
+ ${text}`;
4597
+ }
4598
+ function frameStart(text, receiver) {
4599
+ let host = receiver;
4600
+ try {
4601
+ host = new URL(receiver).host;
4602
+ } catch {
4603
+ }
4604
+ return `${text}
4605
+
4606
+ (Sent via ${host} with sessionpipe control, signed on the sender's device.)`;
4607
+ }
4608
+
4125
4609
  // src/control/stream.ts
4126
4610
  function streamJsonPiece(line) {
4127
4611
  let j;
@@ -4212,7 +4696,7 @@ var {
4212
4696
  recentlyWritten: recentlyWritten2,
4213
4697
  sessionFolder: sessionFolder2
4214
4698
  } = claude_code_control_exports;
4215
- var VERSION2 = true ? "0.5.0" : "0.0.0";
4699
+ var VERSION2 = true ? "0.6.0" : "0.0.0";
4216
4700
  var MAX_REPLY = 2e4;
4217
4701
  var HELLO_EVERY_MS = 10 * 6e4;
4218
4702
  var TURN_FALLBACK_MS = 30 * 6e4;
@@ -4226,6 +4710,15 @@ var CLOSES_ATTENTION = /* @__PURE__ */ new Set([
4226
4710
  "UserPromptSubmit",
4227
4711
  "SessionEnd"
4228
4712
  ]);
4713
+ var DaemonRunning = class extends Error {
4714
+ constructor(other) {
4715
+ super(
4716
+ `another control daemon already serves this machine (${other.pid ? `pid ${other.pid}, ` : ""}sessionpipe ${other.version ?? "?"})`
4717
+ );
4718
+ this.other = other;
4719
+ }
4720
+ other;
4721
+ };
4229
4722
  function sessionToolsFrom(v) {
4230
4723
  if (!Array.isArray(v)) return [];
4231
4724
  return [
@@ -4239,9 +4732,9 @@ var ControlDaemon = class {
4239
4732
  this.dir = dir;
4240
4733
  this.sock = sock;
4241
4734
  this.deps = deps;
4242
- this.nonces = new NonceStore(path17.join(dir, "nonces.jsonl"), deps.now);
4243
- this.acks = new AckOutbox(path17.join(dir, "acks.jsonl"));
4244
- this.state = readState(path17.join(dir, "state.json"));
4735
+ this.nonces = new NonceStore(path19.join(dir, "nonces.jsonl"), deps.now);
4736
+ this.acks = new AckOutbox(path19.join(dir, "acks.jsonl"));
4737
+ this.state = readState(path19.join(dir, "state.json"));
4245
4738
  }
4246
4739
  cfg;
4247
4740
  dir;
@@ -4276,6 +4769,17 @@ var ControlDaemon = class {
4276
4769
  procWaiters = [];
4277
4770
  /** configDir → the last login answer and when (a yes is kept 10 minutes, a no 30 s). */
4278
4771
  logins = /* @__PURE__ */ new Map();
4772
+ /** Intake paused for an update: the long-polls end and wait for this. */
4773
+ paused = null;
4774
+ /** Poll answers whose messages are being read and handed out. */
4775
+ receiving = 0;
4776
+ /** The socket file this daemon created (dev:ino): stop() removes only that one, and a
4777
+ * different file at the path means another daemon took over. */
4778
+ sockId = null;
4779
+ ownTimer = null;
4780
+ /** Every open local connection: stop() ends them, or the server's close would wait
4781
+ * on a client that never says anything. */
4782
+ conns = /* @__PURE__ */ new Set();
4279
4783
  // --- lifecycle ------------------------------------------------------------------
4280
4784
  async start() {
4281
4785
  this.running = true;
@@ -4288,24 +4792,97 @@ var ControlDaemon = class {
4288
4792
  void this.flushAcks();
4289
4793
  const idle = setInterval(() => this.closeIdleSdk(), 6e4);
4290
4794
  idle.unref();
4291
- this.deps.log(`control daemon ${VERSION2} up \xB7 ${this.cfg.receivers.length} receiver(s) \xB7 socket ${this.sock}`);
4795
+ if (this.sockId) {
4796
+ this.ownTimer = setInterval(() => void this.checkSocket(), 1e4);
4797
+ this.ownTimer.unref();
4798
+ }
4799
+ this.deps.log(
4800
+ `control daemon ${VERSION2} up \xB7 pid ${process.pid} \xB7 ${this.cfg.receivers.length} receiver(s) \xB7 socket ${this.sock}`
4801
+ );
4292
4802
  }
4293
- async stop() {
4803
+ /** `leaveSocket`: the socket path is another daemon's now. Closing a Unix-socket
4804
+ * server makes Node unlink its path whoever holds it, so this one's server is left
4805
+ * open for the process's exit to drop. */
4806
+ async stop(o = {}) {
4294
4807
  this.running = false;
4808
+ this.paused?.go();
4295
4809
  for (const a of this.aborts) a.abort();
4296
4810
  if (this.helloTimer) clearInterval(this.helloTimer);
4297
4811
  if (this.helloSoon) clearTimeout(this.helloSoon);
4812
+ if (this.ownTimer) clearInterval(this.ownTimer);
4298
4813
  for (const s of this.sdkSessions.values()) s.close();
4299
4814
  for (const l of this.live.values()) {
4300
4815
  l.waiter?.destroy();
4301
4816
  for (const a of l.attentions.values()) a.destroy();
4302
4817
  }
4818
+ for (const c of this.conns) c.destroy();
4819
+ if (o.leaveSocket) {
4820
+ this.sockId = null;
4821
+ return;
4822
+ }
4303
4823
  await new Promise((r) => this.server ? this.server.close(() => r()) : r());
4304
- if (process.platform !== "win32")
4824
+ this.server = null;
4825
+ if (process.platform !== "win32" && this.sockId && socketId(this.sock) === this.sockId)
4305
4826
  try {
4306
4827
  unlinkSync5(this.sock);
4307
4828
  } catch {
4308
4829
  }
4830
+ this.sockId = null;
4831
+ }
4832
+ /** Null when nothing is in hand, so a restart would interrupt nothing; otherwise what
4833
+ * is. Parked waiters don't count: a waiter reconnects in silence (CONTROL.md §8). A
4834
+ * session someone is working in mid-turn does (a restart forgets it is mid-turn, so a
4835
+ * message to it would fork rather than wait for its Stop), unless it has been silent
4836
+ * for ten minutes: a session killed mid-turn never sends its Stop. */
4837
+ busy() {
4838
+ if (this.inflight.size) return `${this.inflight.size} message(s) in hand`;
4839
+ if (this.procs || this.ownRuns.size) return "a headless Claude Code run";
4840
+ if (this.startsRunning) return "a session starting";
4841
+ for (const s of this.sdkSessions.values()) if (s.busy) return "an Agent SDK session mid-turn";
4842
+ for (const l of this.live.values()) if (l.attentions.size) return "a permission prompt waiting for an answer";
4843
+ const now = this.deps.now();
4844
+ for (const l of this.live.values())
4845
+ if (l.midTurn && now - l.lastEvent < 10 * 6e4) return "a session on this machine mid-turn";
4846
+ if (this.receiving) return "messages just received";
4847
+ if (this.flushing) return "acks being sent";
4848
+ return null;
4849
+ }
4850
+ /** Stop taking messages: every long-poll ends now and waits for resumeIntake(). The
4851
+ * receiver keeps what arrives meanwhile; the local socket keeps answering. */
4852
+ pauseIntake() {
4853
+ if (this.paused) return;
4854
+ let go;
4855
+ const until = new Promise((r) => {
4856
+ go = r;
4857
+ });
4858
+ this.paused = { until, go };
4859
+ for (const a of this.aborts) a.abort();
4860
+ }
4861
+ resumeIntake() {
4862
+ const p = this.paused;
4863
+ this.paused = null;
4864
+ p?.go();
4865
+ }
4866
+ /** The socket path still holds this daemon's file. Gone: listen again. Replaced:
4867
+ * another daemon took over (two started at once), so this one steps aside. */
4868
+ async checkSocket() {
4869
+ if (!this.running || !this.sockId) return;
4870
+ const now = socketId(this.sock);
4871
+ if (now === this.sockId) return;
4872
+ if (now === null) {
4873
+ this.deps.log(`the socket ${this.sock} vanished; listening again`);
4874
+ this.server?.unref();
4875
+ this.server = null;
4876
+ try {
4877
+ await this.listen();
4878
+ } catch (e) {
4879
+ this.deps.log(`couldn't listen again: ${String(e?.message || e)}`);
4880
+ }
4881
+ return;
4882
+ }
4883
+ this.deps.log("another control daemon took over the socket; this one stops");
4884
+ await this.stop({ leaveSocket: true });
4885
+ this.deps.onLostSocket?.();
4309
4886
  }
4310
4887
  reload(cfg) {
4311
4888
  const known = new Set(this.cfg.receivers.map((r) => r.control + r.machine));
@@ -4315,6 +4892,7 @@ var ControlDaemon = class {
4315
4892
  status() {
4316
4893
  return {
4317
4894
  version: VERSION2,
4895
+ pid: process.pid,
4318
4896
  name: this.cfg.name,
4319
4897
  receivers: this.cfg.receivers.map((r) => {
4320
4898
  const p = this.polling.get(r.control + r.machine);
@@ -4333,26 +4911,35 @@ var ControlDaemon = class {
4333
4911
  sdk: [...this.sdkSessions.keys()],
4334
4912
  nonces: this.nonces.size,
4335
4913
  acks_pending: this.acks.all().length,
4336
- delivered: { ...this.delivered }
4914
+ delivered: { ...this.delivered },
4915
+ busy: this.busy(),
4916
+ ...this.deps.updateStatus ? { update: this.deps.updateStatus() } : {}
4337
4917
  };
4338
4918
  }
4339
4919
  // --- the local socket -----------------------------------------------------------
4340
- listen() {
4920
+ /** One daemon per machine: a daemon that answers on the socket keeps it, and this
4921
+ * one doesn't start. A socket file nobody answers on is a dead daemon's; it goes. */
4922
+ async listen() {
4923
+ const other = await ask(this.sock, { op: "status" }, 1500);
4924
+ if (other?.op === "status") throw new DaemonRunning(other.status ?? {});
4341
4925
  if (process.platform !== "win32") {
4342
4926
  try {
4343
- if (existsSync10(this.sock)) unlinkSync5(this.sock);
4927
+ if (existsSync11(this.sock)) unlinkSync5(this.sock);
4344
4928
  } catch {
4345
4929
  }
4346
4930
  }
4347
4931
  this.server = net2.createServer((s) => this.onConn(s));
4348
- return new Promise((resolve, reject) => {
4932
+ await new Promise((resolve, reject) => {
4349
4933
  this.server.once("error", reject);
4350
4934
  this.server.listen(this.sock, () => resolve());
4351
4935
  });
4936
+ if (process.platform !== "win32") this.sockId = socketId(this.sock);
4352
4937
  }
4353
4938
  onConn(s) {
4354
4939
  let buf = "";
4355
4940
  let handled = false;
4941
+ this.conns.add(s);
4942
+ s.on("close", () => this.conns.delete(s));
4356
4943
  s.on("error", () => {
4357
4944
  });
4358
4945
  s.on("data", (d) => {
@@ -4445,6 +5032,11 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4445
5032
  this.onEvent(req);
4446
5033
  this.reply(s, { op: "ok" });
4447
5034
  return;
5035
+ case "restart":
5036
+ this.reply(s, this.deps.onRestart?.() ? { op: "ok" } : { op: "none" });
5037
+ return;
5038
+ default:
5039
+ s.destroy();
4448
5040
  }
4449
5041
  }
4450
5042
  /** A queued turn message that hasn't expired. */
@@ -4492,6 +5084,10 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4492
5084
  for (; ; ) {
4493
5085
  const r = this.cfg.receivers.find((x) => x.control + x.machine === key);
4494
5086
  if (!this.running || !r) break;
5087
+ if (this.paused) {
5088
+ await this.paused.until;
5089
+ continue;
5090
+ }
4495
5091
  if (!r.token) {
4496
5092
  const t2 = this.deps.token?.(r) ?? null;
4497
5093
  if (t2) {
@@ -4525,16 +5121,21 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4525
5121
  break;
4526
5122
  }
4527
5123
  if (res.status === 200) {
4528
- const body = await res.json();
4529
- const cur = this.cfg.receivers.find((x) => x.control + x.machine === key) ?? r;
4530
5124
  let fresh = 0;
4531
- for (const m of body.messages ?? []) {
4532
- if (m && !this.inflight.has(m.id)) fresh++;
4533
- void this.handle(cur, m).catch(
4534
- (e) => this.deps.log(`handle ${String(m?.id).slice(-8)}: ${String(e?.message || e)}`)
4535
- );
5125
+ this.receiving++;
5126
+ try {
5127
+ const body = await res.json();
5128
+ const cur = this.cfg.receivers.find((x) => x.control + x.machine === key) ?? r;
5129
+ for (const m of body.messages ?? []) {
5130
+ if (m && !this.inflight.has(m.id)) fresh++;
5131
+ void this.handle(cur, m).catch(
5132
+ (e) => this.deps.log(`handle ${String(m?.id).slice(-8)}: ${String(e?.message || e)}`)
5133
+ );
5134
+ }
5135
+ } finally {
5136
+ this.receiving--;
4536
5137
  }
4537
- if (!fresh) await sleep(2e3);
5138
+ if (!fresh) await sleep2(2e3);
4538
5139
  st.error = null;
4539
5140
  backoff = 0;
4540
5141
  } else if (res.status === 204) {
@@ -4543,13 +5144,14 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4543
5144
  } else {
4544
5145
  st.error = `HTTP ${res.status}`;
4545
5146
  downSince ||= this.deps.now();
4546
- await sleep(pause(backoff++));
5147
+ await sleep2(pause(backoff++));
4547
5148
  }
4548
5149
  } catch (e) {
4549
5150
  if (!this.running) break;
5151
+ if (this.paused) continue;
4550
5152
  st.error = String(e?.message || e).slice(0, 120);
4551
5153
  downSince ||= this.deps.now();
4552
- await sleep(pause(backoff++));
5154
+ await sleep2(pause(backoff++));
4553
5155
  } finally {
4554
5156
  clearTimeout(t);
4555
5157
  this.aborts.delete(ctl);
@@ -4657,7 +5259,7 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4657
5259
  const added = now.slice(all.length);
4658
5260
  this.acks.keep([...left, ...added]);
4659
5261
  if (!left.length && !added.length) return;
4660
- if (left.length) await sleep(2e3 * (round + 1));
5262
+ if (left.length) await sleep2(2e3 * (round + 1));
4661
5263
  }
4662
5264
  } finally {
4663
5265
  this.flushing = false;
@@ -4730,10 +5332,10 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4730
5332
  return { folder: ok, transcript: null };
4731
5333
  }
4732
5334
  allowed(dir) {
4733
- if (!path17.isAbsolute(dir)) return null;
5335
+ if (!path19.isAbsolute(dir)) return null;
4734
5336
  let r;
4735
5337
  try {
4736
- r = realpathSync3.native(dir);
5338
+ r = realpathSync4.native(dir);
4737
5339
  if (!statSync8(r).isDirectory()) return null;
4738
5340
  } catch {
4739
5341
  return null;
@@ -4741,10 +5343,10 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4741
5343
  for (const f of this.cfg.folders) {
4742
5344
  let rf = f;
4743
5345
  try {
4744
- rf = realpathSync3.native(f);
5346
+ rf = realpathSync4.native(f);
4745
5347
  } catch {
4746
5348
  }
4747
- if (r === rf || r.startsWith(rf.endsWith(path17.sep) ? rf : rf + path17.sep)) return r;
5349
+ if (r === rf || r.startsWith(rf.endsWith(path19.sep) ? rf : rf + path19.sep)) return r;
4748
5350
  }
4749
5351
  return null;
4750
5352
  }
@@ -4929,7 +5531,7 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4929
5531
  const ran = isUuid2(out2.session) ? out2.session : target;
4930
5532
  if (mode === "fork" && ran !== target) {
4931
5533
  this.state.copies[ref.id] = ran;
4932
- writeState(path17.join(this.dir, "state.json"), this.state);
5534
+ writeState(path19.join(this.dir, "state.json"), this.state);
4933
5535
  this.lastEnd.set(ran, this.deps.now());
4934
5536
  }
4935
5537
  this.finish(
@@ -5105,7 +5707,7 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
5105
5707
  const out3 = await this.slot(async () => {
5106
5708
  const s = sdk.start({ session: ref.id, cwd, mode: this.cfg.mode, allowedTools: this.allowedFor(r) });
5107
5709
  this.sdkSessions.set(ref.id, s);
5108
- return s.send(frame(cmd2.text, r.url));
5710
+ return s.send(frameStart(cmd2.text, r.url));
5109
5711
  });
5110
5712
  return this.finish(
5111
5713
  r,
@@ -5130,7 +5732,7 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
5130
5732
  ...this.allowedFor(r).length ? [`--allowedTools=${this.allowedFor(r).join(",")}`] : [],
5131
5733
  "--session-id",
5132
5734
  ref.id,
5133
- frame(cmd2.text, r.url)
5735
+ frameStart(cmd2.text, r.url)
5134
5736
  ],
5135
5737
  {
5136
5738
  cwd,
@@ -5158,15 +5760,23 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
5158
5760
  }
5159
5761
  }
5160
5762
  };
5161
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
5763
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
5764
+ function socketId(p) {
5765
+ try {
5766
+ const st = statSync8(p);
5767
+ return `${st.dev}:${st.ino}`;
5768
+ } catch {
5769
+ return null;
5770
+ }
5771
+ }
5162
5772
 
5163
5773
  // src/control/pair.ts
5164
5774
  import { execFileSync as execFileSync6 } from "node:child_process";
5165
5775
  import { randomBytes as randomBytes3 } from "node:crypto";
5166
- import { existsSync as existsSync11, mkdirSync as mkdirSync10, unlinkSync as unlinkSync6, writeFileSync as writeFileSync8 } from "node:fs";
5776
+ import { existsSync as existsSync12, mkdirSync as mkdirSync10, unlinkSync as unlinkSync6, writeFileSync as writeFileSync8 } from "node:fs";
5167
5777
  import os6 from "node:os";
5168
- import path18 from "node:path";
5169
- var VERSION3 = true ? "0.5.0" : "0.0.0";
5778
+ import path20 from "node:path";
5779
+ var VERSION3 = true ? "0.6.0" : "0.0.0";
5170
5780
  async function json(r) {
5171
5781
  try {
5172
5782
  return await r.json();
@@ -5191,7 +5801,7 @@ async function discover(url, f) {
5191
5801
  }
5192
5802
  async function pair(o) {
5193
5803
  const f = o.fetch ?? fetch;
5194
- const sleep2 = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
5804
+ const sleep3 = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
5195
5805
  const { control, rpId, open } = await discover(o.url, f);
5196
5806
  if (!o.token && !open)
5197
5807
  throw new Error(
@@ -5201,7 +5811,7 @@ async function pair(o) {
5201
5811
  const cfg = readControl(o.env) ?? { name: o.name, folders: [], mode: o.mode, receivers: [] };
5202
5812
  cfg.name = o.name || cfg.name;
5203
5813
  cfg.mode = o.mode;
5204
- cfg.folders = [.../* @__PURE__ */ new Set([...cfg.folders, ...o.folders.map((d) => path18.resolve(d))])];
5814
+ cfg.folders = [.../* @__PURE__ */ new Set([...cfg.folders, ...o.folders.map((d) => path20.resolve(d))])];
5205
5815
  const existing = cfg.receivers.find((r) => r.control === control);
5206
5816
  const auth = { authorization: `Bearer ${o.token ?? pollKey}`, "content-type": "application/json" };
5207
5817
  const started = await f(`${control}/pair`, {
@@ -5243,7 +5853,7 @@ async function pair(o) {
5243
5853
  const until = Date.parse(String(s.expires_at)) || Date.now() + 10 * 6e4;
5244
5854
  for (; ; ) {
5245
5855
  if (Date.now() > until) throw new Error("the pairing link expired; run `sessionpipe control pair` again");
5246
- await sleep2(2e3);
5856
+ await sleep3(2e3);
5247
5857
  const r = await f(`${control}/pair?code=${encodeURIComponent(code)}`, { headers: auth }).catch(() => null);
5248
5858
  if (!r) continue;
5249
5859
  if (r.status === 404) throw new Error("the receiver lost the pairing; run `sessionpipe control pair` again");
@@ -5301,8 +5911,8 @@ function removeKey(id, env) {
5301
5911
  writeControl(cfg, env);
5302
5912
  return n;
5303
5913
  }
5304
- var LABEL = "org.sessionpipe.control";
5305
- var UNIT = "sessionpipe-control.service";
5914
+ var LABEL2 = "org.sessionpipe.control";
5915
+ var UNIT2 = "sessionpipe-control.service";
5306
5916
  function serviceFiles(o) {
5307
5917
  const home = o.home ?? os6.homedir();
5308
5918
  const xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -5310,14 +5920,15 @@ function serviceFiles(o) {
5310
5920
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
5311
5921
  <plist version="1.0">
5312
5922
  <dict>
5313
- <key>Label</key><string>${LABEL}</string>
5923
+ <key>Label</key><string>${LABEL2}</string>
5314
5924
  <key>ProgramArguments</key>
5315
5925
  <array><string>${xml(o.node)}</string><string>${xml(o.cli)}</string><string>control</string><string>run</string></array>
5926
+ <key>EnvironmentVariables</key><dict><key>SESSIONPIPE_SERVICE</key><string>launchd</string></dict>
5316
5927
  <key>RunAtLoad</key><true/>
5317
5928
  <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
5318
5929
  <key>ProcessType</key><string>Background</string>
5319
- <key>StandardOutPath</key><string>${xml(path18.join(o.logDir, "daemon.log"))}</string>
5320
- <key>StandardErrorPath</key><string>${xml(path18.join(o.logDir, "daemon.log"))}</string>
5930
+ <key>StandardOutPath</key><string>${xml(path20.join(o.logDir, "daemon.log"))}</string>
5931
+ <key>StandardErrorPath</key><string>${xml(path20.join(o.logDir, "daemon.log"))}</string>
5321
5932
  </dict>
5322
5933
  </plist>
5323
5934
  `;
@@ -5328,15 +5939,17 @@ After=network-online.target
5328
5939
 
5329
5940
  [Service]
5330
5941
  ExecStart=${q(o.node)} ${q(o.cli)} control run
5942
+ Environment=SESSIONPIPE_SERVICE=systemd
5331
5943
  Restart=on-failure
5944
+ RestartForceExitStatus=75
5332
5945
  RestartSec=5
5333
5946
 
5334
5947
  [Install]
5335
5948
  WantedBy=default.target
5336
5949
  `;
5337
5950
  return {
5338
- plist: { file: path18.join(home, "Library", "LaunchAgents", `${LABEL}.plist`), body: plist },
5339
- unit: { file: path18.join(home, ".config", "systemd", "user", UNIT), body: unit }
5951
+ plist: { file: path20.join(home, "Library", "LaunchAgents", `${LABEL2}.plist`), body: plist },
5952
+ unit: { file: path20.join(home, ".config", "systemd", "user", UNIT2), body: unit }
5340
5953
  };
5341
5954
  }
5342
5955
  var execQuiet = (cmd2, args2) => execFileSync6(cmd2, args2, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 });
@@ -5366,33 +5979,33 @@ function installService(o) {
5366
5979
  const run = o.exec ?? execQuiet;
5367
5980
  const platform = o.platform ?? process.platform;
5368
5981
  if (platform === "darwin") {
5369
- mkdirSync10(path18.dirname(f.plist.file), { recursive: true });
5982
+ mkdirSync10(path20.dirname(f.plist.file), { recursive: true });
5370
5983
  writeFileSync8(f.plist.file, f.plist.body);
5371
5984
  const uid = String(process.getuid?.() ?? "");
5372
5985
  for (const d of ["gui", "user"])
5373
5986
  try {
5374
- run("launchctl", ["bootout", `${d}/${uid}/${LABEL}`]);
5987
+ run("launchctl", ["bootout", `${d}/${uid}/${LABEL2}`]);
5375
5988
  } catch {
5376
5989
  }
5377
5990
  try {
5378
5991
  run("launchctl", ["bootstrap", `gui/${uid}`, f.plist.file]);
5379
- return `launchd agent ${LABEL} (log: ${path18.join(logDir, "daemon.log")})`;
5992
+ return `launchd agent ${LABEL2} (log: ${path20.join(logDir, "daemon.log")})`;
5380
5993
  } catch {
5381
5994
  }
5382
5995
  run("launchctl", ["bootstrap", `user/${uid}`, f.plist.file]);
5383
- return `launchd agent ${LABEL} in the background domain, since nobody is logged in at this Mac's screen; after a restart it starts again when someone logs in there (log: ${path18.join(logDir, "daemon.log")})`;
5996
+ return `launchd agent ${LABEL2} in the background domain, since nobody is logged in at this Mac's screen; after a restart it starts again when someone logs in there (log: ${path20.join(logDir, "daemon.log")})`;
5384
5997
  }
5385
5998
  if (platform === "linux") {
5386
- mkdirSync10(path18.dirname(f.unit.file), { recursive: true });
5999
+ mkdirSync10(path20.dirname(f.unit.file), { recursive: true });
5387
6000
  writeFileSync8(f.unit.file, f.unit.body);
5388
6001
  try {
5389
6002
  run("systemctl", ["--user", "daemon-reload"]);
5390
- run("systemctl", ["--user", "enable", "--now", UNIT]);
6003
+ run("systemctl", ["--user", "enable", "--now", UNIT2]);
5391
6004
  } catch {
5392
6005
  return `wrote ${f.unit.file}, but systemd --user isn't reachable here; run \`sessionpipe control run\` yourself (tmux, screen)`;
5393
6006
  }
5394
6007
  const linger = ensureLinger(os6.userInfo().username, run);
5395
- return `systemd user unit ${UNIT}; ${linger.note} (journalctl --user -u ${UNIT})`;
6008
+ return `systemd user unit ${UNIT2}; ${linger.note} (journalctl --user -u ${UNIT2})`;
5396
6009
  }
5397
6010
  return "no service manager for this platform yet; run `sessionpipe control run` yourself";
5398
6011
  }
@@ -5404,13 +6017,13 @@ function uninstallService(env) {
5404
6017
  } catch {
5405
6018
  }
5406
6019
  };
5407
- if (process.platform === "darwin" && existsSync11(f.plist.file)) {
5408
- run("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${LABEL}`]);
5409
- run("launchctl", ["bootout", `user/${process.getuid?.() ?? ""}/${LABEL}`]);
6020
+ if (process.platform === "darwin" && existsSync12(f.plist.file)) {
6021
+ run("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${LABEL2}`]);
6022
+ run("launchctl", ["bootout", `user/${process.getuid?.() ?? ""}/${LABEL2}`]);
5410
6023
  unlinkSync6(f.plist.file);
5411
6024
  }
5412
- if (process.platform === "linux" && existsSync11(f.unit.file)) {
5413
- run("systemctl", ["--user", "disable", "--now", UNIT]);
6025
+ if (process.platform === "linux" && existsSync12(f.unit.file)) {
6026
+ run("systemctl", ["--user", "disable", "--now", UNIT2]);
5414
6027
  unlinkSync6(f.unit.file);
5415
6028
  run("systemctl", ["--user", "daemon-reload"]);
5416
6029
  }
@@ -5613,9 +6226,9 @@ ${CONTROL_HELP}`);
5613
6226
  out2(
5614
6227
  ` Mode: ${c.mode} (${c.mode === "safe" ? "nothing that needs approval runs unattended" : "Claude Code's auto mode"})`
5615
6228
  );
5616
- let cli = path19.join(distDir2, "cli.js");
6229
+ let cli = path21.join(distDir2, "cli.js");
5617
6230
  try {
5618
- cli = realpathSync4(cli);
6231
+ cli = realpathSync5(cli);
5619
6232
  } catch {
5620
6233
  }
5621
6234
  if (argv.includes("--no-service"))
@@ -5665,12 +6278,15 @@ ${MODE_TEXT}`);
5665
6278
  return out2(JSON.stringify(r.status, null, 2));
5666
6279
  }
5667
6280
  case "run":
5668
- return runDaemon(out2);
6281
+ return runDaemon(out2, distDir2);
5669
6282
  default:
5670
6283
  return out2(CONTROL_HELP);
5671
6284
  }
5672
6285
  }
5673
- async function runDaemon(out2) {
6286
+ function enabledHarnesses(harnesses) {
6287
+ return Object.entries(harnesses).filter(([, h]) => h?.enabled !== false).map(([n]) => n).filter((n) => /^[a-z][a-z0-9-]{0,40}$/.test(n));
6288
+ }
6289
+ async function runDaemon(out2, distDir2) {
5674
6290
  const cfg = readControl();
5675
6291
  if (!cfg?.receivers.length) {
5676
6292
  out2("control isn't paired on this machine; nothing to do (`sessionpipe control pair <receiver>`)");
@@ -5681,11 +6297,12 @@ async function runDaemon(out2) {
5681
6297
  const claudeNow = () => {
5682
6298
  const bin = findClaude2();
5683
6299
  if (!bin) return null;
5684
- const sig = `${bin}:${existsSync12(bin) ? realpathSync4(bin) : ""}`;
6300
+ const sig = `${bin}:${existsSync13(bin) ? realpathSync5(bin) : ""}`;
5685
6301
  if (claude?.sig !== sig) claude = { bin, caps: detectCaps2(bin), sig };
5686
6302
  return claude;
5687
6303
  };
5688
6304
  const sdk = await loadSdk({ claudeBin: findClaude2(), env: process.env, log: log2 });
6305
+ let updater = null;
5689
6306
  const d = new ControlDaemon(cfg, controlState(), socketPath(stateDir()), {
5690
6307
  now: Date.now,
5691
6308
  fetch,
@@ -5698,9 +6315,19 @@ async function runDaemon(out2) {
5698
6315
  const c = claudeNow();
5699
6316
  return c ? claudeLogin2(c.bin, configDir) : { loggedIn: null };
5700
6317
  },
5701
- token: (r) => r.token_in ? secretGet(r.token_in, controlAccount(r.machine)) : null
6318
+ token: (r) => r.token_in ? secretGet(r.token_in, controlAccount(r.machine)) : null,
6319
+ onRestart: () => updater?.restartWhenIdle() ?? false,
6320
+ updateStatus: () => updater ? updater.record() : null,
6321
+ // Two daemons started at once and the other one holds the socket: leave it to it.
6322
+ onLostSocket: () => process.exit(0)
5702
6323
  });
5703
- await d.start();
6324
+ try {
6325
+ await d.start();
6326
+ } catch (e) {
6327
+ if (!(e instanceof DaemonRunning)) throw e;
6328
+ log2(`${e.message}; this one exits`);
6329
+ process.exit(0);
6330
+ }
5704
6331
  const drain = setInterval(() => {
5705
6332
  const sinks = readConfig().sinks.filter((s) => s.token_in && !s.paused);
5706
6333
  if (sinks.length) void flush(stateDir(), sinks, new Outbox(stateDir())).catch(() => {
@@ -5711,12 +6338,50 @@ async function runDaemon(out2) {
5711
6338
  const next = readControl();
5712
6339
  if (next) d.reload(next);
5713
6340
  });
6341
+ let stopping = false;
5714
6342
  const bye = async () => {
6343
+ if (stopping) return;
6344
+ stopping = true;
6345
+ updater?.stop();
5715
6346
  await d.stop();
5716
6347
  process.exit(0);
5717
6348
  };
5718
- process.on("SIGTERM", bye);
5719
- process.on("SIGINT", bye);
6349
+ const signals = ["SIGTERM", "SIGINT"];
6350
+ for (const sig of signals) process.on(sig, bye);
6351
+ const restart = async (_from, to) => {
6352
+ stopping = true;
6353
+ updater?.stop();
6354
+ clearInterval(drain);
6355
+ unwatchFile(controlFile());
6356
+ for (const sig of signals) process.off(sig, bye);
6357
+ await d.stop();
6358
+ const via = restartVia({ env: process.env, platform: process.platform, pid: process.pid, ppid: process.ppid });
6359
+ if (via) {
6360
+ log2(`exiting ${UPDATE_EXIT} so ${via === "supervisor" ? "the supervising sessionpipe" : via} starts ${to}`);
6361
+ process.exit(UPDATE_EXIT);
6362
+ }
6363
+ let cli = path21.join(distDir2, "cli.js");
6364
+ try {
6365
+ cli = realpathSync5(cli);
6366
+ } catch {
6367
+ }
6368
+ log2(`no service manager restarts this daemon: pid ${process.pid} now runs ${to} as its child`);
6369
+ const sup = supervise({ node: process.execPath, args: [cli, "control", "run"], env: process.env, log: log2 });
6370
+ for (const sig of [...signals, "SIGHUP"]) process.on(sig, () => sup.stop(sig));
6371
+ };
6372
+ updater = new AutoUpdater({
6373
+ now: Date.now,
6374
+ log: log2,
6375
+ version: VERSION,
6376
+ file: updateFile(),
6377
+ off: () => autoUpdateOff({ env: process.env, config: readConfig(), distDir: distDir2 }),
6378
+ latest: () => latestVersion(fetch),
6379
+ installed: () => installedVersion({ node: process.execPath, distDir: distDir2 }),
6380
+ install: (v) => installVersion(v, { node: process.execPath, distDir: distDir2, harnesses: enabledHarnesses(readConfig().harnesses) }),
6381
+ daemon: d,
6382
+ restart
6383
+ });
6384
+ updater.start();
5720
6385
  }
5721
6386
  async function waitMain(argv, out2) {
5722
6387
  const i = argv.indexOf("--session");
@@ -5740,16 +6405,16 @@ function adoptSinkToken(url, token, tier, file) {
5740
6405
  }
5741
6406
 
5742
6407
  // src/launcher.ts
5743
- import { accessSync as accessSync2, chmodSync as chmodSync3, constants as constants2, mkdirSync as mkdirSync11, readFileSync as readFileSync11, renameSync as renameSync4, rmSync, writeFileSync as writeFileSync9 } from "node:fs";
6408
+ import { accessSync as accessSync3, chmodSync as chmodSync3, constants as constants3, mkdirSync as mkdirSync11, readFileSync as readFileSync12, renameSync as renameSync4, rmSync, writeFileSync as writeFileSync9 } from "node:fs";
5744
6409
  import os8 from "node:os";
5745
- import path20 from "node:path";
6410
+ import path22 from "node:path";
5746
6411
  var LAUNCHER = "sessionpipe-hook";
5747
6412
  var COPIED = ["worker.js", "hook.js"];
5748
6413
  function dataDir(env = process.env) {
5749
- if (env.SESSIONPIPE_DATA) return path20.resolve(env.SESSIONPIPE_DATA);
5750
- return path20.join(env.XDG_DATA_HOME || path20.join(os8.homedir(), ".local", "share"), "sessionpipe");
6414
+ if (env.SESSIONPIPE_DATA) return path22.resolve(env.SESSIONPIPE_DATA);
6415
+ return path22.join(env.XDG_DATA_HOME || path22.join(os8.homedir(), ".local", "share"), "sessionpipe");
5751
6416
  }
5752
- var launcherPath = (env = process.env) => path20.join(dataDir(env), LAUNCHER);
6417
+ var launcherPath = (env = process.env) => path22.join(dataDir(env), LAUNCHER);
5753
6418
  var usesLauncher = (platform = process.platform) => platform !== "win32";
5754
6419
  var sq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
5755
6420
  function launcherScript(node, hook, version) {
@@ -5764,7 +6429,7 @@ exec "$n" ${sq(hook)} "$@"
5764
6429
  }
5765
6430
  function writeIfChanged(file, body, mode) {
5766
6431
  try {
5767
- const cur = readFileSync11(file);
6432
+ const cur = readFileSync12(file);
5768
6433
  if (cur.equals(Buffer.isBuffer(body) ? body : Buffer.from(body))) {
5769
6434
  chmodSync3(file, mode);
5770
6435
  return false;
@@ -5782,19 +6447,19 @@ function installLauncher(o) {
5782
6447
  mkdirSync11(dir, { recursive: true, mode: 493 });
5783
6448
  let changed = false;
5784
6449
  for (const f of COPIED)
5785
- changed = writeIfChanged(path20.join(dir, f), readFileSync11(path20.join(o.distDir, f)), 420) || changed;
5786
- const launcher = path20.join(dir, LAUNCHER);
5787
- changed = writeIfChanged(launcher, launcherScript(o.node, path20.join(dir, "hook.js"), o.version), 493) || changed;
6450
+ changed = writeIfChanged(path22.join(dir, f), readFileSync12(path22.join(o.distDir, f)), 420) || changed;
6451
+ const launcher = path22.join(dir, LAUNCHER);
6452
+ changed = writeIfChanged(launcher, launcherScript(o.node, path22.join(dir, "hook.js"), o.version), 493) || changed;
5788
6453
  return { launcher, changed };
5789
6454
  }
5790
6455
  function removeLauncher(env = process.env) {
5791
- for (const f of [LAUNCHER, ...COPIED]) rmSync(path20.join(dataDir(env), f), { force: true });
6456
+ for (const f of [LAUNCHER, ...COPIED]) rmSync(path22.join(dataDir(env), f), { force: true });
5792
6457
  }
5793
6458
  function launcherState(version, env = process.env) {
5794
6459
  const file = launcherPath(env);
5795
6460
  let text = "";
5796
6461
  try {
5797
- text = readFileSync11(file, "utf8");
6462
+ text = readFileSync12(file, "utf8");
5798
6463
  } catch {
5799
6464
  return {
5800
6465
  file,
@@ -5810,7 +6475,7 @@ function launcherState(version, env = process.env) {
5810
6475
  let nodeOk = false;
5811
6476
  try {
5812
6477
  if (node) {
5813
- accessSync2(node, constants2.X_OK);
6478
+ accessSync3(node, constants3.X_OK);
5814
6479
  nodeOk = true;
5815
6480
  }
5816
6481
  } catch {
@@ -5822,7 +6487,7 @@ function launcherState(version, env = process.env) {
5822
6487
  );
5823
6488
  for (const f of COPIED)
5824
6489
  try {
5825
- accessSync2(path20.join(path20.dirname(file), f), constants2.R_OK);
6490
+ accessSync3(path22.join(path22.dirname(file), f), constants3.R_OK);
5826
6491
  } catch {
5827
6492
  problems.push(`${f} is missing beside the hook launcher; run \`sessionpipe install\` again`);
5828
6493
  }
@@ -5849,16 +6514,16 @@ var out = (s = "") => {
5849
6514
  `);
5850
6515
  };
5851
6516
  var state = stateDir();
5852
- var distDir = path21.dirname(fileURLToPath(import.meta.url));
6517
+ var distDir = path23.dirname(fileURLToPath(import.meta.url));
5853
6518
  function hookCommand() {
5854
6519
  if (usesLauncher()) {
5855
6520
  const l = launcherPath();
5856
6521
  return (a) => ({ argv: [l, ...a], command: `${JSON.stringify(l)} ${a.join(" ")}` });
5857
6522
  }
5858
6523
  const node = stableNode(process.execPath);
5859
- let script = path21.join(distDir, "hook.js");
6524
+ let script = path23.join(distDir, "hook.js");
5860
6525
  try {
5861
- script = realpathSync5(script);
6526
+ script = realpathSync6(script);
5862
6527
  } catch {
5863
6528
  }
5864
6529
  return (a) => ({
@@ -5928,7 +6593,7 @@ function help() {
5928
6593
  sessionpipe status | doctor [--json]
5929
6594
  sessionpipe tail [--session ID] [--tier N] [--harness NAME]
5930
6595
  sessionpipe backfill [--days 30] | forget <harness> <session> | replay --sink NAME
5931
- sessionpipe update
6596
+ sessionpipe update [off|on] (install the latest now; off/on: the daemon's daily update)
5932
6597
  ${CONTROL_HELP}
5933
6598
 
5934
6599
  Docs: https://sessionpipe.org \xB7 nothing leaves this machine until you add a sink.`);
@@ -6090,7 +6755,7 @@ async function connect() {
6090
6755
  out(` \u2713 Keys: in ${storeLabel(store)}${kept.length ? ` (left in the file: ${kept.join(", ")})` : ""}`);
6091
6756
  } else
6092
6757
  out(
6093
- ` Keys: in ${tilde(path21.dirname(configFile()))} at 0600 \u2014 no unlocked keychain in this session${process.env.SSH_CONNECTION ? " (an ssh shell)" : ""}`
6758
+ ` Keys: in ${tilde(path23.dirname(configFile()))} at 0600 \u2014 no unlocked keychain in this session${process.env.SSH_CONNECTION ? " (an ssh shell)" : ""}`
6094
6759
  );
6095
6760
  if (rows.length) queueBackfill(rows, 30, readConfig());
6096
6761
  out("");
@@ -6362,6 +7027,7 @@ function status() {
6362
7027
  ` sink ${s.name.padEnd(16)} tier ${Math.min(s.tier, s.max_tier ?? 3)} ${s.paused ? `PAUSED ${s.paused}` : pending ? `${pending} session file(s) with undelivered events` : "up to date"}`
6363
7028
  );
6364
7029
  }
7030
+ out(` update: ${updateLine(updateState(cfg), Date.now())}`);
6365
7031
  const t = timing();
6366
7032
  if (t)
6367
7033
  out(
@@ -6375,12 +7041,12 @@ function status() {
6375
7041
  }
6376
7042
  }
6377
7043
  function hookWall(runs = 8) {
6378
- const hook = path21.join(distDir, "hook.js");
6379
- const tmp = mkdtempSync(path21.join(os9.tmpdir(), "sessionpipe-doctor-"));
7044
+ const hook = path23.join(distDir, "hook.js");
7045
+ const tmp = mkdtempSync(path23.join(os9.tmpdir(), "sessionpipe-doctor-"));
6380
7046
  const env = {
6381
7047
  ...process.env,
6382
7048
  SESSIONPIPE_STATE: tmp,
6383
- SESSIONPIPE_CONFIG: path21.join(tmp, "config.json"),
7049
+ SESSIONPIPE_CONFIG: path23.join(tmp, "config.json"),
6384
7050
  SESSIONPIPE_NO_WORKER: "1"
6385
7051
  };
6386
7052
  const input = JSON.stringify({
@@ -6408,7 +7074,7 @@ function hookWall(runs = 8) {
6408
7074
  }
6409
7075
  function timing() {
6410
7076
  try {
6411
- const lines = readFileSync12(path21.join(state, "timing.jsonl"), "utf8").trim().split("\n").slice(-100);
7077
+ const lines = readFileSync13(path23.join(state, "timing.jsonl"), "utf8").trim().split("\n").slice(-100);
6412
7078
  const ms = lines.map((l) => JSON.parse(l).ms).filter((x) => x >= 0).sort((a, b) => a - b);
6413
7079
  if (!ms.length) return null;
6414
7080
  const q = (p) => Math.round(ms[Math.min(ms.length - 1, Math.floor(p * ms.length))] ?? 0);
@@ -6431,9 +7097,15 @@ async function doctor() {
6431
7097
  sinks: [],
6432
7098
  timing: timing(),
6433
7099
  hook_wall_ms: hookWall(),
7100
+ update: updateState(cfg),
6434
7101
  warnings: []
6435
7102
  };
6436
7103
  const warnings = report.warnings;
7104
+ const upd = report.update;
7105
+ if (upd.failed)
7106
+ warnings.push(
7107
+ `the control daemon couldn't install sessionpipe ${upd.failed.version}: ${upd.failed.error}. It tries again tomorrow; \`sessionpipe update\` tries now.`
7108
+ );
6437
7109
  if (usesLauncher()) {
6438
7110
  const l = launcherState(VERSION);
6439
7111
  report.launcher = { file: tilde(l.file), node: l.node, node_ok: l.nodeOk, version: l.version };
@@ -6543,6 +7215,7 @@ async function doctor() {
6543
7215
  out(
6544
7216
  ` claude ${a.dir.padEnd(26)} ${a.signed_in === true ? "signed in" : a.signed_in === false ? "NOT signed in from here" : "can't tell"}`
6545
7217
  );
7218
+ out(` update ${updateLine(upd, Date.now())}`);
6546
7219
  const t = report.timing;
6547
7220
  out(
6548
7221
  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"
@@ -6622,37 +7295,67 @@ async function tail() {
6622
7295
  async function workerFromArgv() {
6623
7296
  const f = args[1];
6624
7297
  if (!f) return;
6625
- const job = JSON.parse(readFileSync12(f, "utf8"));
7298
+ const job = JSON.parse(readFileSync13(f, "utf8"));
6626
7299
  try {
6627
7300
  unlinkSync7(f);
6628
7301
  } catch {
6629
7302
  }
6630
7303
  await runJob(job);
6631
7304
  }
7305
+ function updateState(cfg) {
7306
+ return updateSummary({
7307
+ // The shell's own SESSIONPIPE_NO_UPDATE_CHECK says nothing about the daemon's
7308
+ // environment; the daemon records that one itself.
7309
+ off: autoUpdateOff({ env: {}, config: cfg, distDir }),
7310
+ paired: pairedCount() > 0,
7311
+ rec: readUpdate(updateFile())
7312
+ });
7313
+ }
6632
7314
  async function update() {
7315
+ const sub = args[1];
7316
+ if (sub === "off" || sub === "on") {
7317
+ const cfg = readConfig();
7318
+ cfg.update_check = sub === "on";
7319
+ writeConfig(cfg);
7320
+ return out(
7321
+ sub === "off" ? " \u2713 The control daemon no longer checks npm or updates itself. `sessionpipe update` still updates by hand; `sessionpipe update on` undoes this." : " \u2713 The control daemon checks npm once a day and installs a new release when it's idle."
7322
+ );
7323
+ }
6633
7324
  if (process.env.SESSIONPIPE_NO_UPDATE_CHECK) return out(" update check is off (SESSIONPIPE_NO_UPDATE_CHECK)");
7325
+ let latest;
6634
7326
  try {
6635
- const r = await fetch("https://registry.npmjs.org/sessionpipe/latest", { signal: AbortSignal.timeout(8e3) });
6636
- const j = await r.json();
6637
- if (!j.version) throw new Error("no version");
6638
- if (j.version === VERSION) return out(` sessionpipe ${VERSION} is current`);
6639
- out(` ${j.version} is available (you run ${VERSION}); installing into this copy's prefix\u2026`);
6640
- const prefix = prefixOf(distDir);
6641
- execFileSync7("npm", ["install", "-g", `sessionpipe@${j.version}`, ...prefix ? ["--prefix", prefix] : []], {
6642
- stdio: "inherit"
6643
- });
6644
- out(" re-arming hooks\u2026");
6645
- const bin = prefix ? path21.join(prefix, "bin", "sessionpipe") : "sessionpipe";
6646
- const p = spawn2(bin, ["install", "--backfill", "0"], { stdio: "inherit" });
6647
- await new Promise((r2) => p.on("exit", r2));
7327
+ latest = await latestVersion();
6648
7328
  } catch (e) {
6649
- out(` couldn't check npm: ${e.message}`);
7329
+ return out(` couldn't check npm: ${e.message}`);
6650
7330
  }
6651
- }
6652
- function prefixOf(p) {
6653
- const re = process.platform === "win32" ? /^(.*?)[\\/]node_modules[\\/]sessionpipe[\\/]/ : /^(.*)\/lib\/node_modules\/sessionpipe\//;
6654
- const m = re.exec(p);
6655
- return m?.[1] ?? null;
7331
+ if (!isNewer(latest, VERSION)) return out(` sessionpipe ${VERSION} is current (npm latest: ${latest})`);
7332
+ const prefix = globalPrefix(distDir);
7333
+ if (!prefix)
7334
+ return out(
7335
+ ` ${latest} is available (you run ${VERSION}), but this copy isn't a global npm install (npx, a checkout or a dev build): \`npm install -g sessionpipe@${latest}\` installs one.`
7336
+ );
7337
+ out(` ${latest} is available (you run ${VERSION}); installing into ${tilde(prefix)}\u2026`);
7338
+ const r = await installVersion(latest, {
7339
+ node: process.execPath,
7340
+ distDir,
7341
+ harnesses: enabledHarnesses(readConfig().harnesses)
7342
+ });
7343
+ if (!r.ok) {
7344
+ process.exitCode = 1;
7345
+ return out(` \u2717 ${r.error}`);
7346
+ }
7347
+ out(` \u2713 sessionpipe ${latest} installed in ${(r.ms / 1e3).toFixed(1)} s; ${r.hooks}`);
7348
+ const sock = socketPath(state);
7349
+ const st = await ask(sock, { op: "status" }, 2e3);
7350
+ if (st?.op !== "status") return;
7351
+ const running = st.status.version ?? "?";
7352
+ if (running === latest) return;
7353
+ const r2 = await ask(sock, { op: "restart" }, 2e3);
7354
+ if (r2?.op === "ok") out(` The control daemon (${running}) restarts onto ${latest} as soon as nothing is in hand.`);
7355
+ else
7356
+ out(
7357
+ ` The control daemon still runs ${running} and can't restart itself: ${process.platform === "darwin" ? `\`launchctl kickstart -k gui/${process.getuid?.() ?? "$(id -u)"}/org.sessionpipe.control\`` : process.platform === "linux" ? "`systemctl --user restart sessionpipe-control.service`" : "stop `sessionpipe control run` and start it again"} moves it (a run in progress stops).`
7358
+ );
6656
7359
  }
6657
7360
  main().catch((e) => {
6658
7361
  process.stderr.write(`sessionpipe: ${e.message}