baychat 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -1
- package/dist/args.js +57 -0
- package/dist/client-paths.js +69 -0
- package/dist/commands.js +108 -0
- package/dist/connect.js +46 -14
- package/dist/credential-refresh.js +97 -0
- package/dist/doctor-command.js +154 -0
- package/dist/doctor.js +502 -0
- package/dist/help-topics.js +197 -0
- package/dist/index.js +69 -27
- package/dist/relay/adapters.js +82 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +177 -31
- package/dist/relay/daemon.js +259 -3
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +319 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +98 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +269 -0
- package/dist/runtimes.js +122 -68
- package/package.json +2 -2
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Waking a Codex thread through `codex app-server` instead of `codex exec resume`.
|
|
3
|
+
//
|
|
4
|
+
// WHY. `codex exec resume <id> <prompt>` is a fire-and-forget process whose only
|
|
5
|
+
// report is an exit code. It cannot say whether the turn answered, whether it
|
|
6
|
+
// stopped at an approval, or whether the id even named a real thread — every one
|
|
7
|
+
// of those is "exit non-zero", and the relay could only record DELIVERY PENDING
|
|
8
|
+
// with a number in it.
|
|
9
|
+
//
|
|
10
|
+
// `codex app-server` is the JSON-RPC 2.0 interface behind OpenAI's own VS Code
|
|
11
|
+
// and JetBrains plugins. It answers `thread/resume` for a specific id — with a
|
|
12
|
+
// distinct error when that id is not a real thread — and reports the turn's end
|
|
13
|
+
// as `turn/completed` rather than as a process exit. Verified against codex-cli
|
|
14
|
+
// 0.114.0 on 2026-08-30: initialize → initialized → thread/resume → turn/start,
|
|
15
|
+
// with the resumed thread's own MCP servers (BayChat included) started for it.
|
|
16
|
+
//
|
|
17
|
+
// ONE PROCESS PER WAKE, NOT A HELD CONNECTION. The design note for this work
|
|
18
|
+
// proposed keeping one app-server alive for the daemon's life. That buys a little
|
|
19
|
+
// latency and costs a supervision problem — restarts, health, a wedged child
|
|
20
|
+
// holding every session's queue — for a path that runs at human speed anyway.
|
|
21
|
+
// A process per wake keeps the failure modes identical to the ones `runHeadless`
|
|
22
|
+
// already has, and every win that mattered (an id we address explicitly, a
|
|
23
|
+
// structured turn result, no shell) survives.
|
|
24
|
+
//
|
|
25
|
+
// `approvalPolicy: "never"` IS THE SECURITY BOUNDARY. A headless turn has no
|
|
26
|
+
// human to ask, so the only safe policy is one that never prompts and never
|
|
27
|
+
// escalates: a command needing approval fails inside the sandbox instead of
|
|
28
|
+
// running because a chat message asked for it. The relay carries messages; it
|
|
29
|
+
// does not acquire privileges on their behalf.
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.runCodexTurn = runCodexTurn;
|
|
32
|
+
const runtime_binary_1 = require("../runtime-binary");
|
|
33
|
+
const spawn_env_1 = require("./spawn-env");
|
|
34
|
+
/** How long a whole wake may take — resume, turn, and completion. */
|
|
35
|
+
const TURN_TIMEOUT_MS = 10 * 60_000;
|
|
36
|
+
/** How long the handshake alone may take before we call the binary unusable. */
|
|
37
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
38
|
+
/**
|
|
39
|
+
* Run one turn against an existing Codex thread and resolve with its outcome.
|
|
40
|
+
*
|
|
41
|
+
* Never throws and never rejects: every failure — a binary that will not start,
|
|
42
|
+
* a thread id that names nothing, a protocol error, a timeout — is a `failed`
|
|
43
|
+
* outcome carrying its reason. The caller's job is to record that, and a thrown
|
|
44
|
+
* exception would only turn a describable state into a stack trace.
|
|
45
|
+
*/
|
|
46
|
+
function runCodexTurn(req, deps) {
|
|
47
|
+
const spawn = deps.spawn;
|
|
48
|
+
const turnTimeout = deps.turnTimeoutMs ?? TURN_TIMEOUT_MS;
|
|
49
|
+
const handshakeTimeout = deps.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
// A Windows .cmd needs its interpreter named, exactly as the plain-spawn
|
|
52
|
+
// path does. This was missed when that fix landed, and the result was
|
|
53
|
+
// `delivery failed for CodexTest: spawn EINVAL` on every wake — the
|
|
54
|
+
// transport failing before the handshake, on a machine where Codex was
|
|
55
|
+
// installed and working.
|
|
56
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(req.binaryPath, process.platform);
|
|
57
|
+
const child = spawn(plan.file, [...plan.prefixArgs, "app-server"], {
|
|
58
|
+
cwd: req.cwd,
|
|
59
|
+
// Same reason as `runHeadless`: an npm-installed codex is a script with a
|
|
60
|
+
// `#!/usr/bin/env node` shebang and the daemon's PATH has no node. Both
|
|
61
|
+
// spawn sites need this — fixing only one leaves the other dying with
|
|
62
|
+
// exit 127 the moment it becomes the reachable path.
|
|
63
|
+
env: (0, spawn_env_1.headlessSpawnEnv)(),
|
|
64
|
+
// No shell, ever: the prompt carries message text written by other people
|
|
65
|
+
// in the room, and a shell string would make `$(…)` in a chat message run
|
|
66
|
+
// on this box. Nothing here is concatenated into a command line.
|
|
67
|
+
shell: false,
|
|
68
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
69
|
+
});
|
|
70
|
+
let settled = false;
|
|
71
|
+
let nextId = 0;
|
|
72
|
+
const pending = new Map();
|
|
73
|
+
let stdoutBuffer = "";
|
|
74
|
+
let stderrTail = "";
|
|
75
|
+
/** Resolve once, and always leave no process behind. */
|
|
76
|
+
const finish = (outcome) => {
|
|
77
|
+
if (settled)
|
|
78
|
+
return;
|
|
79
|
+
settled = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
child.kill("SIGTERM");
|
|
82
|
+
// A child that ignores the polite signal would otherwise outlive the relay
|
|
83
|
+
// and keep answering a room nobody is watching.
|
|
84
|
+
setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
|
|
85
|
+
resolve(outcome);
|
|
86
|
+
};
|
|
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
|
+
const request = (method, params) => new Promise((res) => {
|
|
89
|
+
const id = nextId++;
|
|
90
|
+
pending.set(id, res);
|
|
91
|
+
child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
92
|
+
});
|
|
93
|
+
const notify = (method, params) => {
|
|
94
|
+
child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
95
|
+
};
|
|
96
|
+
child.stdout?.on("data", (chunk) => {
|
|
97
|
+
stdoutBuffer += chunk.toString();
|
|
98
|
+
// Newline-delimited JSON. A partial line is kept for the next chunk;
|
|
99
|
+
// parsing one would be the classic framing bug.
|
|
100
|
+
let newline = stdoutBuffer.indexOf("\n");
|
|
101
|
+
while (newline >= 0) {
|
|
102
|
+
const line = stdoutBuffer.slice(0, newline).trim();
|
|
103
|
+
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
104
|
+
newline = stdoutBuffer.indexOf("\n");
|
|
105
|
+
if (line === "")
|
|
106
|
+
continue;
|
|
107
|
+
let message;
|
|
108
|
+
try {
|
|
109
|
+
message = JSON.parse(line);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Not our protocol. Ignored rather than fatal: the binary is entitled
|
|
113
|
+
// to print things, and a stray line must not lose a turn that is
|
|
114
|
+
// otherwise proceeding normally.
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (typeof message.id === "number" && pending.has(message.id)) {
|
|
118
|
+
const waiting = pending.get(message.id);
|
|
119
|
+
pending.delete(message.id);
|
|
120
|
+
waiting?.(message);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// `turn/completed` is the end of the wake — the whole reason this
|
|
124
|
+
// transport is better than an exit code.
|
|
125
|
+
if (message.method === "turn/completed")
|
|
126
|
+
finish({ kind: "completed" });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
child.stderr?.on("data", (chunk) => {
|
|
130
|
+
// Bounded, and kept only to explain a failure. Codex logs freely here even
|
|
131
|
+
// on a healthy run, so it is never treated as a failure signal by itself.
|
|
132
|
+
const text = chunk.toString();
|
|
133
|
+
stderrTail = `${stderrTail}${text}`.slice(-2_000);
|
|
134
|
+
});
|
|
135
|
+
child.on("error", (err) => {
|
|
136
|
+
finish({ kind: "failed", transportUnusable: true, reason: `could not start codex app-server: ${err.message}` });
|
|
137
|
+
});
|
|
138
|
+
child.on("close", (code) => {
|
|
139
|
+
// Only meaningful if we have not already completed: an expected exit
|
|
140
|
+
// follows our own SIGTERM.
|
|
141
|
+
finish({
|
|
142
|
+
kind: "failed",
|
|
143
|
+
// Dying before the turn completed means the transport did not work,
|
|
144
|
+
// whatever the cause — worth one attempt down the older path.
|
|
145
|
+
transportUnusable: true,
|
|
146
|
+
reason: `codex app-server exited ${code ?? "on a signal"} before the turn completed${firstLine(stderrTail)}`,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
void (async () => {
|
|
150
|
+
const initialized = await request("initialize", {
|
|
151
|
+
clientInfo: { name: "baychat-relay", title: "BayChat relay", version: CLIENT_VERSION },
|
|
152
|
+
});
|
|
153
|
+
if (initialized.error) {
|
|
154
|
+
finish({ kind: "failed", transportUnusable: true, reason: `codex app-server refused the handshake: ${initialized.error.message}` });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
notify("initialized", {});
|
|
158
|
+
const resumed = await request("thread/resume", {
|
|
159
|
+
threadId: req.threadId,
|
|
160
|
+
cwd: req.cwd,
|
|
161
|
+
// See the file header: no human is present, so nothing may be approved.
|
|
162
|
+
approvalPolicy: "never",
|
|
163
|
+
});
|
|
164
|
+
if (resumed.error) {
|
|
165
|
+
// "already has an active writer" is not a fault — it is the interactive
|
|
166
|
+
// session being OPEN. A thread being written by a live Codex TUI cannot
|
|
167
|
+
// also be resumed from outside it, and that is the correct behaviour:
|
|
168
|
+
// two writers on one thread is the failure mode, not the refusal.
|
|
169
|
+
//
|
|
170
|
+
// It also names the remedy exactly. A live session is reachable through
|
|
171
|
+
// its `relay attach`, which is what attach is FOR; headless resume is
|
|
172
|
+
// for a session whose terminal has gone. Saying "could not resume" and
|
|
173
|
+
// stopping there sends someone hunting for a break that is not there.
|
|
174
|
+
if (/active writer/i.test(resumed.error.message)) {
|
|
175
|
+
finish({
|
|
176
|
+
kind: "failed",
|
|
177
|
+
transportUnusable: false,
|
|
178
|
+
reason: `Codex session "${req.threadId}" is open in a terminal, so it cannot be resumed from ` +
|
|
179
|
+
`outside it — a live session is woken through \`baychat relay attach\`, which must be ` +
|
|
180
|
+
`running in that session. Close the terminal to make it headlessly resumable instead.`,
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// The distinct failure `exec resume` could never report: the id does not
|
|
185
|
+
// name a thread on this machine. Worth saying plainly, because the usual
|
|
186
|
+
// cause is a Codex whose sessions live somewhere else — a snap install
|
|
187
|
+
// keeps them under ~/snap/codex/current/sessions.
|
|
188
|
+
finish({ kind: "failed", transportUnusable: false, reason: `codex could not resume thread ${req.threadId}: ${resumed.error.message}` });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// The handshake is done; the clock is now the turn's, which is far longer.
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
timer = setTimeout(() => finish({ kind: "failed", transportUnusable: false, reason: `codex turn did not complete within ${Math.round(turnTimeout / 60_000)} minutes` }), turnTimeout);
|
|
194
|
+
const started = await request("turn/start", {
|
|
195
|
+
threadId: req.threadId,
|
|
196
|
+
input: [{ type: "text", text: req.prompt }],
|
|
197
|
+
approvalPolicy: "never",
|
|
198
|
+
});
|
|
199
|
+
if (started.error) {
|
|
200
|
+
finish({ kind: "failed", transportUnusable: false, reason: `codex refused the turn: ${started.error.message}` });
|
|
201
|
+
}
|
|
202
|
+
// Success is NOT the response to turn/start — that only says the turn was
|
|
203
|
+
// accepted. The wake ends at the `turn/completed` notification, handled above.
|
|
204
|
+
})();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Reported to Codex as the client version. Kept in step with the package. */
|
|
208
|
+
const CLIENT_VERSION = "0.12.0";
|
|
209
|
+
/** One line of captured stderr, for appending to a failure reason. */
|
|
210
|
+
function firstLine(text) {
|
|
211
|
+
for (const line of text.split("\n")) {
|
|
212
|
+
const trimmed = line.trim();
|
|
213
|
+
if (trimmed !== "")
|
|
214
|
+
return `: ${trimmed.slice(0, 200)}`;
|
|
215
|
+
}
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.queueToThread = queueToThread;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const runtime_binary_1 = require("../runtime-binary");
|
|
6
|
+
const spawn_env_1 = require("./spawn-env");
|
|
7
|
+
/** How long `codex queue` may take before we treat it as unusable. */
|
|
8
|
+
const QUEUE_TIMEOUT_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* Deliver a message to a Codex session through Codex's own queue.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS IS THE RIGHT PATH, and better than every rung it sits above.
|
|
13
|
+
*
|
|
14
|
+
* The relay's other options all fight the runtime. A socket attach is refused
|
|
15
|
+
* inside a sandbox; a FIFO reaches a process but nothing re-invokes the agent;
|
|
16
|
+
* a headless resume starts a SEPARATE turn with no human, which is why it must
|
|
17
|
+
* run `approvalPolicy: "never"` — and that policy blocks Codex's own BayChat
|
|
18
|
+
* write path, so a headlessly-woken Codex can read the room and never answer it.
|
|
19
|
+
* Observed 2026-08-31 00:09 in Codex's own words: "BayChat's write path is
|
|
20
|
+
* unavailable under the enforced no-approval policy."
|
|
21
|
+
*
|
|
22
|
+
* `codex queue` sidesteps all of it. The message goes to the LIVE session, where
|
|
23
|
+
* the human already is, so approvals work normally and the relay never has to
|
|
24
|
+
* acquire a privilege on a chat message's behalf. It is first-party, needs no
|
|
25
|
+
* pty, injects nothing into a terminal, and queues when the session is between
|
|
26
|
+
* turns rather than being lost.
|
|
27
|
+
*
|
|
28
|
+
* Requires Codex >= 0.149.0, which is where `queue` landed. An older build is
|
|
29
|
+
* reported `unsupported` so the caller drops to the spawn, exactly as `runTurn`
|
|
30
|
+
* does with `transportUnusable`.
|
|
31
|
+
*
|
|
32
|
+
* argv array, never a shell string: the message is text written by other people
|
|
33
|
+
* in the room, and a shell would make `$(…)` in a chat message run on this box.
|
|
34
|
+
*/
|
|
35
|
+
function queueToThread(input) {
|
|
36
|
+
const runner = input.run ?? child_process_1.execFile;
|
|
37
|
+
// Node cannot spawn a `.cmd` directly — it fails EINVAL — so on Windows the
|
|
38
|
+
// interpreter is named explicitly. NEVER `shell: true`: the message is text
|
|
39
|
+
// written by other people in the room, and a shell string would make `$(…)`
|
|
40
|
+
// in a chat message run on this box.
|
|
41
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(input.binaryPath, input.platform ?? process.platform);
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
runner(plan.file, [...plan.prefixArgs, "queue", "--thread", input.threadId, "--message", input.message], {
|
|
44
|
+
cwd: input.cwd,
|
|
45
|
+
env: (0, spawn_env_1.headlessSpawnEnv)(),
|
|
46
|
+
timeout: input.timeoutMs ?? QUEUE_TIMEOUT_MS,
|
|
47
|
+
maxBuffer: 1024 * 1024,
|
|
48
|
+
}, (err, stdout, stderr) => {
|
|
49
|
+
const out = `${stdout ?? ""}${stderr ?? ""}`;
|
|
50
|
+
if (!err) {
|
|
51
|
+
// "Queued message <uuid> for thread <uuid>." — kept as evidence, the
|
|
52
|
+
// same way a resume id carries the evidence it was learned with.
|
|
53
|
+
const id = /Queued message ([0-9a-fA-F-]{8,})/.exec(out)?.[1];
|
|
54
|
+
return resolve({ kind: "queued", messageId: id });
|
|
55
|
+
}
|
|
56
|
+
// An older Codex has no `queue` subcommand. clap says so on stderr, and
|
|
57
|
+
// it is a statement about the BUILD, not about this session — so the
|
|
58
|
+
// caller may still try the rung below.
|
|
59
|
+
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)}` });
|
|
61
|
+
}
|
|
62
|
+
resolve({ kind: "failed", reason: firstLine(out) || err.message });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function firstLine(text) {
|
|
67
|
+
return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
|
68
|
+
}
|
package/dist/relay/commands.js
CHANGED
|
@@ -35,26 +35,36 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.cmdRelayStart = cmdRelayStart;
|
|
37
37
|
exports.ensureRelayInstalled = ensureRelayInstalled;
|
|
38
|
+
exports.tryRelayStatus = tryRelayStatus;
|
|
38
39
|
exports.cmdRelayStatus = cmdRelayStatus;
|
|
39
40
|
exports.cmdRelayStop = cmdRelayStop;
|
|
41
|
+
exports.shouldFallBackToMailbox = shouldFallBackToMailbox;
|
|
42
|
+
exports.attachViaMailbox = attachViaMailbox;
|
|
43
|
+
exports.renderSessionLine = renderSessionLine;
|
|
40
44
|
exports.cmdRelayAttach = cmdRelayAttach;
|
|
41
45
|
exports.resolveRuntimeBin = resolveRuntimeBin;
|
|
42
46
|
const child_process_1 = require("child_process");
|
|
43
47
|
const fs = __importStar(require("fs"));
|
|
44
48
|
const net = __importStar(require("net"));
|
|
45
|
-
const path = __importStar(require("path"));
|
|
46
49
|
const util_1 = require("util");
|
|
47
50
|
const adapters_1 = require("./adapters");
|
|
48
51
|
const autostart_1 = require("./autostart");
|
|
52
|
+
const runtime_binary_1 = require("../runtime-binary");
|
|
49
53
|
const daemon_1 = require("./daemon");
|
|
50
54
|
const resume_1 = require("./resume");
|
|
55
|
+
const mailbox_1 = require("./mailbox");
|
|
51
56
|
const socket_1 = require("./socket");
|
|
57
|
+
const parent_watch_1 = require("./parent-watch");
|
|
52
58
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
53
59
|
/** Connect to a running daemon, or explain that there isn't one. */
|
|
54
60
|
async function connectOrFail() {
|
|
55
61
|
const sockPath = (0, socket_1.socketPath)();
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
|
|
63
|
+
if (!probe.alive) {
|
|
64
|
+
// NOT a flat "no relay is running". See `describeProbeFailure`: a refused
|
|
65
|
+
// pipe and a wedged daemon are both live relays, and telling someone to
|
|
66
|
+
// start another one is the one instruction that makes those worse.
|
|
67
|
+
throw new Error((0, socket_1.describeProbeFailure)(sockPath, probe));
|
|
58
68
|
}
|
|
59
69
|
return net.createConnection(sockPath);
|
|
60
70
|
}
|
|
@@ -165,6 +175,26 @@ async function ensureRelayInstalled() {
|
|
|
165
175
|
return `Relay: could not start automatically (${err instanceof Error ? err.message : String(err)}). Run \`baychat relay start\` yourself.`;
|
|
166
176
|
}
|
|
167
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* The daemon's status, or null when there is no daemon.
|
|
180
|
+
*
|
|
181
|
+
* `cmdRelayStatus` treats an absent relay as an error to report, which is right
|
|
182
|
+
* for a command whose whole subject is the relay. `doctor` needs the opposite:
|
|
183
|
+
* "not running" is one finding among several and must not abort the rest of the
|
|
184
|
+
* report, so the absence is a value here rather than a thrown error.
|
|
185
|
+
*/
|
|
186
|
+
async function tryRelayStatus() {
|
|
187
|
+
try {
|
|
188
|
+
return await request(await connectOrFail(), { type: "status" });
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Every failure means the same thing to a caller that only wants to know
|
|
192
|
+
// whether the relay can answer: no socket, a stale socket, or a daemon too
|
|
193
|
+
// wedged to reply are all "no usable relay". `doctor` reports that state
|
|
194
|
+
// itself; there is nothing here worth logging over it.
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
168
198
|
async function cmdRelayStatus() {
|
|
169
199
|
let status;
|
|
170
200
|
try {
|
|
@@ -184,7 +214,7 @@ async function cmdRelayStatus() {
|
|
|
184
214
|
console.log(" none — a session registers itself by running `baychat relay attach`");
|
|
185
215
|
}
|
|
186
216
|
for (const s of status.sessions) {
|
|
187
|
-
const state = s
|
|
217
|
+
const state = sessionState(s);
|
|
188
218
|
console.log(` ${s.name} [${s.runtime}] ${state}`);
|
|
189
219
|
console.log(` ${resumeLabel(s)}`);
|
|
190
220
|
}
|
|
@@ -265,6 +295,94 @@ async function cmdRelayStop() {
|
|
|
265
295
|
* which then reads the room properly through the BayChat tools. Exit 2 means
|
|
266
296
|
* the wait lapsed with nothing to report.
|
|
267
297
|
*/
|
|
298
|
+
/**
|
|
299
|
+
* Is this probe failure the sandboxed case, and ONLY that?
|
|
300
|
+
*
|
|
301
|
+
* `denied` means a daemon exists and refused us — precisely a sandbox or token
|
|
302
|
+
* boundary, and precisely what the mailbox is for. Every other reason keeps
|
|
303
|
+
* today's error, deliberately:
|
|
304
|
+
*
|
|
305
|
+
* - `absent` — there is no relay. A mailbox would be created that no daemon
|
|
306
|
+
* is watching, and the agent would block on a FIFO forever believing it was
|
|
307
|
+
* reachable. That is the exact failure this transport exists to end, and
|
|
308
|
+
* the fallback must not reintroduce it.
|
|
309
|
+
* - `timeout` / `error` — a wedged or unexplained daemon. Papering over it
|
|
310
|
+
* with a second channel hides the thing someone needs to fix.
|
|
311
|
+
*/
|
|
312
|
+
function shouldFallBackToMailbox(probe) {
|
|
313
|
+
return !probe.alive && probe.reason === "denied";
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Arm over the mailbox: register by file, then block on a FIFO read.
|
|
317
|
+
*
|
|
318
|
+
* FOREGROUND, and it must stay foreground. A backgrounded process does not
|
|
319
|
+
* survive the sandbox: measured 2026-08-30, `nohup bash -c 'sleep 25; …' &`
|
|
320
|
+
* inside Codex never wrote its file, because bubblewrap tears down the mount
|
|
321
|
+
* namespace and kills the process group when the command returns. Telling a
|
|
322
|
+
* sandboxed agent to background this would be handing it an instruction that
|
|
323
|
+
* cannot work — which is how one came to report "Armed and joined the group"
|
|
324
|
+
* having armed nothing at all.
|
|
325
|
+
*/
|
|
326
|
+
async function attachViaMailbox(opts) {
|
|
327
|
+
// PROVE the root by creating it, rather than resolving one and hoping. A
|
|
328
|
+
// sandbox that permits /tmp may still refuse XDG_RUNTIME_DIR, and only this
|
|
329
|
+
// process can find that out.
|
|
330
|
+
const dir = (0, mailbox_1.pickUsableMailboxDir)(opts.session);
|
|
331
|
+
// Declare the path; the daemon creates the FIFO. Making one requires spawning
|
|
332
|
+
// `mkfifo(1)`, and a sandbox that permits writing files still refuses to spawn
|
|
333
|
+
// a helper binary (measured 2026-08-30: `spawnSync mkfifo EPERM`).
|
|
334
|
+
const fifo = (0, mailbox_1.wakeFifoPath)(dir);
|
|
335
|
+
(0, mailbox_1.writeRegistration)(dir, {
|
|
336
|
+
session: opts.session,
|
|
337
|
+
runtime: opts.runtime,
|
|
338
|
+
resumeId: opts.resume.ok ? opts.resume.resumeId : undefined,
|
|
339
|
+
resumeSource: opts.resume.ok ? opts.resume.source : undefined,
|
|
340
|
+
resumeEvidence: opts.resume.ok ? opts.resume.evidence : undefined,
|
|
341
|
+
resumeCwd: opts.resume.ok ? opts.resume.cwd : undefined,
|
|
342
|
+
cwd: process.cwd(),
|
|
343
|
+
// Resolved HERE, inside the session, for the same reason the socket rung
|
|
344
|
+
// does it: the binary a session was launched with is knowable here and
|
|
345
|
+
// nowhere else.
|
|
346
|
+
runtimeBin: resolveRuntimeBin(opts.runtime),
|
|
347
|
+
fifo,
|
|
348
|
+
pid: process.pid,
|
|
349
|
+
registeredAt: new Date().toISOString(),
|
|
350
|
+
});
|
|
351
|
+
await (0, mailbox_1.awaitWakeFifo)(fifo);
|
|
352
|
+
console.log(`Attached as "${opts.session}" over a mailbox FIFO (${fifo}). Waiting for messages…`);
|
|
353
|
+
// Opening for read blocks until the daemon opens for write. That block IS the
|
|
354
|
+
// wait, and it costs nothing: no model is running while it holds.
|
|
355
|
+
const raw = await fs.promises.readFile(fifo, "utf8");
|
|
356
|
+
const frame = JSON.parse(raw.trim());
|
|
357
|
+
console.log(`WAKE ${frame.messages.length} message(s) in ${frame.conversationId}:`);
|
|
358
|
+
for (const m of frame.messages) {
|
|
359
|
+
const flag = m.shouldRespond ? " [shouldRespond=true]" : "";
|
|
360
|
+
console.log(` (${m.id}) ${m.senderType} ${m.senderId}${flag}: ${m.content}`);
|
|
361
|
+
}
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
/** The state half of one `relay status` session line. */
|
|
365
|
+
function sessionState(s) {
|
|
366
|
+
// "registered", not "attached", for the FIFO rung — and the difference is not
|
|
367
|
+
// pedantry. A socket attach IS a held connection, so `attached` is an
|
|
368
|
+
// observation. A mailbox registration is a file on disk; whether the agent is
|
|
369
|
+
// still blocked on its FIFO cannot be checked without ending the wait, since
|
|
370
|
+
// a reader blocked in open() holds no descriptor for /proc to see and opening
|
|
371
|
+
// the write end to look would signal EOF. Delivery is the only honest probe,
|
|
372
|
+
// and it makes it: a wake with no reader is recorded pending, never delivered.
|
|
373
|
+
if (s.attached)
|
|
374
|
+
return s.transport === "fifo" ? "registered (fifo)" : "attached";
|
|
375
|
+
return s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* One `relay status` session line.
|
|
379
|
+
*
|
|
380
|
+
* Exported so the rung's wording is testable: a fallback nobody can see in
|
|
381
|
+
* `status` is a silent fallback, which is the thing this transport must not be.
|
|
382
|
+
*/
|
|
383
|
+
function renderSessionLine(target) {
|
|
384
|
+
return ` ${target.name} [${target.runtime}] ${sessionState(target)}`;
|
|
385
|
+
}
|
|
268
386
|
async function cmdRelayAttach(opts) {
|
|
269
387
|
if (!(0, adapters_1.isKnownRuntime)(opts.runtime)) {
|
|
270
388
|
console.log(`unknown runtime "${opts.runtime}" — expected one of: ${adapters_1.KNOWN_RUNTIMES.join(", ")}`);
|
|
@@ -272,14 +390,18 @@ async function cmdRelayAttach(opts) {
|
|
|
272
390
|
}
|
|
273
391
|
const runtime = opts.runtime;
|
|
274
392
|
const resume = await resolveAttachResumeId(runtime, opts.resumeId, opts.discovery);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
393
|
+
const sockPath = (0, socket_1.socketPath)();
|
|
394
|
+
const probe = await (0, socket_1.probeSocketDetailed)(sockPath);
|
|
395
|
+
if (shouldFallBackToMailbox(probe)) {
|
|
396
|
+
console.log(`Relay socket refused (${probe.alive ? "" : (probe.code ?? "denied")}) — this session is sandboxed.`);
|
|
397
|
+
console.log("Falling back to a mailbox FIFO, which a sandbox permits. `relay status` will show this session as attached (fifo).");
|
|
398
|
+
return attachViaMailbox({ session: opts.session, runtime, resume });
|
|
278
399
|
}
|
|
279
|
-
|
|
280
|
-
console.log(
|
|
400
|
+
if (!probe.alive) {
|
|
401
|
+
console.log((0, socket_1.describeProbeFailure)(sockPath, probe));
|
|
281
402
|
return 1;
|
|
282
403
|
}
|
|
404
|
+
const sock = net.createConnection(sockPath);
|
|
283
405
|
return new Promise((resolve) => {
|
|
284
406
|
const timer = opts.timeoutMs
|
|
285
407
|
? setTimeout(() => {
|
|
@@ -288,6 +410,19 @@ async function cmdRelayAttach(opts) {
|
|
|
288
410
|
resolve(2);
|
|
289
411
|
}, opts.timeoutMs)
|
|
290
412
|
: undefined;
|
|
413
|
+
// An attach outlives nothing. When the session that launched it goes away,
|
|
414
|
+
// this process must go with it — otherwise it keeps the socket open, the
|
|
415
|
+
// daemon keeps believing the session is live, and the next wake is written
|
|
416
|
+
// into a corpse and recorded as delivered. See ./parent-watch.ts.
|
|
417
|
+
const parentWatch = (0, parent_watch_1.watchParent)(process.ppid, () => {
|
|
418
|
+
console.log("Session that started this attach has exited — detaching so the relay stops treating it as live.");
|
|
419
|
+
sock.end();
|
|
420
|
+
resolve(3);
|
|
421
|
+
});
|
|
422
|
+
const settle = (code) => {
|
|
423
|
+
parentWatch.stop();
|
|
424
|
+
resolve(code);
|
|
425
|
+
};
|
|
291
426
|
sock.on("data", (0, socket_1.createFrameReader)((frame) => {
|
|
292
427
|
if (frame.type === "attached") {
|
|
293
428
|
console.log(`Attached as "${frame.session}". Waiting for messages…`);
|
|
@@ -302,7 +437,7 @@ async function cmdRelayAttach(opts) {
|
|
|
302
437
|
console.log(` (${m.id}) ${m.senderType} ${m.senderId}${flag}: ${m.content}`);
|
|
303
438
|
}
|
|
304
439
|
sock.end();
|
|
305
|
-
|
|
440
|
+
settle(0);
|
|
306
441
|
return;
|
|
307
442
|
}
|
|
308
443
|
if (frame.type === "error") {
|
|
@@ -310,19 +445,19 @@ async function cmdRelayAttach(opts) {
|
|
|
310
445
|
clearTimeout(timer);
|
|
311
446
|
console.log(frame.message);
|
|
312
447
|
sock.end();
|
|
313
|
-
|
|
448
|
+
settle(1);
|
|
314
449
|
}
|
|
315
450
|
}));
|
|
316
451
|
sock.on("error", (err) => {
|
|
317
452
|
if (timer)
|
|
318
453
|
clearTimeout(timer);
|
|
319
454
|
console.log(err.message);
|
|
320
|
-
|
|
455
|
+
settle(1);
|
|
321
456
|
});
|
|
322
457
|
sock.on("close", () => {
|
|
323
458
|
if (timer)
|
|
324
459
|
clearTimeout(timer);
|
|
325
|
-
|
|
460
|
+
settle(2);
|
|
326
461
|
});
|
|
327
462
|
(0, socket_1.writeFrame)(sock, {
|
|
328
463
|
type: "attach",
|
|
@@ -359,25 +494,36 @@ async function cmdRelayAttach(opts) {
|
|
|
359
494
|
* machine where PATH is fine keeps working, and one where it is not now works too.
|
|
360
495
|
*/
|
|
361
496
|
function resolveRuntimeBin(runtime) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
497
|
+
// Delegates to the prover rather than walking PATH itself. The hand-rolled
|
|
498
|
+
// walk this replaces had two Windows faults, and both produced a recorded
|
|
499
|
+
// path the daemon could never spawn:
|
|
500
|
+
//
|
|
501
|
+
// • it joined the BARE name onto each PATH entry, so it never considered
|
|
502
|
+
// `codex.cmd` — and every npm-installed CLI on Windows IS a .cmd shim;
|
|
503
|
+
// • it accepted a candidate on `fs.accessSync(X_OK)`, which Windows has no
|
|
504
|
+
// execute bit to answer, so any readable file passes. The extensionless
|
|
505
|
+
// `codex` — an sh script, unrunnable there — sailed through.
|
|
506
|
+
//
|
|
507
|
+
// Observed 2026-08-30: runtimeBin recorded as
|
|
508
|
+
// `C:\Users\…\npm\codex`, and the wake died with `spawn … ENOENT`.
|
|
509
|
+
//
|
|
510
|
+
// `resolveRuntimeBinary` tries the platform's real extension order and PROVES
|
|
511
|
+
// each candidate by running it, which is the same question this function was
|
|
512
|
+
// always asking — just answered correctly.
|
|
513
|
+
const override = process.env[`BAYCHAT_${runtime.toUpperCase()}_BIN`];
|
|
514
|
+
const resolved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(override));
|
|
515
|
+
if (!resolved.ok)
|
|
516
|
+
return undefined;
|
|
517
|
+
try {
|
|
518
|
+
// Resolve symlinks: ~/.local/bin/claude is typically a link into a versioned
|
|
519
|
+
// directory, and recording the link means a later version bump silently
|
|
520
|
+
// repoints every wake. The real path is what this session is actually running.
|
|
521
|
+
return fs.realpathSync(resolved.path);
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// A path we just ran but cannot realpath is still the right answer.
|
|
525
|
+
return resolved.path;
|
|
379
526
|
}
|
|
380
|
-
return undefined;
|
|
381
527
|
}
|
|
382
528
|
/**
|
|
383
529
|
* Decide what resume id this attach registers, and say so out loud.
|