baychat 0.20.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 CHANGED
@@ -30,7 +30,7 @@ Coding sessions show idle after five minutes without use and expire after
30
30
  24 hours. `end_session` closes one immediately. History survives and the same
31
31
  name can rejoin. Persistent agents such as Hermes keep their own lifecycle.
32
32
 
33
- This workflow requires CLI 0.20.0 and its matching API deployment. Update npm,
33
+ This workflow requires CLI 0.20.1 and the existing session-chat API deployment. Update npm,
34
34
  rerun `baychat connect <runtime>` to refresh its skill, and restart running
35
35
  relays and MCP clients. Updating npm alone does not deploy the remote MCP.
36
36
 
@@ -49,10 +49,17 @@ baychat join --group "Coding" --runtime codex
49
49
  ```
50
50
 
51
51
  It joins through remote MCP, uses the server-confirmed identity, checks the relay
52
- and attaches incoming messages. Claude runs this command with its persistent
53
- Monitor and re-arms automatically after a wake. Codex uses a bounded foreground
54
- attach to register its verified native identity. Failed delivery setup is reported
55
- as incomplete, even when the room join succeeded.
52
+ and connects incoming messages. Claude runs this command with its persistent
53
+ Monitor and re-arms automatically after a wake. Codex verifies native queue
54
+ support and returns as soon as the relay confirms registration. It needs no
55
+ waiting terminal or manual re-arming. Failed delivery setup is reported as
56
+ incomplete, even when the room join succeeded.
57
+
58
+ The Windows relay runs without a visible console, including after login and
59
+ network reconnects. Incoming messages appear as `@Sender` in the coding task.
60
+ The skill echoes BayChat chat replies locally; raw tool output stays local.
61
+ Codex can queue messages behind an active turn, so an accepted message is not
62
+ proof of an immediate reply.
56
63
 
57
64
  For a persistent Hermes installation, run this on the Hermes machine:
58
65
 
@@ -197,7 +197,11 @@ const codexAdapter = {
197
197
  */
198
198
  async runTurn({ binaryPath, target, prompt }) {
199
199
  if (process.env.BAYCHAT_CODEX_TRANSPORT === "exec") {
200
- return { kind: "failed", transportUnusable: true, reason: "BAYCHAT_CODEX_TRANSPORT=exec" };
200
+ return {
201
+ kind: "failed",
202
+ transportUnusable: true,
203
+ reason: "BAYCHAT_CODEX_TRANSPORT=exec",
204
+ };
201
205
  }
202
206
  return (0, codex_app_server_1.runCodexTurn)({
203
207
  binaryPath,
@@ -234,7 +238,10 @@ const cursorAdapter = {
234
238
  // Nothing to discover: Cursor writes no on-disk session state this package
235
239
  // can identify a conversation from, and a search that cannot succeed is
236
240
  // only a way to produce a confident-looking wrong answer.
237
- return { ok: false, reason: "Cursor records no resumable session id on this machine" };
241
+ return {
242
+ ok: false,
243
+ reason: "Cursor records no resumable session id on this machine",
244
+ };
238
245
  },
239
246
  headlessCommand() {
240
247
  // Unreachable: the daemon consults canResume first and reports pending.
@@ -259,7 +266,10 @@ const hermesAdapter = {
259
266
  async discoverResume() {
260
267
  // Nothing to discover: Hermes runs somewhere else and leaves no local
261
268
  // transcript. Searching would only produce a confident-looking wrong answer.
262
- return { ok: false, reason: "Hermes keeps no local session state on this machine" };
269
+ return {
270
+ ok: false,
271
+ reason: "Hermes keeps no local session state on this machine",
272
+ };
263
273
  },
264
274
  headlessCommand() {
265
275
  // Unreachable: the daemon consults canResume first and reports pending.
@@ -317,7 +327,9 @@ function profileAdapter(profile) {
317
327
  // `session-name` runtimes are resumed by the name the human chose, which
318
328
  // IS the BayChat session name — so there is nothing to discover and
319
329
  // nothing that could name someone else's session.
320
- const id = profile.sessionId.kind === "session-name" ? target.name : target.resumeId;
330
+ const id = profile.sessionId.kind === "session-name"
331
+ ? target.name
332
+ : target.resumeId;
321
333
  if (!id) {
322
334
  return {
323
335
  ok: false,
@@ -337,7 +349,9 @@ function profileAdapter(profile) {
337
349
  headlessCommand(target, prompt) {
338
350
  if (!profile.headless)
339
351
  throw new Error(`${profile.id} has no headless command`);
340
- const id = profile.sessionId.kind === "session-name" ? target.name : target.resumeId;
352
+ const id = profile.sessionId.kind === "session-name"
353
+ ? target.name
354
+ : target.resumeId;
341
355
  return {
342
356
  file: target.runtimeBin ?? profile.bin,
343
357
  args: (0, profiles_1.fillTemplate)(profile.headless.args, { id, prompt }),
@@ -353,7 +367,10 @@ function adapterFor(runtime) {
353
367
  return profile ? profileAdapter(profile) : unknownAdapter(runtime);
354
368
  }
355
369
  /** The runtimes we ship knowledge of, for help text. NOT a list of what is accepted. */
356
- exports.KNOWN_RUNTIMES = [...Object.keys(ADAPTERS), ...profiles_1.RUNTIME_PROFILES.map((p) => p.id)];
370
+ exports.KNOWN_RUNTIMES = [
371
+ ...Object.keys(ADAPTERS),
372
+ ...profiles_1.RUNTIME_PROFILES.map((p) => p.id),
373
+ ];
357
374
  /**
358
375
  * Do we ship knowledge of this runtime?
359
376
  *
@@ -364,7 +381,8 @@ exports.KNOWN_RUNTIMES = [...Object.keys(ADAPTERS), ...profiles_1.RUNTIME_PROFIL
364
381
  * us, and the Cursor outage was the same mistake from the other side.
365
382
  */
366
383
  function isKnownRuntime(value) {
367
- return Object.prototype.hasOwnProperty.call(ADAPTERS, value) || (0, profiles_1.profileFor)(value) !== undefined;
384
+ return (Object.prototype.hasOwnProperty.call(ADAPTERS, value) ||
385
+ (0, profiles_1.profileFor)(value) !== undefined);
368
386
  }
369
387
  /**
370
388
  * Run a headless turn to completion.
@@ -377,6 +395,7 @@ function isKnownRuntime(value) {
377
395
  function runHeadless(file, args, opts = {}) {
378
396
  return new Promise((resolve) => {
379
397
  const child = (0, child_process_1.spawn)(file, args, {
398
+ windowsHide: true,
380
399
  cwd: opts.cwd,
381
400
  // An npm-installed runtime is a script with a `#!/usr/bin/env node`
