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.
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ /**
3
+ * `baychat help <topic>` — the instructions a PERSON needs, in the terminal.
4
+ *
5
+ * WHY THIS FILE EXISTS. The group tools shipped on 2026-08-25 and were written up in
6
+ * five places: the connect guide (served at baychat.io/connect.md), the agent protocol,
7
+ * the npm README, the skill this CLI writes into a client, and the API's own tool
8
+ * descriptions. Every one of those is read by an AGENT, by a stranger evaluating
9
+ * BayChat, or by somebody with a browser open.
10
+ *
11
+ * None of them is reachable from the terminal the person is actually sitting in.
12
+ * `baychat --help` lists CLI SUBCOMMANDS, and `list_groups` is not one — it is an MCP
13
+ * tool their client calls. So the honest answer to "how do I make a group?" was: read a
14
+ * website. That is how a user ends up reporting that a capability does not exist when it
15
+ * shipped weeks ago.
16
+ *
17
+ * ONE SOURCE, NOT A SIXTH COPY. `ROOMS_TOPIC` below is spliced verbatim into the skill
18
+ * `runtimes.ts` writes, so the words a person reads here and the words their agent was
19
+ * given are the same words. A rule written twice is a rule that will one day be true in
20
+ * only one of the two places.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.HELP_TOPICS = exports.ROOMS_TOPIC = void 0;
24
+ exports.findTopic = findTopic;
25
+ exports.topicIndex = topicIndex;
26
+ /**
27
+ * The rooms guidance, shared with the skill in `runtimes.ts`.
28
+ *
29
+ * Kept as a plain string with no template placeholders precisely so it can be used in
30
+ * both places unchanged — the moment it needs interpolation it stops being one text.
31
+ */
32
+ exports.ROOMS_TOPIC = `## Rooms — find one, or open one
33
+
34
+ \`list_groups\` prints the groups this login is in: the exact title, who is in
35
+ them, and the id. Reach for it whenever a title is uncertain — \`join_session\`
36
+ matches titles exactly and never guesses, so read the title from here and pass it
37
+ back verbatim rather than approximating it.
38
+
39
+ \`create_group\` (\`session\`, \`title\`, optional \`agents\`) opens a new room and
40
+ lands this session in it, with your owner as its admin — exactly as if they had
41
+ made it in the app. \`agents\` takes the exact names \`list_agents\` prints; an
42
+ unknown one is refused with the roster rather than nearest-matched.
43
+
44
+ **Only when the user asked for a new room, and only with the title they gave.**
45
+ That does not weaken the rule above — opening a room is still never your choice.
46
+ In particular, \`create_group\` is **not** how you recover from a join that missed:
47
+ a title that missed is a typo far more often than it is a new room, and creating
48
+ one would fork the conversation in two. Run \`list_groups\`, show the user what is
49
+ really there, and stop.
50
+
51
+ A title that already names one of their groups is refused, and that refusal is
52
+ correct: two rooms sharing one title make either of them impossible to join by
53
+ name until somebody renames one.`;
54
+ const GROUPS = {
55
+ name: "groups",
56
+ summary: "Make a group, or find the ones you are already in",
57
+ keywords: ["group", "groups", "room", "rooms", "create_group", "list_groups", "new group", "channel"],
58
+ body: `${exports.ROOMS_TOPIC}
59
+
60
+ ## Where these tools live, and why yours may not have them
61
+
62
+ \`list_groups\` and \`create_group\` are NOT CLI subcommands — you never type them
63
+ into a shell. They are MCP tools your client (Claude Code, Codex, Cursor,
64
+ Desktop) calls on your behalf. You ask in words; the agent makes the call.
65
+
66
+ You: "make a group called Ad Review and put Codex in it"
67
+ → create_group({ session: "<your session>", title: "Ad Review", agents: ["Codex"] })
68
+
69
+ They are on the SESSION branch only, which means they need a device credential —
70
+ a \`bay_u_\` token from \`baychat login\`. Every call also carries a required
71
+ \`session\` argument, because a terminal has no single agent identity: each call
72
+ names the session it is acting as.
73
+
74
+ **A standing agent gets neither tool, deliberately.** An agent authenticated with
75
+ a \`bay_\` token is a guest in a room somebody else composed. Letting it create
76
+ rooms would let it invent a room, put the agents it likes in it, and talk to them
77
+ unobserved — the escalation the security model exists to prevent. If your agent
78
+ says it cannot create a group, that is correct behaviour, not a bug: ask a person,
79
+ or run it as a session.
80
+
81
+ ## If your client cannot see them
82
+
83
+ 1. \`baychat login\` — mints the device credential and registers the MCP server.
84
+ Without this you are on the agent branch and the tools are genuinely absent.
85
+ 2. Update: \`npx baychat@latest login\`. A client installed before 2026-08-28 was
86
+ written a skill listing ten tools, from a build published on 1 August that
87
+ predates these two entirely.
88
+ 3. Restart your client. Tool lists are read once at connect — a running session
89
+ keeps the list it started with, however current the server is.
90
+ 4. \`baychat help tools\` shows what each branch actually gets.`,
91
+ };
92
+ const TOOLS = {
93
+ name: "tools",
94
+ summary: "Every MCP tool, and which credential it needs",
95
+ keywords: ["tool", "tools", "mcp", "skills", "capabilities", "what can it do"],
96
+ body: `## The two branches
97
+
98
+ Which tools you get depends on WHAT YOU ARE, not on which client you use.
99
+
100
+ **Agent branch** — a standing agent holding a \`bay_\` token. Thirteen tools:
101
+
102
+ list_conversations get_room_context get_conversation_summary
103
+ get_messages send_message set_typing
104
+ react_to_message list_files get_file
105
+ web_search web_fetch list_agents
106
+ ask_connector
107
+
108
+ Plus the \`baychat://protocol\` resource, which serves the full agent protocol.
109
+
110
+ **Session branch** — a person's terminal, holding a \`bay_u_\` device credential
111
+ from \`baychat login\`. Gets all thirteen above, each with a REQUIRED \`session\`
112
+ argument, and these on top:
113
+
114
+ join_session list_sessions end_session
115
+ list_groups create_group
116
+ request_approval await_approval
117
+ create_upload_url whoami
118
+
119
+ The extra ones are things a PERSON does: name a terminal, park it, see their
120
+ rooms, open a new one, be asked a yes/no question on their phone. An agent token
121
+ never reaches them.
122
+
123
+ If you are counting tools and getting ten, your client is running a build from
124
+ before 2026-08-28 — see \`baychat help groups\`.`,
125
+ };
126
+ const SESSIONS = {
127
+ name: "sessions",
128
+ summary: "Name this terminal, join a room as it, park it when done",
129
+ keywords: ["session", "sessions", "join", "join_session", "attach", "terminal", "park", "end_session"],
130
+ body: `## Sessions
131
+
132
+ A session is one terminal, named by you. It appears in the app as an agent your
133
+ messages can reach, and it survives being parked.
134
+
135
+ join_session({ session: "Session-A" }) → a 1:1 with you
136
+ join_session({ session: "Session-A", group: "Ad Review" }) → that group INSTEAD
137
+
138
+ **The group form joins that group and NOT the 1:1** — this catches people out. A
139
+ message you send in the 1:1 lands somewhere a group-joined session cannot see, so
140
+ it reads as the agent ignoring you.
141
+
142
+ list_sessions() → name, live or idle, last seen
143
+ end_session(...) → park it; the chat and its history survive, and rejoining
144
+ the same name revives the same agent
145
+
146
+ **Never invent a session name.** The user names the session and the user names
147
+ the group. Given neither, run \`list_sessions\` and stop — do not derive a name
148
+ from the directory, the repo, the branch, or the hostname. Answering in the wrong
149
+ room is the worst failure this feature has.`,
150
+ };
151
+ const APPROVALS = {
152
+ name: "approvals",
153
+ summary: "Ask a yes/no question that lands on your phone",
154
+ keywords: ["approval", "approvals", "permission", "request_approval", "await_approval", "decision", "hook"],
155
+ body: `## Approvals
156
+
157
+ \`request_approval\` puts a decision card on the owner's phone; \`await_approval\`
158
+ blocks until they answer. Session branch only — a standing agent has no owner to
159
+ ask and no terminal to block.
160
+
161
+ There is no timeout by design. A question worth asking is worth waiting for, and
162
+ a decision that expires silently is worse than one that waits.
163
+
164
+ \`baychat approve-hook\` wires Claude Code's own permission prompts to the same
165
+ cards. It is NOT on by default and moves the last line between an agent and your
166
+ machine onto a phone — read docs/features/REMOTE_APPROVAL_HOOK.md before enabling
167
+ it. It fails CLOSED: every error denies.`,
168
+ };
169
+ exports.HELP_TOPICS = [GROUPS, SESSIONS, TOOLS, APPROVALS];
170
+ /** Exact name first, then keyword, then a substring of the body. */
171
+ function findTopic(query) {
172
+ const q = query.trim().toLowerCase();
173
+ if (!q)
174
+ return undefined;
175
+ const exact = exports.HELP_TOPICS.find((t) => t.name === q);
176
+ if (exact)
177
+ return exact;
178
+ const keyed = exports.HELP_TOPICS.find((t) => t.keywords.some((k) => k === q));
179
+ if (keyed)
180
+ return keyed;
181
+ // Substring over keywords, so "make a group" and "new room" both land.
182
+ const loose = exports.HELP_TOPICS.find((t) => t.keywords.some((k) => q.includes(k) || k.includes(q)));
183
+ if (loose)
184
+ return loose;
185
+ return exports.HELP_TOPICS.find((t) => t.body.toLowerCase().includes(q));
186
+ }
187
+ /** The index printed by `baychat help` with no topic, and on a miss. */
188
+ function topicIndex() {
189
+ const rows = exports.HELP_TOPICS.map((t) => ` baychat help ${t.name.padEnd(10)} ${t.summary}`);
190
+ return [
191
+ "Topics — how to actually use BayChat from a client:",
192
+ "",
193
+ ...rows,
194
+ "",
195
+ "Any wording works: `baychat help \"make a new group\"` finds the groups topic.",
196
+ ].join("\n");
197
+ }
package/dist/index.js CHANGED
@@ -3,10 +3,13 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const commands_1 = require("./commands");
5
5
  const approve_hook_1 = require("./approve-hook");
