baychat 0.19.0 → 0.20.1
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 +40 -1
- package/dist/commands.js +7 -5
- package/dist/connect-claude.js +34 -0
- package/dist/connect-hermes.js +20 -0
- package/dist/connect.js +10 -26
- package/dist/hermes-mcp.js +58 -0
- package/dist/hermes.js +19 -8
- package/dist/index.js +5 -0
- package/dist/relay/adapters.js +26 -7
- package/dist/relay/autostart.js +74 -15
- package/dist/relay/codex-app-server.js +36 -7
- package/dist/relay/codex-queue.js +42 -4
- package/dist/relay/commands.js +59 -11
- package/dist/relay/daemon.js +116 -17
- package/dist/relay/held.js +32 -5
- package/dist/relay/message-format.js +7 -4
- package/dist/runtime-binary.js +26 -6
- package/dist/runtimes.js +62 -43
- package/dist/session-command.js +137 -0
- package/package.json +2 -1
|
@@ -55,6 +55,7 @@ function runCodexTurn(req, deps) {
|
|
|
55
55
|
// installed and working.
|
|
56
56
|
const plan = (0, runtime_binary_1.spawnPlanFor)(req.binaryPath, process.platform);
|
|
57
57
|
const child = spawn(plan.file, [...plan.prefixArgs, "app-server"], {
|
|
58
|
+
windowsHide: true,
|
|
58
59
|
cwd: req.cwd,
|
|
59
60
|
// Same reason as `runHeadless`: an npm-installed codex is a script with a
|
|
60
61
|
// `#!/usr/bin/env node` shebang and the daemon's PATH has no node. Both
|
|
@@ -84,7 +85,11 @@ function runCodexTurn(req, deps) {
|
|
|
84
85
|
setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
|
|
85
86
|
resolve(outcome);
|
|
86
87
|
};
|
|
87
|
-
let timer = setTimeout(() => finish({
|
|
88
|
+
let timer = setTimeout(() => finish({
|
|
89
|
+
kind: "failed",
|
|
90
|
+
transportUnusable: true,
|
|
91
|
+
reason: `codex app-server did not complete the handshake within ${Math.round(handshakeTimeout / 1000)}s`,
|
|
92
|
+
}), handshakeTimeout);
|
|
88
93
|
const request = (method, params) => new Promise((res) => {
|
|
89
94
|
const id = nextId++;
|
|
90
95
|
pending.set(id, res);
|
|
@@ -133,7 +138,11 @@ function runCodexTurn(req, deps) {
|
|
|
133
138
|
stderrTail = `${stderrTail}${text}`.slice(-2_000);
|
|
134
139
|
});
|
|
135
140
|
child.on("error", (err) => {
|
|
136
|
-
finish({
|
|
141
|
+
finish({
|
|
142
|
+
kind: "failed",
|
|
143
|
+
transportUnusable: true,
|
|
144
|
+
reason: `could not start codex app-server: ${err.message}`,
|
|
145
|
+
});
|
|
137
146
|
});
|
|
138
147
|
child.on("close", (code) => {
|
|
139
148
|
// Only meaningful if we have not already completed: an expected exit
|
|
@@ -148,10 +157,18 @@ function runCodexTurn(req, deps) {
|
|
|
148
157
|
});
|
|
149
158
|
void (async () => {
|
|
150
159
|
const initialized = await request("initialize", {
|
|
151
|
-
clientInfo: {
|
|
160
|
+
clientInfo: {
|
|
161
|
+
name: "baychat-relay",
|
|
162
|
+
title: "BayChat relay",
|
|
163
|
+
version: CLIENT_VERSION,
|
|
164
|
+
},
|
|
152
165
|
});
|
|
153
166
|
if (initialized.error) {
|
|
154
|
-
finish({
|
|
167
|
+
finish({
|
|
168
|
+
kind: "failed",
|
|
169
|
+
transportUnusable: true,
|
|
170
|
+
reason: `codex app-server refused the handshake: ${initialized.error.message}`,
|
|
171
|
+
});
|
|
155
172
|
return;
|
|
156
173
|
}
|
|
157
174
|
notify("initialized", {});
|
|
@@ -185,19 +202,31 @@ function runCodexTurn(req, deps) {
|
|
|
185
202
|
// name a thread on this machine. Worth saying plainly, because the usual
|
|
186
203
|
// cause is a Codex whose sessions live somewhere else — a snap install
|
|
187
204
|
// keeps them under ~/snap/codex/current/sessions.
|
|
188
|
-
finish({
|
|
205
|
+
finish({
|
|
206
|
+
kind: "failed",
|
|
207
|
+
transportUnusable: false,
|
|
208
|
+
reason: `codex could not resume thread ${req.threadId}: ${resumed.error.message}`,
|
|
209
|
+
});
|
|
189
210
|
return;
|
|
190
211
|
}
|
|
191
212
|
// The handshake is done; the clock is now the turn's, which is far longer.
|
|
192
213
|
clearTimeout(timer);
|
|
193
|
-
timer = setTimeout(() => finish({
|
|
214
|
+
timer = setTimeout(() => finish({
|
|
215
|
+
kind: "failed",
|
|
216
|
+
transportUnusable: false,
|
|
217
|
+
reason: `codex turn did not complete within ${Math.round(turnTimeout / 60_000)} minutes`,
|
|
218
|
+
}), turnTimeout);
|
|
194
219
|
const started = await request("turn/start", {
|
|
195
220
|
threadId: req.threadId,
|
|
196
221
|
input: [{ type: "text", text: req.prompt }],
|
|
197
222
|
approvalPolicy: "never",
|
|
198
223
|
});
|
|
199
224
|
if (started.error) {
|
|
200
|
-
finish({
|
|
225
|
+
finish({
|
|
226
|
+
kind: "failed",
|
|
227
|
+
transportUnusable: false,
|
|
228
|
+
reason: `codex refused the turn: ${started.error.message}`,
|
|
229
|
+
});
|
|
201
230
|
}
|
|
202
231
|
// Success is NOT the response to turn/start — that only says the turn was
|
|
203
232
|
// accepted. The wake ends at the `turn/completed` notification, handled above.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.queueToThread = queueToThread;
|
|
4
|
+
exports.verifyCodexQueue = verifyCodexQueue;
|
|
4
5
|
const child_process_1 = require("child_process");
|
|
5
6
|
const runtime_binary_1 = require("../runtime-binary");
|
|
6
7
|
const spawn_env_1 = require("./spawn-env");
|
|
@@ -40,8 +41,16 @@ function queueToThread(input) {
|
|
|
40
41
|
// in a chat message run on this box.
|
|
41
42
|
const plan = (0, runtime_binary_1.spawnPlanFor)(input.binaryPath, input.platform ?? process.platform);
|
|
42
43
|
return new Promise((resolve) => {
|
|
43
|
-
runner(plan.file, [
|
|
44
|
+
runner(plan.file, [
|
|
45
|
+
...plan.prefixArgs,
|
|
46
|
+
"queue",
|
|
47
|
+
"--thread",
|
|
48
|
+
input.threadId,
|
|
49
|
+
"--message",
|
|
50
|
+
input.message,
|
|
51
|
+
], {
|
|
44
52
|
cwd: input.cwd,
|
|
53
|
+
windowsHide: true,
|
|
45
54
|
env: (0, spawn_env_1.headlessSpawnEnv)(),
|
|
46
55
|
timeout: input.timeoutMs ?? QUEUE_TIMEOUT_MS,
|
|
47
56
|
maxBuffer: 1024 * 1024,
|
|
@@ -57,12 +66,41 @@ function queueToThread(input) {
|
|
|
57
66
|
// it is a statement about the BUILD, not about this session — so the
|
|
58
67
|
// caller may still try the rung below.
|
|
59
68
|
if (/unrecognized subcommand|unexpected argument|error: unknown/i.test(out)) {
|
|
60
|
-
return resolve({
|
|
69
|
+
return resolve({
|
|
70
|
+
kind: "unsupported",
|
|
71
|
+
reason: `this codex build has no \`queue\` subcommand: ${firstLine(out)}`,
|
|
72
|
+
});
|
|
61
73
|
}
|
|
62
|
-
resolve({
|
|
74
|
+
resolve({
|
|
75
|
+
kind: "failed",
|
|
76
|
+
reason: firstLine(out) || err.message,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** Verify native queue support without submitting a message or changing a task. */
|
|
82
|
+
function verifyCodexQueue(binaryPath) {
|
|
83
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(binaryPath, process.platform);
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
(0, child_process_1.execFile)(plan.file, [...plan.prefixArgs, "queue", "--help"], {
|
|
86
|
+
env: (0, spawn_env_1.headlessSpawnEnv)(),
|
|
87
|
+
windowsHide: true,
|
|
88
|
+
timeout: 5_000,
|
|
89
|
+
maxBuffer: 64 * 1024,
|
|
90
|
+
}, (error, stdout) => {
|
|
91
|
+
if (error ||
|
|
92
|
+
!stdout.includes("--thread") ||
|
|
93
|
+
!stdout.includes("--message")) {
|
|
94
|
+
reject(new Error("This Codex binary cannot receive native queued messages. Update Codex, then join again."));
|
|
95
|
+
}
|
|
96
|
+
else
|
|
97
|
+
resolve();
|
|
63
98
|
});
|
|
64
99
|
});
|
|
65
100
|
}
|
|
66
101
|
function firstLine(text) {
|
|
67
|
-
return text
|
|
102
|
+
return (text
|
|
103
|
+
.split("\n")
|
|
104
|
+
.map((l) => l.trim())
|
|
105
|
+
.filter(Boolean)[0] ?? "");
|
|
68
106
|
}
|
package/dist/relay/commands.js
CHANGED
|
@@ -45,6 +45,7 @@ exports.cmdRelayAttach = cmdRelayAttach;
|
|
|
45
45
|
exports.resolveRuntimeBin = resolveRuntimeBin;
|
|
46
46
|
exports.decideRuntimeBin = decideRuntimeBin;
|
|
47
47
|
const message_format_1 = require("./message-format");
|
|
48
|
+
const codex_queue_1 = require("./codex-queue");
|
|
48
49
|
const child_process_1 = require("child_process");
|
|
49
50
|
const fs = __importStar(require("fs"));
|
|
50
51
|
const net = __importStar(require("net"));
|
|
@@ -163,7 +164,7 @@ async function ensureRelayInstalled() {
|
|
|
163
164
|
return "Relay: auto-start skipped (BAYCHAT_NO_RELAY_AUTOSTART). Run `baychat relay start` to enable wake-ups.";
|
|
164
165
|
}
|
|
165
166
|
if (await (0, socket_1.probeSocket)((0, socket_1.socketPath)()))
|
|
166
|
-
return "Relay:
|
|
167
|
+
return "Relay: running. Join a session to connect incoming messages.";
|
|
167
168
|
const autostart = (0, autostart_1.autostartForPlatform)();
|
|
168
169
|
if (!autostart) {
|
|
169
170
|
return `Relay: no automatic startup on ${process.platform}. Run \`baychat relay start --foreground\` to wake sessions instantly.`;
|
|
@@ -176,7 +177,7 @@ async function ensureRelayInstalled() {
|
|
|
176
177
|
throw err;
|
|
177
178
|
return `Relay: installed and started, but only at login — ${err.message}.`;
|
|
178
179
|
}
|
|
179
|
-
return "Relay:
|
|
180
|
+
return "Relay: startup requested. Join a session to check incoming delivery.";
|
|
180
181
|
}
|
|
181
182
|
catch (err) {
|
|
182
183
|
return `Relay: could not start automatically (${err instanceof Error ? err.message : String(err)}). Run \`baychat relay start\` yourself.`;
|
|
@@ -325,7 +326,9 @@ function resumeLabel(s) {
|
|
|
325
326
|
if (!s.resumeId) {
|
|
326
327
|
return "resume: none — a wake while detached is reported DELIVERY PENDING, not delivered";
|
|
327
328
|
}
|
|
328
|
-
const provenance = s.resumeEvidence ??
|
|
329
|
+
const provenance = s.resumeEvidence ??
|
|
330
|
+
s.resumeSource ??
|
|
331
|
+
"origin not recorded (registered before provenance existed)";
|
|
329
332
|
return `resume: ${s.resumeId} — ${provenance}`;
|
|
330
333
|
}
|
|
331
334
|
/**
|
|
@@ -340,7 +343,9 @@ function transportLabel(status) {
|
|
|
340
343
|
if (!status.transport)
|
|
341
344
|
return "unknown (relay predates transport reporting)";
|
|
342
345
|
const detail = status.transportDetail ? ` (${status.transportDetail})` : "";
|
|
343
|
-
return status.transport === "websocket"
|
|
346
|
+
return status.transport === "websocket"
|
|
347
|
+
? `websocket${detail}`
|
|
348
|
+
: `long-poll${detail}`;
|
|
344
349
|
}
|
|
345
350
|
async function cmdRelayStop() {
|
|
346
351
|
// Disarm the init system FIRST. Stopping the process without it means the
|
|
@@ -351,15 +356,24 @@ async function cmdRelayStop() {
|
|
|
351
356
|
if (autostart && fs.existsSync(autostart.unitPath())) {
|
|
352
357
|
try {
|
|
353
358
|
await autostart.uninstall();
|
|
354
|
-
console.log(`Relay stopped and disabled at boot (${autostart.name}).`);
|
|
355
|
-
return 0;
|
|
356
359
|
}
|
|
357
|
-
catch {
|
|
358
|
-
|
|
360
|
+
catch (error) {
|
|
361
|
+
console.log(`Could not disable relay startup: ${error instanceof Error ? error.message : String(error)}`);
|
|
362
|
+
return 1;
|
|
359
363
|
}
|
|
360
364
|
}
|
|
361
365
|
try {
|
|
362
|
-
|
|
366
|
+
if (await (0, socket_1.probeSocket)((0, socket_1.socketPath)())) {
|
|
367
|
+
// A windowless supervisor may stop without killing its child. Ask the
|
|
368
|
+
// relay itself to shut down, and wait so the next start cannot reuse it.
|
|
369
|
+
await request(await connectOrFail(), { type: "stop" });
|
|
370
|
+
const deadline = Date.now() + 5_000;
|
|
371
|
+
while (await (0, socket_1.probeSocket)((0, socket_1.socketPath)())) {
|
|
372
|
+
if (Date.now() >= deadline)
|
|
373
|
+
throw new Error("Relay did not stop within five seconds.");
|
|
374
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
363
377
|
console.log("Relay stopped.");
|
|
364
378
|
return 0;
|
|
365
379
|
}
|
|
@@ -542,6 +556,8 @@ function sessionState(s, lastWakeFailure) {
|
|
|
542
556
|
// outcome outranks a precondition.
|
|
543
557
|
if (lastWakeFailure)
|
|
544
558
|
return `detached — LAST WAKE FAILED, not reachable: ${lastWakeFailure}`;
|
|
559
|
+
if (s.delivery === "queue")
|
|
560
|
+
return "native delivery registered (no waiting terminal needed)";
|
|
545
561
|
// INTERRUPTED, NOT GONE — and this line used to read "headless resume ready",
|
|
546
562
|
// which sounds fine and is now actively false.
|
|
547
563
|
//
|
|
@@ -567,7 +583,9 @@ function sessionState(s, lastWakeFailure) {
|
|
|
567
583
|
if (!hasLiveQueue && s.ownerPid !== undefined && (0, parent_watch_1.processIsAlive)(s.ownerPid)) {
|
|
568
584
|
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";
|
|
569
585
|
}
|
|
570
|
-
return s.resumeId
|
|
586
|
+
return s.resumeId
|
|
587
|
+
? "detached (headless resume ready)"
|
|
588
|
+
: "detached (no resume id)";
|
|
571
589
|
}
|
|
572
590
|
/**
|
|
573
591
|
* One `relay status` session line.
|
|
@@ -627,9 +645,20 @@ async function cmdRelayAttach(opts) {
|
|
|
627
645
|
console.log(" For wakes when nothing is listening, put the key in the relay service once; `relay status` says how.");
|
|
628
646
|
}
|
|
629
647
|
const resume = await resolveAttachResumeId(runtime, opts.resumeId, opts.discovery);
|
|
648
|
+
if (opts.delivery === "queue") {
|
|
649
|
+
if (runtime !== "codex" || !resume.ok || !runtimeBin.bin) {
|
|
650
|
+
console.log("Native delivery needs a verified Codex session id and a working Codex binary. Nothing was registered.");
|
|
651
|
+
return 1;
|
|
652
|
+
}
|
|
653
|
+
await (0, codex_queue_1.verifyCodexQueue)(runtimeBin.bin);
|
|
654
|
+
}
|
|
630
655
|
const sockPath = (0, socket_1.socketPath)();
|
|
631
656
|
const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
|
|
632
657
|
if (shouldFallBackToMailbox(probe)) {
|
|
658
|
+
if (opts.delivery === "queue") {
|
|
659
|
+
console.log("Native delivery could not reach the local relay. Allow the BayChat local connection, then join again.");
|
|
660
|
+
return 1;
|
|
661
|
+
}
|
|
633
662
|
console.log(`Relay socket refused (${probe.alive ? "" : (probe.code ?? "denied")}) — this session is sandboxed.`);
|
|
634
663
|
console.log("Falling back to a mailbox FIFO, which a sandbox permits. `relay status` will show this session as attached (fifo).");
|
|
635
664
|
return attachViaMailbox({
|
|
@@ -671,6 +700,19 @@ async function cmdRelayAttach(opts) {
|
|
|
671
700
|
};
|
|
672
701
|
sock.on("data", (0, socket_1.createFrameReader)((frame) => {
|
|
673
702
|
if (frame.type === "attached") {
|
|
703
|
+
if (opts.delivery === "queue") {
|
|
704
|
+
if (timer)
|
|
705
|
+
clearTimeout(timer);
|
|
706
|
+
sock.end();
|
|
707
|
+
if (frame.delivery !== "queue") {
|
|
708
|
+
console.log("This relay cannot confirm native delivery. Update and restart the BayChat relay, then join again.");
|
|
709
|
+
settle(1);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
console.log(`Connected as "${frame.session}". Messages go to this Codex session automatically; no re-arming is needed.`);
|
|
713
|
+
settle(0);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
674
716
|
console.log(`Attached as "${frame.session}". Waiting for messages…`);
|
|
675
717
|
return;
|
|
676
718
|
}
|
|
@@ -708,6 +750,7 @@ async function cmdRelayAttach(opts) {
|
|
|
708
750
|
type: "attach",
|
|
709
751
|
session: opts.session,
|
|
710
752
|
runtime,
|
|
753
|
+
delivery: opts.delivery,
|
|
711
754
|
resumeId: resume.ok ? resume.resumeId : undefined,
|
|
712
755
|
resumeSource: resume.ok ? resume.source : undefined,
|
|
713
756
|
resumeEvidence: resume.ok ? resume.evidence : undefined,
|
|
@@ -835,7 +878,12 @@ async function resolveAttachResumeId(runtime, explicit, discovery) {
|
|
|
835
878
|
// Not validated: `--resume-id` is the escape hatch, and a runtime whose ids
|
|
836
879
|
// are not uuids must still be able to use it.
|
|
837
880
|
console.log(`Resume id: ${given} (passed with --resume-id)`);
|
|
838
|
-
return {
|
|
881
|
+
return {
|
|
882
|
+
ok: true,
|
|
883
|
+
resumeId: given,
|
|
884
|
+
source: "flag",
|
|
885
|
+
evidence: "passed with --resume-id",
|
|
886
|
+
};
|
|
839
887
|
}
|
|
840
888
|
const found = await (0, resume_1.resumeIdFromSessionEnv)(runtime, discovery);
|
|
841
889
|
if (found.ok) {
|
package/dist/relay/daemon.js
CHANGED
|
@@ -177,7 +177,9 @@ class RelayDaemon {
|
|
|
177
177
|
this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
|
|
178
178
|
this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
|
|
179
179
|
this.reattachGraceMs = opts.reattachGraceMs ?? 4000;
|
|
180
|
-
this.resolveBinary =
|
|
180
|
+
this.resolveBinary =
|
|
181
|
+
opts.resolveBinary ??
|
|
182
|
+
((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
|
|
181
183
|
this.runTurn = opts.runTurn;
|
|
182
184
|
this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
|
|
183
185
|
this.lastError = `delivery failed for ${session}: ${errText(err)}`;
|
|
@@ -306,7 +308,11 @@ class RelayDaemon {
|
|
|
306
308
|
// has already died would otherwise clear the hold AND record `woken` —
|
|
307
309
|
// destroying the only copy of the messages and filing them as
|
|
308
310
|
// delivered, which is worse than never having held them.
|
|
309
|
-
await (0, socket_1.writeFrameAck)(sock, {
|
|
311
|
+
await (0, socket_1.writeFrameAck)(sock, {
|
|
312
|
+
type: "wake",
|
|
313
|
+
conversationId: room.conversationId,
|
|
314
|
+
messages: room.messages,
|
|
315
|
+
});
|
|
310
316
|
}
|
|
311
317
|
catch (err) {
|
|
312
318
|
// KEEP IT. This store is the only copy: dropping it because the handoff
|
|
@@ -408,7 +414,11 @@ class RelayDaemon {
|
|
|
408
414
|
// reported as delivered. A write that does not complete is not a
|
|
409
415
|
// delivery, and the rungs below exist for exactly this case.
|
|
410
416
|
try {
|
|
411
|
-
await (0, socket_1.writeFrameAck)(sock, {
|
|
417
|
+
await (0, socket_1.writeFrameAck)(sock, {
|
|
418
|
+
type: "wake",
|
|
419
|
+
conversationId: batch[0].conversationId,
|
|
420
|
+
messages: batch,
|
|
421
|
+
});
|
|
412
422
|
this.record({ kind: "woken", via: "attach", session }, session, batch);
|
|
413
423
|
return;
|
|
414
424
|
}
|
|
@@ -462,9 +472,18 @@ class RelayDaemon {
|
|
|
462
472
|
message: (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, undefined),
|
|
463
473
|
});
|
|
464
474
|
if (outcome.kind === "queued") {
|
|
475
|
+
this.held.acknowledge(session, batch[0].conversationId, batch.map((message) => message.id));
|
|
465
476
|
this.record({ kind: "woken", via: "queue", session }, session, batch);
|
|
466
477
|
return;
|
|
467
478
|
}
|
|
479
|
+
if (target.delivery === "queue") {
|
|
480
|
+
this.record({
|
|
481
|
+
kind: "pending",
|
|
482
|
+
session,
|
|
483
|
+
reason: `Native Codex delivery failed: ${outcome.reason}`,
|
|
484
|
+
}, session, batch);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
468
487
|
// EVERY non-success falls through, including `failed`.
|
|
469
488
|
//
|
|
470
489
|
// The queue is an OPTIMISATION, not a gate: it reaches the live session so
|
|
@@ -477,6 +496,14 @@ class RelayDaemon {
|
|
|
477
496
|
this.log(`queue did not deliver for ${session} (${outcome.reason}) — falling through to headless`);
|
|
478
497
|
}
|
|
479
498
|
}
|
|
499
|
+
if (target.delivery === "queue") {
|
|
500
|
+
this.record({
|
|
501
|
+
kind: "pending",
|
|
502
|
+
session,
|
|
503
|
+
reason: "Native Codex delivery is unavailable; join again from the existing session.",
|
|
504
|
+
}, session, batch);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
480
507
|
// A target with no resume id is the unbounded failure this whole path
|
|
481
508
|
// exists to close: without one, `canResume` says no and the message waits
|
|
482
509
|
// for a human. Ask the runtime's own on-disk state who this session is
|
|
@@ -533,11 +560,17 @@ class RelayDaemon {
|
|
|
533
560
|
// namespaced pid that reads alive forever (spec §13) — so applying this to
|
|
534
561
|
// fifo sessions would permanently shadow the one rung that can still reach
|
|
535
562
|
// them, which is the 2026-08-31 00:04 regression in a new costume.
|
|
536
|
-
if (!mailbox &&
|
|
563
|
+
if (!mailbox &&
|
|
564
|
+
target.ownerPid !== undefined &&
|
|
565
|
+
this.isAlive(target.ownerPid)) {
|
|
537
566
|
const rearmed = await this.waitForReattach(session, this.reattachGraceMs);
|
|
538
567
|
if (rearmed) {
|
|
539
568
|
try {
|
|
540
|
-
await (0, socket_1.writeFrameAck)(rearmed, {
|
|
569
|
+
await (0, socket_1.writeFrameAck)(rearmed, {
|
|
570
|
+
type: "wake",
|
|
571
|
+
conversationId: batch[0].conversationId,
|
|
572
|
+
messages: batch,
|
|
573
|
+
});
|
|
541
574
|
this.record({ kind: "woken", via: "attach", session }, session, batch);
|
|
542
575
|
return;
|
|
543
576
|
}
|
|
@@ -572,7 +605,9 @@ class RelayDaemon {
|
|
|
572
605
|
if (!check.ok) {
|
|
573
606
|
// The honest outcome: it reached this box, and nothing answered it.
|
|
574
607
|
const why = this.discoveryReasons.get(session)?.reason;
|
|
575
|
-
const reason = why
|
|
608
|
+
const reason = why
|
|
609
|
+
? `${check.reason ?? "cannot resume"}; discovery: ${why}`
|
|
610
|
+
: (check.reason ?? "cannot resume");
|
|
576
611
|
this.record({ kind: "pending", session, reason }, session, batch);
|
|
577
612
|
return;
|
|
578
613
|
}
|
|
@@ -617,7 +652,11 @@ class RelayDaemon {
|
|
|
617
652
|
if (!path.isAbsolute(file)) {
|
|
618
653
|
const binary = this.binaryFor(file);
|
|
619
654
|
if (!binary.ok) {
|
|
620
|
-
this.record({
|
|
655
|
+
this.record({
|
|
656
|
+
kind: "pending",
|
|
657
|
+
session,
|
|
658
|
+
reason: (0, runtime_binary_1.summarizeResolutionFailure)(binary),
|
|
659
|
+
}, session, batch);
|
|
621
660
|
return;
|
|
622
661
|
}
|
|
623
662
|
executable = binary.path;
|
|
@@ -639,7 +678,11 @@ class RelayDaemon {
|
|
|
639
678
|
// why. So this is mutual exclusion between headless turns, and observability
|
|
640
679
|
// for attach-vs-headless. Claiming more than that is what the review caught.
|
|
641
680
|
if (this.headlessInFlight.has(session)) {
|
|
642
|
-
this.record({
|
|
681
|
+
this.record({
|
|
682
|
+
kind: "pending",
|
|
683
|
+
session,
|
|
684
|
+
reason: "a headless turn is already running for this session — refusing to start a second",
|
|
685
|
+
}, session, batch);
|
|
643
686
|
return;
|
|
644
687
|
}
|
|
645
688
|
this.headlessInFlight.add(session);
|
|
@@ -655,7 +698,17 @@ class RelayDaemon {
|
|
|
655
698
|
// Released here, the lease still stops a SECOND headless turn starting, and
|
|
656
699
|
// the message that would have started it is now recorded pending with a
|
|
657
700
|
// reason a person can read. Silence becomes a visible refusal.
|
|
658
|
-
void this.runHeadlessTurn({
|
|
701
|
+
void this.runHeadlessTurn({
|
|
702
|
+
session,
|
|
703
|
+
batch,
|
|
704
|
+
target,
|
|
705
|
+
adapter,
|
|
706
|
+
resolved,
|
|
707
|
+
executable,
|
|
708
|
+
prompt,
|
|
709
|
+
args,
|
|
710
|
+
file,
|
|
711
|
+
})
|
|
659
712
|
.catch((err) => {
|
|
660
713
|
// Nothing awaits this promise any more, so an escaping rejection would
|
|
661
714
|
// be an unhandled one — which can take the whole daemon down and with it
|
|
@@ -663,7 +716,11 @@ class RelayDaemon {
|
|
|
663
716
|
// the honest record is pending.
|
|
664
717
|
const reason = err instanceof Error ? err.message : String(err);
|
|
665
718
|
this.log(`headless turn for ${session} threw: ${reason}`);
|
|
666
|
-
this.record({
|
|
719
|
+
this.record({
|
|
720
|
+
kind: "pending",
|
|
721
|
+
session,
|
|
722
|
+
reason: `headless turn threw: ${reason}`,
|
|
723
|
+
}, session, batch);
|
|
667
724
|
})
|
|
668
725
|
.finally(() => {
|
|
669
726
|
this.headlessInFlight.delete(session);
|
|
@@ -677,7 +734,7 @@ class RelayDaemon {
|
|
|
677
734
|
* or pending with a reason — happens whenever the turn actually ends.
|
|
678
735
|
*/
|
|
679
736
|
async runHeadlessTurn(ctx) {
|
|
680
|
-
const { session, batch, target, adapter, resolved, executable, prompt, args, file } = ctx;
|
|
737
|
+
const { session, batch, target, adapter, resolved, executable, prompt, args, file, } = ctx;
|
|
681
738
|
try {
|
|
682
739
|
// A runtime with a richer transport than "spawn a command" gets to use it.
|
|
683
740
|
// Only a transport that could not be used AT ALL falls through to the spawn:
|
|
@@ -688,7 +745,11 @@ class RelayDaemon {
|
|
|
688
745
|
? (input) => this.runTurn({ runtime: target.runtime, ...input })
|
|
689
746
|
: adapter.runTurn?.bind(adapter);
|
|
690
747
|
if (richTransport) {
|
|
691
|
-
const outcome = await richTransport({
|
|
748
|
+
const outcome = await richTransport({
|
|
749
|
+
binaryPath: executable,
|
|
750
|
+
target: resolved,
|
|
751
|
+
prompt,
|
|
752
|
+
});
|
|
692
753
|
if (outcome.kind === "completed") {
|
|
693
754
|
this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
|
|
694
755
|
return;
|
|
@@ -717,7 +778,11 @@ class RelayDaemon {
|
|
|
717
778
|
this.binaries.delete(file);
|
|
718
779
|
// A non-zero headless turn did not necessarily reply. Recording it as
|
|
719
780
|
// delivered would claim an answer we cannot evidence.
|
|
720
|
-
this.record({
|
|
781
|
+
this.record({
|
|
782
|
+
kind: "pending",
|
|
783
|
+
session,
|
|
784
|
+
reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}`,
|
|
785
|
+
}, session, batch);
|
|
721
786
|
return;
|
|
722
787
|
}
|
|
723
788
|
this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
|
|
@@ -763,7 +828,10 @@ class RelayDaemon {
|
|
|
763
828
|
return target;
|
|
764
829
|
const result = await (0, adapters_1.adapterFor)(target.runtime).discoverResume(target, this.discovery);
|
|
765
830
|
if (!result.ok) {
|
|
766
|
-
this.discoveryReasons.set(target.name, {
|
|
831
|
+
this.discoveryReasons.set(target.name, {
|
|
832
|
+
at: Date.now(),
|
|
833
|
+
reason: result.reason,
|
|
834
|
+
});
|
|
767
835
|
this.log(`no resume id for ${target.name}: ${result.reason}`);
|
|
768
836
|
return target;
|
|
769
837
|
}
|
|
@@ -814,10 +882,19 @@ class RelayDaemon {
|
|
|
814
882
|
let session;
|
|
815
883
|
const read = (0, socket_1.createFrameReader)((frame) => {
|
|
816
884
|
if (frame.type === "attach") {
|
|
885
|
+
if (frame.delivery === "queue" &&
|
|
886
|
+
(frame.runtime !== "codex" || !frame.resumeId || !frame.runtimeBin)) {
|
|
887
|
+
(0, socket_1.writeFrame)(sock, {
|
|
888
|
+
type: "error",
|
|
889
|
+
message: "Native registration requires a Codex session id and binary.",
|
|
890
|
+
});
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
817
893
|
session = frame.session;
|
|
818
894
|
this.registry.upsert({
|
|
819
895
|
name: frame.session,
|
|
820
896
|
runtime: frame.runtime,
|
|
897
|
+
delivery: frame.delivery,
|
|
821
898
|
resumeId: frame.resumeId,
|
|
822
899
|
resumeSource: frame.resumeSource,
|
|
823
900
|
resumeEvidence: frame.resumeEvidence,
|
|
@@ -850,6 +927,21 @@ class RelayDaemon {
|
|
|
850
927
|
this.log(`replacing attach for ${frame.session}: dropping the previous one`);
|
|
851
928
|
previous.destroy();
|
|
852
929
|
}
|
|
930
|
+
if (frame.delivery === "queue") {
|
|
931
|
+
this.attached.delete(frame.session);
|
|
932
|
+
this.registry.setAttached(frame.session, false);
|
|
933
|
+
(0, socket_1.writeFrame)(sock, {
|
|
934
|
+
type: "attached",
|
|
935
|
+
session: frame.session,
|
|
936
|
+
delivery: "queue",
|
|
937
|
+
});
|
|
938
|
+
this.log(`registered native delivery: ${frame.session} (${frame.runtime})`);
|
|
939
|
+
for (const room of this.held.roomsFor(frame.session)) {
|
|
940
|
+
for (const message of room.messages)
|
|
941
|
+
this.queue.push(frame.session, message);
|
|
942
|
+
}
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
853
945
|
this.attached.set(frame.session, sock);
|
|
854
946
|
this.registry.setAttached(frame.session, true);
|
|
855
947
|
(0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
|
|
@@ -867,7 +959,10 @@ class RelayDaemon {
|
|
|
867
959
|
setTimeout(() => void this.stop(), 50);
|
|
868
960
|
return;
|
|
869
961
|
}
|
|
870
|
-
}, (bad) => (0, socket_1.writeFrame)(sock, {
|
|
962
|
+
}, (bad) => (0, socket_1.writeFrame)(sock, {
|
|
963
|
+
type: "error",
|
|
964
|
+
message: `bad frame: ${bad.slice(0, 80)}`,
|
|
965
|
+
}));
|
|
871
966
|
sock.on("data", read);
|
|
872
967
|
const drop = () => {
|
|
873
968
|
if (session && this.attached.get(session) === sock) {
|
|
@@ -883,7 +978,11 @@ class RelayDaemon {
|
|
|
883
978
|
const sessions = this.registry.all().map((t) => ({
|
|
884
979
|
...t,
|
|
885
980
|
attached: this.attached.has(t.name) || this.mailboxes.has(t.name),
|
|
886
|
-
transport: this.attached.has(t.name)
|
|
981
|
+
transport: this.attached.has(t.name)
|
|
982
|
+
? "socket"
|
|
983
|
+
: this.mailboxes.has(t.name)
|
|
984
|
+
? "fifo"
|
|
985
|
+
: undefined,
|
|
887
986
|
}));
|
|
888
987
|
return {
|
|
889
988
|
running: true,
|
|
@@ -908,7 +1007,7 @@ class RelayDaemon {
|
|
|
908
1007
|
await this.queue.idle();
|
|
909
1008
|
for (const w of this.mailboxWatchers)
|
|
910
1009
|
w.stop();
|
|
911
|
-
await new Promise((resolve) =>
|
|
1010
|
+
await new Promise((resolve) => this.server ? this.server.close(() => resolve()) : resolve());
|
|
912
1011
|
(0, socket_1.unlinkStaleSocket)((0, socket_1.socketPath)());
|
|
913
1012
|
try {
|
|
914
1013
|
fs.unlinkSync((0, socket_1.pidFilePath)());
|
package/dist/relay/held.js
CHANGED
|
@@ -70,6 +70,18 @@ function heldPath() {
|
|
|
70
70
|
*/
|
|
71
71
|
class HeldStore {
|
|
72
72
|
filePath;
|
|
73
|
+
/** Remove only messages the native queue acknowledged, preserving other rooms and pending messages. */
|
|
74
|
+
acknowledge(session, conversationId, messageIds) {
|
|
75
|
+
const room = this.rooms.get(session)?.get(conversationId);
|
|
76
|
+
if (!room)
|
|
77
|
+
return;
|
|
78
|
+
const accepted = new Set(messageIds);
|
|
79
|
+
room.messages = room.messages.filter((message) => !accepted.has(message.id));
|
|
80
|
+
if (room.messages.length === 0)
|
|
81
|
+
this.clearRoom(session, conversationId);
|
|
82
|
+
else
|
|
83
|
+
this.save();
|
|
84
|
+
}
|
|
73
85
|
/** session → conversationId → room. */
|
|
74
86
|
rooms = new Map();
|
|
75
87
|
constructor(filePath = heldPath()) {
|
|
@@ -94,11 +106,15 @@ class HeldStore {
|
|
|
94
106
|
const rooms = new Map();
|
|
95
107
|
for (const [conversationId, value] of Object.entries(bySession)) {
|
|
96
108
|
const room = value;
|
|
97
|
-
if (!room ||
|
|
109
|
+
if (!room ||
|
|
110
|
+
!Array.isArray(room.messages) ||
|
|
111
|
+
room.messages.length === 0)
|
|
98
112
|
continue;
|
|
99
113
|
rooms.set(conversationId, {
|
|
100
114
|
conversationId,
|
|
101
|
-
heldAt: typeof room.heldAt === "string"
|
|
115
|
+
heldAt: typeof room.heldAt === "string"
|
|
116
|
+
? room.heldAt
|
|
117
|
+
: new Date().toISOString(),
|
|
102
118
|
messages: room.messages,
|
|
103
119
|
});
|
|
104
120
|
}
|
|
@@ -122,7 +138,9 @@ class HeldStore {
|
|
|
122
138
|
fs.rmSync(this.filePath, { force: true });
|
|
123
139
|
return;
|
|
124
140
|
}
|
|
125
|
-
fs.writeFileSync(this.filePath, JSON.stringify(out, null, 2), {
|
|
141
|
+
fs.writeFileSync(this.filePath, JSON.stringify(out, null, 2), {
|
|
142
|
+
mode: 0o600,
|
|
143
|
+
});
|
|
126
144
|
}
|
|
127
145
|
catch {
|
|
128
146
|
// Losing durability is not losing the hold: the in-memory copy still
|
|
@@ -138,7 +156,11 @@ class HeldStore {
|
|
|
138
156
|
*/
|
|
139
157
|
add(session, conversationId, messages) {
|
|
140
158
|
const rooms = this.rooms.get(session) ?? new Map();
|
|
141
|
-
const room = rooms.get(conversationId) ?? {
|
|
159
|
+
const room = rooms.get(conversationId) ?? {
|
|
160
|
+
conversationId,
|
|
161
|
+
heldAt: new Date().toISOString(),
|
|
162
|
+
messages: [],
|
|
163
|
+
};
|
|
142
164
|
for (const m of messages) {
|
|
143
165
|
if (!room.messages.some((held) => held.id === m.id))
|
|
144
166
|
room.messages.push(m);
|
|
@@ -175,7 +197,12 @@ class HeldStore {
|
|
|
175
197
|
const out = [];
|
|
176
198
|
for (const [session, rooms] of this.rooms) {
|
|
177
199
|
for (const room of rooms.values()) {
|
|
178
|
-
out.push({
|
|
200
|
+
out.push({
|
|
201
|
+
session,
|
|
202
|
+
conversationId: room.conversationId,
|
|
203
|
+
count: room.messages.length,
|
|
204
|
+
heldAt: room.heldAt,
|
|
205
|
+
});
|
|
179
206
|
}
|
|
180
207
|
}
|
|
181
208
|
return out.sort((a, b) => Date.parse(a.heldAt) - Date.parse(b.heldAt));
|