baychat 0.34.0 → 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 +44 -3
- package/dist/claude-context-flush.js +31 -8
- package/dist/claude-context.js +19 -5
- package/dist/claude-onboarding.js +23 -10
- package/dist/cli-help.js +3 -0
- package/dist/client-paths.js +6 -1
- package/dist/client-scope.js +34 -0
- package/dist/codex-onboarding.js +2 -2
- 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/runtimes.js +4 -3
- package/dist/session-command.js +7 -4
- package/dist/skill-bootstrap.js +2 -0
- package/dist/tool-defs.js +30 -0
- package/package.json +1 -1
|
@@ -14,6 +14,7 @@ const argv_1 = require("./argv");
|
|
|
14
14
|
const approval_settings_1 = require("./approval-settings");
|
|
15
15
|
const capabilities_1 = require("./capabilities");
|
|
16
16
|
const codex_launch_1 = require("./codex-launch");
|
|
17
|
+
const codex_version_1 = require("../codex-version");
|
|
17
18
|
const dev_channel_screen_1 = require("./dev-channel-screen");
|
|
18
19
|
const stop_registered_1 = require("./stop-registered");
|
|
19
20
|
const multiplexer_1 = require("./multiplexer");
|
|
@@ -40,6 +41,15 @@ async function handleLaunch(launch, deps, io) {
|
|
|
40
41
|
return stopSession(launch, deps);
|
|
41
42
|
if ((0, acp_launch_1.isAcpLaunchRuntime)(launch.runtime))
|
|
42
43
|
return startAcp(launch, deps);
|
|
44
|
+
if (launch.runtime === "codex" && deps.codex && (0, codex_version_1.codexExecFallback)() && inDefaultBay(launch, deps)) {
|
|
45
|
+
// BAYCHAT_CODEX_TRANSPORT=exec: its turns run `--ask-for-approval never`, which also blocks
|
|
46
|
+
// Codex's own BayChat replies — a session started from the phone would never answer. Refused
|
|
47
|
+
// instead of started silent (2026-10-23); a Codex at this keyboard works as before.
|
|
48
|
+
deps.log(`launch ${launch.id}: refused, Codex is in fallback mode here (BAYCHAT_CODEX_TRANSPORT=exec) and could not answer in BayChat`);
|
|
49
|
+
return refused("RUNTIME_FALLBACK_MODE");
|
|
50
|
+
}
|
|
51
|
+
if (launch.runtime === "codex" && relayRunsCodex(launch, deps))
|
|
52
|
+
return startCodexRelay(launch, deps);
|
|
43
53
|
const started = startSession(launch, deps);
|
|
44
54
|
if ("status" in started)
|
|
45
55
|
return started;
|
|
@@ -64,6 +74,77 @@ function spotFor(launch, deps) {
|
|
|
64
74
|
}
|
|
65
75
|
return { spot, folder: folder.path };
|
|
66
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Sessions the relay runs itself (dsh, Cursor, Codex over app-server) and the terminal sessions
|
|
79
|
+
* the launcher sees, for the caps — a relay-run Codex's log viewer is a `bc-<name>` tmux session
|
|
80
|
+
* too, and must not be counted twice.
|
|
81
|
+
*/
|
|
82
|
+
function sessionsForCaps(live, deps) {
|
|
83
|
+
const relayRun = [...(deps.acp?.running() ?? []), ...(deps.codex?.running() ?? [])];
|
|
84
|
+
const names = new Set(relayRun.map((a) => a.name));
|
|
85
|
+
return { terminals: live.filter((name) => !names.has(name)), relayRun };
|
|
86
|
+
}
|
|
87
|
+
/** A Codex launch is run by the relay when the daemon offers that, and the launch is in its default Bay. */
|
|
88
|
+
function relayRunsCodex(launch, deps) {
|
|
89
|
+
if (!deps.codex || (0, codex_version_1.codexExecFallback)())
|
|
90
|
+
return false;
|
|
91
|
+
return inDefaultBay(launch, deps);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The relay runs, keeps alive and asks cards for its sessions with its DEFAULT Bay's login; a
|
|
95
|
+
* Codex launched into another Bay keeps the terminal launch, which joins with that Bay's entry.
|
|
96
|
+
*/
|
|
97
|
+
function inDefaultBay(launch, deps) {
|
|
98
|
+
const defaultBay = deps.loadBays().find((b) => b.isDefault);
|
|
99
|
+
return !launch.bay || !defaultBay || launch.bay.id === defaultBay.bayId;
|
|
100
|
+
}
|
|
101
|
+
/** The checks a Codex START makes, whoever runs it; undefined when it may start. */
|
|
102
|
+
function codexRefusal(launch, deps, spot, folder, entry) {
|
|
103
|
+
// Codex reads only its own config.toml: a relay on a setup of its own has no connection to
|
|
104
|
+
// give it there (a token in its argv would show in `ps`), so that case is not offered yet.
|
|
105
|
+
if (deps.ownServer) {
|
|
106
|
+
deps.log(`launch ${launch.id}: refused, Codex cannot use this relay's own connection yet`);
|
|
107
|
+
return refused("RUNTIME_NOT_YET");
|
|
108
|
+
}
|
|
109
|
+
if (!spot.unattendedCodex) {
|
|
110
|
+
deps.log(`launch ${launch.id}: refused, Codex may not start unattended in "${spot.label}". Run \`baychat launch add ${folder} --runtime ${spot.runtimes.join(",")} --unattended\` here to allow it.`);
|
|
111
|
+
return refused("UNATTENDED_NOT_ALLOWED");
|
|
112
|
+
}
|
|
113
|
+
if (launch.join && "group" in launch.join && !codex_launch_1.CODEX_ROOM_ID_RE.test(launch.join.group)) {
|
|
114
|
+
deps.log(`launch ${launch.id}: refused, Codex is started into an existing group only (by id)`);
|
|
115
|
+
return refused("ROOM_EXISTING_ONLY");
|
|
116
|
+
}
|
|
117
|
+
const problem = (0, codex_launch_1.codexLaunchProblem)(folder, () => deps.readCodexConfig?.() ?? null, entry ?? bays_1.DEFAULT_MCP_ENTRY);
|
|
118
|
+
if (problem) {
|
|
119
|
+
deps.log(`launch ${launch.id}: refused, ${problem === "RUNTIME_NOT_CONNECTED" ? "Codex has no BayChat connection here. Run `baychat connect codex`." : `Codex has not trusted ${folder}. Open Codex there once and trust it.`}`);
|
|
120
|
+
return refused(problem);
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
/** Codex run by the relay (codex-relay-launch.ts): the same checks and caps, no terminal of its own. */
|
|
125
|
+
async function startCodexRelay(launch, deps) {
|
|
126
|
+
const place = spotFor(launch, deps);
|
|
127
|
+
if ("status" in place)
|
|
128
|
+
return place;
|
|
129
|
+
const bay = launchBayEntry(launch.bay, deps.loadBays());
|
|
130
|
+
if (!bay) {
|
|
131
|
+
deps.log(`launch ${launch.id}: refused, this computer is not logged in to the Bay "${launch.bay?.name ?? ""}". Run \`baychat login\` here.`);
|
|
132
|
+
return refused("BAY_NOT_CONNECTED");
|
|
133
|
+
}
|
|
134
|
+
const problem = codexRefusal(launch, deps, place.spot, place.folder, bay.entry);
|
|
135
|
+
if (problem)
|
|
136
|
+
return problem;
|
|
137
|
+
const multiplexer = deps.detectMultiplexer();
|
|
138
|
+
const live = multiplexer ? (0, multiplexer_1.liveLaunchSessions)(multiplexer, deps.run) : [];
|
|
139
|
+
const { terminals, relayRun } = sessionsForCaps(live, deps);
|
|
140
|
+
const started = multiplexer ? pruneEnded((0, started_1.loadStarted)((0, started_1.startedRecordPath)(deps.launchDir)), live, deps) : [];
|
|
141
|
+
const inFolder = started.filter((s) => s.spotId === place.spot.id).length + relayRun.filter((a) => a.cwd === place.folder).length;
|
|
142
|
+
if (terminals.length + relayRun.length >= policy_1.MAX_SESSIONS_PER_COMPUTER || inFolder >= place.spot.maxSessions) {
|
|
143
|
+
deps.log(`launch ${launch.id}: refused, too many sessions running (${terminals.length + relayRun.length} here, the folder "${place.spot.label}" allows ${place.spot.maxSessions})`);
|
|
144
|
+
return refused("CAP_REACHED");
|
|
145
|
+
}
|
|
146
|
+
return deps.codex.start({ launchId: launch.id, session: launch.sessionName, folder: place.folder, join: launch.join });
|
|
147
|
+
}
|
|
67
148
|
/** A relay-run agent (dsh, Cursor): no terminal — the daemon registers it and runs it per turn. */
|
|
68
149
|
async function startAcp(launch, deps) {
|
|
69
150
|
const place = spotFor(launch, deps);
|
|
@@ -84,12 +165,12 @@ async function startAcp(launch, deps) {
|
|
|
84
165
|
// The same caps as a terminal session: per computer, and per folder.
|
|
85
166
|
const multiplexer = deps.detectMultiplexer();
|
|
86
167
|
const live = multiplexer ? (0, multiplexer_1.liveLaunchSessions)(multiplexer, deps.run) : [];
|
|
87
|
-
const agents = deps
|
|
168
|
+
const { terminals, relayRun: agents } = sessionsForCaps(live, deps);
|
|
88
169
|
// Ended terminal sessions are cleared first, as a terminal START does, so they never count.
|
|
89
170
|
const started = multiplexer ? pruneEnded((0, started_1.loadStarted)((0, started_1.startedRecordPath)(deps.launchDir)), live, deps) : [];
|
|
90
171
|
const inFolder = started.filter((s) => s.spotId === place.spot.id).length + agents.filter((a) => a.cwd === place.folder).length;
|
|
91
|
-
if (
|
|
92
|
-
deps.log(`launch ${launch.id}: refused, too many sessions running (${
|
|
172
|
+
if (terminals.length + agents.length >= policy_1.MAX_SESSIONS_PER_COMPUTER || inFolder >= place.spot.maxSessions) {
|
|
173
|
+
deps.log(`launch ${launch.id}: refused, too many sessions running (${terminals.length + agents.length} here, the folder "${place.spot.label}" allows ${place.spot.maxSessions})`);
|
|
93
174
|
return refused("CAP_REACHED");
|
|
94
175
|
}
|
|
95
176
|
return deps.acp.start({ launchId: launch.id, session: launch.sessionName, runtime: launch.runtime, folder: place.folder, join: launch.join });
|
|
@@ -110,25 +191,9 @@ function startSession(launch, deps) {
|
|
|
110
191
|
return refused("BAY_NOT_CONNECTED");
|
|
111
192
|
}
|
|
112
193
|
if (codex) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
deps.log(`launch ${launch.id}: refused, Codex cannot use this relay's own connection yet`);
|
|
117
|
-
return refused("RUNTIME_NOT_YET");
|
|
118
|
-
}
|
|
119
|
-
if (!spot.unattendedCodex) {
|
|
120
|
-
deps.log(`launch ${launch.id}: refused, Codex may not start unattended in "${spot.label}". Run \`baychat launch add ${folder.path} --runtime ${spot.runtimes.join(",")} --unattended\` here to allow it.`);
|
|
121
|
-
return refused("UNATTENDED_NOT_ALLOWED");
|
|
122
|
-
}
|
|
123
|
-
if (launch.join && "group" in launch.join && !codex_launch_1.CODEX_ROOM_ID_RE.test(launch.join.group)) {
|
|
124
|
-
deps.log(`launch ${launch.id}: refused, Codex is started into an existing group only (by id)`);
|
|
125
|
-
return refused("ROOM_EXISTING_ONLY");
|
|
126
|
-
}
|
|
127
|
-
const problem = (0, codex_launch_1.codexLaunchProblem)(folder.path, () => deps.readCodexConfig?.() ?? null, bay.entry ?? bays_1.DEFAULT_MCP_ENTRY);
|
|
128
|
-
if (problem) {
|
|
129
|
-
deps.log(`launch ${launch.id}: refused, ${problem === "RUNTIME_NOT_CONNECTED" ? "Codex has no BayChat connection here. Run `baychat connect codex`." : `Codex has not trusted ${folder.path}. Open Codex there once and trust it.`}`);
|
|
130
|
-
return refused(problem);
|
|
131
|
-
}
|
|
194
|
+
const problem = codexRefusal(launch, deps, spot, folder.path, bay.entry);
|
|
195
|
+
if (problem)
|
|
196
|
+
return problem;
|
|
132
197
|
}
|
|
133
198
|
else if (!deps.isFolderTrusted(folder.path)) {
|
|
134
199
|
// An untrusted folder would leave claude waiting at "Is this a project you trust?" forever,
|
|
@@ -153,9 +218,9 @@ function startSession(launch, deps) {
|
|
|
153
218
|
// code of its own and the app offers no Replace it (Mac QA 2026-10-16: a loop otherwise).
|
|
154
219
|
return refused(own ? "NAME_TAKEN" : "NAME_TAKEN_FOREIGN");
|
|
155
220
|
}
|
|
156
|
-
// Relay-run agents (dsh, Cursor) count too: the caps are per computer and per folder, whatever runs.
|
|
157
|
-
const agents = deps
|
|
158
|
-
if (
|
|
221
|
+
// Relay-run agents (dsh, Cursor, Codex) count too: the caps are per computer and per folder, whatever runs.
|
|
222
|
+
const { terminals, relayRun: agents } = sessionsForCaps(live, deps);
|
|
223
|
+
if (terminals.length + agents.length >= policy_1.MAX_SESSIONS_PER_COMPUTER ||
|
|
159
224
|
started.filter((s) => s.spotId === spot.id).length + agents.filter((a) => a.cwd === folder.path).length >= spot.maxSessions) {
|
|
160
225
|
deps.log(`launch ${launch.id}: refused, too many sessions running (${live.length} here, the folder "${spot.label}" allows ${spot.maxSessions})`);
|
|
161
226
|
return refused("CAP_REACHED");
|
|
@@ -336,11 +401,11 @@ async function stopSession(launch, deps) {
|
|
|
336
401
|
const entry = started.find((s) => s.name === launch.sessionName);
|
|
337
402
|
if (!entry) {
|
|
338
403
|
// A relay-run agent (dsh, Cursor) has no terminal: End is the relay no longer running it.
|
|
339
|
-
if (deps.acp?.stop(launch.sessionName)) {
|
|
404
|
+
if (deps.acp?.stop(launch.sessionName) || deps.codex?.stop(launch.sessionName)) {
|
|
340
405
|
deps.forgetSession?.(launch.sessionName);
|
|
341
406
|
return { status: "STARTED" };
|
|
342
407
|
}
|
|
343
|
-
return launch.scope === "any" ? await stopRegisteredSession(launch, deps) : refused("NOT_LAUNCHED");
|
|
408
|
+
return launch.scope === "any" ? await stopRegisteredSession(launch, deps, started) : refused("NOT_LAUNCHED");
|
|
344
409
|
}
|
|
345
410
|
const outcome = stopRecorded(entry, deps);
|
|
346
411
|
if (outcome === "not-running") {
|
|
@@ -354,12 +419,18 @@ async function stopSession(launch, deps) {
|
|
|
354
419
|
deps.log(`launch ${launch.id}: stopped "${entry.name}" for ${launch.requestedBy.name} — its ${entry.multiplexer} session is gone (checked)`);
|
|
355
420
|
return { status: "STARTED" };
|
|
356
421
|
}
|
|
357
|
-
/**
|
|
358
|
-
|
|
422
|
+
/**
|
|
423
|
+
* A scope "any" STOP for a session the launcher did not start under that name: its registered
|
|
424
|
+
* pane, checked. `started` names the tmux sessions the launcher did start, so a session that joined
|
|
425
|
+
* under another name than its launch (B4, 2026-10-24) is still recognised as ours; a launch record
|
|
426
|
+
* left behind by the close is swept on the next poll (`forgetEndedLaunches`).
|
|
427
|
+
*/
|
|
428
|
+
async function stopRegisteredSession(launch, deps, started) {
|
|
359
429
|
if (!deps.registeredSession || !deps.stopRegisteredDeps)
|
|
360
430
|
return refused("NOT_LAUNCHED");
|
|
361
431
|
const name = launch.sessionName;
|
|
362
|
-
const
|
|
432
|
+
const launched = new Set(started.filter((s) => s.multiplexer === "tmux").map((s) => (0, argv_1.multiplexerSessionName)(s.name)));
|
|
433
|
+
const outcome = await (0, stop_registered_1.stopRegistered)(name, deps.registeredSession(name), deps.stopRegisteredDeps, launched);
|
|
363
434
|
switch (outcome) {
|
|
364
435
|
case "stopped":
|
|
365
436
|
deps.forgetSession?.(name);
|
|
@@ -181,7 +181,7 @@ function idOf(raw) {
|
|
|
181
181
|
function describe(err) {
|
|
182
182
|
return err instanceof Error ? err.message : String(err);
|
|
183
183
|
}
|
|
184
|
-
function realLoopDeps(auth, log, signal, { forgetSession, registeredSession, registeredSessions, acp }) {
|
|
184
|
+
function realLoopDeps(auth, log, signal, { forgetSession, registeredSession, registeredSessions, acp, codex }) {
|
|
185
185
|
const binaryEnv = () => (0, runtime_binary_1.currentBinaryEnv)();
|
|
186
186
|
const handleDeps = () => ({
|
|
187
187
|
loadPolicy: () => (0, policy_1.loadLaunchPolicy)(),
|
|
@@ -211,6 +211,7 @@ function realLoopDeps(auth, log, signal, { forgetSession, registeredSession, reg
|
|
|
211
211
|
stopRegisteredDeps: (0, stop_registered_1.realStopRegisteredDeps)(multiplexer_1.realRun, log),
|
|
212
212
|
readCodexConfig: () => (0, codex_launch_1.readCodexConfig)(),
|
|
213
213
|
acp,
|
|
214
|
+
codex,
|
|
214
215
|
});
|
|
215
216
|
return {
|
|
216
217
|
request: exports.deviceRequest,
|
|
@@ -226,6 +227,9 @@ function realLoopDeps(auth, log, signal, { forgetSession, registeredSession, reg
|
|
|
226
227
|
capabilities: () => (0, capabilities_1.buildCapabilities)((0, policy_1.loadLaunchPolicy)(), {
|
|
227
228
|
resolveBinary: (name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, binaryEnv()),
|
|
228
229
|
multiplexer: (0, multiplexer_1.detectMultiplexer)(multiplexer_1.realRun),
|
|
230
|
+
// Never silent (2026-10-24): a folder that cannot start is a line in the relay's log, at
|
|
231
|
+
// every capabilities refresh, until it can.
|
|
232
|
+
onUnavailable: (spot, detail) => log(`launcher: spot "${spot.label}" left out: ${spot.runtime} ${spot.reason === "RUNTIME_BROKEN" ? "does not start" : "not found"} for the relay${detail ? ` (${detail})` : ""} — run \`baychat doctor\``),
|
|
229
233
|
}),
|
|
230
234
|
handle: (launch, io) => (0, handle_1.handleLaunch)(launch, handleDeps(), io),
|
|
231
235
|
sleep: sleepUnlessAborted,
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.elapsedSeconds = elapsedSeconds;
|
|
36
37
|
exports.ownSessionName = ownSessionName;
|
|
37
38
|
exports.stopRegistered = stopRegistered;
|
|
38
39
|
exports.realStopRegisteredDeps = realStopRegisteredDeps;
|
|
@@ -41,6 +42,79 @@ const fs = __importStar(require("fs"));
|
|
|
41
42
|
const terminal_pane_1 = require("../terminal-pane");
|
|
42
43
|
const argv_1 = require("./argv");
|
|
43
44
|
const multiplexer_1 = require("./multiplexer");
|
|
45
|
+
/** `ps -o etime=` has whole seconds; the agent always starts before it registers, so this is slack, not a window. */
|
|
46
|
+
const START_SLACK_MS = 2_000;
|
|
47
|
+
/** `ps -o etime=`: `[[dd-]hh:]mm:ss`, the same on macOS and Linux and in every locale (`lstart`
|
|
48
|
+
* is not: "Sat 24 Oct" here, "Sa 24 Okt" on a German system). Seconds, or NaN. */
|
|
49
|
+
function elapsedSeconds(etime) {
|
|
50
|
+
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime.trim());
|
|
51
|
+
if (!m)
|
|
52
|
+
return NaN;
|
|
53
|
+
const [, days = "0", hours = "0", minutes, seconds] = m;
|
|
54
|
+
return ((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60 + Number(seconds);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Is `pid` the process the session registered, and not a later one that reuses its number? Its
|
|
58
|
+
* start (now minus `ps -o etime=`) must not be after the registration.
|
|
59
|
+
*/
|
|
60
|
+
function startedBeforeRegistration(pid, registeredAt, deps) {
|
|
61
|
+
const registered = registeredAt ? Date.parse(registeredAt) : NaN;
|
|
62
|
+
if (Number.isNaN(registered))
|
|
63
|
+
return false;
|
|
64
|
+
let elapsed;
|
|
65
|
+
try {
|
|
66
|
+
elapsed = elapsedSeconds(deps.check("ps", ["-o", "etime=", "-p", String(pid)]));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return !Number.isNaN(elapsed) && deps.now() - elapsed * 1000 <= registered + START_SLACK_MS;
|
|
72
|
+
}
|
|
73
|
+
/** How long a SIGTERMed agent gets to exit before End says it could not (Claude Code quits in < 1 s). */
|
|
74
|
+
const AGENT_EXIT_WAIT_MS = 5_000;
|
|
75
|
+
const AGENT_EXIT_POLL_MS = 100;
|
|
76
|
+
/**
|
|
77
|
+
* Ask the session's own agent process to quit (SIGTERM, never SIGKILL: it saves its state), and
|
|
78
|
+
* wait for it to be gone. The caller has already proven `pid` is that session's runtime.
|
|
79
|
+
*/
|
|
80
|
+
async function endAgent(name, target, deps) {
|
|
81
|
+
const pid = target.ownerPid;
|
|
82
|
+
if (deps.platform === "win32") {
|
|
83
|
+
deps.log(`stop "${name}": ending its process is not done on Windows (it would be killed, not asked to quit); left running`);
|
|
84
|
+
return "not-ours";
|
|
85
|
+
}
|
|
86
|
+
if (!startedBeforeRegistration(pid, target.registeredAt, deps)) {
|
|
87
|
+
deps.log(`stop "${name}": process ${pid} cannot be shown to be the one that registered (started later, or no registration time); left running`);
|
|
88
|
+
return "not-ours";
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
deps.signal(pid);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
deps.log(`stop "${name}": could not signal its process ${pid} (${err instanceof Error ? err.message : String(err)})`);
|
|
95
|
+
return "failed";
|
|
96
|
+
}
|
|
97
|
+
for (let waited = 0; deps.alive(pid); waited += AGENT_EXIT_POLL_MS) {
|
|
98
|
+
if (waited >= AGENT_EXIT_WAIT_MS) {
|
|
99
|
+
deps.log(`stop "${name}": its process ${pid} did not quit after SIGTERM; left running`);
|
|
100
|
+
return "failed";
|
|
101
|
+
}
|
|
102
|
+
await deps.sleep(AGENT_EXIT_POLL_MS);
|
|
103
|
+
}
|
|
104
|
+
deps.log(`stop "${name}": ended its agent process ${pid}; the terminal stays open`);
|
|
105
|
+
return "stopped";
|
|
106
|
+
}
|
|
107
|
+
/** No terminal BayChat may close (no pane we can name): end the registered agent if it is still that runtime. */
|
|
108
|
+
async function endAgentWithoutPane(name, target, deps) {
|
|
109
|
+
if (target.ownerPid === undefined)
|
|
110
|
+
return "not-ours";
|
|
111
|
+
const running = (0, terminal_pane_1.runtimeStillRunning)(target.ownerPid, target.runtime, deps.check);
|
|
112
|
+
if (!running.ok) {
|
|
113
|
+
deps.log(`stop "${name}": ${running.reason} Left alone.`);
|
|
114
|
+
return "not-ours";
|
|
115
|
+
}
|
|
116
|
+
return endAgent(name, target, deps);
|
|
117
|
+
}
|
|
44
118
|
/**
|
|
45
119
|
* The tmux session names our CLI has ever given, for session `name` (git history of
|
|
46
120
|
* multiplexer-launch.ts and launcher/argv.ts):
|
|
@@ -71,6 +145,22 @@ function lookPane(handle, run) {
|
|
|
71
145
|
const [paneId = "", session = "", panes = "", windows = "", pid = "", tty = ""] = res.stdout.trim().split("\t");
|
|
72
146
|
return { found: { paneId, session, windowPanes: Number(panes), sessionWindows: Number(windows), serverPid: Number(pid), tty } };
|
|
73
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Is the pane gone after a kill? Asked as "is its id in the server's pane list", never through
|
|
150
|
+
* `display-message -t <pane>`: measured 2026-10-24 on real tmux, that answers status 0 with an
|
|
151
|
+
* empty line for a killed pane while other sessions remain, and "no server running" once the
|
|
152
|
+
* killed session was the server's last (the server exits) — both read as "still there", so a
|
|
153
|
+
* terminal that DID close was reported STOP_FAILED. The server answered a moment ago, so "no
|
|
154
|
+
* server" now means it exited with its last session.
|
|
155
|
+
*/
|
|
156
|
+
function paneGone(handle, run) {
|
|
157
|
+
const res = run("tmux", ["-S", handle.socket, "list-panes", "-a", "-F", "#{pane_id}"]);
|
|
158
|
+
if (res.error)
|
|
159
|
+
return false;
|
|
160
|
+
if (res.status !== 0)
|
|
161
|
+
return /no server running|error connecting/i.test(res.stderr);
|
|
162
|
+
return !res.stdout.split("\n").some((id) => id.trim() === handle.pane);
|
|
163
|
+
}
|
|
74
164
|
/** `lookPane`, after making the server's socket again when macOS's tmp cleanup deleted it
|
|
75
165
|
* (multiplexer.ts `healTmuxSocket`): without that, a live pane would read as unreachable. */
|
|
76
166
|
async function lookPaneHealing(handle, deps) {
|
|
@@ -100,13 +190,20 @@ function aliveOn(pid, tty, check) {
|
|
|
100
190
|
return false;
|
|
101
191
|
}
|
|
102
192
|
}
|
|
103
|
-
|
|
104
|
-
|
|
193
|
+
/**
|
|
194
|
+
* @param launched the tmux sessions the relay's launcher itself started (`bc-<launch name>`, from
|
|
195
|
+
* started.json): a registered pane in one of them is ours whatever name the session joined under.
|
|
196
|
+
*/
|
|
197
|
+
async function stopRegistered(name, target, deps, launched = new Set()) {
|
|
198
|
+
if (!target)
|
|
105
199
|
return "not-registered";
|
|
200
|
+
if (!target.pane)
|
|
201
|
+
return target.ownerPid === undefined ? "not-registered" : endAgentWithoutPane(name, target, deps);
|
|
106
202
|
const handle = target.pane;
|
|
107
|
-
// screen and zellij cannot name the session a window is in, so check 2 is impossible:
|
|
203
|
+
// screen and zellij cannot name the session a window is in, so check 2 is impossible: the
|
|
204
|
+
// terminal is never closed, only the agent itself ended.
|
|
108
205
|
if (handle.kind !== "tmux")
|
|
109
|
-
return
|
|
206
|
+
return endAgentWithoutPane(name, target, deps);
|
|
110
207
|
// Without the server's pid a pane id proves nothing (it may be a reused %N): refused.
|
|
111
208
|
if (!handle.serverPid)
|
|
112
209
|
return "not-ours";
|
|
@@ -122,10 +219,7 @@ async function stopRegistered(name, target, deps) {
|
|
|
122
219
|
deps.log(`stop "${name}": pane ${handle.pane} now belongs to another tmux server; left open`);
|
|
123
220
|
return "not-ours";
|
|
124
221
|
}
|
|
125
|
-
|
|
126
|
-
deps.log(`stop "${name}": pane ${handle.pane} is in tmux session "${pane.session}", which BayChat did not make; left open`);
|
|
127
|
-
return "not-ours";
|
|
128
|
-
}
|
|
222
|
+
const ours = ownSessionName(pane.session, name, handle.session) || launched.has(pane.session);
|
|
129
223
|
const running = (0, terminal_pane_1.verifyPane)(handle, target.runtime, deps.check, target.ownerPid);
|
|
130
224
|
if (!running.ok) {
|
|
131
225
|
deps.log(`stop "${name}": ${running.reason} Left open.`);
|
|
@@ -135,10 +229,18 @@ async function stopRegistered(name, target, deps) {
|
|
|
135
229
|
deps.log(`stop "${name}": its process ${target.ownerPid} is not on pane ${handle.pane} any more; left open`);
|
|
136
230
|
return "not-ours";
|
|
137
231
|
}
|
|
232
|
+
if (!ours) {
|
|
233
|
+
// The person's own tmux session: never closed. Its agent, proven on that pane, is ended.
|
|
234
|
+
if (target.ownerPid === undefined) {
|
|
235
|
+
deps.log(`stop "${name}": pane ${handle.pane} is in tmux session "${pane.session}", which BayChat did not make, and the session recorded no process to end; left open`);
|
|
236
|
+
return "not-ours";
|
|
237
|
+
}
|
|
238
|
+
return endAgent(name, target, deps);
|
|
239
|
+
}
|
|
138
240
|
const whole = pane.windowPanes === 1 && pane.sessionWindows === 1;
|
|
139
241
|
deps.run("tmux", whole ? ["-S", handle.socket, "kill-session", "-t", `=${pane.session}`] : ["-S", handle.socket, "kill-pane", "-t", handle.pane]);
|
|
140
242
|
// The kill's own answer is not the proof (handle.ts `stopRecorded`): the pane must be GONE.
|
|
141
|
-
if (!(
|
|
243
|
+
if (!paneGone(handle, deps.run))
|
|
142
244
|
return "failed";
|
|
143
245
|
deps.log(`stop "${name}": closed ${whole ? `tmux session "${pane.session}"` : `pane ${handle.pane} of "${pane.session}"`}`);
|
|
144
246
|
return "stopped";
|
|
@@ -151,5 +253,18 @@ function realStopRegisteredDeps(run, log) {
|
|
|
151
253
|
socketExists: (socket) => fs.existsSync(socket),
|
|
152
254
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
153
255
|
log,
|
|
256
|
+
platform: process.platform,
|
|
257
|
+
now: () => Date.now(),
|
|
258
|
+
signal: (pid) => process.kill(pid, "SIGTERM"),
|
|
259
|
+
alive: (pid) => {
|
|
260
|
+
try {
|
|
261
|
+
process.kill(pid, 0);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
// EPERM: it exists, it is just not ours to signal.
|
|
266
|
+
return err.code === "EPERM";
|
|
267
|
+
}
|
|
268
|
+
},
|
|
154
269
|
};
|
|
155
270
|
}
|
package/dist/relay/registry.js
CHANGED
|
@@ -56,6 +56,12 @@ function registryPath() {
|
|
|
56
56
|
class SessionRegistry {
|
|
57
57
|
filePath;
|
|
58
58
|
sessions = new Map();
|
|
59
|
+
/**
|
|
60
|
+
* Told whenever what was known about a session stops being true — it is removed, replaced,
|
|
61
|
+
* pruned, or its conversation is forgotten (`clearResume`, a relay-run Codex losing its thread).
|
|
62
|
+
* The daemon forgets that session's "Allow for this session" answers here (codex-allowances.ts).
|
|
63
|
+
*/
|
|
64
|
+
onForget;
|
|
59
65
|
constructor(filePath = registryPath()) {
|
|
60
66
|
this.filePath = filePath;
|
|
61
67
|
}
|
|
@@ -118,6 +124,7 @@ class SessionRegistry {
|
|
|
118
124
|
replace(target) {
|
|
119
125
|
const fresh = { ...stripUndefined(target), name: target.name, runtime: target.runtime, registeredAt: new Date().toISOString() };
|
|
120
126
|
this.sessions.set(target.name, fresh);
|
|
127
|
+
this.onForget?.(target.name);
|
|
121
128
|
this.save();
|
|
122
129
|
return fresh;
|
|
123
130
|
}
|
|
@@ -144,6 +151,7 @@ class SessionRegistry {
|
|
|
144
151
|
const known = this.sessions.delete(name);
|
|
145
152
|
if (known)
|
|
146
153
|
this.save();
|
|
154
|
+
this.onForget?.(name);
|
|
147
155
|
return known;
|
|
148
156
|
}
|
|
149
157
|
/**
|
|
@@ -167,6 +175,7 @@ class SessionRegistry {
|
|
|
167
175
|
delete existing.resumeSource;
|
|
168
176
|
delete existing.resumeEvidence;
|
|
169
177
|
this.save();
|
|
178
|
+
this.onForget?.(name);
|
|
170
179
|
}
|
|
171
180
|
all() {
|
|
172
181
|
return [...this.sessions.values()];
|
|
@@ -209,6 +218,7 @@ class SessionRegistry {
|
|
|
209
218
|
if (!live.has(name) && !target.attached && !target.relayOwned) {
|
|
210
219
|
this.sessions.delete(name);
|
|
211
220
|
dropped.push(name);
|
|
221
|
+
this.onForget?.(name);
|
|
212
222
|
}
|
|
213
223
|
}
|
|
214
224
|
if (dropped.length > 0)
|
|
@@ -226,6 +236,26 @@ class SessionRegistry {
|
|
|
226
236
|
t.acp.sessionId = sessionId;
|
|
227
237
|
this.save();
|
|
228
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Record (or, with `undefined`, forget) a relay-run Codex session's thread. A different thread
|
|
241
|
+
* is a new conversation, so it is not primed until a turn completes on it.
|
|
242
|
+
*/
|
|
243
|
+
setCodexThread(name, threadId, primed = false) {
|
|
244
|
+
const t = this.sessions.get(name);
|
|
245
|
+
if (!t?.codex)
|
|
246
|
+
return;
|
|
247
|
+
if (threadId === undefined) {
|
|
248
|
+
delete t.codex.threadId;
|
|
249
|
+
this.onForget?.(name);
|
|
250
|
+
}
|
|
251
|
+
else
|
|
252
|
+
t.codex.threadId = threadId;
|
|
253
|
+
if (primed)
|
|
254
|
+
t.codex.primed = true;
|
|
255
|
+
else
|
|
256
|
+
delete t.codex.primed;
|
|
257
|
+
this.save();
|
|
258
|
+
}
|
|
229
259
|
setAcpMode(name, mode) {
|
|
230
260
|
const t = this.sessions.get(name);
|
|
231
261
|
if (!t?.acp)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// A terminal session's plan limits and running cost, sent to BayChat (TERMINAL_CREDITS.md):
|
|
3
|
+
// `POST /api/device-api/session-limits`. Only the session's own person ever sees the percentages;
|
|
4
|
+
// the cost is the runtime's own estimate (for a Pro/Max plan the subscription is a fixed price).
|
|
5
|
+
//
|
|
6
|
+
// Claude Code from its status-line JSON (claude-context.ts notes it, claude-context-flush.ts
|
|
7
|
+
// sends it): `rate_limits.five_hour` / `seven_day` {used_percentage, resets_at}
|
|
8
|
+
// and, where it is there, `cost.total_cost_usd`.
|
|
9
|
+
// Codex from `codex app-server` (`readCodexRateLimits`, codex-app-server.ts) every
|
|
10
|
+
// 10 minutes, for each Codex session this relay hosts — `CodexLimitsPoller`.
|
|
11
|
+
//
|
|
12
|
+
// Light on purpose: the status-line path imports this, and it must not pull in the relay.
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.CodexLimitsPoller = exports.CODEX_LIMITS_POLL_MS = exports.LIMITS_MIN_INTERVAL_MS = exports.LIMITS_REFRESH_MS = void 0;
|
|
15
|
+
exports.claudeLimitsFromStatus = claudeLimitsFromStatus;
|
|
16
|
+
exports.codexLimitsReport = codexLimitsReport;
|
|
17
|
+
exports.limitsKey = limitsKey;
|
|
18
|
+
exports.limitsDue = limitsDue;
|
|
19
|
+
exports.postSessionLimits = postSessionLimits;
|
|
20
|
+
const api_1 = require("../api");
|
|
21
|
+
/** A report at least this often while the numbers stand still — the screen's "updated" time. */
|
|
22
|
+
exports.LIMITS_REFRESH_MS = 10 * 60_000;
|
|
23
|
+
/** …and no more often than this when they move. */
|
|
24
|
+
exports.LIMITS_MIN_INTERVAL_MS = 60_000;
|
|
25
|
+
exports.CODEX_LIMITS_POLL_MS = 10 * 60_000;
|
|
26
|
+
const CLAUDE_WINDOWS = [
|
|
27
|
+
["five_hour", 5 * 60],
|
|
28
|
+
["seven_day", 7 * 24 * 60],
|
|
29
|
+
];
|
|
30
|
+
const pct = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.min(100, Math.round(v * 10) / 10) : undefined;
|
|
31
|
+
const isoFromEpochSeconds = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? new Date(v * 1000).toISOString() : undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Claude Code's status-line JSON → a report, or null when it carries neither a window nor a cost
|
|
34
|
+
* (an API-key session before its first answer, or an older Claude Code).
|
|
35
|
+
*/
|
|
36
|
+
function claudeLimitsFromStatus(status) {
|
|
37
|
+
const s = (status ?? {});
|
|
38
|
+
const limits = (s.rate_limits ?? {});
|
|
39
|
+
const windows = [];
|
|
40
|
+
for (const [kind, minutes] of CLAUDE_WINDOWS) {
|
|
41
|
+
const w = limits[kind];
|
|
42
|
+
const usedPercent = pct(w?.used_percentage);
|
|
43
|
+
if (usedPercent === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
const resetsAt = isoFromEpochSeconds(w?.resets_at);
|
|
46
|
+
windows.push({ kind, usedPercent, windowMinutes: minutes, ...(resetsAt ? { resetsAt } : {}) });
|
|
47
|
+
}
|
|
48
|
+
const cost = (s.cost ?? {});
|
|
49
|
+
const total = cost.total_cost_usd;
|
|
50
|
+
const totalCostUsd = typeof total === "number" && Number.isFinite(total) && total >= 0 ? Math.round(total * 1e6) / 1e6 : undefined;
|
|
51
|
+
const plan = typeof s.subscription_type === "string" && s.subscription_type ? s.subscription_type.slice(0, 40) : undefined;
|
|
52
|
+
if (windows.length === 0 && totalCostUsd === undefined)
|
|
53
|
+
return null;
|
|
54
|
+
return { runtime: "claude-code", ...(plan ? { plan } : {}), windows, ...(totalCostUsd !== undefined ? { totalCostUsd } : {}) };
|
|
55
|
+
}
|
|
56
|
+
/** Codex's account limits → a report, or null when it has no window to show. */
|
|
57
|
+
function codexLimitsReport(limits) {
|
|
58
|
+
if (!limits || limits.windows.length === 0)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
runtime: "codex",
|
|
62
|
+
...(limits.plan ? { plan: limits.plan.slice(0, 40) } : {}),
|
|
63
|
+
windows: limits.windows.map((w) => ({
|
|
64
|
+
kind: w.kind,
|
|
65
|
+
usedPercent: Math.min(100, Math.max(0, w.usedPercent)),
|
|
66
|
+
...(w.windowMinutes ? { windowMinutes: w.windowMinutes } : {}),
|
|
67
|
+
...(w.resetsAt ? { resetsAt: w.resetsAt } : {}),
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The part of a report that, when it changes, is news: whole percentages and cents. */
|
|
72
|
+
function limitsKey(report) {
|
|
73
|
+
const windows = report.windows.map((w) => `${w.kind}:${Math.floor(w.usedPercent)}:${w.resetsAt ?? ""}`).join(",");
|
|
74
|
+
const cents = report.totalCostUsd === undefined ? "" : Math.floor(report.totalCostUsd * 100);
|
|
75
|
+
return `${report.plan ?? ""}|${windows}|${cents}`;
|
|
76
|
+
}
|
|
77
|
+
function limitsDue(prev, report, now) {
|
|
78
|
+
if (!prev)
|
|
79
|
+
return true;
|
|
80
|
+
if (now - prev.at >= exports.LIMITS_REFRESH_MS)
|
|
81
|
+
return true;
|
|
82
|
+
return now - prev.at >= exports.LIMITS_MIN_INTERVAL_MS && prev.key !== limitsKey(report);
|
|
83
|
+
}
|
|
84
|
+
/** Send one report for a named session, with the device credential. Never throws. */
|
|
85
|
+
async function postSessionLimits(auth, session, report, request) {
|
|
86
|
+
try {
|
|
87
|
+
await request(auth, "POST", "/api/device-api/session-limits", { session, ...report });
|
|
88
|
+
return { kind: "sent" };
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err instanceof api_1.ApiError) {
|
|
92
|
+
// Express's own 404 carries no code: a server from before this route.
|
|
93
|
+
if (err.status === 404 && !err.code)
|
|
94
|
+
return { kind: "not-deployed" };
|
|
95
|
+
if (err.status >= 400 && err.status < 500 && err.status !== 429) {
|
|
96
|
+
return { kind: "refused", status: err.status, code: err.code, message: err.message };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { kind: "failed", message: err instanceof Error ? err.message : String(err) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Every 10 minutes, while this relay hosts a Codex session: read the account's limits once
|
|
104
|
+
* (they are the account's, so one read serves every session) and report them for each session.
|
|
105
|
+
* A server without the route is asked again an hour later, not every round.
|
|
106
|
+
*/
|
|
107
|
+
class CodexLimitsPoller {
|
|
108
|
+
deps;
|
|
109
|
+
timer;
|
|
110
|
+
running = false;
|
|
111
|
+
notDeployedUntil = 0;
|
|
112
|
+
constructor(deps) {
|
|
113
|
+
this.deps = deps;
|
|
114
|
+
}
|
|
115
|
+
start() {
|
|
116
|
+
if (this.timer)
|
|
117
|
+
return;
|
|
118
|
+
const every = this.deps.setInterval ??
|
|
119
|
+
((fn, ms) => {
|
|
120
|
+
const t = setInterval(fn, ms);
|
|
121
|
+
t.unref?.();
|
|
122
|
+
return { cancel: () => clearInterval(t) };
|
|
123
|
+
});
|
|
124
|
+
this.timer = every(() => void this.poll(), exports.CODEX_LIMITS_POLL_MS);
|
|
125
|
+
}
|
|
126
|
+
stop() {
|
|
127
|
+
this.timer?.cancel();
|
|
128
|
+
this.timer = undefined;
|
|
129
|
+
}
|
|
130
|
+
/** One round; resolves when every report was attempted. Public for tests and a first run. */
|
|
131
|
+
async poll(now = Date.now()) {
|
|
132
|
+
if (this.running || now < this.notDeployedUntil)
|
|
133
|
+
return;
|
|
134
|
+
// In this order: no Codex session, no binary lookup (a machine without Codex logs nothing).
|
|
135
|
+
const sessions = this.deps.sessions();
|
|
136
|
+
if (sessions.length === 0)
|
|
137
|
+
return;
|
|
138
|
+
const auth = this.deps.auth();
|
|
139
|
+
const binary = auth ? this.deps.binary() : undefined;
|
|
140
|
+
if (!auth || !binary)
|
|
141
|
+
return;
|
|
142
|
+
this.running = true;
|
|
143
|
+
try {
|
|
144
|
+
const report = codexLimitsReport(await this.deps.read(binary));
|
|
145
|
+
if (!report)
|
|
146
|
+
return;
|
|
147
|
+
for (const session of sessions) {
|
|
148
|
+
const outcome = await postSessionLimits(auth, session, report, this.deps.request);
|
|
149
|
+
if (outcome.kind === "not-deployed") {
|
|
150
|
+
this.notDeployedUntil = now + 60 * 60_000;
|
|
151
|
+
this.deps.log("this BayChat server does not take plan limits yet — asking again in an hour");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (outcome.kind === "refused" || outcome.kind === "failed") {
|
|
155
|
+
this.deps.log(`plan limits for ${session} not sent: ${outcome.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
this.deps.log(`plan limits: ${err instanceof Error ? err.message : String(err)}`);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
this.running = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
exports.CodexLimitsPoller = CodexLimitsPoller;
|