6
+ const doctor_command_1 = require("./doctor-command");
6
7
  const connect_1 = require("./connect");
7
8
  const mcp_1 = require("./mcp");
8
9
  const mcp_config_1 = require("./mcp-config");
9
10
  const commands_2 = require("./relay/commands");
11
+ const help_topics_1 = require("./help-topics");
12
+ const args_1 = require("./args");
10
13
  const HELP = `baychat — BayChat connector CLI for agent sessions (Claude Code, Codex)
11
14
 
12
15
  Usage:
@@ -27,6 +30,12 @@ Usage:
27
30
  baychat pair <code> [--base <url>] Redeem a pairing code from the BayChat app
28
31
  baychat link [--name <n>] [--base <url>]
29
32
  Link this session via a QR you scan with your phone
33
+ baychat help [topic|question] How to USE BayChat from a client — making a
34
+ group, joining a room, which tools your
35
+ credential gets. Ask it however you like:
36
+ \`baychat help "make a new group"\`. These are
37
+ MCP tools your client calls, not subcommands,
38
+ so they are not in the list below
30
39
  baychat whoami Show the connected agent identity
31
40
  baychat conversations List conversations this agent is in
32
41
  baychat send <conversationId> <text> Send a message
@@ -76,6 +85,14 @@ Usage:
76
85
  came from, so "we can wake this headlessly"
77
86
  is a claim you can check
78
87
  baychat relay stop Stop the relay and disable it at boot
88
+ baychat doctor [--json] Check every link between this machine and
89
+ BayChat — credential, relay, and per runtime
90
+ its MCP registration, skill, executable and
91
+ live session — and print exactly what to type
92
+ for each thing that is wrong. Exit 0 clear,
93
+ 1 broken, 2 messages nothing answered.
94
+ --json emits the same report as data, for
95
+ pasting into a support thread
79
96
  baychat approve-hook [--session <name>] [--timeout <sec>]
80
97
  Claude Code PermissionRequest hook: send the
81
98
  permission prompt to BayChat as a decision card
@@ -101,46 +118,45 @@ Usage:
101
118
  Connect flow: in BayChat, open the agent -> Connect -> copy the pairing code,
102
119
  then run \`baychat pair <code>\`. Pairing rotates the agent token; use a
103
120
  dedicated agent per session.`;
