baychat 0.12.0 → 0.13.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.
@@ -0,0 +1,324 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AUTOSTART_IMPLEMENTATIONS = exports.LingerUnavailable = exports.SYSTEMD_UNIT_NAME = exports.SERVICE_LABEL = void 0;
37
+ exports.autostartForPlatform = autostartForPlatform;
38
+ exports.relayCommand = relayCommand;
39
+ // Making the relay come back by itself, on all three platforms.
40
+ //
41
+ // THE BUG THIS EXISTS TO FIX. `baychat relay start` used to write a systemd user
42
+ // unit and, off Linux, print "Automatic startup needs systemd" and hand the user
43
+ // `--foreground` to run themselves. A foreground process dies with the terminal
44
+ // that started it, so on macOS and Windows the "wake my session instantly" half of
45
+ // BayChat was unattended-broken: the relay ran exactly as long as somebody kept a
46
+ // window open, and every reboot silently turned wake-ups off. Recorded as the
47
+ // motivating finding of `docs/prd/desktop_app_spec.md` §2, whose Stage 1 this is.
48
+ //
49
+ // THE SHAPE. One `Autostart` per init system, chosen by `process.platform`. Each
50
+ // splits into two halves on purpose:
51
+ //
52
+ // - `unit()` — a PURE function from a command line to the text the init system
53
+ // wants (a systemd unit, a launchd plist, a Task Scheduler XML). No I/O, no
54
+ // spawning, so the part that is easy to get wrong is the part a test can read.
55
+ // - `install()` — writes that text and runs the one command that arms it. This
56
+ // half cannot be tested off its own platform, which is exactly why it is kept
57
+ // as thin as it is.
58
+ //
59
+ // WHY NOT A DESKTOP APP INSTEAD. Eventually, yes — the same spec's Stage 3 moves
60
+ // the relay inside an Electron main process. This lands first because it is a week
61
+ // rather than a month, it fixes the breakage for people who will never install a
62
+ // desktop app, and the spec says so: "Pays off even if the app is never built."
63
+ const child_process_1 = require("child_process");
64
+ const fs = __importStar(require("fs"));
65
+ const os = __importStar(require("os"));
66
+ const path = __importStar(require("path"));
67
+ const util_1 = require("util");
68
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
69
+ /** The reverse-DNS name macOS and Windows both want, and the systemd unit's stem.
70
+ * One constant so a rename cannot half-happen: a plist labelled one thing and
71
+ * bootstrapped as another leaves an agent nobody can stop. */
72
+ exports.SERVICE_LABEL = "io.baychat.relay";
73
+ exports.SYSTEMD_UNIT_NAME = "baychat-relay.service";
74
+ /**
75
+ * Linux — a systemd **user** unit.
76
+ *
77
+ * `enable-linger` is what makes "after reboot" true rather than "after the next
78
+ * login": without it a user manager only runs while somebody is logged in, so a
79
+ * headless box that rebooted overnight comes back deaf.
80
+ */
81
+ const systemd = {
82
+ name: "systemd",
83
+ unit: ({ node, entry }) => `[Unit]
84
+ Description=BayChat relay — wakes local agent sessions on new messages
85
+ After=network-online.target
86
+ Wants=network-online.target
87
+
88
+ [Service]
89
+ Type=simple
90
+ ExecStart=${node} ${entry} relay start --foreground
91
+ Restart=always
92
+ RestartSec=5
93
+ Environment=NODE_ENV=production
94
+
95
+ [Install]
96
+ WantedBy=default.target
97
+ `,
98
+ unitPath: () => path.join(os.homedir(), ".config", "systemd", "user", exports.SYSTEMD_UNIT_NAME),
99
+ async install(cmd) {
100
+ const target = systemd.unitPath();
101
+ fs.mkdirSync(path.dirname(target), { recursive: true });
102
+ fs.writeFileSync(target, systemd.unit(cmd), { mode: 0o644 });
103
+ await execFileAsync("systemctl", ["--user", "daemon-reload"]);
104
+ await execFileAsync("systemctl", ["--user", "enable", "--now", exports.SYSTEMD_UNIT_NAME]);
105
+ try {
106
+ await execFileAsync("loginctl", ["enable-linger", os.userInfo().username]);
107
+ }
108
+ catch {
109
+ // Not fatal and not silent: the caller prints the degraded promise rather
110
+ // than claiming boot-time startup it did not get.
111
+ throw new LingerUnavailable();
112
+ }
113
+ },
114
+ async uninstall() {
115
+ await execFileAsync("systemctl", ["--user", "disable", "--now", exports.SYSTEMD_UNIT_NAME]).catch(() => undefined);
116
+ fs.rmSync(systemd.unitPath(), { force: true });
117
+ },
118
+ };
119
+ /** Thrown when the unit is installed but only starts at login, not at boot. The
120
+ * relay works; the promise made to the user is the smaller one, and saying so is
121
+ * the whole point of having a distinct type rather than a swallowed catch. */
122
+ class LingerUnavailable extends Error {
123
+ constructor() {
124
+ super("relay will start at login rather than at boot (could not enable linger)");
125
+ this.name = "LingerUnavailable";
126
+ }
127
+ }
128
+ exports.LingerUnavailable = LingerUnavailable;
129
+ /**
130
+ * macOS — a launchd **LaunchAgent** in the user's own domain.
131
+ *
132
+ * `RunAtLoad` + `KeepAlive` is launchd's equivalent of `Restart=always`, and the
133
+ * `gui/<uid>` domain is what makes it a per-user agent rather than a root daemon:
134
+ * the relay reads `~/.baychat/credentials.json` and must never run as anyone else.
135
+ *
136
+ * `bootstrap` (not the deprecated `load`) is used because `load` reports success
137
+ * for a plist it silently refused, which is the failure mode this whole file
138
+ * exists to avoid.
139
+ */
140
+ const launchd = {
141
+ name: "launchd",
142
+ unit: ({ node, entry }) => `<?xml version="1.0" encoding="UTF-8"?>
143
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
144
+ <plist version="1.0">
145
+ <dict>
146
+ <key>Label</key>
147
+ <string>${exports.SERVICE_LABEL}</string>
148
+ <key>ProgramArguments</key>
149
+ <array>
150
+ <string>${escapeXml(node)}</string>
151
+ <string>${escapeXml(entry)}</string>
152
+ <string>relay</string>
153
+ <string>start</string>
154
+ <string>--foreground</string>
155
+ </array>
156
+ <key>RunAtLoad</key>
157
+ <true/>
158
+ <key>KeepAlive</key>
159
+ <true/>
160
+ <key>EnvironmentVariables</key>
161
+ <dict>
162
+ <key>NODE_ENV</key>
163
+ <string>production</string>
164
+ </dict>
165
+ <key>ProcessType</key>
166
+ <string>Background</string>
167
+ </dict>
168
+ </plist>
169
+ `,
170
+ unitPath: () => path.join(os.homedir(), "Library", "LaunchAgents", `${exports.SERVICE_LABEL}.plist`),
171
+ async install(cmd) {
172
+ const target = launchd.unitPath();
173
+ fs.mkdirSync(path.dirname(target), { recursive: true });
174
+ fs.writeFileSync(target, launchd.unit(cmd), { mode: 0o644 });
175
+ const domain = `gui/${os.userInfo().uid}`;
176
+ // Replacing an agent means removing the old one first; bootout on a label
177
+ // that was never loaded exits non-zero, which is not a failure here.
178
+ await execFileAsync("launchctl", ["bootout", `${domain}/${exports.SERVICE_LABEL}`]).catch(() => undefined);
179
+ await execFileAsync("launchctl", ["bootstrap", domain, target]);
180
+ },
181
+ async uninstall() {
182
+ const domain = `gui/${os.userInfo().uid}`;
183
+ await execFileAsync("launchctl", ["bootout", `${domain}/${exports.SERVICE_LABEL}`]).catch(() => undefined);
184
+ fs.rmSync(launchd.unitPath(), { force: true });
185
+ },
186
+ };
187
+ /**
188
+ * Windows — a Task Scheduler task, triggered at logon.
189
+ *
190
+ * Three candidates were considered and two rejected, because the difference
191
+ * matters to whether wake-ups survive a crash:
192
+ *
193
+ * - A **Startup-folder shortcut** or a **Run registry key** launches the process
194
+ * once and never looks at it again. A relay that dies at 3am stays dead until
195
+ * the next login — the same "works while somebody is watching" failure this
196
+ * file replaces.
197
+ * - A **Windows Service** would restart it, but installing one needs elevation
198
+ * and a service wrapper; asking for an admin prompt to receive chat messages
199
+ * is the wrong trade.
200
+ * - A **scheduled task** needs no elevation in the user's own context, and
201
+ * `RestartOnFailure` gives the `Restart=always` behaviour the other two
202
+ * platforms have.
203
+ *
204
+ * `schtasks /create /xml` is used rather than the flag soup of `/sc onlogon`,
205
+ * because the flags cannot express the restart policy and cannot turn off the
206
+ * "stop after 3 days" default that would quietly end the relay every third day.
207
+ */
208
+ const scheduledTask = {
209
+ name: "Task Scheduler",
210
+ unit: (cmd) => `<?xml version="1.0" encoding="UTF-16"?>
211
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
212
+ <RegistrationInfo>
213
+ <Description>BayChat relay — wakes local agent sessions on new messages</Description>
214
+ </RegistrationInfo>
215
+ <Triggers>
216
+ <LogonTrigger>
217
+ <Enabled>true</Enabled>
218
+ <UserId>${escapeXml(windowsUserId())}</UserId>
219
+ </LogonTrigger>
220
+ </Triggers>
221
+ <Principals>
222
+ <Principal id="Author">
223
+ <UserId>${escapeXml(windowsUserId())}</UserId>
224
+ <LogonType>InteractiveToken</LogonType>
225
+ <RunLevel>LeastPrivilege</RunLevel>
226
+ </Principal>
227
+ </Principals>
228
+ <Settings>
229
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
230
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
231
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
232
+ <StartWhenAvailable>true</StartWhenAvailable>
233
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
234
+ <IdleSettings>
235
+ <StopOnIdleEnd>false</StopOnIdleEnd>
236
+ <RestartOnIdle>false</RestartOnIdle>
237
+ </IdleSettings>
238
+ <AllowHardTerminate>true</AllowHardTerminate>
239
+ <Enabled>true</Enabled>
240
+ <Hidden>true</Hidden>
241
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
242
+ <RestartOnFailure>
243
+ <Interval>PT1M</Interval>
244
+ <Count>999</Count>
245
+ </RestartOnFailure>
246
+ </Settings>
247
+ <Actions Context="Author">
248
+ <Exec>
249
+ <Command>${escapeXml(cmd.node)}</Command>
250
+ <Arguments>"${escapeXml(cmd.entry)}" relay start --foreground</Arguments>
251
+ </Exec>
252
+ </Actions>
253
+ </Task>
254
+ `,
255
+ // Written beside the credentials rather than in %TEMP%: schtasks reads it once,
256
+ // but keeping it means `relay status` can show what was actually registered.
257
+ unitPath: () => path.join(configHome(), "relay-task.xml"),
258
+ async install(cmd) {
259
+ const target = scheduledTask.unitPath();
260
+ fs.mkdirSync(path.dirname(target), { recursive: true });
261
+ // UTF-16LE with a BOM, because that is what the XML declaration above claims
262
+ // and what schtasks refuses the file without.
263
+ fs.writeFileSync(target, "\ufeff" + scheduledTask.unit(cmd), { encoding: "utf16le" });
264
+ await execFileAsync("schtasks", ["/create", "/tn", exports.SERVICE_LABEL, "/xml", target, "/f"]);
265
+ await execFileAsync("schtasks", ["/run", "/tn", exports.SERVICE_LABEL]);
266
+ },
267
+ async uninstall() {
268
+ await execFileAsync("schtasks", ["/delete", "/tn", exports.SERVICE_LABEL, "/f"]).catch(() => undefined);
269
+ fs.rmSync(scheduledTask.unitPath(), { force: true });
270
+ },
271
+ };
272
+ /** `DOMAIN\user`, which is what a task's `UserId` wants. `USERDOMAIN` is absent
273
+ * on a machine that was never joined to a domain; the bare username is correct
274
+ * there, and inventing a domain would register a task for a principal that does
275
+ * not exist. */
276
+ function windowsUserId() {
277
+ const user = process.env.USERNAME || os.userInfo().username;
278
+ const domain = process.env.USERDOMAIN;
279
+ return domain ? `${domain}\\${user}` : user;
280
+ }
281
+ /** Duplicated from `config.ts` rather than imported, and deliberately: importing
282
+ * it here would pull the credentials module into a file that must stay loadable
283
+ * on a machine with no credentials at all. */
284
+ function configHome() {
285
+ return process.env.BAYCHAT_CONFIG_DIR || path.join(os.homedir(), ".baychat");
286
+ }
287
+ /** The five characters that would otherwise end the element early. Paths can
288
+ * legally contain `&` on every platform this ships to, and a home directory with
289
+ * one in it would produce a plist launchd rejects with no useful message. */
290
+ function escapeXml(value) {
291
+ return value
292
+ .replace(/&/g, "&amp;")
293
+ .replace(/</g, "&lt;")
294
+ .replace(/>/g, "&gt;")
295
+ .replace(/"/g, "&quot;")
296
+ .replace(/'/g, "&apos;");
297
+ }
298
+ const BY_PLATFORM = {
299
+ linux: systemd,
300
+ darwin: launchd,
301
+ win32: scheduledTask,
302
+ };
303
+ /**
304
+ * The init system for this machine, or `undefined` where BayChat has none.
305
+ *
306
+ * `undefined` rather than a throwing default, because every caller has something
307
+ * better to do than fail: `connect` prints the manual command and carries on, and
308
+ * `relay start` explains itself. The platforms that reach `undefined` are the
309
+ * BSDs and anything exotic — never the three this product ships to.
310
+ */
311
+ function autostartForPlatform(platform = process.platform) {
312
+ return BY_PLATFORM[platform];
313
+ }
314
+ /** The command line the init system should run, resolved from this process.
315
+ *
316
+ * `process.argv[1]` is the script node was given; resolving it is what keeps an
317
+ * npx-run or `npm link`ed CLI producing a unit that still works after the shell
318
+ * that installed it is gone. */
319
+ function relayCommand() {
320
+ return { node: process.execPath, entry: path.resolve(process.argv[1] ?? "") };
321
+ }
322
+ /** Exported for tests, which need to reach a platform other than the one they
323
+ * run on. Production code goes through {@link autostartForPlatform}. */
324
+ exports.AUTOSTART_IMPLEMENTATIONS = { systemd, launchd, scheduledTask };
@@ -38,18 +38,18 @@ exports.ensureRelayInstalled = ensureRelayInstalled;
38
38
  exports.cmdRelayStatus = cmdRelayStatus;
39
39
  exports.cmdRelayStop = cmdRelayStop;
40
40
  exports.cmdRelayAttach = cmdRelayAttach;
41
+ exports.resolveRuntimeBin = resolveRuntimeBin;
41
42
  const child_process_1 = require("child_process");
42
43
  const fs = __importStar(require("fs"));
43
44
  const net = __importStar(require("net"));
44
- const os = __importStar(require("os"));
45
45
  const path = __importStar(require("path"));
46
46
  const util_1 = require("util");
47
47
  const adapters_1 = require("./adapters");
48
+ const autostart_1 = require("./autostart");
48
49
  const daemon_1 = require("./daemon");
49
50
  const resume_1 = require("./resume");
50
51
  const socket_1 = require("./socket");
51
52
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
52
- const UNIT_NAME = "baychat-relay.service";
53
53
  /** Connect to a running daemon, or explain that there isn't one. */
54
54
  async function connectOrFail() {
55
55
  const sockPath = (0, socket_1.socketPath)();
@@ -106,53 +106,28 @@ async function cmdRelayStart(opts = {}) {
106
106
  console.log("Relay is already running. `baychat relay status` for details.");
107
107
  return;
108
108
  }
109
- if (process.platform !== "linux") {
110
- console.log("Automatic startup needs systemd (Linux). Run the daemon yourself with:");
109
+ const autostart = (0, autostart_1.autostartForPlatform)();
110
+ if (!autostart) {
111
+ console.log(`No automatic startup is available on ${process.platform}. Run the daemon`);
112
+ console.log("yourself with:");
111
113
  console.log(" baychat relay start --foreground");
112
114
  return;
113
115
  }
114
- await installUserUnit();
115
- console.log("Relay started and enabled at boot.");
116
- console.log(" baychat relay status — see sessions, cursor, pending deliveries");
117
- console.log(" baychat relay stop — stop it");
118
- }
119
- /**
120
- * Write and enable the user unit.
121
- *
122
- * `enable-linger` is what makes "after reboot" true rather than "after the
123
- * next login": without it a user manager only runs while someone is logged in,
124
- * so a headless box that rebooted overnight would come back deaf.
125
- */
126
- async function installUserUnit() {
127
- const unitDir = path.join(os.homedir(), ".config", "systemd", "user");
128
- fs.mkdirSync(unitDir, { recursive: true });
129
- // Resolve the installed CLI entry point rather than assuming a global name —
130
- // an npx-run or locally-linked copy must produce a unit that keeps working.
131
- const entry = path.resolve(process.argv[1]);
132
- const unit = `[Unit]
133
- Description=BayChat relay — wakes local agent sessions on new messages
134
- After=network-online.target
135
- Wants=network-online.target
136
-
137
- [Service]
138
- Type=simple
139
- ExecStart=${process.execPath} ${entry} relay start --foreground
140
- Restart=always
141
- RestartSec=5
142
- Environment=NODE_ENV=production
143
-
144
- [Install]
145
- WantedBy=default.target
146
- `;
147
- fs.writeFileSync(path.join(unitDir, UNIT_NAME), unit, { mode: 0o644 });
148
- await execFileAsync("systemctl", ["--user", "daemon-reload"]);
149
- await execFileAsync("systemctl", ["--user", "enable", "--now", UNIT_NAME]);
150
116
  try {
151
- await execFileAsync("loginctl", ["enable-linger", os.userInfo().username]);
117
+ await autostart.install((0, autostart_1.relayCommand)());
118
+ console.log(`Relay started and enabled at boot (${autostart.name}).`);
152
119
  }
153
- catch {
154
- console.log("Note: could not enable linger — the relay will start at login rather than at boot.");
120
+ catch (err) {
121
+ if (!(err instanceof autostart_1.LingerUnavailable))
122
+ throw err;
123
+ // The relay IS installed and running; only the boot-time half is missing.
124
+ // Reported rather than swallowed, because "enabled at boot" would be a lie
125
+ // and the user would not find out until the machine next rebooted unattended.
126
+ console.log(`Relay started and enabled at login (${autostart.name}).`);
127
+ console.log(`Note: ${err.message}.`);
155
128
  }
129
+ console.log(" baychat relay status — see sessions, cursor, pending deliveries");
130
+ console.log(" baychat relay stop — stop it");
156
131
  }
157
132
  /**
158
133
  * Best-effort relay setup, run as the last step of `baychat connect`.
@@ -172,10 +147,18 @@ async function ensureRelayInstalled() {
172
147
  }
173
148
  if (await (0, socket_1.probeSocket)((0, socket_1.socketPath)()))
174
149
  return "Relay: already running — sessions wake instantly.";
175
- if (process.platform !== "linux") {
176
- return "Relay: not installed (needs systemd). Run `baychat relay start --foreground` to wake sessions instantly.";
150
+ const autostart = (0, autostart_1.autostartForPlatform)();
151
+ if (!autostart) {
152
+ return `Relay: no automatic startup on ${process.platform}. Run \`baychat relay start --foreground\` to wake sessions instantly.`;
153
+ }
154
+ try {
155
+ await autostart.install((0, autostart_1.relayCommand)());
156
+ }
157
+ catch (err) {
158
+ if (!(err instanceof autostart_1.LingerUnavailable))
159
+ throw err;
160
+ return `Relay: installed and started, but only at login — ${err.message}.`;
177
161
  }
178
- await installUserUnit();
179
162
  return "Relay: installed and started — sessions now wake the moment a message arrives.";
180
163
  }
181
164
  catch (err) {
@@ -249,10 +232,15 @@ function transportLabel(status) {
249
232
  return status.transport === "websocket" ? `websocket${detail}` : `long-poll${detail}`;
250
233
  }
251
234
  async function cmdRelayStop() {
252
- if (process.platform === "linux" && fs.existsSync(path.join(os.homedir(), ".config", "systemd", "user", UNIT_NAME))) {
235
+ // Disarm the init system FIRST. Stopping the process without it means the
236
+ // relay is back within the minute — systemd's Restart=always, launchd's
237
+ // KeepAlive and the scheduled task's RestartOnFailure all exist to make that
238
+ // happen, and all three would make `relay stop` look like it silently failed.
239
+ const autostart = (0, autostart_1.autostartForPlatform)();
240
+ if (autostart && fs.existsSync(autostart.unitPath())) {
253
241
  try {
254
- await execFileAsync("systemctl", ["--user", "disable", "--now", UNIT_NAME]);
255
- console.log("Relay stopped and disabled at boot.");
242
+ await autostart.uninstall();
243
+ console.log(`Relay stopped and disabled at boot (${autostart.name}).`);
256
244
  return 0;
257
245
  }
258
246
  catch {
@@ -345,9 +333,52 @@ async function cmdRelayAttach(opts) {
345
333
  resumeEvidence: resume.ok ? resume.evidence : undefined,
346
334
  resumeCwd: resume.ok ? resume.cwd : undefined,
347
335
  cwd: process.cwd(),
336
+ // Resolved HERE, inside the session, because this is the only process that
337
+ // can answer it correctly — see SessionTarget.runtimeBin.
338
+ runtimeBin: resolveRuntimeBin(runtime),
348
339
  });
349
340
  });
350
341
  }
342
+ /**
343
+ * The absolute path to the runtime binary this session is running under, or
344
+ * `undefined` if it cannot be established.
345
+ *
346
+ * WHY THE ATTACHING PROCESS AND NOT THE DAEMON. The daemon inherits whatever
347
+ * environment started it — typically a systemd unit or a login shell from days ago
348
+ * — and spawning a bare `claude` asks THAT environment to find the binary. On the
349
+ * machine where this bug was found it could not: `claude` lived on an interactive
350
+ * shell's PATH and not the daemon's, so every detached wake died with ENOENT and no
351
+ * message ever said so. `attach` runs inside the session itself, so its PATH is the
352
+ * one the human actually uses.
353
+ *
354
+ * `process.execPath` is deliberately NOT used: that is the Node binary running this
355
+ * CLI, which for a globally-installed `baychat` is not the runtime at all.
356
+ *
357
+ * Returns undefined rather than throwing or guessing. The adapters fall back to the
358
+ * bare name, which is exactly the behaviour that shipped before this existed — a
359
+ * machine where PATH is fine keeps working, and one where it is not now works too.
360
+ */
361
+ function resolveRuntimeBin(runtime) {
362
+ const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
363
+ for (const dir of dirs) {
364
+ const candidate = path.join(dir, runtime);
365
+ try {
366
+ // X_OK, not existsSync: a same-named directory or an unreadable file on PATH
367
+ // must not be recorded as the binary, or the recorded path is worse than the
368
+ // bare name it replaced.
369
+ fs.accessSync(candidate, fs.constants.X_OK);
370
+ // Resolve symlinks: ~/.local/bin/claude is typically a link into a versioned
371
+ // directory, and recording the link means a later version bump silently
372
+ // repoints every wake. The real path is what this session is actually running.
373
+ return fs.realpathSync(candidate);
374
+ }
375
+ catch {
376
+ // Not here; keep looking. No logging — a PATH entry that does not hold the
377
+ // binary is the normal case, not a problem.
378
+ }
379
+ }
380
+ return undefined;
381
+ }
351
382
  /**
352
383
  * Decide what resume id this attach registers, and say so out loud.
353
384
  *
@@ -303,6 +303,7 @@ class RelayDaemon {
303
303
  resumeEvidence: frame.resumeEvidence,
304
304
  resumeCwd: frame.resumeCwd,
305
305
  cwd: frame.cwd,
306
+ runtimeBin: frame.runtimeBin,
306
307
  });
307
308
  // A session that names itself supersedes anything discovery guessed
308
309
  // for it, so drop the throttle and let the next wake use the new id.
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.socketPath = socketPath;
37
+ exports.isNamedPipe = isNamedPipe;
37
38
  exports.pidFilePath = pidFilePath;
38
39
  exports.createFrameReader = createFrameReader;
39
40
  exports.writeFrame = writeFrame;
@@ -41,24 +42,60 @@ exports.probeSocket = probeSocket;
41
42
  exports.unlinkStaleSocket = unlinkStaleSocket;
42
43
  const fs = __importStar(require("fs"));
43
44
  const net = __importStar(require("net"));
45
+ const os = __importStar(require("os"));
44
46
  const path = __importStar(require("path"));
45
47
  const config_1 = require("../config");
46
48
  /**
47
- * Where the attach socket lives.
49
+ * Where the attach channel lives.
48
50
  *
49
- * `XDG_RUNTIME_DIR` is the right home: it is user-private (0700), on tmpfs, and
50
- * cleared on logout, so a stale socket never outlives the session that made it.
51
- * The config-dir fallback covers boxes without it (some containers, macOS).
51
+ * On Unix this is a filesystem path to a socket. `XDG_RUNTIME_DIR` is the right
52
+ * home: it is user-private (0700), on tmpfs, and cleared on logout, so a stale
53
+ * socket never outlives the session that made it. The config-dir fallback covers
54
+ * boxes without it — some containers, and macOS, which has no XDG_RUNTIME_DIR at
55
+ * all.
56
+ *
57
+ * ON WINDOWS IT IS NOT A PATH. Windows has no filesystem-visible Unix sockets:
58
+ * `net.createServer().listen("C:\\Users\\…\\relay.sock")` does not create a
59
+ * listener a peer can reach, and `net.createConnection` on the same string does
60
+ * not connect. Node's `net` speaks NAMED PIPES there instead, addressed as
61
+ * `\\.\pipe\<name>` — the same API, a different namespace, so every call site
62
+ * that already went through this function keeps working untouched.
63
+ *
64
+ * The name carries the username because the pipe namespace is machine-wide while
65
+ * the relay is per-user: two people signed in to one Windows box must not collide
66
+ * on one pipe, and must not be able to reach each other's daemon by opening it.
67
+ * (Reaching it is separately refused by the pipe's default ACL, which admits only
68
+ * the creating user — the name keeps the two daemons from fighting over it.)
52
69
  */
53
70
  function socketPath() {
54
71
  const override = process.env.BAYCHAT_RELAY_SOCKET;
55
72
  if (override)
56
73
  return override;
74
+ if (process.platform === "win32") {
75
+ return `\\\\.\\pipe\\baychat-relay-${sanitizePipeName(os.userInfo().username)}`;
76
+ }
57
77
  const runtimeDir = process.env.XDG_RUNTIME_DIR;
58
78
  if (runtimeDir)
59
79
  return path.join(runtimeDir, "baychat-relay.sock");
60
80
  return path.join((0, config_1.configDir)(), "relay.sock");
61
81
  }
82
+ /** A pipe name may not contain a backslash — it would read as another level of
83
+ * the pipe namespace — and Windows usernames legally can (`DOMAIN\user` reaches
84
+ * `os.userInfo()` that way on some setups). Everything outside the safe set is
85
+ * collapsed rather than dropped, so two different users cannot sanitize to one
86
+ * name. */
87
+ function sanitizePipeName(value) {
88
+ return value.replace(/[^A-Za-z0-9_-]/g, "_") || "default";
89
+ }
90
+ /** Is this endpoint a file on disk, or a Windows named pipe?
91
+ *
92
+ * A pipe is not a file: it has no directory entry, `fs.existsSync` is always
93
+ * false for it, and unlinking it is meaningless — the OS reclaims it when the
94
+ * last handle closes, which is exactly why a Windows relay never leaves a stale
95
+ * endpoint the way a hard-killed Unix one does. */
96
+ function isNamedPipe(endpoint) {
97
+ return endpoint.startsWith("\\\\.\\pipe\\") || endpoint.startsWith("\\\\?\\pipe\\");
98
+ }
62
99
  function pidFilePath() {
63
100
  return path.join((0, config_1.configDir)(), "relay.pid");
64
101
  }
@@ -101,7 +138,11 @@ function writeFrame(sock, frame) {
101
138
  */
102
139
  function probeSocket(sockPath, timeoutMs = 1_000) {
103
140
  return new Promise((resolve) => {
104
- if (!fs.existsSync(sockPath))
141
+ // The existence check is a cheap way to skip a connect that cannot succeed —
142
+ // but a named pipe has no directory entry, so on Windows it answers "false"
143
+ // for a perfectly healthy daemon. Skipped there; the connect below is the
144
+ // honest test on every platform anyway.
145
+ if (!isNamedPipe(sockPath) && !fs.existsSync(sockPath))
105
146
  return resolve(false);
106
147
  const sock = net.createConnection(sockPath);
107
148
  const done = (alive) => {
@@ -113,8 +154,16 @@ function probeSocket(sockPath, timeoutMs = 1_000) {
113
154
  sock.on("error", () => done(false));
114
155
  });
115
156
  }
116
- /** Remove a socket file we have already proven dead. */
157
+ /** Remove a socket file we have already proven dead.
158
+ *
159
+ * A no-op for a named pipe, which has nothing on disk to remove: Windows tears
160
+ * the pipe down when the last handle closes, so there is no stale-endpoint case
161
+ * to recover from there. Calling `fs.unlinkSync` on the pipe name would fail
162
+ * harmlessly, but silently — and a reader would be left believing this function
163
+ * had done something. */
117
164
  function unlinkStaleSocket(sockPath) {
165
+ if (isNamedPipe(sockPath))
166
+ return;
118
167
  try {
119
168
  fs.unlinkSync(sockPath);
120
169
  }