382
401
  // shebang, and the daemon's systemd PATH has no node. See ./spawn-env.ts.
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.AUTOSTART_IMPLEMENTATIONS = exports.LingerUnavailable = exports.SYSTEMD_UNIT_NAME = exports.SERVICE_LABEL = void 0;
36
+ exports.AUTOSTART_IMPLEMENTATIONS = exports.WINDOWS_RELAY_LAUNCHER = exports.LingerUnavailable = exports.SYSTEMD_UNIT_NAME = exports.SERVICE_LABEL = void 0;
37
37
  exports.autostartForPlatform = autostartForPlatform;
38
38
  exports.relayCommand = relayCommand;
39
39
  // Making the relay come back by itself, on all three platforms.
@@ -101,9 +101,17 @@ WantedBy=default.target
101
101
  fs.mkdirSync(path.dirname(target), { recursive: true });
102
102
  fs.writeFileSync(target, systemd.unit(cmd), { mode: 0o644 });
103
103
  await execFileAsync("systemctl", ["--user", "daemon-reload"]);
104
- await execFileAsync("systemctl", ["--user", "enable", "--now", exports.SYSTEMD_UNIT_NAME]);
104
+ await execFileAsync("systemctl", [
105
+ "--user",
106
+ "enable",
107
+ "--now",
108
+ exports.SYSTEMD_UNIT_NAME,
109
+ ]);
105
110
  try {
106
- await execFileAsync("loginctl", ["enable-linger", os.userInfo().username]);
111
+ await execFileAsync("loginctl", [
112
+ "enable-linger",
113
+ os.userInfo().username,
114
+ ]);
107
115
  }
