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/daemon.js
CHANGED
|
@@ -36,10 +36,16 @@ 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");
|
|
43
|
+
const parent_watch_1 = require("./parent-watch");
|
|
41
44
|
const queue_1 = require("./queue");
|
|
45
|
+
const held_1 = require("./held");
|
|
42
46
|
const registry_1 = require("./registry");
|
|
47
|
+
const mailbox_1 = require("./mailbox");
|
|
48
|
+
const mailbox_watcher_1 = require("./mailbox-watcher");
|
|
43
49
|
const socket_1 = require("./socket");
|
|
44
50
|
const transport_1 = require("./transport");
|
|
45
51
|
const watermarks_1 = require("./watermarks");
|
|
@@ -57,6 +63,9 @@ class RelayDaemon {
|
|
|
57
63
|
abort = new AbortController();
|
|
58
64
|
/** Live attach sockets by session name. */
|
|
59
65
|
attached = new Map();
|
|
66
|
+
/** Sessions reachable over a mailbox FIFO, by session name. */
|
|
67
|
+
mailboxes = new Map();
|
|
68
|
+
mailboxWatchers;
|
|
60
69
|
/** Per (session, conversation) delivery positions — the 409 catch-up baseline. */
|
|
61
70
|
watermarks = new watermarks_1.Watermarks();
|
|
62
71
|
pending = [];
|
|
@@ -75,11 +84,100 @@ class RelayDaemon {
|
|
|
75
84
|
discoveryReasons = new Map();
|
|
76
85
|
discovery;
|
|
77
86
|
spawnHeadless;
|
|
87
|
+
isAlive;
|
|
88
|
+
/**
|
|
89
|
+
* Sessions with a headless turn running right now — the one-session lease.
|
|
90
|
+
*
|
|
91
|
+
* `SessionQueue` serialises `deliver()` calls, which is NOT the same thing: a
|
|
92
|
+
* headless turn is awaited inside one of those calls, and a second delivery
|
|
93
|
+
* arriving after it resolves would happily start another. Two headless agents
|
|
94
|
+
* under one name is the same fault as the attach/headless clone, reached by a
|
|
95
|
+
* different door.
|
|
96
|
+
*
|
|
97
|
+
* HONEST LIMIT: this can stop a new headless STARTING. It cannot un-run one
|
|
98
|
+
* already in flight, so it does not make a duplicate impossible — it makes the
|
|
99
|
+
* daemon stop creating them.
|
|
100
|
+
*/
|
|
101
|
+
headlessInFlight = new Set();
|
|
102
|
+
/**
|
|
103
|
+
* Batches held for a session that is ALIVE but between wakes.
|
|
104
|
+
*
|
|
105
|
+
* A socket attach exits on every wake, and the turn it triggers runs for
|
|
106
|
+
* MINUTES before the session re-arms. A message arriving in that window is not
|
|
107
|
+
* undeliverable — it is EARLY. Timing out and recording it pending threw away
|
|
108
|
+
* two of the owner's own messages on 2026-08-31 while the agent was busy
|
|
109
|
+
* answering the one before them, which reads to a user as the relay stopping.
|
|
110
|
+
*
|
|
111
|
+
* Held here and flushed the instant the session attaches again.
|
|
112
|
+
*
|
|
113
|
+
* KEYED BY CONVERSATION as well as session. A session is in several rooms, and
|
|
114
|
+
* a wake frame carries ONE conversation id — so merging every held batch under
|
|
115
|
+
* the first one tells the agent that messages from room B came from room A, and
|
|
116
|
+
* sends it to re-read the wrong room. Rooms are held apart and flushed apart.
|
|
117
|
+
*
|
|
118
|
+
* PERSISTED, and that is not a detail. While this was a plain Map a daemon
|
|
119
|
+
* restart lost every held message, and the poll cursor had already moved past
|
|
120
|
+
* them, so the server never sent them again — gone, with `relay status`
|
|
121
|
+
* reporting nothing at all. See `held.ts`.
|
|
122
|
+
*/
|
|
123
|
+
held = new held_1.HeldStore();
|
|
124
|
+
/** Beyond this a session is not "busy", it is broken; stop growing and report. */
|
|
125
|
+
static MAX_HELD = 200;
|
|
126
|
+
reattachGraceMs;
|
|
127
|
+
resolveBinary;
|
|
128
|
+
runTurn;
|
|
129
|
+
/**
|
|
130
|
+
* Resolutions already proven this process, by runtime binary name.
|
|
131
|
+
*
|
|
132
|
+
* Resolution costs a `--version` spawn per candidate, which must not be paid
|
|
133
|
+
* per message. The entry is dropped whenever a headless turn fails, so a user
|
|
134
|
+
* who installs the missing runtime is believed on the next message rather
|
|
135
|
+
* than after a relay restart nothing would have told them to perform.
|
|
136
|
+
*/
|
|
137
|
+
binaries = new Map();
|
|
78
138
|
log;
|
|
79
139
|
constructor(opts = {}) {
|
|
80
140
|
this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
|
|
141
|
+
// EVERY candidate root, because the daemon cannot know which one the agent's
|
|
142
|
+
// sandbox turned out to permit — it may have fallen back past the one this
|
|
143
|
+
// process would have chosen.
|
|
144
|
+
this.mailboxWatchers = (0, mailbox_1.mailboxRootCandidates)().map((root) => new mailbox_watcher_1.MailboxWatcher({
|
|
145
|
+
root,
|
|
146
|
+
onRegister: (reg) => {
|
|
147
|
+
this.registry.upsert((0, mailbox_1.registrationToTarget)(reg));
|
|
148
|
+
// A session that names itself supersedes anything discovery guessed,
|
|
149
|
+
// exactly as an attach frame does.
|
|
150
|
+
if (reg.resumeId)
|
|
151
|
+
this.discoveryReasons.delete(reg.session);
|
|
152
|
+
// The daemon makes the FIFO, because the agent cannot: creating one
|
|
153
|
+
// needs `mkfifo(1)` spawned, and a sandbox that lets an agent write
|
|
154
|
+
// files still refuses it a helper process. This side has the privilege,
|
|
155
|
+
// so this side does the privileged step.
|
|
156
|
+
try {
|
|
157
|
+
// ENSURE, never recreate: a re-arm must not unlink the FIFO a live
|
|
158
|
+
// reader is already blocked on. See `ensureWakeFifo`.
|
|
159
|
+
(0, mailbox_1.ensureWakeFifo)(path.dirname(reg.fifo));
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
this.log(`could not create the wake FIFO for ${reg.session}: ${errText(err)}`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.mailboxes.set(reg.session, reg);
|
|
166
|
+
this.registry.setAttached(reg.session, true);
|
|
167
|
+
this.log(`attached via mailbox: ${reg.session} (${reg.runtime}) at ${reg.fifo}`);
|
|
168
|
+
},
|
|
169
|
+
onDrop: (session, reason) => {
|
|
170
|
+
this.mailboxes.delete(session);
|
|
171
|
+
this.registry.setAttached(session, false);
|
|
172
|
+
this.log(`detached: ${session} — ${reason}`);
|
|
173
|
+
},
|
|
174
|
+
}));
|
|
81
175
|
this.discovery = opts.discovery ?? {};
|
|
82
176
|
this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
|
|
177
|
+
this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
|
|
178
|
+
this.reattachGraceMs = opts.reattachGraceMs ?? 4000;
|
|
179
|
+
this.resolveBinary = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
|
|
180
|
+
this.runTurn = opts.runTurn;
|
|
83
181
|
this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
|
|
84
182
|
this.lastError = `delivery failed for ${session}: ${errText(err)}`;
|
|
85
183
|
this.log(this.lastError);
|
|
@@ -99,11 +197,19 @@ class RelayDaemon {
|
|
|
99
197
|
}
|
|
100
198
|
const auth = { baseUrl: device.baseUrl, token: device.token };
|
|
101
199
|
this.registry.load();
|
|
200
|
+
// Held messages outlive the process that held them. Loaded before the socket
|
|
201
|
+
// opens, so a session that re-arms in the first moments of a new daemon is
|
|
202
|
+
// handed what the previous one was holding rather than a clean slate.
|
|
203
|
+
this.held.load();
|
|
102
204
|
const sockPath = (0, socket_1.socketPath)();
|
|
103
205
|
if (await (0, socket_1.probeSocket)(sockPath)) {
|
|
104
206
|
throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
|
|
105
207
|
}
|
|
106
208
|
(0, socket_1.unlinkStaleSocket)(sockPath);
|
|
209
|
+
// Sandboxed agents cannot reach the socket below, so the mailbox has to be
|
|
210
|
+
// watched from the moment the daemon is up — not lazily on first delivery.
|
|
211
|
+
for (const w of this.mailboxWatchers)
|
|
212
|
+
w.start();
|
|
107
213
|
this.server = net.createServer((sock) => this.onConnection(sock));
|
|
108
214
|
await new Promise((resolve, reject) => {
|
|
109
215
|
this.server.once("error", reject);
|
|
@@ -181,6 +287,59 @@ class RelayDaemon {
|
|
|
181
287
|
// could never have matched anything.
|
|
182
288
|
this.queue.push(sessionName, { ...message, conversationId });
|
|
183
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Hand a freshly attached session everything held while it was mid-turn.
|
|
292
|
+
*
|
|
293
|
+
* Sent as ONE wake: the messages arrived while the agent was busy and are read
|
|
294
|
+
* together, in order, the way a person returning to a chat reads them. Waking
|
|
295
|
+
* once per held message would spend a turn on each and let the agent answer the
|
|
296
|
+
* first three not knowing the fourth exists.
|
|
297
|
+
*/
|
|
298
|
+
async flushHeld(session, sock) {
|
|
299
|
+
const rooms = this.held.roomsFor(session);
|
|
300
|
+
if (rooms.length === 0)
|
|
301
|
+
return;
|
|
302
|
+
for (const room of rooms) {
|
|
303
|
+
try {
|
|
304
|
+
// ACKNOWLEDGED, not fired and forgotten. A hand-over into a socket that
|
|
305
|
+
// has already died would otherwise clear the hold AND record `woken` —
|
|
306
|
+
// destroying the only copy of the messages and filing them as
|
|
307
|
+
// delivered, which is worse than never having held them.
|
|
308
|
+
await (0, socket_1.writeFrameAck)(sock, { type: "wake", conversationId: room.conversationId, messages: room.messages });
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
// KEEP IT. This store is the only copy: dropping it because the handoff
|
|
312
|
+
// failed turns "held" into "lost", which is the failure it exists to
|
|
313
|
+
// prevent. The next attach gets it instead.
|
|
314
|
+
this.log(`could not hand ${room.messages.length} held message(s) for ${session} over: ${errText(err)}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Removed only once it is actually on the wire, and only for THIS room —
|
|
318
|
+
// a failure on the second room must not discard the first's receipt or the
|
|
319
|
+
// third's contents.
|
|
320
|
+
this.held.clearRoom(session, room.conversationId);
|
|
321
|
+
this.record({ kind: "woken", via: "attach", session }, session, room.messages);
|
|
322
|
+
this.log(`delivered ${room.messages.length} held message(s) to ${session} in ${room.conversationId} on re-attach`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Wait for a session to re-arm its socket, up to `graceMs`.
|
|
327
|
+
*
|
|
328
|
+
* Polled rather than event-driven on purpose: the attach path already evicts
|
|
329
|
+
* a previous holder and re-registers, so the socket map is the single source
|
|
330
|
+
* of truth, and subscribing to it would mean a second one to keep in step.
|
|
331
|
+
*/
|
|
332
|
+
async waitForReattach(session, graceMs) {
|
|
333
|
+
const deadline = Date.now() + graceMs;
|
|
334
|
+
for (;;) {
|
|
335
|
+
const sock = this.attached.get(session);
|
|
336
|
+
if (sock && !sock.destroyed)
|
|
337
|
+
return sock;
|
|
338
|
+
if (Date.now() >= deadline)
|
|
339
|
+
return undefined;
|
|
340
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
184
343
|
/**
|
|
185
344
|
* Deliver one coalesced batch to one session. Runs under the queue's
|
|
186
345
|
* per-session lock, so an attach wake and a headless resume can never be in
|
|
@@ -189,23 +348,224 @@ class RelayDaemon {
|
|
|
189
348
|
async deliver(session, batch) {
|
|
190
349
|
const target = this.registry.get(session);
|
|
191
350
|
if (!target) {
|
|
351
|
+
// NOT reported as pending, though an earlier version of this did.
|
|
352
|
+
//
|
|
353
|
+
// `liveSessions` lists every session of the USER, not of this machine. A
|
|
354
|
+
// person with WSL and Windows side by side has BOTH relays receiving
|
|
355
|
+
// events for BOTH machines' sessions, because the server fans out per
|
|
356
|
+
// credential and a session's host is not something it records. Treating
|
|
357
|
+
// "live for this user" as "ours" made each relay report DELIVERY PENDING
|
|
358
|
+
// for sessions the other machine was serving perfectly well — and a false
|
|
359
|
+
// pending is worse than none, because the entire value of that signal is
|
|
360
|
+
// that it means something is genuinely wrong.
|
|
361
|
+
//
|
|
362
|
+
// A session in the registry cannot reach here at all (`target` would be
|
|
363
|
+
// set), so there is no local evidence to appeal to: this name has never
|
|
364
|
+
// been seen on this machine. Another device is very likely handling it,
|
|
365
|
+
// and silence is the honest answer.
|
|
366
|
+
//
|
|
367
|
+
// The genuinely uncovered case — a session that joined a room and never
|
|
368
|
+
// attached ANYWHERE — is per-machine, not per-message, and belongs in
|
|
369
|
+
// `doctor`, which can compare the Bay's live sessions against this
|
|
370
|
+
// machine's registry without ever firing a false alarm.
|
|
192
371
|
this.record({ kind: "ignored", reason: `unknown session ${session}` }, session, batch);
|
|
193
372
|
return;
|
|
194
373
|
}
|
|
195
374
|
const sock = this.attached.get(session);
|
|
196
375
|
if (sock && !sock.destroyed) {
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
376
|
+
// AN OPEN SOCKET IS NOT A LIVE SESSION.
|
|
377
|
+
//
|
|
378
|
+
// `watchParent` used to guarantee that by making an orphaned attach exit
|
|
379
|
+
// with its launcher. Without it — and a `Stop` hook has to detach, so it
|
|
380
|
+
// cannot rely on ppid — an orphan keeps its socket open, the daemon reads
|
|
381
|
+
// "socket open" as "session alive", and the wake is written into a corpse
|
|
382
|
+
// and recorded `woken via attach`. A destroyed message, filed as delivered.
|
|
383
|
+
// Two orphans were measured doing exactly that on 2026-08-30.
|
|
384
|
+
//
|
|
385
|
+
// `ownerPid` answers the same question `watchParent` did, from the other
|
|
386
|
+
// side and without depending on the attach's own process tree: rung 3.5
|
|
387
|
+
// uses it to refuse to clone a LIVING session, and this uses it to refuse
|
|
388
|
+
// to deliver into a DEAD one. Absent (an old registration, or a platform
|
|
389
|
+
// with no readable process table) means unknown, and unknown must not cost
|
|
390
|
+
// a live session its delivery — so only a definite "dead" declines.
|
|
391
|
+
if (target.ownerPid !== undefined && !this.isAlive(target.ownerPid)) {
|
|
392
|
+
this.log(`socket for ${session} is orphaned — owner ${target.ownerPid} is gone; not delivering into it`);
|
|
393
|
+
this.attached.delete(session);
|
|
394
|
+
this.registry.setAttached(session, false);
|
|
395
|
+
sock.destroy();
|
|
396
|
+
// Fall through: the session really is gone, which is what the rungs
|
|
397
|
+
// below are for.
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
// Live session: hand it the batch and let the harness re-invoke it. The
|
|
401
|
+
// attach client exits after one wake, which is what makes this a wake
|
|
402
|
+
// rather than a stream — and why the socket is dropped here.
|
|
403
|
+
//
|
|
404
|
+
// AWAITED. `sock.write()` buffers and returns, so an EPIPE from a peer
|
|
405
|
+
// that died between the liveness check and this line used to arrive
|
|
406
|
+
// after `record` had already filed the batch as `woken` — a lost message
|
|
407
|
+
// reported as delivered. A write that does not complete is not a
|
|
408
|
+
// delivery, and the rungs below exist for exactly this case.
|
|
409
|
+
try {
|
|
410
|
+
await (0, socket_1.writeFrameAck)(sock, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
|
|
411
|
+
this.record({ kind: "woken", via: "attach", session }, session, batch);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
this.log(`attach socket for ${session} took no frame: ${errText(err)} — falling through`);
|
|
416
|
+
this.attached.delete(session);
|
|
417
|
+
this.registry.setAttached(session, false);
|
|
418
|
+
sock.destroy();
|
|
419
|
+
// Fall through: nothing was delivered, so this must not shadow a rung
|
|
420
|
+
// that could still reach the session.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
203
423
|
}
|
|
204
424
|
const adapter = (0, adapters_1.adapterFor)(target.runtime);
|
|
425
|
+
// ORDERED ABOVE THE FIFO, deliberately.
|
|
426
|
+
//
|
|
427
|
+
// A FIFO hands bytes to a blocked `relay attach`, which prints them and
|
|
428
|
+
// exits. Where the harness re-invokes on that exit (Claude) it is a wake;
|
|
429
|
+
// for an interactive TUI it is only a PRINT — the text appears and nothing
|
|
430
|
+
// makes the agent act on it. Measured 2026-08-31 01:08: delivered via fifo
|
|
431
|
+
// to a live Codex, which then said nothing.
|
|
432
|
+
//
|
|
433
|
+
// `codex queue` puts the message in the session's own turn queue, so the
|
|
434
|
+
// agent ACTS. Prefer the transport that produces an ANSWER over the one
|
|
435
|
+
// that only produces a delivery.
|
|
436
|
+
// RUNG 2: the runtime's OWN inter-session queue, where it has one.
|
|
437
|
+
//
|
|
438
|
+
// Preferred over the headless spawn below because it reaches the LIVE
|
|
439
|
+
// session, where the human already is — so approvals work normally and the
|
|
440
|
+
// relay never acquires a privilege on a chat message's behalf. The headless
|
|
441
|
+
// rung must run `approvalPolicy: "never"`, which blocks Codex's own BayChat
|
|
442
|
+
// write path: observed 2026-08-31, a headlessly-woken Codex read the room
|
|
443
|
+
// and was structurally unable to answer it.
|
|
444
|
+
const resolvedForQueue = await this.resolveResume(target);
|
|
445
|
+
if (adapter.queueMessage && resolvedForQueue.resumeId) {
|
|
446
|
+
// Same rule as the headless spawn: an ABSOLUTE runtimeBin came from the
|
|
447
|
+
// session itself and is never re-resolved; only a bare name is looked up.
|
|
448
|
+
const declared = resolvedForQueue.runtimeBin ?? target.runtime;
|
|
449
|
+
let queueBin = declared;
|
|
450
|
+
if (!path.isAbsolute(declared)) {
|
|
451
|
+
const resolved = this.binaryFor(declared);
|
|
452
|
+
queueBin = resolved.ok ? resolved.path : "";
|
|
453
|
+
}
|
|
454
|
+
if (!queueBin) {
|
|
455
|
+
this.log(`queue unavailable for ${session}: no usable ${target.runtime} binary`);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const outcome = await adapter.queueMessage({
|
|
459
|
+
binaryPath: queueBin,
|
|
460
|
+
target: resolvedForQueue,
|
|
461
|
+
message: (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, undefined),
|
|
462
|
+
});
|
|
463
|
+
if (outcome.kind === "queued") {
|
|
464
|
+
this.record({ kind: "woken", via: "queue", session }, session, batch);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// EVERY non-success falls through, including `failed`.
|
|
468
|
+
//
|
|
469
|
+
// The queue is an OPTIMISATION, not a gate: it reaches the live session so
|
|
470
|
+
// approvals work, but the headless rung below is strictly more capable —
|
|
471
|
+
// it carries the Windows `.cmd` spawn plan, the app-server transport, and
|
|
472
|
+
// the binary resolution this path does not replicate. A rung that could
|
|
473
|
+
// not deliver must never shadow one that might; that exact mistake made a
|
|
474
|
+
// stale FIFO swallow three messages earlier tonight, and it would be the
|
|
475
|
+
// same mistake to repeat here for a different reason.
|
|
476
|
+
this.log(`queue did not deliver for ${session} (${outcome.reason}) — falling through to headless`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
205
479
|
// A target with no resume id is the unbounded failure this whole path
|
|
206
480
|
// exists to close: without one, `canResume` says no and the message waits
|
|
207
481
|
// for a human. Ask the runtime's own on-disk state who this session is
|
|
208
482
|
// before accepting that answer.
|
|
483
|
+
// RUNG 3: a session whose sandbox refuses the socket, waiting on a FIFO.
|
|
484
|
+
//
|
|
485
|
+
// Ordered after the socket and before headless because it means the SAME
|
|
486
|
+
// thing the socket does — a live session is waiting — while headless means
|
|
487
|
+
// the opposite. `writeWake` never blocks: a FIFO write with no reader would
|
|
488
|
+
// otherwise stall this daemon on one detached agent and stop it serving
|
|
489
|
+
// every other session, which is worse than the bug this rung fixes.
|
|
490
|
+
const mailbox = this.mailboxes.get(session);
|
|
491
|
+
if (mailbox) {
|
|
492
|
+
const payload = `${JSON.stringify({ type: "wake", conversationId: batch[0].conversationId, messages: batch })}\n`;
|
|
493
|
+
const written = (0, mailbox_1.writeWake)(mailbox.fifo, payload);
|
|
494
|
+
if (written.ok) {
|
|
495
|
+
// The one rung where `delivered` is EVIDENCED rather than assumed: a
|
|
496
|
+
// FIFO write completes only when a reader takes the bytes.
|
|
497
|
+
this.record({ kind: "woken", via: "fifo", session }, session, batch);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// NOBODY IS WAITING — so FALL THROUGH, do not stop here.
|
|
501
|
+
//
|
|
502
|
+
// A registration is not a listener. The daemon cannot tell a stale
|
|
503
|
+
// registration from a live one on this rung, because a sandboxed agent
|
|
504
|
+
// records a NAMESPACED pid that `processIsAlive` reports alive forever
|
|
505
|
+
// (spec §13). Measured 2026-08-31 00:04: a stale mailbox intercepted three
|
|
506
|
+
// deliveries in a row and recorded each `pending`, while the session had a
|
|
507
|
+
// perfectly good resume id and could have been woken headlessly the whole
|
|
508
|
+
// time. A dead rung was shadowing a working one.
|
|
509
|
+
//
|
|
510
|
+
// ENXIO is the only trustworthy evidence available here, and what it
|
|
511
|
+
// proves is exactly "no live session is waiting" — which is the condition
|
|
512
|
+
// the rung below exists for. So we take it.
|
|
513
|
+
this.log(`mailbox for ${session} has no reader (${written.reason}) — falling through to headless`);
|
|
514
|
+
this.mailboxes.delete(session);
|
|
515
|
+
this.registry.setAttached(session, false);
|
|
516
|
+
}
|
|
517
|
+
// RUNG 3.5: NEVER CLONE A LIVING SESSION.
|
|
518
|
+
//
|
|
519
|
+
// A socket attach exits by design on every wake, so a healthy session is
|
|
520
|
+
// briefly unreachable between wakes — and to every rung above, that is
|
|
521
|
+
// indistinguishable from a session that has gone. On 2026-08-31 a message
|
|
522
|
+
// landed in that gap and the ladder spawned a headless twin of a session
|
|
523
|
+
// that was alive and re-arming: two agents on one name, both authorised to
|
|
524
|
+
// answer, both able to act. The twin behaved impeccably and still committed
|
|
525
|
+
// and pushed under an identity that already had an occupant.
|
|
526
|
+
//
|
|
527
|
+
// `ownerPid` is the session itself, so it survives the gap that `pid` and
|
|
528
|
+
// the socket do not. Alive means "wait for it", never "replace it".
|
|
529
|
+
//
|
|
530
|
+
// SKIPPED after a mailbox fell through, and that exception is load-bearing:
|
|
531
|
+
// ENXIO has already PROVEN no reader there, and a sandboxed agent records a
|
|
532
|
+
// namespaced pid that reads alive forever (spec §13) — so applying this to
|
|
533
|
+
// fifo sessions would permanently shadow the one rung that can still reach
|
|
534
|
+
// them, which is the 2026-08-31 00:04 regression in a new costume.
|
|
535
|
+
if (!mailbox && target.ownerPid !== undefined && this.isAlive(target.ownerPid)) {
|
|
536
|
+
const rearmed = await this.waitForReattach(session, this.reattachGraceMs);
|
|
537
|
+
if (rearmed) {
|
|
538
|
+
try {
|
|
539
|
+
await (0, socket_1.writeFrameAck)(rearmed, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
|
|
540
|
+
this.record({ kind: "woken", via: "attach", session }, session, batch);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
// The socket appeared and then went. Do NOT record a delivery, and do
|
|
545
|
+
// not fall to headless either — the session's process is still alive,
|
|
546
|
+
// so this is the held case arriving by a different door.
|
|
547
|
+
this.log(`re-armed socket for ${session} took no frame: ${errText(err)} — holding instead`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// HELD, not pending. The session's process is still there — it is mid-turn,
|
|
551
|
+
// which is the normal state of a working agent, not a failure. Recording
|
|
552
|
+
// this pending threw the message away and told the user their relay had
|
|
553
|
+
// stopped; spawning a headless one instead is the clone bug. The third
|
|
554
|
+
// option is the correct one: keep it and hand it over when it re-arms.
|
|
555
|
+
const conversationId = batch[0].conversationId;
|
|
556
|
+
const total = this.held.countFor(session);
|
|
557
|
+
if (total + batch.length > RelayDaemon.MAX_HELD) {
|
|
558
|
+
this.record({
|
|
559
|
+
kind: "pending",
|
|
560
|
+
session,
|
|
561
|
+
reason: `session process ${target.ownerPid} is alive but has not re-attached, and ${total} message(s) are already held — not holding more`,
|
|
562
|
+
}, session, batch);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
this.held.add(session, conversationId, batch);
|
|
566
|
+
this.log(`holding ${batch.length} message(s) for ${session} in ${conversationId} until it re-attaches (${this.held.countFor(session)} held)`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
209
569
|
const resolved = await this.resolveResume(target);
|
|
210
570
|
const check = adapter.canResume(resolved);
|
|
211
571
|
if (!check.ok) {
|
|
@@ -215,19 +575,158 @@ class RelayDaemon {
|
|
|
215
575
|
this.record({ kind: "pending", session, reason }, session, batch);
|
|
216
576
|
return;
|
|
217
577
|
}
|
|
218
|
-
|
|
578
|
+
// The daemon knows both absolute paths because it IS them — and a session it
|
|
579
|
+
// resumes inherits ITS environment, where neither `node` nor `baychat` is on
|
|
580
|
+
// PATH. Without this the woken session is told to re-arm with a command it
|
|
581
|
+
// cannot run, answers once, and goes quiet.
|
|
582
|
+
const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, {
|
|
583
|
+
node: process.execPath,
|
|
584
|
+
cli: process.argv[1] ?? "",
|
|
585
|
+
runtime: target.runtime,
|
|
586
|
+
// A session that last armed over a mailbox is sandboxed, and a sandboxed
|
|
587
|
+
// session cannot background its re-arm. Telling it to would hand it an
|
|
588
|
+
// instruction that silently does nothing.
|
|
589
|
+
transport: target.transport,
|
|
590
|
+
});
|
|
219
591
|
const { file, args } = adapter.headlessCommand(resolved, prompt);
|
|
220
|
-
//
|
|
221
|
-
// attach
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
592
|
+
// An ABSOLUTE path came from `SessionTarget.runtimeBin` — recorded by
|
|
593
|
+
// `attach`, inside the session, because the binary a session was launched
|
|
594
|
+
// with is knowable there and nowhere else. Re-resolving it against the
|
|
595
|
+
// daemon's PATH is precisely the guess that rule exists to forbid, and
|
|
596
|
+
// doing so broke a live session: the path ended in `claude.exe`, PATH
|
|
597
|
+
// resolution was asked for a file of that name, and every wake died with
|
|
598
|
+
// "not found on PATH".
|
|
599
|
+
//
|
|
600
|
+
// A BARE NAME has no such provenance, and is where PATH gets it wrong —
|
|
601
|
+
// a Windows shim ahead of the real binary under WSL. That one is resolved.
|
|
602
|
+
let executable = file;
|
|
603
|
+
if (!path.isAbsolute(file)) {
|
|
604
|
+
const binary = this.binaryFor(file);
|
|
605
|
+
if (!binary.ok) {
|
|
606
|
+
this.record({ kind: "pending", session, reason: (0, runtime_binary_1.summarizeResolutionFailure)(binary) }, session, batch);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
executable = binary.path;
|
|
610
|
+
}
|
|
611
|
+
// THE LEASE — around the WHOLE headless phase, rich transport included.
|
|
612
|
+
//
|
|
613
|
+
// `SessionQueue` already serialises `deliver()`, so this is NOT what stops
|
|
614
|
+
// a second headless turn under normal flow; it is the guard for the paths
|
|
615
|
+
// that do not go through the queue, and the place the invariant is stated
|
|
616
|
+
// rather than assumed. It sits ABOVE `richTransport` because Codex's real
|
|
617
|
+
// headless turn runs there — a lease that wrapped only the plain spawn left
|
|
618
|
+
// the primary path uncovered, which is worse than none: it reads as
|
|
619
|
+
// protection while protecting the branch least likely to be taken.
|
|
620
|
+
//
|
|
621
|
+
// WHAT IT DOES NOT DO, deliberately. An attach arriving mid-turn is still
|
|
622
|
+
// accepted and only logged. Declining it would make a session with a HUMAN
|
|
623
|
+
// sitting at it unreachable in order to protect a spawned proxy, which is a
|
|
624
|
+
// worse failure than the overlap: the person gets silence and no way to see
|
|
625
|
+
// why. So this is mutual exclusion between headless turns, and observability
|
|
626
|
+
// for attach-vs-headless. Claiming more than that is what the review caught.
|
|
627
|
+
if (this.headlessInFlight.has(session)) {
|
|
628
|
+
this.record({ kind: "pending", session, reason: "a headless turn is already running for this session — refusing to start a second" }, session, batch);
|
|
228
629
|
return;
|
|
229
630
|
}
|
|
230
|
-
this.
|
|
631
|
+
this.headlessInFlight.add(session);
|
|
632
|
+
// DELIBERATELY NOT AWAITED.
|
|
633
|
+
//
|
|
634
|
+
// `deliver()` runs under `SessionQueue`'s per-session lock, and a headless
|
|
635
|
+
// turn takes MINUTES. Awaiting it here holds that lock for the whole turn,
|
|
636
|
+
// so every later message for this session parks in the queue: not delivered,
|
|
637
|
+
// not recorded pending, not logged. Measured 2026-08-31 — three messages
|
|
638
|
+
// from the room vanished exactly this way while a twin ran, and the only
|
|
639
|
+
// reason anyone noticed was the human asking whether the agent was alive.
|
|
640
|
+
//
|
|
641
|
+
// Released here, the lease still stops a SECOND headless turn starting, and
|
|
642
|
+
// the message that would have started it is now recorded pending with a
|
|
643
|
+
// reason a person can read. Silence becomes a visible refusal.
|
|
644
|
+
void this.runHeadlessTurn({ session, batch, target, adapter, resolved, executable, prompt, args, file })
|
|
645
|
+
.catch((err) => {
|
|
646
|
+
// Nothing awaits this promise any more, so an escaping rejection would
|
|
647
|
+
// be an unhandled one — which can take the whole daemon down and with it
|
|
648
|
+
// every other session. A turn that threw also did not answer anybody, so
|
|
649
|
+
// the honest record is pending.
|
|
650
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
651
|
+
this.log(`headless turn for ${session} threw: ${reason}`);
|
|
652
|
+
this.record({ kind: "pending", session, reason: `headless turn threw: ${reason}` }, session, batch);
|
|
653
|
+
})
|
|
654
|
+
.finally(() => {
|
|
655
|
+
this.headlessInFlight.delete(session);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* The headless turn itself, outside the delivery lock.
|
|
660
|
+
*
|
|
661
|
+
* Split from `deliver()` for one reason: its duration must not decide whether
|
|
662
|
+
* OTHER messages for this session are handled. Everything it records — woken,
|
|
663
|
+
* or pending with a reason — happens whenever the turn actually ends.
|
|
664
|
+
*/
|
|
665
|
+
async runHeadlessTurn(ctx) {
|
|
666
|
+
const { session, batch, target, adapter, resolved, executable, prompt, args, file } = ctx;
|
|
667
|
+
try {
|
|
668
|
+
// A runtime with a richer transport than "spawn a command" gets to use it.
|
|
669
|
+
// Only a transport that could not be used AT ALL falls through to the spawn:
|
|
670
|
+
// a failure that is a real answer about this session — an id that names no
|
|
671
|
+
// thread, a turn the runtime refused — would say exactly the same thing
|
|
672
|
+
// again down the older path, more slowly and less clearly.
|
|
673
|
+
const richTransport = this.runTurn
|
|
674
|
+
? (input) => this.runTurn({ runtime: target.runtime, ...input })
|
|
675
|
+
: adapter.runTurn?.bind(adapter);
|
|
676
|
+
if (richTransport) {
|
|
677
|
+
const outcome = await richTransport({ binaryPath: executable, target: resolved, prompt });
|
|
678
|
+
if (outcome.kind === "completed") {
|
|
679
|
+
this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (!outcome.transportUnusable) {
|
|
683
|
+
this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
|
|
687
|
+
}
|
|
688
|
+
// A Windows .cmd needs its interpreter named explicitly, whatever produced
|
|
689
|
+
// the path — resolution or the session's own runtimeBin — so the decision
|
|
690
|
+
// lives here, after both, rather than inside either. Never a shell: these
|
|
691
|
+
// args carry the wake prompt, and therefore other people's message text.
|
|
692
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
|
|
693
|
+
// The session's own directory when we know it: `cwd` is only where the
|
|
694
|
+
// attach process ran, and `codex exec` refuses to start outside a trusted
|
|
695
|
+
// directory at all.
|
|
696
|
+
const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
|
|
697
|
+
cwd: resolved.resumeCwd ?? resolved.cwd,
|
|
698
|
+
});
|
|
699
|
+
if (exitCode !== 0) {
|
|
700
|
+
// The binary ran and the turn still failed, so what we resolved may be
|
|
701
|
+
// stale — a half-finished upgrade, a runtime removed since. Drop it and
|
|
702
|
+
// prove it again next time rather than trusting it for the process's life.
|
|
703
|
+
this.binaries.delete(file);
|
|
704
|
+
// A non-zero headless turn did not necessarily reply. Recording it as
|
|
705
|
+
// delivered would claim an answer we cannot evidence.
|
|
706
|
+
this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
|
|
710
|
+
}
|
|
711
|
+
finally {
|
|
712
|
+
// Nothing to release here — the lease is cleared by the caller's
|
|
713
|
+
// `.finally`, which owns it for exactly as long as this turn runs.
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The proven executable for a runtime, resolved at most once per process
|
|
718
|
+
* until something gives us reason to doubt it.
|
|
719
|
+
*/
|
|
720
|
+
binaryFor(name) {
|
|
721
|
+
const known = this.binaries.get(name);
|
|
722
|
+
if (known)
|
|
723
|
+
return known;
|
|
724
|
+
const resolved = this.resolveBinary(name);
|
|
725
|
+
this.binaries.set(name, resolved);
|
|
726
|
+
this.log(resolved.ok
|
|
727
|
+
? `${name} resolved to ${resolved.path} (${resolved.version})`
|
|
728
|
+
: `${name} unresolved: ${(0, runtime_binary_1.summarizeResolutionFailure)(resolved)}`);
|
|
729
|
+
return resolved;
|
|
231
730
|
}
|
|
232
731
|
/**
|
|
233
732
|
* Fill in a missing resume id from the runtime's on-disk state, at most once
|
|
@@ -269,6 +768,13 @@ class RelayDaemon {
|
|
|
269
768
|
record(outcome, session, batch) {
|
|
270
769
|
if (outcome.kind === "woken") {
|
|
271
770
|
this.delivered += batch.length;
|
|
771
|
+
// The one witness `relay status` may treat as proof that a session is
|
|
772
|
+
// reachable again. A headless turn only counts when it exited cleanly: a
|
|
773
|
+
// spawn that ran and failed delivered nothing, and recording it would
|
|
774
|
+
// clear the very failure it just repeated.
|
|
775
|
+
if (outcome.via !== "headless" || outcome.exitCode === 0) {
|
|
776
|
+
this.registry.markDelivered(session, new Date().toISOString());
|
|
777
|
+
}
|
|
272
778
|
this.log(`woke ${session} via ${outcome.via} with ${batch.length} message(s)`);
|
|
273
779
|
return;
|
|
274
780
|
}
|
|
@@ -304,15 +810,35 @@ class RelayDaemon {
|
|
|
304
810
|
resumeCwd: frame.resumeCwd,
|
|
305
811
|
cwd: frame.cwd,
|
|
306
812
|
runtimeBin: frame.runtimeBin,
|
|
813
|
+
ownerPid: frame.ownerPid,
|
|
307
814
|
});
|
|
308
815
|
// A session that names itself supersedes anything discovery guessed
|
|
309
816
|
// for it, so drop the throttle and let the next wake use the new id.
|
|
310
817
|
if (frame.resumeId)
|
|
311
818
|
this.discoveryReasons.delete(frame.session);
|
|
819
|
+
// OVERLAP, AND WE CANNOT PREVENT IT — only report it. A headless turn
|
|
820
|
+
// already running was spawned because this session looked gone; it is
|
|
821
|
+
// mid-turn now, and the daemon will not kill a model that may be part
|
|
822
|
+
// way through writing something. Two agents briefly hold this name.
|
|
823
|
+
// Said out loud because the alternative is that nobody ever knows.
|
|
824
|
+
if (this.headlessInFlight.has(frame.session)) {
|
|
825
|
+
this.log(`WARNING: ${frame.session} attached while a headless turn for it is still running — two agents hold this session name until that turn ends`);
|
|
826
|
+
}
|
|
827
|
+
// EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
|
|
828
|
+
// and `set` alone would leave the old socket open and its process
|
|
829
|
+
// running — which is how two orphaned attaches came to be stacked on
|
|
830
|
+
// one name on 2026-08-30, each one a place a wake could vanish into.
|
|
831
|
+
// Destroying it makes the displaced attach exit instead of lingering.
|
|
832
|
+
const previous = this.attached.get(frame.session);
|
|
833
|
+
if (previous && previous !== sock) {
|
|
834
|
+
this.log(`replacing attach for ${frame.session}: dropping the previous one`);
|
|
835
|
+
previous.destroy();
|
|
836
|
+
}
|
|
312
837
|
this.attached.set(frame.session, sock);
|
|
313
838
|
this.registry.setAttached(frame.session, true);
|
|
314
839
|
(0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
|
|
315
840
|
this.log(`attached: ${frame.session} (${frame.runtime})`);
|
|
841
|
+
void this.flushHeld(frame.session, sock);
|
|
316
842
|
return;
|
|
317
843
|
}
|
|
318
844
|
if (frame.type === "status") {
|
|
@@ -340,7 +866,8 @@ class RelayDaemon {
|
|
|
340
866
|
status() {
|
|
341
867
|
const sessions = this.registry.all().map((t) => ({
|
|
342
868
|
...t,
|
|
343
|
-
attached: this.attached.has(t.name),
|
|
869
|
+
attached: this.attached.has(t.name) || this.mailboxes.has(t.name),
|
|
870
|
+
transport: this.attached.has(t.name) ? "socket" : this.mailboxes.has(t.name) ? "fifo" : undefined,
|
|
344
871
|
}));
|
|
345
872
|
return {
|
|
346
873
|
running: true,
|
|
@@ -353,6 +880,7 @@ class RelayDaemon {
|
|
|
353
880
|
delivered: this.delivered,
|
|
354
881
|
sessions,
|
|
355
882
|
pending: this.pending,
|
|
883
|
+
held: this.held.summary(),
|
|
356
884
|
lastError: this.lastError,
|
|
357
885
|
};
|
|
358
886
|
}
|
|
@@ -362,6 +890,8 @@ class RelayDaemon {
|
|
|
362
890
|
sock.destroy();
|
|
363
891
|
this.attached.clear();
|
|
364
892
|
await this.queue.idle();
|
|
893
|
+
for (const w of this.mailboxWatchers)
|
|
894
|
+
w.stop();
|
|
365
895
|
await new Promise((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
|
|
366
896
|
(0, socket_1.unlinkStaleSocket)((0, socket_1.socketPath)());
|
|
367
897
|
try {
|