104
- function flag(args, name) {
105
- const i = args.indexOf(name);
106
- return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
107
- }
108
- /** The first positional (non `--flag`) argument, so a command's id isn't shadowed
109
- * by a leading boolean flag like `--catch-up`/`--refresh`. */
110
- function positional(args) {
111
- return args.find((a) => !a.startsWith("--"));
112
- }
113
121
  /** Drop a `--name <value>` pair, so a multi-word positional (a search query)
114
- * doesn't swallow the flag's value as part of itself. */
122
+ * doesn't swallow the flag's value as part of itself.
123
+ *
124
+ * Only the flag is dropped when its value is missing — the next token is
125
+ * another option, or there is none. Consuming two entries there would eat an
126
+ * unrelated argument, the same mistake `flag` refuses to make. */
115
127
  function withoutFlag(args, name) {
116
128
  const i = args.indexOf(name);
117
- return i < 0 ? args : [...args.slice(0, i), ...args.slice(i + 2)];
129
+ if (i < 0)
130
+ return args;
131
+ const next = args[i + 1];
132
+ const consumed = next !== undefined && !next.startsWith("--") ? 2 : 1;
133
+ return [...args.slice(0, i), ...args.slice(i + consumed)];
118
134
  }
119
135
  /** A flag's value as a number, or undefined when absent. A non-numeric value
120
136
  * becomes NaN and is rejected by the tool's own bounds check with a message
121
137
  * that names the field. */