108
116
  catch {
109
117
  // Not fatal and not silent: the caller prints the degraded promise rather
@@ -112,7 +120,12 @@ WantedBy=default.target
112
120
  }
113
121
  },
114
122
  async uninstall() {
115
- await execFileAsync("systemctl", ["--user", "disable", "--now", exports.SYSTEMD_UNIT_NAME]).catch(() => undefined);
123
+ await execFileAsync("systemctl", [
124
+ "--user",
125
+ "disable",
126
+ "--now",
127
+ exports.SYSTEMD_UNIT_NAME,
128
+ ]).catch(() => undefined);
116
129
  fs.rmSync(systemd.unitPath(), { force: true });
117
130
  },
118
131
  };
@@ -175,12 +188,18 @@ const launchd = {
175
188
  const domain = `gui/${os.userInfo().uid}`;
176
189
  // Replacing an agent means removing the old one first; bootout on a label
177
190
  // that was never loaded exits non-zero, which is not a failure here.
178
- await execFileAsync("launchctl", ["bootout", `${domain}/${exports.SERVICE_LABEL}`]).catch(() => undefined);
191
+ await execFileAsync("launchctl", [
192
+ "bootout",
193
+ `${domain}/${exports.SERVICE_LABEL}`,
194
+ ]).catch(() => undefined);
179
195
  await execFileAsync("launchctl", ["bootstrap", domain, target]);
180
196
  },
181
197
  async uninstall() {
182
198
  const domain = `gui/${os.userInfo().uid}`;
183
- await execFileAsync("launchctl", ["bootout", `${domain}/${exports.SERVICE_LABEL}`]).catch(() => undefined);
199
+ await execFileAsync("launchctl", [
200
+ "bootout",
201
+ `${domain}/${exports.SERVICE_LABEL}`,
202
+ ]).catch(() => undefined);
184
203
  fs.rmSync(launchd.unitPath(), { force: true });
185
204
  },
186
205
  };
