baychat 0.13.1 → 0.15.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 +72 -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 +521 -0
- package/dist/index.js +47 -26
- package/dist/relay/adapters.js +100 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +440 -36
- package/dist/relay/daemon.js +547 -17
- package/dist/relay/held.js +184 -0
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +320 -0
- package/dist/relay/owner-pid.js +129 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/registry.js +11 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +146 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +283 -0
- package/dist/runtimes.js +126 -46
- package/package.json +2 -2
package/dist/relay/commands.js
CHANGED
|
@@ -35,10 +35,15 @@ 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;
|
|
46
|
+
exports.decideRuntimeBin = decideRuntimeBin;
|
|
42
47
|
const child_process_1 = require("child_process");
|
|
43
48
|
const fs = __importStar(require("fs"));
|
|
44
49
|
const net = __importStar(require("net"));
|
|
@@ -46,15 +51,24 @@ const path = __importStar(require("path"));
|
|
|
46
51
|
const util_1 = require("util");
|
|
47
52
|
const adapters_1 = require("./adapters");
|
|
48
53
|
const autostart_1 = require("./autostart");
|
|
54
|
+
const runtime_binary_1 = require("../runtime-binary");
|
|
55
|
+
const owner_pid_1 = require("./owner-pid");
|
|
56
|
+
const parent_watch_1 = require("./parent-watch");
|
|
49
57
|
const daemon_1 = require("./daemon");
|
|
50
58
|
const resume_1 = require("./resume");
|
|
59
|
+
const mailbox_1 = require("./mailbox");
|
|
51
60
|
const socket_1 = require("./socket");
|
|
61
|
+
const parent_watch_2 = require("./parent-watch");
|
|
52
62
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
53
63
|
/** Connect to a running daemon, or explain that there isn't one. */
|
|
54
64
|
async function connectOrFail() {
|
|
55
65
|
const sockPath = (0, socket_1.socketPath)();
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
|
|
67
|
+
if (!probe.alive) {
|
|
68
|
+
// NOT a flat "no relay is running". See `describeProbeFailure`: a refused
|
|
69
|
+
// pipe and a wedged daemon are both live relays, and telling someone to
|
|
70
|
+
// start another one is the one instruction that makes those worse.
|
|
71
|
+
throw new Error((0, socket_1.describeProbeFailure)(sockPath, probe));
|
|
58
72
|
}
|
|
59
73
|
return net.createConnection(sockPath);
|
|
60
74
|
}
|
|
@@ -165,6 +179,26 @@ async function ensureRelayInstalled() {
|
|
|
165
179
|
return `Relay: could not start automatically (${err instanceof Error ? err.message : String(err)}). Run \`baychat relay start\` yourself.`;
|
|
166
180
|
}
|
|
167
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* The daemon's status, or null when there is no daemon.
|
|
184
|
+
*
|
|
185
|
+
* `cmdRelayStatus` treats an absent relay as an error to report, which is right
|
|
186
|
+
* for a command whose whole subject is the relay. `doctor` needs the opposite:
|
|
187
|
+
* "not running" is one finding among several and must not abort the rest of the
|
|
188
|
+
* report, so the absence is a value here rather than a thrown error.
|
|
189
|
+
*/
|
|
190
|
+
async function tryRelayStatus() {
|
|
191
|
+
try {
|
|
192
|
+
return await request(await connectOrFail(), { type: "status" });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Every failure means the same thing to a caller that only wants to know
|
|
196
|
+
// whether the relay can answer: no socket, a stale socket, or a daemon too
|
|
197
|
+
// wedged to reply are all "no usable relay". `doctor` reports that state
|
|
198
|
+
// itself; there is nothing here worth logging over it.
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
168
202
|
async function cmdRelayStatus() {
|
|
169
203
|
let status;
|
|
170
204
|
try {
|
|
@@ -179,24 +213,98 @@ async function cmdRelayStatus() {
|
|
|
179
213
|
console.log(` polls: ${status.polls} delivered: ${status.delivered} cursor: ${status.cursor ?? "—"}`);
|
|
180
214
|
if (status.lastError)
|
|
181
215
|
console.log(` last error: ${status.lastError}`);
|
|
216
|
+
// Computed before the session list so a line can report its own last failure.
|
|
217
|
+
const byName = new Map(status.sessions.map((s) => [s.name, s]));
|
|
218
|
+
/**
|
|
219
|
+
* Why this failure no longer stands, or undefined if it still does.
|
|
220
|
+
*
|
|
221
|
+
* Returns the REASON rather than a boolean so the line can say what cleared
|
|
222
|
+
* it. "(stale)" with no cause is the same species of unexamined claim this
|
|
223
|
+
* command exists to stop printing.
|
|
224
|
+
*/
|
|
225
|
+
const staleReason = (p) => {
|
|
226
|
+
const session = byName.get(p.session);
|
|
227
|
+
// No session at all means nothing has been repaired — that is live, not stale.
|
|
228
|
+
if (!session)
|
|
229
|
+
return undefined;
|
|
230
|
+
// TWO WITNESSES, AND ONLY TWO. Everything weaker was tried and was wrong.
|
|
231
|
+
//
|
|
232
|
+
// A live SOCKET is demonstrably reachable — the attach is holding it now, so
|
|
233
|
+
// whatever this entry records is no longer true. `attached` alone is not
|
|
234
|
+
// that: the daemon sets it for a mailbox REGISTRATION too, and the fifo rung
|
|
235
|
+
// itself refuses to trust one, proving a reader with ENXIO on every write.
|
|
236
|
+
if (session.attached && session.transport !== "fifo")
|
|
237
|
+
return "an attach is holding the socket now";
|
|
238
|
+
// A delivery that actually LANDED after the failure. `registeredAt` was used
|
|
239
|
+
// for this and should not have been: it proves `relay attach` was rerun, and
|
|
240
|
+
// the npm/snap outage was made entirely of attaches that registered
|
|
241
|
+
// perfectly and then failed every single delivery.
|
|
242
|
+
const delivered = session.lastDeliveredAt;
|
|
243
|
+
if (delivered !== undefined && Date.parse(delivered) > Date.parse(p.at)) {
|
|
244
|
+
return `a later delivery landed at ${delivered}`;
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
};
|
|
248
|
+
const lastFailureFor = new Map();
|
|
249
|
+
for (const p of status.pending) {
|
|
250
|
+
if (!staleReason(p))
|
|
251
|
+
lastFailureFor.set(p.session, p.reason);
|
|
252
|
+
}
|
|
182
253
|
console.log(`\nSessions (${status.sessions.length}):`);
|
|
183
254
|
if (status.sessions.length === 0) {
|
|
184
255
|
console.log(" none — a session registers itself by running `baychat relay attach`");
|
|
185
256
|
}
|
|
186
257
|
for (const s of status.sessions) {
|
|
187
|
-
const state = s.
|
|
258
|
+
const state = sessionState(s, lastFailureFor.get(s.name));
|
|
188
259
|
console.log(` ${s.name} [${s.runtime}] ${state}`);
|
|
189
260
|
console.log(` ${resumeLabel(s)}`);
|
|
190
261
|
}
|
|
262
|
+
// HELD is a THIRD state and prints as itself.
|
|
263
|
+
//
|
|
264
|
+
// These messages reached this box, were not delivered, and are not a failure:
|
|
265
|
+
// the agent is mid-turn and they go over the moment it re-arms. Folding them
|
|
266
|
+
// into pending would paint a working relay red; leaving them out is what we
|
|
267
|
+
// came from — before this they lived only in the daemon's heap, so a restart
|
|
268
|
+
// lost them silently and `relay status` never mentioned they had existed.
|
|
269
|
+
//
|
|
270
|
+
// Deliberately does NOT affect the exit code. A monitor should page on
|
|
271
|
+
// pending, never on an agent that is simply busy.
|
|
272
|
+
const held = status.held ?? [];
|
|
273
|
+
if (held.length > 0) {
|
|
274
|
+
const total = held.reduce((n, h) => n + h.count, 0);
|
|
275
|
+
console.log(`\nHELD (${total}) — waiting for a busy session to re-arm, not lost:`);
|
|
276
|
+
for (const h of held) {
|
|
277
|
+
console.log(` ${h.heldAt} ${h.session} ${h.count} message(s) in ${h.conversationId}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
191
280
|
// Pending is the point of the whole command: these are messages that reached
|
|
192
281
|
// this machine and that nobody answered.
|
|
282
|
+
//
|
|
283
|
+
// But a failure the operator has already FIXED must stop shouting. Pending
|
|
284
|
+
// records are never removed, so before this a single bad afternoon left the
|
|
285
|
+
// command exiting 2 forever — and a status that is permanently red is a status
|
|
286
|
+
// nobody reads. An entry that predates its session's current registration
|
|
287
|
+
// describes a session that has since been repaired: still worth printing as
|
|
288
|
+
// history, no longer worth alerting on.
|
|
193
289
|
if (status.pending.length > 0) {
|
|
194
|
-
|
|
290
|
+
const live = status.pending.filter((p) => !staleReason(p));
|
|
291
|
+
const stale = status.pending.length - live.length;
|
|
292
|
+
const heading = live.length > 0
|
|
293
|
+
? `DELIVERY PENDING (${live.length}) — reached this box, not answered:`
|
|
294
|
+
: `DELIVERY PENDING (0 live, ${stale} historical) — every one has been superseded by a later success:`;
|
|
295
|
+
console.log(`\n${heading}`);
|
|
195
296
|
for (const p of status.pending.slice(-10)) {
|
|
196
|
-
|
|
297
|
+
const why = staleReason(p);
|
|
298
|
+
console.log(` ${p.at} ${p.session} msg ${p.messageId}${why ? ` (stale — ${why})` : ""}`);
|
|
197
299
|
console.log(` ${p.reason}`);
|
|
198
300
|
}
|
|
199
|
-
|
|
301
|
+
if (live.length > 0 && stale > 0) {
|
|
302
|
+
console.log(` (${stale} older entr${stale === 1 ? "y" : "ies"} marked stale — superseded by a later success.)`);
|
|
303
|
+
}
|
|
304
|
+
// Distinct exit code so a monitor can alert on it — but only for failures
|
|
305
|
+
// that still stand.
|
|
306
|
+
if (live.length > 0)
|
|
307
|
+
return 2;
|
|
200
308
|
}
|
|
201
309
|
return 0;
|
|
202
310
|
}
|
|
@@ -265,21 +373,250 @@ async function cmdRelayStop() {
|
|
|
265
373
|
* which then reads the room properly through the BayChat tools. Exit 2 means
|
|
266
374
|
* the wait lapsed with nothing to report.
|
|
267
375
|
*/
|
|
376
|
+
/**
|
|
377
|
+
* Is this probe failure the sandboxed case, and ONLY that?
|
|
378
|
+
*
|
|
379
|
+
* `denied` means a daemon exists and refused us — precisely a sandbox or token
|
|
380
|
+
* boundary, and precisely what the mailbox is for. Every other reason keeps
|
|
381
|
+
* today's error, deliberately:
|
|
382
|
+
*
|
|
383
|
+
* - `absent` — there is no relay. A mailbox would be created that no daemon
|
|
384
|
+
* is watching, and the agent would block on a FIFO forever believing it was
|
|
385
|
+
* reachable. That is the exact failure this transport exists to end, and
|
|
386
|
+
* the fallback must not reintroduce it.
|
|
387
|
+
* - `timeout` / `error` — a wedged or unexplained daemon. Papering over it
|
|
388
|
+
* with a second channel hides the thing someone needs to fix.
|
|
389
|
+
*/
|
|
390
|
+
function shouldFallBackToMailbox(probe) {
|
|
391
|
+
return !probe.alive && probe.reason === "denied";
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Arm over the mailbox: register by file, then block on a FIFO read.
|
|
395
|
+
*
|
|
396
|
+
* FOREGROUND, and it must stay foreground. A backgrounded process does not
|
|
397
|
+
* survive the sandbox: measured 2026-08-30, `nohup bash -c 'sleep 25; …' &`
|
|
398
|
+
* inside Codex never wrote its file, because bubblewrap tears down the mount
|
|
399
|
+
* namespace and kills the process group when the command returns. Telling a
|
|
400
|
+
* sandboxed agent to background this would be handing it an instruction that
|
|
401
|
+
* cannot work — which is how one came to report "Armed and joined the group"
|
|
402
|
+
* having armed nothing at all.
|
|
403
|
+
*/
|
|
404
|
+
async function attachViaMailbox(opts) {
|
|
405
|
+
// PROVE the root by creating it, rather than resolving one and hoping. A
|
|
406
|
+
// sandbox that permits /tmp may still refuse XDG_RUNTIME_DIR, and only this
|
|
407
|
+
// process can find that out.
|
|
408
|
+
const dir = (0, mailbox_1.pickUsableMailboxDir)(opts.session);
|
|
409
|
+
// Declare the path; the daemon creates the FIFO. Making one requires spawning
|
|
410
|
+
// `mkfifo(1)`, and a sandbox that permits writing files still refuses to spawn
|
|
411
|
+
// a helper binary (measured 2026-08-30: `spawnSync mkfifo EPERM`).
|
|
412
|
+
const fifo = (0, mailbox_1.wakeFifoPath)(dir);
|
|
413
|
+
(0, mailbox_1.writeRegistration)(dir, {
|
|
414
|
+
session: opts.session,
|
|
415
|
+
runtime: opts.runtime,
|
|
416
|
+
resumeId: opts.resume.ok ? opts.resume.resumeId : undefined,
|
|
417
|
+
resumeSource: opts.resume.ok ? opts.resume.source : undefined,
|
|
418
|
+
resumeEvidence: opts.resume.ok ? opts.resume.evidence : undefined,
|
|
419
|
+
resumeCwd: opts.resume.ok ? opts.resume.cwd : undefined,
|
|
420
|
+
cwd: process.cwd(),
|
|
421
|
+
// Resolved inside the session, for the same reason the socket rung does it:
|
|
422
|
+
// the binary a session was launched with is knowable here and nowhere else.
|
|
423
|
+
// Passed in rather than re-probed — see `decideRuntimeBin`.
|
|
424
|
+
runtimeBin: opts.runtimeBin,
|
|
425
|
+
fifo,
|
|
426
|
+
pid: process.pid,
|
|
427
|
+
// The SESSION's pid, not this attach's and not its shell's. `pid` above dies
|
|
428
|
+
// with every wake, and `process.ppid` is a throwaway wrapper that exits in
|
|
429
|
+
// seconds — recording either told the daemon a live session had gone, and it
|
|
430
|
+
// spawned a headless duplicate. See `currentOwnerPid`.
|
|
431
|
+
ownerPid: opts.ownerPid ?? (0, owner_pid_1.currentOwnerPid)(opts.runtime),
|
|
432
|
+
registeredAt: new Date().toISOString(),
|
|
433
|
+
});
|
|
434
|
+
const timeoutMs = opts.timeoutMs;
|
|
435
|
+
// NON-BLOCKING, because the obvious version cannot be cancelled.
|
|
436
|
+
//
|
|
437
|
+
// `readFile(fifo)` blocks in `open(2)` inside the libuv threadpool. No signal
|
|
438
|
+
// reaches it, and — measured — `process.exit()` does not end the process
|
|
439
|
+
// either: Node waits for that worker, so a "timeout" printed there is a lie the
|
|
440
|
+
// caller then hangs behind. An earlier fix here claimed a bound and shipped
|
|
441
|
+
// exactly that; the test missed it by never reaching the read.
|
|
442
|
+
//
|
|
443
|
+
// `O_RDONLY | O_NONBLOCK` opens a FIFO with no writer IMMEDIATELY, and reads
|
|
444
|
+
// then return nothing until the daemon writes. Polling that is genuinely
|
|
445
|
+
// cancellable: the deadline is a plain loop condition, the process can exit on
|
|
446
|
+
// its own, and there is nothing left blocked behind it.
|
|
447
|
+
const deadline = timeoutMs === undefined ? Number.POSITIVE_INFINITY : Date.now() + timeoutMs;
|
|
448
|
+
try {
|
|
449
|
+
await (0, mailbox_1.awaitWakeFifo)(fifo, Math.min(timeoutMs ?? 30_000, 30_000));
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
// The relay never made the FIFO. Bounded arm-once treats that as its timeout
|
|
453
|
+
// rather than an error: the registration above still happened, which is the
|
|
454
|
+
// point of arming once.
|
|
455
|
+
if (timeoutMs === undefined)
|
|
456
|
+
throw err;
|
|
457
|
+
console.log(err instanceof Error ? err.message : String(err));
|
|
458
|
+
return 2;
|
|
459
|
+
}
|
|
460
|
+
console.log(`Attached as "${opts.session}" over a mailbox FIFO (${fifo}). Waiting for messages…`);
|
|
461
|
+
const raw = await readWakeBounded(fifo, deadline);
|
|
462
|
+
if (raw === "timeout") {
|
|
463
|
+
console.log("No new messages before timeout. Registered and reachable — the relay will wake this session.");
|
|
464
|
+
return 2;
|
|
465
|
+
}
|
|
466
|
+
const frame = JSON.parse(raw.trim());
|
|
467
|
+
console.log(`WAKE ${frame.messages.length} message(s) in ${frame.conversationId}:`);
|
|
468
|
+
for (const m of frame.messages) {
|
|
469
|
+
const flag = m.shouldRespond ? " [shouldRespond=true]" : "";
|
|
470
|
+
console.log(` (${m.id}) ${m.senderType} ${m.senderId}${flag}: ${m.content}`);
|
|
471
|
+
}
|
|
472
|
+
return 0;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Wait for one wake frame on a FIFO, without ever blocking uninterruptibly.
|
|
476
|
+
*
|
|
477
|
+
* Returns "timeout" instead of throwing: for `arm-once` a timeout is the normal
|
|
478
|
+
* ending, not a failure — registration already happened, and the relay owns
|
|
479
|
+
* every later wake.
|
|
480
|
+
*/
|
|
481
|
+
async function readWakeBounded(fifo, deadline, pollMs = 25) {
|
|
482
|
+
const handle = await fs.promises.open(fifo, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
|
483
|
+
try {
|
|
484
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
485
|
+
let acc = "";
|
|
486
|
+
for (;;) {
|
|
487
|
+
let bytes = 0;
|
|
488
|
+
try {
|
|
489
|
+
({ bytesRead: bytes } = await handle.read(buf, 0, buf.length, null));
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
// EAGAIN is "no writer has written yet" on a non-blocking FIFO, which is
|
|
493
|
+
// the normal state of this wait. Anything else is a real fault.
|
|
494
|
+
if (err.code !== "EAGAIN")
|
|
495
|
+
throw err;
|
|
496
|
+
}
|
|
497
|
+
// A read of 0 is NOT end-of-input here. With no writer attached a
|
|
498
|
+
// non-blocking FIFO reports EOF, and the writer we are waiting for has not
|
|
499
|
+
// arrived yet — treating it as the end would turn every wait into an
|
|
500
|
+
// instant empty answer.
|
|
501
|
+
if (bytes > 0) {
|
|
502
|
+
acc += buf.subarray(0, bytes).toString("utf8");
|
|
503
|
+
if (acc.includes("\n"))
|
|
504
|
+
return acc;
|
|
505
|
+
}
|
|
506
|
+
if (Date.now() >= deadline)
|
|
507
|
+
return "timeout";
|
|
508
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
await handle.close();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/** The state half of one `relay status` session line. */
|
|
516
|
+
function sessionState(s, lastWakeFailure) {
|
|
517
|
+
// "registered", not "attached", for the FIFO rung — and the difference is not
|
|
518
|
+
// pedantry. A socket attach IS a held connection, so `attached` is an
|
|
519
|
+
// observation. A mailbox registration is a file on disk; whether the agent is
|
|
520
|
+
// still blocked on its FIFO cannot be checked without ending the wait, since
|
|
521
|
+
// a reader blocked in open() holds no descriptor for /proc to see and opening
|
|
522
|
+
// the write end to look would signal EOF. Delivery is the only honest probe,
|
|
523
|
+
// and it makes it: a wake with no reader is recorded pending, never delivered.
|
|
524
|
+
// A live SOCKET outranks a past failure: the attach is holding it right now.
|
|
525
|
+
// A FIFO REGISTRATION does not, and must not short-circuit the check below —
|
|
526
|
+
// that is the same "a registration is not a reader" rule `staleReason` applies
|
|
527
|
+
// to the pending list, and leaving it out here meant a session whose last wake
|
|
528
|
+
// died still printed a clean `registered (fifo)` on the one line a human reads.
|
|
529
|
+
if (s.attached && s.transport !== "fifo")
|
|
530
|
+
return "attached";
|
|
531
|
+
if (s.attached && !lastWakeFailure)
|
|
532
|
+
return "registered (fifo)";
|
|
533
|
+
if (s.attached)
|
|
534
|
+
return `registered (fifo) — LAST WAKE FAILED, not reachable: ${lastWakeFailure}`;
|
|
535
|
+
// "headless resume ready" is a claim about a PRECONDITION — that we hold an id
|
|
536
|
+
// — and for a long time it was printed even while every wake using that id had
|
|
537
|
+
// failed. A non-engineer reads "ready" as "fine", so the one line that answers
|
|
538
|
+
// "is anything listening?" was the line most likely to mislead. If a wake has
|
|
539
|
+
// actually been tried and failed since this session registered, say THAT: an
|
|
540
|
+
// outcome outranks a precondition.
|
|
541
|
+
if (lastWakeFailure)
|
|
542
|
+
return `detached — LAST WAKE FAILED, not reachable: ${lastWakeFailure}`;
|
|
543
|
+
// INTERRUPTED, NOT GONE — and this line used to read "headless resume ready",
|
|
544
|
+
// which sounds fine and is now actively false.
|
|
545
|
+
//
|
|
546
|
+
// Stopping a task in the driving harness kills its background `relay attach`
|
|
547
|
+
// from OUTSIDE the process: no self-detach line is printed, nothing fails, the
|
|
548
|
+
// registration simply stops having a listener. And interrupting your own agent
|
|
549
|
+
// is an ORDINARY thing to do — every other fault in this release needed an
|
|
550
|
+
// unusual machine; this one needs Ctrl+C.
|
|
551
|
+
//
|
|
552
|
+
// "Headless resume ready" is wrong here for a specific reason: the daemon will
|
|
553
|
+
// NOT resume a session whose owning process is still alive — that is the
|
|
554
|
+
// no-clone guard — so a wake is held as pending instead. Reachable is exactly
|
|
555
|
+
// what this session is not.
|
|
556
|
+
//
|
|
557
|
+
// SCOPED TO RUNTIMES WITH NO LIVE-SESSION QUEUE. Codex is deliberately
|
|
558
|
+
// `arm-once`: its bounded attach exits and the session is SUPPOSED to sit
|
|
559
|
+
// detached with a live owner, because rung 2 hands the message to
|
|
560
|
+
// `codex queue` — which reaches the live session, and is tried BEFORE the
|
|
561
|
+
// no-clone guard is ever consulted. Warning there would alarm every healthy
|
|
562
|
+
// Codex on the machine, and a warning that fires when nothing is wrong is how
|
|
563
|
+
// a real one stops being read.
|
|
564
|
+
const hasLiveQueue = (0, adapters_1.adapterFor)(s.runtime).queueMessage !== undefined;
|
|
565
|
+
if (!hasLiveQueue && s.ownerPid !== undefined && (0, parent_watch_1.processIsAlive)(s.ownerPid)) {
|
|
566
|
+
return "detached — NOTHING IS LISTENING, re-arm: the session is alive but its attach is gone (interrupted?), so a wake will be held, not resumed";
|
|
567
|
+
}
|
|
568
|
+
return s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* One `relay status` session line.
|
|
572
|
+
*
|
|
573
|
+
* Exported so the rung's wording is testable: a fallback nobody can see in
|
|
574
|
+
* `status` is a silent fallback, which is the thing this transport must not be.
|
|
575
|
+
*/
|
|
576
|
+
function renderSessionLine(target, lastWakeFailure) {
|
|
577
|
+
return ` ${target.name} [${target.runtime}] ${sessionState(target, lastWakeFailure)}`;
|
|
578
|
+
}
|
|
268
579
|
async function cmdRelayAttach(opts) {
|
|
269
580
|
if (!(0, adapters_1.isKnownRuntime)(opts.runtime)) {
|
|
270
581
|
console.log(`unknown runtime "${opts.runtime}" — expected one of: ${adapters_1.KNOWN_RUNTIMES.join(", ")}`);
|
|
271
582
|
return 1;
|
|
272
583
|
}
|
|
273
584
|
const runtime = opts.runtime;
|
|
585
|
+
// An explicit BAYCHAT_<RUNTIME>_BIN is a user ASSERTION, not a hint: they have
|
|
586
|
+
// told us which binary this session runs under. If it cannot be proven, the
|
|
587
|
+
// only honest move is to stop. Recording `runtimeBin: undefined` and attaching
|
|
588
|
+
// anyway looks like success and hands the daemon back exactly the guess this
|
|
589
|
+
// setting exists to prevent — which is how a confined snap `codex` came to be
|
|
590
|
+
// spawned for an npm session and every wake died "no rollout found".
|
|
591
|
+
// Probed ONCE, here, and carried to whichever transport wins below.
|
|
592
|
+
const runtimeBin = decideRuntimeBin(runtime);
|
|
593
|
+
if (!runtimeBin.ok) {
|
|
594
|
+
console.log(`BAYCHAT_${runtime.toUpperCase()}_BIN is set but unusable: ${runtimeBin.reason}`);
|
|
595
|
+
console.log("Not attaching. Fix it or unset it — nothing was registered, so the relay will not report this session as reachable.");
|
|
596
|
+
return 1;
|
|
597
|
+
}
|
|
274
598
|
const resume = await resolveAttachResumeId(runtime, opts.resumeId, opts.discovery);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
599
|
+
const sockPath = (0, socket_1.socketPath)();
|
|
600
|
+
const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
|
|
601
|
+
if (shouldFallBackToMailbox(probe)) {
|
|
602
|
+
console.log(`Relay socket refused (${probe.alive ? "" : (probe.code ?? "denied")}) — this session is sandboxed.`);
|
|
603
|
+
console.log("Falling back to a mailbox FIFO, which a sandbox permits. `relay status` will show this session as attached (fifo).");
|
|
604
|
+
return attachViaMailbox({
|
|
605
|
+
session: opts.session,
|
|
606
|
+
runtime,
|
|
607
|
+
resume,
|
|
608
|
+
runtimeBin: runtimeBin.bin,
|
|
609
|
+
ownerPid: opts.ownerPid,
|
|
610
|
+
// Honoured on BOTH transports now. It was socket-only, which made
|
|
611
|
+
// `--timeout 30` a no-op precisely where a sandboxed session needs it.
|
|
612
|
+
timeoutMs: opts.timeoutMs,
|
|
613
|
+
});
|
|
278
614
|
}
|
|
279
|
-
|
|
280
|
-
console.log(
|
|
615
|
+
if (!probe.alive) {
|
|
616
|
+
console.log((0, socket_1.describeProbeFailure)(sockPath, probe));
|
|
281
617
|
return 1;
|
|
282
618
|
}
|
|
619
|
+
const sock = net.createConnection(sockPath);
|
|
283
620
|
return new Promise((resolve) => {
|
|
284
621
|
const timer = opts.timeoutMs
|
|
285
622
|
? setTimeout(() => {
|
|
@@ -288,6 +625,19 @@ async function cmdRelayAttach(opts) {
|
|
|
288
625
|
resolve(2);
|
|
289
626
|
}, opts.timeoutMs)
|
|
290
627
|
: undefined;
|
|
628
|
+
// An attach outlives nothing. When the session that launched it goes away,
|
|
629
|
+
// this process must go with it — otherwise it keeps the socket open, the
|
|
630
|
+
// daemon keeps believing the session is live, and the next wake is written
|
|
631
|
+
// into a corpse and recorded as delivered. See ./parent-watch.ts.
|
|
632
|
+
const parentWatch = (0, parent_watch_2.watchParent)(process.ppid, () => {
|
|
633
|
+
console.log("Session that started this attach has exited — detaching so the relay stops treating it as live.");
|
|
634
|
+
sock.end();
|
|
635
|
+
resolve(3);
|
|
636
|
+
});
|
|
637
|
+
const settle = (code) => {
|
|
638
|
+
parentWatch.stop();
|
|
639
|
+
resolve(code);
|
|
640
|
+
};
|
|
291
641
|
sock.on("data", (0, socket_1.createFrameReader)((frame) => {
|
|
292
642
|
if (frame.type === "attached") {
|
|
293
643
|
console.log(`Attached as "${frame.session}". Waiting for messages…`);
|
|
@@ -302,7 +652,7 @@ async function cmdRelayAttach(opts) {
|
|
|
302
652
|
console.log(` (${m.id}) ${m.senderType} ${m.senderId}${flag}: ${m.content}`);
|
|
303
653
|
}
|
|
304
654
|
sock.end();
|
|
305
|
-
|
|
655
|
+
settle(0);
|
|
306
656
|
return;
|
|
307
657
|
}
|
|
308
658
|
if (frame.type === "error") {
|
|
@@ -310,19 +660,19 @@ async function cmdRelayAttach(opts) {
|
|
|
310
660
|
clearTimeout(timer);
|
|
311
661
|
console.log(frame.message);
|
|
312
662
|
sock.end();
|
|
313
|
-
|
|
663
|
+
settle(1);
|
|
314
664
|
}
|
|
315
665
|
}));
|
|
316
666
|
sock.on("error", (err) => {
|
|
317
667
|
if (timer)
|
|
318
668
|
clearTimeout(timer);
|
|
319
669
|
console.log(err.message);
|
|
320
|
-
|
|
670
|
+
settle(1);
|
|
321
671
|
});
|
|
322
672
|
sock.on("close", () => {
|
|
323
673
|
if (timer)
|
|
324
674
|
clearTimeout(timer);
|
|
325
|
-
|
|
675
|
+
settle(2);
|
|
326
676
|
});
|
|
327
677
|
(0, socket_1.writeFrame)(sock, {
|
|
328
678
|
type: "attach",
|
|
@@ -333,9 +683,12 @@ async function cmdRelayAttach(opts) {
|
|
|
333
683
|
resumeEvidence: resume.ok ? resume.evidence : undefined,
|
|
334
684
|
resumeCwd: resume.ok ? resume.cwd : undefined,
|
|
335
685
|
cwd: process.cwd(),
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
686
|
+
// Decided once above, not re-probed here — see `decideRuntimeBin`.
|
|
687
|
+
runtimeBin: runtimeBin.bin,
|
|
688
|
+
// See SessionTarget.ownerPid: the runtime process that owns this session —
|
|
689
|
+
// found by walking past the launching shell, which does not outlive the
|
|
690
|
+
// wake and so cannot witness anything.
|
|
691
|
+
ownerPid: opts.ownerPid ?? (0, owner_pid_1.currentOwnerPid)(runtime),
|
|
339
692
|
});
|
|
340
693
|
});
|
|
341
694
|
}
|
|
@@ -359,25 +712,76 @@ async function cmdRelayAttach(opts) {
|
|
|
359
712
|
* machine where PATH is fine keeps working, and one where it is not now works too.
|
|
360
713
|
*/
|
|
361
714
|
function resolveRuntimeBin(runtime) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
715
|
+
// Delegates to the prover rather than walking PATH itself. The hand-rolled
|
|
716
|
+
// walk this replaces had two Windows faults, and both produced a recorded
|
|
717
|
+
// path the daemon could never spawn:
|
|
718
|
+
//
|
|
719
|
+
// • it joined the BARE name onto each PATH entry, so it never considered
|
|
720
|
+
// `codex.cmd` — and every npm-installed CLI on Windows IS a .cmd shim;
|
|
721
|
+
// • it accepted a candidate on `fs.accessSync(X_OK)`, which Windows has no
|
|
722
|
+
// execute bit to answer, so any readable file passes. The extensionless
|
|
723
|
+
// `codex` — an sh script, unrunnable there — sailed through.
|
|
724
|
+
//
|
|
725
|
+
// Observed 2026-08-30: runtimeBin recorded as
|
|
726
|
+
// `C:\Users\…\npm\codex`, and the wake died with `spawn … ENOENT`.
|
|
727
|
+
//
|
|
728
|
+
// `resolveRuntimeBinary` tries the platform's real extension order and PROVES
|
|
729
|
+
// each candidate by running it, which is the same question this function was
|
|
730
|
+
// always asking — just answered correctly.
|
|
731
|
+
const decided = decideRuntimeBin(runtime);
|
|
732
|
+
return decided.ok ? decided.bin : undefined;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* What to record for this runtime, or why the attach must not proceed.
|
|
736
|
+
*
|
|
737
|
+
* ONE probe, and the caller carries the answer. `resolveRuntimeBinary` SPAWNS
|
|
738
|
+
* each candidate to prove it, so asking twice is not merely wasteful: the second
|
|
739
|
+
* answer can differ from the first. An attach that passed the override guard and
|
|
740
|
+
* then re-probed could still register `runtimeBin: undefined` — the exact state
|
|
741
|
+
* the guard exists to prevent, reached by way of the guard.
|
|
742
|
+
*/
|
|
743
|
+
function decideRuntimeBin(runtime) {
|
|
744
|
+
const override = process.env[`BAYCHAT_${runtime.toUpperCase()}_BIN`];
|
|
745
|
+
const resolved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(override));
|
|
746
|
+
if (!resolved.ok) {
|
|
747
|
+
// An explicit override is a user ASSERTION. If it cannot be proven, stop —
|
|
748
|
+
// see the guard in `cmdRelayAttach`. Absence of one is not an assertion, so
|
|
749
|
+
// a plain failed probe keeps the best-effort fallback.
|
|
750
|
+
if (override)
|
|
751
|
+
return { ok: false, reason: (0, runtime_binary_1.summarizeResolutionFailure)(resolved) };
|
|
752
|
+
return { ok: true, bin: managedRuntimeBin(runtime) };
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
// Resolve symlinks: ~/.local/bin/claude is typically a link into a versioned
|
|
756
|
+
// directory, and recording the link means a later version bump silently
|
|
757
|
+
// repoints every wake. The real path is what this session is actually running.
|
|
758
|
+
return { ok: true, bin: fs.realpathSync(resolved.path) };
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
// A path we just ran but cannot realpath is still the right answer.
|
|
762
|
+
return { ok: true, bin: resolved.path };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function managedRuntimeBin(runtime) {
|
|
766
|
+
if (runtime !== "codex")
|
|
767
|
+
return undefined;
|
|
768
|
+
const root = process.env.CODEX_MANAGED_PACKAGE_ROOT?.trim();
|
|
769
|
+
if (!root)
|
|
770
|
+
return undefined;
|
|
771
|
+
const candidate = path.join(root, "bin", "codex.js");
|
|
772
|
+
// PROVEN, not assumed. `statSync().isFile()` was the whole check here, so any
|
|
773
|
+
// file at that path counted — including one that cannot be spawned. Recording
|
|
774
|
+
// an unrunnable path is the failure this fallback exists to prevent, so it is
|
|
775
|
+
// put through the same prover an override gets: spawn it, ask its version.
|
|
776
|
+
const proved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(candidate));
|
|
777
|
+
if (!proved.ok)
|
|
778
|
+
return undefined;
|
|
779
|
+
try {
|
|
780
|
+
return fs.realpathSync(proved.path);
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
return proved.path;
|
|
379
784
|
}
|
|
380
|
-
return undefined;
|
|
381
785
|
}
|
|
382
786
|
/**
|
|
383
787
|
* Decide what resume id this attach registers, and say so out loud.
|