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
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.askPhone = askPhone;
4
+ exports.askPhoneChoice = askPhoneChoice;
4
5
  const api_1 = require("../../api");
5
6
  const approve_hook_1 = require("../../approve-hook");
7
+ const waiting_cards_1 = require("../../waiting-cards");
6
8
  const HOLD_SECONDS = 25; // under the usual 60s proxy idle timeout — same as approve-hook
7
9
  const MAX_CONSECUTIVE_FAILURES = 3;
8
10
  const RETRY_DELAY_MS = 2_000;
@@ -13,53 +15,120 @@ const ALLOW_INDEX = 0; // the ONLY index that means yes
13
15
  * NEVER THROWS, and every path that is not an explicit "Allow this once" is a refusal: an
14
16
  * unreachable server, a status we do not know, a withdrawn card, the deadline. The card itself
15
17
  * never expires server-side; our deadline exists so a turn cannot hold a process forever, and
16
- * reaching it REFUSES. Silence is never a yes.
18
+ * reaching it REFUSES — and now also takes the card back, so a late tap on it answers nothing.
19
+ * Silence is never a yes.
17
20
  */
18
21
  async function askPhone(params, deps = {}) {
22
+ const answer = await askPhoneChoice({ ...params, options: approve_hook_1.APPROVE_OPTIONS }, deps);
23
+ return { allowed: answer.index === ALLOW_INDEX, reason: answer.reason };
24
+ }
25
+ /**
26
+ * A decision card with the caller's own buttons. Resolves the tapped index, or `index: null` with
27
+ * the reason nobody's answer counts — the deadline, an abort, a withdrawn card, an unreachable or
28
+ * confused server. What an index MEANS is the caller's; `null` must always be read as a refusal.
29
+ *
30
+ * NEVER THROWS. When it gives up on a card it created (the deadline, or `signal`), it withdraws
31
+ * it (`POST /approvals/:id/withdraw`, best effort), so the phone stops offering a question whose
32
+ * answer would reach nobody.
33
+ */
34
+ async function askPhoneChoice(params, deps = {}) {
19
35
  const request = deps.request ?? api_1.apiRequest;
20
36
  const now = deps.now ?? Date.now;
21
37
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
38
+ const markWaiting = deps.markWaiting ?? waiting_cards_1.markWaitingCard;
22
39
  const waitSeconds = params.waitSeconds ?? approve_hook_1.DEFAULT_WAIT_SECONDS;
23
40
  const deadline = now() + waitSeconds * 1000;
24
41
  const hold = () => Math.max(1, Math.min(HOLD_SECONDS, Math.ceil((deadline - now()) / 1000)));
25
- const no = (reason) => ({ allowed: false, reason });
42
+ const no = (reason) => ({ index: null, reason });
43
+ const label = (index, answer) => answer ?? (typeof index === "number" ? params.options[index] : undefined) ?? "no answer";
44
+ const withdraw = async (requestId) => {
45
+ try {
46
+ await request(params.auth, "POST", `/api/device-api/approvals/${encodeURIComponent(requestId)}/withdraw`, {});
47
+ }
48
+ catch {
49
+ // A card left pending is harmless — its answer reaches nobody. Nothing to do but move on.
50
+ }
51
+ };
52
+ /** The next reply, or "aborted" the moment `signal` fires — a long-poll is not waited out. */
53
+ const orAbort = (pending) => {
54
+ const signal = params.signal;
55
+ if (!signal)
56
+ return pending;
57
+ if (signal.aborted)
58
+ return Promise.resolve("aborted");
59
+ return new Promise((resolve, reject) => {
60
+ const onAbort = () => resolve("aborted");
61
+ signal.addEventListener("abort", onAbort, { once: true });
62
+ pending.then((value) => {
63
+ signal.removeEventListener("abort", onAbort);
64
+ resolve(value);
65
+ }, (err) => {
66
+ signal.removeEventListener("abort", onAbort);
67
+ reject(err);
68
+ });
69
+ });
70
+ };
71
+ if (params.signal?.aborted)
72
+ return no("nobody needed the answer any more");
26
73
  try {
27
- let reply = await request(params.auth, "POST", "/api/device-api/approvals", {
74
+ // With a signal the first hold is ONE second, so a stop that lands while the card is being
75
+ // created is seen at once — never after a 25-second hold.
76
+ const creating = request(params.auth, "POST", "/api/device-api/approvals", {
28
77
  session: params.session,
29
- conversationId: params.conversationId,
78
+ ...(params.direct ? { direct: true } : { conversationId: params.conversationId }),
30
79
  question: params.question,
31
- options: [...approve_hook_1.APPROVE_OPTIONS],
32
- wait: hold(),
80
+ options: [...params.options],
81
+ ...(params.guardedOptions?.length ? { guardedOptions: params.guardedOptions } : {}),
82
+ wait: params.signal ? 1 : hold(),
33
83
  });
84
+ let reply = await orAbort(creating);
85
+ if (reply === "aborted") {
86
+ // The card is (being) created and nobody wants its answer: take it back the moment its id is known.
87
+ void creating.then((created) => (typeof created.requestId === "string" && created.requestId ? withdraw(created.requestId) : undefined)).catch(() => undefined);
88
+ return no("nobody needed the answer any more — the card was withdrawn");
89
+ }
34
90
  const requestId = typeof reply.requestId === "string" ? reply.requestId : "";
35
91
  if (!requestId)
36
92
  return no("BayChat did not return an approval id");
37
- let failures = 0;
38
- for (;;) {
39
- if (reply.status === "answered") {
40
- return reply.answerIndex === ALLOW_INDEX
41
- ? { allowed: true, reason: "approved in BayChat" }
42
- : no(`answered "${reply.answer ?? approve_hook_1.APPROVE_OPTIONS[1]}" in BayChat`);
43
- }
44
- if (reply.status === "cancelled")
45
- return no("the decision was withdrawn in BayChat");
46
- if (reply.status !== "waiting")
47
- return no(`BayChat answered with a status this relay does not understand ("${String(reply.status)}")`);
48
- if (deadline - now() <= 0)
49
- return no(`nobody answered within ${waitSeconds}s — the card is still in BayChat, and this request was refused`);
50
- try {
51
- reply = await request(params.auth, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${hold()}`);
52
- failures = 0;
53
- }
54
- catch (err) {
55
- if (err instanceof api_1.ApiError && err.status < 500 && err.status !== 429)
56
- return no(`BayChat refused the wait — ${err.message}`);
57
- failures += 1;
58
- if (failures >= MAX_CONSECUTIVE_FAILURES)
59
- return no(`BayChat became unreachable while waiting — ${err instanceof Error ? err.message : String(err)}`);
60
- await sleep(RETRY_DELAY_MS);
93
+ const unmark = markWaiting(requestId);
94
+ try {
95
+ let failures = 0;
96
+ for (;;) {
97
+ if (params.signal?.aborted)
98
+ reply = "aborted";
99
+ if (reply === "aborted") {
100
+ await withdraw(requestId);
101
+ return no("nobody needed the answer any more — the card was withdrawn");
102
+ }
103
+ if (reply.status === "answered") {
104
+ const index = typeof reply.answerIndex === "number" ? reply.answerIndex : null;
105
+ return { index, reason: `answered "${label(index, reply.answer)}" in BayChat` };
106
+ }
107
+ if (reply.status === "cancelled")
108
+ return no("the decision was withdrawn in BayChat");
109
+ if (reply.status !== "waiting")
110
+ return no(`BayChat answered with a status this relay does not understand ("${String(reply.status)}")`);
111
+ if (deadline - now() <= 0) {
112
+ await withdraw(requestId);
113
+ return no(`nobody answered within ${waitSeconds}s — the card was withdrawn, and this request was refused`);
114
+ }
115
+ try {
116
+ reply = await orAbort(request(params.auth, "GET", `/api/device-api/approvals/${encodeURIComponent(requestId)}?wait=${hold()}`));
117
+ failures = 0;
118
+ }
119
+ catch (err) {
120
+ if (err instanceof api_1.ApiError && err.status < 500 && err.status !== 429)
121
+ return no(`BayChat refused the wait — ${err.message}`);
122
+ failures += 1;
123
+ if (failures >= MAX_CONSECUTIVE_FAILURES)
124
+ return no(`BayChat became unreachable while waiting — ${err instanceof Error ? err.message : String(err)}`);
125
+ await sleep(RETRY_DELAY_MS);
126
+ }
61
127
  }
62
128
  }
129
+ finally {
130
+ unmark();
131
+ }
63
132
  }
64
133
  catch (err) {
65
134
  return no(`BayChat could not be asked — ${err instanceof Error ? err.message : String(err)}`);
@@ -193,7 +193,8 @@ function registerAcpFrame(frame, deps) {
193
193
  * ever. `upsert` merges, so an attach over it would keep `acp` and give the name two drivers.
194
194
  */
195
195
  function acpAttachRefusal(registry, session) {
196
- return registry.get(session)?.acp
196
+ const target = registry.get(session);
197
+ return target?.acp || target?.codex
197
198
  ? `"${session}" is run by the relay on this computer; attach a terminal under a different session name.`
198
199
  : undefined;
199
200
  }
@@ -196,7 +196,7 @@ const codexAdapter = {
196
196
  * interface is marked `[experimental]` by OpenAI and its shape is not frozen,
197
197
  * so there has to be a way back that does not require a new release.
198
198
  */
199
- async runTurn({ binaryPath, target, prompt }) {
199
+ async runTurn({ binaryPath, target, prompt, policy, onApproval, onNotification, log }) {
200
200
  if (process.env.BAYCHAT_CODEX_TRANSPORT === "exec") {
201
201
  return {
202
202
  kind: "failed",
@@ -209,6 +209,10 @@ const codexAdapter = {
209
209
  threadId: target.resumeId,
210
210
  prompt,
211
211
  cwd: target.resumeCwd ?? target.cwd,
212
+ ...(policy ? { policy } : {}),
213
+ ...(onApproval ? { onApproval } : {}),
214
+ ...(onNotification ? { onNotification } : {}),
215
+ ...(log ? { log } : {}),
212
216
  }, { spawn: child_process_1.spawn });
213
217
  },
214
218
  };
@@ -55,17 +55,22 @@ var __importStar = (this && this.__importStar) || (function () {
55
55
  };
56
56
  })();
57
57
  Object.defineProperty(exports, "__esModule", { value: true });
58
- exports.BayRelaySupervisor = exports.BAY_RELAY_PARENT_ENV = exports.BAY_RELAY_CHILD_ENV = void 0;
58
+ exports.BayRelaySupervisor = exports.BAY_RELAY_PARENT_ENV = exports.BAY_RELAY_MCP_ENTRY_ENV = exports.BAY_RELAY_CHILD_ENV = void 0;
59
59
  exports.bayRelayScope = bayRelayScope;
60
60
  exports.bayClientEnv = bayClientEnv;
61
61
  exports.bayRelayEnv = bayRelayEnv;
62
+ exports.relayMcpEntry = relayMcpEntry;
62
63
  exports.isBayRelayChild = isBayRelayChild;
63
64
  const node_child_process_1 = require("node:child_process");
64
65
  const path = __importStar(require("node:path"));
66
+ const bays_1 = require("../bays");
65
67
  const socket_1 = require("./socket");
66
68
  const log_line_1 = require("./log-line");
67
69
  /** Set in a per-Bay child relay: it must never supervise children of its own. */
68
70
  exports.BAY_RELAY_CHILD_ENV = "BAYCHAT_RELAY_BAY";
71
+ /** The MCP entry name this child's Bay has in the computer's client configs (`baychat-<bay>`):
72
+ * a child cannot work it out itself — its credentials hold only its own Bay's token. */
73
+ exports.BAY_RELAY_MCP_ENTRY_ENV = "BAYCHAT_RELAY_MCP_ENTRY";
69
74
  /** The supervising relay's pid, so an orphaned child can stop itself. */
70
75
  exports.BAY_RELAY_PARENT_ENV = "BAYCHAT_RELAY_PARENT_PID";
71
76
  /** What a Bay id may contribute to a path or pipe name. */
@@ -97,8 +102,11 @@ function bayClientEnv(scope) {
97
102
  };
98
103
  }
99
104
  /** The environment a per-Bay CHILD relay runs with. Holds the Bay's token: never logged. */
100
- function bayRelayEnv(bay, baseUrl, scope, parentPid) {
105
+ function bayRelayEnv(bay, baseUrl, scope, parentPid,
106
+ /** The Bay's MCP entry name among the computer's Bays (`bayEntryName`). */
107
+ mcpEntry) {
101
108
  return {
109
+ ...(mcpEntry ? { [exports.BAY_RELAY_MCP_ENTRY_ENV]: mcpEntry } : {}),
102
110
  ...bayClientEnv(scope),
103
111
  BAYCHAT_DEVICE_TOKEN: bay.token,
104
112
  BAYCHAT_API_URL: baseUrl,
@@ -107,6 +115,11 @@ function bayRelayEnv(bay, baseUrl, scope, parentPid) {
107
115
  [exports.BAY_RELAY_PARENT_ENV]: String(parentPid),
108
116
  };
109
117
  }
118
+ /** The MCP entry this relay's Bay has in Codex's and the other clients' configs: the default
119
+ * Bay's is `baychat`, a per-Bay child's the name its parent gave it. */
120
+ function relayMcpEntry(env = process.env) {
121
+ return env[exports.BAY_RELAY_MCP_ENTRY_ENV] || bays_1.DEFAULT_MCP_ENTRY;
122
+ }
110
123
  /** Is THIS process a per-Bay child relay? */
111
124
  function isBayRelayChild(env = process.env) {
112
125
  return Boolean(env[exports.BAY_RELAY_CHILD_ENV]);
@@ -164,14 +177,14 @@ class BayRelaySupervisor {
164
177
  const exited = this.lastExit.get(bayId);
165
178
  if (exited !== undefined && this.now() - exited < (this.deps.restartDelayMs ?? 10_000))
166
179
  continue;
167
- this.startChild(bay, baseUrl);
180
+ this.startChild(bay, baseUrl, (0, bays_1.bayEntryName)(bay, bays));
168
181
  }
169
182
  }
170
- startChild(bay, baseUrl) {
183
+ startChild(bay, baseUrl, mcpEntry) {
171
184
  const scope = bayRelayScope(bay.bayId, this.deps.baseConfigDir, this.deps.baseSocket);
172
185
  let child;
173
186
  try {
174
- child = this.spawnChild(bayRelayEnv(bay, baseUrl, scope, process.pid));
187
+ child = this.spawnChild(bayRelayEnv(bay, baseUrl, scope, process.pid, mcpEntry));
175
188
  }
176
189
  catch (err) {
177
190
  this.lastExit.set(bay.bayId, this.now());
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CodexAllowances = void 0;
37
+ exports.completedFileChangePaths = completedFileChangePaths;
38
+ const path = __importStar(require("path"));
39
+ const codex_approvals_1 = require("./codex-approvals");
40
+ /**
41
+ * "Allow for this session" answers, per BayChat session AND per Codex thread (review 2026-10-23).
42
+ *
43
+ * Codex's own `acceptForSession` dies with the per-turn `codex app-server` process, so the relay
44
+ * remembers the answer for later turns — in memory only, never on disk. A remembered answer
45
+ * belongs to ONE conversation: when the session's thread changes (`/new`, a fresh start, a
46
+ * terminal Codex that registered a new thread) the answers are gone, and they are forgotten
47
+ * outright whenever the relay forgets or re-registers the session (End, a dead-session sweep, a
48
+ * new attach or registration, `/clear`).
49
+ *
50
+ * "This exact command" is not "this exact effect": a remembered command runs whatever its scripts
51
+ * contain when it runs. So a file change Codex completes under a remembered command's folder drops
52
+ * every command remembered there, and the next run is asked again.
53
+ */
54
+ class CodexAllowances {
55
+ bySession = new Map();
56
+ /** The answers for this session's current thread (a new, empty set when the thread changed). */
57
+ for(session, thread) {
58
+ const held = this.bySession.get(session);
59
+ if (held && held.thread === thread)
60
+ return held.keys;
61
+ const fresh = { thread, keys: new Set() };
62
+ this.bySession.set(session, fresh);
63
+ return fresh.keys;
64
+ }
65
+ /** A thread that had no id yet (a new one) now has one: its answers move with it. */
66
+ rebind(session, from, to) {
67
+ const held = this.bySession.get(session);
68
+ if (held && held.thread === from)
69
+ held.thread = to;
70
+ }
71
+ forget(session) {
72
+ this.bySession.delete(session);
73
+ }
74
+ count(session) {
75
+ return this.bySession.get(session)?.keys.size ?? 0;
76
+ }
77
+ /** Files changed: drop the commands remembered in any folder they are under. Returns how many. */
78
+ noteFileChanges(session, paths) {
79
+ const held = this.bySession.get(session);
80
+ if (!held || paths.length === 0)
81
+ return 0;
82
+ let dropped = 0;
83
+ for (const key of [...held.keys]) {
84
+ const cwd = (0, codex_approvals_1.commandKeyCwd)(key);
85
+ if (cwd === undefined)
86
+ continue;
87
+ const under = paths.some((p) => {
88
+ const abs = path.resolve(cwd || "/", p);
89
+ const rel = path.relative(cwd || "/", abs);
90
+ return !cwd || rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
91
+ });
92
+ if (under) {
93
+ held.keys.delete(key);
94
+ dropped += 1;
95
+ }
96
+ }
97
+ return dropped;
98
+ }
99
+ }
100
+ exports.CodexAllowances = CodexAllowances;
101
+ /** The paths of a completed file change, from an `item/completed` notification; [] for anything else. */
102
+ function completedFileChangePaths(method, params) {
103
+ if (method !== "item/completed")
104
+ return [];
105
+ const item = (params.item ?? {});
106
+ if (item.type !== "fileChange" || item.status !== "completed" || !Array.isArray(item.changes))
107
+ return [];
108
+ return item.changes.flatMap((c) => {
109
+ const change = (c ?? {});
110
+ const kind = (change.kind ?? {});
111
+ return [change.path, kind.move_path].filter((p) => typeof p === "string" && p !== "");
112
+ });
113
+ }