@@ -246,8 +265,8 @@ const scheduledTask = {
246
265
  </Settings>
247
266
  <Actions Context="Author">
248
267
  <Exec>
249
- <Command>${escapeXml(cmd.node)}</Command>
250
- <Arguments>"${escapeXml(cmd.entry)}" relay start --foreground</Arguments>
268
+ <Command>${escapeXml(path.win32.join(process.env.SystemRoot || "C:\\Windows", "System32", "wscript.exe"))}</Command>
269
+ <Arguments>//B //NoLogo ${escapeXml(`"${windowsLauncherPath()}" "${cmd.node}" "${cmd.entry}"`)} relay start --foreground</Arguments>
251
270
  </Exec>
252
271
  </Actions>
253
272
  </Task>
@@ -258,17 +277,47 @@ const scheduledTask = {
258
277
  async install(cmd) {
259
278
  const target = scheduledTask.unitPath();
260
279
  fs.mkdirSync(path.dirname(target), { recursive: true });
280
+ fs.writeFileSync(windowsLauncherPath(), exports.WINDOWS_RELAY_LAUNCHER, "utf8");
261
281
  // UTF-16LE with a BOM, because that is what the XML declaration above claims
262
282
  // and what schtasks refuses the file without.
263
- fs.writeFileSync(target, "\ufeff" + scheduledTask.unit(cmd), { encoding: "utf16le" });
264
- await execFileAsync("schtasks", ["/create", "/tn", exports.SERVICE_LABEL, "/xml", target, "/f"]);
265
- await execFileAsync("schtasks", ["/run", "/tn", exports.SERVICE_LABEL]);
283
+ fs.writeFileSync(target, "\ufeff" + scheduledTask.unit(cmd), {
284
+ encoding: "utf16le",
285
+ });
286
+ await execFileAsync("schtasks", ["/create", "/tn", exports.SERVICE_LABEL, "/xml", target, "/f"], { windowsHide: true });
287
+ await execFileAsync("schtasks", ["/run", "/tn", exports.SERVICE_LABEL], {
288
+ windowsHide: true,
289
+ });
266
290
  },
267
291
  async uninstall() {
268
- await execFileAsync("schtasks", ["/delete", "/tn", exports.SERVICE_LABEL, "/f"]).catch(() => undefined);
292
+ // Deleting a task does not stop its running child. End the supervised
293
+ // process first so `relay stop` cannot leave an invisible orphan behind.
294
+ await execFileAsync("schtasks", ["/end", "/tn", exports.SERVICE_LABEL], {
295
+ windowsHide: true,
296
+ }).catch(() => undefined);
297
+ await execFileAsync("schtasks", ["/delete", "/tn", exports.SERVICE_LABEL, "/f"], {
298
+ windowsHide: true,
299
+ }).catch(() => undefined);
269
300
  fs.rmSync(scheduledTask.unitPath(), { force: true });
301
+ fs.rmSync(windowsLauncherPath(), { force: true });
270
302
  },
271
303
  };
304
+ /** WSH is a GUI executable, so starting it never allocates a visible console.
305
+ * Run's window style 0 hides the child too. Waiting and forwarding its exit
306
+ * code keeps Task Scheduler's restart supervision attached to the actual relay.
307
+ * This is Windows Script Host JScript, not Node; no cmd.exe or shell is involved.
308
+ */
309
+ exports.WINDOWS_RELAY_LAUNCHER = `var shell = new ActiveXObject("WScript.Shell");
310
+ var command = [];
311
+ for (var index = 0; index < WScript.Arguments.length; index++) {
312
+ var argument = WScript.Arguments(index);
313
+ if (/["\\r\\n]/.test(argument)) throw new Error("Invalid relay startup argument");
314
+ command.push('"' + argument + '"');
315
+ }
316
+ WScript.Quit(shell.Run(command.join(" "), 0, true));
317
+ `;
318
+ function windowsLauncherPath() {
319
+ return path.join(configHome(), "relay-launcher.js");
320
+ }
272
321
  /** `DOMAIN\user`, which is what a task's `UserId` wants. `USERDOMAIN` is absent
273
322
  * on a machine that was never joined to a domain; the bare username is correct
274
323
  * there, and inventing a domain would register a task for a principal that does
@@ -327,4 +376,8 @@ function relayCommand(physicalPath = (value) => fs.realpathSync.native(value)) {
327
376
  }
328
377
  /** Exported for tests, which need to reach a platform other than the one they
329
378
  * run on. Production code goes through {@link autostartForPlatform}. */
330
- exports.AUTOSTART_IMPLEMENTATIONS = { systemd, launchd, scheduledTask };
379
+ exports.AUTOSTART_IMPLEMENTATIONS = {
380
+ systemd,
381
+ launchd,
382
+ scheduledTask,
383
+ };
@@ -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"));
@@ -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));
@@ -68,11 +68,20 @@ function resolveRuntimeBinary(name, env) {
68
68
  for (const candidate of candidates) {
69
69
  const probed = env.probe(candidate.path);
70
70
  if (probed.ok) {
71
- return { ok: true, path: candidate.path, version: probed.version, source: candidate.source };
71
+ return {
72
+ ok: true,
73
+ path: candidate.path,
74
+ version: probed.version,
75
+ source: candidate.source,
76
+ };
72
77
  }
73
78
  rejected.push({ path: candidate.path, reason: probed.detail });
74
79
  }
75
- return { ok: false, reason: `no working ${name} binary on this machine`, rejected };
80
+ return {
81
+ ok: false,
82
+ reason: `no working ${name} binary on this machine`,
83
+ rejected,
84
+ };
76
85
  }
