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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -2
- package/dist/bays-command.js +6 -0
- package/dist/channel/feed.js +48 -4
- package/dist/channel/log-file.js +37 -0
- package/dist/channel/server.js +34 -4
- package/dist/claude-context-flush.js +31 -8
- package/dist/claude-context.js +19 -5
- package/dist/claude-onboarding.js +21 -8
- package/dist/cli-help.js +3 -0
- package/dist/client-paths.js +6 -1
- package/dist/client-scope.js +34 -0
- package/dist/commands.js +16 -8
- package/dist/launch/command.js +6 -3
- package/dist/main.js +14 -1
- package/dist/mcp-files.js +2 -0
- package/dist/mcp-register.js +2 -0
- package/dist/protocol-content.js +1 -1
- package/dist/refresh-clients.js +7 -0
- package/dist/relay/acp/approval.js +99 -30
- package/dist/relay/acp/daemon-glue.js +2 -1
- package/dist/relay/adapters.js +5 -1
- package/dist/relay/bay-relays.js +18 -5
- package/dist/relay/codex-allowances.js +113 -0
- package/dist/relay/codex-app-server.js +637 -157
- package/dist/relay/codex-approvals.js +390 -0
- package/dist/relay/codex-commands.js +61 -0
- package/dist/relay/codex-log.js +260 -0
- package/dist/relay/codex-runner.js +505 -0
- package/dist/relay/codex-version.js +50 -0
- package/dist/relay/daemon.js +190 -6
- package/dist/relay/fresh-start.js +2 -1
- package/dist/relay/launcher/capabilities.js +24 -9
- package/dist/relay/launcher/codex-launch.js +9 -2
- package/dist/relay/launcher/codex-relay-launch.js +80 -0
- package/dist/relay/launcher/handle.js +101 -30
- package/dist/relay/launcher/loop.js +5 -1
- package/dist/relay/launcher/stop-registered.js +124 -9
- package/dist/relay/registry.js +30 -0
- package/dist/relay/runtime-limits.js +167 -0
- package/dist/relay/spawn-env.js +40 -3
- package/dist/relay/terminal-pane.js +1 -0
- package/dist/skill-bootstrap.js +2 -0
- package/dist/tool-defs.js +30 -0
- package/package.json +1 -1
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Codex run by the relay itself, with its approvals as BayChat cards (2026-10-23).
|
|
3
|
+
//
|
|
4
|
+
// WHAT CHANGED. A Codex started from the app used to be a TUI in a detached tmux pane, run with
|
|
5
|
+
// `--ask-for-approval never` because nobody sits at that keyboard: whatever the sandbox did not
|
|
6
|
+
// allow simply failed. Now the relay runs such a session the way it runs an ACP agent
|
|
7
|
+
// (acp/runner.ts): one `codex app-server` process per turn (codex-app-server.ts), the turn's
|
|
8
|
+
// messages from a persisted queue, and — the point of it — every approval Codex asks for put on
|
|
9
|
+
// the owner's phone as a decision card: "Allow once", "Allow for this session" (Face ID), "Deny".
|
|
10
|
+
//
|
|
11
|
+
// WHO GETS TO BE ASKED. A turn's power depends on who woke it, exactly as an ACP turn's mode
|
|
12
|
+
// does (`effectiveMode`): when EVERY addressed message is from someone with command access to the
|
|
13
|
+
// session (the owner, or a grantee — `senderIsOwner` / `commandAuthorized`), the turn runs
|
|
14
|
+
// `on-request` in a workspace-write sandbox with no network, and asks. One message from anybody
|
|
15
|
+
// else makes the whole turn `never` + read-only: it may read and answer, nothing more, and no
|
|
16
|
+
// card is ever raised on its behalf (a person without command access must not be able to put a
|
|
17
|
+
// "run this?" question on the owner's phone).
|
|
18
|
+
//
|
|
19
|
+
// "ALLOW FOR THIS SESSION" OUTLIVES THE PROCESS HERE. Codex's own `acceptForSession` is held by
|
|
20
|
+
// the app-server process, which ends with the turn — so the next turn would ask again. The runner
|
|
21
|
+
// therefore keeps its own allow-list per session: an exact command in an exact folder, or an exact
|
|
22
|
+
// set of file paths (codex-approvals.ts `sessionAllowKey`), answered `accept` without a card in
|
|
23
|
+
// later turns (and logged). It is forgotten on `/new`, on End, on a fresh start, and when the
|
|
24
|
+
// relay restarts — never written to disk, so it can never outlive the session it was given for.
|
|
25
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
26
|
+
if (k2 === undefined) k2 = k;
|
|
27
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
28
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
29
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
30
|
+
}
|
|
31
|
+
Object.defineProperty(o, k2, desc);
|
|
32
|
+
}) : (function(o, m, k, k2) {
|
|
33
|
+
if (k2 === undefined) k2 = k;
|
|
34
|
+
o[k2] = m[k];
|
|
35
|
+
}));
|
|
36
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
37
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
38
|
+
}) : function(o, v) {
|
|
39
|
+
o["default"] = v;
|
|
40
|
+
});
|
|
41
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
42
|
+
var ownKeys = function(o) {
|
|
43
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
44
|
+
var ar = [];
|
|
45
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
46
|
+
return ar;
|
|
47
|
+
};
|
|
48
|
+
return ownKeys(o);
|
|
49
|
+
};
|
|
50
|
+
return function (mod) {
|
|
51
|
+
if (mod && mod.__esModule) return mod;
|
|
52
|
+
var result = {};
|
|
53
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
54
|
+
__setModuleDefault(result, mod);
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
})();
|
|
58
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
59
|
+
exports.CodexSessionRunner = exports.DEFAULT_CODEX_MCP_ENTRY = void 0;
|
|
60
|
+
exports.codexQueuePath = codexQueuePath;
|
|
61
|
+
exports.codexPowerDescription = codexPowerDescription;
|
|
62
|
+
exports.codexTurnPolicyFor = codexTurnPolicyFor;
|
|
63
|
+
exports.codexApprovalHandler = codexApprovalHandler;
|
|
64
|
+
exports.codexTurnQueue = codexTurnQueue;
|
|
65
|
+
const os = __importStar(require("os"));
|
|
66
|
+
const path = __importStar(require("path"));
|
|
67
|
+
const child_process_1 = require("child_process");
|
|
68
|
+
const config_1 = require("../config");
|
|
69
|
+
const approval_1 = require("./acp/approval");
|
|
70
|
+
const presence_1 = require("./acp/presence");
|
|
71
|
+
const prompt_1 = require("./acp/prompt");
|
|
72
|
+
const turn_queue_1 = require("./acp/turn-queue");
|
|
73
|
+
const codex_app_server_1 = require("./codex-app-server");
|
|
74
|
+
const codex_approvals_1 = require("./codex-approvals");
|
|
75
|
+
const codex_allowances_1 = require("./codex-allowances");
|
|
76
|
+
const codex_log_1 = require("./codex-log");
|
|
77
|
+
const codex_launch_1 = require("./launcher/codex-launch");
|
|
78
|
+
const fresh_start_1 = require("./fresh-start");
|
|
79
|
+
/** Where a relay-run Codex session's waiting messages are kept (its own file, beside ACP's). */
|
|
80
|
+
function codexQueuePath() {
|
|
81
|
+
return path.join((0, config_1.configDir)(), "codex-turn-queue.json");
|
|
82
|
+
}
|
|
83
|
+
/** The BayChat MCP entry Codex knows BayChat under (`[mcp_servers.baychat]`). */
|
|
84
|
+
exports.DEFAULT_CODEX_MCP_ENTRY = "baychat";
|
|
85
|
+
/** The allow-list key of a thread that has no id yet (its first turn starts it). */
|
|
86
|
+
const NEW_THREAD = "(new thread)";
|
|
87
|
+
const errText = (err) => (err instanceof Error ? err.message : String(err));
|
|
88
|
+
/** What the setup text tells Codex it may do, for the policy this turn runs under. */
|
|
89
|
+
function codexPowerDescription(policy) {
|
|
90
|
+
return policy.approvalPolicy === "on-request"
|
|
91
|
+
? "you may edit files in this folder; there is no network. Anything else — a command outside the sandbox, network access, files elsewhere — is asked of the session's owner on their phone, so ask only when you need it."
|
|
92
|
+
: "read only. You may read files here and answer in the chat; nothing you run may change anything, and nobody will be asked to allow more.";
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* How much ONE turn may do, from who woke it: every addressed message from someone with command
|
|
96
|
+
* access → it asks (`askingPolicy`); anyone else among them → read only, never asks.
|
|
97
|
+
*/
|
|
98
|
+
function codexTurnPolicyFor(addressed, entry,
|
|
99
|
+
/** Is `entry` a server in Codex's config? Only then may a read-only turn set its approval mode. */
|
|
100
|
+
entryConfigured = (e) => (0, codex_launch_1.codexConfigHasEntry)((0, codex_launch_1.readCodexConfig)(), e)) {
|
|
101
|
+
const allAuthorized = addressed.length > 0 && addressed.every((m) => m.senderIsOwner === true || m.commandAuthorized === true);
|
|
102
|
+
if (allAuthorized)
|
|
103
|
+
return { allAuthorized, policy: (0, codex_app_server_1.askingPolicy)() };
|
|
104
|
+
return { allAuthorized, policy: (0, codex_app_server_1.readOnlyPolicy)(entryConfigured(entry) ? entry : undefined) };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* `onApproval` for a Codex turn (codex-app-server.ts): BayChat's own tools pass, other
|
|
108
|
+
* elicitations are declined unseen, a turn woken by someone without command access never asks,
|
|
109
|
+
* an exact request allowed for the session passes, and everything else is a card on the phone.
|
|
110
|
+
*/
|
|
111
|
+
function codexApprovalHandler(c) {
|
|
112
|
+
const tag = `codex: ${c.session}`;
|
|
113
|
+
return async (req, signal, hooks) => {
|
|
114
|
+
if (req.method === "mcpServer/elicitation/request") {
|
|
115
|
+
// BayChat's own tools are the session's voice in the room: no card. Anything else that
|
|
116
|
+
// asks through an elicitation (a form, another server) is declined, never shown.
|
|
117
|
+
if ((0, codex_approvals_1.isBayChatMcpApproval)(req.params, c.entry)) {
|
|
118
|
+
c.log(`${tag} BayChat tool call allowed without asking`);
|
|
119
|
+
return (0, codex_approvals_1.decisionFor)(req, "once");
|
|
120
|
+
}
|
|
121
|
+
c.log(`${tag} ${(0, codex_approvals_1.describeForLog)(req)} — declined (not asked)`);
|
|
122
|
+
return (0, codex_approvals_1.decisionFor)(req, null);
|
|
123
|
+
}
|
|
124
|
+
if (!c.allAuthorized) {
|
|
125
|
+
// Unreachable under `never`, and refused if Codex ever asks anyway.
|
|
126
|
+
c.log(`${tag} ${(0, codex_approvals_1.describeForLog)(req)} — refused (woken by someone without command access)`);
|
|
127
|
+
return (0, codex_approvals_1.decisionFor)(req, null);
|
|
128
|
+
}
|
|
129
|
+
const key = (0, codex_approvals_1.sessionAllowKey)(req, c.cwd);
|
|
130
|
+
if (key && c.allow.has(key)) {
|
|
131
|
+
c.log(`${tag} ${(0, codex_approvals_1.describeForLog)(req)} — allowed for this session earlier, not asked again`);
|
|
132
|
+
return (0, codex_approvals_1.decisionFor)(req, "once");
|
|
133
|
+
}
|
|
134
|
+
const plan = (0, codex_approvals_1.planCodexCard)(req, { host: c.host, session: c.session });
|
|
135
|
+
if (plan.kind === "refuse") {
|
|
136
|
+
c.log(`${tag} ${(0, codex_approvals_1.describeForLog)(req)} — refused without asking: ${plan.reason}`);
|
|
137
|
+
return (0, codex_approvals_1.decisionFor)(req, null);
|
|
138
|
+
}
|
|
139
|
+
const { card } = plan;
|
|
140
|
+
hooks?.personAsked();
|
|
141
|
+
c.typing?.()?.pause(); // the card is the signal while a person decides
|
|
142
|
+
c.log(`${tag} card asked — ${(0, codex_approvals_1.describeForLog)(req)}`);
|
|
143
|
+
const answer = await (c.ask ?? approval_1.askPhoneChoice)({
|
|
144
|
+
auth: c.auth,
|
|
145
|
+
session: c.session,
|
|
146
|
+
conversationId: c.room,
|
|
147
|
+
// The private chat between the session and its owner, never a group room: the card can show
|
|
148
|
+
// a command, paths and file contents, and only the owner may answer it anyway.
|
|
149
|
+
direct: true,
|
|
150
|
+
question: card.question,
|
|
151
|
+
options: card.labels,
|
|
152
|
+
guardedOptions: card.guardedOptions,
|
|
153
|
+
waitSeconds: codex_approvals_1.CODEX_CARD_WAIT_SECONDS,
|
|
154
|
+
signal,
|
|
155
|
+
});
|
|
156
|
+
c.typing?.()?.resume();
|
|
157
|
+
const choice = (0, codex_approvals_1.choiceAt)(card, answer.index);
|
|
158
|
+
if (choice === "session" && key && card.rememberable)
|
|
159
|
+
c.allow.add(key);
|
|
160
|
+
c.log(`${tag} card ${choice === null ? "not answered" : `answered "${card.labels[answer.index]}"`} — ${answer.reason}`);
|
|
161
|
+
return (0, codex_approvals_1.decisionFor)(req, choice);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Runs relay-run Codex sessions' turns ONE AT A TIME per session, in arrival order. `handle` never
|
|
166
|
+
* blocks: it files the batch and makes sure a drain loop is running, so `/stop` during a long turn
|
|
167
|
+
* is processed at once.
|
|
168
|
+
*/
|
|
169
|
+
class CodexSessionRunner {
|
|
170
|
+
deps;
|
|
171
|
+
running = new Map();
|
|
172
|
+
loops = new Map();
|
|
173
|
+
said = new Map();
|
|
174
|
+
/** "Allow for this session" answers, per session. Memory only — see the file header. */
|
|
175
|
+
allowances = new codex_allowances_1.CodexAllowances();
|
|
176
|
+
/** Bumped by every reset: a turn records its thread only if no reset happened while it ran. */
|
|
177
|
+
generation = new Map();
|
|
178
|
+
/** A `/compact` asked for, by session → the room to answer in. Run between turns. */
|
|
179
|
+
compacts = new Map();
|
|
180
|
+
/** A fresh start's first prompt, run as the session's next turn, alone. */
|
|
181
|
+
freshTurns = new Map();
|
|
182
|
+
/** Sessions whose running turn a fresh start stopped: its messages wait, they are not lost. */
|
|
183
|
+
requeueOnStop = new Map();
|
|
184
|
+
logs = new Map();
|
|
185
|
+
closed = false;
|
|
186
|
+
constructor(deps) {
|
|
187
|
+
this.deps = deps;
|
|
188
|
+
}
|
|
189
|
+
handle(session, batch) {
|
|
190
|
+
const { dropped } = this.deps.queue.add(session, batch);
|
|
191
|
+
if (dropped > 0)
|
|
192
|
+
this.deps.log(`codex: ${session} has too many messages waiting — ${dropped} not queued`);
|
|
193
|
+
this.kick(session);
|
|
194
|
+
}
|
|
195
|
+
/** `/stop`: interrupt the running turn. False when none is running. */
|
|
196
|
+
stop(session) {
|
|
197
|
+
const turn = this.running.get(session);
|
|
198
|
+
if (!turn)
|
|
199
|
+
return false;
|
|
200
|
+
turn.abort();
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/** `/new`: the next turn starts a new thread, and nothing allowed before carries over. */
|
|
204
|
+
reset(session) {
|
|
205
|
+
// A turn that finishes after this must not write its (old) thread back.
|
|
206
|
+
this.generation.set(session, (this.generation.get(session) ?? 0) + 1);
|
|
207
|
+
this.deps.registry.setCodexThread(session, undefined);
|
|
208
|
+
this.allowances.forget(session);
|
|
209
|
+
}
|
|
210
|
+
/** End: stop what runs and forget everything this runner held for the session. */
|
|
211
|
+
end(session) {
|
|
212
|
+
this.stop(session);
|
|
213
|
+
this.allowances.forget(session);
|
|
214
|
+
this.compacts.delete(session);
|
|
215
|
+
this.freshTurns.delete(session);
|
|
216
|
+
this.deps.queue.clear(session);
|
|
217
|
+
}
|
|
218
|
+
/** The session's "Allow for this session" answers — shared with the headless rung (daemon.ts). */
|
|
219
|
+
allowListFor(session, thread) {
|
|
220
|
+
return this.allowances.for(session, thread);
|
|
221
|
+
}
|
|
222
|
+
/** Forget every "Allow for this session" answer the session holds (the daemon calls this whenever
|
|
223
|
+
* it forgets or re-registers a session). */
|
|
224
|
+
forgetAllowances(session) {
|
|
225
|
+
this.allowances.forget(session);
|
|
226
|
+
}
|
|
227
|
+
/** A notification from a turn: a completed file change under a remembered command's folder
|
|
228
|
+
* drops that command. Returns the number dropped. */
|
|
229
|
+
noteNotification(session, method, params) {
|
|
230
|
+
return this.allowances.noteFileChanges(session, (0, codex_allowances_1.completedFileChangePaths)(method, params));
|
|
231
|
+
}
|
|
232
|
+
/** How many "Allow for this session" answers the session holds (for `/status`). */
|
|
233
|
+
allowedCount(session) {
|
|
234
|
+
return this.allowances.count(session);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* `/compact`: tidy the thread's memory, BETWEEN turns — one asked for during a turn runs when it
|
|
238
|
+
* ends. The answer is said in `conversationId`. False when there is nothing to compact.
|
|
239
|
+
*/
|
|
240
|
+
compact(session, conversationId) {
|
|
241
|
+
if (!this.deps.registry.get(session)?.codex?.threadId)
|
|
242
|
+
return false;
|
|
243
|
+
this.compacts.set(session, conversationId);
|
|
244
|
+
this.kick(session);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
/** A fresh start (fresh-start.ts): a new thread, with the handoff note as its first prompt. */
|
|
248
|
+
freshStart(session, first, handoffId) {
|
|
249
|
+
this.deps.queue.discard(session, (m) => (0, fresh_start_1.isNoteRequestFor)(m, handoffId));
|
|
250
|
+
this.freshTurns.set(session, first);
|
|
251
|
+
this.compacts.delete(session);
|
|
252
|
+
const running = this.running.get(session);
|
|
253
|
+
if (running) {
|
|
254
|
+
this.requeueOnStop.set(session, handoffId);
|
|
255
|
+
running.abort();
|
|
256
|
+
}
|
|
257
|
+
this.reset(session);
|
|
258
|
+
this.kick(session);
|
|
259
|
+
}
|
|
260
|
+
status(session) {
|
|
261
|
+
return { running: this.running.has(session), queued: this.deps.queue.depth(session) };
|
|
262
|
+
}
|
|
263
|
+
activeSessions() {
|
|
264
|
+
return [...this.loops.keys()];
|
|
265
|
+
}
|
|
266
|
+
async idle() {
|
|
267
|
+
while (this.loops.size > 0)
|
|
268
|
+
await Promise.all([...this.loops.values()]);
|
|
269
|
+
}
|
|
270
|
+
shutdown() {
|
|
271
|
+
this.closed = true;
|
|
272
|
+
for (const turn of this.running.values())
|
|
273
|
+
turn.abort();
|
|
274
|
+
}
|
|
275
|
+
kick(session) {
|
|
276
|
+
if (this.closed || this.loops.has(session))
|
|
277
|
+
return;
|
|
278
|
+
const loop = this.drain(session)
|
|
279
|
+
.catch((err) => {
|
|
280
|
+
this.deps.log(`codex: ${session} drain failed — ${errText(err)}`);
|
|
281
|
+
})
|
|
282
|
+
.then(() => {
|
|
283
|
+
this.loops.delete(session);
|
|
284
|
+
// A `handle` that landed after the drain saw an empty queue found this loop still
|
|
285
|
+
// registered and did not start one; pick its batch up now.
|
|
286
|
+
if (!this.closed && (this.deps.queue.depth(session) > 0 || this.compacts.has(session) || this.freshTurns.has(session)))
|
|
287
|
+
this.kick(session);
|
|
288
|
+
});
|
|
289
|
+
this.loops.set(session, loop);
|
|
290
|
+
}
|
|
291
|
+
async drain(session) {
|
|
292
|
+
for (;;) {
|
|
293
|
+
if (this.closed)
|
|
294
|
+
return;
|
|
295
|
+
const room = this.compacts.get(session);
|
|
296
|
+
if (room !== undefined) {
|
|
297
|
+
this.compacts.delete(session);
|
|
298
|
+
await this.runCompact(session, room);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const first = this.freshTurns.get(session);
|
|
302
|
+
if (first)
|
|
303
|
+
this.freshTurns.delete(session);
|
|
304
|
+
const turn = first ? { addressed: [first], digest: { messages: [], omitted: 0 } } : this.deps.queue.take(session);
|
|
305
|
+
if (!turn)
|
|
306
|
+
return;
|
|
307
|
+
try {
|
|
308
|
+
await this.runOne(session, turn);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
// A bug or a disk error, not an outcome. Recorded pending, never silently lost.
|
|
312
|
+
this.deps.log(`codex: ${session} turn failed unexpectedly — ${errText(err)}`);
|
|
313
|
+
this.deps.onPending(session, turn.addressed, "the relay failed while running this turn");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** The session's log, made once. */
|
|
318
|
+
logFor(session) {
|
|
319
|
+
if (!this.logs.has(session))
|
|
320
|
+
this.logs.set(session, this.deps.sessionLog ? this.deps.sessionLog(session) : new codex_log_1.CodexSessionLog(session));
|
|
321
|
+
return this.logs.get(session);
|
|
322
|
+
}
|
|
323
|
+
async sayOnce(session, key, conversationId, text) {
|
|
324
|
+
const seen = this.said.get(session) ?? new Set();
|
|
325
|
+
if (seen.has(key))
|
|
326
|
+
return;
|
|
327
|
+
seen.add(key);
|
|
328
|
+
this.said.set(session, seen);
|
|
329
|
+
await this.tell(session, conversationId, text);
|
|
330
|
+
}
|
|
331
|
+
async tell(session, conversationId, text) {
|
|
332
|
+
try {
|
|
333
|
+
await this.deps.say(conversationId, session, text);
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
this.deps.log(`codex: could not tell the room about ${session} — ${errText(err)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async runCompact(session, room) {
|
|
340
|
+
const { deps } = this;
|
|
341
|
+
const codex = deps.registry.get(session)?.codex;
|
|
342
|
+
const bin = deps.binary();
|
|
343
|
+
if (!codex?.threadId || !bin.ok) {
|
|
344
|
+
await this.tell(session, room, `"${session}" has nothing to compact yet.`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
deps.log(`codex: ${session} compacting thread ${codex.threadId}`);
|
|
348
|
+
const slog = this.logFor(session);
|
|
349
|
+
slog?.line("── /compact");
|
|
350
|
+
// Registered as the running work, so /stop, End and shutdown end a compaction too.
|
|
351
|
+
const abort = new AbortController();
|
|
352
|
+
this.running.set(session, abort);
|
|
353
|
+
let outcome;
|
|
354
|
+
try {
|
|
355
|
+
outcome = await (deps.runCompact ?? codex_app_server_1.runCodexCompact)({ binaryPath: bin.path, threadId: codex.threadId, cwd: codex.cwd, policy: (0, codex_app_server_1.readOnlyPolicy)(), onNotification: (method, params) => slog?.notification(method, params), signal: abort.signal }, { spawn: deps.spawn ?? child_process_1.spawn });
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
this.running.delete(session);
|
|
359
|
+
}
|
|
360
|
+
if (outcome.kind === "failed" && outcome.stopped) {
|
|
361
|
+
slog?.line("── compaction stopped");
|
|
362
|
+
if (!this.closed)
|
|
363
|
+
await this.tell(session, room, `Stopped compacting "${session}".`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
slog?.line(outcome.kind === "completed" ? "── compacted" : `── compaction failed: ${outcome.reason}`);
|
|
367
|
+
if (outcome.kind === "completed") {
|
|
368
|
+
deps.log(`codex: ${session} compacted`);
|
|
369
|
+
await this.tell(session, room, `"${session}" compacted its memory.`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
deps.log(`codex: ${session} could not compact — ${outcome.reason}`);
|
|
373
|
+
await this.tell(session, room, `"${session}" could not compact its memory this time. The details are in the relay's log on that computer.`);
|
|
374
|
+
}
|
|
375
|
+
async runOne(session, turn, retried = false) {
|
|
376
|
+
const { deps } = this;
|
|
377
|
+
const room = turn.addressed.at(-1).conversationId;
|
|
378
|
+
const drop = async (key, reason) => {
|
|
379
|
+
deps.onPending(session, turn.addressed, reason);
|
|
380
|
+
await this.sayOnce(session, key, room, reason);
|
|
381
|
+
};
|
|
382
|
+
const target = deps.registry.get(session);
|
|
383
|
+
const codex = target?.codex;
|
|
384
|
+
if (!target || !codex)
|
|
385
|
+
return drop("setup", `"${session}" is not set up on that computer any more. Start it again from BayChat.`);
|
|
386
|
+
const auth = deps.auth();
|
|
387
|
+
if (!auth)
|
|
388
|
+
return drop("setup", "The relay on that computer is not logged in to BayChat. Run `baychat login` there.");
|
|
389
|
+
const bin = deps.binary();
|
|
390
|
+
if (!bin.ok)
|
|
391
|
+
return drop("spawn", `Codex is not installed where the relay can find it (${bin.reason}).`);
|
|
392
|
+
// Every turn: the recorded binary may have been replaced, or downgraded, since the launch.
|
|
393
|
+
const ready = await deps.ready?.(bin.path);
|
|
394
|
+
if (ready && !ready.ok)
|
|
395
|
+
return drop("spawn", ready.reason);
|
|
396
|
+
const entry = deps.mcpEntry ?? exports.DEFAULT_CODEX_MCP_ENTRY;
|
|
397
|
+
const { allAuthorized, policy } = codexTurnPolicyFor(turn.addressed, entry, deps.entryConfigured);
|
|
398
|
+
const fresh = !codex.threadId || !codex.primed;
|
|
399
|
+
const prompt = `${fresh ? `${(0, prompt_1.setupPreamble)(session, codexPowerDescription(policy))}\n\n` : ""}${(0, prompt_1.turnPrompt)(turn)}`;
|
|
400
|
+
const caller = (deps.caller ?? presence_1.deviceMcpCaller)(auth);
|
|
401
|
+
const host = (deps.hostname ?? os.hostname)();
|
|
402
|
+
const thread = codex.threadId ?? NEW_THREAD;
|
|
403
|
+
const generation = this.generation.get(session) ?? 0;
|
|
404
|
+
const allow = this.allowListFor(session, thread);
|
|
405
|
+
const slog = this.logFor(session);
|
|
406
|
+
const prefix = `codex: ${session} `;
|
|
407
|
+
const both = (line) => {
|
|
408
|
+
deps.log(line);
|
|
409
|
+
slog?.line(line.startsWith(prefix) ? line.slice(prefix.length) : line);
|
|
410
|
+
};
|
|
411
|
+
let typing;
|
|
412
|
+
const onApproval = codexApprovalHandler({
|
|
413
|
+
auth,
|
|
414
|
+
session,
|
|
415
|
+
room,
|
|
416
|
+
allAuthorized,
|
|
417
|
+
entry,
|
|
418
|
+
host,
|
|
419
|
+
allow,
|
|
420
|
+
cwd: codex.cwd,
|
|
421
|
+
log: both,
|
|
422
|
+
ask: deps.askPhoneChoice,
|
|
423
|
+
typing: () => typing,
|
|
424
|
+
});
|
|
425
|
+
const abort = new AbortController();
|
|
426
|
+
let outcome;
|
|
427
|
+
try {
|
|
428
|
+
this.running.set(session, abort);
|
|
429
|
+
typing = (0, presence_1.startTyping)(caller, session, room, { log: deps.log });
|
|
430
|
+
slog?.turnStart(allAuthorized ? "people with command access" : "someone without command access", turn.addressed.length, policy.approvalPolicy === "on-request" ? "asks the owner, no network" : "read only, no cards");
|
|
431
|
+
deps.log(`codex: ${session} turn — ${turn.addressed.length} addressed, ${turn.digest.messages.length} context, ` +
|
|
432
|
+
`${policy.approvalPolicy === "on-request" ? "asks the owner" : "read only (not all from someone with command access)"}` +
|
|
433
|
+
`${codex.threadId ? "" : ", new thread"}`);
|
|
434
|
+
outcome = await (deps.runTurn ?? codex_app_server_1.runCodexTurn)({
|
|
435
|
+
binaryPath: bin.path,
|
|
436
|
+
...(codex.threadId ? { threadId: codex.threadId } : {}),
|
|
437
|
+
prompt,
|
|
438
|
+
cwd: codex.cwd,
|
|
439
|
+
policy,
|
|
440
|
+
onApproval,
|
|
441
|
+
signal: abort.signal,
|
|
442
|
+
log: (line) => both(`${prefix}${line}`),
|
|
443
|
+
onNotification: (method, params) => {
|
|
444
|
+
slog?.notification(method, params);
|
|
445
|
+
const dropped = this.noteNotification(session, method, params);
|
|
446
|
+
if (dropped > 0)
|
|
447
|
+
both(`${prefix}files changed under ${dropped} command(s) allowed for this session — they will be asked again`);
|
|
448
|
+
},
|
|
449
|
+
}, { spawn: deps.spawn ?? child_process_1.spawn });
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
typing?.stop();
|
|
453
|
+
this.running.delete(session);
|
|
454
|
+
}
|
|
455
|
+
slog?.turnEnd(outcome.kind === "completed" ? "completed" : outcome.stopped ? "stopped" : `failed — ${outcome.reason}`);
|
|
456
|
+
// A fresh start stopped this turn: its messages wait behind the note (minus the note request).
|
|
457
|
+
const requeueFor = this.requeueOnStop.get(session);
|
|
458
|
+
if (requeueFor !== undefined) {
|
|
459
|
+
this.requeueOnStop.delete(session);
|
|
460
|
+
const keep = (m) => !(0, fresh_start_1.isNoteRequestFor)(m, requeueFor);
|
|
461
|
+
deps.queue.putBack(session, {
|
|
462
|
+
addressed: turn.addressed.filter(keep),
|
|
463
|
+
digest: { messages: turn.digest.messages.filter(keep), omitted: turn.digest.omitted },
|
|
464
|
+
});
|
|
465
|
+
deps.log(`codex: ${session} turn stopped for a fresh start — its messages wait for the fresh thread`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (outcome.kind === "completed") {
|
|
469
|
+
// A fresh start that landed while this turn ran has already forgotten the old thread.
|
|
470
|
+
if (!this.freshTurns.has(session) && (this.generation.get(session) ?? 0) === generation) {
|
|
471
|
+
if (outcome.threadId)
|
|
472
|
+
this.allowances.rebind(session, NEW_THREAD, outcome.threadId);
|
|
473
|
+
deps.registry.setCodexThread(session, outcome.threadId ?? codex.threadId, true);
|
|
474
|
+
}
|
|
475
|
+
deps.onDelivered(session, turn.addressed);
|
|
476
|
+
this.said.delete(session);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (outcome.stopped) {
|
|
480
|
+
// Stopped by `shutdown`, not by a person: the batch is still owed an answer after restart.
|
|
481
|
+
if (this.closed) {
|
|
482
|
+
deps.queue.putBack(session, turn);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
deps.onPending(session, turn.addressed, "stopped from the chat");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (outcome.threadMissing && !codex.primed && !retried) {
|
|
489
|
+
// A thread started for this session that Codex never wrote down (no turn completed on it):
|
|
490
|
+
// start a new one rather than leave the session unable to answer.
|
|
491
|
+
deps.log(`codex: ${session} thread ${codex.threadId} is gone before its first turn — starting a new one`);
|
|
492
|
+
deps.registry.setCodexThread(session, undefined);
|
|
493
|
+
return this.runOne(session, turn, true);
|
|
494
|
+
}
|
|
495
|
+
// The raw reason can name paths on that computer: the room (and the pending record, which
|
|
496
|
+
// BayChat may show) get a plain line, and the full reason goes to the log only.
|
|
497
|
+
deps.log(`codex: ${session} could not answer — ${outcome.reason}`);
|
|
498
|
+
return drop("failed", "Codex could not answer this time. The details are in the relay's log on that computer.");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
exports.CodexSessionRunner = CodexSessionRunner;
|
|
502
|
+
/** The queue a daemon gives its Codex runner. */
|
|
503
|
+
function codexTurnQueue() {
|
|
504
|
+
return new turn_queue_1.AcpTurnQueue(codexQueuePath());
|
|
505
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CODEX_UPGRADE_HINT = exports.MIN_CODEX_APP_SERVER = void 0;
|
|
4
|
+
exports.codexExecFallback = codexExecFallback;
|
|
5
|
+
exports.codexVersionOf = codexVersionOf;
|
|
6
|
+
exports.codexAppServerReady = codexAppServerReady;
|
|
7
|
+
const runtime_binary_1 = require("../runtime-binary");
|
|
8
|
+
const self_upgrade_1 = require("../self-upgrade");
|
|
9
|
+
const spawn_env_1 = require("./spawn-env");
|
|
10
|
+
/**
|
|
11
|
+
* The oldest Codex the relay runs over `codex app-server` with approvals as cards (2026-10-23).
|
|
12
|
+
*
|
|
13
|
+
* The approval requests, `serverRequest/resolved`, `turn.status` and `thread/compact/start` this
|
|
14
|
+
* relay speaks are the official 0.160 schema's; app-server is marked [experimental] by OpenAI, so
|
|
15
|
+
* older builds are not guessed at. Checked at launch and before every turn — the recorded path may
|
|
16
|
+
* have been replaced or upgraded in place since — never assumed.
|
|
17
|
+
*/
|
|
18
|
+
exports.MIN_CODEX_APP_SERVER = "0.160.0";
|
|
19
|
+
/** The upgrade line every refusal ends with. */
|
|
20
|
+
exports.CODEX_UPGRADE_HINT = "Update it with `npm install -g @openai/codex@latest` (or your package manager), then try again.";
|
|
21
|
+
/** `BAYCHAT_CODEX_TRANSPORT=exec`: the way back to the pre-app-server ladder, with no new release. */
|
|
22
|
+
function codexExecFallback(env = process.env) {
|
|
23
|
+
return env.BAYCHAT_CODEX_TRANSPORT === "exec";
|
|
24
|
+
}
|
|
25
|
+
/** "codex-cli 0.160.0" → "0.160.0"; a pre-release keeps its numeric part. */
|
|
26
|
+
function codexVersionOf(output) {
|
|
27
|
+
return /(\d+\.\d+\.\d+)/.exec(output)?.[1];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Is the Codex at `binaryPath` new enough for app-server approvals? Never throws: a binary that
|
|
31
|
+
* will not answer `--version`, or answers something unreadable, is not ready.
|
|
32
|
+
*/
|
|
33
|
+
async function codexAppServerReady(binaryPath, probe = (bin) => (0, runtime_binary_1.probeBinaryVersion)(bin, (0, spawn_env_1.headlessSpawnEnv)())) {
|
|
34
|
+
let probed;
|
|
35
|
+
try {
|
|
36
|
+
probed = await probe(binaryPath);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return { ok: false, reason: `${binaryPath} could not be asked its version (${err instanceof Error ? err.message : String(err)}). ${exports.CODEX_UPGRADE_HINT}` };
|
|
40
|
+
}
|
|
41
|
+
if (!probed.ok)
|
|
42
|
+
return { ok: false, reason: `${binaryPath} did not answer \`--version\` (${probed.detail}). ${exports.CODEX_UPGRADE_HINT}` };
|
|
43
|
+
const version = codexVersionOf(probed.version);
|
|
44
|
+
const order = version ? (0, self_upgrade_1.compareVersions)(version, exports.MIN_CODEX_APP_SERVER) : undefined;
|
|
45
|
+
if (!version || order === undefined)
|
|
46
|
+
return { ok: false, reason: `Codex answered \`--version\` with "${probed.version}", which this relay cannot read. ${exports.CODEX_UPGRADE_HINT}` };
|
|
47
|
+
if (order < 0)
|
|
48
|
+
return { ok: false, reason: `Codex ${version} is older than ${exports.MIN_CODEX_APP_SERVER}, which BayChat needs to bring its approvals to your phone. ${exports.CODEX_UPGRADE_HINT}` };
|
|
49
|
+
return { ok: true, version };
|
|
50
|
+
}
|