baychat 0.13.0 → 0.14.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,217 @@
1
+ "use strict";
2
+ // Waking a Codex thread through `codex app-server` instead of `codex exec resume`.
3
+ //
4
+ // WHY. `codex exec resume <id> <prompt>` is a fire-and-forget process whose only
5
+ // report is an exit code. It cannot say whether the turn answered, whether it
6
+ // stopped at an approval, or whether the id even named a real thread — every one
7
+ // of those is "exit non-zero", and the relay could only record DELIVERY PENDING
8
+ // with a number in it.
9
+ //
10
+ // `codex app-server` is the JSON-RPC 2.0 interface behind OpenAI's own VS Code
11
+ // and JetBrains plugins. It answers `thread/resume` for a specific id — with a
12
+ // distinct error when that id is not a real thread — and reports the turn's end
13
+ // as `turn/completed` rather than as a process exit. Verified against codex-cli
14
+ // 0.114.0 on 2026-08-30: initialize → initialized → thread/resume → turn/start,
15
+ // with the resumed thread's own MCP servers (BayChat included) started for it.
16
+ //
17
+ // ONE PROCESS PER WAKE, NOT A HELD CONNECTION. The design note for this work
18
+ // proposed keeping one app-server alive for the daemon's life. That buys a little
19
+ // latency and costs a supervision problem — restarts, health, a wedged child
20
+ // holding every session's queue — for a path that runs at human speed anyway.
21
+ // A process per wake keeps the failure modes identical to the ones `runHeadless`
22
+ // already has, and every win that mattered (an id we address explicitly, a
23
+ // structured turn result, no shell) survives.
24
+ //
25
+ // `approvalPolicy: "never"` IS THE SECURITY BOUNDARY. A headless turn has no
26
+ // human to ask, so the only safe policy is one that never prompts and never
27
+ // escalates: a command needing approval fails inside the sandbox instead of
28
+ // running because a chat message asked for it. The relay carries messages; it
29
+ // does not acquire privileges on their behalf.
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.runCodexTurn = runCodexTurn;
32
+ const runtime_binary_1 = require("../runtime-binary");
33
+ const spawn_env_1 = require("./spawn-env");
34
+ /** How long a whole wake may take — resume, turn, and completion. */
35
+ const TURN_TIMEOUT_MS = 10 * 60_000;
36
+ /** How long the handshake alone may take before we call the binary unusable. */
37
+ const HANDSHAKE_TIMEOUT_MS = 30_000;
38
+ /**
39
+ * Run one turn against an existing Codex thread and resolve with its outcome.
40
+ *
41
+ * Never throws and never rejects: every failure — a binary that will not start,
42
+ * a thread id that names nothing, a protocol error, a timeout — is a `failed`
43
+ * outcome carrying its reason. The caller's job is to record that, and a thrown
44
+ * exception would only turn a describable state into a stack trace.
45
+ */
46
+ function runCodexTurn(req, deps) {
47
+ const spawn = deps.spawn;
48
+ const turnTimeout = deps.turnTimeoutMs ?? TURN_TIMEOUT_MS;
49
+ const handshakeTimeout = deps.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
50
+ return new Promise((resolve) => {
51
+ // A Windows .cmd needs its interpreter named, exactly as the plain-spawn
52
+ // path does. This was missed when that fix landed, and the result was
53
+ // `delivery failed for CodexTest: spawn EINVAL` on every wake — the
54
+ // transport failing before the handshake, on a machine where Codex was
55
+ // installed and working.
56
+ const plan = (0, runtime_binary_1.spawnPlanFor)(req.binaryPath, process.platform);
57
+ const child = spawn(plan.file, [...plan.prefixArgs, "app-server"], {
58
+ cwd: req.cwd,
59
+ // Same reason as `runHeadless`: an npm-installed codex is a script with a
60
+ // `#!/usr/bin/env node` shebang and the daemon's PATH has no node. Both
61
+ // spawn sites need this — fixing only one leaves the other dying with
62
+ // exit 127 the moment it becomes the reachable path.
63
+ env: (0, spawn_env_1.headlessSpawnEnv)(),
64
+ // No shell, ever: the prompt carries message text written by other people
65
+ // in the room, and a shell string would make `$(…)` in a chat message run
66
+ // on this box. Nothing here is concatenated into a command line.
67
+ shell: false,
68
+ stdio: ["pipe", "pipe", "pipe"],
69
+ });
70
+ let settled = false;
71
+ let nextId = 0;
72
+ const pending = new Map();
73
+ let stdoutBuffer = "";
74
+ let stderrTail = "";
75
+ /** Resolve once, and always leave no process behind. */
76
+ const finish = (outcome) => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ clearTimeout(timer);
81
+ child.kill("SIGTERM");
82
+ // A child that ignores the polite signal would otherwise outlive the relay
83
+ // and keep answering a room nobody is watching.
84
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
85
+ resolve(outcome);
86
+ };
87
+ let timer = setTimeout(() => finish({ kind: "failed", transportUnusable: true, reason: `codex app-server did not complete the handshake within ${Math.round(handshakeTimeout / 1000)}s` }), handshakeTimeout);
88
+ const request = (method, params) => new Promise((res) => {
89
+ const id = nextId++;
90
+ pending.set(id, res);
91
+ child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
92
+ });
93
+ const notify = (method, params) => {
94
+ child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
95
+ };
96
+ child.stdout?.on("data", (chunk) => {
97
+ stdoutBuffer += chunk.toString();
98
+ // Newline-delimited JSON. A partial line is kept for the next chunk;
99
+ // parsing one would be the classic framing bug.
100
+ let newline = stdoutBuffer.indexOf("\n");
101
+ while (newline >= 0) {
102
+ const line = stdoutBuffer.slice(0, newline).trim();
103
+ stdoutBuffer = stdoutBuffer.slice(newline + 1);
104
+ newline = stdoutBuffer.indexOf("\n");
105
+ if (line === "")
106
+ continue;
107
+ let message;
108
+ try {
109
+ message = JSON.parse(line);
110
+ }
111
+ catch {
112
+ // Not our protocol. Ignored rather than fatal: the binary is entitled
113
+ // to print things, and a stray line must not lose a turn that is
114
+ // otherwise proceeding normally.
115
+ continue;
116
+ }
117
+ if (typeof message.id === "number" && pending.has(message.id)) {
118
+ const waiting = pending.get(message.id);
119
+ pending.delete(message.id);
120
+ waiting?.(message);
121
+ continue;
122
+ }
123
+ // `turn/completed` is the end of the wake — the whole reason this
124
+ // transport is better than an exit code.
125
+ if (message.method === "turn/completed")
126
+ finish({ kind: "completed" });
127
+ }
128
+ });
129
+ child.stderr?.on("data", (chunk) => {
130
+ // Bounded, and kept only to explain a failure. Codex logs freely here even
131
+ // on a healthy run, so it is never treated as a failure signal by itself.
132
+ const text = chunk.toString();
133
+ stderrTail = `${stderrTail}${text}`.slice(-2_000);
134
+ });
135
+ child.on("error", (err) => {
136
+ finish({ kind: "failed", transportUnusable: true, reason: `could not start codex app-server: ${err.message}` });
137
+ });
138
+ child.on("close", (code) => {
139
+ // Only meaningful if we have not already completed: an expected exit
140
+ // follows our own SIGTERM.
141
+ finish({
142
+ kind: "failed",
143
+ // Dying before the turn completed means the transport did not work,
144
+ // whatever the cause — worth one attempt down the older path.
145
+ transportUnusable: true,
146
+ reason: `codex app-server exited ${code ?? "on a signal"} before the turn completed${firstLine(stderrTail)}`,
147
+ });
148
+ });
149
+ void (async () => {
150
+ const initialized = await request("initialize", {
151
+ clientInfo: { name: "baychat-relay", title: "BayChat relay", version: CLIENT_VERSION },
152
+ });
153
+ if (initialized.error) {
154
+ finish({ kind: "failed", transportUnusable: true, reason: `codex app-server refused the handshake: ${initialized.error.message}` });
155
+ return;
156
+ }
157
+ notify("initialized", {});
158
+ const resumed = await request("thread/resume", {
159
+ threadId: req.threadId,
160
+ cwd: req.cwd,
161
+ // See the file header: no human is present, so nothing may be approved.
162
+ approvalPolicy: "never",
163
+ });
164
+ if (resumed.error) {
165
+ // "already has an active writer" is not a fault — it is the interactive
166
+ // session being OPEN. A thread being written by a live Codex TUI cannot
167
+ // also be resumed from outside it, and that is the correct behaviour:
168
+ // two writers on one thread is the failure mode, not the refusal.
169
+ //
170
+ // It also names the remedy exactly. A live session is reachable through
171
+ // its `relay attach`, which is what attach is FOR; headless resume is
172
+ // for a session whose terminal has gone. Saying "could not resume" and
173
+ // stopping there sends someone hunting for a break that is not there.
174
+ if (/active writer/i.test(resumed.error.message)) {
175
+ finish({
176
+ kind: "failed",
177
+ transportUnusable: false,
178
+ reason: `Codex session "${req.threadId}" is open in a terminal, so it cannot be resumed from ` +
179
+ `outside it — a live session is woken through \`baychat relay attach\`, which must be ` +
180
+ `running in that session. Close the terminal to make it headlessly resumable instead.`,
181
+ });
182
+ return;
183
+ }
184
+ // The distinct failure `exec resume` could never report: the id does not
185
+ // name a thread on this machine. Worth saying plainly, because the usual
186
+ // cause is a Codex whose sessions live somewhere else — a snap install
187
+ // keeps them under ~/snap/codex/current/sessions.
188
+ finish({ kind: "failed", transportUnusable: false, reason: `codex could not resume thread ${req.threadId}: ${resumed.error.message}` });
189
+ return;
190
+ }
191
+ // The handshake is done; the clock is now the turn's, which is far longer.
192
+ clearTimeout(timer);
193
+ timer = setTimeout(() => finish({ kind: "failed", transportUnusable: false, reason: `codex turn did not complete within ${Math.round(turnTimeout / 60_000)} minutes` }), turnTimeout);
194
+ const started = await request("turn/start", {
195
+ threadId: req.threadId,
196
+ input: [{ type: "text", text: req.prompt }],
197
+ approvalPolicy: "never",
198
+ });
199
+ if (started.error) {
200
+ finish({ kind: "failed", transportUnusable: false, reason: `codex refused the turn: ${started.error.message}` });
201
+ }
202
+ // Success is NOT the response to turn/start — that only says the turn was
203
+ // accepted. The wake ends at the `turn/completed` notification, handled above.
204
+ })();
205
+ });
206
+ }
207
+ /** Reported to Codex as the client version. Kept in step with the package. */
208
+ const CLIENT_VERSION = "0.12.0";
209
+ /** One line of captured stderr, for appending to a failure reason. */
210
+ function firstLine(text) {
211
+ for (const line of text.split("\n")) {
212
+ const trimmed = line.trim();
213
+ if (trimmed !== "")
214
+ return `: ${trimmed.slice(0, 200)}`;
215
+ }
216
+ return "";
217
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.queueToThread = queueToThread;
4
+ const child_process_1 = require("child_process");
5
+ const runtime_binary_1 = require("../runtime-binary");
6
+ const spawn_env_1 = require("./spawn-env");
7
+ /** How long `codex queue` may take before we treat it as unusable. */
8
+ const QUEUE_TIMEOUT_MS = 30_000;
9
+ /**
10
+ * Deliver a message to a Codex session through Codex's own queue.
11
+ *
12
+ * WHY THIS IS THE RIGHT PATH, and better than every rung it sits above.
13
+ *
14
+ * The relay's other options all fight the runtime. A socket attach is refused
15
+ * inside a sandbox; a FIFO reaches a process but nothing re-invokes the agent;
16
+ * a headless resume starts a SEPARATE turn with no human, which is why it must
17
+ * run `approvalPolicy: "never"` — and that policy blocks Codex's own BayChat
18
+ * write path, so a headlessly-woken Codex can read the room and never answer it.
19
+ * Observed 2026-08-31 00:09 in Codex's own words: "BayChat's write path is
20
+ * unavailable under the enforced no-approval policy."
21
+ *
22
+ * `codex queue` sidesteps all of it. The message goes to the LIVE session, where
23
+ * the human already is, so approvals work normally and the relay never has to
24
+ * acquire a privilege on a chat message's behalf. It is first-party, needs no
25
+ * pty, injects nothing into a terminal, and queues when the session is between
26
+ * turns rather than being lost.
27
+ *
28
+ * Requires Codex >= 0.149.0, which is where `queue` landed. An older build is
29
+ * reported `unsupported` so the caller drops to the spawn, exactly as `runTurn`
30
+ * does with `transportUnusable`.
31
+ *
32
+ * argv array, never a shell string: the message is text written by other people
33
+ * in the room, and a shell would make `$(…)` in a chat message run on this box.
34
+ */
35
+ function queueToThread(input) {
36
+ const runner = input.run ?? child_process_1.execFile;
37
+ // Node cannot spawn a `.cmd` directly — it fails EINVAL — so on Windows the
38
+ // interpreter is named explicitly. NEVER `shell: true`: the message is text
39
+ // written by other people in the room, and a shell string would make `$(…)`
40
+ // in a chat message run on this box.
41
+ const plan = (0, runtime_binary_1.spawnPlanFor)(input.binaryPath, input.platform ?? process.platform);
42
+ return new Promise((resolve) => {
43
+ runner(plan.file, [...plan.prefixArgs, "queue", "--thread", input.threadId, "--message", input.message], {
44
+ cwd: input.cwd,
45
+ env: (0, spawn_env_1.headlessSpawnEnv)(),
46
+ timeout: input.timeoutMs ?? QUEUE_TIMEOUT_MS,
47
+ maxBuffer: 1024 * 1024,
48
+ }, (err, stdout, stderr) => {
49
+ const out = `${stdout ?? ""}${stderr ?? ""}`;
50
+ if (!err) {
51
+ // "Queued message <uuid> for thread <uuid>." — kept as evidence, the
52
+ // same way a resume id carries the evidence it was learned with.
53
+ const id = /Queued message ([0-9a-fA-F-]{8,})/.exec(out)?.[1];
54
+ return resolve({ kind: "queued", messageId: id });
55
+ }
56
+ // An older Codex has no `queue` subcommand. clap says so on stderr, and
57
+ // it is a statement about the BUILD, not about this session — so the
58
+ // caller may still try the rung below.
59
+ if (/unrecognized subcommand|unexpected argument|error: unknown/i.test(out)) {
60
+ return resolve({ kind: "unsupported", reason: `this codex build has no \`queue\` subcommand: ${firstLine(out)}` });
61
+ }
62
+ resolve({ kind: "failed", reason: firstLine(out) || err.message });
63
+ });
64
+ });
65
+ }
66
+ function firstLine(text) {
67
+ return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
68
+ }
@@ -35,26 +35,36 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.cmdRelayStart = cmdRelayStart;
37
37
  exports.ensureRelayInstalled = ensureRelayInstalled;
38
+ exports.tryRelayStatus = tryRelayStatus;
38
39
  exports.cmdRelayStatus = cmdRelayStatus;
39
40
  exports.cmdRelayStop = cmdRelayStop;
41
+ exports.shouldFallBackToMailbox = shouldFallBackToMailbox;
42
+ exports.attachViaMailbox = attachViaMailbox;
43
+ exports.renderSessionLine = renderSessionLine;
40
44
  exports.cmdRelayAttach = cmdRelayAttach;
41
45
  exports.resolveRuntimeBin = resolveRuntimeBin;
42
46
  const child_process_1 = require("child_process");
43
47
  const fs = __importStar(require("fs"));
44
48
  const net = __importStar(require("net"));
45
- const path = __importStar(require("path"));
46
49
  const util_1 = require("util");
47
50
  const adapters_1 = require("./adapters");
48
51
  const autostart_1 = require("./autostart");
52
+ const runtime_binary_1 = require("../runtime-binary");
49
53
  const daemon_1 = require("./daemon");
50
54
  const resume_1 = require("./resume");
55
+ const mailbox_1 = require("./mailbox");
51
56
  const socket_1 = require("./socket");
57
+ const parent_watch_1 = require("./parent-watch");
52
58
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
53
59
  /** Connect to a running daemon, or explain that there isn't one. */
54
60
  async function connectOrFail() {
55
61
  const sockPath = (0, socket_1.socketPath)();
56
- if (!(await (0, socket_1.probeSocket)(sockPath))) {
57
- throw new Error("no relay is running — start one with `baychat relay start`");
62
+ const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
63
+ if (!probe.alive) {
64
+ // NOT a flat "no relay is running". See `describeProbeFailure`: a refused
65
+ // pipe and a wedged daemon are both live relays, and telling someone to
66
+ // start another one is the one instruction that makes those worse.
67
+ throw new Error((0, socket_1.describeProbeFailure)(sockPath, probe));
58
68
  }
59
69
  return net.createConnection(sockPath);
60
70
  }
@@ -165,6 +175,26 @@ async function ensureRelayInstalled() {
165
175
  return `Relay: could not start automatically (${err instanceof Error ? err.message : String(err)}). Run \`baychat relay start\` yourself.`;
166
176
  }
167
177
  }
178
+ /**
179
+ * The daemon's status, or null when there is no daemon.
180
+ *
181
+ * `cmdRelayStatus` treats an absent relay as an error to report, which is right
182
+ * for a command whose whole subject is the relay. `doctor` needs the opposite:
183
+ * "not running" is one finding among several and must not abort the rest of the
184
+ * report, so the absence is a value here rather than a thrown error.
185
+ */
186
+ async function tryRelayStatus() {
187
+ try {
188
+ return await request(await connectOrFail(), { type: "status" });
189
+ }
190
+ catch {
191
+ // Every failure means the same thing to a caller that only wants to know
192
+ // whether the relay can answer: no socket, a stale socket, or a daemon too
193
+ // wedged to reply are all "no usable relay". `doctor` reports that state
194
+ // itself; there is nothing here worth logging over it.
195
+ return null;
196
+ }
197
+ }
168
198
  async function cmdRelayStatus() {
169
199
  let status;
170
200
  try {
@@ -184,7 +214,7 @@ async function cmdRelayStatus() {
184
214
  console.log(" none — a session registers itself by running `baychat relay attach`");
185
215
  }
186
216
  for (const s of status.sessions) {
187
- const state = s.attached ? "attached" : s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
217
+ const state = sessionState(s);
188
218
  console.log(` ${s.name} [${s.runtime}] ${state}`);
189
219
  console.log(` ${resumeLabel(s)}`);
190
220
  }
@@ -265,6 +295,94 @@ async function cmdRelayStop() {
265
295
  * which then reads the room properly through the BayChat tools. Exit 2 means
266
296
  * the wait lapsed with nothing to report.
267
297
  */
298
+ /**
299
+ * Is this probe failure the sandboxed case, and ONLY that?
300
+ *
301
+ * `denied` means a daemon exists and refused us — precisely a sandbox or token
302
+ * boundary, and precisely what the mailbox is for. Every other reason keeps
303
+ * today's error, deliberately:
304
+ *
305
+ * - `absent` — there is no relay. A mailbox would be created that no daemon
306
+ * is watching, and the agent would block on a FIFO forever believing it was
307
+ * reachable. That is the exact failure this transport exists to end, and
308
+ * the fallback must not reintroduce it.
309
+ * - `timeout` / `error` — a wedged or unexplained daemon. Papering over it
310
+ * with a second channel hides the thing someone needs to fix.
311
+ */
312
+ function shouldFallBackToMailbox(probe) {
313
+ return !probe.alive && probe.reason === "denied";
314
+ }
315
+ /**
316
+ * Arm over the mailbox: register by file, then block on a FIFO read.
317
+ *
318
+ * FOREGROUND, and it must stay foreground. A backgrounded process does not
319
+ * survive the sandbox: measured 2026-08-30, `nohup bash -c 'sleep 25; …' &`
320
+ * inside Codex never wrote its file, because bubblewrap tears down the mount
321
+ * namespace and kills the process group when the command returns. Telling a
322
+ * sandboxed agent to background this would be handing it an instruction that
323
+ * cannot work — which is how one came to report "Armed and joined the group"
324
+ * having armed nothing at all.
325
+ */
326
+ async function attachViaMailbox(opts) {
327
+ // PROVE the root by creating it, rather than resolving one and hoping. A
328
+ // sandbox that permits /tmp may still refuse XDG_RUNTIME_DIR, and only this
329
+ // process can find that out.
330
+ const dir = (0, mailbox_1.pickUsableMailboxDir)(opts.session);
331
+ // Declare the path; the daemon creates the FIFO. Making one requires spawning
332
+ // `mkfifo(1)`, and a sandbox that permits writing files still refuses to spawn
333
+ // a helper binary (measured 2026-08-30: `spawnSync mkfifo EPERM`).
334
+ const fifo = (0, mailbox_1.wakeFifoPath)(dir);
335
+ (0, mailbox_1.writeRegistration)(dir, {
336
+ session: opts.session,
337
+ runtime: opts.runtime,
338
+ resumeId: opts.resume.ok ? opts.resume.resumeId : undefined,
339
+ resumeSource: opts.resume.ok ? opts.resume.source : undefined,
340
+ resumeEvidence: opts.resume.ok ? opts.resume.evidence : undefined,
341
+ resumeCwd: opts.resume.ok ? opts.resume.cwd : undefined,
342
+ cwd: process.cwd(),
343
+ // Resolved HERE, inside the session, for the same reason the socket rung
344
+ // does it: the binary a session was launched with is knowable here and
345
+ // nowhere else.
346
+ runtimeBin: resolveRuntimeBin(opts.runtime),
347
+ fifo,
348
+ pid: process.pid,
349
+ registeredAt: new Date().toISOString(),
350
+ });
351
+ await (0, mailbox_1.awaitWakeFifo)(fifo);
352
+ console.log(`Attached as "${opts.session}" over a mailbox FIFO (${fifo}). Waiting for messages…`);
353
+ // Opening for read blocks until the daemon opens for write. That block IS the
354
+ // wait, and it costs nothing: no model is running while it holds.
355
+ const raw = await fs.promises.readFile(fifo, "utf8");
356
+ const frame = JSON.parse(raw.trim());
357
+ console.log(`WAKE ${frame.messages.length} message(s) in ${frame.conversationId}:`);
358
+ for (const m of frame.messages) {
359
+ const flag = m.shouldRespond ? " [shouldRespond=true]" : "";
360
+ console.log(` (${m.id}) ${m.senderType} ${m.senderId}${flag}: ${m.content}`);
361
+ }
362
+ return 0;
363
+ }
364
+ /** The state half of one `relay status` session line. */
365
+ function sessionState(s) {
366
+ // "registered", not "attached", for the FIFO rung — and the difference is not
367
+ // pedantry. A socket attach IS a held connection, so `attached` is an
368
+ // observation. A mailbox registration is a file on disk; whether the agent is
369
+ // still blocked on its FIFO cannot be checked without ending the wait, since
370
+ // a reader blocked in open() holds no descriptor for /proc to see and opening
371
+ // the write end to look would signal EOF. Delivery is the only honest probe,
372
+ // and it makes it: a wake with no reader is recorded pending, never delivered.
373
+ if (s.attached)
374
+ return s.transport === "fifo" ? "registered (fifo)" : "attached";
375
+ return s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
376
+ }
377
+ /**
378
+ * One `relay status` session line.
379
+ *
380
+ * Exported so the rung's wording is testable: a fallback nobody can see in
381
+ * `status` is a silent fallback, which is the thing this transport must not be.
382
+ */
383
+ function renderSessionLine(target) {
384
+ return ` ${target.name} [${target.runtime}] ${sessionState(target)}`;
385
+ }
268
386
  async function cmdRelayAttach(opts) {
269
387
  if (!(0, adapters_1.isKnownRuntime)(opts.runtime)) {
270
388
  console.log(`unknown runtime "${opts.runtime}" — expected one of: ${adapters_1.KNOWN_RUNTIMES.join(", ")}`);
@@ -272,14 +390,18 @@ async function cmdRelayAttach(opts) {
272
390
  }
273
391
  const runtime = opts.runtime;
274
392
  const resume = await resolveAttachResumeId(runtime, opts.resumeId, opts.discovery);
275
- let sock;
276
- try {
277
- sock = await connectOrFail();
393
+ const sockPath = (0, socket_1.socketPath)();
394
+ const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
395
+ if (shouldFallBackToMailbox(probe)) {
396
+ console.log(`Relay socket refused (${probe.alive ? "" : (probe.code ?? "denied")}) — this session is sandboxed.`);
397
+ console.log("Falling back to a mailbox FIFO, which a sandbox permits. `relay status` will show this session as attached (fifo).");
398
+ return attachViaMailbox({ session: opts.session, runtime, resume });
278
399
  }
279
- catch (err) {
280
- console.log(err instanceof Error ? err.message : String(err));
400
+ if (!probe.alive) {
401
+ console.log((0, socket_1.describeProbeFailure)(sockPath, probe));
281
402
  return 1;
282
403
  }
404
+ const sock = net.createConnection(sockPath);
283
405
  return new Promise((resolve) => {
284
406
  const timer = opts.timeoutMs
285
407
  ? setTimeout(() => {
@@ -288,6 +410,19 @@ async function cmdRelayAttach(opts) {
288
410
  resolve(2);
289
411
  }, opts.timeoutMs)
290
412
  : undefined;
413
+ // An attach outlives nothing. When the session that launched it goes away,
414
+ // this process must go with it — otherwise it keeps the socket open, the
415
+ // daemon keeps believing the session is live, and the next wake is written
416
+ // into a corpse and recorded as delivered. See ./parent-watch.ts.
417
+ const parentWatch = (0, parent_watch_1.watchParent)(process.ppid, () => {
418
+ console.log("Session that started this attach has exited — detaching so the relay stops treating it as live.");
419
+ sock.end();
420
+ resolve(3);
421
+ });
422
+ const settle = (code) => {
423
+ parentWatch.stop();
424
+ resolve(code);
425
+ };
291
426
  sock.on("data", (0, socket_1.createFrameReader)((frame) => {
292
427
  if (frame.type === "attached") {
293
428
  console.log(`Attached as "${frame.session}". Waiting for messages…`);
@@ -302,7 +437,7 @@ async function cmdRelayAttach(opts) {
302
437
  console.log(` (${m.id}) ${m.senderType} ${m.senderId}${flag}: ${m.content}`);
303
438
  }
304
439
  sock.end();
305
- resolve(0);
440
+ settle(0);
306
441
  return;
307
442
  }
308
443
  if (frame.type === "error") {
@@ -310,19 +445,19 @@ async function cmdRelayAttach(opts) {
310
445
  clearTimeout(timer);
311
446
  console.log(frame.message);
312
447
  sock.end();
313
- resolve(1);
448
+ settle(1);
314
449
  }
315
450
  }));
316
451
  sock.on("error", (err) => {
317
452
  if (timer)
318
453
  clearTimeout(timer);
319
454
  console.log(err.message);
320
- resolve(1);
455
+ settle(1);
321
456
  });
322
457
  sock.on("close", () => {
323
458
  if (timer)
324
459
  clearTimeout(timer);
325
- resolve(2);
460
+ settle(2);
326
461
  });
327
462
  (0, socket_1.writeFrame)(sock, {
328
463
  type: "attach",
@@ -359,25 +494,36 @@ async function cmdRelayAttach(opts) {
359
494
  * machine where PATH is fine keeps working, and one where it is not now works too.
360
495
  */
361
496
  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
- }
497
+ // Delegates to the prover rather than walking PATH itself. The hand-rolled
498
+ // walk this replaces had two Windows faults, and both produced a recorded
499
+ // path the daemon could never spawn:
500
+ //
501
+ // • it joined the BARE name onto each PATH entry, so it never considered
502
+ // `codex.cmd` — and every npm-installed CLI on Windows IS a .cmd shim;
503
+ // • it accepted a candidate on `fs.accessSync(X_OK)`, which Windows has no
504
+ // execute bit to answer, so any readable file passes. The extensionless
505
+ // `codex` — an sh script, unrunnable there — sailed through.
506
+ //
507
+ // Observed 2026-08-30: runtimeBin recorded as
508
+ // `C:\Users\…\npm\codex`, and the wake died with `spawn … ENOENT`.
509
+ //
510
+ // `resolveRuntimeBinary` tries the platform's real extension order and PROVES
511
+ // each candidate by running it, which is the same question this function was
512
+ // always asking — just answered correctly.
513
+ const override = process.env[`BAYCHAT_${runtime.toUpperCase()}_BIN`];
514
+ const resolved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(override));
515
+ if (!resolved.ok)
516
+ return undefined;
517
+ try {
518
+ // Resolve symlinks: ~/.local/bin/claude is typically a link into a versioned
519
+ // directory, and recording the link means a later version bump silently
520
+ // repoints every wake. The real path is what this session is actually running.
521
+ return fs.realpathSync(resolved.path);
522
+ }
523
+ catch {
524
+ // A path we just ran but cannot realpath is still the right answer.
525
+ return resolved.path;
379
526
  }
380
- return undefined;
381
527
  }
382
528
  /**
383
529
  * Decide what resume id this attach registers, and say so out loud.