baychat 0.13.1 → 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.
- package/README.md +146 -1
- package/dist/args.js +57 -0
- package/dist/client-paths.js +69 -0
- package/dist/commands.js +108 -0
- package/dist/connect.js +46 -14
- package/dist/credential-refresh.js +97 -0
- package/dist/doctor-command.js +154 -0
- package/dist/doctor.js +502 -0
- package/dist/index.js +40 -26
- package/dist/relay/adapters.js +82 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +177 -31
- package/dist/relay/daemon.js +259 -3
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +319 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +98 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +269 -0
- package/dist/runtimes.js +118 -46
- package/package.json +2 -2
package/dist/relay/commands.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
+
settle(1);
|
|
321
456
|
});
|
|
322
457
|
sock.on("close", () => {
|
|
323
458
|
if (timer)
|
|
324
459
|
clearTimeout(timer);
|
|
325
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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.
|
package/dist/relay/daemon.js
CHANGED
|
@@ -36,10 +36,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.RelayDaemon = void 0;
|
|
37
37
|
const fs = __importStar(require("fs"));
|
|
38
38
|
const net = __importStar(require("net"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
39
40
|
const config_1 = require("../config");
|
|
41
|
+
const runtime_binary_1 = require("../runtime-binary");
|
|
40
42
|
const adapters_1 = require("./adapters");
|
|
41
43
|
const queue_1 = require("./queue");
|
|
42
44
|
const registry_1 = require("./registry");
|
|
45
|
+
const mailbox_1 = require("./mailbox");
|
|
46
|
+
const mailbox_watcher_1 = require("./mailbox-watcher");
|
|
43
47
|
const socket_1 = require("./socket");
|
|
44
48
|
const transport_1 = require("./transport");
|
|
45
49
|
const watermarks_1 = require("./watermarks");
|
|
@@ -57,6 +61,9 @@ class RelayDaemon {
|
|
|
57
61
|
abort = new AbortController();
|
|
58
62
|
/** Live attach sockets by session name. */
|
|
59
63
|
attached = new Map();
|
|
64
|
+
/** Sessions reachable over a mailbox FIFO, by session name. */
|
|
65
|
+
mailboxes = new Map();
|
|
66
|
+
mailboxWatchers;
|
|
60
67
|
/** Per (session, conversation) delivery positions — the 409 catch-up baseline. */
|
|
61
68
|
watermarks = new watermarks_1.Watermarks();
|
|
62
69
|
pending = [];
|
|
@@ -75,11 +82,58 @@ class RelayDaemon {
|
|
|
75
82
|
discoveryReasons = new Map();
|
|
76
83
|
discovery;
|
|
77
84
|
spawnHeadless;
|
|
85
|
+
resolveBinary;
|
|
86
|
+
runTurn;
|
|
87
|
+
/**
|
|
88
|
+
* Resolutions already proven this process, by runtime binary name.
|
|
89
|
+
*
|
|
90
|
+
* Resolution costs a `--version` spawn per candidate, which must not be paid
|
|
91
|
+
* per message. The entry is dropped whenever a headless turn fails, so a user
|
|
92
|
+
* who installs the missing runtime is believed on the next message rather
|
|
93
|
+
* than after a relay restart nothing would have told them to perform.
|
|
94
|
+
*/
|
|
95
|
+
binaries = new Map();
|
|
78
96
|
log;
|
|
79
97
|
constructor(opts = {}) {
|
|
80
98
|
this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
|
|
99
|
+
// EVERY candidate root, because the daemon cannot know which one the agent's
|
|
100
|
+
// sandbox turned out to permit — it may have fallen back past the one this
|
|
101
|
+
// process would have chosen.
|
|
102
|
+
this.mailboxWatchers = (0, mailbox_1.mailboxRootCandidates)().map((root) => new mailbox_watcher_1.MailboxWatcher({
|
|
103
|
+
root,
|
|
104
|
+
onRegister: (reg) => {
|
|
105
|
+
this.registry.upsert((0, mailbox_1.registrationToTarget)(reg));
|
|
106
|
+
// A session that names itself supersedes anything discovery guessed,
|
|
107
|
+
// exactly as an attach frame does.
|
|
108
|
+
if (reg.resumeId)
|
|
109
|
+
this.discoveryReasons.delete(reg.session);
|
|
110
|
+
// The daemon makes the FIFO, because the agent cannot: creating one
|
|
111
|
+
// needs `mkfifo(1)` spawned, and a sandbox that lets an agent write
|
|
112
|
+
// files still refuses it a helper process. This side has the privilege,
|
|
113
|
+
// so this side does the privileged step.
|
|
114
|
+
try {
|
|
115
|
+
// ENSURE, never recreate: a re-arm must not unlink the FIFO a live
|
|
116
|
+
// reader is already blocked on. See `ensureWakeFifo`.
|
|
117
|
+
(0, mailbox_1.ensureWakeFifo)(path.dirname(reg.fifo));
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
this.log(`could not create the wake FIFO for ${reg.session}: ${errText(err)}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.mailboxes.set(reg.session, reg);
|
|
124
|
+
this.registry.setAttached(reg.session, true);
|
|
125
|
+
this.log(`attached via mailbox: ${reg.session} (${reg.runtime}) at ${reg.fifo}`);
|
|
126
|
+
},
|
|
127
|
+
onDrop: (session, reason) => {
|
|
128
|
+
this.mailboxes.delete(session);
|
|
129
|
+
this.registry.setAttached(session, false);
|
|
130
|
+
this.log(`detached: ${session} — ${reason}`);
|
|
131
|
+
},
|
|
132
|
+
}));
|
|
81
133
|
this.discovery = opts.discovery ?? {};
|
|
82
134
|
this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
|
|
135
|
+
this.resolveBinary = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
|
|
136
|
+
this.runTurn = opts.runTurn;
|
|
83
137
|
this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
|
|
84
138
|
this.lastError = `delivery failed for ${session}: ${errText(err)}`;
|
|
85
139
|
this.log(this.lastError);
|
|
@@ -104,6 +158,10 @@ class RelayDaemon {
|
|
|
104
158
|
throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
|
|
105
159
|
}
|
|
106
160
|
(0, socket_1.unlinkStaleSocket)(sockPath);
|
|
161
|
+
// Sandboxed agents cannot reach the socket below, so the mailbox has to be
|
|
162
|
+
// watched from the moment the daemon is up — not lazily on first delivery.
|
|
163
|
+
for (const w of this.mailboxWatchers)
|
|
164
|
+
w.start();
|
|
107
165
|
this.server = net.createServer((sock) => this.onConnection(sock));
|
|
108
166
|
await new Promise((resolve, reject) => {
|
|
109
167
|
this.server.once("error", reject);
|
|
@@ -189,6 +247,26 @@ class RelayDaemon {
|
|
|
189
247
|
async deliver(session, batch) {
|
|
190
248
|
const target = this.registry.get(session);
|
|
191
249
|
if (!target) {
|
|
250
|
+
// NOT reported as pending, though an earlier version of this did.
|
|
251
|
+
//
|
|
252
|
+
// `liveSessions` lists every session of the USER, not of this machine. A
|
|
253
|
+
// person with WSL and Windows side by side has BOTH relays receiving
|
|
254
|
+
// events for BOTH machines' sessions, because the server fans out per
|
|
255
|
+
// credential and a session's host is not something it records. Treating
|
|
256
|
+
// "live for this user" as "ours" made each relay report DELIVERY PENDING
|
|
257
|
+
// for sessions the other machine was serving perfectly well — and a false
|
|
258
|
+
// pending is worse than none, because the entire value of that signal is
|
|
259
|
+
// that it means something is genuinely wrong.
|
|
260
|
+
//
|
|
261
|
+
// A session in the registry cannot reach here at all (`target` would be
|
|
262
|
+
// set), so there is no local evidence to appeal to: this name has never
|
|
263
|
+
// been seen on this machine. Another device is very likely handling it,
|
|
264
|
+
// and silence is the honest answer.
|
|
265
|
+
//
|
|
266
|
+
// The genuinely uncovered case — a session that joined a room and never
|
|
267
|
+
// attached ANYWHERE — is per-machine, not per-message, and belongs in
|
|
268
|
+
// `doctor`, which can compare the Bay's live sessions against this
|
|
269
|
+
// machine's registry without ever firing a false alarm.
|
|
192
270
|
this.record({ kind: "ignored", reason: `unknown session ${session}` }, session, batch);
|
|
193
271
|
return;
|
|
194
272
|
}
|
|
@@ -202,10 +280,98 @@ class RelayDaemon {
|
|
|
202
280
|
return;
|
|
203
281
|
}
|
|
204
282
|
const adapter = (0, adapters_1.adapterFor)(target.runtime);
|
|
283
|
+
// ORDERED ABOVE THE FIFO, deliberately.
|
|
284
|
+
//
|
|
285
|
+
// A FIFO hands bytes to a blocked `relay attach`, which prints them and
|
|
286
|
+
// exits. Where the harness re-invokes on that exit (Claude) it is a wake;
|
|
287
|
+
// for an interactive TUI it is only a PRINT — the text appears and nothing
|
|
288
|
+
// makes the agent act on it. Measured 2026-08-31 01:08: delivered via fifo
|
|
289
|
+
// to a live Codex, which then said nothing.
|
|
290
|
+
//
|
|
291
|
+
// `codex queue` puts the message in the session's own turn queue, so the
|
|
292
|
+
// agent ACTS. Prefer the transport that produces an ANSWER over the one
|
|
293
|
+
// that only produces a delivery.
|
|
294
|
+
// RUNG 2: the runtime's OWN inter-session queue, where it has one.
|
|
295
|
+
//
|
|
296
|
+
// Preferred over the headless spawn below because it reaches the LIVE
|
|
297
|
+
// session, where the human already is — so approvals work normally and the
|
|
298
|
+
// relay never acquires a privilege on a chat message's behalf. The headless
|
|
299
|
+
// rung must run `approvalPolicy: "never"`, which blocks Codex's own BayChat
|
|
300
|
+
// write path: observed 2026-08-31, a headlessly-woken Codex read the room
|
|
301
|
+
// and was structurally unable to answer it.
|
|
302
|
+
const resolvedForQueue = await this.resolveResume(target);
|
|
303
|
+
if (adapter.queueMessage && resolvedForQueue.resumeId) {
|
|
304
|
+
// Same rule as the headless spawn: an ABSOLUTE runtimeBin came from the
|
|
305
|
+
// session itself and is never re-resolved; only a bare name is looked up.
|
|
306
|
+
const declared = resolvedForQueue.runtimeBin ?? target.runtime;
|
|
307
|
+
let queueBin = declared;
|
|
308
|
+
if (!path.isAbsolute(declared)) {
|
|
309
|
+
const resolved = this.binaryFor(declared);
|
|
310
|
+
queueBin = resolved.ok ? resolved.path : "";
|
|
311
|
+
}
|
|
312
|
+
if (!queueBin) {
|
|
313
|
+
this.log(`queue unavailable for ${session}: no usable ${target.runtime} binary`);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
const outcome = await adapter.queueMessage({
|
|
317
|
+
binaryPath: queueBin,
|
|
318
|
+
target: resolvedForQueue,
|
|
319
|
+
message: (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, undefined),
|
|
320
|
+
});
|
|
321
|
+
if (outcome.kind === "queued") {
|
|
322
|
+
this.record({ kind: "woken", via: "queue", session }, session, batch);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// EVERY non-success falls through, including `failed`.
|
|
326
|
+
//
|
|
327
|
+
// The queue is an OPTIMISATION, not a gate: it reaches the live session so
|
|
328
|
+
// approvals work, but the headless rung below is strictly more capable —
|
|
329
|
+
// it carries the Windows `.cmd` spawn plan, the app-server transport, and
|
|
330
|
+
// the binary resolution this path does not replicate. A rung that could
|
|
331
|
+
// not deliver must never shadow one that might; that exact mistake made a
|
|
332
|
+
// stale FIFO swallow three messages earlier tonight, and it would be the
|
|
333
|
+
// same mistake to repeat here for a different reason.
|
|
334
|
+
this.log(`queue did not deliver for ${session} (${outcome.reason}) — falling through to headless`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
205
337
|
// A target with no resume id is the unbounded failure this whole path
|
|
206
338
|
// exists to close: without one, `canResume` says no and the message waits
|
|
207
339
|
// for a human. Ask the runtime's own on-disk state who this session is
|
|
208
340
|
// before accepting that answer.
|
|
341
|
+
// RUNG 3: a session whose sandbox refuses the socket, waiting on a FIFO.
|
|
342
|
+
//
|
|
343
|
+
// Ordered after the socket and before headless because it means the SAME
|
|
344
|
+
// thing the socket does — a live session is waiting — while headless means
|
|
345
|
+
// the opposite. `writeWake` never blocks: a FIFO write with no reader would
|
|
346
|
+
// otherwise stall this daemon on one detached agent and stop it serving
|
|
347
|
+
// every other session, which is worse than the bug this rung fixes.
|
|
348
|
+
const mailbox = this.mailboxes.get(session);
|
|
349
|
+
if (mailbox) {
|
|
350
|
+
const payload = `${JSON.stringify({ type: "wake", conversationId: batch[0].conversationId, messages: batch })}\n`;
|
|
351
|
+
const written = (0, mailbox_1.writeWake)(mailbox.fifo, payload);
|
|
352
|
+
if (written.ok) {
|
|
353
|
+
// The one rung where `delivered` is EVIDENCED rather than assumed: a
|
|
354
|
+
// FIFO write completes only when a reader takes the bytes.
|
|
355
|
+
this.record({ kind: "woken", via: "fifo", session }, session, batch);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// NOBODY IS WAITING — so FALL THROUGH, do not stop here.
|
|
359
|
+
//
|
|
360
|
+
// A registration is not a listener. The daemon cannot tell a stale
|
|
361
|
+
// registration from a live one on this rung, because a sandboxed agent
|
|
362
|
+
// records a NAMESPACED pid that `processIsAlive` reports alive forever
|
|
363
|
+
// (spec §13). Measured 2026-08-31 00:04: a stale mailbox intercepted three
|
|
364
|
+
// deliveries in a row and recorded each `pending`, while the session had a
|
|
365
|
+
// perfectly good resume id and could have been woken headlessly the whole
|
|
366
|
+
// time. A dead rung was shadowing a working one.
|
|
367
|
+
//
|
|
368
|
+
// ENXIO is the only trustworthy evidence available here, and what it
|
|
369
|
+
// proves is exactly "no live session is waiting" — which is the condition
|
|
370
|
+
// the rung below exists for. So we take it.
|
|
371
|
+
this.log(`mailbox for ${session} has no reader (${written.reason}) — falling through to headless`);
|
|
372
|
+
this.mailboxes.delete(session);
|
|
373
|
+
this.registry.setAttached(session, false);
|
|
374
|
+
}
|
|
209
375
|
const resolved = await this.resolveResume(target);
|
|
210
376
|
const check = adapter.canResume(resolved);
|
|
211
377
|
if (!check.ok) {
|
|
@@ -215,13 +381,75 @@ class RelayDaemon {
|
|
|
215
381
|
this.record({ kind: "pending", session, reason }, session, batch);
|
|
216
382
|
return;
|
|
217
383
|
}
|
|
218
|
-
|
|
384
|
+
// The daemon knows both absolute paths because it IS them — and a session it
|
|
385
|
+
// resumes inherits ITS environment, where neither `node` nor `baychat` is on
|
|
386
|
+
// PATH. Without this the woken session is told to re-arm with a command it
|
|
387
|
+
// cannot run, answers once, and goes quiet.
|
|
388
|
+
const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, {
|
|
389
|
+
node: process.execPath,
|
|
390
|
+
cli: process.argv[1] ?? "",
|
|
391
|
+
runtime: target.runtime,
|
|
392
|
+
// A session that last armed over a mailbox is sandboxed, and a sandboxed
|
|
393
|
+
// session cannot background its re-arm. Telling it to would hand it an
|
|
394
|
+
// instruction that silently does nothing.
|
|
395
|
+
transport: target.transport,
|
|
396
|
+
});
|
|
219
397
|
const { file, args } = adapter.headlessCommand(resolved, prompt);
|
|
398
|
+
// An ABSOLUTE path came from `SessionTarget.runtimeBin` — recorded by
|
|
399
|
+
// `attach`, inside the session, because the binary a session was launched
|
|
400
|
+
// with is knowable there and nowhere else. Re-resolving it against the
|
|
401
|
+
// daemon's PATH is precisely the guess that rule exists to forbid, and
|
|
402
|
+
// doing so broke a live session: the path ended in `claude.exe`, PATH
|
|
403
|
+
// resolution was asked for a file of that name, and every wake died with
|
|
404
|
+
// "not found on PATH".
|
|
405
|
+
//
|
|
406
|
+
// A BARE NAME has no such provenance, and is where PATH gets it wrong —
|
|
407
|
+
// a Windows shim ahead of the real binary under WSL. That one is resolved.
|
|
408
|
+
let executable = file;
|
|
409
|
+
if (!path.isAbsolute(file)) {
|
|
410
|
+
const binary = this.binaryFor(file);
|
|
411
|
+
if (!binary.ok) {
|
|
412
|
+
this.record({ kind: "pending", session, reason: (0, runtime_binary_1.summarizeResolutionFailure)(binary) }, session, batch);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
executable = binary.path;
|
|
416
|
+
}
|
|
417
|
+
// A runtime with a richer transport than "spawn a command" gets to use it.
|
|
418
|
+
// Only a transport that could not be used AT ALL falls through to the spawn:
|
|
419
|
+
// a failure that is a real answer about this session — an id that names no
|
|
420
|
+
// thread, a turn the runtime refused — would say exactly the same thing
|
|
421
|
+
// again down the older path, more slowly and less clearly.
|
|
422
|
+
const richTransport = this.runTurn
|
|
423
|
+
? (input) => this.runTurn({ runtime: target.runtime, ...input })
|
|
424
|
+
: adapter.runTurn?.bind(adapter);
|
|
425
|
+
if (richTransport) {
|
|
426
|
+
const outcome = await richTransport({ binaryPath: executable, target: resolved, prompt });
|
|
427
|
+
if (outcome.kind === "completed") {
|
|
428
|
+
this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!outcome.transportUnusable) {
|
|
432
|
+
this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
|
|
436
|
+
}
|
|
437
|
+
// A Windows .cmd needs its interpreter named explicitly, whatever produced
|
|
438
|
+
// the path — resolution or the session's own runtimeBin — so the decision
|
|
439
|
+
// lives here, after both, rather than inside either. Never a shell: these
|
|
440
|
+
// args carry the wake prompt, and therefore other people's message text.
|
|
441
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
|
|
220
442
|
// The session's own directory when we know it: `cwd` is only where the
|
|
221
443
|
// attach process ran, and `codex exec` refuses to start outside a trusted
|
|
222
444
|
// directory at all.
|
|
223
|
-
const { exitCode, stderr } = await this.spawnHeadless(file, args, {
|
|
445
|
+
const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
|
|
446
|
+
cwd: resolved.resumeCwd ?? resolved.cwd,
|
|
447
|
+
});
|
|
224
448
|
if (exitCode !== 0) {
|
|
449
|
+
// The binary ran and the turn still failed, so what we resolved may be
|
|
450
|
+
// stale — a half-finished upgrade, a runtime removed since. Drop it and
|
|
451
|
+
// prove it again next time rather than trusting it for the process's life.
|
|
452
|
+
this.binaries.delete(file);
|
|
225
453
|
// A non-zero headless turn did not necessarily reply. Recording it as
|
|
226
454
|
// delivered would claim an answer we cannot evidence.
|
|
227
455
|
this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
|
|
@@ -229,6 +457,21 @@ class RelayDaemon {
|
|
|
229
457
|
}
|
|
230
458
|
this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
|
|
231
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* The proven executable for a runtime, resolved at most once per process
|
|
462
|
+
* until something gives us reason to doubt it.
|
|
463
|
+
*/
|
|
464
|
+
binaryFor(name) {
|
|
465
|
+
const known = this.binaries.get(name);
|
|
466
|
+
if (known)
|
|
467
|
+
return known;
|
|
468
|
+
const resolved = this.resolveBinary(name);
|
|
469
|
+
this.binaries.set(name, resolved);
|
|
470
|
+
this.log(resolved.ok
|
|
471
|
+
? `${name} resolved to ${resolved.path} (${resolved.version})`
|
|
472
|
+
: `${name} unresolved: ${(0, runtime_binary_1.summarizeResolutionFailure)(resolved)}`);
|
|
473
|
+
return resolved;
|
|
474
|
+
}
|
|
232
475
|
/**
|
|
233
476
|
* Fill in a missing resume id from the runtime's on-disk state, at most once
|
|
234
477
|
* every `DISCOVERY_INTERVAL_MS` per session.
|
|
@@ -309,6 +552,16 @@ class RelayDaemon {
|
|
|
309
552
|
// for it, so drop the throttle and let the next wake use the new id.
|
|
310
553
|
if (frame.resumeId)
|
|
311
554
|
this.discoveryReasons.delete(frame.session);
|
|
555
|
+
// EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
|
|
556
|
+
// and `set` alone would leave the old socket open and its process
|
|
557
|
+
// running — which is how two orphaned attaches came to be stacked on
|
|
558
|
+
// one name on 2026-08-30, each one a place a wake could vanish into.
|
|
559
|
+
// Destroying it makes the displaced attach exit instead of lingering.
|
|
560
|
+
const previous = this.attached.get(frame.session);
|
|
561
|
+
if (previous && previous !== sock) {
|
|
562
|
+
this.log(`replacing attach for ${frame.session}: dropping the previous one`);
|
|
563
|
+
previous.destroy();
|
|
564
|
+
}
|
|
312
565
|
this.attached.set(frame.session, sock);
|
|
313
566
|
this.registry.setAttached(frame.session, true);
|
|
314
567
|
(0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
|
|
@@ -340,7 +593,8 @@ class RelayDaemon {
|
|
|
340
593
|
status() {
|
|
341
594
|
const sessions = this.registry.all().map((t) => ({
|
|
342
595
|
...t,
|
|
343
|
-
attached: this.attached.has(t.name),
|
|
596
|
+
attached: this.attached.has(t.name) || this.mailboxes.has(t.name),
|
|
597
|
+
transport: this.attached.has(t.name) ? "socket" : this.mailboxes.has(t.name) ? "fifo" : undefined,
|
|
344
598
|
}));
|
|
345
599
|
return {
|
|
346
600
|
running: true,
|
|
@@ -362,6 +616,8 @@ class RelayDaemon {
|
|
|
362
616
|
sock.destroy();
|
|
363
617
|
this.attached.clear();
|
|
364
618
|
await this.queue.idle();
|
|
619
|
+
for (const w of this.mailboxWatchers)
|
|
620
|
+
w.stop();
|
|
365
621
|
await new Promise((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
|
|
366
622
|
(0, socket_1.unlinkStaleSocket)((0, socket_1.socketPath)());
|
|
367
623
|
try {
|