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.
@@ -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({ kind: "failed", transportUnusable: true, reason: `codex app-server did not complete the handshake within ${Math.round(handshakeTimeout / 1000)}s` }), handshakeTimeout);
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({ kind: "failed", transportUnusable: true, reason: `could not start codex app-server: ${err.message}` });
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: { name: "baychat-relay", title: "BayChat relay", version: CLIENT_VERSION },
160
+ clientInfo: {
161
+ name: "baychat-relay",
162
+ title: "BayChat relay",
163
+ version: CLIENT_VERSION,
164
+ },
152
165
  });
153
166
  if (initialized.error) {
154
- finish({ kind: "failed", transportUnusable: true, reason: `codex app-server refused the handshake: ${initialized.error.message}` });
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({ kind: "failed", transportUnusable: false, reason: `codex could not resume thread ${req.threadId}: ${resumed.error.message}` });
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({ kind: "failed", transportUnusable: false, reason: `codex turn did not complete within ${Math.round(turnTimeout / 60_000)} minutes` }), turnTimeout);
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({ kind: "failed", transportUnusable: false, reason: `codex refused the turn: ${started.error.message}` });
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, [...plan.prefixArgs, "queue", "--thread", input.threadId, "--message", input.message], {
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({ kind: "unsupported", reason: `this codex build has no \`queue\` subcommand: ${firstLine(out)}` });
69
+ return resolve({
70
+ kind: "unsupported",
71
+ reason: `this codex build has no \`queue\` subcommand: ${firstLine(out)}`,
72
+ });
61
73
  }
62
- resolve({ kind: "failed", reason: firstLine(out) || err.message });
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.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
102
+ return (text
103
+ .split("\n")
104
+ .map((l) => l.trim())
105
+ .filter(Boolean)[0] ?? "");
68
106
  }
@@ -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: already running sessions wake instantly.";
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: installed and started sessions now wake the moment a message arrives.";
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 ?? s.resumeSource ?? "origin not recorded (registered before provenance existed)";
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" ? `websocket${detail}` : `long-poll${detail}`;
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
- // Fall through to the socket path the unit may not be the thing running.
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
- await request(await connectOrFail(), { type: "stop" });
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 ? "detached (headless resume ready)" : "detached (no resume id)";
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 { ok: true, resumeId: given, source: "flag", evidence: "passed with --resume-id" };
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) {
@@ -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 = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
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, { type: "wake", conversationId: room.conversationId, messages: room.messages });
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, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
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 && target.ownerPid !== undefined && this.isAlive(target.ownerPid)) {
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, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
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 ? `${check.reason ?? "cannot resume"}; discovery: ${why}` : (check.reason ?? "cannot resume");
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({ kind: "pending", session, reason: (0, runtime_binary_1.summarizeResolutionFailure)(binary) }, session, batch);
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({ kind: "pending", session, reason: "a headless turn is already running for this session — refusing to start a second" }, session, batch);
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({ session, batch, target, adapter, resolved, executable, prompt, args, file })
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({ kind: "pending", session, reason: `headless turn threw: ${reason}` }, session, batch);
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({ binaryPath: executable, target: resolved, prompt });
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({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
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, { at: Date.now(), reason: result.reason });
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, { type: "error", message: `bad frame: ${bad.slice(0, 80)}` }));
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) ? "socket" : this.mailboxes.has(t.name) ? "fifo" : undefined,
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) => (this.server ? this.server.close(() => resolve()) : 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)());
@@ -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 || !Array.isArray(room.messages) || room.messages.length === 0)
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" ? room.heldAt : new Date().toISOString(),
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), { mode: 0o600 });
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) ?? { conversationId, heldAt: new Date().toISOString(), messages: [] };
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({ session, conversationId: room.conversationId, count: room.messages.length, heldAt: room.heldAt });
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));