baychat 0.34.1 → 0.34.2

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.
Files changed (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -2
  3. package/dist/bays-command.js +6 -0
  4. package/dist/channel/feed.js +48 -4
  5. package/dist/channel/log-file.js +37 -0
  6. package/dist/channel/server.js +34 -4
  7. package/dist/claude-context-flush.js +31 -8
  8. package/dist/claude-context.js +19 -5
  9. package/dist/claude-onboarding.js +21 -8
  10. package/dist/cli-help.js +3 -0
  11. package/dist/client-paths.js +6 -1
  12. package/dist/client-scope.js +34 -0
  13. package/dist/commands.js +16 -8
  14. package/dist/launch/command.js +6 -3
  15. package/dist/main.js +14 -1
  16. package/dist/mcp-files.js +2 -0
  17. package/dist/mcp-register.js +2 -0
  18. package/dist/protocol-content.js +1 -1
  19. package/dist/refresh-clients.js +7 -0
  20. package/dist/relay/acp/approval.js +99 -30
  21. package/dist/relay/acp/daemon-glue.js +2 -1
  22. package/dist/relay/adapters.js +5 -1
  23. package/dist/relay/bay-relays.js +18 -5
  24. package/dist/relay/codex-allowances.js +113 -0
  25. package/dist/relay/codex-app-server.js +637 -157
  26. package/dist/relay/codex-approvals.js +390 -0
  27. package/dist/relay/codex-commands.js +61 -0
  28. package/dist/relay/codex-log.js +260 -0
  29. package/dist/relay/codex-runner.js +505 -0
  30. package/dist/relay/codex-version.js +50 -0
  31. package/dist/relay/daemon.js +190 -6
  32. package/dist/relay/fresh-start.js +2 -1
  33. package/dist/relay/launcher/capabilities.js +24 -9
  34. package/dist/relay/launcher/codex-launch.js +9 -2
  35. package/dist/relay/launcher/codex-relay-launch.js +80 -0
  36. package/dist/relay/launcher/handle.js +101 -30
  37. package/dist/relay/launcher/loop.js +5 -1
  38. package/dist/relay/launcher/stop-registered.js +124 -9
  39. package/dist/relay/registry.js +30 -0
  40. package/dist/relay/runtime-limits.js +167 -0
  41. package/dist/relay/spawn-env.js +40 -3
  42. package/dist/relay/terminal-pane.js +1 -0
  43. package/dist/skill-bootstrap.js +2 -0
  44. package/dist/tool-defs.js +30 -0
  45. package/package.json +1 -1
@@ -0,0 +1,390 @@
1
+ "use strict";
2
+ // Codex's approval requests, as BayChat decision cards (2026-10-23).
3
+ //
4
+ // WHAT THIS IS. A relay-run Codex turn (`codex app-server`, codex-app-server.ts) runs with
5
+ // `approvalPolicy: "on-request"` when everyone who woke it may command the session. Codex then
6
+ // stops and ASKS before it runs a command outside its sandbox, writes a file, widens its own
7
+ // permissions or calls an MCP tool — as JSON-RPC requests from the server to us. This module is
8
+ // the pure half of answering them: what the card says, which buttons it has, and what each tap
9
+ // (or no tap) means in the protocol's own words. No I/O here; the runner (codex-runner.ts) asks
10
+ // the phone and remembers what was allowed for the session.
11
+ //
12
+ // THE BUTTONS. "Allow once", "Allow for this session", "Deny". The middle one is GUARDED — the
13
+ // app confirms it on the device (Face ID / a confirm step) before sending, because it answers
14
+ // every later identical request without asking (the card API's `guardedOptions`). No answer
15
+ // within the card's time is a Deny, and the card is withdrawn.
16
+ //
17
+ // NEVER AN AMENDMENT. Codex offers "allow commands like this forever" (execpolicy amendments) and
18
+ // "allow this host forever" (network policy amendments). Both write rules that outlive the
19
+ // session and the person who tapped, so this relay never sends them: the strongest answer it
20
+ // gives is `acceptForSession`.
21
+ //
22
+ // Protocol shapes are the official 0.160 schema's (`openai/codex@rust-v0.160.0`,
23
+ // `codex-rs/app-server-protocol/schema/json/`), not guesses.
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.CODEX_CARD_WAIT_SECONDS = exports.CODEX_GUARDED_OPTIONS = exports.CODEX_APPROVE_OPTIONS = void 0;
26
+ exports.isCodexApprovalMethod = isCodexApprovalMethod;
27
+ exports.cachedItemFrom = cachedItemFrom;
28
+ exports.fileChangesFrom = fileChangesFrom;
29
+ exports.choiceAt = choiceAt;
30
+ exports.planCodexCard = planCodexCard;
31
+ exports.composeCodexQuestion = composeCodexQuestion;
32
+ exports.looksSecret = looksSecret;
33
+ exports.describePermissions = describePermissions;
34
+ exports.decisionFor = decisionFor;
35
+ exports.isBayChatMcpApproval = isBayChatMcpApproval;
36
+ exports.sessionAllowKey = sessionAllowKey;
37
+ exports.commandKeyCwd = commandKeyCwd;
38
+ exports.describeForLog = describeForLog;
39
+ exports.clean = clean;
40
+ /** The card's buttons when everything can be shown: index 1 is guarded. */
41
+ exports.CODEX_APPROVE_OPTIONS = ["Allow once", "Allow for this session", "Deny"];
42
+ /** Confirmed on the device before it is sent: it answers later requests without asking. */
43
+ exports.CODEX_GUARDED_OPTIONS = [1];
44
+ /** How long a card is waited on before it counts as Deny and is withdrawn (the owner's call: 10 min). */
45
+ exports.CODEX_CARD_WAIT_SECONDS = 600;
46
+ /** The card API's limit on a question (apps/api/src/lib/cards.ts). */
47
+ const QUESTION_MAX = 2_000;
48
+ /** How much of a command the card shows. */
49
+ const COMMAND_MAX = 600;
50
+ /** How much of one path the card shows (the middle is elided). */
51
+ const PATH_MAX = 160;
52
+ /** How many files, or permission entries of one kind, a card lists. More than that and it says so. */
53
+ const LIST_MAX = 12;
54
+ /** How much of a NEW file's contents the card shows. */
55
+ const ADD_EXCERPT_MAX = 300;
56
+ function isCodexApprovalMethod(method) {
57
+ return (method === "item/commandExecution/requestApproval" ||
58
+ method === "item/fileChange/requestApproval" ||
59
+ method === "item/permissions/requestApproval" ||
60
+ method === "mcpServer/elicitation/request");
61
+ }
62
+ /** Read one item from `item/started` / `item/fileChange/patchUpdated`, defensively. */
63
+ function cachedItemFrom(raw) {
64
+ const item = (raw ?? {});
65
+ const id = typeof item.id === "string" ? item.id : "";
66
+ if (!id)
67
+ return undefined;
68
+ if (item.type === "fileChange")
69
+ return { type: "fileChange", id, changes: fileChangesFrom(item.changes) };
70
+ if (item.type === "commandExecution")
71
+ return { type: "commandExecution", id, command: str(item.command), cwd: str(item.cwd) };
72
+ return undefined;
73
+ }
74
+ function fileChangesFrom(raw) {
75
+ if (!Array.isArray(raw))
76
+ return [];
77
+ return raw.flatMap((c) => {
78
+ const change = (c ?? {});
79
+ const kindObj = (change.kind ?? {});
80
+ const kind = kindObj.type === "add" || kindObj.type === "delete" ? kindObj.type : "update";
81
+ if (typeof change.path !== "string")
82
+ return [];
83
+ const movePath = typeof kindObj.move_path === "string" ? kindObj.move_path : undefined;
84
+ return [{ path: change.path, kind, ...(movePath ? { movePath } : {}), diff: str(change.diff) }];
85
+ });
86
+ }
87
+ /** The button that was tapped, as a choice; null (no answer, or an index the card never had) is a refusal. */
88
+ function choiceAt(card, index) {
89
+ return index === null ? null : (card.choices[index] ?? null);
90
+ }
91
+ /**
92
+ * The card for one approval request, or a refusal when no card could honestly show it. At most
93
+ * 2,000 characters, every control and invisible character a renderer could act on removed — this
94
+ * is text Codex (and so, indirectly, anyone in the room) chose.
95
+ */
96
+ function planCodexCard(req, ctx) {
97
+ const p = req.params;
98
+ const who = `Codex on ${clean(ctx.host)} (session "${clean(ctx.session)}")`;
99
+ /** Lines that must always be on the card, in order. */
100
+ const head = [];
101
+ /** The variable-length part, clipped to whatever room is left. */
102
+ let body = "";
103
+ let complete = true;
104
+ let sessionNote = "";
105
+ const reason = str(p.reason);
106
+ switch (req.method) {
107
+ case "item/commandExecution/requestApproval": {
108
+ const command = str(p.command) || (req.item?.type === "commandExecution" ? req.item.command : "");
109
+ const cwd = str(p.cwd) || (req.item?.type === "commandExecution" ? req.item.cwd : "");
110
+ head.push(p.kind === "writeStdin" ? `${who} wants to send input to a running command.` : `${who} wants to run a command.`);
111
+ if (cwd)
112
+ head.push(`In: ${shortPath(cwd)}`);
113
+ const net = (p.networkApprovalContext ?? null);
114
+ if (net && typeof net.host === "string")
115
+ head.push(`Network: it wants to reach ${clean(net.host)}${typeof net.protocol === "string" ? ` (${clean(net.protocol)})` : ""}`);
116
+ const shown = clipWithCount(collapse(clean(command, true)) || "(no command given)", COMMAND_MAX);
117
+ if (shown.clipped)
118
+ complete = false;
119
+ body = shown.text;
120
+ sessionNote = `"Allow for this session" lets this exact command run again here without asking — and that covers whatever the scripts it runs contain when they run.`;
121
+ break;
122
+ }
123
+ case "item/fileChange/requestApproval": {
124
+ const changes = req.item?.type === "fileChange" ? req.item.changes : [];
125
+ head.push(`${who} wants to change ${changes.length === 1 ? "1 file" : changes.length > 1 ? `${changes.length} files` : "files (Codex did not say which)"}.`);
126
+ if (changes.length === 0)
127
+ complete = false;
128
+ const root = str(p.grantRoot);
129
+ if (root) {
130
+ head.push(`It also asks to write ANYWHERE under ${shortPath(root)} for the rest of the session.`);
131
+ complete = false; // a whole folder is never remembered
132
+ }
133
+ for (const c of changes.slice(0, LIST_MAX))
134
+ head.push(`${c.kind} ${shortPath(c.path)}${c.movePath ? ` → ${shortPath(c.movePath)}` : ""}`);
135
+ if (changes.length > LIST_MAX) {
136
+ head.push(`…and ${changes.length - LIST_MAX} more file(s) not listed`);
137
+ complete = false;
138
+ }
139
+ body = changes.map(contentsFor).filter(Boolean).join("\n");
140
+ sessionNote = `"Allow for this session" lets changes to exactly these files through without asking.`;
141
+ break;
142
+ }
143
+ case "item/permissions/requestApproval": {
144
+ const described = describePermissions(p.permissions);
145
+ if (described.refuse)
146
+ return { kind: "refuse", reason: described.refuse };
147
+ head.push(`${who} wants more access:`, ...described.lines);
148
+ if (str(p.cwd))
149
+ head.push(`In: ${shortPath(str(p.cwd))}`);
150
+ break;
151
+ }
152
+ case "mcpServer/elicitation/request": {
153
+ head.push(`${who}: the tool server "${clean(str(p.serverName))}" asks:`);
154
+ const shown = clipWithCount(collapse(clean(str(p.message), true)), COMMAND_MAX);
155
+ body = shown.text;
156
+ break;
157
+ }
158
+ }
159
+ if (reason)
160
+ head.push(`Why: ${clipWithCount(clean(reason), 300).text}`);
161
+ const offerSession = complete && sessionNote !== "";
162
+ const tail = ["", ...(offerSession ? [sessionNote] : []), "Allowing does it on that computer, now. If you did not expect this, deny."];
163
+ const fixed = [...head, "", "", ...tail].join("\n");
164
+ // The lines a decision turns on must all fit; a request too big to show is not asked about.
165
+ if (fixed.length > QUESTION_MAX)
166
+ return { kind: "refuse", reason: "it is too big to show on one card" };
167
+ const room = Math.max(0, QUESTION_MAX - fixed.length - 40);
168
+ let question;
169
+ if (body) {
170
+ const shown = clipWithCount(body, room);
171
+ question = [...head, "", shown.text, ...tail].join("\n");
172
+ }
173
+ else {
174
+ question = [...head, ...tail].join("\n");
175
+ }
176
+ question = question.replace(/\n{3,}/g, "\n\n").trim().slice(0, QUESTION_MAX);
177
+ const labels = offerSession ? [...exports.CODEX_APPROVE_OPTIONS] : ["Allow once", "Deny"];
178
+ const choices = offerSession ? ["once", "session", "deny"] : ["once", "deny"];
179
+ return { kind: "card", card: { question, labels, choices, guardedOptions: offerSession ? [...exports.CODEX_GUARDED_OPTIONS] : [], rememberable: offerSession } };
180
+ }
181
+ /** The card's question alone (tests and logs). A refused request has none. */
182
+ function composeCodexQuestion(req, ctx) {
183
+ const plan = planCodexCard(req, ctx);
184
+ return plan.kind === "card" ? plan.card.question : `(refused without asking: ${plan.reason})`;
185
+ }
186
+ /** Paths whose contents never go on a card: they very likely hold a secret. */
187
+ const SECRET_PATH = [
188
+ /(^|[\\/])\.env(\.[^\\/]*)?$/i,
189
+ /credential/i,
190
+ /secret/i,
191
+ /password|passwd/i,
192
+ /token/i,
193
+ /\.(pem|key|p12|pfx|jks|keystore|kdbx|gpg|asc)$/i,
194
+ /(^|[\\/])id_[a-z0-9]+(\.pub)?$/i,
195
+ /(^|[\\/])\.(ssh|aws|gnupg|kube|docker)[\\/]/i,
196
+ /(^|[\\/])\.(npmrc|netrc|pypirc|pgpass|git-credentials)$/i,
197
+ ];
198
+ function looksSecret(path) {
199
+ return SECRET_PATH.some((re) => re.test(path));
200
+ }
201
+ /**
202
+ * What a card shows of one file's contents. In 0.160 an add's or a delete's `diff` is the WHOLE
203
+ * file, so: a deletion shows nothing, a new file a short excerpt, a path that looks like it holds
204
+ * a secret nothing at all.
205
+ */
206
+ function contentsFor(change) {
207
+ if (!change.diff)
208
+ return "";
209
+ const where = shortPath(change.path);
210
+ if (change.kind === "delete")
211
+ return `(${where} is deleted — its contents are not shown)`;
212
+ if (looksSecret(change.path) || (change.movePath && looksSecret(change.movePath)))
213
+ return `(${where} — contents not shown)`;
214
+ if (change.kind === "add") {
215
+ const shown = clipWithCount(clean(change.diff, true), ADD_EXCERPT_MAX);
216
+ return `New file ${where}:\n${shown.text}`;
217
+ }
218
+ return clean(change.diff, true);
219
+ }
220
+ const SPECIAL_KIND = {
221
+ root: "the WHOLE disk",
222
+ minimal: "the minimal system paths",
223
+ tmpdir: "the temp folder ($TMPDIR)",
224
+ slash_tmp: "/tmp",
225
+ };
226
+ /** One file-system target, named so a person can see exactly what it is. */
227
+ function describeTarget(raw) {
228
+ const where = (raw ?? {});
229
+ if (where.type === "path")
230
+ return { text: shortPath(str(where.path)), root: str(where.path) === "/" };
231
+ if (where.type === "glob_pattern")
232
+ return { text: `files matching ${clean(str(where.pattern))}`, root: false };
233
+ if (where.type === "special") {
234
+ const value = (where.value ?? {});
235
+ const kind = str(value.kind);
236
+ const sub = str(value.subpath);
237
+ if (kind === "project_roots")
238
+ return { text: `the project folders${sub ? ` (${shortPath(sub)})` : ""}`, root: false };
239
+ if (kind === "unknown")
240
+ return { text: `"${shortPath(str(value.path))}"${sub ? ` (${shortPath(sub)})` : ""} — a special path this relay does not know`, root: false };
241
+ const known = SPECIAL_KIND[kind];
242
+ return { text: known ?? `an unrecognised special path "${clean(kind)}"`, root: kind === "root" };
243
+ }
244
+ return { text: `an unrecognised path ${clean(JSON.stringify(where).slice(0, 80))}`, root: false };
245
+ }
246
+ /**
247
+ * Every grant a permissions request asks for, as card lines — or a refusal when the card could
248
+ * not list them all, or one of them is write access to the whole disk. Never fewer lines than
249
+ * grants: `decisionFor` grants the request as a whole.
250
+ */
251
+ function describePermissions(raw) {
252
+ const perms = (raw ?? {});
253
+ const lines = [];
254
+ const fs = (perms.fileSystem ?? null);
255
+ if (fs) {
256
+ const write = Array.isArray(fs.write) ? fs.write : [];
257
+ const read = Array.isArray(fs.read) ? fs.read : [];
258
+ const entries = Array.isArray(fs.entries) ? fs.entries : [];
259
+ if (write.length > LIST_MAX || read.length > LIST_MAX || entries.length > LIST_MAX) {
260
+ return { lines, refuse: `it asked for more file-system access than a card can list (${write.length} write, ${read.length} read, ${entries.length} entries)` };
261
+ }
262
+ for (const w of write) {
263
+ if (str(w) === "/")
264
+ return { lines, refuse: "it asked to write to the whole disk" };
265
+ lines.push(`Write: ${shortPath(str(w))}`);
266
+ }
267
+ for (const r of read)
268
+ lines.push(`Read: ${str(r) === "/" ? "the WHOLE disk" : shortPath(str(r))}`);
269
+ for (const e of entries) {
270
+ const entry = (e ?? {});
271
+ const access = str(entry.access);
272
+ const target = describeTarget(entry.path);
273
+ if (target.root && access === "write")
274
+ return { lines, refuse: "it asked to write to the whole disk" };
275
+ if (access !== "read" && access !== "write" && access !== "deny")
276
+ return { lines, refuse: `it asked for an access mode this relay does not know ("${clean(access)}")` };
277
+ lines.push(`${access === "write" ? "Write" : access === "read" ? "Read" : "Keep closed"}: ${target.text}`);
278
+ }
279
+ if (typeof fs.globScanMaxDepth === "number")
280
+ lines.push(`(patterns are searched up to ${fs.globScanMaxDepth} folder(s) deep)`);
281
+ }
282
+ const net = (perms.network ?? null);
283
+ if (net && net.enabled === true)
284
+ lines.push("Network: ON, to any host");
285
+ return { lines: lines.length > 0 ? lines : ["(Codex did not say what access.)"] };
286
+ }
287
+ /**
288
+ * What to send back for a choice — or for `null`, which is no answer at all (deadline, withdrawn,
289
+ * unreachable, an index the card never had) and is ALWAYS the refusal.
290
+ */
291
+ function decisionFor(req, choice) {
292
+ const allow = choice === "once" || choice === "session";
293
+ const forSession = choice === "session";
294
+ switch (req.method) {
295
+ case "item/commandExecution/requestApproval":
296
+ case "item/fileChange/requestApproval":
297
+ return { decision: allow ? (forSession ? "acceptForSession" : "accept") : "decline" };
298
+ case "item/permissions/requestApproval":
299
+ // Exactly what was requested — and the card listed all of it — or nothing.
300
+ return allow ? { permissions: req.params.permissions ?? {}, scope: forSession ? "session" : "turn" } : { permissions: {}, scope: "turn" };
301
+ case "mcpServer/elicitation/request":
302
+ return allow ? { action: "accept", content: {} } : { action: "decline" };
303
+ }
304
+ }
305
+ /**
306
+ * Is this elicitation Codex asking whether it may call one of BAYCHAT'S OWN tools (`send_message`
307
+ * and the rest)? Those are the session's voice in the room — asking the owner's phone before every
308
+ * reply would make the session unusable, and the turn is already one they allowed. Only an
309
+ * approval of an MCP tool call (`_meta.codex_approval_kind: "mcp_tool_call"`) from the server
310
+ * entry BayChat is configured under counts; any other server, or any other kind of elicitation
311
+ * (a form asking for input), does not.
312
+ */
313
+ function isBayChatMcpApproval(params, entry) {
314
+ const meta = (params._meta ?? null);
315
+ return params.serverName === entry && meta?.codex_approval_kind === "mcp_tool_call";
316
+ }
317
+ /**
318
+ * The key "Allow for this session" is remembered under, or undefined when the request cannot be
319
+ * named exactly enough to be allowed again without asking. A command: its exact text in its exact
320
+ * folder. A file change: its exact set of paths, in the session's folder. Nothing broader —
321
+ * "allowed `npm test` here" must never cover `npm test && curl …`, nor one file another.
322
+ */
323
+ function sessionAllowKey(req, sessionCwd = "") {
324
+ if (req.method === "item/commandExecution/requestApproval") {
325
+ if (req.params.kind === "writeStdin")
326
+ return undefined; // input to a live process is never the same twice
327
+ if (req.params.networkApprovalContext)
328
+ return undefined; // a host is not a command
329
+ const command = str(req.params.command) || (req.item?.type === "commandExecution" ? req.item.command : "");
330
+ const cwd = str(req.params.cwd) || (req.item?.type === "commandExecution" ? req.item.cwd : "") || sessionCwd;
331
+ return command ? `command\u0000${cwd}\u0000${command}` : undefined;
332
+ }
333
+ if (req.method === "item/fileChange/requestApproval") {
334
+ if (str(req.params.grantRoot))
335
+ return undefined; // a whole folder is a different request
336
+ const paths = req.item?.type === "fileChange" ? req.item.changes.map((c) => (c.movePath ? `${c.path}\u0000${c.movePath}` : c.path)) : [];
337
+ return paths.length ? `files\u0000${sessionCwd}\u0000${[...new Set(paths)].sort().join("\u0001")}` : undefined;
338
+ }
339
+ return undefined;
340
+ }
341
+ /** The folder a remembered command runs in, for forgetting it when files under it change. */
342
+ function commandKeyCwd(key) {
343
+ return key.startsWith("command\u0000") ? key.split("\u0000")[1] : undefined;
344
+ }
345
+ /** A short line for the session log: what was asked, never more than one line. */
346
+ function describeForLog(req) {
347
+ switch (req.method) {
348
+ case "item/commandExecution/requestApproval":
349
+ return `command: ${clipWithCount(collapse(clean(str(req.params.command) || (req.item?.type === "commandExecution" ? req.item.command : ""))), 200).text}`;
350
+ case "item/fileChange/requestApproval":
351
+ return `files: ${req.item?.type === "fileChange" ? req.item.changes.map((c) => `${c.kind} ${shortPath(c.path)}`).join(", ") : "(unknown)"}`;
352
+ case "item/permissions/requestApproval": {
353
+ const d = describePermissions(req.params.permissions);
354
+ return `permissions: ${d.refuse ?? d.lines.join("; ")}`;
355
+ }
356
+ case "mcpServer/elicitation/request":
357
+ return `elicitation from ${clean(str(req.params.serverName))}`;
358
+ }
359
+ }
360
+ function str(value) {
361
+ return typeof value === "string" ? value : "";
362
+ }
363
+ /** Clip to `max`, saying how much was left out; `clipped` tells the caller the card is incomplete. */
364
+ function clipWithCount(text, max) {
365
+ if (text.length <= max)
366
+ return { text, clipped: false };
367
+ const keep = Math.max(0, max - 40);
368
+ return { text: `${text.slice(0, keep)}… [${text.length - keep} more characters not shown]`, clipped: true };
369
+ }
370
+ /** Runs of spaces or tabs as one space, and blank lines as one line break: padding cannot push a
371
+ * part of a command off the card. */
372
+ function collapse(text) {
373
+ return text.replace(/[ \t]{2,}/g, " ").replace(/\n\s*\n+/g, "\n").trim();
374
+ }
375
+ /** A path, cleaned, with its middle elided past PATH_MAX. */
376
+ function shortPath(path) {
377
+ const p = clean(path);
378
+ return p.length <= PATH_MAX ? p : `${p.slice(0, 60)}…${p.slice(-(PATH_MAX - 61))}`;
379
+ }
380
+ // C0 controls except tab and newline, DEL, C1 controls; the bidi and invisible-direction marks that
381
+ // could make a line DISPLAY as something it is not; zero-width characters and the BOM that can hide
382
+ // text; and the Unicode line/paragraph separators a renderer may break on.
383
+ const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
384
+ const SEPARATORS = /[\u2028\u2029\u0085]/g;
385
+ const ANSI = /\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)?/g;
386
+ /** Strip escapes and control characters. `multiline` keeps line breaks (a diff); else they become spaces. */
387
+ function clean(text, multiline = false) {
388
+ const out = text.replace(ANSI, "").replace(/\r\n?/g, "\n").replace(SEPARATORS, "\n").replace(CONTROL, "");
389
+ return multiline ? out : out.replace(/\s*\n\s*/g, " ").trim();
390
+ }
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runCodexSessionCommand = runCodexSessionCommand;
4
+ const codex_version_1 = require("./codex-version");
5
+ /**
6
+ * The session commands for a Codex session the RELAY runs (`target.codex`, codex-runner.ts) —
7
+ * there is no terminal to type `/compact` or `/new` into, so each means what the runner can do.
8
+ * Kept out of `daemon.ts`, which only calls in.
9
+ *
10
+ * Returns true when the command was answered here; false hands it back to the daemon (`/help`).
11
+ * A Codex in a terminal never reaches this: its commands are typed into its pane as before.
12
+ * Every path replies — a command produces no agent turn, so silence would read as failure.
13
+ */
14
+ async function runCodexSessionCommand(codex, session, target, message, command, say) {
15
+ if (!target.codex)
16
+ return false;
17
+ if (command.mode) {
18
+ await say(`"${session}" has no modes: it works in its folder without network, and asks you on your phone before anything more.`);
19
+ return true;
20
+ }
21
+ switch (command.id) {
22
+ case "compact":
23
+ if ((0, codex_version_1.codexExecFallback)()) {
24
+ await say(`/compact for "${session}" needs \`codex app-server\`, which BAYCHAT_CODEX_TRANSPORT=exec turns off on that computer.`);
25
+ return true;
26
+ }
27
+ await say(codex.compact(session, message.conversationId)
28
+ ? `Compacting "${session}"'s memory${codex.status(session).running ? " after its current turn" : ""} — it will say when it is done.`
29
+ : `"${session}" has nothing to compact yet.`);
30
+ return true;
31
+ case "stop":
32
+ await say(codex.stop(session) ? `Stopped "${session}". Anything it had already done stays done.` : `"${session}" is not doing anything right now.`);
33
+ return true;
34
+ case "clear":
35
+ case "new":
36
+ // There is no terminal to type into: forgetting the thread IS the fresh chat. The running
37
+ // turn is stopped first, or it would finish into the memory being closed.
38
+ codex.stop(session);
39
+ codex.reset(session);
40
+ await say(`"${session}" will start a fresh conversation with your next message. Its earlier memory is closed, and nothing allowed "for this session" carries over.`);
41
+ return true;
42
+ case "exit":
43
+ await say(`"${session}" is run by the relay on that computer and has no terminal to detach. Use /stop to cancel a running turn.`);
44
+ return true;
45
+ case "status": {
46
+ const s = codex.status(session);
47
+ const allowed = codex.allowedCount(session);
48
+ await say([
49
+ `Session "${session}" — Codex run by the relay, no terminal`,
50
+ ` folder: ${target.codex.cwd}`,
51
+ " approvals: asked on your phone (no network unless you allow it)",
52
+ ` now: ${s.running ? "working on a turn" : "idle"}, ${s.queued} message(s) waiting`,
53
+ ` memory: ${target.codex.threadId ? "kept between messages" : "fresh on the next message"}`,
54
+ ` allowed for this session: ${allowed === 0 ? "nothing" : `${allowed} exact request(s)`}`,
55
+ ].join("\n"));
56
+ return true;
57
+ }
58
+ default:
59
+ return false;
60
+ }
61
+ }