77
86
  /** Render a resolution for a human — one line on success, a full account on failure. */
78
87
  function describeResolution(name, resolution) {
@@ -101,7 +110,12 @@ function resolveOverride(override, env) {
101
110
  }
102
111
  const probed = env.probe(override);
103
112
  if (probed.ok)
104
- return { ok: true, path: override, version: probed.version, source: "override" };
113
+ return {
114
+ ok: true,
115
+ path: override,
116
+ version: probed.version,
117
+ source: "override",
118
+ };
105
119
  return {
106
120
  ok: false,
107
121
  reason: `configured override ${override} does not run`,
@@ -122,7 +136,9 @@ function pathCandidates(name, env) {
122
136
  for (const entry of env.pathEntries) {
123
137
  if (entry.trim() === "")
124
138
  continue;
125
- const source = isForeignMount(entry, env.platform) ? "foreign-path" : "path";
139
+ const source = isForeignMount(entry, env.platform)
140
+ ? "foreign-path"
141
+ : "path";
126
142
  for (const fileName of executableNames(name, env.platform)) {
127
143
  const candidate = joinPath(entry, fileName, env.platform);
128
144
  if (seen.has(candidate))
@@ -166,7 +182,9 @@ function executableNames(name, platform) {
166
182
  */
167
183
  function joinPath(dir, file, platform) {
168
184
  const separator = platform === "win32" ? "\\" : "/";
169
- const trimmed = dir.endsWith(separator) ? dir.slice(0, -separator.length) : dir;
185
+ const trimmed = dir.endsWith(separator)
186
+ ? dir.slice(0, -separator.length)
187
+ : dir;
170
188
  return `${trimmed}${separator}${file}`;
171
189
  }
172
190
  /**
@@ -209,7 +227,8 @@ function summarizeProbe(outcome) {
209
227
  if (outcome.status !== 0) {
210
228
  // Prefer stderr: a failing CLI puts its diagnosis there, and it is what
211
229
  // names the actual fault (e.g. the missing optional dependency).
212
- const said = firstMeaningfulLine(outcome.stderr) || firstMeaningfulLine(outcome.stdout);
230
+ const said = firstMeaningfulLine(outcome.stderr) ||
231
+ firstMeaningfulLine(outcome.stdout);
213
232
  const exited = `exits ${outcome.status ?? "on a signal"}`;
214
233
  return { ok: false, detail: said ? `${exited}: ${said}` : exited };
215
234
  }
@@ -270,6 +289,7 @@ function currentBinaryEnv(override, env = process.env, execPath = process.execPa
270
289
  probe(candidate) {
271
290
  const plan = spawnPlanFor(candidate, process.platform);
272
291
  const run = (0, child_process_1.spawnSync)(plan.file, [...plan.prefixArgs, "--version"], {
292
+ windowsHide: true,
273
293
  timeout: PROBE_TIMEOUT_MS,
274
294
  encoding: "utf8",
275
295
  shell: false,
package/dist/runtimes.js CHANGED
@@ -28,7 +28,15 @@ exports.renderCommandFor = renderCommandFor;
28
28
  // The rooms guidance is shared with `baychat help groups`, so the words a person
29
29
  // reads in their terminal and the words their agent was given are the same words.
30
30
  const help_topics_1 = require("./help-topics");
31
- exports.RUNTIMES = ["claude", "codex", "cursor", "desktop", "pi", "hermes", "generic"];
31
+ exports.RUNTIMES = [
32
+ "claude",
33
+ "codex",
34
+ "cursor",
35
+ "desktop",
36
+ "pi",
37
+ "hermes",
38
+ "generic",
39
+ ];
32
40
  const GENERIC_RESUME_NOTE = `The relay can only wake this session while \`attach\` is running. Re-arm it after
33
41
  every wake; a message that arrives while nothing is listening is recorded
34
42
  DELIVERY PENDING and waits for a human.`;
@@ -55,7 +63,9 @@ function attachFor(spec) {
55
63
  reachability: reachabilityFor(attachLine, GENERIC_RESUME_NOTE, "background-every-wake"),
56
64
  };
57
65
  }
58
- const resumeFlag = spec.relay.sessionIdExpr ? ` --resume-id "${spec.relay.sessionIdExpr}"` : "";
66
+ const resumeFlag = spec.relay.sessionIdExpr
67
+ ? ` --resume-id "${spec.relay.sessionIdExpr}"`
68
+ : "";
59
69
  const attachLine = `baychat relay attach --session "<name>" --runtime ${spec.relay.runtime}${resumeFlag}`;
60
70
  return {
61
71
  attachLine,
@@ -76,38 +86,25 @@ function attachFor(spec) {
76
86
  */
77
87
  function reachabilityFor(attachLine, resumeNote, reArm) {
78
88
  if (reArm === "arm-once") {
79
- // BOUNDED. `cmdRelayAttach` only installs a timer when `--timeout` is given,
80
- // and the mailbox path blocks in `readFile(fifo)` with no deadline at all —
81
- // so an unbounded "arm once" command hangs the agent's turn until a message
82
- // happens to arrive. Registration completes in milliseconds; the wait is not
83
- // what this rung is for.
84
- const bounded = `${attachLine} --timeout 30`;
85
- return `## Staying reachable — arm once
86
-
87
- If \`baychat relay\` is running on this machine (\`baychat connect\` starts it), run
88
- this ONCE, in the FOREGROUND, as soon as you have joined:
89
-
90
- \`\`\`
91
- ${bounded}
92
- \`\`\`
89
+ return `## Staying reachable automatic delivery
93
90
 
94
- This is how the relay learns which runtime session you are. \`--timeout 30\` is
95
- part of the command, not a suggestion: registration is the point here, and the
96
- wait is not. Without it the command blocks until a message happens to arrive,
97
- which hangs your turn for no benefit.
98
-
99
- **Do NOT put it in the background.** Your sandbox kills backgrounded processes
100
- when the command returns, so a backgrounded attach listens to nothing while
101
- looking like it worked.
91
+ \`baychat join\` registers this verified Codex task with the relay, confirms native
92
+ delivery, and returns immediately. Run that short command in the FOREGROUND.
93
+ **Do NOT put it in the background.** No waiting terminal or polling tool is needed.
102
94
 
103
95
  **You do not need to re-arm it.** ${resumeNote}
104
96
 
105
- It prints \`No new messages before timeout.\` and exits that is success, not a
106
- failure. Arm once, then get on with your work.
97
+ Do not run a separate \`relay attach\` after joining. If registration fails, show
98
+ the actual error and fix it; a join alone does not prove incoming delivery works.
99
+ Recovery is to rerun the same join with the user's confirmed name and room.
100
+
101
+ Incoming BayChat messages appear in this task as \`@Sender\` text. Answer in the
102
+ confirmed BayChat conversation when \`shouldRespond\` authorizes you, and echo
103
+ your chat reply here so the user can follow the same conversation. Do not mirror
104
+ tool output, credentials, private reasoning or unrelated coding conversation.
107
105
 
108
- If you are ever unsure whether the relay knows you, run \`baychat relay status\`:
109
- you are reachable when your session is listed with a resume id, whether or not
110
- anything is attached.`;
106
+ A native queue receipt means accepted by Codex, not read or answered. Codex may
107
+ queue messages behind an active turn; never describe that as an instant reply.`;
111
108
  }
112
109
  if (reArm === "supervised-loop") {
113
110
  // WHO runs attach again is the whole fix — attach itself is unchanged.
@@ -258,7 +255,7 @@ ${help_topics_1.ROOMS_TOPIC}
258
255
  room context, starts the relay if needed, and connects incoming messages.
259
256
  ${ctx.runtime === "claude"
260
257
  ? "Run it with the **Monitor** tool, **persistent: true**. It stays in the foreground and re-arms itself after each wake. Do not start a second attach loop while this command is running."
261
- : "Run it in the **foreground**. Codex uses a bounded 30-second wait; never use nohup, setsid or shell backgrounding."}
258
+ : "Run it in the **foreground**. Codex returns after native delivery registration; never use nohup, setsid or shell backgrounding."}
262
259
  2. Use the **server-confirmed name** as \`session\` on every later BayChat tool
263
260
  call, including \`list_agents\` and \`contact_agent\`. There is no default.
264
261
  Print the confirmed name and room to the user. A join refusal or delivery
@@ -464,7 +461,11 @@ DELIVERY PENDING and waits for a human — re-arm attach after every wake, witho
464
461
  desktop: {
465
462
  id: "desktop",
466
463
  label: "Claude Desktop",
467
- mcp: { kind: "file", format: "json", describe: "claude_desktop_config.json" },
464
+ mcp: {
465
+ kind: "file",
466
+ format: "json",
467
+ describe: "claude_desktop_config.json",
468
+ },
468
469
  // Claude Desktop has no user-authored command mechanism on disk.
469
470
  command: null,
470
471
  invocation: 'ask it to "join BayChat as <name>"',
@@ -486,7 +487,10 @@ DELIVERY PENDING and waits for a human — re-arm attach after every wake, witho
486
487
  hermes: {
487
488
  id: "hermes",
488
489
  label: "Hermes",
489
- mcp: { kind: "manual", describe: "printed `mcp_servers` block for ~/.hermes/config.yaml" },
490
+ mcp: {
491
+ kind: "manual",
492
+ describe: "printed `mcp_servers` block for ~/.hermes/config.yaml",
493
+ },
490
494
  // Hermes is self-hosted: it is WOKEN through the Agent API (its platform
491
495
  // adapter long-polls), and it ACTS through our remote MCP endpoint. Two
492
496
  // halves, and this entry used to claim the second did not exist — "Hermes
@@ -520,7 +524,12 @@ function runtimeSpec(id) {
520
524
  /** The full `CommandContext` a runtime's skill body is rendered from. */
521
525
  function commandContextFor(id) {
522
526
  const spec = exports.RUNTIME_SPECS[id];
523
- return { runtime: id, invocation: spec.invocation, name: "baychat", ...attachFor(spec) };
527
+ return {
528
+ runtime: id,
529
+ invocation: spec.invocation,
530
+ name: "baychat",
531
+ ...attachFor(spec),
532
+ };
524
533
  }
525
534
  /** Body for a runtime's command file, or null when it has none. */
526
535
  function renderCommandFor(id) {
@@ -36,10 +36,14 @@ function parseJoinArgs(args) {
36
36
  }
37
37
  }
38
38
  if (positionals.length > 2)
39
- throw new Error('Usage: baychat join [name] [group] [--runtime runtime]');
39
+ throw new Error("Usage: baychat join [name] [group] [--runtime runtime]");
40
40
  if (positionals[1] && options.group)
41
41
  throw new Error("Choose the group once, as a title or with --group.");
42
- return { ...options, session: positionals[0], group: options.group ?? positionals[1] };
42
+ return {
43
+ ...options,
44
+ session: positionals[0],
45
+ group: options.group ?? positionals[1],
46
+ };
43
47
  }
44
48
  /** Join and arm the current terminal in one foreground command.
45
49
  * The server owns identity/membership; the relay owns native wake delivery.
@@ -57,10 +61,15 @@ async function cmdJoinSession(args) {
57
61
  if (runtime === "hermes") {
58
62
  throw new Error("Hermes is a persistent agent. Use baychat connect hermes.");
59
63
  }
60
- if (joining && runtime !== "codex" && runtime !== "claude" && runtime !== "cursor") {
64
+ if (joining &&
65
+ runtime !== "codex" &&
66
+ runtime !== "claude" &&
67
+ runtime !== "cursor") {
61
68
  throw new Error("Supported coding runtimes: codex, claude, cursor.");
62
69
  }
63
- let name = joining ? options.session ?? await (0, session_name_1.automaticSessionName)(runtime) : undefined;
70
+ let name = joining
71
+ ? (options.session ?? (await (0, session_name_1.automaticSessionName)(runtime)))
72
+ : undefined;
64
73
  const client = new index_js_1.Client({ name: "baychat-session", version: "1" });
65
74
  try {
66
75
  await client.connect(new streamableHttp_js_1.StreamableHTTPClientTransport(new URL("/api/mcp", device.baseUrl), {
@@ -71,18 +80,21 @@ async function cmdJoinSession(args) {
71
80
  }));
72
81
  const result = await client.callTool({
73
82
  name: joining ? "join_session" : "list_sessions",
74
- arguments: joining ? { session: name, ...(options.group ? { group: options.group } : {}) } : {},
83
+ arguments: joining
84
+ ? { session: name, ...(options.group ? { group: options.group } : {}) }
85
+ : {},
75
86
  });
76
87
  const text = (0, message_format_1.cleanTerminalText)(result.content
77
- .filter(item => item.type === "text")
78
- .map(item => item.text ?? "")
88
+ .filter((item) => item.type === "text")
89
+ .map((item) => item.text ?? "")
79
90
  .join("\n"));
80
91
  if (result.isError)
81
92
  throw new Error(text || "BayChat refused the session request.");
82
93
  if (joining) {
83
94
  const structured = result.structuredContent;
84
95
  const confirmedName = structured && typeof structured === "object" && "session" in structured
85
- ? structured.session : undefined;
96
+ ? structured.session
97
+ : undefined;
86
98
  if (typeof confirmedName !== "string" || !confirmedName.trim()) {
87
99
  throw new Error("The server did not confirm the session identity. Check the API version before retrying.");
88
100
  }
@@ -100,7 +112,7 @@ async function cmdJoinSession(args) {
100
112
  const startup = await (0, commands_1.ensureRelayInstalled)();
101
113
  let status = await (0, commands_1.tryRelayStatus)();
102
114
  for (let attempt = 0; !status && attempt < 10; attempt++) {
103
- await new Promise(resolve => setTimeout(resolve, 100));
115
+ await new Promise((resolve) => setTimeout(resolve, 100));
104
116
  status = await (0, commands_1.tryRelayStatus)();
105
117
  }
106
118
  if (!status) {
@@ -111,9 +123,10 @@ async function cmdJoinSession(args) {
111
123
  const attach = () => (0, commands_1.cmdRelayAttach)({
112
124
  session: name,
113
125
  runtime: runtime,
114
- // Codex must keep this command in the foreground; a bounded wait returns
115
- // control to its tool loop. Other runtimes supervise their own foreground wait.
116
- ...(runtime === "codex" ? { timeoutMs: 30_000 } : {}),
126
+ // Native delivery only needs a confirmed registration, not a waiting tool.
127
+ ...(runtime === "codex"
128
+ ? { delivery: "queue", timeoutMs: 5_000 }
129
+ : {}),
117
130
  });
118
131
  let code = await attach();
119
132
  // Claude's Monitor keeps this foreground process alive. Re-arm immediately
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "BayChat connector CLI — pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
5
5
  "bin": {
6
6
  "baychat": "dist/index.js"