baychat 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ // `baychat doctor` — the I/O half of the diagnosis.
3
+ //
4
+ // `doctor.ts` decides what is wrong from a description of a machine. This file
5
+ // builds that description from the real one and prints the result. The split is
6
+ // the reason the whole report is testable against fixtures, including machines
7
+ // nobody here owns — a Mac, a Windows box, a beta user's laptop with a broken
8
+ // Codex install.
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.currentDoctorEnv = currentDoctorEnv;
44
+ exports.cmdDoctor = cmdDoctor;
45
+ const child_process_1 = require("child_process");
46
+ const fs = __importStar(require("fs"));
47
+ const os = __importStar(require("os"));
48
+ const config_1 = require("./config");
49
+ const doctor_1 = require("./doctor");
50
+ const commands_1 = require("./relay/commands");
51
+ const credential_refresh_1 = require("./credential-refresh");
52
+ const client_paths_1 = require("./client-paths");
53
+ const runtime_binary_1 = require("./runtime-binary");
54
+ /** How long `claude mcp list` may take before we treat it as unanswerable. */
55
+ const MCP_LIST_TIMEOUT_MS = 15_000;
56
+ /**
57
+ * An environment variable naming a runtime's executable explicitly, for the
58
+ * machine where resolution genuinely cannot work it out.
59
+ *
60
+ * Documented in the `binary` check's remedy, so a user who hits an unresolvable
61
+ * runtime is told the escape hatch at the moment they need it.
62
+ */
63
+ function overrideFor(command) {
64
+ return process.env[`BAYCHAT_${command.toUpperCase()}_BIN`];
65
+ }
66
+ /** Where Codex is, so a snap install's own config location is checked too. */
67
+ function codexPath() {
68
+ const resolved = (0, runtime_binary_1.resolveRuntimeBinary)("codex", (0, runtime_binary_1.currentBinaryEnv)(overrideFor("codex")));
69
+ return resolved.ok ? resolved.path : undefined;
70
+ }
71
+ async function currentDoctorEnv() {
72
+ return {
73
+ home: os.homedir(),
74
+ platform: process.platform,
75
+ now: Date.now(),
76
+ device: (0, config_1.loadDeviceCredentials)(),
77
+ relay: await (0, commands_1.tryRelayStatus)(),
78
+ readText(filePath) {
79
+ try {
80
+ return fs.readFileSync(filePath, "utf8");
81
+ }
82
+ catch {
83
+ // Missing, unreadable, or a directory — all mean "nothing to inspect".
84
+ // The checks turn that into a finding; there is nothing to log here.
85
+ return null;
86
+ }
87
+ },
88
+ staleConfigs(token) {
89
+ // Uses the same reader the refresh does, so "doctor says stale" and
90
+ // "login fixed it" can never disagree about which files count.
91
+ return (0, credential_refresh_1.staleClientConfigs)(token, {
92
+ readFile: (filePath) => {
93
+ try {
94
+ return fs.readFileSync(filePath, "utf8");
95
+ }
96
+ catch {
97
+ // Absent or unreadable: nothing to compare, so nothing to report.
98
+ return null;
99
+ }
100
+ },
101
+ writeFile: () => undefined,
102
+ copyFile: () => undefined,
103
+ mkdirp: () => undefined,
104
+ }, (0, client_paths_1.currentPathEnv)());
105
+ },
106
+ resolveBinary(command) {
107
+ return (0, runtime_binary_1.resolveRuntimeBinary)(command, (0, runtime_binary_1.currentBinaryEnv)(overrideFor(command)));
108
+ },
109
+ mcpList(runtime) {
110
+ // Ask the runtime itself. Its own answer is correct wherever its config
111
+ // turns out to live — which, for a snap install, is not where we wrote.
112
+ const resolved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(overrideFor(runtime)));
113
+ if (!resolved.ok)
114
+ return null;
115
+ // Same .cmd rule as the probe: an npm-installed CLI on Windows is a shim
116
+ // that cannot be spawned without a shell, and this call would otherwise
117
+ // throw EINVAL and be reported as "could not run `codex mcp list`".
118
+ const plan = (0, runtime_binary_1.spawnPlanFor)(resolved.path, process.platform);
119
+ try {
120
+ return (0, child_process_1.execFileSync)(plan.file, [...plan.prefixArgs, "mcp", "list"], {
121
+ encoding: "utf8",
122
+ timeout: MCP_LIST_TIMEOUT_MS,
123
+ stdio: ["ignore", "pipe", "pipe"],
124
+ });
125
+ }
126
+ catch (err) {
127
+ // A non-zero exit can still carry the listing on stdout, so it is worth
128
+ // reading before giving up. Only a genuinely empty result becomes
129
+ // "could not run", which the check reports as unknown rather than as
130
+ // "not registered".
131
+ const stdout = err.stdout;
132
+ return stdout && stdout.trim() !== "" ? stdout : null;
133
+ }
134
+ },
135
+ };
136
+ }
137
+ /**
138
+ * `baychat doctor [--json]`
139
+ *
140
+ * Exit codes match `relay status`: 0 clear, 1 something is broken, 2 there is
141
+ * mail nobody read.
142
+ */
143
+ async function cmdDoctor(argv) {
144
+ const report = (0, doctor_1.buildReport)(await currentDoctorEnv());
145
+ if (argv.includes("--json")) {
146
+ // The whole report as data, so a beta user can paste one blob into a
147
+ // support thread and it can be read without a round of questions.
148
+ console.log(JSON.stringify(report, null, 2));
149
+ return (0, doctor_1.exitCodeFor)(report);
150
+ }
151
+ for (const line of (0, doctor_1.renderReport)(report))
152
+ console.log(line);
153
+ return (0, doctor_1.exitCodeFor)(report);
154
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,502 @@
1
+ "use strict";
2
+ // One command that says which link in the chain is broken, and what to type.
3
+ //
4
+ // WHY THIS EXISTS. Connecting an agent has four independent parts — a device
5
+ // credential, a running relay, a runtime that has BayChat's MCP server AND its
6
+ // skill, and a session registered with the relay — and until now nothing
7
+ // reported on any of them together. On 2026-08-30 three of those were broken at
8
+ // once on a developer machine, each silently, and finding out took an hour of
9
+ // reading code. A beta user on a machine we will never touch has no such hour
10
+ // and no such code.
11
+ //
12
+ // So every check here answers two questions and no others: what did we observe,
13
+ // and what do I type. A check that cannot say the second is not worth printing.
14
+ //
15
+ // THREE RULES THIS FILE HOLDS.
16
+ //
17
+ // 1. NEVER PRINT A CREDENTIAL. The whole point of the output is that it gets
18
+ // pasted into a support thread. Names and expiry dates, never tokens.
19
+ // 2. SKIP IS NOT FAIL. Claude Desktop has no CLI binary; a stopped relay knows
20
+ // nothing about sessions. Reporting those as failures sends people hunting
21
+ // for things that do not exist, which is worse than staying quiet.
22
+ // 3. OBSERVE, DO NOT ACT. Nothing here writes, installs, or starts anything.
23
+ // A diagnostic that repairs things cannot be trusted to describe them.
24
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ var desc = Object.getOwnPropertyDescriptor(m, k);
27
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
28
+ desc = { enumerable: true, get: function() { return m[k]; } };
29
+ }
30
+ Object.defineProperty(o, k2, desc);
31
+ }) : (function(o, m, k, k2) {
32
+ if (k2 === undefined) k2 = k;
33
+ o[k2] = m[k];
34
+ }));
35
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
36
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
37
+ }) : function(o, v) {
38
+ o["default"] = v;
39
+ });
40
+ var __importStar = (this && this.__importStar) || (function () {
41
+ var ownKeys = function(o) {
42
+ ownKeys = Object.getOwnPropertyNames || function (o) {
43
+ var ar = [];
44
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
45
+ return ar;
46
+ };
47
+ return ownKeys(o);
48
+ };
49
+ return function (mod) {
50
+ if (mod && mod.__esModule) return mod;
51
+ var result = {};
52
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
53
+ __setModuleDefault(result, mod);
54
+ return result;
55
+ };
56
+ })();
57
+ Object.defineProperty(exports, "__esModule", { value: true });
58
+ exports.buildReport = buildReport;
59
+ exports.mailboxCheck = mailboxCheck;
60
+ exports.exitCodeFor = exitCodeFor;
61
+ exports.renderReport = renderReport;
62
+ const fs = __importStar(require("fs"));
63
+ const path = __importStar(require("path"));
64
+ const mailbox_1 = require("./relay/mailbox");
65
+ const parent_watch_1 = require("./relay/parent-watch");
66
+ const client_paths_1 = require("./client-paths");
67
+ const runtime_binary_1 = require("./runtime-binary");
68
+ const runtimes_1 = require("./runtimes");
69
+ /**
70
+ * The runtimes worth reporting on.
71
+ *
72
+ * Deliberately not every entry in `RUNTIME_SPECS`: Pi, Hermes and `generic`
73
+ * leave no detectable footprint on this machine, so every check for them would
74
+ * be a `skip` and the report would be mostly noise about software the user
75
+ * probably does not run.
76
+ */
77
+ const REPORTED = ["claude", "codex", "cursor", "desktop"];
78
+ /** Runtimes that ARE a local command, and so can have a binary at all. */
79
+ const HAS_BINARY = { claude: "claude", codex: "codex" };
80
+ /**
81
+ * The command that sets each runtime up.
82
+ *
83
+ * NOT `baychat connect <runtime>` for all of them: `connect` accepts only
84
+ * codex, cursor and desktop, so a remedy of "baychat connect claude" is a line
85
+ * telling the user to run something that errors. Claude Code is set up by
86
+ * `baychat login`, which registers its MCP server and installs its skill.
87
+ *
88
+ * A remedy that does not work is worse than no remedy, because the user spends
89
+ * their trust on it before finding out.
90
+ */
91
+ const SETUP_COMMAND = {
92
+ claude: "baychat login",
93
+ codex: "baychat connect codex",
94
+ cursor: "baychat connect cursor",
95
+ desktop: "baychat connect desktop",
96
+ };
97
+ function setupCommand(runtime) {
98
+ return SETUP_COMMAND[runtime] ?? `baychat connect ${runtime}`;
99
+ }
100
+ /** How close to expiry a device credential is worth warning about. */
101
+ const EXPIRY_WARNING_MS = 3 * 86_400_000;
102
+ function buildReport(env) {
103
+ return {
104
+ credentials: credentialsCheck(env),
105
+ relay: relayCheck(env),
106
+ mailbox: mailboxCheck(),
107
+ staleCredentials: staleCredentialCheck(env),
108
+ runtimes: REPORTED.map((runtime) => runtimeReport(runtime, env)),
109
+ pending: pendingCheck(env),
110
+ };
111
+ }
112
+ /**
113
+ * Is every mailbox registration one the relay can actually act on?
114
+ *
115
+ * This check exists because both sides of the mailbox transport fail silently.
116
+ * The agent blocks on its FIFO and sees a perfectly healthy wait; the daemon
117
+ * never learns the session exists. Nothing is logged, nothing errors, and the
118
+ * agent is simply unreachable — the shape of the 2026-08-30 evening, in a new
119
+ * place. Neither side can detect it, so this is the only place that can.
120
+ *
121
+ * Takes no `DoctorEnv`: the mailbox root is resolved from the environment the
122
+ * same way the agent and daemon resolve it, and reading it is the check.
123
+ *
124
+ * `skip` when no mailbox exists. Most machines will never use this rung, and a
125
+ * check that fails for everyone who does not need it is noise.
126
+ */
127
+ function mailboxCheck() {
128
+ const live = [];
129
+ const problems = [];
130
+ // Every candidate root: an agent may have fallen back past the one this
131
+ // process would pick, and a mailbox we never look in is invisible.
132
+ const dirs = (0, mailbox_1.mailboxRootCandidates)().flatMap((root) => {
133
+ try {
134
+ return fs
135
+ .readdirSync(root, { withFileTypes: true })
136
+ .filter((e) => e.isDirectory())
137
+ .map((e) => path.join(root, e.name));
138
+ }
139
+ catch {
140
+ return [];
141
+ }
142
+ });
143
+ for (const dir of dirs) {
144
+ try {
145
+ fs.accessSync(dir, fs.constants.R_OK | fs.constants.X_OK);
146
+ }
147
+ catch {
148
+ problems.push(`the relay cannot read ${dir} — that session armed somewhere this relay has no access to`);
149
+ continue;
150
+ }
151
+ const reg = (0, mailbox_1.readRegistration)(dir);
152
+ if (!reg) {
153
+ problems.push(`${dir} holds no readable registration`);
154
+ continue;
155
+ }
156
+ if (!(0, parent_watch_1.processIsAlive)(reg.pid)) {
157
+ problems.push(`${reg.session} registered a mailbox but process ${reg.pid} is no longer running`);
158
+ continue;
159
+ }
160
+ live.push(reg.session);
161
+ }
162
+ if (problems.length > 0)
163
+ return { name: "mailbox", status: "fail", detail: problems.join("; ") };
164
+ if (live.length === 0)
165
+ return { name: "mailbox", status: "skip", detail: "no session is using the mailbox transport" };
166
+ // NOT "reachable". A registration says the agent armed, not that it is still
167
+ // waiting — and that cannot be checked without ending the wait. Delivery is
168
+ // where the truth is established, honestly, as pending-or-delivered.
169
+ return { name: "mailbox", status: "pass", detail: `registered a mailbox FIFO: ${live.join(", ")}` };
170
+ }
171
+ /**
172
+ * The process exit code for a report.
173
+ *
174
+ * Follows the convention `relay status` already set, because a user who has
175
+ * learned one should not have to learn a second: 0 clear, 1 something is broken,
176
+ * 2 there is mail nobody read.
177
+ */
178
+ function exitCodeFor(report) {
179
+ const checks = allChecks(report);
180
+ if (checks.some((c) => c.status === "fail"))
181
+ return 1;
182
+ if (report.pending.status === "warn")
183
+ return 2;
184
+ return 0;
185
+ }
186
+ /** The whole report as lines, ready to print. */
187
+ function renderReport(report) {
188
+ const lines = [];
189
+ lines.push(...renderSummaryCheck(report.credentials));
190
+ lines.push(...renderSummaryCheck(report.staleCredentials));
191
+ lines.push(...renderSummaryCheck(report.relay));
192
+ lines.push(...renderSummaryCheck(report.mailbox));
193
+ for (const block of report.runtimes) {
194
+ const [first, ...rest] = block.checks;
195
+ lines.push(...renderGroupCheck(block.runtime, first));
196
+ // Continuation rows carry no label, so the runtime name reads as a heading
197
+ // for its group rather than repeating four times.
198
+ for (const check of rest)
199
+ lines.push(...renderGroupCheck("", check));
200
+ }
201
+ lines.push(...renderSummaryCheck(report.pending));
202
+ return lines;
203
+ }
204
+ // ─── Individual checks ──────────────────────────────────────────────────────
205
+ function credentialsCheck(env) {
206
+ if (!env.device) {
207
+ return {
208
+ name: "credentials",
209
+ status: "fail",
210
+ detail: "this machine has never been connected",
211
+ remedy: "baychat login",
212
+ };
213
+ }
214
+ const name = env.device.user.name;
215
+ const expiresAt = Date.parse(env.device.expiresAt);
216
+ // An unparseable expiry is treated as expired for the same reason
217
+ // `deviceStateFrom` does: calling it live sends the user into a flow that
218
+ // fails at the first authenticated call, blaming the server.
219
+ if (Number.isNaN(expiresAt) || expiresAt <= env.now) {
220
+ return {
221
+ name: "credentials",
222
+ status: "fail",
223
+ detail: `credential for "${name}" has expired`,
224
+ remedy: "baychat login",
225
+ };
226
+ }
227
+ const left = expiresAt - env.now;
228
+ if (left <= EXPIRY_WARNING_MS) {
229
+ return {
230
+ name: "credentials",
231
+ status: "warn",
232
+ detail: `paired as "${name}" — ${remaining(left)} left`,
233
+ remedy: "baychat login",
234
+ };
235
+ }
236
+ return { name: "credentials", status: "pass", detail: `paired as "${name}" — ${remaining(left)} left` };
237
+ }
238
+ /**
239
+ * Client configs still holding a PREVIOUS credential.
240
+ *
241
+ * The renewal fault, made visible. Every connected client keeps a copy of the
242
+ * device token, so a renewal that updates only `~/.baychat/credentials.json`
243
+ * leaves them authenticating with one that is about to expire — and the failure
244
+ * arrives hours later, in a different tool, with nothing tying it back to the
245
+ * login that caused it.
246
+ *
247
+ * Reported as a failure rather than a warning: it is not "will break later",
248
+ * it is already wrong, and it is silent until the old token lapses.
249
+ */
250
+ function staleCredentialCheck(env) {
251
+ if (!env.device)
252
+ return { name: "clients", status: "skip", detail: "not logged in" };
253
+ const stale = env.staleConfigs(env.device.token);
254
+ if (stale.length === 0) {
255
+ return { name: "clients", status: "pass", detail: "all connected clients hold the current credential" };
256
+ }
257
+ return {
258
+ name: "clients",
259
+ status: "fail",
260
+ detail: `${stale.length} config(s) still hold a previous credential: ${stale.map((p) => display(p, env)).join(", ")}`,
261
+ remedy: "baychat login",
262
+ };
263
+ }
264
+ /**
265
+ * How long is left, in the largest unit that is still honest.
266
+ *
267
+ * Flooring to days printed "0 day(s)" for a credential with hours to live —
268
+ * technically true, and read by everyone as a rendering bug rather than as the
269
+ * urgent warning it actually was.
270
+ */
271
+ function remaining(ms) {
272
+ const hours = Math.floor(ms / 3_600_000);
273
+ if (hours < 1)
274
+ return "under an hour";
275
+ if (hours < 48)
276
+ return `${hours} hour(s)`;
277
+ return `${Math.floor(hours / 24)} day(s)`;
278
+ }
279
+ function relayCheck(env) {
280
+ if (!env.relay) {
281
+ return {
282
+ name: "relay",
283
+ status: "fail",
284
+ detail: "not running — messages arriving now wake nothing",
285
+ remedy: "baychat relay start",
286
+ };
287
+ }
288
+ // The transport is optional across the process boundary: a CLI newer than the
289
+ // daemon it queries gets none, and naming one anyway would be a guess.
290
+ const transport = env.relay.transport
291
+ ? `, transport ${env.relay.transport}${env.relay.transportDetail ? ` (${env.relay.transportDetail})` : ""}`
292
+ : "";
293
+ return { name: "relay", status: "pass", detail: `running (pid ${env.relay.pid})${transport}` };
294
+ }
295
+ function pendingCheck(env) {
296
+ if (!env.relay) {
297
+ return { name: "pending", status: "skip", detail: "relay not running — nothing to report" };
298
+ }
299
+ const count = env.relay.pending.length;
300
+ if (count === 0)
301
+ return { name: "pending", status: "pass", detail: "none" };
302
+ return {
303
+ name: "pending",
304
+ status: "warn",
305
+ detail: `${count} message(s) reached this machine and nothing answered — latest: ${env.relay.pending[count - 1].reason}`,
306
+ remedy: "baychat relay status",
307
+ };
308
+ }
309
+ /**
310
+ * One runtime's block — or a single line saying it is simply not set up here.
311
+ *
312
+ * A machine that only runs Claude Code would otherwise show four permanent `✗`
313
+ * rows for Codex, four more for Cursor, and four for Desktop. That is not a
314
+ * diagnosis, it is a wall of red about software the user does not have, and its
315
+ * only effect is to teach them that red rows in this report do not mean
316
+ * anything. So a runtime with neither an MCP registration nor a skill is
317
+ * reported as UNCONFIGURED — with the command that would configure it — and is
318
+ * not counted as a failure.
319
+ *
320
+ * A runtime with one but not the other is a different animal entirely: that is a
321
+ * half-finished install, it is genuinely broken, and it stays a failure.
322
+ */
323
+ function runtimeReport(runtime, env) {
324
+ const spec = runtimes_1.RUNTIME_SPECS[runtime];
325
+ const mcp = mcpCheck(runtime, env);
326
+ const skill = skillCheck(runtime, env);
327
+ if (mcp.status === "fail" && skill.status !== "pass") {
328
+ return {
329
+ runtime,
330
+ label: spec.label,
331
+ checks: [
332
+ {
333
+ name: "setup",
334
+ status: "skip",
335
+ detail: "not configured on this machine",
336
+ remedy: setupCommand(runtime),
337
+ },
338
+ ],
339
+ };
340
+ }
341
+ return {
342
+ runtime,
343
+ label: spec.label,
344
+ checks: [mcp, skill, binaryCheck(runtime, env), sessionCheck(runtime, env)],
345
+ };
346
+ }
347
+ /**
348
+ * Is BayChat actually registered as an MCP server for this runtime?
349
+ *
350
+ * "The config file exists" is not the question — the observed failure was a
351
+ * `~/.codex/config.toml` full of plugins and trust levels with no `mcp_servers`
352
+ * block at all. So the check is whether the file names BayChat.
353
+ */
354
+ function mcpCheck(runtime, env) {
355
+ const remedy = setupCommand(runtime);
356
+ const command = HAS_BINARY[runtime];
357
+ // ASK THE RUNTIME, DO NOT READ A FILE WE GUESSED.
358
+ //
359
+ // Reading the config we believe a runtime uses gives a confident answer to
360
+ // the wrong question. On 2026-08-30 that produced a FALSE PASS: connect had
361
+ // written a correct block into ~/.codex/config.toml, this check read it back
362
+ // and reported "registered", and the Codex on that machine — a snap install,
363
+ // reading ~/snap/codex/current/config.toml — listed no MCP servers at all.
364
+ // A check that confirms our own write is not a check.
365
+ //
366
+ // `<runtime> mcp list` is the runtime's own answer about its own state, and
367
+ // it is correct wherever the config turns out to live.
368
+ if (command) {
369
+ const listed = env.mcpList(runtime);
370
+ if (listed === null) {
371
+ // Unknown, not absent. Reporting "not registered" because we could not
372
+ // run the command would send a user to reinstall something that is fine.
373
+ return { name: "mcp", status: "skip", detail: `could not run \`${command} mcp list\``, remedy };
374
+ }
375
+ return listed.includes("baychat")
376
+ ? { name: "mcp", status: "pass", detail: `${command} lists baychat` }
377
+ : { name: "mcp", status: "fail", detail: `\`${command} mcp list\` does not list baychat`, remedy };
378
+ }
379
+ // No CLI to ask — a GUI client. Every location this client might read is
380
+ // checked, because "the file we would write" and "the file it opens" are not
381
+ // reliably the same question.
382
+ const paths = (0, client_paths_1.configPathsFor)(runtime, { home: env.home, platform: env.platform });
383
+ if (paths.length === 0) {
384
+ return { name: "mcp", status: "skip", detail: `no known config location on ${env.platform}` };
385
+ }
386
+ for (const configPath of paths) {
387
+ const contents = env.readText(configPath);
388
+ if (contents?.includes("baychat")) {
389
+ return { name: "mcp", status: "pass", detail: display(configPath, env) };
390
+ }
391
+ }
392
+ return {
393
+ name: "mcp",
394
+ status: "fail",
395
+ detail: `no baychat entry in ${paths.map((p) => display(p, env)).join(" or ")}`,
396
+ remedy,
397
+ };
398
+ }
399
+ /**
400
+ * Is the runtime's BayChat skill on disk?
401
+ *
402
+ * Separate from the MCP check because they fail independently: a runtime with
403
+ * the MCP server and no skill has the tools and has never been told the rules of
404
+ * the room — which is exactly how a Codex session comes to "not understand
405
+ * BayChat" while appearing connected.
406
+ */
407
+ function skillCheck(runtime, env) {
408
+ const spec = runtimes_1.RUNTIME_SPECS[runtime];
409
+ if (!spec.command) {
410
+ return { name: "skill", status: "skip", detail: spec.fallback ?? "no command mechanism" };
411
+ }
412
+ const file = `${env.home}/${spec.command.dir}/${spec.command.file}`;
413
+ if (env.readText(file) === null) {
414
+ return {
415
+ name: "skill",
416
+ status: "fail",
417
+ detail: `${display(file, env)} missing`,
418
+ remedy: setupCommand(runtime),
419
+ };
420
+ }
421
+ return { name: "skill", status: "pass", detail: display(file, env) };
422
+ }
423
+ function binaryCheck(runtime, env) {
424
+ const command = HAS_BINARY[runtime];
425
+ if (!command) {
426
+ return { name: "binary", status: "skip", detail: `${runtimes_1.RUNTIME_SPECS[runtime].label} is not a command-line runtime` };
427
+ }
428
+ const resolved = env.resolveBinary(command);
429
+ if (!resolved.ok) {
430
+ return {
431
+ name: "binary",
432
+ status: "fail",
433
+ detail: (0, runtime_binary_1.summarizeResolutionFailure)(resolved),
434
+ remedy: `install ${command}, or set BAYCHAT_${command.toUpperCase()}_BIN to its path`,
435
+ };
436
+ }
437
+ return { name: "binary", status: "pass", detail: `${resolved.path} (${resolved.version})` };
438
+ }
439
+ /**
440
+ * Has any session of this runtime registered with the relay?
441
+ *
442
+ * Skipped rather than failed when the relay is down: session state lives in the
443
+ * daemon, so with no daemon there is no evidence either way, and "never
444
+ * attached" would be an invented finding.
445
+ */
446
+ function sessionCheck(runtime, env) {
447
+ if (!env.relay) {
448
+ return { name: "session", status: "skip", detail: "relay not running — cannot tell" };
449
+ }
450
+ const mine = env.relay.sessions.filter((s) => s.runtime === runtime);
451
+ if (mine.length === 0) {
452
+ // A warning, not a failure. The runtime IS set up — nobody has started a
453
+ // session in it yet, which is a normal state of a working machine at 9am and
454
+ // resolves itself the moment they do.
455
+ return {
456
+ name: "session",
457
+ status: "warn",
458
+ detail: "configured, but no session of this runtime has attached",
459
+ remedy: `in ${runtimes_1.RUNTIME_SPECS[runtime].label}, run: ${runtimes_1.RUNTIME_SPECS[runtime].invocation}`,
460
+ };
461
+ }
462
+ return { name: "session", status: "pass", detail: mine.map((s) => `"${s.name}"`).join(", ") };
463
+ }
464
+ // ─── Rendering ──────────────────────────────────────────────────────────────
465
+ const GLYPH = { pass: "✓", fail: "✗", warn: "!", skip: "–" };
466
+ const LABEL_WIDTH = 12;
467
+ const CHECK_WIDTH = 8;
468
+ /**
469
+ * A check inside a runtime's block: `codex ✗ mcp …`.
470
+ *
471
+ * The label is the runtime on the first row and blank on the rest, so the name
472
+ * reads as a heading for the group instead of repeating on every line.
473
+ */
474
+ function renderGroupCheck(label, check) {
475
+ return withRemedy(`${label.padEnd(LABEL_WIDTH)}${GLYPH[check.status]} ${check.name.padEnd(CHECK_WIDTH)}${check.detail}`, check);
476
+ }
477
+ /**
478
+ * A standalone check, whose name IS its heading: `credentials ✓ …`.
479
+ *
480
+ * Rendered separately rather than through `renderGroupCheck` because passing the
481
+ * name as both label and column printed it twice ("credentials ✓ credentials").
482
+ * The detail column stays aligned with the runtime blocks.
483
+ */
484
+ function renderSummaryCheck(check) {
485
+ return withRemedy(`${check.name.padEnd(LABEL_WIDTH)}${GLYPH[check.status]} ${" ".repeat(CHECK_WIDTH)}${check.detail}`, check);
486
+ }
487
+ /**
488
+ * The remedy sits directly under its finding. A block of fixes at the bottom
489
+ * makes the reader match them back up, and they mismatch them.
490
+ */
491
+ function withRemedy(line, check) {
492
+ if (!check.remedy)
493
+ return [line];
494
+ return [line, `${" ".repeat(LABEL_WIDTH + 2 + CHECK_WIDTH)}→ ${check.remedy}`];
495
+ }
496
+ function allChecks(report) {
497
+ return [report.credentials, report.staleCredentials, report.relay, report.mailbox, ...report.runtimes.flatMap((r) => r.checks), report.pending];
498
+ }
499
+ /** Shorten a path under the user's home, which is where nearly all of these are. */
500
+ function display(filePath, env) {
501
+ return filePath.startsWith(env.home) ? `~${filePath.slice(env.home.length)}` : filePath;
502
+ }