122
138
  function numberFlag(args, name) {
123
- const raw = flag(args, name);
139
+ const raw = (0, args_1.flag)(args, name);
124
140
  return raw === undefined ? undefined : Number(raw);
125
141
  }
126
142
  async function main() {
127
143
  const [command, ...args] = process.argv.slice(2);
128
144
  switch (command) {
129
145
  case "onboard":
130
- await (0, commands_1.cmdOnboard)(positional(args), { catchUp: args.includes("--catch-up") });
146
+ await (0, commands_1.cmdOnboard)((0, args_1.positional)(args), { catchUp: args.includes("--catch-up") });
131
147
  return 0;
132
148
  case "login": {
133
- const loggedIn = await (0, commands_1.cmdLogin)({ base: flag(args, "--base"), token: flag(args, "--token") });
149
+ const loggedIn = await (0, commands_1.cmdLogin)({ base: (0, args_1.flag)(args, "--base"), token: (0, args_1.flag)(args, "--token") });
134
150
  return loggedIn ? 0 : 2; // 2 = the link request expired without approval
135
151
  }
136
152
  case "pair": {
137
153
  if (!args[0])
138
154
  throw new Error("Usage: baychat pair <code>");
139
- await (0, commands_1.cmdPair)(args[0], flag(args, "--base"));
155
+ await (0, commands_1.cmdPair)(args[0], (0, args_1.flag)(args, "--base"));
140
156
  return 0;
141
157
  }
142
158
  case "link": {
143
- const linked = await (0, commands_1.cmdLink)({ name: flag(args, "--name"), base: flag(args, "--base") });
159
+ const linked = await (0, commands_1.cmdLink)({ name: (0, args_1.flag)(args, "--name"), base: (0, args_1.flag)(args, "--base") });
144
160
  return linked ? 0 : 2;
145
161
  }
146
162
  case "qr":
@@ -202,7 +218,7 @@ async function main() {
202
218
  return 0;
203
219
  }
204
220
  case "summary": {
205
- const conversationId = positional(args);
221
+ const conversationId = (0, args_1.positional)(args);
206
222
  if (!conversationId)
207
223
  throw new Error("Usage: baychat summary <conversationId> [--refresh]");
208
224
  await (0, commands_1.cmdSummary)(conversationId, { refresh: args.includes("--refresh") });
@@ -216,7 +232,7 @@ async function main() {
216
232
  return 0;
217
233
  }
218
234
  case "fetch": {
219
- const url = positional(withoutFlag(args, "--max-chars"));
235
+ const url = (0, args_1.positional)(withoutFlag(args, "--max-chars"));
220
236
  if (!url)
221
237
  throw new Error("Usage: baychat fetch <url> [--max-chars <n>]");
222
238
  await (0, commands_1.cmdFetch)(url, { maxChars: numberFlag(args, "--max-chars") });
@@ -225,8 +241,8 @@ async function main() {
225
241
  case "watch": {
226
242
  if (!args[0])
227
243
  throw new Error("Usage: baychat watch <conversationId>");
228
- const intervalSec = Number(flag(args, "--interval") ?? "5");
229
- const timeoutSec = Number(flag(args, "--timeout") ?? "300");
244
+ const intervalSec = Number((0, args_1.flag)(args, "--interval") ?? "5");
245
+ const timeoutSec = Number((0, args_1.flag)(args, "--timeout") ?? "300");
230
246
  const got = await (0, commands_1.cmdWatch)(args[0], {
231
247
  intervalMs: intervalSec * 1000,
232
248
  timeoutMs: timeoutSec * 1000,
@@ -234,7 +250,7 @@ async function main() {
234
250
  return got ? 0 : 2;
235
251
  }
236
252
  case "relay": {
237
- const sub = positional(args) ?? "";
253
+ const sub = (0, args_1.positional)(args) ?? "";
238
254
  const rest = args.filter((a) => a !== sub);
239
255
  switch (sub) {
240
256
  case "start":
@@ -245,15 +261,15 @@ async function main() {
245
261
  case "stop":
246
262
  return await (0, commands_2.cmdRelayStop)();
247
263
  case "attach": {
248
- const session = flag(rest, "--session");
264
+ const session = (0, args_1.flag)(rest, "--session");
249
265
  if (!session) {
250
266
  throw new Error("Usage: baychat relay attach --session <name> [--runtime claude|codex|hermes] [--resume-id <id>] [--timeout <sec>]");
251
267
  }
252
268
  const timeoutSec = numberFlag(rest, "--timeout");
253
269
  return await (0, commands_2.cmdRelayAttach)({
254
270
  session,
255
- runtime: flag(rest, "--runtime") ?? "claude",
256
- resumeId: flag(rest, "--resume-id"),
271
+ runtime: (0, args_1.flag)(rest, "--runtime") ?? "claude",
272
+ resumeId: (0, args_1.flag)(rest, "--resume-id"),
257
273
  timeoutMs: timeoutSec ? timeoutSec * 1000 : undefined,
258
274
  });
259
275
  }
@@ -267,15 +283,20 @@ async function main() {
267
283
  // exit-1 hook with no JSON on stdout is a NON-BLOCKING error: the tool call proceeds. The
268
284
  // guard in the catch below covers that anyway.
269
285
  return await (0, approve_hook_1.cmdApproveHook)(args);
286
+ case "doctor":
287
+ // Deliberately placed with the setup commands: it is the thing you run
288
+ // when one of them did not take, and the one command that inspects all
289
+ // of them at once.
290
+ return await (0, doctor_command_1.cmdDoctor)(args);
270
291
  case "connect": {
271
292
  // A bare `connect` prints the client menu; positional() skips a leading flag
272
293
  // so `connect --base x codex` still finds the client.
273
- return await (0, connect_1.cmdConnect)(positional(args), { base: flag(args, "--base") });
294
+ return await (0, connect_1.cmdConnect)((0, args_1.positional)(args), { base: (0, args_1.flag)(args, "--base") });
274
295
  }
275
296
  case "mcp-config": {
276
297
  // `--client` with no value is a typo, not a request for the menu: pass the
277
298
  // empty string so it is rejected by name rather than silently listing.
278
- (0, mcp_config_1.cmdMcpConfig)(args.includes("--client") ? (flag(args, "--client") ?? "") : undefined);
299
+ (0, mcp_config_1.cmdMcpConfig)(args.includes("--client") ? ((0, args_1.flag)(args, "--client") ?? "") : undefined);
279
300
  return 0;
280
301
  }
281
302
  case "mcp": {
@@ -288,9 +309,30 @@ async function main() {
288
309
  }
289
310
  case "help":
290
311
  case "--help":
291
- case undefined:
312
+ case undefined: {
313
+ // `baychat help <anything>` answers a question about USING BayChat from a
314
+ // client — how to make a group, what tools you get, why yours are missing.
315
+ // Those are MCP tools, not subcommands, so they can never appear in the
316
+ // usage block above, and until this existed the only answer was "read a
317
+ // website" — which is how a shipped capability gets reported as absent.
318
+ const query = args.join(" ").trim();
319
+ if (query) {
320
+ const topic = (0, help_topics_1.findTopic)(query);
321
+ if (topic) {
322
+ console.log(topic.body);
323
+ return 0;
324
+ }
325
+ // A miss is not an error: they asked a real question and deserve the
326
+ // list rather than a usage dump.
327
+ console.error(`No help topic matches "${query}".\n`);
328
+ console.log((0, help_topics_1.topicIndex)());
329
+ return 1;
330
+ }
292
331
  console.log(HELP);
332
+ console.log("");
333
+ console.log((0, help_topics_1.topicIndex)());
293
334
  return 0;
335
+ }
294
336
  default:
295
337
  console.error(`Unknown command: ${command}`);
296
338
  console.log(HELP);
@@ -7,7 +7,10 @@ exports.isKnownRuntime = isKnownRuntime;
7
7
  exports.runHeadless = runHeadless;
8
8
  const child_process_1 = require("child_process");
9
9
  const attachments_1 = require("../attachments");
10
+ const codex_app_server_1 = require("./codex-app-server");
11
+ const codex_queue_1 = require("./codex-queue");
10
12
  const resume_1 = require("./resume");
13
+ const spawn_env_1 = require("./spawn-env");
11
14
  /** How long a headless turn may run before the relay gives up on it. */
12
15
  const HEADLESS_TIMEOUT_MS = 10 * 60_000;
13
16
  /**
@@ -18,7 +21,7 @@ const HEADLESS_TIMEOUT_MS = 10 * 60_000;
18
21
  * relay carries the message and nothing more. Telling a resumed turn to "answer
19
22
  * this" would route around the room's reply policy from outside the room.
20
23
  */
21
- function buildWakePrompt(session, conversationId, batch) {
24
+ function buildWakePrompt(session, conversationId, batch, reArm) {
22
25
  const lines = [];
23
26
  for (const m of batch) {
24
27
  const flag = m.shouldRespond ? " [shouldRespond=true]" : "";
@@ -40,8 +43,47 @@ function buildWakePrompt(session, conversationId, batch) {
40
43
  ...lines,
41
44
  "",
42
45
  `Re-read the room with the BayChat tools (session="${session}") before acting. Reply ONLY if the server marks shouldRespond for you; otherwise stay silent and end the turn.`,
46
+ ...reArmLines(session, reArm),
43
47
  ].join("\n");
44
48
  }
49
+ /**
50
+ * How this session gets woken a SECOND time.
51
+ *
52
+ * A headless resume is spawned by the daemon, so it inherits the daemon's
53
+ * environment — and under systemd that PATH contains neither `node` nor
54
+ * `baychat` nor the runtime itself. The installed skill tells a woken session to
55
+ * re-arm with a bare `baychat relay attach`, which a resumed session cannot run:
56
+ * command not found. It answers once and then goes quiet forever, which is
57
+ * precisely the failure re-arming exists to prevent — and it looks like the
58
+ * model forgetting rather than like us handing it an impossible instruction.
59
+ *
60
+ * So the daemon, which knows both absolute paths because it IS them, spells the
61
+ * command out. Omitted entirely when the caller has no paths to offer, rather
62
+ * than printed with holes in it.
63
+ */
64
+ function reArmLines(session, reArm) {
65
+ if (!reArm)
66
+ return [];
67
+ const command = ` ${reArm.node} ${reArm.cli} relay attach --session "${session}" --runtime ${reArm.runtime}`;
68
+ if (reArm.transport === "fifo") {
69
+ return [
70
+ "",
71
+ "To stay reachable, run this IN THE FOREGROUND before ending your turn. It",
72
+ "blocks until a message arrives, prints it, and exits — that exit is the wake.",
73
+ "Do NOT put it in the background: your sandbox kills backgrounded processes",
74
+ "when the command returns, so a backgrounded attach listens to nothing while",
75
+ "looking like it worked.",
76
+ command,
77
+ ];
78
+ }
79
+ return [
80
+ "",
81
+ "To stay reachable, run this in the BACKGROUND before ending your turn. It is",
82
+ "written with absolute paths because a resumed session's PATH may not contain",
83
+ "node or baychat at all:",
84
+ command,
85
+ ];
86
+ }
45
87
  /**
46
88
  * One attachment line for the wake prompt: what it is, then how to fetch it.
47
89
  *
@@ -91,6 +133,19 @@ const claudeAdapter = {
91
133
  };
92
134
  const codexAdapter = {
93
135
  runtime: "codex",
136
+ queueMessage({ binaryPath, target, message }) {
137
+ // `codex queue` landed in 0.149.0. Delivering here rather than through the
138
+ // headless spawn is what lets a woken Codex actually REPLY: the message goes
139
+ // to the live session, where the human is present to approve, instead of a
140
+ // separate turn that must run `approvalPolicy: "never"` and is therefore
141
+ // blocked from calling BayChat's write tools at all.
142
+ return (0, codex_queue_1.queueToThread)({
143
+ binaryPath,
144
+ threadId: target.resumeId,
145
+ message,
146
+ cwd: target.resumeCwd ?? target.cwd,
147
+ });
148
+ },
94
149
  canResume(target) {
95
150
  if (!target.resumeId) {
96
151
  return {
@@ -109,6 +164,29 @@ const codexAdapter = {
109
164
  args: ["exec", "resume", target.resumeId, prompt],
110
165
  };
111
166
  },
167
+ /**
168
+ * Prefer `codex app-server`, which can say what `codex exec` never could.
169
+ *
170
+ * `exec resume` reports only an exit code, so "the thread id names nothing on
171
+ * this machine", "the turn stopped at an approval" and "the model was
172
+ * unavailable" all arrive as `exited 1`. app-server distinguishes them, and
173
+ * the reason is what a user needs to act.
174
+ *
175
+ * Opting out with BAYCHAT_CODEX_TRANSPORT=exec is deliberate: the app-server
176
+ * interface is marked `[experimental]` by OpenAI and its shape is not frozen,
177
+ * so there has to be a way back that does not require a new release.
178
+ */
179
+ async runTurn({ binaryPath, target, prompt }) {
180
+ if (process.env.BAYCHAT_CODEX_TRANSPORT === "exec") {
181
+ return { kind: "failed", transportUnusable: true, reason: "BAYCHAT_CODEX_TRANSPORT=exec" };
182
+ }
183
+ return (0, codex_app_server_1.runCodexTurn)({
184
+ binaryPath,
185
+ threadId: target.resumeId,
186
+ prompt,
187
+ cwd: target.resumeCwd ?? target.cwd,
188
+ }, { spawn: child_process_1.spawn });
189
+ },
112
190
  };
113
191
  /**
114
192
  * Cursor can be woken, and cannot be resumed. Both halves matter.
@@ -200,6 +278,9 @@ function runHeadless(file, args, opts = {}) {
200
278
  return new Promise((resolve) => {
201
279
  const child = (0, child_process_1.spawn)(file, args, {
202
280
  cwd: opts.cwd,
281
+ // An npm-installed runtime is a script with a `#!/usr/bin/env node`
282
+ // shebang, and the daemon's systemd PATH has no node. See ./spawn-env.ts.
283
+ env: (0, spawn_env_1.headlessSpawnEnv)(),
203
284
  shell: false,
204
285
  stdio: ["ignore", "ignore", "pipe"],
205
286
  detached: false,