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.
- package/README.md +146 -1
- package/dist/args.js +57 -0
- package/dist/client-paths.js +69 -0
- package/dist/commands.js +108 -0
- package/dist/connect.js +46 -14
- package/dist/credential-refresh.js +97 -0
- package/dist/doctor-command.js +154 -0
- package/dist/doctor.js +502 -0
- package/dist/help-topics.js +197 -0
- package/dist/index.js +69 -27
- package/dist/relay/adapters.js +82 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +177 -31
- package/dist/relay/daemon.js +259 -3
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +319 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +98 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +269 -0
- package/dist/runtimes.js +122 -68
- package/package.json +2 -2
|
@@ -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
|
+
}
|