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 +12 -5
- package/dist/relay/adapters.js +26 -7
- package/dist/relay/autostart.js +66 -13
- package/dist/relay/codex-app-server.js +36 -7
- package/dist/relay/codex-queue.js +42 -4
- package/dist/relay/commands.js +57 -9
- package/dist/relay/daemon.js +116 -17
- package/dist/relay/held.js +32 -5
- package/dist/runtime-binary.js +26 -6
- package/dist/runtimes.js +42 -33
- package/dist/session-command.js +25 -12
- package/package.json +1 -1
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.
|
|
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
|
|
53
|
-
Monitor and re-arms automatically after a wake. Codex
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
package/dist/relay/adapters.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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"
|
|
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"
|
|
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 = [
|
|
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) ||
|
|
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.
|
package/dist/relay/autostart.js
CHANGED
|
@@ -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", [
|
|
104
|
+
await execFileAsync("systemctl", [
|
|
105
|
+
"--user",
|
|
106
|
+
"enable",
|
|
107
|
+
"--now",
|
|
108
|
+
exports.SYSTEMD_UNIT_NAME,
|
|
109
|
+
]);
|
|
105
110
|
try {
|
|
106
|
-
await execFileAsync("loginctl", [
|
|
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", [
|
|
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", [
|
|
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", [
|
|
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(
|
|
250
|
-
<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), {
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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 = {
|
|
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({
|
|
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"));
|
|
@@ -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));
|
package/dist/runtime-binary.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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)
|
|
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)
|
|
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) ||
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
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: {
|
|
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: {
|
|
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 {
|
|
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) {
|
package/dist/session-command.js
CHANGED
|
@@ -36,10 +36,14 @@ function parseJoinArgs(args) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
if (positionals.length > 2)
|
|
39
|
-
throw new Error(
|
|
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 {
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
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
|